From 4fa6e2105a12d6e17d930bc76d2297dfd19ac924 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 30 Sep 2026 00:02:24 +0300 Subject: [PATCH] TopBar standardization + chain table round 2 (owner refinements, 2026-09-29 late) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - components/top-bar.tsx: THE shared top bar (standardization ruling) — h-15, lh-drag + carve-outs, select-none, SidebarToggle reopen with the Electron traffic-light gutter; adopted by chat (rename keeps its drag={false} escape hatch), new-session, practice-list, practice-exam, and the chains overlay. - chain table: prominent indentation (pl-11 / pl-[4.75rem]), the ONLY lines are the separators between cluster groups (row + vertical lines gone), plain statement rows (no conclusion marker), all three columns sortable (Props too) with a small chevron on the active header. --- README.md | 2 +- components/chain-table.tsx | 279 ++++++++++++++++-------------- components/chat.tsx | 16 +- components/new-session.tsx | 14 +- components/practice-exam.tsx | 7 +- components/practice-list.tsx | 7 +- components/top-bar.tsx | 48 +++++ docs/architecture.md | 4 +- tests/render-chain-table.test.tsx | 70 ++++++-- 9 files changed, 276 insertions(+), 171 deletions(-) create mode 100644 components/top-bar.tsx diff --git a/README.md b/README.md index 0bc1267..89c36ab 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 — 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. +**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 under the shared top bar, 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 b4f77a4..94790e6 100644 --- a/components/chain-table.tsx +++ b/components/chain-table.tsx @@ -1,31 +1,39 @@ "use client"; /** - * 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). + * 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 — 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). + * 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 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. + * 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 when expanded), chains WaypointsIcon, + * 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). + * + * 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, @@ -33,9 +41,9 @@ * 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. + * 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. @@ -43,7 +51,9 @@ import { useEffect, useMemo, useState } from "react"; import { + ChevronDownIcon, ChevronRightIcon, + ChevronUpIcon, FolderIcon, FolderOpenIcon, ListTreeIcon, @@ -53,16 +63,14 @@ import { } 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"; -/** 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]"; - -/** The sortable columns (owner ruling: Name + Created only — size is text). */ -export type ChainSortCol = "name" | "created"; +/** 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), -1 = descending. */ + /** 1 = ascending (A→Z, oldest-first, smallest-first), -1 = descending. */ dir: 1 | -1; } @@ -158,7 +166,8 @@ export function ChainTableBody({ 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; + 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); } @@ -170,31 +179,32 @@ export function ChainTableBody({ } } 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); - } + // 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) { - 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; - }); - } + 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]); @@ -207,23 +217,21 @@ export function ChainTableBody({ 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 ? "▲" : "▼"; + const dirIcon = (col: ChainSortCol) => + sort.col !== col ? null : 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 - + {/* 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). */} + + Chains + {chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} propositions -
-
+
-
+ - {/* The table: hairline column + row lines (owner ruling — a real - table look). Sortable headers on Name + Created only. */} + {/* The list: NO per-row lines (owner ruling) — the ONLY horizontal + separators sit between cluster groups; PROMINENT indentation + carries the hierarchy (clusters → chains pl-11 → statements + pl-[4.75rem]). All three columns sort, small chevron on active. */}
-
- - - +
+ - Props
@@ -280,40 +295,45 @@ export function ChainTableBody({
)} - {groups.map((group) => { + {groups.map((group, groupIndex) => { 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"}`} - /> - +
+ 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). */} + {/* 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]; @@ -324,25 +344,23 @@ export function ChainTableBody({
[id, { name: n.name }])))} - className={`grid ${ROW_COLS} items-center gap-1 border-b border-border/40 px-3 py-1.5`} + className="flex items-center gap-1 py-1 pl-11 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)}`} - /> - + 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 && →} @@ -355,45 +373,40 @@ export function ChainTableBody({ ))} - + {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. */} + 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; - const isConclusion = id === chain.conclusion; return (
- - onToggleNodes([id], next)} - testid={`prop-checkbox-${id}`} - label={`Select proposition ${node.name}`} - /> - - - - - {isConclusion && →} + onToggleNodes([id], next)} + testid={`prop-checkbox-${id}`} + label={`Select proposition ${node.name}`} + /> + {node.statement} - - + +
); })} @@ -451,7 +464,7 @@ function Expander({ open, onClick, testid, label }: { open: boolean; onClick: () onClick={onClick} aria-label={label} aria-expanded={open} - className="flex size-5 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground" + className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground" > @@ -483,7 +496,7 @@ function RowCheckbox({ data-testid={testid} aria-label={label} onChange={(e) => onToggle(e.target.checked)} - className="size-3.5 accent-primary" + className="size-3.5 shrink-0 accent-primary" /> ); } @@ -567,7 +580,7 @@ export function ChainTableOverlay({ setSort((prev) => prev.col === col ? { col, dir: prev.dir === 1 ? -1 : 1 } - : { col, dir: col === "created" ? -1 : 1 }, + : { col, dir: col === "name" ? 1 : -1 }, ) } selection={selection} diff --git a/components/chat.tsx b/components/chat.tsx index bb12bb3..25d3497 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -31,7 +31,7 @@ import { MermaidOverlay, SessionMermaidContext } from "@/components/session-over import { WaypointsIcon } from "lucide-react"; import type { PlanBlockData } from "@/src/core"; import { QuestionDock } from "@/components/question-dock"; -import { SidebarToggle } from "@/components/sidebar"; +import { TopBar } from "@/components/top-bar"; import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core"; import type { LhViewMessage } from "@/src/core"; import { PhaseChip, PhaseDivider } from "@/components/phase-divider"; @@ -926,15 +926,12 @@ export function Chat({ 60px is an ALIGNMENT, not just a size: it equals the sidebar's LH wordmark band, so the header's border-b continues that band's bottom edge as one line across the app (owner, round 6). */} -
{ // Escape hitbox: any mousedown outside the input itself blurs // it, running the existing onBlur commit path. The input is @@ -943,7 +940,6 @@ export function Chat({ if (renaming && e.target !== titleInputRef.current) titleInputRef.current?.blur(); }} > - {renaming ? ( <> -
+
diff --git a/components/new-session.tsx b/components/new-session.tsx index 2a0cef2..e5df119 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation"; import { Composer } from "@/components/composer"; import { DictationBar, useDictation } from "@/components/dictation"; -import { SidebarToggle } from "@/components/sidebar"; +import { TopBar } from "@/components/top-bar"; import { SetupDialog } from "@/components/setup-dialog"; import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { StatsBar } from "@/components/stats-bar"; @@ -77,12 +77,12 @@ export function DraftShell({ return (
- {/* h-15 (60px) matches the chat header and the sidebar's LH wordmark - band (owner, round 6). */} -
- -
New session
-
+ {/* The shared top bar (standardization ruling, 2026-09-29): one + element, every surface — h-15, drag, sidebar reopen, lights + gutter. h-15 (60px) matches the sidebar's wordmark band. */} + +
New session
+
{/* The pointer-events pattern (953b22b): the sticky wrapper is diff --git a/components/practice-exam.tsx b/components/practice-exam.tsx index bdaed70..3a9e848 100644 --- a/components/practice-exam.tsx +++ b/components/practice-exam.tsx @@ -31,9 +31,9 @@ import type { Problem, ProblemSet } from "@/src/core"; import { CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "@/src/core"; import { restoreMathEscapes } from "@/src/core"; import { MessageResponse } from "@/components/ai-elements/message"; +import { TopBar } from "@/components/top-bar"; import { BrailleSpinner } from "@/components/braille-spinner"; import { DictationBar, useDictation } from "@/components/dictation"; -import { SidebarToggle } from "@/components/sidebar"; /** One answerable problem: textarea + focus-gated dictation bar. */ export function ProblemBox({ @@ -99,15 +99,14 @@ export function PracticeExamBody({ pattern — h-15, lh-drag (the Electron window-drag surface, so the traffic lights never cover content), the sidebar reopen toggle first, the set title truncating beside it. */} -
- +
{set.title}
-
+
- +
Practice
@@ -61,7 +60,7 @@ export function PracticeListBody({ New problem set - +
; + children?: ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/docs/architecture.md b/docs/architecture.md index deafecf..ac8d4fc 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 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. +**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 shared **TopBar** (`components/top-bar.tsx`, standardization ruling 2026-09-29: one element, every surface — h-15 matching the sidebar's wordmark band, `lh-drag` with `lh-no-drag` carve-outs on interactive children, `select-none` so drags never highlight, the SidebarToggle reopen with its Electron traffic-light gutter, `px-3` + one border line; chat's rename mode passes `drag={false}` — the one escape hatch) with 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; the top bar is THE shared TopBar — the standard title size and drag mechanics, the sidebar reopen, and the close X at the right edge, so home's window-drag mask never eats it (the overlay covers home's floating header while open, and the TopBar's own toggle is the reopen path); 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 refinements, evening round 2): PROMINENT indentation carries the hierarchy (chains deep under their folder, statements deepest) and the ONLY lines left are the horizontal separators between cluster groups — per-row lines and the vertical column hairlines are gone ("anything else is better without them"); statement rows are PLAIN (the derivation already lives in the chain line — no conclusion marker). ALL THREE columns sort (owner: "consistency is key") — Name (A→Z default), Props (largest-first), Created (newest-first) — click toggles asc/desc, ordering both cluster rows and chains within; a SMALL chevron marks the active header. 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 shared TopBar, 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). @@ -469,7 +469,7 @@ All deterministic, no network: engines are injected (pi-ai's **faux provider** 1. **Pure core units** — merger via real adapters, derivation, coverage, question grading, plan tree, phases, clusters, layout, tool-parts, prices, fuzzy search. 2. **Server flows** — `handleChatRequest` driven end-to-end with scripted faux engines: persistence, liveness, steer claiming, the wake pipeline (409 retry, quiz deferral, idempotency), crash recovery, the data-dir seam (including a fresh child process). 3. **Sub-agent loops** — researcher/demo-builder/visual-maker with fake headless APIs; the extraction pipeline end-to-end (audit drops, failure-checkpoints-nothing, single-flight, abort); the practice pipeline end-to-end (partition echo/coverage retries, failure isolation, semaphore width, abort mid-generation, grading, boot sweep). -4. **Render tests** — transcript, cards, docks, composer, sidebar, coverage, stats, practice surfaces (creation dialog both modes, chain table, list, exam states) via React server-side rendering (`renderToStaticMarkup`), no DOM library. +4. **Render tests** — transcript, cards, docks, composer, sidebar, coverage, stats, practice surfaces (creation dialog both modes, chain table incl. the shared-TopBar + lines-law pins, list, exam states) via React server-side rendering (`renderToStaticMarkup`), no DOM library. Gates on every change: `tsc` clean, all tests, `eslint` clean, `next build` clean. diff --git a/tests/render-chain-table.test.tsx b/tests/render-chain-table.test.tsx index 0e53f8a..dbe54a2 100644 --- a/tests/render-chain-table.test.tsx +++ b/tests/render-chain-table.test.tsx @@ -7,9 +7,16 @@ * 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. + * icons, a floating bottom selection bar that never shifts layout, and + * a fuzzy search that auto-expands matching clusters. + * + * ROUND 2 (owner refinements, 2026-09-29 late): the top bar is THE + * shared TopBar (h-15, lh-drag + select-none, sidebar reopen, standard + * title size); PROMINENT indentation carries the hierarchy — the ONLY + * lines left are the separators between cluster groups (vertical column + * hairlines and per-row lines are gone); statement rows are PLAIN (no + * conclusion marker); ALL THREE columns sort (Props too — consistency) + * with a SMALL chevron on the active header. */ import React from "react"; @@ -115,6 +122,29 @@ describe("ChainTableBody — clusters (the outermost layer)", () => { }); }); +describe("ChainTableBody — THE shared TopBar + the lines law (owner, round 2)", () => { + it("the body renders the standardized top bar: h-15, lh-drag, select-none, standard title size, close X", () => { + const html = render(); + assert.ok(html.includes("lh-drag"), "the window-drag band"); + assert.ok(html.includes("select-none"), "dragging never highlights the title"); + assert.ok(html.includes("h-15"), "the standard height"); + assert.ok(html.includes('font-title text-3xl tracking-tight">Chains<'), "the standard title size"); + assert.ok(html.includes('data-testid="chain-table-close"'), "the close X"); + assert.ok(html.includes('data-testid="chain-table-search"'), "the search input"); + }); + + it("the ONLY lines are the separators between cluster groups — no per-row or vertical lines", () => { + const html = render({ expandedClusters: new Set(["chat1"]) }); + // group wrappers: every group but the last carries the separator + const sep = html.split("border-b border-border/60").length - 1; + assert.ok(sep >= 2, "separators between groups"); + // chain rows themselves carry NO border — their entire class list is + // the indent law (pl-11 under the cluster folder, owner round 2); the + // chip outlines' border-border/70 is an outline, not a row line + assert.ok(html.includes('class="flex items-center gap-1 py-1 pl-11 pr-3"'), "the chain row: indent, no border"); + }); +}); + describe("ChainTableBody — chain rows (chips + connectors)", () => { it("names render as outlined chips with → connectors BETWEEN them", () => { const html = render({ expandedClusters: new Set(["chat1"]) }); @@ -143,7 +173,7 @@ describe("ChainTableBody — chain rows (chips + connectors)", () => { assert.ok(html.includes('aria-label="Premise one, Premise two → Conclusion"')); }); - it("expanding a chain reveals member STATEMENTS directly — names never twice, conclusion marked", () => { + it("expanding a chain reveals member STATEMENTS directly — PLAIN rows, no conclusion marker (owner, round 2)", () => { const html = render({ expandedClusters: new Set(["chat1"]), expandedChains: new Set(["c1"]), @@ -151,20 +181,26 @@ describe("ChainTableBody — chain rows (chips + connectors)", () => { 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"); + // owner ruling: the derivation is already encoded in the chain line — + // no conclusion marker down here + assert.ok(!html.includes("prop-conclusion"), "no conclusion marker anywhere"); + // statements indent DEEPEST (pl-[4.75rem]); chains pl-11 under clusters + assert.ok(html.includes("pl-[4.75rem]"), "the deepest indent"); + assert.ok(html.includes("pl-11"), "the chain indent"); }); }); describe("ChainTableBody — the sortable table headers", () => { - it("Name + Created are sort controls with aria-sort; Props is plain text", () => { + it("ALL THREE columns are sort controls with aria-sort (owner: consistency is key)", () => { const html = render(); assert.ok(html.includes('data-testid="sort-name"')); + assert.ok(html.includes('data-testid="sort-props"')); 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"); + // the small chevron (size-3 lucide) marks the ACTIVE header only — + // no giant text triangles (owner, round 2) + assert.ok(html.includes('lucide-chevron-up size-3'), "small up chevron on active name"); + assert.ok(html.includes('lucide-chevron-down') === false, "no down chevron while ascending"); }); it("descending state shows on the active header only", () => { @@ -172,6 +208,20 @@ describe("ChainTableBody — the sortable table headers", () => { 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"); + assert.ok(html.includes('lucide-chevron-down size-3'), "small down chevron"); + }); + + it("Props sort orders cluster rows AND chains within (largest-first by default)", () => { + const html = render({ sort: { col: "props", dir: 1 }, expandedClusters: new Set(["chat1"]) }); // ascending: smallest first + const unlabeled = html.indexOf("cluster-group-\0unlabeled"); + const notes = html.indexOf('data-testid="cluster-group-notes1"'); + const chat = html.indexOf('data-testid="cluster-group-chat1"'); + assert.ok(unlabeled > 0 && notes > 0 && chat > 0, "all three groups render"); + assert.ok(unlabeled < notes && notes < chat, "1 < 2 < 4 member ids ascending"); + // within chat1: the singleton (1) sorts before the three-member chain + const soloRow = html.indexOf('data-testid="chain-row-solo"'); + const c1Row = html.indexOf('data-testid="chain-row-c1"'); + assert.ok(soloRow > 0 && c1Row > 0 && soloRow < c1Row, "chain sizes ascending within the cluster"); }); });