Files
learnharness/components/graph-view.tsx
T
amosblomqvist 1c27694dbc Wave 6 chunk A: sidebar context menu (Rename/Delete) + the two-option delete dialog
- Right-click any session row: MenuShell context menu, exactly two actions
  (owner ruling): Rename and Delete… — MenuShell discipline (placement,
  focus, arrow cycling, lh:menu-open announcements), agent-section's
  outside-click/Esc contract with [data-lh-menu] carve-outs.
- Rename is INLINE in the row (owner ruling): select-all entry, Enter/blur
  commits through the existing plumbing — PUT /api/chats/:id then
  lh:chat-renamed. The open chat header now LISTENS for the event too, so
  renaming the active session from the sidebar updates the header in place
  (it only dispatched before).
- Delete opens the two-option dialog (locked design, DELETE_OPTIONS pure
  seam for render pins): "Delete the session only" (preserve-graph — the
  graph keeps every contribution) vs "Also remove its graph traces"
  (delete-nodes — shared topics survive, only-this-session topics die).
  409 turn_in_flight/jobs_running and 503 extraction_winding_down render
  the server's line verbatim with a same-mode Try-again.
- Success: the sidebar patches its snapshot instantly, the active session
  navigates home (chat unmounts — drafts die with it), and lh:graph-revision
  announces the returned revision — the graph view refetches the snapshot
  AND costs (deletion is the one writer the status poll can't see).
- ChatDeleteMode type re-exported through the core barrel (the shell's one
  needed seam from core/graph-deletion.ts).

Gates: tsc clean, 909/909, lint 0, build ok. Live-verified on :3001:
context menu open/Esc, inline rename (row + API + active header), both
delete modes on scratch sessions, simulated 409 + retry-then-succeed,
active-session navigation home, graph+costs refetch deltas on home.
2026-09-23 12:45:12 +03:00

1336 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 {
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]);
// 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),
// Any camera move detaches the card from its node — close
// it, and (debounced) remember where the camera stands.
onCameraMove: (t) => {
setSelected(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="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" />
</div>
)}
{/* 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>
);
}