Files
learnharness/components/chain-table.tsx
T
amosblomqvist e7f4923159 deletion screen minimal + 'node' as THE UI word (owner ruling)
Owner 2026-09-30: the confirm dialog's pre-flight impact read had to
go — what full death takes is obvious, the per-open fetch flickered,
and richer facts would only confuse as deletion semantics evolve
(rewiring is on the table). The screen is now: Delete node? / Delete
N nodes?, the names in the mono block, one permanence line, Cancel /
Delete. No loading state, no gone state (a 404 mid-run lands in the
honest failure line), no sessions join — the joins prop died too.

The dead path is deleted end to end: GET /api/graph/nodes/[id],
graphNodeImpact (server), nodeDeletionImpact (core) and their tests.

WORDING: node/node(s) is THE UI word for graph objects, standardized
across every surface's copy (owner, same ruling): the dialog titles,
the chain table's counts/search/summary/delete aria-labels, the
practice dialog's cluster counts, selection line, and empty-cluster
hint. Internal identifiers (selection.propositions) unchanged.

Rewritten dialog pins (tests/delete-node-dialog.test.tsx): minimal
contract — no facts/loading/fetch copy, singular/plural titles, the
cap, 409 dock phrasing, busy states, no 'proposition' left in the
dialog.
2026-09-30 01:19:03 +03:00

687 lines
29 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 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 → a clearly separated connector between chips — a lonely chip
* reads as a standalone proposition (the "(no premises)" annotation is
* dead in the visible UI; it survives only as the row's aria-label).
* Icons: ONLY the cluster FolderIcon survives (owner ruling, evening
* round 3: the chain waypoints icons are not needed — the chips +
* connectors already read as chains); propositions chip-only. Chevrons
* rotate with expansion. Statement rows show propositions PLAIN (owner
* ruling: the derivation is already encoded in the chain line — no
* conclusion marker down there).
*
* THE ALIGNMENT STAIRCASE (owner ruling, round 3): every level's
* checkbox sits ONE COLUMN under its parent's expander — aligned with
* the parent's ICON slot: open a folder and the chain checkboxes line
* up with the FOLDER icon; the statements step one more unit out (the
* would-be chain-icon column — the chains lost their icons but the
* pattern continues, owner: "double the indentation"). Geometry: every
* column is 14px + 4px gap = 18px and each step is TWO columns (36px) —
* clusters pl-3 (12px), chains pl-12 (48px = the cluster's folder
* icon), statements pl-21 (84px = where a chain icon WOULD sit).
*
* 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 + Generate problem set. 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. Named neutrally (ChainTable, not
* Practice*) — only the Generate handoff knows about practice.
*/
import { useEffect, useMemo, useState } from "react";
import {
ChevronDownIcon,
ChevronRightIcon,
ChevronUpIcon,
FolderIcon,
FolderOpenIcon,
Trash2Icon,
ListTreeIcon,
SearchIcon,
XIcon,
} from "lucide-react";
import { chainsForSelection, formatChainLine, fuzzyScore } from "@/src/core";
import type { GraphEdge, GraphNode } from "@/src/core";
import { TopBar } from "@/components/top-bar";
import { PracticeDialog } from "@/components/practice-dialog";
import { DeleteNodeDialog } from "@/components/delete-node-dialog";
/** 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<typeof chainsForSelection>;
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,
edges,
sessions,
fileSources,
query,
onQuery,
sort,
onSort,
selection,
expandedClusters,
expandedChains,
onToggleCluster,
onToggleChain,
onToggleNodes,
onClear,
onGenerate,
onDelete,
onClose,
}: {
nodes: GraphNode[];
edges: GraphEdge[];
sessions: Record<string, string>;
fileSources: Record<string, string>;
query: string;
onQuery: (v: string) => void;
sort: ChainSort;
onSort: (col: ChainSortCol) => void;
selection: ReadonlySet<string>;
expandedClusters: ReadonlySet<string>;
expandedChains: ReadonlySet<string>;
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;
onGenerate: () => void;
/** Opens the full-death confirm dialog (owner, 2026-09-29 night). */
onDelete: () => void;
onClose: () => void;
}) {
const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
const chains = useMemo(() => chainsForSelection(nodes, edges), [nodes, edges]);
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<string, ClusterGroup>();
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, edges);
}, [selection, byId, edges]);
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 ? <ChevronUpIcon className="size-3" /> : <ChevronDownIcon className="size-3" />;
return (
<div data-testid="chain-table" className="relative flex h-full min-h-0 flex-col">
{/* THE shared top bar (standardization ruling): title at the standard
size, drag mechanics, sidebar reopen, traffic-light gutter — the
same element as every chat/practice surface. Search + close
carve out with lh-no-drag (the mask law: later in the DOM). */}
<TopBar>
<span className="min-w-0 flex-1 truncate font-title text-3xl tracking-tight">Chains</span>
<span className="shrink-0 text-xs text-muted-foreground">
{chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} {nodes.length === 1 ? "node" : "nodes"}
</span>
<div className="relative shrink-0">
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
data-testid="chain-table-search"
onChange={(e) => 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"
/>
</div>
<button
type="button"
data-testid="chain-table-close"
onClick={onClose}
aria-label="Close chains"
className="lh-no-drag flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-card/80 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<XIcon className="size-4" />
</button>
</TopBar>
{/* 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. */}
<div className="min-h-0 flex-1 overflow-y-auto">
{/* The TOP rule (owner round 5: the table is framed — a line under
the header, a line under the last group, lines between groups). */}
<div className="sticky top-0 z-10 flex items-center gap-1 border-b border-border/60 bg-background/80 px-3 py-1.5 text-[11px] uppercase tracking-widest text-muted-foreground/70 backdrop-blur">
<button
type="button"
data-testid="sort-name"
onClick={() => onSort("name")}
aria-sort={sort.col === "name" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
className="flex flex-1 items-center gap-1 text-left transition-colors hover:text-foreground"
>
Name {dirIcon("name")}
</button>
<button
type="button"
data-testid="sort-props"
onClick={() => onSort("props")}
aria-sort={sort.col === "props" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
className="flex w-14 shrink-0 items-center justify-end gap-1 text-right transition-colors hover:text-foreground"
>
Props {dirIcon("props")}
</button>
<button
type="button"
data-testid="sort-created"
onClick={() => onSort("created")}
aria-sort={sort.col === "created" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
className="flex w-20 shrink-0 items-center justify-end gap-1 text-right transition-colors hover:text-foreground"
>
Created {dirIcon("created")}
</button>
</div>
{groups.length === 0 && (
<div data-testid="chain-table-nomatch" className="px-4 pt-3 text-xs text-muted-foreground">
No matching chains.
</div>
)}
{groups.map((group) => {
const open = searching || expandedClusters.has(group.key);
const selectedCount = group.nodeIds.filter((id) => selection.has(id)).length;
return (
<div
key={group.key}
data-testid={`cluster-group-${group.key}`}
// The horizontal rules (owner rulings, rounds 2 + 5): between
// cluster groups AND under the last one — the table is framed
// top (header) and bottom. Never on individual rows.
className="relative border-b border-border/60"
>
{/* 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 && (
<div
data-testid={`cluster-thread-${group.key}`}
className="pointer-events-none absolute bottom-[8px] left-[37px] top-[23px] w-px bg-border/50"
/>
)}
{/* The cluster row: birth-session group header. */}
<div className="flex items-center gap-1 px-3 py-1.5">
<RowCheckbox
checked={selectedCount === group.nodeIds.length && group.nodeIds.length > 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"}`}
/>
<Expander open={open} onClick={() => onToggleCluster(group.key)} testid={`cluster-toggle-${group.key}`} label={`Expand cluster ${group.label ?? "unlabeled"}`} />
<span className="flex shrink-0 items-center justify-center text-muted-foreground">
{open ? <FolderOpenIcon className="size-3.5" /> : <FolderIcon className="size-3.5" />}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{group.label ?? <span className="text-muted-foreground/60">unlabeled</span>}
{group.kind === "notes" && <span className="ml-2 text-[11px] uppercase tracking-widest text-muted-foreground/60">notes</span>}
</span>
<span className="w-14 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{group.nodeIds.length}
</span>
<span className="w-20 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{group.newestCreatedAt.slice(0, 10)}
</span>
</div>
{/* 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 (
<div key={chain.id} data-testid={`chain-block-${chain.id}`} className="relative">
{/* 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 && (
<div
data-testid={`chain-thread-${chain.id}`}
className="pointer-events-none absolute bottom-[8px] left-[73px] top-[22px] w-px bg-border/50"
/>
)}
<div
data-testid={`chain-row-${chain.id}`}
aria-label={formatChainLine(chain, Object.fromEntries([...byId.entries()].map(([id, n]) => [id, { name: n.name }])))}
className="flex items-center gap-1 py-1 pl-12 pr-3"
>
<RowCheckbox
checked={selectedInChain === memberIds.length && memberIds.length > 0}
indeterminate={selectedInChain > 0 && selectedInChain < memberIds.length}
onToggle={(next) => onToggleNodes(memberIds, next)}
testid={`chain-checkbox-${chain.id}`}
label={`Select chain ${nameOf(chain.conclusion)}`}
/>
<Expander open={chainOpen} onClick={() => onToggleChain(chain.id)} testid={`chain-toggle-${chain.id}`} label={`Expand chain ${nameOf(chain.conclusion)}`} />
{/* The chain line: proposition names as faint-outlined
chips, → the connector BETWEEN them (owner ruling) — a
lonely chip reads as a standalone proposition. */}
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-0.5 gap-y-1 text-[13px] leading-tight">
{memberIds.map((id, i) => (
<span key={id} className="flex items-center">
{i > 0 && <span data-testid={`chain-arrow-${chain.id}-${i}`} className="px-1.5 text-muted-foreground/50">→</span>}
<span
data-testid={`prop-chip-${id}`}
className="rounded border border-border/70 bg-muted/30 px-1.5 py-0.5"
>
{nameOf(id)}
</span>
</span>
))}
</span>
<span className="w-14 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{memberIds.length}
</span>
<span className="w-20 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{dateOf(chain.conclusion)}
</span>
</div>
{/* 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 (
<div
key={id}
data-testid={`prop-row-${id}`}
className="flex items-start gap-2 py-1 pl-21 pr-3"
>
<RowCheckbox
checked={selection.has(id)}
onToggle={(next) => onToggleNodes([id], next)}
testid={`prop-checkbox-${id}`}
label={`Select node ${node.name}`}
/>
<span className="min-w-0 flex-1 text-xs leading-relaxed text-muted-foreground">
{node.statement}
</span>
<span className="w-14 shrink-0" />
<span className="w-20 shrink-0" />
</div>
);
})}
</div>
);
})}
</div>
);
})}
</div>
{/* 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 && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex justify-center pb-4">
<div
data-testid="chain-selection-bar"
className="pointer-events-auto flex items-center gap-3 rounded-full border bg-card px-4 py-2 shadow-lg"
>
<span data-testid="chain-selected-summary" className="text-xs font-medium">
{selection.size} {selection.size === 1 ? "node" : "nodes"} →{" "}
{derived.length} {derived.length === 1 ? "chain" : "chains"}
</span>
<button
type="button"
data-testid="chain-clear"
onClick={onClear}
className="rounded-full px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Clear
</button>
<button
type="button"
data-testid="chain-generate"
onClick={onGenerate}
className="rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Generate problem set
</button>
{/* 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. */}
<span data-testid="chain-danger-divider" aria-hidden="true" className="h-4 w-px bg-border/70" />
<button
type="button"
data-testid="chain-delete"
onClick={onDelete}
aria-label={`Delete ${selection.size} selected ${selection.size === 1 ? "node" : "nodes"}`}
className="flex items-center gap-1.5 rounded-full border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
>
<Trash2Icon aria-hidden="true" className="size-3.5" />
Delete
</button>
</div>
</div>
)}
</div>
);
}
/** 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 (
<button
type="button"
data-testid={testid}
onClick={onClick}
aria-label={label}
aria-expanded={open}
className="flex h-5 w-3.5 shrink-0 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground"
>
<ChevronRightIcon className={`size-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
</button>
);
}
/** 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 (
<input
type="checkbox"
checked={checked}
ref={(el) => {
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 + the custom practice dialog; 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,
edges,
sessions,
fileSources,
onClose,
}: {
open: boolean;
nodes: GraphNode[];
edges: GraphEdge[];
sessions: Record<string, string>;
fileSources: Record<string, string>;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [sort, setSort] = useState<ChainSort>({ col: "name", dir: 1 });
const [selection, setSelection] = useState<ReadonlySet<string>>(() => new Set());
const [expandedClusters, setExpandedClusters] = useState<ReadonlySet<string>>(() => new Set());
const [expandedChains, setExpandedChains] = useState<ReadonlySet<string>>(() => new Set());
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
// 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 || dialogOpen || deleteOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, dialogOpen, 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, edges);
const nodeIds = [...selection].sort();
const toggleNodes = (ids: string[], next: boolean) =>
setSelection((prev) => {
const s = new Set(prev);
for (const id of ids) {
if (next) s.add(id);
else s.delete(id);
}
return s;
});
const toggleIn = (set: ReadonlySet<string>, id: string) => {
const s = new Set(set);
if (s.has(id)) s.delete(id);
else s.add(id);
return s;
};
return (
<>
<div
data-testid="chain-table-overlay"
className="absolute inset-0 z-40 flex flex-col bg-background/80 backdrop-blur-sm"
>
<ChainTableBody
nodes={nodes}
edges={edges}
sessions={sessions}
fileSources={fileSources}
query={query}
onQuery={setQuery}
sort={sort}
onSort={(col) =>
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={() => setSelection(new Set())}
onGenerate={() => setDialogOpen(true)}
onDelete={() => setDeleteOpen(true)}
onClose={onClose}
/>
</div>
{dialogOpen && (
<PracticeDialog
mode="custom"
selection={{ nodeIds, propositions: selectedNodes.length, chains: derived.length }}
onClose={() => setDialogOpen(false)}
/>
)}
{deleteOpen && (
<DeleteNodeDialog
nodes={selectedNodes.map((n) => ({ 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);
setSelection((prev) => new Set([...prev].filter((id) => !dead.has(id))));
window.dispatchEvent(new CustomEvent("lh:graph-revision", { detail: { graphRevision } }));
}}
/>
)}
</>
);
}