From e2109d1c29e6bf829b641db62f3afc5cc6302dea Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 29 Sep 2026 23:21:51 +0300 Subject: [PATCH] chain table redesign (owner refinements, 2026-09-29 evening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - full-bleed: blur + translucent surface IS the overlay, no inner card; own top bar (info + search + close, lh-no-drag so home's drag mask never eats the X — the clickability bug) - three levels, one click each: cluster (birth session; a chain lives in its CONCLUSION's cluster) → chain → proposition STATEMENTS revealed directly (names never twice; singletons one click); all collapsed by default - proposition names as outlined chips, → connectors between; lonely chip = standalone proposition — the '(no premises)' annotation is dead in the visible UI (survives only as aria-label); Folder/Waypoints icons; rotating chevrons; deep statement indent - real table look: hairline row + column lines; sortable Name + Created headers (click toggles asc/desc; size is text, not a control) - selection never shifts layout: floating bottom bar (Google-Photos pattern) with N propositions → M chains + Clear + Generate - fuzzy search grouped + auto-expanding clusters live-verified end-to-end on the owner's dev server; 15 rewritten render pins; gates: tsc 0, 1179/1179, lint 0, build 0 --- README.md | 2 +- components/chain-table.tsx | 918 ++++++++++++++++-------------- docs/architecture.md | 2 +- tests/render-chain-table.test.tsx | 205 ++++--- 4 files changed, 627 insertions(+), 500 deletions(-) diff --git a/README.md b/README.md index 73cb0fe..0bc1267 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ data/ chats/ · graph/ (six ledgers + positions/source **The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees only the session, rendered as readable markdown by the shared `core/session-markdown.ts` engine — propositions as true statements, `follows`/`supports` edges with verbatim evidence; fully trusted, graph-blind) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools, stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create — and may draw bridging edges between existing nodes and the new batch) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes edges, checkpoints coverage) → layout settle. No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 4096, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read; the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection. -**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — every reasoning chain in one selectable overlay whose pinned preview shows the exact `N propositions → M chains` the agents will receive — handing a free-form node selection to the same dialog. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot. +**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N propositions → M chains` the agents will receive — handing a free-form node selection to the same dialog. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot. **Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. diff --git a/components/chain-table.tsx b/components/chain-table.tsx index 7f20f64..b4f77a4 100644 --- a/components/chain-table.tsx +++ b/components/chain-table.tsx @@ -1,92 +1,465 @@ "use client"; /** - * THE CHAIN TABLE (chains overlay, owner-approved plan 2026-09-29): every - * reasoning chain of the graph in one selectable table — the graph view's - * practice entry for FREE-FORM selections, complementing the cluster - * picker (which stays the /practice page's only creation mode). Read-only - * toward the graph: it renders the snapshot it is handed, selects node - * ids, and hands the selection to the practice dialog's CUSTOM mode. + * THE CHAIN TABLE (chains overlay; owner redesign, 2026-09-29 evening): + * every reasoning chain of the graph in one FULL-BLEED table — the blur + * + translucent background IS the surface, no inner card (owner ruling: + * "full width of the area"). The overlay's own top bar carries the info + * + the close button (lh-no-drag — it lives inside home's 52px drag + * mask, and being later in the DOM the carve-out sticks; that mask was + * what ate the old card's X clicks). * - * MOUNT IDIOM (the session-overlay precedent, session-overlay.tsx): the - * overlay is ABSOLUTE INSIDE the graph area (never a body portal) — - * inset-0, z-40, bg-background/80 + backdrop blur — so the app chrome - * (drag bar, sidebar) stays sharp and reachable; Esc and backdrop clicks - * dismiss. + * 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 — the names already + * live in the chain line, so they never render twice; a singleton chain + * reveals its one statement in a single click. Everything starts + * COLLAPSED (owner ruling — no arbitrary default expansion). * - * STRUCTURE: a fuzzy search box (names, aliases, statements — core's - * fuzzyScore) at the very top filters the chain list only; the SELECTED - * section is pinned beneath it, never filtered — N propositions → M - * chains via chainsForSelection over the selection (the agents' ACTUAL - * input: a selected premise without its conclusion shows as the singleton - * it becomes). Chain rows (the core formatter's name line) expand to - * indented member proposition rows; each member expands its full - * statement inline. Columns: checkbox | chain/proposition tree | birth - * session(s) | created. NO understanding-levels column (explicitly not - * built — no dead markup). + * THE TABLE LOOK: drawn hairline row + column lines; sortable column + * headers (Name + Created clickable, owner ruling — the size column is + * text, not a control). In the chain line every proposition name is a + * faint-outlined CHIP and the → a clearly separated connector between + * chips (owner ruling) — a lonely chip with no connector reads as a + * standalone proposition; the "(no premises)" text annotation is dead. + * Icons: clusters FolderIcon (opens when expanded), chains WaypointsIcon + * (the sidebar's graph glyph), propositions chip-only. Chevrons rotate + * with expansion state. * - * Named neutrally (ChainTable, not Practice*) — the table is a - * graph-surface; only the Generate handoff knows about practice. + * 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 (the one action + * for now; the bar is a neutral action slot for future ones). The fuzzy + * search (names, aliases, statements) filters chains; clusters with + * matches auto-expand, others hide — the collapsed grouped view returns + * when the query clears. + * + * Read-only toward the graph. Named neutrally (ChainTable, not + * Practice*) — only the Generate handoff knows about practice. */ import { useEffect, useMemo, useState } from "react"; -import { ListTreeIcon, SearchIcon, XIcon } from "lucide-react"; +import { + ChevronRightIcon, + FolderIcon, + FolderOpenIcon, + ListTreeIcon, + SearchIcon, + WaypointsIcon, + XIcon, +} from "lucide-react"; import { chainsForSelection, formatChainLine, fuzzyScore } from "@/src/core"; import type { GraphEdge, GraphNode } from "@/src/core"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; import { PracticeDialog } from "@/components/practice-dialog"; -/** The grid every row shares: checkbox | tree | birth | date. */ -const ROW_COLS = "grid-cols-[1.5rem_minmax(0,1fr)_9rem_5.5rem]"; +/** The shared row grid: checkbox | chevron | icon | content | props | created. */ +const ROW_COLS = "grid-cols-[1.75rem_1.25rem_1.5rem_minmax(0,1fr)_5.5rem_6rem]"; -/** A node's birth-session label, mirroring the clusters' honesty join: - * the first surviving provenance point's chatId → chat title / notes - * file path, null when unnameable (rendered muted "unlabeled", never - * guessed). Inlined here (one line) because session-clusters.ts is not - * barrel-exported — the graph stream owns it; the shape is contract- - * pinned (provenance[0] is the birth by the getGraph fold order). */ -function birthLabelOf( - node: Pick, - sessions: Record, - fileSources: Record, -): string | null { - const birth = node.provenance[0]?.chatId ?? null; - if (birth === null) return null; - return sessions[birth] ?? fileSources[birth] ?? null; +/** The sortable columns (owner ruling: Name + Created only — size is text). */ +export type ChainSortCol = "name" | "created"; +export interface ChainSort { + col: ChainSortCol; + /** 1 = ascending (A→Z, oldest-first), -1 = descending. */ + dir: 1 | -1; } -/** One chain's birth labels — unique over its members, sorted by session - * id (deterministic); a member with no nameable birth contributes a - * muted "unlabeled" entry (honest, per the clusters law). */ -function chainBirthLabels( - chain: { premises: string[]; conclusion: string }, - byId: Map, - sessions: Record, - fileSources: Record, -): string[] { - const labels = new Map(); - for (const id of [...chain.premises, chain.conclusion]) { - const node = byId.get(id); - if (!node) continue; - const birth = node.provenance[0]?.chatId ?? null; - if (birth === null) { - labels.set("\0unlabeled", "unlabeled"); - continue; +/** 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, + edges, + sessions, + fileSources, + query, + onQuery, + sort, + onSort, + selection, + expandedClusters, + expandedChains, + onToggleCluster, + onToggleChain, + onToggleNodes, + onClear, + onGenerate, + onClose, +}: { + nodes: GraphNode[]; + edges: GraphEdge[]; + 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; + onGenerate: () => 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(); + 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; + } } - labels.set(birth, sessions[birth] ?? fileSources[birth] ?? "unlabeled"); - } - return [...labels.entries()] - .sort((a, b) => (a[0] < b[0] ? -1 : 1)) - .map(([, label]) => label); + const collator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true }); + const nameKey = (g: ClusterGroup) => g.label ?? "\uffff"; // unlabeled last + const list = [...map.values()]; + if (sort.col === "created") { + list.sort((a, b) => + sort.dir === -1 + ? b.newestCreatedAt.localeCompare(a.newestCreatedAt) + : a.newestCreatedAt.localeCompare(b.newestCreatedAt), + ); + } else { + list.sort((a, b) => collator.compare(nameKey(a), nameKey(b)) * sort.dir); + } + for (const group of list) { + if (sort.col === "created") { + group.chains.sort((a, b) => { + const da = byId.get(a.conclusion)?.createdAt ?? ""; + const db = byId.get(b.conclusion)?.createdAt ?? ""; + return sort.dir === -1 ? db.localeCompare(da) : da.localeCompare(db); + }); + } else { + group.chains.sort((a, b) => { + const na = byId.get(a.conclusion)?.name ?? a.conclusion; + const nb = byId.get(b.conclusion)?.name ?? b.conclusion; + return collator.compare(na, nb) * sort.dir; + }); + } + } + 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 sortArrow = (col: ChainSortCol) => + sort.col !== col ? "" : sort.dir === 1 ? "▲" : "▼"; + + return ( +
+ {/* The overlay's own top bar (owner ruling): info + search + close. + lh-no-drag — this strip sits inside home's 52px window-drag mask, + and being later in the DOM the carve-out sticks (the mask law, + home.tsx). pl-12 clears the floating sidebar toggle (top-left). */} +
+ + Chains + + {chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} propositions + +
+
+ + onQuery(e.target.value)} + placeholder="Search propositions…" + aria-label="Search propositions" + spellCheck={false} + className="h-8 w-64 rounded-lg border bg-background pl-8 pr-3 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" + /> +
+ +
+ + {/* The table: hairline column + row lines (owner ruling — a real + table look). Sortable headers on Name + Created only. */} +
+
+ + + + + Props + +
+ + {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 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 (visible only when the cluster is open — + searching auto-expands, owner ruling). */} + {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 ( +
+
[id, { name: n.name }])))} + className={`grid ${ROW_COLS} items-center gap-1 border-b border-border/40 px-3 py-1.5`} + > + + 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 connector BETWEEN them (owner ruling) — a + lonely chip reads as a standalone proposition. */} + + {memberIds.map((id, i) => ( + + {i > 0 && →} + + {nameOf(id)} + + + ))} + + + {memberIds.length} + + + {dateOf(chain.conclusion)} + +
+ + {/* The propositions — their STATEMENT text directly (owner + ruling: names already live in the line; never shown twice; + a singleton reveals its one statement in a single click). + Deep indent under the chain. */} + {chainOpen && + memberIds.map((id) => { + const node = byId.get(id); + if (!node) return null; + const isConclusion = id === chain.conclusion; + return ( +
+ + onToggleNodes([id], next)} + testid={`prop-checkbox-${id}`} + label={`Select proposition ${node.name}`} + /> + + + + + {isConclusion && →} + {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 ? "proposition" : "propositions"} →{" "} + {derived.length} {derived.length === 1 ? "chain" : "chains"} + + + +
+
+ )} +
+ ); } -/** The checkbox — checked/indeterminate (a chain with SOME members - * selected); clicks stop at the checkbox so they never toggle the - * surrounding Collapsible row. */ +/** 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, @@ -104,321 +477,22 @@ function RowCheckbox({ { if (el) el.indeterminate = indeterminate; }} data-testid={testid} aria-label={label} - onClick={(e) => e.stopPropagation()} onChange={(e) => onToggle(e.target.checked)} className="size-3.5 accent-primary" /> ); } -/** The table's pure body — exported for SSR pins (the render-suite - * discipline). All state (query, selection) arrives via props; the - * overlay wrapper below owns it. */ -export function ChainTableBody({ - nodes, - edges, - sessions, - fileSources, - query, - onQuery, - selection, - onToggleNode, - onToggleChain, - onClear, - onGenerate, - onClose, -}: { - nodes: GraphNode[]; - edges: GraphEdge[]; - sessions: Record; - fileSources: Record; - query: string; - onQuery: (v: string) => void; - selection: ReadonlySet; - onToggleNode: (id: string, next: boolean) => void; - onToggleChain: (ids: string[], next: boolean) => void; - onClear: () => void; - onGenerate: () => void; - onClose: () => void; -}) { - const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); - const chains = useMemo( - () => chainsForSelection(nodes, edges), - [nodes, edges], - ); - const nameOf = (id: string) => byId.get(id)?.name ?? id; - - // The fuzzy filter: a chain is visible when ANY member matches the - // query on name, aliases, or statement; chains rank by their best - // member's score (desc), ties by chain id — deterministic. - const visibleChains = useMemo(() => { - const scored = chains.map((chain) => { - const memberIds = [...chain.premises, chain.conclusion]; - // Empty query matches everything at score 0; a non-empty query - // starts at null (no match) and takes the best member score. - let best: number | null = query.trim() === "" ? 0 : null; - for (const id of memberIds) { - 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 { chain, best }; - }); - return scored - .filter((row): row is { chain: (typeof chains)[number]; best: number } => row.best !== null) - .sort((a, b) => b.best - a.best || (a.chain.id < b.chain.id ? -1 : 1)) - .map(({ chain }) => chain); - }, [chains, byId, query]); - - // The agents' ACTUAL input: chainsForSelection over the selection — - // a selected premise without its conclusion shows as the singleton it - // becomes. This is the pinned preview, never search-filtered. - 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 propositions = useMemo(() => { - const known = new Set([...selection].filter((id) => byId.has(id))); - return known; - }, [selection, byId]); - - return ( -
- {/* Header: title + count + close (the session-overlay chrome). */} -
- - Chains - - {chains.length} {chains.length === 1 ? "chain" : "chains"} ·{" "} - {nodes.length} propositions - -
- -
- - {/* The fuzzy search — very top; filters the LIST only. */} -
-
- - onQuery(e.target.value)} - placeholder="Search propositions…" - aria-label="Search propositions" - spellCheck={false} - className="h-8 w-full rounded-lg border bg-background pl-8 pr-3 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" - /> -
-
- - {/* The SELECTED section — pinned, always visible, never filtered. */} -
- {propositions.size === 0 ? ( -
- Nothing selected — check whole chains or single propositions below. -
- ) : ( -
-
-
- {propositions.size}{" "} - {propositions.size === 1 ? "proposition" : "propositions"} →{" "} - {derived.length} {derived.length === 1 ? "chain" : "chains"} -
-
    - {derived.map((chain) => ( -
  • - {formatChainLine( - chain, - Object.fromEntries( - [...byId.entries()].map(([id, n]) => [ - id, - { name: n.name }, - ]), - ), - )} -
  • - ))} -
-
-
- - -
-
- )} -
- - {/* Column headers + the chain rows (scrollable). */} -
-
- - Chain - Born in - Created -
- {visibleChains.length === 0 && ( -
- No matching chains. -
- )} -
- {visibleChains.map((chain, index) => { - const memberIds = [...chain.premises, chain.conclusion]; - const selectedCount = memberIds.filter((id) => - selection.has(id), - ).length; - const conclusion = byId.get(chain.conclusion); - return ( - - - 0 - } - indeterminate={ - selectedCount > 0 && selectedCount < memberIds.length - } - onToggle={(next) => onToggleChain(memberIds, next)} - testid={`chain-checkbox-${chain.id}`} - label={`Select chain ${nameOf(chain.conclusion)}`} - /> - - {formatChainLine( - chain, - Object.fromEntries( - [...byId.entries()].map(([id, n]) => [ - id, - { name: n.name }, - ]), - ), - )} - - - {chainBirthLabels(chain, byId, sessions, fileSources) - .map((label) => - label === "unlabeled" ? "unlabeled" : label, - ) - .join(" · ")} - - - {conclusion ? conclusion.createdAt.slice(0, 10) : ""} - - - -
- {memberIds.map((id) => { - const node = byId.get(id); - if (!node) return null; - const isConclusion = id === chain.conclusion; - return ( - - - - onToggleNode(id, next)} - testid={`prop-checkbox-${id}`} - label={`Select proposition ${node.name}`} - /> - - - {isConclusion && ( - - → - - )} - {node.name} - - - - - -
- {node.statement} -
-
-
- ); - })} -
-
-
- ); - })} -
-
-
- ); -} - -/** The graph view's overlay wrapper: owns query + selection + the custom - * practice dialog; mounts ABSOLUTE INSIDE the graph area (the - * session-overlay idiom — never a body portal). Read-only toward the - * graph — selection lives in component state only. */ +/** 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, @@ -435,9 +509,10 @@ export function ChainTableOverlay({ onClose: () => void; }) { const [query, setQuery] = useState(""); - const [selection, setSelection] = useState>( - () => new Set(), - ); + const [sort, setSort] = useState({ col: "name", dir: 1 }); + const [selection, setSelection] = useState>(() => new Set()); + const [expandedClusters, setExpandedClusters] = useState>(() => new Set()); + const [expandedChains, setExpandedChains] = useState>(() => new Set()); const [dialogOpen, setDialogOpen] = useState(false); // Esc dismisses (unless the practice dialog has focus — its own Esc @@ -454,63 +529,62 @@ export function ChainTableOverlay({ 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 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, id: string) => { + const s = new Set(set); + if (s.has(id)) s.delete(id); + else s.add(id); + return s; + }; + return ( <>
-
e.stopPropagation()} - > - - setSelection((prev) => { - const s = new Set(prev); - if (next) s.add(id); - else s.delete(id); - return s; - }) - } - onToggleChain={(ids, next) => - setSelection((prev) => { - const s = new Set(prev); - for (const id of ids) { - if (next) s.add(id); - else s.delete(id); - } - return s; - }) - } - onClear={() => setSelection(new Set())} - onGenerate={() => setDialogOpen(true)} - onClose={onClose} - /> -
+ + setSort((prev) => + prev.col === col + ? { col, dir: prev.dir === 1 ? -1 : 1 } + : { col, dir: col === "created" ? -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)} + onClose={onClose} + />
{dialogOpen && ( setDialogOpen(false)} /> )} diff --git a/docs/architecture.md b/docs/architecture.md index 5eb9d02..deafecf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -382,7 +382,7 @@ Practice turns the graph into problem sets the learner reasons through out loud. **The jobs pattern — registry, not JobRecords.** Practice runs are NOT Workbench jobs: no `JobStore` records, no steer messages, nothing ever wakes the teacher — a practice run isn't chat-scoped (a set outlives its birth session and has no lesson to re-enter; its result is itself, persisted in its own store and read by its own page). What it shares with the Workbench is the observability surface: a `globalThis` run registry (the module-instance idiom — `__lhPracticeRuns`) with PER-PROBLEM ROWS (owner ruling, 2026-09-29 — matching the extraction per-chunk pattern): `setId:partition` while the partitioner runs ("Partitioning — title"), then one `setId:gen:problemId` per problem ("queued"→"generating", label "Problem i/N — title"), and one `setId:grade:problemId` per graded answer ("queued"→"grading", "Grading i/N — title") — each row its OWN abort controller and its OWN usage accumulation; the row key IS the feed id (the dock dup-key law) and the abort target. ABORT ROUTES BY ROW SHAPE: a gen-row abort kills THAT problem only (siblings continue; the set still settles `ready` with one errored problem), a partition-row abort ends the whole run, a grade-row abort drops that grade (answer kept — one attempt, the problem stays done); set-level abort (delete) composes from row aborts plus the synchronous persisted marks. Model calls — partitioner included — run under the SHARED background-LLM semaphore (`bg-llm-semaphore.ts`): one process-wide pool of 3 permits for ALL background LLM jobs, extraction chunks included (total background LLM concurrency bounded at 3, whoever calls). A boot sweep marks orphaned `partitioning`/`generating` sets as `error "Interrupted (server restarted)"` — once per process, and only when the set's `updatedAt` heartbeat is >120s stale (a live run's 30s FILE watchdog keeps the heartbeat fresh even across dev's per-worker processes — the registry is not shared between them; the guard trio closed a live-stamping bug from the first live test) — the job runner's crash-honesty at set grain. Store writes serialize behind a per-set mutex; the pipeline is the sole writer of `data/practice`. -**API + UI.** Creation is GRAPH-DOMAIN, not chat-domain (owner ruling 2026-09-29: "problem sets always take in nodes — graph objects"): `POST /api/practice/sets` takes `{nodeIds, params, title?, note?}` ONLY — no chatId, no clusterId; the UI resolves a picked cluster to its member node ids and passes the cluster's label as the title. The `note` is the learner's optional free-form creation input (owner, same evening: more learner control, no other purpose): trimmed, capped at 2000 characters, persisted on the set, and carried to the partitioner and every generator as `THE LEARNER'S NOTE` — an additional input the agents WEIGH but are never obliged to follow (the doctrine pins the stance: it never moves a problem off its rung or out of its band, and the partition laws always win). `GET /api/graph/clusters` serves the picker: the birth-session partition itself (`birthSessionPartition`, same ordering — size desc, ties by id) — EVERY group regardless of size (owner ruling, same evening: MIN_CLUSTER_SIZE is a hull-drawing rule; practice availability must not inherit a visual threshold), each as `{key, kind: "chat"|"notes", label, nodeIds, nodeCount}`; a cluster whose naming record is gone keeps `label: null` — honest, never guessed. 202 `{id}` with generation fire-and-forget; 400 empty selection (empty intersection with live nodes — the vanished-nodes edge) / bad params; 503 model/key refusal. `GET /api/practice/sets` · `GET/DELETE /api/practice/sets/[id]` (delete aborts live runs first) · `POST /api/practice/sets/[id]/answers` (202; 409 when not ready; 400 re-answering a submitted problem — one attempt). Surfaces: `/practice` (the list — the chat-pattern top bar with the sidebar reopen toggle and the New action at the right edge, status/counts rows, delete confirm, the new-set dialog with the named-level controls (a 5-detent ladder slider, a 3-detent cue slider, a Theory|Application toggle — each showing the level's name + definition), the cluster picker, and the optional free-form note textarea (typing-only). The SECOND creation entry (owner plan, same evening): the graph view's **chain table** — a `Chains` button in the graph chrome (empty graph: hidden) opens a full-screen overlay (the session-overlay idiom: absolute inside the graph area, Esc/backdrop dismiss) listing EVERY reasoning chain via the core `formatChainLine` one-liner (`"Premise one, Premise two → Conclusion"`, singletons marked `"(no premises)"`), each row expanding to its member propositions and each member to its full statement; columns are birth session(s) (the clusters' honest label join — chat title / notes path / muted `unlabeled`) and created date; NO understanding-levels column (explicitly not built). Selection is checkboxes on whole chains or single propositions — a flat node-id set (a node in several chains is one selection) — with a fuzzy search (names, aliases, statements) that filters the LIST only. The pinned Selected section derives the agents' ACTUAL input live (`chainsForSelection` over the selection: a premise picked without its conclusion shows as the singleton it becomes) as `N propositions → M chains` + the derived lines, with Clear and **Generate problem set**, which opens the SAME creation dialog in **custom mode**: no cluster picker — a selection summary (the derived shape + the dated default title the pipeline will stamp) — posting `{nodeIds, params, note}` with no title. The overlay is read-only toward the graph and `/practice/[id]` (the exam page — the chat-pattern top bar, a levels + status line, numbered problems, markdown statements with math healing at the display seam, a textarea + focus-gated dictation bar per problem with its OWN Submit button — one attempt per problem, instant solution/rubric reveal, the score landing live). Entry: the sidebar's Practice nav row. +**API + UI.** Creation is GRAPH-DOMAIN, not chat-domain (owner ruling 2026-09-29: "problem sets always take in nodes — graph objects"): `POST /api/practice/sets` takes `{nodeIds, params, title?, note?}` ONLY — no chatId, no clusterId; the UI resolves a picked cluster to its member node ids and passes the cluster's label as the title. The `note` is the learner's optional free-form creation input (owner, same evening: more learner control, no other purpose): trimmed, capped at 2000 characters, persisted on the set, and carried to the partitioner and every generator as `THE LEARNER'S NOTE` — an additional input the agents WEIGH but are never obliged to follow (the doctrine pins the stance: it never moves a problem off its rung or out of its band, and the partition laws always win). `GET /api/graph/clusters` serves the picker: the birth-session partition itself (`birthSessionPartition`, same ordering — size desc, ties by id) — EVERY group regardless of size (owner ruling, same evening: MIN_CLUSTER_SIZE is a hull-drawing rule; practice availability must not inherit a visual threshold), each as `{key, kind: "chat"|"notes", label, nodeIds, nodeCount}`; a cluster whose naming record is gone keeps `label: null` — honest, never guessed. 202 `{id}` with generation fire-and-forget; 400 empty selection (empty intersection with live nodes — the vanished-nodes edge) / bad params; 503 model/key refusal. `GET /api/practice/sets` · `GET/DELETE /api/practice/sets/[id]` (delete aborts live runs first) · `POST /api/practice/sets/[id]/answers` (202; 409 when not ready; 400 re-answering a submitted problem — one attempt). Surfaces: `/practice` (the list — the chat-pattern top bar with the sidebar reopen toggle and the New action at the right edge, status/counts rows, delete confirm, the new-set dialog with the named-level controls (a 5-detent ladder slider, a 3-detent cue slider, a Theory|Application toggle — each showing the level's name + definition), the cluster picker, and the optional free-form note textarea (typing-only). The SECOND creation entry (owner redesign, same evening): the graph view's **chain table** — a `Chains` button in the graph chrome (empty graph: hidden) opens a FULL-BLEED table overlay (absolute inside the graph area; the blur + translucent background IS the surface, no inner card; its own top bar carries the info + the close button, `lh-no-drag` so home's window-drag mask never eats it; Esc or X dismiss). The hierarchy is three levels, one click each: CLUSTER (the birth session — a chat or a notes file; a chain lives where its CONCLUSION was born) → 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 a single click); everything starts collapsed. The table look (owner ruling: "it should really resemble a table"): drawn hairline row + column lines, sortable column headers on Name + Created (click toggles asc/desc; the size column is text, not a control; default Name A→Z). In 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: clusters FolderIcon (open-folder when expanded), chains WaypointsIcon, propositions chip-only; chevrons rotate with expansion. Selection is checkboxes on whole clusters, chains, or single propositions — a flat node-id set (a node in several chains is one selection) — accumulated in a FLOATING BOTTOM BAR (the Google-Photos pattern: appears only when something is selected, never shifts the table layout) showing the agents' ACTUAL input (`chainsForSelection` over the selection: a premise picked without its conclusion counts as the singleton it becomes) as `N propositions → M chains`, with Clear and **Generate problem set** (the one action for now — the bar is a neutral action slot), which opens the SAME creation dialog in **custom mode**: no cluster picker — a selection summary (the derived shape + the dated default title the pipeline will stamp) — posting `{nodeIds, params, note}` with no title. A fuzzy search (names, aliases, statements) filters chains grouped + auto-expanded under their clusters (non-matching clusters hide; the collapsed view returns on clear). NO understanding-levels column (explicitly not built). The overlay is read-only toward the graph and `/practice/[id]` (the exam page — the chat-pattern top bar, a levels + status line, numbered problems, markdown statements with math healing at the display seam, a textarea + focus-gated dictation bar per problem with its OWN Submit button — one attempt per problem, instant solution/rubric reveal, the score landing live). Entry: the sidebar's Practice nav row. **Model wiring.** Three roles — `partitioner`, `problemGenerator`, `grader` — all pinned to the **strong** slot (quality-critical, one settings edit from cheap) and in `BACKGROUND_MODEL_ROLES` (per-role overrides). The stage agents are single-submit loops on the shared `runSubmitAgent` seam (`submit-agent.ts`, extracted from extraction for exactly this): a `validate` hook turns a bad submit into a tool-input error the model retries against, within `maxTurns 4`; exhaustion fails the stage honestly (the set says so, the chains re-run cleanly on a new set). diff --git a/tests/render-chain-table.test.tsx b/tests/render-chain-table.test.tsx index 44c7808..0e53f8a 100644 --- a/tests/render-chain-table.test.tsx +++ b/tests/render-chain-table.test.tsx @@ -1,31 +1,38 @@ /** * THE CHAIN TABLE — SSR render pins (the render-suite discipline: pure - * bodies, renderToStaticMarkup, testids). The chains overlay (owner- - * approved plan, 2026-09-29): every reasoning chain in one selectable - * table; the pinned Selected section derives N propositions → M chains - * via chainsForSelection over the selection (the agents' ACTUAL input); - * fuzzy search filters the list only; the practice dialog opens in - * CUSTOM mode from the Generate button. + * bodies, renderToStaticMarkup, testids). The owner redesign + * (2026-09-29 evening): full-bleed table (blur + translucency IS the + * surface), cluster → chain → statement hierarchy (a chain lives in its + * CONCLUSION's birth cluster; expanding a chain reveals statements + * directly — names live in the line, never twice), proposition CHIPS + + * → connectors (a lonely chip = a standalone proposition; the "(no + * premises)" annotation is dead), rotating chevrons, folder/waypoints + * icons, hairline table lines, sortable Name + Created headers, a + * floating bottom selection bar that never shifts layout, and a fuzzy + * search that auto-expands matching clusters. */ import React from "react"; (globalThis as Record).React = React; import assert from "node:assert/strict"; -import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, it } from "node:test"; import { ChainTableBody } from "../components/chain-table.tsx"; +import type { ChainSort } from "../components/chain-table.tsx"; import type { GraphEdge, GraphNode } from "../src/core/contracts.ts"; /** The fixture graph — two follows-chains + one standalone: - * a → c1, b → c1 (chain one), d → e (chain two), solo (singleton). */ + * a → c1 ← b (chain one, conclusion born chat1), d → e (chain two, + * conclusion born notes1), solo (singleton born chat1). b is born + * chat2 — a PREMISE-only birth: no chain concludes there, so chat2 + * never appears as a group (the conclusion's-cluster law). */ function node(id: string, name: string, statement: string, chatId: string | null): GraphNode { return { id, name, statement, - aliases: [], + aliases: [] as string[], state: "new", createdAt: "2026-09-20T10:00:00.000Z", provenance: chatId === null ? [] : [{ chatId, messageId: `m_${id}` }], @@ -39,6 +46,7 @@ const NODES: GraphNode[] = [ node("d", "Support", "Statement D.", null), node("e", "Follow-up", "Statement E with keyword zebra.", "notes1"), node("solo", "Standalone", "Statement S.", "chat1"), + node("orphan", "Orphan", "Statement O.", null), ]; const EDGES: GraphEdge[] = [ @@ -47,118 +55,163 @@ const EDGES: GraphEdge[] = [ { from: "d", to: "e", type: "follows" }, ] as GraphEdge[]; -const SESSIONS = { chat1: "Ethics lesson", chat2: "Second chat" }; -const FILE_SOURCES = { notes1: "notes/raw/ethics.md" }; - const BASE = { nodes: NODES, edges: EDGES, - sessions: SESSIONS, - fileSources: FILE_SOURCES, + sessions: { chat1: "Ethics lesson", chat2: "Second chat" }, + fileSources: { notes1: "notes/raw/ethics.md" }, query: "", onQuery: () => {}, + sort: { col: "name", dir: 1 } as ChainSort, + onSort: () => {}, selection: new Set(), - onToggleNode: () => {}, + expandedClusters: new Set(), + expandedChains: new Set(), + onToggleCluster: () => {}, onToggleChain: () => {}, + onToggleNodes: () => {}, onClear: () => {}, onGenerate: () => {}, onClose: () => {}, }; function render(props: Partial = {}) { - return renderToStaticMarkup(createElement(ChainTableBody, { ...BASE, ...props })); + return renderToStaticMarkup(React.createElement(ChainTableBody, { ...BASE, ...props })); } -describe("ChainTableBody — the chain rows", () => { - it("renders every chain as a formatted name line, with birth + created columns", () => { +describe("ChainTableBody — clusters (the outermost layer)", () => { + it("collapsed by default: cluster rows render, chain rows do not", () => { const html = render(); - // chain ids are the conclusion ids (chainsForSelection's law) - assert.ok(html.includes('data-testid="chain-row-c1"'), "chain row for c1"); - assert.ok(html.includes('data-testid="chain-row-e"'), "chain row for e"); - assert.ok(html.includes('data-testid="chain-row-solo"'), "the singleton renders as its own chain"); - assert.ok(html.includes("Premise one, Premise two → Conclusion"), "the two-premise line"); - assert.ok(html.includes("Support → Follow-up"), "the one-premise line"); - assert.ok(html.includes("Standalone (no premises)"), "the singleton marker"); - // birth labels: the union of member labels, honest "unlabeled" included - assert.ok(html.includes("Ethics lesson · Second chat"), "chain one spans two labeled sessions"); - assert.ok(html.includes("notes/raw/ethics.md"), "a notes birth shows the file path"); - assert.ok(html.includes("unlabeled"), "an unnameable birth is honest about it"); - // created = the conclusion's createdAt (the practice-list date shape) - assert.ok(html.includes('data-testid="chain-date-c1"')); - assert.ok(html.includes("2026-09-20"), "the date column renders"); + assert.ok(html.includes('data-testid="cluster-group-chat1"'), "the chat cluster"); + assert.ok(html.includes('data-testid="cluster-group-notes1"'), "the notes cluster"); + assert.ok(html.includes("Ethics lesson"), "chat label"); + assert.ok(html.includes("notes/raw/ethics.md"), "notes label"); + assert.ok(html.includes("lucide-folder"), "the closed folder icon"); + assert.ok(!html.includes('data-testid="chain-row-c1"'), "no chain row when collapsed"); + assert.ok(!html.includes("Premise one"), "no chips when collapsed"); }); - it("the first chain row expands by default to member proposition rows; closed members keep their statements unmounted", () => { + it("a premise-only birth never becomes a group — chains live in their CONCLUSION's cluster", () => { const html = render(); - assert.ok(html.includes('data-testid="prop-row-a"'), "premise row a"); - assert.ok(html.includes('data-testid="prop-row-c1"'), "conclusion row"); - // member statements expand on click — Radix unmounts closed - // content, so SSR honestly does not contain them - assert.ok(!html.includes("Statement A."), "statements wait for their click"); + assert.ok(!html.includes("cluster-group-chat2"), "chat2 births only a premise"); + assert.ok(!html.includes("Second chat"), "its label never renders"); }); - it("every chain has a checkbox; expanded chains expose member checkboxes", () => { + it("an unnameable CONCLUSION birth forms one honest muted unlabeled group", () => { const html = render(); - assert.ok(html.includes('data-testid="chain-checkbox-c1"')); - assert.ok(html.includes('data-testid="chain-checkbox-e"')); - assert.ok(html.includes('data-testid="chain-checkbox-solo"')); - assert.ok(html.includes('data-testid="prop-checkbox-a"'), "inside the default-open chain"); + assert.ok(html.includes("unlabeled"), "the muted label"); + // orphan (birth null) concludes its own singleton chain → the group exists; + // d (also birth null) is a premise of e and never forms one + assert.ok(html.includes('data-testid="chain-row-orphan"') === false, "collapsed, like every group"); + }); + + it("cluster rows carry checkbox + counts + newest date; notes kind marked", () => { + const html = render(); + assert.ok(html.includes('data-testid="cluster-checkbox-chat1"')); + // chat1: chains c1 (a, b, c1) + solo (solo) → 4 member ids + assert.ok(html.includes(">4"), "chat1's proposition count"); + assert.ok(html.includes("2026-09-20"), "the newest-member date"); + assert.ok(html.includes(">notes"), "the notes kind tag"); }); }); -describe("ChainTableBody — the pinned Selected section", () => { - it("empty selection: guidance, no summary, no generate enablement pins", () => { - const html = render(); - assert.ok(html.includes("Nothing selected")); - assert.ok(!html.includes('data-testid="chain-selected-summary"')); +describe("ChainTableBody — chain rows (chips + connectors)", () => { + it("names render as outlined chips with → connectors BETWEEN them", () => { + const html = render({ expandedClusters: new Set(["chat1"]) }); + assert.ok(html.includes('data-testid="chain-row-c1"')); + assert.ok(html.includes('data-testid="prop-chip-a"'), "premise chip"); + assert.ok(html.includes('data-testid="prop-chip-c1"'), "conclusion chip"); + assert.ok(html.includes("Premise one"), "chip text"); + assert.ok(html.includes('data-testid="chain-arrow-c1-1"'), "the connector"); + assert.ok(html.includes("→"), "the arrow glyph"); + assert.ok(html.includes("lucide-waypoints"), "the chain icon"); }); - it("a full chain selection: N propositions → M chains + derived line", () => { + it("a singleton renders ONE lonely chip, no connector; the annotation survives only for screen readers", () => { + const html = render({ expandedClusters: new Set(["chat1"]) }); + assert.ok(html.includes('data-testid="chain-row-solo"')); + assert.ok(html.includes('data-testid="prop-chip-solo"')); + assert.ok(!html.includes('data-testid="chain-arrow-solo-1"'), "no connector on a singleton"); + // the visible line is the chip alone; "(no premises)" exists ONLY as the + // row's aria-label (the a11y one-liner) — exactly one occurrence + assert.equal(html.split("no premises").length - 1, 1, "aria-label only, never visible text"); + assert.ok(html.includes('aria-label="Standalone (no premises)"')); + }); + + it("the row carries the one-line derivation as its aria-label (core formatChainLine)", () => { + const html = render({ expandedClusters: new Set(["chat1"]) }); + assert.ok(html.includes('aria-label="Premise one, Premise two → Conclusion"')); + }); + + it("expanding a chain reveals member STATEMENTS directly — names never twice, conclusion marked", () => { + const html = render({ + expandedClusters: new Set(["chat1"]), + expandedChains: new Set(["c1"]), + }); + assert.ok(html.includes('data-testid="prop-row-a"'), "premise statement row"); + assert.ok(html.includes("Statement A."), "the statement text, one click deep"); + assert.ok(html.includes("Statement C."), "the conclusion statement"); + assert.ok(html.includes('data-testid="prop-conclusion-c1"'), "the conclusion marker"); + // premise rows carry no conclusion marker + const aRow = html.split('data-testid="prop-row-a"')[1]?.split('data-testid="prop-row-')[0] ?? ""; + assert.ok(!aRow.includes("prop-conclusion"), "premises unmarked"); + }); +}); + +describe("ChainTableBody — the sortable table headers", () => { + it("Name + Created are sort controls with aria-sort; Props is plain text", () => { + const html = render(); + assert.ok(html.includes('data-testid="sort-name"')); + assert.ok(html.includes('data-testid="sort-created"')); + assert.ok(html.includes('aria-sort="ascending"'), "default name A→Z"); + assert.ok(!html.includes('data-testid="sort-props"'), "size is text, not a control"); + }); + + it("descending state shows on the active header only", () => { + const html = render({ sort: { col: "created", dir: -1 } }); + assert.ok(html.includes('data-testid="sort-created"')); + assert.ok(html.includes('aria-sort="descending"')); + assert.ok(html.includes('aria-sort="none"'), "the inactive column"); + }); +}); + +describe("ChainTableBody — the floating selection bar (never shifts layout)", () => { + it("absent when nothing is selected", () => { + const html = render({ expandedClusters: new Set(["chat1"]) }); + assert.ok(!html.includes('data-testid="chain-selection-bar"')); + }); + + it("appears with the derived N propositions → M chains + Clear + Generate", () => { const html = render({ selection: new Set(["a", "b", "c1"]) }); + assert.ok(html.includes('data-testid="chain-selection-bar"')); assert.ok(html.includes('data-testid="chain-selected-summary"')); - assert.ok(html.includes("3 propositions → 1 chain"), "the derived shape, exactly"); - assert.ok(html.includes("Premise one, Premise two → Conclusion"), "the derived chain line"); + assert.ok(html.includes("3 propositions → 1 chain")); + assert.ok(html.includes('data-testid="chain-clear"')); + assert.ok(html.includes('data-testid="chain-generate"')); }); it("a premise WITHOUT its conclusion derives as the singleton it becomes", () => { const html = render({ selection: new Set(["a", "d"]) }); assert.ok(html.includes("2 propositions → 2 chains"), "no follows-edge survives inside the selection"); - assert.ok(html.includes("Premise one (no premises)"), "regrouped singleton a"); - assert.ok(html.includes("Support (no premises)"), "regrouped singleton d"); - }); - - it("a node in several chains is ONE flat selection wherever it appears", () => { - // e is conclusion of d→e; picking e alone = 1 proposition, 1 singleton chain - const html = render({ selection: new Set(["e"]) }); - assert.ok(html.includes("1 proposition → 1 chain")); - assert.ok(html.includes("Follow-up (no premises)")); - }); - - it("Clear + Generate problem set render in the pinned section", () => { - const html = render({ selection: new Set(["a"]) }); - assert.ok(html.includes('data-testid="chain-clear"')); - assert.ok(html.includes('data-testid="chain-generate"')); - assert.ok(html.includes("Generate problem set")); }); }); -describe("ChainTableBody — the fuzzy search (list only, never Selected)", () => { - it("filters chains by member name / statement / alias; the pinned selection survives", () => { - const aliased = NODES.map((n) => - n.id === "b" ? { ...n, aliases: ["zebra alias"] } : n, - ); +describe("ChainTableBody — the fuzzy search (grouped + auto-expand)", () => { + it("matching clusters auto-expand to reveal their chains; others hide; the bar is unaffected", () => { + const aliased = NODES.map((n) => (n.id === "b" ? { ...n, aliases: ["zebra alias"] } : n)); const html = render({ nodes: aliased, query: "zebra", selection: new Set(["a", "b", "c1"]), // selection spans BOTH chains }); // zebra hits: chain e (statement) + chain c1 (alias on b) - assert.ok(html.includes('data-testid="chain-row-c1"'), "alias match keeps the chain"); - assert.ok(html.includes('data-testid="chain-row-e"'), "statement match keeps the chain"); + assert.ok(html.includes('data-testid="chain-row-c1"'), "auto-expanded cluster reveals the chain"); + assert.ok(html.includes('data-testid="chain-row-e"'), "statement match"); assert.ok(!html.includes('data-testid="chain-row-solo"'), "non-matching chain hidden"); - // the pinned section is never filtered — it still shows the full derived line + assert.ok(!html.includes("cluster-group-notes1") === false, "notes1 still renders (it has a match)"); + assert.ok(!html.includes("Ethics lesson") === false, "chat1 still renders (it has a match)"); + // the floating bar is never search-filtered assert.ok(html.includes("3 propositions → 1 chain")); - assert.ok(html.includes("Premise one, Premise two → Conclusion")); }); it("no matches: the honest empty line", () => {