From 88c1568b461e03c460436d32a2cfbccc284b0ba4 Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Sat, 18 Jul 2026 13:51:54 +0530 Subject: [PATCH] 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. --- packages/memory-graph/README.md | 3 + .../src/__tests__/node-search.test.ts | 115 +++++++++++ .../src/__tests__/search-control.e2e.test.tsx | 96 +++++++++ .../src/components/memory-graph.tsx | 87 ++++++++- .../src/components/search-control.tsx | 183 ++++++++++++++++++ packages/memory-graph/src/index.tsx | 3 + packages/memory-graph/src/types.ts | 4 + .../memory-graph/src/utils/node-search.ts | 75 +++++++ 8 files changed, 564 insertions(+), 2 deletions(-) create mode 100644 packages/memory-graph/src/__tests__/node-search.test.ts create mode 100644 packages/memory-graph/src/__tests__/search-control.e2e.test.tsx create mode 100644 packages/memory-graph/src/components/search-control.tsx create mode 100644 packages/memory-graph/src/utils/node-search.ts diff --git a/packages/memory-graph/README.md b/packages/memory-graph/README.md index 83710f5a..5953f667 100644 --- a/packages/memory-graph/README.md +++ b/packages/memory-graph/README.md @@ -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` | 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 diff --git a/packages/memory-graph/src/__tests__/node-search.test.ts b/packages/memory-graph/src/__tests__/node-search.test.ts new file mode 100644 index 00000000..7d21f5d9 --- /dev/null +++ b/packages/memory-graph/src/__tests__/node-search.test.ts @@ -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([]) + }) +}) diff --git a/packages/memory-graph/src/__tests__/search-control.e2e.test.tsx b/packages/memory-graph/src/__tests__/search-control.e2e.test.tsx new file mode 100644 index 00000000..3cb99297 --- /dev/null +++ b/packages/memory-graph/src/__tests__/search-control.e2e.test.tsx @@ -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> = {}, +) { + 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() } +} + +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) + }) +}) diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index d165ac71..4f5ae592 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -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( () => ({ ...DEFAULT_LABELS, ...labelOverrides }), @@ -69,6 +73,9 @@ export function MemoryGraph({ const [hoveredNode, setHoveredNode] = useState(null) const [selectedNode, setSelectedNode] = useState(null) const [zoomDisplay, setZoomDisplay] = useState(50) + const [searchQuery, setSearchQuery] = useState("") + const [searchIndex, setSearchIndex] = useState(0) + const searchInputRef = useRef(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 && (
+ {showSearch && nodes.length > 0 && ( + + )} 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 +} + +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 ( + + ) +} + +export const SearchControl = memo( + ({ + 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 ( +
+ 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 && {countLabel}} + +
+ + ↑ + + + ↓ + + + × + +
+
+ ) + }, +) + +SearchControl.displayName = "SearchControl" diff --git a/packages/memory-graph/src/index.tsx b/packages/memory-graph/src/index.tsx index edeeb31c..9b710f79 100644 --- a/packages/memory-graph/src/index.tsx +++ b/packages/memory-graph/src/index.tsx @@ -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" diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts index f871b3da..d0a293b3 100644 --- a/packages/memory-graph/src/types.ts +++ b/packages/memory-graph/src/types.ts @@ -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 { diff --git a/packages/memory-graph/src/utils/node-search.ts b/packages/memory-graph/src/utils/node-search.ts new file mode 100644 index 00000000..3974e5fa --- /dev/null +++ b/packages/memory-graph/src/utils/node-search.ts @@ -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) +}