Files
openhuman/app/src/components/intelligence/MemoryGraph.tsx
T

874 lines
32 KiB
TypeScript

/**
* Obsidian-style force-directed graph view for the memory tree.
*
* Two modes:
* - `tree` — sealed summary nodes connected by parent→child
* - `contacts` — raw chunks linked to person entities they mention
*
* Layout: a tiny barycentric force simulation
* - parent → child links pull connected nodes together
* - all-pairs Coulomb repulsion pushes overlapping nodes apart
* - centring force keeps the cloud anchored in the viewport
*
* Colour: in tree mode each level lights up in its own hue (mirroring the
* Obsidian `path:L{n}` groups) with a soft glow on summary nodes; leaves
* stay a quiet slate.
*
* Interaction: drag a node to reposition it, drag the background to pan,
* scroll to zoom, and "Reset view" recentres. Click a summary node →
* opens the matching `.md` file through the shared workspace path
* command (skipped when the pointer was dragging). This keeps Memory
* graph file actions on the same guarded contract as chat workspace links.
*
* Rendering: where WebGL is available we use a Pixi.js + d3-force canvas
* ({@link PixiGraph}) — the same stack Obsidian's graph runs on, smooth
* well past the 1000-node cap. Without WebGL (e.g. jsdom under test) it
* falls back to a deterministic pure-SVG renderer with the same colours,
* interactions and click/preview behaviour.
*/
import {
type PointerEvent as ReactPointerEvent,
type WheelEvent as ReactWheelEvent,
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { useAppSelector } from '../../store/hooks';
import { resolveTheme, type ThemeMode } from '../../store/themeSlice';
import { type GraphEdge, type GraphMode, type GraphNode } from '../../utils/tauriCommands';
import { openWorkspacePath, previewWorkspaceText } from '../../utils/tauriCommands/workspacePaths';
import Button from '../ui/Button';
import {
CONTACT_COLOR,
LEAF_COLOR,
levelColor,
nodeColor,
nodeRadius,
type SimTuning,
SOURCE_COLOR,
VIEWPORT_H,
VIEWPORT_W,
ZOOM_MAX,
ZOOM_MIN,
} from './memoryGraphLayout';
import { summaryWorkspacePath } from './memoryWorkspacePaths';
import { PixiGraph } from './PixiGraph';
import { seedSvgLayout } from './seedSvgLayout';
import { useSvgForceLayout, WORKER_SUPPORTED } from './useSvgForceLayout';
/** Use WebGL (Pixi) in production; fall back to SVG in test (jsdom). */
const HAS_WEBGL =
typeof document !== 'undefined' &&
typeof document.createElement === 'function' &&
(() => {
try {
const c = document.createElement('canvas');
return !!(c.getContext('webgl2') || c.getContext('webgl'));
} catch {
return false;
}
})();
interface SimNode extends GraphNode {
x: number;
y: number;
vx: number;
vy: number;
}
interface SimState {
sim: SimNode[];
edges: Array<[number, number]>;
radii: number[];
alpha: number;
}
// Stable empties so the worker-layout effect's deps don't change every render
// when there's no graph yet.
const NO_NODES: SimNode[] = [];
const NO_RADII: number[] = [];
const NO_EDGES: Array<[number, number]> = [];
// Stable centre the SVG worker layout settles around (matches the viewBox).
const SVG_CENTER: readonly [number, number] = [VIEWPORT_W / 2, VIEWPORT_H / 2];
interface MemoryGraphProps {
/** Pre-fetched summary / chunk / contact nodes. */
nodes: GraphNode[];
/** Explicit edges (only used in contacts mode). */
edges: GraphEdge[];
/** Which graph this is — drives colour palette + click behaviour. */
mode: GraphMode;
/** Optional override for the empty-state message. */
emptyHint?: string;
/** Optional label for the central hub node (WebGL path). Defaults to "Memory". */
rootLabel?: string;
/** Fill the parent's height (full-screen) instead of the fixed 640px card. */
fill?: boolean;
/** Initial auto-fit zoom. Lower = more zoomed out. Defaults to 0.17. */
fitScale?: number;
/** Fit the whole graph tightly to the viewport instead of a fixed zoom. */
fitToBounds?: boolean;
/** Draw an always-on text label under each node. */
showLabels?: boolean;
/**
* Optional force-simulation tuning applied on the WebGL (Pixi) path. Every
* field defaults to identity, so omitting it preserves the standard layout;
* the orchestration scale showcase passes higher repulsion / link length for
* a wide fan-out.
*/
tuning?: SimTuning;
/**
* Fired exactly once when the graph's force layout first settles (SVG
* worker `end`, the synchronous relax fallback, or the Pixi sim cooling).
* A loading overlay (e.g. the Brain page) waits on this to reveal the graph.
*/
onReady?: () => void;
}
interface SummaryPreviewState {
path: string;
contents: string;
truncated: boolean;
error: string | null;
}
/**
* Map a pointer's client coords into the SVG's viewBox coordinate space
* (SVG fallback only). Returns null without a live CTM (e.g. jsdom) so the
* pan/zoom handlers degrade to no-ops under test.
*/
function clientToViewBox(
svg: SVGSVGElement | null,
clientX: number,
clientY: number
): { x: number; y: number } | null {
if (!svg || typeof svg.getScreenCTM !== 'function') return null;
const ctm = svg.getScreenCTM();
if (!ctm) return null;
const inv = ctm.inverse();
return {
x: inv.a * clientX + inv.c * clientY + inv.e,
y: inv.b * clientX + inv.d * clientY + inv.f,
};
}
/**
* Run the force simulation for `iterations` ticks. Mutates positions in
* place so we can re-use the same buffer across renders.
*/
function relaxLayout(nodes: SimNode[], edges: Array<[number, number]>, iterations = 220): void {
const REPULSION = 1800;
const SPRING_K = 0.04;
const SPRING_LEN = 60;
const CENTER_K = 0.0025;
const FRICTION = 0.85;
const cx = VIEWPORT_W / 2;
const cy = VIEWPORT_H / 2;
for (let iter = 0; iter < iterations; iter++) {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i];
const b = nodes[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const dist2 = dx * dx + dy * dy + 0.01;
const force = REPULSION / dist2;
const dist = Math.sqrt(dist2);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
}
for (const [ai, bi] of edges) {
const a = nodes[ai];
const b = nodes[bi];
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy) + 0.01;
const delta = dist - SPRING_LEN;
const fx = (dx / dist) * delta * SPRING_K;
const fy = (dy / dist) * delta * SPRING_K;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
for (const n of nodes) {
n.vx += (cx - n.x) * CENTER_K;
n.vy += (cy - n.y) * CENTER_K;
n.vx *= FRICTION;
n.vy *= FRICTION;
n.x += n.vx;
n.y += n.vy;
}
}
}
export function MemoryGraph({
nodes,
edges,
mode,
emptyHint,
rootLabel,
fill,
fitScale,
fitToBounds,
showLabels,
tuning,
onReady,
}: MemoryGraphProps) {
const { t } = useT();
const themeMode = useAppSelector(state => state.theme?.mode ?? 'system') as ThemeMode;
const isDark = resolveTheme(themeMode) === 'dark';
const [hovered, setHovered] = useState<GraphNode | null>(null);
// Fire `onReady` at most once across this component's lifetime. The latest
// callback is held in a ref so `fireReady` stays stable (the SVG layout hook
// depends on a stable `onSettled`, and the guard prevents refires on reheat).
const onReadyRef = useRef(onReady);
onReadyRef.current = onReady;
const readyFiredRef = useRef(false);
const fireReady = useCallback(() => {
if (readyFiredRef.current) return;
readyFiredRef.current = true;
console.debug('[memory-graph] layout settled → onReady');
onReadyRef.current?.();
}, []);
const [preview, setPreview] = useState<SummaryPreviewState | null>(null);
const [previewingPath, setPreviewingPath] = useState<string | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
// Pan / zoom transform applied to the graph group, plus the live drag
// state. Node positions live in the memoised `sim` buffer and are
// mutated in place during a node drag; `bumpTick` forces a re-render so
// the moved node repaints without re-running the physics.
const [view, setView] = useState({ tx: 0, ty: 0, scale: 1 });
const [, bumpTick] = useReducer((c: number) => c + 1, 0);
const [grabbing, setGrabbing] = useState(false);
// Bumped by "Reset view" — the Pixi renderer watches it to recentre.
const [resetSignal, bumpReset] = useReducer((c: number) => c + 1, 0);
// Flips true if Pixi fails to init at runtime → fall back to SVG even
// though supportsWebGL() was true at module load.
const [pixiFailed, setPixiFailed] = useState(false);
const useWebGL = HAS_WEBGL && !pixiFailed;
const dragRef = useRef<
| { kind: 'node'; node: SimNode; dx: number; dy: number }
| { kind: 'pan'; vbStartX: number; vbStartY: number; tx0: number; ty0: number }
| null
>(null);
// True once the pointer moved during the current gesture — guards the
// node click so a drag doesn't also open the summary file.
const movedRef = useRef(false);
// Halts the SVG layout worker once the user grabs a node/background, so its
// streamed positions stop fighting the manual drag. Set after the hook below.
const stopLayoutRef = useRef<() => void>(() => {});
// Set once the user grabs the camera, so the settle-time auto-fit doesn't
// yank the view out from under them.
const userInteractedRef = useRef(false);
// Re-frame the SVG graph from "Reset view" (set after fitToView below).
const fitRef = useRef<() => void>(() => {});
// Holds the current sim across renders; during the next build it still points
// at the OUTGOING sim, whose nodes carry the latest live coordinates (the
// worker / a drag mutate them in place) — read for position carry-over.
const liveSimRef = useRef<SimState | null>(null);
const clientToGraph = useCallback(
(clientX: number, clientY: number) => {
const vb = clientToViewBox(svgRef.current, clientX, clientY);
if (!vb) return null;
return { x: (vb.x - view.tx) / view.scale, y: (vb.y - view.ty) / view.scale };
},
[view]
);
const onNodePointerDown = useCallback(
(e: ReactPointerEvent, n: SimNode) => {
// Stop the background pan from also starting on this pointer down.
e.stopPropagation();
movedRef.current = false;
const g = clientToGraph(e.clientX, e.clientY);
if (!g) return;
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
dragRef.current = { kind: 'node', node: n, dx: g.x - n.x, dy: g.y - n.y };
setGrabbing(true);
},
[clientToGraph]
);
const onBackgroundPointerDown = useCallback(
(e: ReactPointerEvent) => {
movedRef.current = false;
const vb = clientToViewBox(svgRef.current, e.clientX, e.clientY);
if (!vb) return;
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
dragRef.current = { kind: 'pan', vbStartX: vb.x, vbStartY: vb.y, tx0: view.tx, ty0: view.ty };
setGrabbing(true);
},
[view]
);
const onPointerMove = useCallback(
(e: ReactPointerEvent) => {
const d = dragRef.current;
if (!d) return;
// On the first real movement (not a plain click), hand the camera to the
// user: freeze the worker layout and suppress the settle-time auto-fit.
if (!movedRef.current) {
stopLayoutRef.current();
userInteractedRef.current = true;
}
if (d.kind === 'node') {
const g = clientToGraph(e.clientX, e.clientY);
if (!g) return;
d.node.x = g.x - d.dx;
d.node.y = g.y - d.dy;
movedRef.current = true;
bumpTick();
} else {
const vb = clientToViewBox(svgRef.current, e.clientX, e.clientY);
if (!vb) return;
movedRef.current = true;
setView(v => ({ ...v, tx: d.tx0 + (vb.x - d.vbStartX), ty: d.ty0 + (vb.y - d.vbStartY) }));
}
},
[clientToGraph]
);
const endDrag = useCallback(() => {
dragRef.current = null;
setGrabbing(false);
}, []);
const onWheelZoom = useCallback((e: ReactWheelEvent) => {
const vb = clientToViewBox(svgRef.current, e.clientX, e.clientY);
if (!vb) return;
userInteractedRef.current = true;
setView(v => {
const factor = Math.exp(-e.deltaY * 0.0015);
const scale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, v.scale * factor));
// Keep the graph point under the cursor fixed while zooming.
const gx = (vb.x - v.tx) / v.scale;
const gy = (vb.y - v.ty) / v.scale;
return { scale, tx: vb.x - gx * scale, ty: vb.y - gy * scale };
});
}, []);
const resetView = useCallback(() => {
// SVG re-frames the whole graph; the Pixi canvas listens on the reset
// signal. Both are triggered so the button works in either path.
userInteractedRef.current = false;
fitRef.current();
bumpReset();
}, []);
const openSummary = useCallback(async (node: GraphNode) => {
const path = summaryWorkspacePath(node);
if (!path) return;
console.debug('[memory-graph] open workspace path=%s', path);
try {
await openWorkspacePath(path);
} catch (err) {
console.error('[memory-graph] openWorkspacePath failed', err);
}
}, []);
const previewSummary = useCallback(async (node: GraphNode) => {
const path = summaryWorkspacePath(node);
if (!path) return;
setPreviewingPath(path);
try {
const next = await previewWorkspaceText(path);
setPreview({ path, contents: next.contents, truncated: next.truncated, error: null });
} catch (err) {
console.error('[memory-graph] previewWorkspaceText failed', err);
setPreview({
path,
contents: '',
truncated: false,
error: err instanceof Error ? err.message : String(err),
});
} finally {
setPreviewingPath(null);
}
}, []);
// Build edges + seed positions, carrying over the last positions of any
// surviving node so a live data update doesn't reshuffle the whole graph
// (seedSvgLayout). The O(n²) relax only runs as the no-worker fallback (test
// env); the worker settles otherwise; the Pixi path runs its own sim.
const sim = useMemo<SimState | null>(() => {
if (!nodes || nodes.length === 0) return null;
// Snapshot the OUTGOING graph's live coordinates so survivors carry over
// from where they actually are now — not a stale init/settle snapshot —
// even mid-settle or after a drag.
const prev = liveSimRef.current;
const prevPos = new Map<string, { x: number; y: number }>();
if (prev) for (const n of prev.sim) prevPos.set(n.id, { x: n.x, y: n.y });
const seed = seedSvgLayout(nodes, edges, mode, prevPos);
const sim: SimNode[] = nodes.map((n, i) => ({
...n,
x: seed.positions[i].x,
y: seed.positions[i].y,
vx: 0,
vy: 0,
}));
const radii = sim.map(n => nodeRadius(n));
if (!useWebGL && !WORKER_SUPPORTED) relaxLayout(sim, seed.edges);
return { sim, edges: seed.edges, radii, alpha: seed.reheatAlpha };
}, [nodes, edges, mode, useWebGL]);
// Becomes the "previous" sim on the next build (above).
liveSimRef.current = sim;
// Element refs for imperative position updates: while the worker streams
// positions we write cx/cy (and line endpoints) straight to the DOM instead
// of re-rendering up to 10k elements through React every frame.
const circleEls = useRef<(SVGCircleElement | null)[]>([]);
const lineEls = useRef<(SVGLineElement | null)[]>([]);
const textEls = useRef<(SVGTextElement | null)[]>([]);
// Progressive DOM mount for the SVG path: reveal nodes in per-frame batches
// so a large graph never blocks building thousands of elements in one commit.
// WebGL draws to one canvas, so it shows everything at once.
const FIRST_BATCH = 800;
const [svgVisible, setSvgVisible] = useState(() =>
sim ? Math.min(sim.sim.length, FIRST_BATCH) : 0
);
// Reset the reveal window + element refs during render when the graph data
// changes (the recommended alternative to setState-in-effect).
const simIdRef = useRef(sim);
if (simIdRef.current !== sim) {
simIdRef.current = sim;
circleEls.current = [];
lineEls.current = [];
textEls.current = [];
setSvgVisible(sim ? Math.min(sim.sim.length, FIRST_BATCH) : 0);
}
// Latest visible count read by the stable imperative applier without
// re-subscribing the worker every render.
const latestVisibleRef = useRef(svgVisible);
latestVisibleRef.current = svgVisible;
// Write current positions straight to the mounted SVG elements.
const applyPositions = useCallback(() => {
const s = liveSimRef.current;
if (!s) return;
const vis = latestVisibleRef.current;
const ns = s.sim;
for (let i = 0; i < vis && i < ns.length; i++) {
const el = circleEls.current[i];
if (el) {
el.setAttribute('cx', String(ns[i].x));
el.setAttribute('cy', String(ns[i].y));
}
const tel = textEls.current[i];
if (tel) {
tel.setAttribute('x', String(ns[i].x));
tel.setAttribute('y', String(ns[i].y + nodeRadius(ns[i]) + 12));
}
}
for (let e = 0; e < s.edges.length; e++) {
const [ai, bi] = s.edges[e];
if (ai >= vis || bi >= vis) continue;
const el = lineEls.current[e];
if (el) {
el.setAttribute('x1', String(ns[ai].x));
el.setAttribute('y1', String(ns[ai].y));
el.setAttribute('x2', String(ns[bi].x));
el.setAttribute('y2', String(ns[bi].y));
}
}
}, []);
// Frame the whole cloud in the viewport. d3-force spreads a large graph far
// past the viewBox, so without this most nodes sit off-screen. Committed to
// `view` state (single source) so pan/zoom keep working; called once the
// worker settles, unless the user already grabbed the camera.
const fitToView = useCallback(() => {
const s = liveSimRef.current;
if (!s || userInteractedRef.current) return;
const ns = s.sim;
if (ns.length === 0) return;
// Center on the root node at a fixed comfortable zoom.
if (fitToBounds) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of ns) {
const r = nodeRadius(n) + 8;
minX = Math.min(minX, n.x - r);
minY = Math.min(minY, n.y - r);
maxX = Math.max(maxX, n.x + r);
maxY = Math.max(maxY, n.y + r);
}
const w = Math.max(1, maxX - minX);
const h = Math.max(1, maxY - minY);
const scale = Math.min(
ZOOM_MAX,
Math.max(ZOOM_MIN, Math.min(VIEWPORT_W / w, VIEWPORT_H / h) * 0.92)
);
const bx = (minX + maxX) / 2;
const by = (minY + maxY) / 2;
setView({ scale, tx: VIEWPORT_W / 2 - bx * scale, ty: VIEWPORT_H / 2 - by * scale });
return;
}
const root = ns.find(n => n.kind === 'root');
const cx = root?.x ?? 0;
const cy = root?.y ?? 0;
const scale = fitScale ?? 0.17;
setView({ scale, tx: VIEWPORT_W / 2 - cx * scale, ty: VIEWPORT_H / 2 - cy * scale });
}, [fitScale, fitToBounds]);
fitRef.current = fitToView;
// Coalesce worker ticks to one DOM write per frame.
const applyPendingRef = useRef(false);
const scheduleApply = useCallback(() => {
if (applyPendingRef.current) return;
applyPendingRef.current = true;
const run = () => {
applyPendingRef.current = false;
applyPositions();
};
if (typeof window.requestAnimationFrame === 'function') window.requestAnimationFrame(run);
else run();
}, [applyPositions]);
// Frame the graph then signal readiness once the worker layout cools.
const onSvgSettled = useCallback(() => {
fitToView();
fireReady();
}, [fitToView, fireReady]);
// SVG fallback layout runs in a worker (off the main thread); positions
// stream back and are applied imperatively. No-op on WebGL and where workers
// are unavailable (the synchronous relaxLayout above covers that case).
const svgLayout = useSvgForceLayout(
!useWebGL && !!sim,
sim?.sim ?? NO_NODES,
sim?.radii ?? NO_RADII,
sim?.edges ?? NO_EDGES,
SVG_CENTER,
sim?.alpha ?? 1,
scheduleApply,
onSvgSettled
);
stopLayoutRef.current = svgLayout.stop;
// Synchronous-layout path (no WebGL, no Worker — e.g. jsdom under test):
// `relaxLayout` already ran inside the `sim` memo, so the graph is laid out
// as soon as `sim` exists. Signal readiness on the next tick.
useEffect(() => {
if (useWebGL || WORKER_SUPPORTED || !sim) return;
fireReady();
}, [useWebGL, sim, fireReady]);
// Ramp the rest in per-frame batches (setState only inside the rAF callback).
useEffect(() => {
if (useWebGL || !sim) return;
const total = sim.sim.length;
if (total <= FIRST_BATCH || typeof window.requestAnimationFrame !== 'function') return;
let raf = 0;
const step = () => {
setSvgVisible(c => {
const next = Math.min(total, c + 1200);
if (next < total) raf = window.requestAnimationFrame(step);
return next;
});
};
raf = window.requestAnimationFrame(step);
return () => window.cancelAnimationFrame(raf);
}, [useWebGL, sim]);
if (nodes.length === 0) {
return (
<div
className="flex h-[640px] items-center justify-center rounded-lg border border-line-subtle bg-surface-muted/40 text-sm text-content-muted"
data-testid="memory-graph-empty">
{emptyHint ?? (mode === 'contacts' ? t('graph.noContactMentions') : t('graph.noMemory'))}
</div>
);
}
if (!sim) return null;
// Distinct legend rows for the active mode. Tree mode lists the levels
// actually present (each lit in its own colour) plus a leaf row when
// chunks are shown.
const legend =
mode === 'tree'
? [
...(nodes.some(n => n.kind === 'source')
? [{ label: t('graph.source', 'Source'), color: SOURCE_COLOR }]
: []),
...Array.from(new Set(nodes.filter(n => n.kind === 'summary').map(n => n.level ?? 0)))
.sort((a, b) => a - b)
.map(lvl => ({ label: `L${lvl}`, color: levelColor(lvl) })),
...(nodes.some(n => n.kind === 'chunk')
? [{ label: t('graph.document'), color: LEAF_COLOR }]
: []),
]
: [
{ label: t('graph.document'), color: LEAF_COLOR },
{ label: t('graph.contact'), color: CONTACT_COLOR },
];
const hoveredSummaryPath = hovered?.kind === 'summary' ? summaryWorkspacePath(hovered) : null;
return (
<div
className={`memory-graph rounded-lg border border-line-subtle bg-surface ${
fill ? 'flex h-full flex-col overflow-hidden' : ''
}`}
onMouseLeave={() => setHovered(null)}>
<div className="flex flex-none items-center justify-between gap-4 border-b border-line-subtle px-4 py-2">
<div className="flex items-center gap-3 text-xs text-content-muted">
<span>
{nodes.length} {t('graph.nodes')}
</span>
<span className="text-content-faint dark:text-neutral-600">·</span>
<span>
{sim.edges.length}{' '}
{mode === 'tree' ? t('graph.parentChild') : t('graph.documentContact')}{' '}
{sim.edges.length === 1 ? t('graph.link') : t('graph.links')}
</span>
</div>
<div className="flex items-center gap-3">
{legend.map(item => (
<span
key={item.label}
className="flex items-center gap-1.5 text-xs text-content-secondary">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: item.color }}
/>
{item.label}
</span>
))}
<Button
variant="secondary"
size="xs"
onClick={resetView}
data-testid="memory-graph-reset-view"
className="text-[11px] shadow-sm">
{t('graph.resetView')}
</Button>
</div>
</div>
{useWebGL ? (
<PixiGraph
nodes={nodes}
edges={edges}
mode={mode}
rootLabel={rootLabel}
fill={fill}
fitScale={fitScale}
fitToBounds={fitToBounds}
showLabels={showLabels}
tuning={tuning}
dark={
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
}
resetSignal={resetSignal}
onHover={setHovered}
onOpen={n => {
if (n.kind === 'summary') void openSummary(n);
}}
onError={() => setPixiFailed(true)}
onReady={fireReady}
/>
) : (
<svg
ref={svgRef}
viewBox={`0 0 ${VIEWPORT_W} ${VIEWPORT_H}`}
className={`block w-full touch-none select-none ${fill ? 'min-h-0 flex-1' : ''}`}
style={{
height: fill ? '100%' : 'min(640px, calc(100vh - 22rem))',
cursor: grabbing ? 'grabbing' : 'grab',
}}
onPointerDown={onBackgroundPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerLeave={endDrag}
onWheel={onWheelZoom}
data-testid="memory-graph-svg">
{/* Pan / zoom group — drag the background to pan, scroll to zoom. */}
<g transform={`translate(${view.tx} ${view.ty}) scale(${view.scale})`}>
<g
stroke={isDark ? '#cbd5e1' : '#475569'}
strokeWidth={isDark ? 0.6 : 1.2}
opacity={isDark ? 0.7 : 0.7}>
{sim.edges.map(([ai, bi], idx) => {
// Only draw edges whose endpoints are both mounted yet.
if (ai >= svgVisible || bi >= svgVisible) return null;
const a = sim.sim[ai];
const b = sim.sim[bi];
return (
<line
key={idx}
ref={el => {
lineEls.current[idx] = el;
}}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
/>
);
})}
</g>
<g>
{sim.sim.slice(0, svgVisible).map((n, i) => {
const r = nodeRadius(n);
const fill = nodeColor(n);
const isHover = hovered?.id === n.id;
// Leaves stay flat; summary / contact nodes glow in their
// own colour so the tree levels "light up".
const glow =
n.kind === 'chunk' ? undefined : `drop-shadow(0 0 ${isHover ? 7 : 4}px ${fill})`;
return (
<circle
key={n.id}
ref={el => {
circleEls.current[i] = el;
}}
cx={n.x}
cy={n.y}
r={isHover ? r + 2 : r}
fill={fill}
stroke={
isHover ? (isDark ? '#0f172a' : '#1e293b') : isDark ? '#ffffff' : '#e2e8f0'
}
strokeWidth={isHover ? 1.4 : 0.8}
style={{ cursor: grabbing ? 'grabbing' : 'pointer', filter: glow }}
onPointerDown={e => onNodePointerDown(e, n)}
onMouseEnter={() => setHovered(n)}
onClick={() => {
// A drag ends with a click event too — skip the open
// when the pointer actually moved.
if (movedRef.current) return;
if (n.kind === 'summary') void openSummary(n);
}}
data-testid={`memory-graph-node-${n.id}`}>
<title>{tooltipFor(n, t)}</title>
</circle>
);
})}
</g>
{showLabels && (
<g>
{sim.sim.slice(0, svgVisible).map((n, i) => {
const label = (n.label ?? '').trim();
const text = label.length > 22 ? `${label.slice(0, 21)}…` : label;
return (
<text
key={n.id}
ref={el => {
textEls.current[i] = el;
}}
x={n.x}
y={n.y + nodeRadius(n) + 12}
textAnchor="middle"
fontSize={13}
fill={isDark ? '#e2e8f0' : '#334155'}
style={{ pointerEvents: 'none', userSelect: 'none' }}>
{text}
</text>
);
})}
</g>
)}
</g>
</svg>
)}
{hovered && (
<div
className="border-t border-line-subtle bg-surface-muted/70 dark:bg-surface/70 px-4 py-2 text-xs text-content-secondary"
data-testid="memory-graph-tooltip">
{hovered.kind === 'root' ? (
<span className="font-medium text-violet-600 dark:text-violet-400">
{hovered.label}
</span>
) : hovered.kind === 'source' ? (
<span className="font-medium text-orange-600 dark:text-orange-400">
{hovered.label}
</span>
) : hovered.kind === 'summary' ? (
<>
<span className="font-mono">L{hovered.level ?? '?'}</span>
<span className="text-content-faint"> · </span>
<span className="capitalize">{hovered.tree_kind}</span>
<span className="text-content-faint"> · </span>
<span>{hovered.tree_scope}</span>
<span className="text-content-faint"> · </span>
<span>
{hovered.child_count ?? 0} {t('graph.children')}
</span>
{hoveredSummaryPath && (
<>
<span className="ml-3 break-all font-mono text-content-faint">
workspace:{hoveredSummaryPath}
</span>
<Button
variant="secondary"
size="xs"
data-testid={`memory-graph-preview-${hovered.id}`}
disabled={previewingPath === hoveredSummaryPath}
onClick={() => void previewSummary(hovered)}
className="ml-3 text-[11px] shadow-sm">
{previewingPath === hoveredSummaryPath
? t('migration.previewRunning')
: t('migration.previewAction')}
</Button>
</>
)}
</>
) : hovered.kind === 'contact' ? (
<>
<span className="font-medium text-violet-700 dark:text-violet-300">
{hovered.label}
</span>
<span className="ml-3 text-content-faint">
{t('graph.person')} · canonical id {hovered.id.slice(0, 12)}
</span>
</>
) : (
<>
<span className="font-medium">{hovered.label || 'chunk'}</span>
<span className="ml-3 text-content-faint">{t('graph.document')}</span>
</>
)}
</div>
)}
{preview && (
<div
className="border-t border-line-subtle bg-surface px-4 py-3 dark:bg-surface-canvas"
data-testid="memory-graph-preview">
<div className="mb-2 break-all font-mono text-[11px] text-content-faint">
workspace:{preview.path}
</div>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap rounded-md bg-surface-muted p-3 text-xs text-content-secondary">
{preview.error || preview.contents}
{preview.truncated ? '\n…' : ''}
</pre>
</div>
)}
</div>
);
}
function tooltipFor(n: GraphNode, t: (key: string, fallback?: string) => string): string {
if (n.kind === 'root') return n.label;
if (n.kind === 'summary') return t('graph.tooltip.summary');
if (n.kind === 'contact') return t('graph.tooltip.contact');
return n.label || t('graph.document');
}