Platform's 50bfcba engine, surfaced. Three pieces: 1. THE RIGHT-CLICK (graph-canvas.ts): a contextmenu listener — always preventDefault (the native menu on a full-bleed custom canvas is never useful) — picks the node under the pointer and hands the canvas-relative position to a new onContext callback (null on empty stage). pickAt already respected the visibility filters: a hidden node cannot be right-clicked, same as it cannot be clicked. The view opens a MenuShell anchored at a zero-size point-div positioned at the pointer — MenuShell's placement machine measures it like any element, so the menu gets the full discipline: placement, focus on open, arrow cycling, honest-fit flip. Delete… is its one action; the dismissal contract is the sidebar's (wave 6): outside-click / Esc (claimed) close, and a camera move closes it too — a menu floating over a moved graph is anchored to nothing. 2. THE CONFIRM (delete-node-dialog.tsx, portaled z-50 like the session-delete dialog whose tone it follows): the pre-flight impact read (GET /api/graph/nodes/[id]) renders the plain facts — link count, quiz-answer count, session-note count (zeros stay visible — hiding a zero fact would understate the blast), and the referencing sessions with display names joined CLIENT-SIDE from the snapshot (chat titles; imported-notes file paths; unknown ids honestly themselves). "This cannot be undone" says the permanence. The 409 extraction_running renders the dock phrasing (EXTRACTION_RETRY_LINE — the server names the extraction, the ACTIVITY DOCK is the surface the user actually watches drain) with a Try-again; every other failure shows the server's line verbatim. The honest 404 (deleted elsewhere meanwhile) states it plainly and its Close rides the same refresh. The pure body is exported separately (the settings-dialog pattern) — SSR pins everything the live dialog shows. 3. SUCCESS: onDeleted dispatches lh:graph-revision with the returned graphRevision — the wave-6 chain refetches snapshot + costs. The detail card closes if it pointed at the dying node (the refetch would close it anyway; the same frame should not show a card for a node the user just killed). Clusters shrink / unrender below the minimum on the server's read — the client just renders the refreshed snapshot. Tests: 867/867 (+8 — the join helper, honest plurals, the 409 phrasing pin, the dialog body at loading/gone/impact/failure states). Live-verified on :3001 against the real engine, scratch data: impact facts match the API, the intercepted 409 shows the dock line and Try-again recovers into a real delete, the three-member cluster died below MIN_CLUSTER_SIZE server-side, the labeled seedplan cluster + its canvas label died with it (cold load draws nothing stale; the in-session refresh refetches and the dying node's label stops drawing), other nodes survived, and the full menu contract holds (empty stage → no menu; Esc, outside-click, camera move all close).
1465 lines
59 KiB
TypeScript
1465 lines
59 KiB
TypeScript
"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) plus the cluster-labels checkbox. 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, 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 { GraphCoverageControl } from "./graph-coverage.tsx";
|
||
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
||
import { useActivity } from "@/components/activity-dock";
|
||
import { MenuShell } from "@/components/menu-shell.tsx";
|
||
import { DeleteNodeDialog } from "@/components/delete-node-dialog.tsx";
|
||
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<GraphNode, "name" | "statement">, query: string): boolean {
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return true;
|
||
return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q);
|
||
}
|
||
|
||
// ---- 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 (
|
||
<label className={`flex flex-col gap-1${disabled ? " opacity-40" : ""}`}>
|
||
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||
{label}
|
||
<span className="tabular-nums">{display ?? value}</span>
|
||
</span>
|
||
<input
|
||
type="range"
|
||
min={min}
|
||
max={max}
|
||
step={step}
|
||
value={value}
|
||
disabled={disabled}
|
||
onChange={(e) => onChange(Number(e.target.value))}
|
||
className="h-1.5 w-full accent-primary"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
/** 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 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<HTMLDivElement>(null);
|
||
usePopoverDismiss(rootRef, open, () => setOpen(false));
|
||
return (
|
||
<div ref={rootRef} className="relative">
|
||
<button
|
||
type="button"
|
||
title="Graph pipeline costs — click for the breakdown"
|
||
aria-expanded={open}
|
||
onClick={() => setOpen((o) => !o)}
|
||
className={`rounded-lg px-2 py-1 text-xs tabular-nums transition-colors hover:bg-accent ${
|
||
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
${costs.totalDollars.toFixed(2)}
|
||
</button>
|
||
{open && (
|
||
<div className="absolute right-0 top-full mt-2 max-h-[60vh] overflow-y-auto">
|
||
<GraphCostPanel costs={costs} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The cost control's expanded panel: stage totals (extraction / audit /
|
||
* cluster-naming — 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.
|
||
*/
|
||
function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
||
const stageRows: [string, GraphCostSummary["stages"]["extraction"]][] = [
|
||
["Extraction", costs.stages.extraction],
|
||
["Audit", costs.stages.audit],
|
||
["Cluster naming", costs.stages.naming],
|
||
];
|
||
return (
|
||
<div className="w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border bg-card/95 p-3 text-xs shadow-lg backdrop-blur">
|
||
<div className="mb-2 flex items-baseline justify-between">
|
||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||
Graph pipeline costs
|
||
</span>
|
||
<span className="text-foreground tabular-nums">
|
||
${costs.totalDollars.toFixed(2)}
|
||
{costs.unpricedRuns > 0 && (
|
||
<span className="text-muted-foreground"> · {costs.unpricedRuns} unpriced</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{stageRows.map(([label, stage]) => (
|
||
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
|
||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||
<span className="min-w-0 text-right tabular-nums">
|
||
{fmtRunCost(stage.runs === 0 || (stage.unpricedRuns > 0 && stage.dollars === 0) ? null : stage.dollars)}
|
||
{stage.unpricedRuns > 0 && stage.dollars > 0 && (
|
||
<span className="text-muted-foreground"> +{stage.unpricedRuns} unpriced</span>
|
||
)}
|
||
<span className="text-muted-foreground">
|
||
{" "}
|
||
· {fmtTokens(stage.tokens.inputTokens)} in / {fmtTokens(stage.tokens.outputTokens)} out
|
||
</span>
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-2 flex flex-col gap-0.5 border-t pt-2">
|
||
{costs.runs.map((run) => (
|
||
<div key={run.startedAt} className="flex items-baseline justify-between gap-2 text-[11px]">
|
||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||
<span className="text-foreground/70">{timeAgo(run.startedAt)}</span>{" "}
|
||
{run.title}
|
||
{run.merged && (
|
||
<span className="text-muted-foreground">
|
||
{" "}
|
||
+{run.merged.nodes}n
|
||
</span>
|
||
)}
|
||
{run.kind === "naming" && (
|
||
<span className="text-muted-foreground"> {run.labels ?? 0} labels</span>
|
||
)}
|
||
</span>
|
||
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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<HTMLDivElement>(null);
|
||
usePopoverDismiss(rootRef, open, () => setOpen(false));
|
||
const regionTuning = settings.regions;
|
||
const physicsTuning = settings.physics;
|
||
return (
|
||
<div ref={rootRef} className="relative">
|
||
<button
|
||
type="button"
|
||
title="Rendering settings"
|
||
aria-expanded={open}
|
||
onClick={() => setOpen((o) => !o)}
|
||
className={`flex size-9 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${
|
||
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
<SettingsIcon className="size-4" />
|
||
</button>
|
||
{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.
|
||
<div className="absolute right-0 top-full mt-2 flex max-h-[60vh] w-72 flex-col rounded-xl border bg-card/85 p-2.5 shadow-lg backdrop-blur">
|
||
<div className="flex flex-col gap-2 overflow-y-auto pr-2.5">
|
||
{/* 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). */}
|
||
<SettingSlider
|
||
label="Node size"
|
||
min={0}
|
||
max={100}
|
||
step={10}
|
||
value={toLadderPosition(settings.nodeSize, NODE_SIZE_LADDER)}
|
||
display={sizeDisplay(settings.nodeSize)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({ ...cur, nodeSize: fromLadderPosition(p, NODE_SIZE_LADDER) }))}
|
||
/>
|
||
<SettingSlider
|
||
label="Edge width"
|
||
min={0}
|
||
max={100}
|
||
step={10}
|
||
value={toLadderPosition(settings.edgeWidth, EDGE_WIDTH_LADDER)}
|
||
display={sizeDisplay(settings.edgeWidth)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({ ...cur, edgeWidth: fromLadderPosition(p, EDGE_WIDTH_LADDER) }))}
|
||
/>
|
||
<SettingSlider
|
||
label="Arrowhead size"
|
||
min={0}
|
||
max={100}
|
||
step={10}
|
||
value={toLadderPosition(settings.arrowLength, ARROW_LENGTH_LADDER)}
|
||
display={sizeDisplay(settings.arrowLength)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({ ...cur, arrowLength: fromLadderPosition(p, ARROW_LENGTH_LADDER) }))}
|
||
/>
|
||
<SettingSlider
|
||
label="Label size"
|
||
min={0}
|
||
max={100}
|
||
step={10}
|
||
value={toLadderPosition(settings.labelSize, LABEL_SIZE_LADDER)}
|
||
display={sizeDisplay(settings.labelSize)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({ ...cur, labelSize: fromLadderPosition(p, LABEL_SIZE_LADDER) }))}
|
||
/>
|
||
{/* Display is absolute (ratio / 0.5): "8×" is the tuned default, dead-center. */}
|
||
<SettingSlider
|
||
label="Labels until zoom"
|
||
min={0}
|
||
max={100}
|
||
step={10}
|
||
value={toLadderPosition(settings.labelZoom, LABEL_ZOOM_LADDER)}
|
||
display={labelZoomDisplay(settings.labelZoom)}
|
||
onChange={(p) =>
|
||
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. */}
|
||
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||
Regions
|
||
</span>
|
||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
|
||
<span>Background colors</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.regions.enabled}
|
||
onChange={(e) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
regions: { ...cur.regions, enabled: e.target.checked },
|
||
}))}
|
||
className="accent-primary"
|
||
/>
|
||
</label>
|
||
<SettingSlider
|
||
label="Dilation"
|
||
disabled={!regionTuning.enabled}
|
||
min={0}
|
||
max={100}
|
||
step={10}
|
||
value={toLadderPosition(regionTuning.dilation, REGION_DILATION_LADDER)}
|
||
display={sizeDisplay(regionTuning.dilation)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
regions: { ...cur.regions, dilation: fromLadderPosition(p, REGION_DILATION_LADDER) },
|
||
}))}
|
||
/>
|
||
<SettingSlider
|
||
label="Blur"
|
||
disabled={!regionTuning.enabled}
|
||
min={0}
|
||
max={100}
|
||
step={10}
|
||
value={toLadderPosition(regionTuning.blur, REGION_BLUR_LADDER)}
|
||
display={sizeDisplay(regionTuning.blur)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
regions: { ...cur.regions, blur: fromLadderPosition(p, REGION_BLUR_LADDER) },
|
||
}))
|
||
}
|
||
/>
|
||
<SettingSlider
|
||
label="Edge alpha"
|
||
disabled={!regionTuning.enabled}
|
||
min={0}
|
||
max={100}
|
||
step={10}
|
||
value={toLadderPosition(regionTuning.edgeAlpha, REGION_EDGE_ALPHA_LADDER)}
|
||
display={sizeDisplay(regionTuning.edgeAlpha)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
regions: { ...cur.regions, edgeAlpha: fromLadderPosition(p, REGION_EDGE_ALPHA_LADDER) },
|
||
}))}
|
||
/>
|
||
<label
|
||
className={`flex items-center justify-between gap-2 text-[11px] text-muted-foreground${!regionTuning.enabled ? " opacity-40" : ""}`}
|
||
>
|
||
<span>Bitmap resolution</span>
|
||
<select
|
||
disabled={!regionTuning.enabled}
|
||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
||
value={regionTuning.bitmap}
|
||
onChange={(e) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
regions: { ...cur.regions, bitmap: Number(e.target.value) },
|
||
}))
|
||
}
|
||
>
|
||
{REGION_BITMAP_OPTIONS.map((o) => (
|
||
<option key={o} value={o}>
|
||
{o}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{/* 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). */}
|
||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
|
||
<span>Cluster labels</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.clusterLabels}
|
||
onChange={(e) => changeSettings((cur) => ({ ...cur, clusterLabels: e.target.checked }))}
|
||
className="accent-primary"
|
||
/>
|
||
</label>
|
||
<SettingSlider
|
||
label="Cluster label size"
|
||
disabled={!settings.clusterLabels}
|
||
min={0}
|
||
max={100}
|
||
step={10}
|
||
value={toLadderPosition(settings.clusterLabelSize, CLUSTER_LABEL_SIZE_LADDER)}
|
||
display={sizeDisplay(settings.clusterLabelSize)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
clusterLabelSize: fromLadderPosition(p, CLUSTER_LABEL_SIZE_LADDER),
|
||
}))}
|
||
/>
|
||
</div>
|
||
{/* 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. */}
|
||
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||
Physics
|
||
</span>
|
||
<SettingSlider
|
||
label="Node repulsion"
|
||
min={0}
|
||
max={100}
|
||
step={5}
|
||
value={toSliderPosition(-physicsTuning.chargeStrength, -CHARGE_RANGE.max, -CHARGE_RANGE.min)}
|
||
display={`-${Math.round(-physicsTuning.chargeStrength)}`}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
physics: { ...cur.physics, chargeStrength: -fromSliderPosition(p, -CHARGE_RANGE.max, -CHARGE_RANGE.min) },
|
||
}))}
|
||
/>
|
||
<SettingSlider
|
||
label="Edge attraction"
|
||
min={0}
|
||
max={100}
|
||
step={5}
|
||
value={toSliderPosition(physicsTuning.linkStrength, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max)}
|
||
display={sizeDisplay(physicsTuning.linkStrength)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
physics: { ...cur.physics, linkStrength: fromSliderPosition(p, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max) },
|
||
}))}
|
||
/>
|
||
<SettingSlider
|
||
label="Edge length"
|
||
min={0}
|
||
max={100}
|
||
step={5}
|
||
value={toSliderPosition(physicsTuning.linkDistance, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max)}
|
||
display={sizeDisplay(physicsTuning.linkDistance)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
physics: { ...cur.physics, linkDistance: fromSliderPosition(p, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max) },
|
||
}))}
|
||
/>
|
||
<SettingSlider
|
||
label="Central pull"
|
||
min={0}
|
||
max={100}
|
||
step={5}
|
||
value={toSliderPosition(physicsTuning.gravity, GRAVITY_RANGE.min, GRAVITY_RANGE.max)}
|
||
display={sizeDisplay(physicsTuning.gravity)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
physics: { ...cur.physics, gravity: fromSliderPosition(p, GRAVITY_RANGE.min, GRAVITY_RANGE.max) },
|
||
}))}
|
||
/>
|
||
<SettingSlider
|
||
label="Cluster pull"
|
||
min={0}
|
||
max={100}
|
||
step={5}
|
||
value={toOffSliderPosition(physicsTuning.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max)}
|
||
display={physicsTuning.clusterPull <= 0 ? "off" : sizeDisplay(physicsTuning.clusterPull)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
physics: { ...cur.physics, clusterPull: fromOffSliderPosition(p, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) },
|
||
}))}
|
||
/>
|
||
<SettingSlider
|
||
label="Friction"
|
||
min={0}
|
||
max={100}
|
||
step={5}
|
||
value={toSliderPosition(physicsTuning.velocityDecay, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max)}
|
||
display={sizeDisplay(physicsTuning.velocityDecay)}
|
||
onChange={(p) =>
|
||
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. */}
|
||
<label className="flex items-center justify-between gap-2 text-xs">
|
||
Depth anchor
|
||
<select
|
||
className="rounded border bg-background px-1 py-0.5 text-xs"
|
||
value={physicsTuning.depthMode}
|
||
onChange={(e) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
physics: { ...cur.physics, depthMode: e.target.value as GraphRenderSettings["physics"]["depthMode"] },
|
||
}))
|
||
}
|
||
>
|
||
<option value="off">Off</option>
|
||
<option value="vertical">Vertical (up)</option>
|
||
<option value="radial">Radial (outward)</option>
|
||
</select>
|
||
</label>
|
||
<SettingSlider
|
||
label="Depth strength"
|
||
disabled={physicsTuning.depthMode === "off"}
|
||
min={0}
|
||
max={100}
|
||
step={5}
|
||
value={toSliderPosition(physicsTuning.depthStrength, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max)}
|
||
display={sizeDisplay(physicsTuning.depthStrength)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
physics: { ...cur.physics, depthStrength: fromSliderPosition(p, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max) },
|
||
}))}
|
||
/>
|
||
<SettingSlider
|
||
label="Depth spacing"
|
||
disabled={physicsTuning.depthMode === "off"}
|
||
min={0}
|
||
max={100}
|
||
step={5}
|
||
value={toSliderPosition(physicsTuning.depthSpacing, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max)}
|
||
display={sizeDisplay(physicsTuning.depthSpacing)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) },
|
||
}))}
|
||
/>
|
||
<SettingSlider
|
||
label="Base ring radius"
|
||
disabled={physicsTuning.depthMode !== "radial"}
|
||
min={0}
|
||
max={100}
|
||
step={5}
|
||
value={toSliderPosition(physicsTuning.depthBaseRadius, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max)}
|
||
display={sizeDisplay(physicsTuning.depthBaseRadius)}
|
||
onChange={(p) =>
|
||
changeSettings((cur) => ({
|
||
...cur,
|
||
physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) },
|
||
}))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function GraphView() {
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
|
||
const [selected, setSelected] = useState<Selection | null>(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<GraphSnapshot | null>(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<HTMLInputElement>(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<GraphRenderSettings>(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<unknown>) : 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<GraphCanvasRenderer | null>(null);
|
||
const posRef = useRef<Map<string, { x: number; y: number }> | 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<string>();
|
||
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);
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
fetch("/api/graph")
|
||
.then((res) => res.json() as Promise<GraphSnapshot>)
|
||
.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<HTMLInputElement>(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; the dock's fast active interval carries the run from there.
|
||
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: poll /api/graph/status; when a run
|
||
// finishes (some -> none), refetch the graph.
|
||
const [pipelineRuns, setPipelineRuns] = useState<{ chatId: string; title: string; phase: string; startedAt: number }[]>([]);
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
let hadRuns = false;
|
||
const poll = () =>
|
||
fetch("/api/graph/status")
|
||
.then((res) => res.json() as Promise<{ runs: typeof pipelineRuns }>)
|
||
.then(({ runs }) => {
|
||
if (cancelled) return;
|
||
setPipelineRuns(runs);
|
||
if (hadRuns && runs.length === 0) setRefreshTick((t) => t + 1);
|
||
hadRuns = runs.length > 0;
|
||
})
|
||
.catch(() => {}); // status is cosmetic — never surface poll errors
|
||
poll();
|
||
const timer = setInterval(poll, 2000);
|
||
return () => {
|
||
cancelled = true;
|
||
clearInterval(timer);
|
||
};
|
||
}, []);
|
||
|
||
// Graph pipeline costs (2026-09 cost metrics wave): the cumulative fold
|
||
// of every usage channel (chat + notes extraction runs, cluster naming)
|
||
// 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<GraphCostSummary | null>(null);
|
||
const refreshCosts = useCallback(() => {
|
||
fetch("/api/graph/costs")
|
||
.then((res) => res.json() as Promise<GraphCostSummary>)
|
||
.then(setCosts)
|
||
.catch(() => {});
|
||
}, []);
|
||
useEffect(() => {
|
||
refreshCosts();
|
||
}, [refreshCosts]);
|
||
const costsHadRunning = useRef(false);
|
||
useEffect(() => {
|
||
if (costsHadRunning.current && pipelineRuns.length === 0) refreshCosts();
|
||
costsHadRunning.current = pipelineRuns.length > 0;
|
||
}, [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 transitions some→none 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 (wave 7 interlude): 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. Patch the snapshot in place: the sessions map
|
||
(the detail card's provenance line) and the one matching cluster.
|
||
No refetch — the server derives the same join on its next read, this
|
||
just keeps the two views from disagreeing until then. Unlabeled
|
||
clusters keep their hull-only silence (a null label is a GONE
|
||
record — a rename can't reach one, and the guard holds the line). */
|
||
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;
|
||
setSnapshot((snap) => {
|
||
if (snap == null) return snap;
|
||
const sessions = { ...snap.sessions, [id]: title };
|
||
const clusters = snap.clusters.map((c) =>
|
||
c.id === id && c.label != null ? { ...c, label: title } : c,
|
||
);
|
||
return { ...snap, sessions, clusters };
|
||
});
|
||
};
|
||
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<HTMLDivElement | null>(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<string, { x: number; y: number }>();
|
||
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) {
|
||
posRef.current = 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<typeof setTimeout> | 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, type: e.type })),
|
||
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 (
|
||
<div className="relative h-full w-full">
|
||
{isEmpty ? (
|
||
// The original home placeholder — kept verbatim for the empty graph.
|
||
<div className="flex h-full w-full items-center justify-center px-4">
|
||
<div className="flex flex-col items-center gap-3 text-center text-muted-foreground">
|
||
<WaypointsIcon className="size-10 opacity-40" strokeWidth={1.25} />
|
||
<div className="text-lg font-medium text-foreground">Knowledge graph</div>
|
||
<p className="max-w-sm text-sm">
|
||
Every session you complete will map what you know — and where the edge of your
|
||
understanding is — right here.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div ref={containerRef} className="relative h-full w-full">
|
||
{/* THE single canvas: every layer (hulls, edges, nodes, both
|
||
label systems, hover) draws into it, once per frame. */}
|
||
<canvas ref={canvasRef} className="block h-full w-full touch-none" />
|
||
{/* 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 && (
|
||
<div
|
||
ref={ctxAnchorRef}
|
||
style={{ position: "absolute", left: ctxNode.x, top: ctxNode.y, width: 0, height: 0 }}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 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 && (
|
||
<MenuShell
|
||
anchorRef={ctxAnchorRef}
|
||
testid="node-context-menu"
|
||
preferredSide="down"
|
||
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl"
|
||
>
|
||
<button
|
||
type="button"
|
||
data-testid="ctx-node-delete"
|
||
onClick={() => {
|
||
setDeletingNode({ id: ctxNode.id, name: ctxNode.name });
|
||
setCtxNode(null);
|
||
}}
|
||
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
|
||
>
|
||
Delete…
|
||
</button>
|
||
</MenuShell>
|
||
)}
|
||
|
||
{/* The full-death confirm dialog (node interlude) — portaled to
|
||
body by the component itself, lives here for state locality. */}
|
||
<DeleteNodeDialog
|
||
node={deletingNode}
|
||
joins={{
|
||
sessions: snapshot?.sessions ?? {},
|
||
fileSources: snapshot?.fileSources ?? {},
|
||
}}
|
||
onClose={() => setDeletingNode(null)}
|
||
onDeleted={(nodeId, 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?.node.id === nodeId) 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. */}
|
||
<div className="lh-no-drag absolute right-2 top-2 z-10 flex max-w-[calc(100%-5.5rem)] flex-col items-end gap-2">
|
||
{/* 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. */}
|
||
<div className="relative z-20 flex min-w-0 items-start gap-2">
|
||
{!isEmpty && (
|
||
<>
|
||
{/* Search — its own container (icon + input + no-match note).
|
||
The header band's flexible element: w-56 normally, shrinks
|
||
(min-w-0) when the column's width cap binds in narrow
|
||
windows — the row's other controls are shrink-0. */}
|
||
<div className="w-56 min-w-0 shrink rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
|
||
<div className="relative">
|
||
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||
<input
|
||
ref={searchInputRef}
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="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"
|
||
/>
|
||
</div>
|
||
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
|
||
<div className="px-1.5 pt-1 text-[11px] text-muted-foreground">
|
||
No matching nodes.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
{/* Extraction coverage — between search and import (2026-09-17);
|
||
always present, even when the graph is EMPTY — an empty graph
|
||
with unextracted sessions is exactly when it matters (extract
|
||
→ the graph gains nodes). The panel re-reads when background
|
||
runs settle (refreshTick). */}
|
||
<div className="shrink-0"><GraphCoverageControl refreshKey={refreshTick} /></div>
|
||
{!isEmpty && (
|
||
<>
|
||
{/* Import notes — its own container (36px, header band). */}
|
||
<div className="shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
accept=".md,.markdown,.txt"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
onImportFiles(e.target.files);
|
||
e.target.value = ""; // allow re-picking the same file
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={importing}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
||
>
|
||
<UploadIcon className="size-3" />
|
||
{importing ? "Importing…" : "Import notes…"}
|
||
</button>
|
||
</div>
|
||
{/* 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. */}
|
||
<GraphSettingsControl settings={settings} changeSettings={changeSettings} />
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* 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 && <GraphCostControl costs={costs} />}
|
||
{pipelineRuns.map((run) => (
|
||
<div
|
||
key={run.chatId}
|
||
className="flex items-center gap-2 rounded-full border bg-card/85 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur"
|
||
>
|
||
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
|
||
<span className="capitalize">{run.phase}</span>
|
||
<span className="max-w-48 truncate text-foreground">{run.title}</span>
|
||
<span className="tabular-nums">{Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{selected && !isEmpty && (
|
||
<div
|
||
ref={cardRef}
|
||
// Positioned imperatively in the layout effect above; hidden
|
||
// until placed so the flip never flashes in the wrong spot.
|
||
style={{ visibility: "hidden", left: 0, top: 0 }}
|
||
// lh-no-drag: the card is placed anywhere on the canvas —
|
||
// when it lands in the top strip it must stay interactive
|
||
// over home.tsx's drag surface (2026-09-17).
|
||
className="lh-no-drag absolute z-10 w-80 max-w-[calc(100%-1rem)] rounded-xl border bg-card p-4 text-left shadow-lg"
|
||
>
|
||
<div className="text-sm font-medium leading-snug">{selected.node.name}</div>
|
||
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
|
||
{selected.sourceIds.length > 0 ? (
|
||
<div className="mt-3 flex flex-col gap-1">
|
||
<div className="text-xs text-muted-foreground">Sessions</div>
|
||
{selected.sourceIds.map((sourceId) => {
|
||
// That source's understanding observation — the
|
||
// extractor's named-word note (confidence history).
|
||
const note = snapshot?.nodeObservations?.[selected.node.id]?.[sourceId];
|
||
// An imported-notes file: a STATIC entry — the path,
|
||
// plain text, never a link (no session to open).
|
||
if (snapshot?.fileSources?.[sourceId] !== undefined) {
|
||
return (
|
||
<div key={sourceId} className="flex items-center gap-1.5">
|
||
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||
<span className="min-w-0 flex-1 truncate px-1 py-1 text-sm text-muted-foreground">
|
||
{snapshot.fileSources[sourceId]}
|
||
</span>
|
||
{note && (
|
||
<span className="max-w-28 shrink-0 truncate whitespace-nowrap rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
|
||
{note}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
// A real session: deep-linkable provenance.
|
||
return (
|
||
<div key={sourceId} className="flex items-center gap-1.5">
|
||
<Link
|
||
href={`/chat/${sourceId}`}
|
||
className="min-w-0 flex-1 truncate rounded-md px-1 py-1 text-sm text-foreground transition-colors hover:bg-accent"
|
||
>
|
||
{snapshot?.sessions[sourceId] ?? sourceId}
|
||
</Link>
|
||
{/* The note is a fixed-scale word (solid/shaky/...) — the
|
||
session name is the main thing; the chip is bounded so
|
||
it can never push or overflow the row. */}
|
||
{note && (
|
||
<span className="max-w-28 shrink-0 truncate whitespace-nowrap rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
|
||
{note}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="mt-3 text-xs text-muted-foreground">No sessions recorded.</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|