feat(memory-graph): add in-graph node search

The graph had no way to find a specific document or memory once the node
count grew past a screenful. You could pan, zoom, fit, and arrow-key
between neighbours, but locating "the Berlin memory" in a few hundred
nodes meant eyeballing the canvas.

Add a search box to the bottom-left control stack that finds nodes by
text and steps through the matches:

- searchNodes(nodes, query): a pure, case-insensitive substring matcher
  over document title/summary/type and memory text/content. Results are
  ranked (title/memory prefix, then substring, then supporting copy) with
  input order as a stable tiebreaker, so ordering never depends on where
  the force simulation happened to drop a node. Exported for advanced use.
- SearchControl: an input styled like the existing navigation controls,
  with a live "current/total" counter, prev/next steppers, and a clear
  button. Enter / Shift+Enter cycle matches, Escape clears.
- Wiring in MemoryGraph: the focused match is selected and centered
  (reusing the same selectAndCenter path as arrow-key navigation), and the
  documents behind all matches are highlighted (memory matches highlight
  their parent document) by merging into the existing highlightDocumentIds
  pipeline. Press "/" to focus the box, consistent with the existing
  single-key shortcuts.

The box is on by default; embedders can hide it with showSearch={false}
or relabel it with searchPlaceholder. Existing keyboard handlers already
ignore events originating from inputs, so typing a query never triggers
the fit/zoom/arrow shortcuts.

Tested: 14 new tests (searchNodes ranking/empty-query/null-field cases;
SearchControl typing, Enter/Shift+Enter/Escape, clear, counter, disabled
states). Full package suite green (202 tests), tsc --noEmit clean, biome
clean, vite build succeeds.
This commit is contained in:
abhay-codes07 2026-07-18 13:51:54 +05:30
parent 3487666481
commit 88c1568b46
No known key found for this signature in database
8 changed files with 564 additions and 2 deletions

View file

@ -51,6 +51,7 @@ function App() {
- **Interactive canvas visualization** - Pan, zoom, and drag nodes using Canvas 2D rendering
- **Document and memory nodes** - Documents as rectangles, memories as hexagons
- **Relationship visualization** - Edges show document similarity and memory version chains
- **Node search** - Find documents and memories by text, highlight matches, and step through them (press `/` to focus, `Enter` / `Shift+Enter` to cycle)
- **Space filtering** - Filter by workspace or view all memories
- **Two variants** - Full-featured console mode or embedded consumer mode
- **Pagination support** - Load more documents on demand
@ -66,6 +67,8 @@ function App() {
| `error` | `Error \| null` | Error to display |
| `loadMoreDocuments` | `() => Promise<void>` | Function to load more data |
| `highlightDocumentIds` | `string[]` | IDs of documents to highlight |
| `showSearch` | `boolean` | Show the in-graph node search box (default: `true`) |
| `searchPlaceholder` | `string` | Placeholder text for the search box |
## Documentation

View file

@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest"
import type { GraphNode } from "../types"
import { searchNodes } from "../utils/node-search"
function docNode(
id: string,
title: string | null,
summary: string | null = null,
type = "document",
): GraphNode {
return {
id,
type: "document",
x: 0,
y: 0,
size: 40,
borderColor: "#fff",
isHovered: false,
isDragging: false,
data: {
id,
title,
summary,
type,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
memories: [],
},
}
}
function memoryNode(id: string, memory: string, content = ""): GraphNode {
return {
id,
type: "memory",
x: 0,
y: 0,
size: 24,
borderColor: "#fff",
isHovered: false,
isDragging: false,
data: {
id,
memory,
content,
documentId: "doc-x",
isStatic: false,
isLatest: true,
isForgotten: false,
forgetAfter: null,
forgetReason: null,
version: 1,
parentMemoryId: null,
spaceId: "space-1",
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
},
}
}
describe("searchNodes", () => {
it("returns nothing for an empty or whitespace query", () => {
const nodes = [docNode("d1", "Alpha")]
expect(searchNodes(nodes, "")).toEqual([])
expect(searchNodes(nodes, " ")).toEqual([])
})
it("matches document titles case-insensitively", () => {
const nodes = [docNode("d1", "Quarterly Report"), docNode("d2", "Recipes")]
const result = searchNodes(nodes, "REPORT")
expect(result.map((n) => n.id)).toEqual(["d1"])
})
it("matches memory text and content", () => {
const nodes = [
memoryNode("m1", "User prefers dark mode"),
memoryNode("m2", "Lives in Berlin", "moved there in 2021"),
]
expect(searchNodes(nodes, "dark").map((n) => n.id)).toEqual(["m1"])
expect(searchNodes(nodes, "2021").map((n) => n.id)).toEqual(["m2"])
})
it("ranks primary-field matches above secondary-field matches", () => {
// d2's summary contains the term; d1's title contains it. Title wins.
const nodes = [
docNode("d2", "Unrelated", "a note about invoices"),
docNode("d1", "Invoices", "quarterly totals"),
]
expect(searchNodes(nodes, "invoice").map((n) => n.id)).toEqual(["d1", "d2"])
})
it("ranks a prefix match above a mid-string match", () => {
const nodes = [docNode("d1", "Redesign proposal"), docNode("d2", "Design")]
expect(searchNodes(nodes, "design").map((n) => n.id)).toEqual(["d2", "d1"])
})
it("keeps input order for equally-scored matches", () => {
const nodes = [
docNode("d1", "Design system"),
docNode("d2", "Design tokens"),
docNode("d3", "Design review"),
]
expect(searchNodes(nodes, "design").map((n) => n.id)).toEqual([
"d1",
"d2",
"d3",
])
})
it("tolerates null titles and missing content without matching them", () => {
const nodes = [docNode("d1", null, null), memoryNode("m1", "hello")]
expect(searchNodes(nodes, "hello").map((n) => n.id)).toEqual(["m1"])
expect(searchNodes(nodes, "null")).toEqual([])
})
})

View file

@ -0,0 +1,96 @@
/**
* Mounted-render verification for the in-graph search control.
*/
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"
afterEach(cleanup)
import { SearchControl } from "../components/search-control"
import { DEFAULT_COLORS } from "../constants"
function renderControl(
overrides: Partial<React.ComponentProps<typeof SearchControl>> = {},
) {
const props = {
query: "",
onQueryChange: vi.fn(),
matchCount: 0,
currentIndex: -1,
onNext: vi.fn(),
onPrev: vi.fn(),
onClear: vi.fn(),
colors: DEFAULT_COLORS,
...overrides,
}
return { props, ...render(<SearchControl {...props} />) }
}
describe("SearchControl mounted render", () => {
it("hides the match counter until there is a query", () => {
const { queryByText } = renderControl()
expect(queryByText("0/0")).toBeNull()
})
it("shows the 1-based position and total for the current match", () => {
const { getByText } = renderControl({
query: "design",
matchCount: 3,
currentIndex: 1,
})
getByText("2/3")
})
it("reports zero results distinctly", () => {
const { getByText } = renderControl({
query: "zzz",
matchCount: 0,
currentIndex: 0,
})
getByText("0/0")
})
it("forwards typing to onQueryChange", () => {
const { props, getByLabelText } = renderControl()
fireEvent.change(getByLabelText("Search graph nodes"), {
target: { value: "berlin" },
})
expect(props.onQueryChange).toHaveBeenCalledWith("berlin")
})
it("steps matches with Enter and Shift+Enter", () => {
const { props, getByLabelText } = renderControl({
query: "a",
matchCount: 2,
currentIndex: 0,
})
const input = getByLabelText("Search graph nodes")
fireEvent.keyDown(input, { key: "Enter" })
expect(props.onNext).toHaveBeenCalledTimes(1)
fireEvent.keyDown(input, { key: "Enter", shiftKey: true })
expect(props.onPrev).toHaveBeenCalledTimes(1)
})
it("clears on Escape and on the clear button", () => {
const { props, getByLabelText } = renderControl({
query: "a",
matchCount: 1,
currentIndex: 0,
})
fireEvent.keyDown(getByLabelText("Search graph nodes"), { key: "Escape" })
fireEvent.click(getByLabelText("Clear search"))
expect(props.onClear).toHaveBeenCalledTimes(2)
})
it("disables the step buttons when there are no matches", () => {
const { getByLabelText } = renderControl({ query: "zzz", matchCount: 0 })
expect((getByLabelText("Next match") as HTMLButtonElement).disabled).toBe(
true,
)
expect(
(getByLabelText("Previous match") as HTMLButtonElement).disabled,
).toBe(true)
})
})

View file

@ -14,11 +14,13 @@ import type {
MemoryGraphProps,
ResolvedMemoryGraphLabels,
} from "../types"
import { searchNodes } from "../utils/node-search"
import { GraphCanvas } from "./graph-canvas"
import { Legend } from "./legend"
import { LoadingIndicator } from "./loading-indicator"
import { NavigationControls } from "./navigation-controls"
import { NodeHoverPopover } from "./node-hover-popover"
import { SearchControl } from "./search-control"
export function MemoryGraph({
documents = [],
@ -41,6 +43,8 @@ export function MemoryGraph({
onOpenDocument,
labels: labelOverrides,
layering,
showSearch = true,
searchPlaceholder,
}: MemoryGraphProps) {
const resolvedLabels = useMemo<ResolvedMemoryGraphLabels>(
() => ({ ...DEFAULT_LABELS, ...labelOverrides }),
@ -69,6 +73,9 @@ export function MemoryGraph({
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = useState<string | null>(null)
const [zoomDisplay, setZoomDisplay] = useState(50)
const [searchQuery, setSearchQuery] = useState("")
const [searchIndex, setSearchIndex] = useState(0)
const searchInputRef = useRef<HTMLInputElement>(null)
// Monotonic counter that increments on any viewport change (pan or zoom)
// Used as a dependency proxy to recalculate popover positions
const [viewportVersion, setViewportVersion] = useState(0)
@ -447,6 +454,12 @@ export function MemoryGraph({
case "_":
handleZoomOut()
break
case "/":
if (showSearch) {
e.preventDefault()
searchInputRef.current?.focus()
}
break
case "Escape":
setSelectedNode(null)
break
@ -455,7 +468,7 @@ export function MemoryGraph({
window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler)
}, [handleAutoFit, handleCenter, handleZoomIn, handleZoomOut])
}, [handleAutoFit, handleCenter, handleZoomIn, handleZoomOut, showSearch])
// Arrow key navigation through nodes
const selectAndCenter = useCallback(
@ -473,6 +486,61 @@ export function MemoryGraph({
[nodes, containerSize.width, graphFitHeight],
)
// Node search — find documents/memories by text and step through matches.
const searchMatches = useMemo(
() => (showSearch ? searchNodes(nodes, searchQuery) : []),
[showSearch, nodes, searchQuery],
)
const matchCount = searchMatches.length
// Keep the focused index in range as the match set changes (typing, data
// reloads, pagination).
useEffect(() => {
setSearchIndex((i) => (matchCount === 0 ? 0 : Math.min(i, matchCount - 1)))
}, [matchCount])
const currentMatchId = searchMatches[searchIndex]?.id ?? null
// Focus (select + center) the current match whenever it changes.
useEffect(() => {
if (currentMatchId) selectAndCenter(currentMatchId)
}, [currentMatchId, selectAndCenter])
// Highlight the documents behind every match (memory matches highlight their
// parent document) so the user can see where results sit, merged with any
// externally-supplied highlights.
const effectiveHighlightIds = useMemo(() => {
if (!highlightsVisible) return []
if (matchCount === 0) return highlightDocumentIds
const ids = new Set(highlightDocumentIds)
for (const node of searchMatches) {
if (node.type === "document") ids.add(node.id)
else if ("documentId" in node.data) ids.add(node.data.documentId)
}
return [...ids]
}, [highlightsVisible, highlightDocumentIds, searchMatches, matchCount])
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value)
setSearchIndex(0)
}, [])
const handleSearchNext = useCallback(() => {
if (matchCount === 0) return
setSearchIndex((i) => (i + 1) % matchCount)
}, [matchCount])
const handleSearchPrev = useCallback(() => {
if (matchCount === 0) return
setSearchIndex((i) => (i - 1 + matchCount) % matchCount)
}, [matchCount])
const handleSearchClear = useCallback(() => {
setSearchQuery("")
setSearchIndex(0)
setSelectedNode(null)
}, [])
const navigateUp = useCallback(() => {
if (!selectedNode) return
const chain = chainIndex.current.getChain(selectedNode)
@ -773,7 +841,7 @@ export function MemoryGraph({
colors={colors}
edges={edges}
height={containerSize.height}
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
highlightDocumentIds={effectiveHighlightIds}
nodes={nodes}
onNodeClick={handleNodeClick}
onNodeDragEnd={handleNodeDragEnd}
@ -812,6 +880,21 @@ export function MemoryGraph({
{containerSize.width > 0 && (
<div style={bottomLeftStackStyle}>
{showSearch && nodes.length > 0 && (
<SearchControl
colors={colors}
compact={isCompactViewport}
currentIndex={searchIndex}
inputRef={searchInputRef}
matchCount={matchCount}
onClear={handleSearchClear}
onNext={handleSearchNext}
onPrev={handleSearchPrev}
onQueryChange={handleSearchChange}
placeholder={searchPlaceholder}
query={searchQuery}
/>
)}
<NavigationControls
nodes={nodes}
compact={isCompactViewport}

View file

@ -0,0 +1,183 @@
import { memo } from "react"
import type { GraphThemeColors } from "../types"
interface SearchControlProps {
query: string
onQueryChange: (query: string) => void
/** Number of nodes matching the current query. */
matchCount: number
/** Zero-based index of the focused match, or -1 when there is none. */
currentIndex: number
onNext: () => void
onPrev: () => void
onClear: () => void
colors: GraphThemeColors
compact?: boolean
placeholder?: string
inputRef?: React.RefObject<HTMLInputElement | null>
}
function StepButton({
onClick,
disabled,
label,
colors,
children,
}: {
onClick: () => void
disabled: boolean
label: string
colors: GraphThemeColors
children: React.ReactNode
}) {
const style: React.CSSProperties = {
width: 20,
height: 20,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 4,
backgroundColor: colors.controlBg,
border: `1px solid ${colors.controlBorder}`,
color: colors.textSecondary,
cursor: disabled ? "default" : "pointer",
fontSize: 12,
padding: 0,
opacity: disabled ? 0.4 : 1,
transition: "opacity 0.15s",
}
return (
<button
aria-label={label}
disabled={disabled}
onClick={onClick}
style={style}
type="button"
onMouseEnter={(e) => {
if (!disabled) e.currentTarget.style.opacity = "0.8"
}}
onMouseLeave={(e) => {
if (!disabled) e.currentTarget.style.opacity = "1"
}}
>
{children}
</button>
)
}
export const SearchControl = memo<SearchControlProps>(
({
query,
onQueryChange,
matchCount,
currentIndex,
onNext,
onPrev,
onClear,
colors,
compact = false,
placeholder = "Search nodes",
inputRef,
}) => {
const hasQuery = query.trim().length > 0
const rowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
width: "fit-content",
maxWidth: compact ? "calc(100vw - 32px)" : 260,
paddingLeft: 12,
paddingRight: 8,
paddingTop: 6,
paddingBottom: 6,
borderRadius: 9999,
backgroundColor: colors.controlBg,
border: `1px solid ${colors.controlBorder}`,
boxShadow: "0 1px 2px 0 rgba(0,0,0,0.05)",
}
const inputStyle: React.CSSProperties = {
flex: 1,
minWidth: 0,
width: compact ? 120 : 150,
background: "transparent",
border: "none",
outline: "none",
color: colors.textPrimary,
fontSize: 12,
padding: 0,
}
const countStyle: React.CSSProperties = {
fontSize: 11,
fontWeight: 500,
color:
hasQuery && matchCount === 0 ? colors.textMuted : colors.textSecondary,
whiteSpace: "nowrap",
minWidth: 34,
textAlign: "right",
}
const countLabel = !hasQuery
? ""
: matchCount === 0
? "0/0"
: `${currentIndex + 1}/${matchCount}`
return (
<div style={rowStyle}>
<input
aria-label="Search graph nodes"
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
if (e.shiftKey) onPrev()
else onNext()
} else if (e.key === "Escape") {
e.preventDefault()
onClear()
}
}}
placeholder={placeholder}
ref={inputRef}
style={inputStyle}
type="text"
value={query}
/>
{hasQuery && <span style={countStyle}>{countLabel}</span>}
<div style={{ display: "flex", alignItems: "center", gap: 2 }}>
<StepButton
colors={colors}
disabled={matchCount === 0}
label="Previous match"
onClick={onPrev}
>
<span style={{ fontSize: 11 }}>↑</span>
</StepButton>
<StepButton
colors={colors}
disabled={matchCount === 0}
label="Next match"
onClick={onNext}
>
<span style={{ fontSize: 11 }}>↓</span>
</StepButton>
<StepButton
colors={colors}
disabled={!hasQuery}
label="Clear search"
onClick={onClear}
>
<span style={{ fontSize: 12 }}>×</span>
</StepButton>
</div>
</div>
)
},
)
SearchControl.displayName = "SearchControl"

View file

@ -6,6 +6,9 @@ export { GraphCanvas } from "./components/graph-canvas"
export { useGraphData } from "./hooks/use-graph-data"
export { useGraphTheme } from "./hooks/use-graph-theme"
// Utilities
export { searchNodes } from "./utils/node-search"
// Engine classes (for advanced usage)
export { ForceSimulation } from "./canvas/simulation"
export { ViewportState } from "./canvas/viewport"

View file

@ -220,6 +220,10 @@ export interface MemoryGraphProps {
labels?: MemoryGraphLabels
/** Overlay layering controls */
layering?: MemoryGraphLayering
/** Show the in-graph node search box (default: true) */
showSearch?: boolean
/** Placeholder text for the search box */
searchPlaceholder?: string
}
export interface ChainEntry {

View file

@ -0,0 +1,75 @@
import type { GraphNode } from "../types"
/**
* Relevance tiers for a single node match, highest first. A node's overall
* score is the best tier any of its fields achieves for the query.
*/
const SCORE_PRIMARY_PREFIX = 3
const SCORE_PRIMARY_INCLUDES = 2
const SCORE_SECONDARY_INCLUDES = 1
const SCORE_NONE = 0
/**
* The text fields that make a node findable, split into a primary field (the
* node's headline: a document title or a memory's text) and secondary fields
* (supporting copy that should still match but rank lower).
*/
function getNodeSearchFields(node: GraphNode): {
primary: string
secondary: string
} {
if (node.type === "document") {
const data = node.data as {
title: string | null
summary: string | null
type: string
}
return {
primary: data.title ?? "",
secondary: `${data.summary ?? ""} ${data.type ?? ""}`,
}
}
const data = node.data as { memory: string; content: string }
return {
primary: data.memory ?? "",
secondary: data.content ?? "",
}
}
function scoreNode(node: GraphNode, query: string): number {
const { primary, secondary } = getNodeSearchFields(node)
const primaryLower = primary.toLowerCase()
if (primaryLower.startsWith(query)) return SCORE_PRIMARY_PREFIX
if (primaryLower.includes(query)) return SCORE_PRIMARY_INCLUDES
if (secondary.toLowerCase().includes(query)) return SCORE_SECONDARY_INCLUDES
return SCORE_NONE
}
/**
* Find the nodes whose text matches `rawQuery`, ordered by relevance.
*
* Matching is a case-insensitive substring test across each node's title/memory
* (primary) and summary/type/content (secondary) text. Results are sorted by
* relevance tier — primary-prefix, then primary-substring, then
* secondary-substring — and ties keep the input order so the result is stable
* and independent of the force-simulation's node positions.
*
* An empty or whitespace-only query returns no matches.
*/
export function searchNodes(nodes: GraphNode[], rawQuery: string): GraphNode[] {
const query = rawQuery.trim().toLowerCase()
if (!query) return []
const scored: { node: GraphNode; score: number; index: number }[] = []
for (let index = 0; index < nodes.length; index++) {
const node = nodes[index]
if (!node) continue
const score = scoreNode(node, query)
if (score > SCORE_NONE) scored.push({ node, score, index })
}
scored.sort((a, b) => b.score - a.score || a.index - b.index)
return scored.map((entry) => entry.node)
}