This commit is contained in:
Abhay Singh 2026-09-26 20:20:51 +00:00 • committed by GitHub
commit 82cddbb87a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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** - Keep the initial graph fitted as pages arrive; manual pan, zoom, or selection takes control of the view
@ -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 |
Console mode uses the supplied theme colors for its surface, 16px dot grid, document icons, and node fills and strokes. Consumer mode keeps its transparent surface and cluster colors.

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

@ -22,11 +22,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 = [],
@ -49,6 +51,8 @@ export function MemoryGraph({
onOpenDocument,
labels: labelOverrides,
layering,
showSearch = true,
searchPlaceholder,
}: MemoryGraphProps) {
const resolvedLabels = useMemo<ResolvedMemoryGraphLabels>(
() => ({ ...DEFAULT_LABELS, ...labelOverrides }),
@ -77,6 +81,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)
@ -399,6 +406,12 @@ export function MemoryGraph({
case "_":
handleZoomOut()
break
case "/":
if (showSearch) {
e.preventDefault()
searchInputRef.current?.focus()
}
break
case "Escape":
setSelectedNode(null)
break
@ -407,7 +420,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(
@ -426,6 +439,61 @@ export function MemoryGraph({
[nodes, containerSize.width, graphFitHeight, stopFollowing],
)
// 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)
@ -740,7 +808,7 @@ export function MemoryGraph({
colors={colors}
edges={edges}
height={containerSize.height}
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
highlightDocumentIds={effectiveHighlightIds}
nodes={nodes}
onNodeClick={handleNodeClick}
onNodeDragEnd={handleNodeDragEnd}
@ -779,6 +847,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

@ -224,6 +224,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)
}