"use client"; /** * The home knowledge-graph view (W4; vision §1 "Rendering (DECIDED)"). * * ARCHITECTURE (2026-09-13, decided after the research pass — * docs/research/graph-rendering.md, Option B): sigma.js is GONE. The * renderer is now a hand-rolled single Canvas 2D (./graph-canvas.ts) — * a DUMB DRAW FUNCTION over plain data with ONE requestAnimationFrame * loop and a dirty flag. Everything that made the sigma era fragile is * structurally impossible now: hulls cannot lag nodes (one loop, one * camera, one frame), settings changes cannot shift anything (the * camera lives in the renderer and no rebuild ever happens), and the * per-camera-event full-canvas repaints (GPU spikes + coil whine) are * gone (at most one draw per frame, and NO frame at all while idle). * * Layout fallback (ledger absent): ONE synchronous incrementalLayout * pass (d3-force, core/layout.ts) inside a useEffect writes world-space * x/y, then the positions are handed to the renderer as data. No layout * animation ever starts; the only animation is the * short hover fade, strictly bounded in time — idle CPU is zero (the * loop schedules no rAF until something actually changes). * * The component's job is everything AROUND drawing: fetch + refetch the * snapshot, run the layout, resolve theme colors to hex, filter via the * search box, own the settings panel / detail card / import button / * pipeline chips, and FEED the renderer (data, settings, visibility). * The renderer's contract stays presentation-only — which is exactly * what the upcoming playground and cluster-first layout work needs: a * renderer that draws whatever data it is given, never questioning it. * * Visual semantics, all preserved verbatim from the sigma era: * - Node labels (the short extractor-coined names) wrap under the * node; one GLOBAL zoom gate fades them together (no per-node pop). * - Hover: a soft glow halo in the node's own color at a constant * radius, opacity cross-faded ~180ms with the name brightening on * the same factor; the whole halo is hit-testable (picking pad). * - Cluster hulls (vision §1): soft layered translucent fills + a * dashed boundary per cluster, stable hue from the cluster id; the * region label at the centroid fades in over the exact zoom span * node labels fade out (THE single zoom control) — and is * toggleable in the settings panel (hulls always draw). * - The persisted labelZoom setting keeps sigma's camera-ratio * convention (1 = fit zoom, SMALLER = zoomed in): labels until * zoom 0.5x means labels appear once zoomed in past 2x fit. * - Click a node -> the detail card anchored just below it (flipping * above near the bottom edge); any camera move dismisses it. * Clicking the empty stage closes it. Search filtering hides nodes * (and their edges) from rendering, picking, hover, AND labels; * an open card closes if its node is filtered out. * * Rendering settings (2026-09-13): the gear in the search panel reveals * five sliders (node size, edge width, arrowhead length, label size, the * zoom threshold), and the cluster-labels checkbox. (The supports-edge * draw toggle + thickness slider died with supports edges themselves — * chains-primary, 2026-09-30.) SERVER-persisted * (data/render-settings.json via /api/graph/render-settings): loaded * once after mount, saved debounced on every user change — one source * of truth shared by every browser. The label-zoom slider is log-scaled * (its useful band 0.25–0.5 sits mid-slider, not crammed at the left) * and displays normalized units where the default 0.5 reads as "1×". * Settings apply by just redrawing — no rebuild, no re-layout, and the * camera never moves. */ import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react"; import { incrementalLayout, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core"; import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts"; import { usePopoverDismiss } from "@/components/popover-dismiss"; import { useActivity } from "@/components/activity-dock"; import { useServerEvent, useServerResync } from "@/components/server-events"; import { MenuShell } from "@/components/menu-shell.tsx"; import { DeleteNodeDialog } from "@/components/delete-node-dialog.tsx"; import { ChainTableOverlay } from "@/components/chain-table.tsx"; import { Hint } from "@/components/ui/hint"; import { ARROW_LENGTH_LADDER, CHARGE_RANGE, CLUSTER_LABEL_SIZE_LADDER, CLUSTER_PULL_RANGE, DEPTH_BASE_RADIUS_RANGE, DEPTH_SPACING_RANGE, DEPTH_STRENGTH_RANGE, DEFAULT_RENDER_SETTINGS, EDGE_WIDTH_LADDER, GRAVITY_RANGE, LABEL_SIZE_LADDER, LABEL_ZOOM_LADDER, LINK_DISTANCE_RANGE, LINK_STRENGTH_RANGE, NODE_SIZE_LADDER, REGION_BITMAP_OPTIONS, REGION_BLUR_LADDER, fromLadderPosition, snapToLadder, toLadderPosition, REGION_DILATION_LADDER, REGION_EDGE_ALPHA_LADDER, VELOCITY_DECAY_RANGE, fromOffSliderPosition, fromSliderPosition, labelZoomDisplay, sanitizeRenderSettings, toOffSliderPosition, toSliderPosition, } from "@/src/core"; /** The uniform node look (no state classes — canvas wants hex, not oklch): * the accent color, until the state model lands. */ const NODE_COLOR_TOKEN = "--primary"; /** Per-device graph camera persistence (zoom+pan view state). v1 in the * key: the stored shape is the renderer's d3 transform {k, x, y}. */ const CAMERA_STORAGE_KEY = "lh.graph.camera.v1"; // ---- Render settings (the settings-panel controls) ------------------------ // // The shape, defaults, sanitizer, and slider mappings live in // src/core/render-settings.ts (shared with the server store + tests). type RenderSettings = GraphRenderSettings; /** * Pure filter predicate for the search panel: a node is visible when the * query is empty or matches its name or statement (case-insensitive). * Exported for tests; the visibility feed just consumes its verdict. */ export function isNodeVisible(node: Pick, query: string): boolean { const q = query.trim().toLowerCase(); if (!q) return true; return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q); } /** In-place sessions patch for a live rename (rename-shuffle fix, * 2026-09-23). The OLD path built a fresh snapshot object, which * flowed through the [snapshot]-keyed setData effect → geometry * rebuild → last-fetched ledger became authoritative + pending drag * commit cancelled → the graph visibly rewound whenever display had * drifted. This seam NEVER changes object identity: same snapshot * reference, same clusters array — so the data feed cannot re-fire. * The display refresh rides a separate tick state, and the cluster * label itself goes through the renderer's label-only channel * (setClusterLabels). Exported for tests; pure display state only — * the server derives the same join on its next read. */ export function renameSessionsPatch(snapshot: Pick, id: string, title: string): void { snapshot.sessions[id] = title; } /** Seed positions for nodes the ledger doesn't know yet (hygiene, * 2026-09-23 fix wave): they used to fall through to a ?? 0 and dump * at the origin. Place them on a deterministic ring around the known * positions' centroid — angle hashed from the id (FNV-1a), golden-angle * spiral offset so even hash collisions spread. Stable across * remounts, far enough off the mass to see, and they relax with the * pack on the next drag. In-place on the provided map; pure otherwise. */ export function seedMissingNodePositions( nodes: readonly { id: string }[], positions: Map, ): Map { if (positions.size === 0 || nodes.every((n) => positions.has(n.id))) return positions; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const p of positions.values()) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; const radius = Math.max(maxX - minX, maxY - minY, 40) * 0.7 + 20; const missing = nodes.filter((n) => !positions.has(n.id)); missing.forEach((n, i) => { // FNV-1a hash → [0,1) → angle; the golden-angle spiral offset // both spreads collisions and staggers long paths deterministically. let h = 0x811c9dc5; for (let c = 0; c < n.id.length; c++) { h ^= n.id.charCodeAt(c); h = (h * 0x01000193) >>> 0; } const angle = ((h / 0xffffffff) * 2 * Math.PI + i * 2.39996323) % (2 * Math.PI); positions.set(n.id, { x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius, }); }); return positions; } // ---- Color helpers (canvas wants hex, not oklch) --------------------------- /** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */ function cssColorToHex(css: string): string { const canvas = document.createElement("canvas"); canvas.width = 1; canvas.height = 1; const ctx = canvas.getContext("2d"); if (!ctx) return "#888888"; ctx.fillStyle = css; ctx.fillRect(0, 0, 1, 1); const { data } = ctx.getImageData(0, 0, 1, 1); return `#${[data[0]!, data[1]!, data[2]!] .map((v) => v.toString(16).padStart(2, "0")) .join("")}`; } function tokenHex(cssVar: string): string { const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim(); return value ? cssColorToHex(value) : "#888888"; } // ---- Component ------------------------------------------------------------ interface Selection { node: GraphNode; /** The sources that touched this node: chat ids (deep-linkable sessions) * and notes-source ids ("src_…" — imported files, static entries). */ sourceIds: string[]; /** Node position in container pixels (at click time; camera moves * dismiss the card, so it never needs re-anchoring). */ anchor: { x: number; y: number }; } /** One labeled range row for the rendering-settings panel. */ /** Compact size display for the log sliders: "2", "1.6", "0.75". */ function sizeDisplay(v: number): string { return `${v >= 1 ? Math.round(v * 10) / 10 : Math.round(v * 100) / 100}`; } function SettingSlider({ label, min, max, step, value, display, onChange, disabled, }: { label: string; min: number; max: number; step: number; value: number; /** Value display override (e.g. "1.5×" for zoom) — defaults to the raw number. */ display?: string; onChange: (v: number) => void; /** Dependency graying (user 2026-09-17): children dim + lock when * their parent toggle/mode is off — consistently across sections. */ disabled?: boolean; }) { return ( ); } /** Compact token display: 45231 → "45.2k"; absent → "—". */ function fmtTokens(n: number | undefined): string { if (n === undefined) return "—"; if (n < 1000) return String(Math.round(n)); if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; return `${(n / 1_000_000).toFixed(2)}M`; } /** Relative time for run rows: "now", "12m", "3h", "2d". */ function timeAgo(startedAt: number): string { const mins = Math.round((Date.now() - startedAt) / 60_000); if (mins < 1) return "now"; if (mins < 60) return `${mins}m`; const hours = Math.round(mins / 60); if (hours < 24) return `${hours}h`; return `${Math.round(hours / 24)}d`; } /** Run/stage cost at 3 decimals (pipeline pennies are real money at this * scale); headline totals use 2. Null (unpriced model) renders "—". */ function fmtRunCost(dollars: number | null): string { return dollars === null ? "—" : `$${dollars.toFixed(3)}`; } /** * The cost figures the UI DISPLAYS (owner refinement round, 2026-09-24): * extraction + audit only. Cluster naming is dead — labels are session * titles / file names — so the core's naming fold (and its historical * spend) is deliberately not shown anywhere: the pill and the panel must * agree, and both read THIS, never GraphCostSummary.totalDollars. * (The core fold's naming stage was then culled outright — 2026-09-24 — * so the recent-runs list carries extraction runs only.) */ export function displayCostTotals(costs: Pick): { totalDollars: number; unpricedRuns: number; } { // The two live stages (the spine came and went on one day, // 2026-09-30 — digest-aware extraction replaced it). return { totalDollars: costs.stages.extraction.dollars + costs.stages.audit.dollars, unpricedRuns: costs.stages.extraction.unpricedRuns + costs.stages.audit.unpricedRuns, }; } /** * The graph-pipeline cost control (2026-09-20 placement wave): a bare * cumulative-dollars number — no pill chrome (owner: "just a number"), * hover-highlighted, click toggles the breakdown panel. Same dismiss * pattern as the settings gear: outside click or Escape closes. The * panel is viewport-capped + scrollable like the coverage dropdown. */ function GraphCostControl({ costs }: { costs: GraphCostSummary }) { const [open, setOpen] = useState(false); // Click anywhere outside (or Escape) closes the panel — the root // wraps button + panel, so the toggle itself keeps working. const rootRef = useRef(null); usePopoverDismiss(rootRef, open, () => setOpen(false)); return (
{open && (
)}
); } /** * The cost control's expanded panel: stage totals (extraction / audit — * dollars headline, tokens secondary) + the recent-runs list. * Presentational: the fold happens server-side (core/cost-summary.ts); * dollars are inferred read-time, never stored. Cluster naming is GONE * from the panel (owner refinement round, 2026-09-24): labels are session * titles / file names now, the namer is dead, and its historical spend is * not tracked anymore — the headline sums the displayed stages only and * naming-kind runs never reach the list. */ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) { const stageRows: [string, GraphCostSummary["stages"]["extraction"]][] = [ ["Extraction", costs.stages.extraction], ["Audit", costs.stages.audit], ]; // The displayed figures come from displayCostTotals (naming excluded — // the pill and this panel must agree). const { totalDollars, unpricedRuns } = displayCostTotals(costs); return (
Graph pipeline costs ${totalDollars.toFixed(2)} {unpricedRuns > 0 && ( · {unpricedRuns} unpriced )}
{stageRows.map(([label, stage]) => (
{label} {fmtRunCost(stage.runs === 0 || (stage.unpricedRuns > 0 && stage.dollars === 0) ? null : stage.dollars)} {stage.unpricedRuns > 0 && stage.dollars > 0 && ( +{stage.unpricedRuns} unpriced )} {" "} · {fmtTokens(stage.tokens.inputTokens)} in / {fmtTokens(stage.tokens.outputTokens)} out
))}
{costs.runs.map((run) => (
{timeAgo(run.startedAt)}{" "} {run.title} {run.merged && ( {" "} +{run.merged.nodes}n )} {fmtRunCost(run.dollars)}
))}
); } /** * The rendering-settings gear + its collapsible panel (owner, graph-view * round 2): its own container at the top right, separate from the search * box and the import button — clicking the gear expands/collapses the * panel, which drops below the button, right-aligned with it. */ function GraphSettingsControl({ settings, changeSettings, }: { settings: GraphRenderSettings; changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void; }) { const [open, setOpen] = useState(false); // Click anywhere outside (or Escape) closes the panel (2026-09-17) — // the root wraps button + panel, so the toggle itself keeps working. const rootRef = useRef(null); usePopoverDismiss(rootRef, open, () => setOpen(false)); const regionTuning = settings.regions; const physicsTuning = settings.physics; return (
{open && ( // Viewport-capped + internally scrollable — the coverage // dropdown's exact pattern (owner, 2026-09-17): max-h keeps a // floor of free space above the window bottom at any height, // and the overflow-y-auto column (which also zeroes the flex // item's min-height) scrolls instead of stretching the page. // w-72 + inner pr (owner, 2026-09-20): the scrollbar used to // press against the content — the panel widens so the scroll // gutter gets real air. Spacing concentric: content -> 10px // (pr-2.5) -> scrollbar -> 10px (p-2.5) -> panel edge. The // scrollbar itself does NOT move: it stays pinned at the same // distance from the panel's right border.
{/* Size/zoom/region sliders: LADDER-mapped 0-100, step 10 (11 stops, every DEFAULT dead-center at 50, every value a nice number — src/core/render-settings.ts). Physics keeps the fine-grained log sliders (step 5). */} changeSettings((cur) => ({ ...cur, nodeSize: fromLadderPosition(p, NODE_SIZE_LADDER) }))} /> changeSettings((cur) => ({ ...cur, edgeWidth: fromLadderPosition(p, EDGE_WIDTH_LADDER) }))} /> changeSettings((cur) => ({ ...cur, arrowLength: fromLadderPosition(p, ARROW_LENGTH_LADDER) }))} /> changeSettings((cur) => ({ ...cur, labelSize: fromLadderPosition(p, LABEL_SIZE_LADDER) }))} /> {/* Display is absolute (ratio / 0.5): "8×" is the tuned default, dead-center. */} changeSettings((cur) => ({ ...cur, labelZoom: fromLadderPosition(p, LABEL_ZOOM_LADDER) }))} /> {/* Regions: the blurred cluster tints. Sliders + a fixed-set dropdown for the bitmap resolution (it's a discrete choice, not a free number). Server-persisted like the sizes; every range end was sweep-validated on the real graph. The enable switch is a COMPUTE switch: off skips the whole build (splat/blur/readback/blit), not just the paint. */}
Regions changeSettings((cur) => ({ ...cur, regions: { ...cur.regions, dilation: fromLadderPosition(p, REGION_DILATION_LADDER) }, }))} /> changeSettings((cur) => ({ ...cur, regions: { ...cur.regions, blur: fromLadderPosition(p, REGION_BLUR_LADDER) }, })) } /> changeSettings((cur) => ({ ...cur, regions: { ...cur.regions, edgeAlpha: fromLadderPosition(p, REGION_EDGE_ALPHA_LADDER) }, }))} /> {/* Cluster labels moved here 2026-09-16 (user: cluster stuff lives in the regions section). Decoupled from the Background colors switch above — labels follow their own clusterLabels toggle. Size is screen px (constant at any zoom, unlike node labels). */} changeSettings((cur) => ({ ...cur, clusterLabelSize: fromLadderPosition(p, CLUSTER_LABEL_SIZE_LADDER), }))} />
{/* Physics: the live sim's forces. Every value feeds the SAME engine the server settle runs (createGraphSimulation); changing one rebuilds the sim from the current positions — drag mid-settle re-anneals, pins survive. Sliders over sweep-validated ranges (src/core/render-settings.ts): range ends and all four corners settle cleanly — no NaN, no explosion, no non-settle. Repulsion is displayed as the negative it is; cluster pull's far left = off. Order (user 2026-09-17): general forces first; depth anchor + its gray-out-able controls last. */}
Physics changeSettings((cur) => ({ ...cur, physics: { ...cur.physics, chargeStrength: -fromSliderPosition(p, -CHARGE_RANGE.max, -CHARGE_RANGE.min) }, }))} /> changeSettings((cur) => ({ ...cur, physics: { ...cur.physics, linkStrength: fromSliderPosition(p, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max) }, }))} /> changeSettings((cur) => ({ ...cur, physics: { ...cur.physics, linkDistance: fromSliderPosition(p, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max) }, }))} /> changeSettings((cur) => ({ ...cur, physics: { ...cur.physics, gravity: fromSliderPosition(p, GRAVITY_RANGE.min, GRAVITY_RANGE.max) }, }))} /> changeSettings((cur) => ({ ...cur, physics: { ...cur.physics, clusterPull: fromOffSliderPosition(p, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) }, }))} /> changeSettings((cur) => ({ ...cur, physics: { ...cur.physics, velocityDecay: fromSliderPosition(p, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max) }, }))} /> {/* Depth anchor LAST (user 2026-09-17): general forces first, then the mode + its conditional controls at the very bottom — the grayed group reads as a self-contained sub-section. */} changeSettings((cur) => ({ ...cur, physics: { ...cur.physics, depthStrength: fromSliderPosition(p, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max) }, }))} /> changeSettings((cur) => ({ ...cur, physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) }, }))} /> changeSettings((cur) => ({ ...cur, physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) }, }))} />
)}
); } /** The top-chrome actions row's body (empty-graph chrome wave, owner * 2026-09-25): Import Notes renders ALWAYS (graph enabled) — it's the * one action that works on an empty graph, the entry point itself; * search + settings render only when the snapshot has nodes (dead * controls are lies — the honesty principle). The two gated controls * fade in on their mount (graph-chrome-fade-in, globals.css — the * appear-once transition as the first nodes land). Exported pure for * the empty/populated SSR pins (the DeleteNodeBody pattern). */ export function GraphChromeRow({ isEmpty, query, onQuery, noMatch, importing, searchInputRef, fileInputRef, onImportFiles, settings, changeSettings, onOpenChains, }: { isEmpty: boolean; query: string; onQuery: (v: string) => void; /** The search panel's "No matching nodes." note (computed by the * wrapper — it owns visibleIds + the snapshot). */ noMatch: boolean; importing: boolean; searchInputRef: React.RefObject; fileInputRef: React.RefObject; onImportFiles: (files: FileList | null) => void; settings: GraphRenderSettings; changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void; /** Opens the chain table overlay (the chains-overlay plan, * 2026-09-29). */ onOpenChains: () => void; }) { return ( <> {!isEmpty && (
onQuery(e.target.value)} placeholder="Search nodes…" aria-label="Search nodes" spellCheck={false} className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" />
{noMatch && (
No matching nodes.
)}
)} {/* The chain table (chains overlay, owner plan 2026-09-29). Own container (36px band), between the search and Import. */} {!isEmpty && (
)} {/* Import notes — its own container (36px, header band); ALWAYS rendered (the empty-graph ruling above — the entry point). */}
{ onImportFiles(e.target.files); e.target.value = ""; // allow re-picking the same file }} />
{!isEmpty && ( // Rendering settings — own component; the gear toggles it. The // gear sits at the far right (owner, round 3) so its panel drops // at the very right edge, over less of the graph. The fade wrapper // is a plain shrink-0 flex child — the panel itself positions // within GraphSettingsControl's own relative root, unaffected.
)} ); } export function GraphView() { const containerRef = useRef(null); const canvasRef = useRef(null); const cardRef = useRef(null); const [snapshot, setSnapshot] = useState(null); const [selected, setSelected] = useState(null); // The latest snapshot for the renderer's click callbacks (they are // constructed once, so they read through this ref, never a closure). const snapshotRef = useRef(null); snapshotRef.current = snapshot; // Search panel state: the query text + the settings section toggle. const [query, setQuery] = useState(""); // ⌘F (shortcut wave C): the find event focuses the node search — // surface-scoped, dispatched as lh:kbd-find (only this page listens). const searchInputRef = useRef(null); useEffect(() => { const onFind = () => searchInputRef.current?.focus(); window.addEventListener("lh:kbd-find", onFind); return () => window.removeEventListener("lh:kbd-find", onFind); }, []); // Render settings: SERVER-persisted (data/render-settings.json via // /api/graph/render-settings) — sizes AND the region/physics sections // (user-facing since 2026-09-13; the dev-session era is over). Loaded // once after mount; every user change saves back, debounced. Defaults // render until the load resolves — a fraction of a second at worst. const [settings, setSettings] = useState(DEFAULT_RENDER_SETTINGS); // The two nested sections, unpacked for the renderer feed effects // below (same names the old session state had). const regionTuning = settings.regions; const physicsTuning = settings.physics; const settingsRef = useRef(settings); settingsRef.current = settings; // True once the user has moved a control this mount: the server copy // must never clobber a (possibly newer) local change. const userTouched = useRef(false); // True once the initial GET settled — saves are held back until then, // so merely mounting never PUTs defaults over the server copy. const serverLoaded = useRef(false); const savePending = useRef(false); const changeSettings = (update: (cur: RenderSettings) => RenderSettings) => { userTouched.current = true; setSettings(update); }; const saveToServer = (s: RenderSettings) => { fetch("/api/graph/render-settings", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(s), }).catch((err) => console.error("[graph] settings save failed:", err)); }; useEffect(() => { fetch("/api/graph/render-settings") .then((res) => (res.ok ? (res.json() as Promise) : null)) .then((raw) => { // Apply only if the user hasn't already moved a slider — // if they outran the network, their change wins. if (raw !== null && !userTouched.current) setSettings(sanitizeRenderSettings(raw)); }) .catch(() => { // server unreachable → in-memory defaults keep working }) .finally(() => { serverLoaded.current = true; // A change made during the fetch was held back — send now. if (savePending.current) { savePending.current = false; saveToServer(settingsRef.current); } }); }, []); useEffect(() => { if (!userTouched.current) return; // mount-run / server-apply: no save if (!serverLoaded.current) { savePending.current = true; // hold until the GET settles return; } const id = setTimeout(() => saveToServer(settings), 400); return () => clearTimeout(id); }, [settings]); // The renderer instance lives across snapshot refetches and settings // changes (its camera is never reset by either); `posRef` carries the // frozen layout out of the layout effect. const rendererRef = useRef(null); const posRef = useRef | null>(null); // The id set that survives the current search — consumed by the // visibility feed and the "no matches" hint. const visibleIds = useMemo(() => { const set = new Set(); if (snapshot) for (const node of snapshot.nodes) { if (isNodeVisible(node, query)) set.add(node.id); } return set; }, [snapshot, query]); // Fetch the derived graph. Re-runs when a background pipeline run // finishes (refreshTick) — its merges just landed in the ledger. const [refreshTick, setRefreshTick] = useState(0); // The chain table overlay (chains-overlay plan, 2026-09-29) — // component-local: a browsing surface, not graph state. const [chainsOpen, setChainsOpen] = useState(false); useEffect(() => { let cancelled = false; fetch("/api/graph") .then((res) => res.json() as Promise) .then((data) => { if (!cancelled) setSnapshot(data); }) .catch((err) => console.error("[graph] fetch failed:", err)); return () => { cancelled = true; }; }, [refreshTick]); // Notes import (the format-agnostic front door): file picker → POST // /api/graph/import; the runs surface in the status chips below, and the // graph refetches when they finish (the runs-empty transition). const fileInputRef = useRef(null); const [importing, setImporting] = useState(false); // The dock's start-site nudge (accuracy wave, 2026-09-19): the import // POST awaits the WHOLE pipeline server-side, so the run registers long // before the response — nudge at click and again at +600ms to catch the // start; from its first setPhase the run announces itself on the events // channel (S5), and the dock rides those announcements thereafter. const refreshActivity = useActivity().refresh; const onImportFiles = (files: FileList | null) => { if (!files || files.length === 0) return; setImporting(true); refreshActivity(); setTimeout(() => refreshActivity(), 600); const form = new FormData(); for (const file of Array.from(files)) form.append("files", file); fetch("/api/graph/import", { method: "POST", body: form }) .then(async (res) => { const body = (await res.json()) as { results: { filePath: string; ok: boolean }[] }; const failed = body.results.filter((r) => !r.ok); if (failed.length > 0) console.error("notes import failed for:", failed); }) .catch((err) => console.error("import failed:", err)) .finally(() => setImporting(false)); }; // Background pipeline status: read /api/graph/status; whenever a run // LEAVES the feed (finished / aborted / failed), refetch the graph. // A run only leaves the registry after its merge + settle + usage // persist complete, so disappearance ⇒ the graph change is already on // disk. The old some→none transition batched PARALLEL runs into one // reveal (owner 2026-09-28): with serialized audits, the first run's // merge is its own graph change and must surface immediately, not wait // for the whole feed to drain. A redundant fetch on aborted runs is // free — the same doctrine as the deletion-refresh path below. // Tracked by ROW id (the registry JOB key — source id, or "src#i" for // chunk jobs), NOT chatId: chatId-scoping re-batched per-chunk jobs // into one end-of-import reveal (the exact bug this rule exists to // prevent — a chunk's row leaving IS its merge landing). const [pipelineRuns, setPipelineRuns] = useState<{ id: string; chatId: string; title: string; phase: string; startedAt: number }[]>([]); const rowIdsRef = useRef>(new Set()); const readPipelineStatus = useCallback(() => { fetch("/api/graph/status") .then((res) => res.json() as Promise<{ runs: typeof pipelineRuns }>) .then(({ runs }) => { setPipelineRuns(runs); const ids = new Set(runs.map((r) => r.id)); if ([...rowIdsRef.current].some((id) => !ids.has(id))) setRefreshTick((t) => t + 1); rowIdsRef.current = ids; }) .catch(() => {}); // status is cosmetic — never surface read errors }, []); // PUSH (S7, 2026-10-02): the standing 2s poll is RETIRED — the // extraction registry announces every phase transition and every // finish (activity-changed), so each read lands exactly when truth // moved. The per-chunk reveal semantics are PRESERVED event-for-event: // setPhase + finishExtraction fire per part, so a chunk's row leaving // is still its own, immediately-visible merge landing. Debounced (the // S4 law); resync heals a reconnect that ate events. const statusDebounceRef = useRef | undefined>(undefined); useServerEvent("activity-changed", () => { if (statusDebounceRef.current) clearTimeout(statusDebounceRef.current); statusDebounceRef.current = setTimeout(() => { statusDebounceRef.current = undefined; readPipelineStatus(); }, 250); }); useServerResync(() => readPipelineStatus()); useEffect(() => { readPipelineStatus(); return () => { if (statusDebounceRef.current) clearTimeout(statusDebounceRef.current); }; }, [readPipelineStatus]); // Graph pipeline costs (2026-09 cost metrics wave): the cumulative fold // of the usage channels (chat + notes extraction runs; the dead namer's // channel is not displayed anymore, owner 2026-09-24) // behind the bottom-left cost pill. Fetched once on mount and refetched // when a pipeline run finishes (running -> none) so fresh spend lands // without a manual reload. Best-effort: a fetch failure never blocks // the view — the pill just stays on its last numbers. const [costs, setCosts] = useState(null); const refreshCosts = useCallback(() => { fetch("/api/graph/costs") .then((res) => res.json() as Promise) .then(setCosts) .catch(() => {}); }, []); useEffect(() => { refreshCosts(); }, [refreshCosts]); // The same disappearance rule as the snapshot refresh (owner // 2026-09-28): usage persists per JOB (per-chunk jobs), so costs refresh // per job row — not only when the whole feed drains. const prevRunIds = useRef(new Set()); useEffect(() => { const gone = [...prevRunIds.current].some((id) => !pipelineRuns.some((r) => r.id === id)); if (gone && prevRunIds.current.size > 0) refreshCosts(); prevRunIds.current = new Set(pipelineRuns.map((r) => r.id)); }, [pipelineRuns, refreshCosts]); /* Deletion refresh (wave 6 chunk A): the DELETE /api/chats/:id response carries the new graphRevision, and the sidebar (which owns the delete flow) announces it as lh:graph-revision. The status poll above can't see a deletion — no pipeline run ever disappears from the feed for it — so this event is the one seam. Refetch the snapshot and the costs on every announcement: deletions are rare, a redundant fetch is free. */ useEffect(() => { const onRevision = () => { setRefreshTick((t) => t + 1); refreshCosts(); }; window.addEventListener("lh:graph-revision", onRevision); return () => window.removeEventListener("lh:graph-revision", onRevision); }, [refreshCosts]); /* Live rename labels (rename-shuffle fix, 2026-09-23): a session rename anywhere (sidebar inline row, chat header) announces itself as lh:chat-renamed {id, title} — and the birth-session partition means that title IS the cluster label. The label update goes through the renderer's label-only channel (setClusterLabels): NO new snapshot object, NO setData, NO geometry rebuild — so the position ledger can't assert itself and a pending drag commit survives. The sessions map (the detail card's provenance line) patches in place; a tick bump re-renders the view so an open detail card reads the fresh title. Unlabeled clusters keep their hull-only silence — the null-label guard lives in the channel (applyClusterLabels): a rename can't reach a gone record. */ const [, setLabelTick] = useState(0); useEffect(() => { const onRenamed = (e: Event) => { const { id, title } = (e as CustomEvent<{ id?: string; title?: string }>).detail ?? {}; if (typeof id !== "string" || typeof title !== "string" || id === "" || title === "") return; const snap = snapshotRef.current; if (snap != null) renameSessionsPatch(snap, id, title); rendererRef.current?.setClusterLabels(new Map([[id, title]])); setLabelTick((t) => t + 1); }; window.addEventListener("lh:chat-renamed", onRenamed); return () => window.removeEventListener("lh:chat-renamed", onRenamed); }, []); // The node context menu (node interlude): right-click a node → Delete. // The anchor is a zero-size div positioned at the pointer inside the // canvas container — MenuShell's placement machine measures it like // any element. Camera moves close the menu (it would float stale); // the dismissal contract is the sidebar's (wave 6). const [ctxNode, setCtxNode] = useState<{ id: string; name: string; x: number; y: number } | null>(null); const ctxAnchorRef = useRef(null); const [deletingNode, setDeletingNode] = useState<{ id: string; name: string } | null>(null); useEffect(() => { if (ctxNode === null) return; const onPointerDown = (e: PointerEvent) => { const target = e.target as Node; if (target instanceof Element && target.closest("[data-lh-menu]")) return; if (ctxAnchorRef.current?.contains(target)) return; setCtxNode(null); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); setCtxNode(null); } }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("keydown", onKeyDown); }; }, [ctxNode]); // Layout (Option A): the positions ledger (written by the server's // incremental settle after every merge) IS the layout — the renderer // receives it as frozen data. The local one-shot forceAtlas2 pass runs // ONLY as a fallback when the ledger is absent (a graph never merged // through the pipeline, e.g. tests or a fresh install). useEffect(() => { if (!snapshot || snapshot.nodes.length === 0) return; // The ledger path: every positioned node uses its resting position; // nodes the ledger doesn't know yet (raced mid-settle) fall through // to seeded scatter below. const ledger = snapshot.positions; if (ledger) { const positions = new Map(); for (const node of snapshot.nodes) { const p = ledger[node.id]; if (p) positions.set(node.id, { x: p.x, y: p.y }); } if (positions.size > 0) { // Hygiene (2026-09-23 fix wave): nodes the ledger doesn't know // yet (raced mid-settle) get seeded ring positions instead of // the old ?? 0 origin dump. posRef.current = seedMissingNodePositions(snapshot.nodes, positions); return () => { posRef.current = null; }; } } // Bootstrap fallback: the ledger is absent (fresh graph) — run // the SAME engine the server settle uses (d3-force via // incrementalLayout, seeded deterministic) and freeze the result. // Deterministic for a given node set across remounts. posRef.current = incrementalLayout(snapshot.nodes, snapshot.edges, new Map()); return () => { posRef.current = null; }; }, [snapshot]); // Construct the renderer once the canvas exists; destroy when it goes // away (the empty-graph placeholder replaces it). The callbacks are // created once and read through refs, so they never go stale. const isEmpty = snapshot === null || snapshot.nodes.length === 0; useEffect(() => { if (isEmpty) return; const canvas = canvasRef.current; if (!canvas) return; // Persisted camera (2026-09-15): the graph reopens exactly where the // user left it — zoom AND pan, in localStorage (per-device view // state, deliberately NOT the server settings ledger). Absent or // corrupt → undefined → the renderer fits on first open, ever. let initialCamera: { k: number; x: number; y: number } | null = null; try { const stored = localStorage.getItem(CAMERA_STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored) as { k?: unknown; x?: unknown; y?: unknown }; if ( typeof parsed.k === "number" && Number.isFinite(parsed.k) && parsed.k > 0 && typeof parsed.x === "number" && Number.isFinite(parsed.x) && typeof parsed.y === "number" && Number.isFinite(parsed.y) ) initialCamera = { k: parsed.k, x: parsed.x, y: parsed.y }; } } catch { // Corrupt entry → treat as absent; the renderer fits. } // Debounced camera writes: a pan emits hundreds of transforms per // second; one trailing write per quiet spell is plenty. let cameraTimer: ReturnType | null = null; let lastCamera: { k: number; x: number; y: number } | null = null; const persistCamera = (t: { k: number; x: number; y: number }) => { lastCamera = t; if (cameraTimer) clearTimeout(cameraTimer); cameraTimer = setTimeout(() => { cameraTimer = null; try { localStorage.setItem(CAMERA_STORAGE_KEY, JSON.stringify(t)); } catch { // Storage full/blocked: camera persistence is best-effort. } }, 250); }; const renderer = new GraphCanvasRenderer( canvas, { onSelect: (nodeId, pos) => { const snap = snapshotRef.current; const node = snap?.nodes.find((n) => n.id === nodeId); if (!snap || !node) return; setSelected({ node, sourceIds: snap.nodeSessions[node.id] ?? [], anchor: pos, }); }, onStageClick: () => setSelected(null), // Right-click → the node context menu (node interlude). Null // (empty stage) closes a stale menu; a pick opens one anchored // at the pointer. The name comes from the snapshot — what the // canvas shows is what the menu and dialog speak. onContext: (nodeId, pos) => { if (nodeId === null) { setCtxNode(null); return; } const node = snapshotRef.current?.nodes.find((n) => n.id === nodeId); if (!node) return; setCtxNode({ id: node.id, name: node.name, x: pos.x, y: pos.y }); }, // Any camera move detaches the card from its node — close // it, and (debounced) remember where the camera stands. The // context menu closes with it: a menu floating over a moved // graph is anchored to nothing. onCameraMove: (t) => { setSelected(null); setCtxNode(null); persistCamera(t); }, // Live physics settled: persist the new rest state. Fire-and- // forget — the next snapshot fetch carries the ledger anyway. onCommit: (positions) => { void fetch("/api/graph/positions", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(positions), }).catch((err) => console.error("[graph] positions commit failed", err)); }, }, initialCamera, ); rendererRef.current = renderer; return () => { rendererRef.current = null; // Flush a pending camera write so an unmount <250ms after a // move still persists the user's last standpoint. if (cameraTimer && lastCamera) { clearTimeout(cameraTimer); cameraTimer = null; try { localStorage.setItem(CAMERA_STORAGE_KEY, JSON.stringify(lastCamera)); } catch { // best-effort } } renderer.destroy(); }; }, [isEmpty]); // Feed #1 — data: positions + edges + clusters + resolved colors. Runs // after the layout effect (same commit, declaration order) and after // the construct effect, so the renderer exists and positions are fresh. useEffect(() => { const renderer = rendererRef.current; const positions = posRef.current; if (!renderer || !snapshot || snapshot.nodes.length === 0 || !positions) return; renderer.setData({ nodes: snapshot.nodes.map((n) => ({ id: n.id, x: positions.get(n.id)?.x ?? 0, y: positions.get(n.id)?.y ?? 0, label: n.name, })), edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to })), logicalEdges: snapshot.logicalEdges.map((e) => ({ from: e.from, to: e.to })), impLinks: snapshot.impLinks, clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })), colors: { node: tokenHex(NODE_COLOR_TOKEN), edge: tokenHex("--ring"), label: tokenHex("--muted-foreground"), hoverText: tokenHex("--foreground"), }, // Match the app typography (Geist via the body's stack). fontFamily: getComputedStyle(document.body).fontFamily, }); }, [snapshot, isEmpty]); // Feed #2 — settings: just marks the frame dirty. No rebuild, no // re-layout, no camera touch (the whole point of the new renderer). const deferredSettings = useDeferredValue(settings); useEffect(() => { rendererRef.current?.setSettings(deferredSettings); }, [deferredSettings, isEmpty]); // Feed #3 — visibility: hidden nodes drop out of rendering, picking, // hover, AND labels; edges drop when either endpoint is hidden. useEffect(() => { rendererRef.current?.setVisible(query.trim() ? visibleIds : null); // The open detail card cannot outlive its node's visibility. setSelected((sel) => (sel && visibleIds.has(sel.node.id) ? sel : null)); }, [visibleIds, query]); // Feed #4 — region tuning. `isEmpty` in the deps (same as Feed #2): // the settings fetch resolves BEFORE the snapshot creates the renderer, // so the load-time run hits a null renderer and would never re-fire — // the renderer would silently keep the default tuning forever. useEffect(() => { rendererRef.current?.setRegionTuning(regionTuning); }, [regionTuning, isEmpty]); // Feed #5 — physics tuning: rebuilds the live sim in place (drag pins // survive; a mid-settle change re-anneals, the honest response). Same // isEmpty dep as Feed #4 — see there. useEffect(() => { rendererRef.current?.setPhysicsTuning(physicsTuning); }, [physicsTuning, isEmpty]); // Position the detail card: below the clicked node, flipped above near // the bottom edge, clamped horizontally. Imperative (ref + layout effect, // before paint) because the flip needs the card's measured height. useLayoutEffect(() => { const card = cardRef.current; const container = containerRef.current; if (!selected || !card || !container) return; const width = card.offsetWidth; const height = card.offsetHeight; const margin = 8; const gap = 12; const left = Math.min( Math.max(selected.anchor.x - width / 2, margin), Math.max(container.clientWidth - width - margin, margin), ); let top = selected.anchor.y + gap; if (top + height > container.clientHeight - margin) { top = Math.max(selected.anchor.y - gap - height, margin); } card.style.left = `${left}px`; card.style.top = `${top}px`; card.style.visibility = "visible"; }, [selected]); return (
{isEmpty ? ( // The original home placeholder — kept verbatim for the empty graph.
Knowledge graph

Every session you complete will map what you know — and where the edge of your understanding is — right here.

) : (
{/* THE single canvas: every layer (hulls, edges, nodes, both label systems, hover) draws into it, once per frame. */} {/* The context menu's anchor: a zero-size point at the right-click position (canvas-relative == container-relative, the canvas fills this div). */} {ctxNode !== null && (
)}
)} {/* The node context menu (node interlude): Delete is its one action (the same MenuShell discipline as the sidebar's session menu — placement, focus, arrow cycling; the dismissal effect above owns the contract). */} {ctxNode !== null && ( )} {/* The full-death confirm dialog (node interlude) — portaled to body by the component itself, lives here for state locality. */} setDeletingNode(null)} onDeleted={(nodeIds, graphRevision) => { // Close the dialog; close the detail card if it pointed here // (the refetch would close it anyway, but the same frame // should not show a card for a node the user just killed). setDeletingNode(null); if (selected && nodeIds.includes(selected.node.id)) setSelected(null); // The wave-6 chain: every listener refetches on this. Clusters // shrink / unrender below the minimum on the server's read. window.dispatchEvent(new CustomEvent("lh:graph-revision", { detail: { graphRevision } })); }} /> {/* Legend + filters: search box and per-state toggle rows. */} {/* Top-right controls — separate floating containers (owner, graph-view round 2). Row order (2026-09-17): search, coverage, import, gear — coverage sits between search and import; the gear keeps the corner. items-start (not center) so the search container's no-match note can grow downward without shifting its siblings — every control is 36px tall (header band, 2026-09-20), top alignment pins the row. Coverage renders even when the graph is EMPTY — an empty graph with unextracted sessions is exactly when it matters (extract → the graph gains nodes). lh-no-drag (2026-09-17): home.tsx's floating drag strip underlays this row — Electron's drag mask ignores z-index, so without an explicit carve-out the controls were unclickable in the desktop app (owner report). The old flex-wrap (narrow-window wave, 2026-09-17) folded the row onto two lines below the sidebar threshold — owner rejected it (2026-09-20): one row always, the search shrinks (see the header-band comment on the column below). */} {/* Header band (owner, 2026-09-20): the controls sit IN the header's visual row — top-2 aligns with the sidebar toggle's band (header py-2, toggle h-9), every control slimmed to 36px. The width cap lives HERE on the absolute element (100% resolves against the full-bleed parent) — the row NEVER wraps (owner rejected the two-row fold); in narrow windows the search is the flexible element and shrinks instead. */}
{/* z-20 (2026-09-20): the row paints above the cost number below it — without this, the later-in-DOM cost control layered over the settings/coverage dropdowns (owner report), still hoverable and clickable on top of them. The row is a stacking context, so its open dropdowns cover the number; clicks land on the panel. */}
{/* The actions row (empty-graph chrome wave, owner 2026-09-25): Import Notes always — the empty graph's entry point; search + settings only when nodes exist, fading in on their mount. */} setChainsOpen(true)} />
{/* Top-right tail (owner, 2026-09-20): the pipeline cost number — just a number, hover-highlighted, dropdown breakdown — then the running-pipeline pills under it. Lives BELOW the actions row so narrow windows can never push it over the composer the way the old bottom-left stack did; the whole top-right cluster is one column, right-aligned. */} {costs && costs.runs.length > 0 && } {pipelineRuns.map((run) => (
{run.phase} {run.title} {Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s
))}
{selected && !isEmpty && ( )} {/* The chain table overlay (chains-overlay plan, 2026-09-29): ABSOLUTE INSIDE the graph area (the session-overlay idiom — never a body portal) so the app chrome stays sharp and reachable; the overlay itself is read-only toward the graph and owns its selection. LAST CHILD OF THE ROOT, DELIBERATELY (owner, 2026-09-29 night): app-region subtraction resolves by DOM order, not z-index — mounted earlier, the chrome's lh-no-drag column punched dead zones through the overlay's TopBar drag band (over the counts, around the search field). A full-bleed surface covering a drag band must paint AFTER everything it covers. (DeleteNodeDialog is body-portaled — DOM-irrelevant here.) */} setChainsOpen(false)} />
); }