import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react" import { DENSE_GRAPH_STATIC_THRESHOLD, ForceSimulation, } from "../canvas/simulation" import { DEFAULT_HOVER_POPOVER_Z_INDEX, DEFAULT_LABELS } from "../constants" import { VersionChainIndex } from "../canvas/version-chain" import type { ViewportState } from "../canvas/viewport" import { useGraphData } from "../hooks/use-graph-data" import { useGraphTheme } from "../hooks/use-graph-theme" import { useInitialGraphFit } from "../hooks/use-initial-graph-fit" import type { GraphApiDocument, GraphNode, MemoryNodeData, MemoryGraphProps, ResolvedMemoryGraphLabels, } from "../types" 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 { Minimap } from "./minimap" function documentIdForNode( node: GraphNode | null, documents: GraphApiDocument[], ): string | null { if (!node || typeof node.data.id !== "string" || !node.data.id.trim()) return null if (node.type === "document") { return documents.some((document) => document.id === node.data.id) ? node.data.id : null } const sourceId = (node.data as MemoryNodeData).documentId if (typeof sourceId !== "string" || !sourceId.trim()) return null const ids = new Set( documents .filter( (document) => document.id.trim() && document.memories.some((memory) => memory.id === node.data.id), ) .map((document) => document.id), ) return ids.size === 1 && ids.has(sourceId) ? sourceId : null } export function MemoryGraph({ documents = [], isLoading: externalIsLoading = false, isLoadingMore = false, onLoadMore, hasMore = false, error: externalError = null, children, variant = "console", highlightDocumentIds = [], highlightsVisible = true, showFps = false, showMinimap = false, maxNodes, isSlideshowActive = false, onSlideshowNodeChange, canvasRef: externalCanvasRef, colors: colorOverrides, totalCount, onOpenDocument: externalOnOpenDocument, onNodeSelect, labels: labelOverrides, layering, appearance, openDocumentOnEnter = false, }: MemoryGraphProps) { const resolvedLabels = useMemo( () => ({ ...DEFAULT_LABELS, ...labelOverrides }), [labelOverrides], ) const hoverPopoverZIndex = layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX const colors = useGraphTheme(colorOverrides) const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) const [containerBounds, setContainerBounds] = useState(null) const containerRef = useRef(null) const viewportRef = useRef(null) const simulationRef = useRef(null) const [simulation, setSimulation] = useState(null) const chainIndex = useRef(new VersionChainIndex()) // React state only for things that affect DOM const [hoveredNode, setHoveredNode] = useState(null) const [selectedNode, setSelectedNode] = useState(null) const onNodeSelectRef = useRef(onNodeSelect) onNodeSelectRef.current = onNodeSelect const lastSelectedRef = useRef(selectedNode) // Compares against the last value so mount and StrictMode re-runs do not emit. useEffect(() => { if (lastSelectedRef.current === selectedNode) return lastSelectedRef.current = selectedNode onNodeSelectRef.current?.(selectedNode) }, [selectedNode]) const [zoomDisplay, setZoomDisplay] = useState(50) // 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) // Cap documents + unique memories at maxNodes; shared memories count once. const limitedDocuments = useMemo(() => { if (!maxNodes || documents.length === 0) return documents let totalNodes = 0 const seenMemoryIds = new Set() const limited: GraphApiDocument[] = [] for (const doc of documents) { if (totalNodes >= maxNodes) break const memories = doc.memories ?? [] const includedMemories: GraphApiDocument["memories"] = [] totalNodes += 1 for (const memory of memories) { if (seenMemoryIds.has(memory.id)) { includedMemories.push(memory) continue } if (totalNodes >= maxNodes) continue seenMemoryIds.add(memory.id) includedMemories.push(memory) totalNodes += 1 } limited.push( includedMemories.length === memories.length ? doc : { ...doc, memories: includedMemories }, ) } return limited }, [documents, maxNodes]) const hasContainerSize = containerSize.width > 0 && containerSize.height > 0 const { nodes, edges } = useGraphData( hasContainerSize ? limitedDocuments : [], null, containerSize.width, containerSize.height, colors, variant === "console" ? "theme" : "cluster", ) const isCompactViewport = containerSize.width > 0 && containerSize.width < 640 const graphFitHeight = isCompactViewport ? Math.max(containerSize.height - 170, 240) : containerSize.height const compactLegendMaxHeight = isCompactViewport ? Math.max(containerSize.height - 104, 160) : undefined // Rebuild version chain index during render (not in an effect) so that // the chain data is up-to-date when getChain() is called in useMemo below. // rebuild() has an early-return guard (referential equality on documents) // that makes this a no-op on re-renders where limitedDocuments hasn't changed. chainIndex.current.rebuild(limitedDocuments) const prevSimIdsRef = useRef>(new Set()) useLayoutEffect(() => { if (nodes.length === 0) { simulationRef.current?.destroy() simulationRef.current = null setSimulation(null) prevSimIdsRef.current = new Set() return } const currentIds = new Set(nodes.map((n) => n.id)) if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) { simulationRef.current?.destroy() simulationRef.current = null setSimulation(null) prevSimIdsRef.current = currentIds return } const previousIds = prevSimIdsRef.current const hasPreviousIds = previousIds.size > 0 const idsChanged = currentIds.size !== previousIds.size || [...currentIds].some((id) => !previousIds.has(id)) const isAppendOnly = hasPreviousIds && [...previousIds].every((id) => currentIds.has(id)) if (!simulationRef.current) { const sim = new ForceSimulation() simulationRef.current = sim setSimulation(sim) } if (!hasPreviousIds || (idsChanged && !isAppendOnly)) { prevSimIdsRef.current = currentIds simulationRef.current.init(nodes, edges) } else if (idsChanged && isAppendOnly) { prevSimIdsRef.current = currentIds simulationRef.current.update(nodes, edges) simulationRef.current.settle() } else { simulationRef.current.update(nodes, edges) } }, [nodes, edges]) // Cleanup simulation on unmount useEffect(() => { return () => { simulationRef.current?.destroy() simulationRef.current = null setSimulation(null) } }, []) useEffect(() => { viewportRef.current?.setMinZoomForNodes( nodes, containerSize.width, graphFitHeight, ) }, [nodes, containerSize.width, graphFitHeight]) const stopFollowing = useInitialGraphFit({ documents: limitedDocuments, nodes, viewportRef, simulationRef, width: containerSize.width, height: hasContainerSize ? graphFitHeight : 0, isLoading: externalIsLoading, isLoadingMore, hasMore, }) // Container resize observer useEffect(() => { const el = containerRef.current if (!el) return const measure = () => { const rect = el.getBoundingClientRect() const width = Math.round(rect.width) || el.clientWidth const height = Math.round(rect.height) || el.clientHeight setContainerSize({ width, height }) setContainerBounds(rect) } const ro = new ResizeObserver(measure) ro.observe(el) const parent = el.parentElement if (parent) ro.observe(parent) measure() const raf = requestAnimationFrame(measure) return () => { cancelAnimationFrame(raf) ro.disconnect() } }, []) // Callbacks for GraphCanvas const handleNodeHover = useCallback( (id: string | null) => setHoveredNode(id), [], ) const handleNodeClick = useCallback((id: string | null) => { setSelectedNode((prev) => (id === null ? null : prev === id ? null : id)) }, []) const handleNodeDragStart = useCallback((_id: string) => { // Drag is handled imperatively by InputHandler }, []) const handleNodeDragEnd = useCallback(() => { // Drag end handled by InputHandler }, []) // Load more when user zooms out enough that visible area dwarfs the node extent const loadMoreTimerRef = useRef | null>(null) const loadMoreRef = useRef({ hasMore, isLoadingMore, onLoadMore }) loadMoreRef.current = { hasMore, isLoadingMore, onLoadMore } const handleViewportChange = useCallback( (zoom: number, popoverVisible: boolean) => { setZoomDisplay(Math.round(zoom * 100)) if (popoverVisible) { setViewportVersion((v) => v + 1) } const { hasMore: more, isLoadingMore: loading, onLoadMore: load, } = loadMoreRef.current if (!more || loading || !load || !viewportRef.current) return const vp = viewportRef.current const currentNodes = nodes if (currentNodes.length === 0) return const topLeft = vp.screenToWorld(0, 0) const bottomRight = vp.screenToWorld( containerSize.width, containerSize.height, ) const viewW = bottomRight.x - topLeft.x const viewH = bottomRight.y - topLeft.y let minX = Number.POSITIVE_INFINITY let minY = Number.POSITIVE_INFINITY let maxX = Number.NEGATIVE_INFINITY let maxY = Number.NEGATIVE_INFINITY for (const n of currentNodes) { if (n.x < minX) minX = n.x if (n.y < minY) minY = n.y if (n.x > maxX) maxX = n.x if (n.y > maxY) maxY = n.y } const nodeW = maxX - minX || 1 const nodeH = maxY - minY || 1 // Only trigger when the visible area is 3x larger than the node extent // (i.e. user has zoomed out significantly past the data) const zoomedOut = viewW > nodeW * 3 || viewH > nodeH * 3 if (zoomedOut && !loadMoreTimerRef.current) { loadMoreTimerRef.current = setTimeout(() => { loadMoreTimerRef.current = null loadMoreRef.current.onLoadMore?.() }, 500) } }, [nodes, containerSize.width, containerSize.height], ) // Navigation const handleAutoFit = useCallback(() => { if (nodes.length === 0 || !viewportRef.current) return stopFollowing() viewportRef.current.setMinZoomForNodes( nodes, containerSize.width, graphFitHeight, ) viewportRef.current.fitToNodes(nodes, containerSize.width, graphFitHeight) }, [nodes, containerSize.width, graphFitHeight, stopFollowing]) const handleCenter = useCallback(() => { if (nodes.length === 0 || !viewportRef.current) return stopFollowing() let sx = 0 let sy = 0 for (const n of nodes) { sx += n.x sy += n.y } viewportRef.current.centerOn( sx / nodes.length, sy / nodes.length, containerSize.width, graphFitHeight, ) }, [nodes, containerSize.width, graphFitHeight, stopFollowing]) const handleZoomIn = useCallback(() => { const vp = viewportRef.current if (!vp) return stopFollowing() vp.zoomTo(vp.zoom * 1.3, containerSize.width / 2, graphFitHeight / 2) }, [containerSize.width, graphFitHeight, stopFollowing]) const handleZoomOut = useCallback(() => { const vp = viewportRef.current if (!vp) return stopFollowing() vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, graphFitHeight / 2) }, [containerSize.width, graphFitHeight, stopFollowing]) const activeDocumentId = useMemo( () => openDocumentOnEnter ? documentIdForNode( nodes.find((node) => node.id === (selectedNode ?? hoveredNode)) ?? null, documents, ) : null, [openDocumentOnEnter, selectedNode, hoveredNode, nodes, documents], ) const onOpenDocument = useMemo( () => !openDocumentOnEnter ? externalOnOpenDocument : externalOnOpenDocument && activeDocumentId ? (_documentId: string) => externalOnOpenDocument(activeDocumentId) : undefined, [openDocumentOnEnter, externalOnOpenDocument, activeDocumentId], ) const selectedDocumentId = useMemo( () => openDocumentOnEnter ? documentIdForNode( nodes.find((node) => node.id === selectedNode) ?? null, documents, ) : null, [openDocumentOnEnter, selectedNode, nodes, documents], ) const canOpenOnEnter = openDocumentOnEnter && !!externalOnOpenDocument const handleOpenDocument = useCallback( (documentId: string) => { setSelectedNode(null) setHoveredNode(null) onOpenDocument?.(documentId) }, [onOpenDocument], ) const documentNavigationProps = canOpenOnEnter ? { tabIndex: 0, role: "region" as const, "aria-label": "Memory graph. Select a node, then press Enter to open its document.", onKeyDown: (event: React.KeyboardEvent) => { if ( !selectedDocumentId || event.key !== "Enter" || event.defaultPrevented || event.repeat || event.nativeEvent.isComposing || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey ) return if ( event.target !== event.currentTarget && !(event.target instanceof HTMLCanvasElement) ) return event.preventDefault() event.stopPropagation() handleOpenDocument(selectedDocumentId) }, } : {} useEffect(() => { const handler = (e: KeyboardEvent) => { const target = e.target as HTMLElement if ( openDocumentOnEnter && (e.defaultPrevented || e.isComposing || e.ctrlKey || e.metaKey || e.altKey || (target instanceof Element && target.closest( "input, textarea, select, [contenteditable], [role=textbox]", ))) ) return if ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable ) return switch (e.key) { case "z": case "Z": handleAutoFit() break case "c": case "C": handleCenter() break case "=": case "+": handleZoomIn() break case "-": case "_": handleZoomOut() break case "Escape": setSelectedNode(null) break } } window.addEventListener("keydown", handler) return () => window.removeEventListener("keydown", handler) }, [ handleAutoFit, handleCenter, handleZoomIn, handleZoomOut, openDocumentOnEnter, ]) // Arrow key navigation through nodes const selectAndCenter = useCallback( (nodeId: string) => { stopFollowing() setSelectedNode(nodeId) const n = nodes.find((nd) => nd.id === nodeId) if (n && viewportRef.current) viewportRef.current.centerOn( n.x, n.y, containerSize.width, graphFitHeight, ) }, [nodes, containerSize.width, graphFitHeight, stopFollowing], ) const navigateUp = useCallback(() => { if (!selectedNode) return const chain = chainIndex.current.getChain(selectedNode) if (chain && chain.length > 1) { const idx = chain.findIndex((e) => e.id === selectedNode) const prev = chain[idx - 1] if (idx > 0 && prev) { selectAndCenter(prev.id) return } } // At top of chain or no chain — go to parent document const node = nodes.find((n) => n.id === selectedNode) if (node?.type === "memory" && "documentId" in node.data) { selectAndCenter(node.data.documentId) } }, [selectedNode, nodes, selectAndCenter]) const navigateDown = useCallback(() => { if (!selectedNode) return // Version chain navigation const chain = chainIndex.current.getChain(selectedNode) if (chain && chain.length > 1) { const idx = chain.findIndex((e) => e.id === selectedNode) const next = chain[idx + 1] if (idx >= 0 && idx < chain.length - 1 && next) { selectAndCenter(next.id) return } } // On a document — go to its first memory const node = nodes.find((n) => n.id === selectedNode) if (node?.type === "document") { const child = nodes.find( (n) => n.type === "memory" && "documentId" in n.data && n.data.documentId === selectedNode, ) if (child) selectAndCenter(child.id) } }, [selectedNode, nodes, selectAndCenter]) const navigateNext = useCallback(() => { if (!selectedNode) return const node = nodes.find((n) => n.id === selectedNode) if (!node) return if (node.type === "document") { const docs = nodes.filter((n) => n.type === "document") const idx = docs.findIndex((n) => n.id === selectedNode) const next = docs[(idx + 1) % docs.length] if (next) selectAndCenter(next.id) } else { const docId = "documentId" in node.data ? node.data.documentId : null const siblings = nodes.filter( (n) => n.type === "memory" && "documentId" in n.data && n.data.documentId === docId, ) if (siblings.length === 0) return const idx = siblings.findIndex((n) => n.id === selectedNode) const next = siblings[(idx + 1) % siblings.length] if (next) selectAndCenter(next.id) } }, [selectedNode, nodes, selectAndCenter]) const navigatePrev = useCallback(() => { if (!selectedNode) return const node = nodes.find((n) => n.id === selectedNode) if (!node) return if (node.type === "document") { const docs = nodes.filter((n) => n.type === "document") const idx = docs.findIndex((n) => n.id === selectedNode) const prev = docs[(idx - 1 + docs.length) % docs.length] if (prev) selectAndCenter(prev.id) } else { const docId = "documentId" in node.data ? node.data.documentId : null const siblings = nodes.filter( (n) => n.type === "memory" && "documentId" in n.data && n.data.documentId === docId, ) if (siblings.length === 0) return const idx = siblings.findIndex((n) => n.id === selectedNode) const prev = siblings[(idx - 1 + siblings.length) % siblings.length] if (prev) selectAndCenter(prev.id) } }, [selectedNode, nodes, selectAndCenter]) // Arrow key navigation useEffect(() => { const handler = (e: KeyboardEvent) => { const target = e.target as HTMLElement if ( openDocumentOnEnter && (e.defaultPrevented || e.isComposing || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || (target instanceof Element && target.closest( "input, textarea, select, [contenteditable], [role=textbox]", ))) ) return if ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable ) return switch (e.key) { case "ArrowUp": e.preventDefault() navigateUp() break case "ArrowDown": e.preventDefault() navigateDown() break case "ArrowRight": e.preventDefault() navigateNext() break case "ArrowLeft": e.preventDefault() navigatePrev() break } } window.addEventListener("keydown", handler) return () => window.removeEventListener("keydown", handler) }, [ navigateUp, navigateDown, navigateNext, navigatePrev, openDocumentOnEnter, ]) // Slideshow — use refs to avoid re-creating the interval on resize or node updates const nodesRef = useRef(nodes) nodesRef.current = nodes const containerSizeRef = useRef(containerSize) containerSizeRef.current = containerSize const onSlideshowNodeChangeRef = useRef(onSlideshowNodeChange) onSlideshowNodeChangeRef.current = onSlideshowNodeChange useEffect(() => { if (!isSlideshowActive || nodes.length === 0) { if (!isSlideshowActive) { setSelectedNode(null) } return } let lastIdx = -1 let coolDownTimer: ReturnType | null = null const pick = () => { const currentNodes = nodesRef.current if (currentNodes.length === 0) return let idx: number if (currentNodes.length > 1) { do { idx = Math.floor(Math.random() * currentNodes.length) } while (idx === lastIdx) } else { idx = 0 } lastIdx = idx const n = currentNodes[idx] if (!n) return setSelectedNode(n.id) const sz = containerSizeRef.current viewportRef.current?.centerOn(n.x, n.y, sz.width, sz.height) simulationRef.current?.reheat() onSlideshowNodeChangeRef.current?.(n.id) // Clear any pending coolDown before scheduling a new one if (coolDownTimer) clearTimeout(coolDownTimer) coolDownTimer = setTimeout(() => { simulationRef.current?.coolDown() coolDownTimer = null }, 1000) } pick() const interval = setInterval(pick, 3500) return () => { clearInterval(interval) if (coolDownTimer) clearTimeout(coolDownTimer) simulationRef.current?.coolDown() } }, [isSlideshowActive, nodes.length]) // Active node: selected takes priority, then hovered const activeNodeId = selectedNode ?? hoveredNode const activeNodeData = useMemo(() => { if (!activeNodeId) return null return nodes.find((n) => n.id === activeNodeId) ?? null }, [activeNodeId, nodes]) // biome-ignore lint/correctness/useExhaustiveDependencies: viewportVersion intentionally used as proxy for viewport state changes const activePopoverPosition = useMemo(() => { if (!activeNodeData || !viewportRef.current) return null const vp = viewportRef.current const screen = vp.worldToScreen(activeNodeData.x, activeNodeData.y) return { screenX: screen.x, screenY: screen.y, nodeRadius: (activeNodeData.size * vp.zoom) / 2, } // viewportVersion triggers re-computation on any viewport change (pan + zoom) // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeNodeData, viewportVersion]) // biome-ignore lint/correctness/useExhaustiveDependencies: limitedDocuments triggers re-computation after chainIndex.current.rebuild() runs with new data const activeVersionChain = useMemo(() => { if (!activeNodeData || activeNodeData.type !== "memory") return null return chainIndex.current.getChain(activeNodeData.id) }, [activeNodeData, limitedDocuments]) const isLayoutPending = !hasContainerSize && limitedDocuments.length > 0 const isLoading = externalIsLoading || isLayoutPending if (externalError) { const errorContainerStyle: React.CSSProperties = { height: "100%", display: "flex", alignItems: "center", justifyContent: "center", backgroundColor: variant === "console" ? colors.bg : "transparent", borderRadius: 12, } const errorBoxStyle: React.CSSProperties = { color: colors.textSecondary, paddingLeft: 24, paddingRight: 24, paddingTop: 16, paddingBottom: 16, } return (
Error loading graph: {externalError.message}
) } const wrapperStyle: React.CSSProperties = { position: "relative", height: "100%", borderRadius: 12, overflow: "hidden", backgroundColor: variant === "console" ? colors.bg : "transparent", backgroundImage: variant === "console" ? `radial-gradient(circle, ${colors.dotColor ?? colors.textMuted} 0.5px, transparent 0.5px)` : undefined, backgroundSize: variant === "console" ? "16px 16px" : undefined, } const canvasContainerStyle: React.CSSProperties = { width: "100%", height: "100%", position: "relative", overflow: "hidden", touchAction: "none", userSelect: "none", } const emptyStateStyle: React.CSSProperties = { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, display: "flex", alignItems: "center", justifyContent: "center", } const bottomLeftStackStyle: React.CSSProperties = { position: "absolute", bottom: isCompactViewport ? 12 : 16, left: isCompactViewport ? 12 : 16, zIndex: 20, display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 8, maxWidth: isCompactViewport ? "calc(100% - 24px)" : undefined, } return (
{!isLoading && !nodes.some((n) => n.type === "document") && children && (
{children}
)}
{ if (event.target instanceof HTMLCanvasElement) { stopFollowing() if (canOpenOnEnter) containerRef.current?.focus({ preventScroll: true }) } }} onWheelCapture={(event) => { if (event.target instanceof HTMLCanvasElement) stopFollowing() }} > {hasContainerSize && ( )} {activeNodeData && activePopoverPosition && ( )} {containerSize.width > 0 && (
)} {showMinimap && !isCompactViewport && containerSize.width > 0 && nodes.length > 0 && ( )}
) }