"use client"; /** * THE CHAIN TABLE (chains overlay; owner refinements round 2, 2026-09-29): * every reasoning chain of the graph in one FULL-BLEED surface — the blur * + translucent background IS the surface, no inner card. The top bar is * THE shared TopBar (standardization ruling: one element, every surface — * h-15, lh-drag with carve-outs, select-none, sidebar reopen, traffic- * light gutter; born from this overlay's hand-rolled bar breaking every * rule at once). The overlay (z-40) covers home's floating header while * open, so the TopBar's own SidebarToggle is the reopen path. * * HIERARCHY (three levels, one click each): CLUSTER (the birth session — * a chat or a notes file; a chain lives where its CONCLUSION was born, * owner ruling) → REASONING CHAIN → PROPOSITION STATEMENTS. Expanding a * chain reveals its members' statement text directly — names live in the * chain line, never twice; a singleton reveals its one statement in one * click. Everything starts collapsed. PROMINENT indentation carries the * structure (owner ruling: chains deep under folders, statements deeper) * — the ONLY lines are the horizontal separators between cluster groups * (vertical column hairlines removed with them, owner ruling: "anything * else is better without them"). * * THE CHAIN LINE: every proposition name is a faint-outlined CHIP * with the LOGIC as the connectives (owner rulings, 2026-09-30): * premises join with the LaTeX LAND (∧) and the conclusion separates * by the LaTeX IMPLICATION (⟹) — `A ∧ B ⟹ C`, never the misleading * `A → B → C`. The glyphs are REAL KaTeX renders (components/ * latex-glyph.tsx — the app's math engine, never SVGs), PROMINENT in * the theme foreground (white), not dim gray. A lonely chip (no * premises) carries no glyph — it reads as a standalone proposition; * "(no premises)" survives only as the row's aria-label. * * SORTING: all three columns sortable (owner ruling — "consistency is * key"): Name (A→Z), Props (largest-first), Created (newest-first); * click toggles asc/desc; a SMALL chevron marks the active header. Sort * orders cluster rows AND chains within. * * SELECTION never shifts layout (owner ruling): a floating bottom bar — * the Google-Photos pattern — appears only when something is selected, * showing the agents' ACTUAL input (chainsForSelection over the flat * node-id selection: a premise without its conclusion counts as the * singleton it becomes) + Clear. DELETE (owner, * 2026-09-29 night: "this is really the view deletion should be done * in") rides the same bar FENCED at the right end — divider + the * destructive red pair — and opens the SAME full-death confirm dialog * the canvas uses (multi-selections list their names + the unioned * impact); this ends the overlay's read-only stance: it is the graph's * MANAGEMENT surface now. The fuzzy * search (names, aliases, statements) filters chains grouped + * auto-expanded under their clusters (non-matching clusters hide; the * collapsed view returns on clear). * * Read-only toward the graph. */ import { useEffect, useMemo, useState } from "react"; import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, DumbbellIcon, FolderIcon, FolderOpenIcon, Trash2Icon, ListTreeIcon, SearchIcon, XIcon, } from "lucide-react"; import { chainsForSelection, formatChainLine, fuzzyScore } from "@/src/core"; import type { GraphChain, GraphNode } from "@/src/core"; import { TopBar } from "@/components/top-bar"; import { DeleteNodeDialog } from "@/components/delete-node-dialog"; import { LatexGlyph } from "@/components/latex-glyph"; /** The sortable columns (owner ruling: all three — consistency). */ export type ChainSortCol = "name" | "props" | "created"; export interface ChainSort { col: ChainSortCol; /** 1 = ascending (A→Z, oldest-first, smallest-first), -1 = descending. */ dir: 1 | -1; } /** A cluster's chains share the conclusion's birth (owner ruling). This * record is assembled per render from the visible chains. */ interface ClusterGroup { key: string; birth: string | null; label: string | null; kind: "chat" | "notes" | null; chains: ReturnType; nodeIds: string[]; newestCreatedAt: string; } /** The table's pure body — exported for SSR pins (the render-suite * discipline). All state (query, sort, selection, expansion) arrives * via props; the overlay wrapper below owns it. */ export function ChainTableBody({ nodes, chains: storedChains, sessions, fileSources, query, onQuery, sort, onSort, selection, expandedClusters, expandedChains, onToggleCluster, onToggleChain, onToggleNodes, onClear, onDelete, onClose, selectMode, onSelect, }: { nodes: GraphNode[]; chains: GraphChain[]; sessions: Record; fileSources: Record; query: string; onQuery: (v: string) => void; sort: ChainSort; onSort: (col: ChainSortCol) => void; selection: ReadonlySet; expandedClusters: ReadonlySet; expandedChains: ReadonlySet; onToggleCluster: (key: string) => void; onToggleChain: (id: string) => void; /** Selection is ALWAYS a flat node-id set — cluster, chain, and single * proposition checks all reduce to this. */ onToggleNodes: (ids: string[], next: boolean) => void; onClear: () => void; /** Opens the full-death confirm dialog (owner, 2026-09-29 night). */ onDelete: () => void; onClose: () => void; /** PRACTICE SELECT (2026-10-04): the floating bar's confirm becomes * "Practice these" — no delete fence; the caller owns what the * selection becomes. */ selectMode?: boolean; onSelect?: () => void; }) { const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); const chains = useMemo(() => chainsForSelection(nodes, storedChains), [nodes, storedChains]); const searching = query.trim() !== ""; // The fuzzy filter: a chain is visible when ANY member matches on // name, aliases, or statement; chains rank by best member score. const visibleChains = useMemo(() => { if (!searching) return chains; const scored = chains .map((chain) => { let best: number | null = null; for (const id of [...chain.premises, chain.conclusion]) { const node = byId.get(id); if (!node) continue; const s = fuzzyScore(query, node.name, node.statement, ...node.aliases); if (s === null) continue; if (best === null || s > best) best = s; } return best === null ? null : { chain, best }; }) .filter((r): r is { chain: (typeof chains)[number]; best: number } => r !== null); return scored .sort((a, b) => b.best - a.best || (a.chain.id < b.chain.id ? -1 : 1)) .map(({ chain }) => chain); }, [chains, byId, query, searching]); // Group the visible chains by their CONCLUSION's birth (owner ruling). // Unlabeled births (provenance gone / never named) form one honest // muted group, sorted last under name-sort. const groups = useMemo(() => { const map = new Map(); for (const chain of visibleChains) { const conclusion = byId.get(chain.conclusion); const birth = conclusion?.provenance[0]?.chatId ?? null; const key = birth ?? "\0unlabeled"; let group = map.get(key); if (!group) { const label = birth === null ? null : (sessions[birth] ?? fileSources[birth] ?? null); const kind = birth === null ? null : sessions[birth] !== undefined ? "chat" : fileSources[birth] !== undefined ? "notes" : null; group = { key, birth, label, kind, chains: [], nodeIds: [], newestCreatedAt: "" }; map.set(key, group); } group.chains.push(chain); for (const id of [...chain.premises, chain.conclusion]) { if (!group.nodeIds.includes(id)) group.nodeIds.push(id); const createdAt = byId.get(id)?.createdAt ?? ""; if (createdAt > group.newestCreatedAt) group.newestCreatedAt = createdAt; } } const collator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true }); const list = [...map.values()]; // Sort law (owner ruling: all three columns sortable — consistency): // the active column orders BOTH cluster rows and chains within; name // is the universal tiebreak (asc). Unlabeled clusters sort last under // name-sort (￿ sentinel). const nameKey = (g: ClusterGroup) => g.label ?? "\uffff"; const cmpName = (a: ClusterGroup, b: ClusterGroup) => collator.compare(nameKey(a), nameKey(b)) * (sort.col === "name" ? sort.dir : 1); const cmpProps = (a: ClusterGroup, b: ClusterGroup) => sort.dir === -1 ? b.nodeIds.length - a.nodeIds.length : a.nodeIds.length - b.nodeIds.length; const cmpCreated = (a: ClusterGroup, b: ClusterGroup) => sort.dir === -1 ? b.newestCreatedAt.localeCompare(a.newestCreatedAt) : a.newestCreatedAt.localeCompare(b.newestCreatedAt); list.sort((a, b) => sort.col === "created" ? cmpCreated(a, b) || cmpName(a, b) : sort.col === "props" ? cmpProps(a, b) || cmpName(a, b) : cmpName(a, b), ); for (const group of list) { const chainName = (chain: (typeof chains)[number]) => byId.get(chain.conclusion)?.name ?? chain.conclusion; const chainSize = (chain: (typeof chains)[number]) => chain.premises.length + 1; const chainDate = (chain: (typeof chains)[number]) => byId.get(chain.conclusion)?.createdAt ?? ""; const cName = (a: (typeof chains)[number], b: (typeof chains)[number]) => collator.compare(chainName(a), chainName(b)) * (sort.col === "name" ? sort.dir : 1); const cProps = (a: (typeof chains)[number], b: (typeof chains)[number]) => sort.dir === -1 ? chainSize(b) - chainSize(a) : chainSize(a) - chainSize(b); const cDate = (a: (typeof chains)[number], b: (typeof chains)[number]) => sort.dir === -1 ? chainDate(b).localeCompare(chainDate(a)) : chainDate(a).localeCompare(chainDate(b)); group.chains.sort((a, b) => sort.col === "created" ? cDate(a, b) || cName(a, b) : sort.col === "props" ? cProps(a, b) || cName(a, b) : cName(a, b), ); } return list; }, [visibleChains, byId, sessions, fileSources, sort]); // The derived shape under the floating bar (the agents' ACTUAL input). const derived = useMemo(() => { const selectedNodes = [...selection].map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined); return chainsForSelection(selectedNodes, storedChains); }, [selection, byId, storedChains]); const nameOf = (id: string) => byId.get(id)?.name ?? id; const dateOf = (id: string) => byId.get(id)?.createdAt.slice(0, 10) ?? ""; const dirIcon = (col: ChainSortCol) => sort.col !== col ? null : sort.dir === 1 ? : ; return (
{/* THE shared top bar (standardization ruling): drag mechanics, sidebar reopen, traffic-light gutter — the same element as every chat surface. NO TITLE (owner round 11, 2026-10-05): the serif title register is RESERVED — it always names a session; the chain table names nothing, so its bar carries counts + search + close only. Search + close carve out with lh-no-drag (the mask law: later in the DOM). */} {chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} {nodes.length === 1 ? "node" : "nodes"}
onQuery(e.target.value)} placeholder="Search nodes…" aria-label="Search nodes" spellCheck={false} className="lh-no-drag h-8 w-56 rounded-lg border bg-background pl-8 pr-3 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" />
{/* The list: NO per-row lines (owner ruling) — the ONLY horizontal separators sit between cluster groups; the ALIGNMENT STAIRCASE carries the hierarchy (owner round 4, "double the indentation": two-column steps — chains pl-12 aligned with the folder icon, statements pl-21 where a chain icon would sit). All three columns sort, small chevron on active. */}
{/* The TOP rule (owner round 5: the table is framed — a line under the header, a line under the last group, lines between groups). */}
{groups.length === 0 && (
No matching chains.
)} {groups.map((group) => { const open = searching || expandedClusters.has(group.key); const selectedCount = group.nodeIds.filter((id) => selection.has(id)).length; return (
{/* The TREE THREAD (owner rounds 5–6): a vertical guide at the expander arrow's column (center 37px: pl-3 + checkbox 14 + gap 4 + half the 14px expander), BRACKETED by checkbox bottom edges — it starts at the cluster row's own checkbox bottom (top-[23px], leaving the arrow's stroke a visible gap, live-measured) and terminates exactly at the LAST checkbox bottom (bottom-[8px] = 9px from the border box minus the 1px group rule, measured). */} {open && group.chains.length > 0 && (
)} {/* The cluster row: birth-session group header. */}
0} indeterminate={selectedCount > 0 && selectedCount < group.nodeIds.length} onToggle={(next) => onToggleNodes(group.nodeIds, next)} testid={`cluster-checkbox-${group.key}`} label={`Select everything born in ${group.label ?? "unlabeled"}`} /> onToggleCluster(group.key)} testid={`cluster-toggle-${group.key}`} label={`Expand cluster ${group.label ?? "unlabeled"}`} /> {open ? : } {group.label ?? unlabeled} {group.kind === "notes" && notes} {group.nodeIds.length} {group.newestCreatedAt.slice(0, 10)}
{/* The chain rows — clearly indented under the folder (owner ruling); visible only when the cluster is open (searching auto-expands). */} {open && group.chains.map((chain) => { const memberIds = [...chain.premises, chain.conclusion]; const chainOpen = expandedChains.has(chain.id); const selectedInChain = memberIds.filter((id) => selection.has(id)).length; return (
{/* The chain's own thread (owner rounds 5–6): the same bracket law one level down — at the chain arrow's column (center 73px), from the chain row's checkbox bottom (top-[22px]) to the LAST statement row's checkbox bottom (bottom-[8px]) — live-measured anchors. */} {chainOpen && memberIds.length > 0 && (
)}
[id, { name: n.name }])))} className="flex items-center gap-1 py-1 pl-12 pr-3" > 0} indeterminate={selectedInChain > 0 && selectedInChain < memberIds.length} onToggle={(next) => onToggleNodes(memberIds, next)} testid={`chain-checkbox-${chain.id}`} label={`Select chain ${nameOf(chain.conclusion)}`} /> onToggleChain(chain.id)} testid={`chain-toggle-${chain.id}`} label={`Expand chain ${nameOf(chain.conclusion)}`} /> {/* The chain line: proposition names as faint-outlined chips. THE LOGIC IS THE PAYLOAD (owner rulings, 2026-09-30): premises join with the LaTeX LAND (∧), the conclusion separates by the LaTeX IMPLICATION (⟹) — `A ∧ B ⟹ C`, never the misleading `A → B → C` — the glyphs render through KaTeX (the app's math engine), PROMINENT in the theme foreground (white), not the old dim gray. A lonely chip reads as a standalone proposition (no glyphs at all); the row's aria-label keeps the plain-text form. */} {memberIds.map((id, i) => { const isConclusion = i === memberIds.length - 1; return ( {i > 0 && (isConclusion ? ( ) : ( ))} {nameOf(id)} ); })} {memberIds.length} {dateOf(chain.conclusion)}
{/* The propositions — their STATEMENT text directly (owner ruling: names live in the line; a singleton reveals its one statement in one click). PLAIN rows: the derivation is already encoded in the chain line — no conclusion marker down here. Deepest indent. */} {chainOpen && memberIds.map((id) => { const node = byId.get(id); if (!node) return null; return (
onToggleNodes([id], next)} testid={`prop-checkbox-${id}`} label={`Select node ${node.name}`} /> {node.statement}
); })}
); })}
); })}
{/* The floating selection bar (owner ruling: never shifts layout — the Google-Photos pattern). Appears only when something is selected; floats over the table's bottom edge. */} {selection.size > 0 && (
{selection.size} {selection.size === 1 ? "node" : "nodes"} →{" "} {derived.length} {derived.length === 1 ? "chain" : "chains"} {selectMode ? ( ) : ( <> {/* The danger fence (owner ruling): a divider quarantines the destructive action at the bar's right end — the safe actions and the kill switch never blend. The full-death confirm dialog catches the misclick. */}
)}
); } /** The rotating chevron — collapsed points right, expanded rotates down * (owner ruling: the affordance must read on every expandable row). */ function Expander({ open, onClick, testid, label }: { open: boolean; onClick: () => void; testid: string; label: string }) { return ( ); } /** The checkbox — checked/indeterminate (partial chains/clusters); DOM * property set via ref (React does not expose `indeterminate`). */ function RowCheckbox({ checked, indeterminate = false, onToggle, testid, label, }: { checked: boolean; indeterminate?: boolean; onToggle: (next: boolean) => void; testid: string; label: string; }) { return ( { if (el) el.indeterminate = indeterminate; }} data-testid={testid} aria-label={label} onChange={(e) => onToggle(e.target.checked)} className="size-3.5 shrink-0 accent-primary" /> ); } /** The graph view's overlay wrapper: owns query + sort + selection + * expansion; mounts ABSOLUTE INSIDE the * graph area, full-bleed (owner ruling: the translucent blurred surface * IS the overlay — no inner card). Esc + the top-bar X dismiss (there * is no backdrop margin to click anymore). Read-only toward the graph. */ export function ChainTableOverlay({ open, nodes, chains: storedChains, sessions, fileSources, onClose, /** PRACTICE SELECT (owner vision, 2026-10-04): the selection is * PERSISTENT and caller-owned — when these props are present the * table is CONTROLLED (every check/clear/delete-prune reports up * immediately; the composer-side chip reflects it live). selectMode * only changes the floating bar: Done instead of the delete fence * (deleting while picking is a footgun). */ selectMode, controlledSelection, onSelectionChange, }: { open: boolean; nodes: GraphNode[]; chains: GraphChain[]; sessions: Record; fileSources: Record; onClose: () => void; selectMode?: boolean; controlledSelection?: ReadonlySet; onSelectionChange?: (next: ReadonlySet) => void; }) { const [query, setQuery] = useState(""); const [sort, setSort] = useState({ col: "name", dir: 1 }); const [internalSelection, setInternalSelection] = useState>(() => new Set()); const [expandedClusters, setExpandedClusters] = useState>(() => new Set()); const [expandedChains, setExpandedChains] = useState>(() => new Set()); const [deleteOpen, setDeleteOpen] = useState(false); // Controlled when the caller lifts the selection (the practice pick // set); internal (uncontrolled) otherwise — the delete flow's own. const selection = controlledSelection ?? internalSelection; const applySelection = (next: ReadonlySet) => { if (onSelectionChange) onSelectionChange(next); else setInternalSelection(next); }; // Esc dismisses (unless a dialog has focus — each dialog's own Esc // owns its layer; all listen on window, so the guards are explicit). useEffect(() => { if (!open || deleteOpen) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [open, deleteOpen, onClose]); if (!open) return null; const byId = new Map(nodes.map((n) => [n.id, n])); const selectedNodes = [...selection].map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined); const derived = chainsForSelection(selectedNodes, storedChains); const nodeIds = [...selection].sort(); const toggleNodes = (ids: string[], next: boolean) => { const s = new Set(selection); for (const id of ids) { if (next) s.add(id); else s.delete(id); } applySelection(s); }; const toggleIn = (set: ReadonlySet, id: string) => { const s = new Set(set); if (s.has(id)) s.delete(id); else s.add(id); return s; }; return ( <>
setSort((prev) => prev.col === col ? { col, dir: prev.dir === 1 ? -1 : 1 } : { col, dir: col === "name" ? 1 : -1 }, ) } selection={selection} expandedClusters={expandedClusters} expandedChains={expandedChains} onToggleCluster={(key) => setExpandedClusters((prev) => toggleIn(prev, key))} onToggleChain={(id) => setExpandedChains((prev) => toggleIn(prev, id))} onToggleNodes={toggleNodes} onClear={() => applySelection(new Set())} onDelete={() => setDeleteOpen(true)} onClose={onClose} selectMode={selectMode} onSelect={onClose} />
{deleteOpen && ( ({ id: n.id, name: n.name }))} onClose={() => setDeleteOpen(false)} onDeleted={(deadIds, graphRevision) => { // Close the dialog; prune the dead ids from the // selection (the rest of a big selection survives); // announce the revision so GraphView refetches the // snapshot — fresh nodes/edges flow back in as props. setDeleteOpen(false); const dead = new Set(deadIds); applySelection(new Set([...selection].filter((id) => !dead.has(id)))); window.dispatchEvent(new CustomEvent("lh:graph-revision", { detail: { graphRevision } })); }} /> )} ); }