diff --git a/README.md b/README.md
index 89c36ab..51b9f68 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 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.
+**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 — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm 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 69a7f06..3320360 100644
--- a/components/chain-table.tsx
+++ b/components/chain-table.tsx
@@ -51,8 +51,13 @@
* 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
+ * singleton it becomes) + Clear + Generate problem set. DELETE (owner,
+ * 2026-09-29 night: "this is really the view deletion should be done
+ * in") rides the same bar FENCED at the right end — divider + the
+ * destructive red pair — and opens the SAME full-death confirm dialog
+ * the canvas uses (multi-selections list their names + the unioned
+ * impact); this ends the overlay's read-only stance: it is the graph's
+ * MANAGEMENT surface now. The fuzzy
* search (names, aliases, statements) filters chains grouped +
* auto-expanded under their clusters (non-matching clusters hide; the
* collapsed view returns on clear).
@@ -68,6 +73,7 @@ import {
ChevronUpIcon,
FolderIcon,
FolderOpenIcon,
+ Trash2Icon,
ListTreeIcon,
SearchIcon,
XIcon,
@@ -76,6 +82,7 @@ import { chainsForSelection, formatChainLine, fuzzyScore } from "@/src/core";
import type { GraphEdge, GraphNode } from "@/src/core";
import { TopBar } from "@/components/top-bar";
import { PracticeDialog } from "@/components/practice-dialog";
+import { DeleteNodeDialog } from "@/components/delete-node-dialog";
/** The sortable columns (owner ruling: all three — consistency). */
export type ChainSortCol = "name" | "props" | "created";
@@ -117,6 +124,7 @@ export function ChainTableBody({
onToggleNodes,
onClear,
onGenerate,
+ onDelete,
onClose,
}: {
nodes: GraphNode[];
@@ -137,6 +145,8 @@ export function ChainTableBody({
onToggleNodes: (ids: string[], next: boolean) => void;
onClear: () => void;
onGenerate: () => void;
+ /** Opens the full-death confirm dialog (owner, 2026-09-29 night). */
+ onDelete: () => void;
onClose: () => void;
}) {
const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
@@ -484,6 +494,21 @@ export function ChainTableBody({
>
Generate problem set
+ {/* The danger fence (owner ruling): a divider quarantines
+ the destructive action at the bar's right end — the
+ safe actions and the kill switch never blend. The
+ full-death confirm dialog catches the misclick. */}
+
+
)}
@@ -564,17 +589,18 @@ export function ChainTableOverlay({
const [expandedClusters, setExpandedClusters] = useState>(() => new Set());
const [expandedChains, setExpandedChains] = useState>(() => new Set());
const [dialogOpen, setDialogOpen] = useState(false);
+ const [deleteOpen, setDeleteOpen] = useState(false);
- // Esc dismisses (unless the practice dialog has focus — its own Esc
- // owns that layer; both listen on window, so the guard is explicit).
+ // Esc dismisses (unless a dialog has focus — each dialog's own Esc
+ // owns its layer; all listen on window, so the guards are explicit).
useEffect(() => {
- if (!open || dialogOpen) return;
+ if (!open || dialogOpen || deleteOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
- }, [open, dialogOpen, onClose]);
+ }, [open, dialogOpen, deleteOpen, onClose]);
if (!open) return null;
@@ -628,6 +654,7 @@ export function ChainTableOverlay({
onToggleNodes={toggleNodes}
onClear={() => setSelection(new Set())}
onGenerate={() => setDialogOpen(true)}
+ onDelete={() => setDeleteOpen(true)}
onClose={onClose}
/>
@@ -638,6 +665,23 @@ export function ChainTableOverlay({
onClose={() => setDialogOpen(false)}
/>
)}
+ {deleteOpen && (
+ ({ id: n.id, name: n.name }))}
+ joins={{ sessions, fileSources }}
+ onClose={() => setDeleteOpen(false)}
+ onDeleted={(deadIds, graphRevision) => {
+ // Close the dialog; prune the dead ids from the
+ // selection (the rest of a big selection survives);
+ // announce the revision so GraphView refetches the
+ // snapshot — fresh nodes/edges flow back in as props.
+ setDeleteOpen(false);
+ const dead = new Set(deadIds);
+ setSelection((prev) => new Set([...prev].filter((id) => !dead.has(id))));
+ window.dispatchEvent(new CustomEvent("lh:graph-revision", { detail: { graphRevision } }));
+ }}
+ />
+ )}
>
);
}
diff --git a/components/delete-node-dialog.tsx b/components/delete-node-dialog.tsx
index e70e111..f71fbc6 100644
--- a/components/delete-node-dialog.tsx
+++ b/components/delete-node-dialog.tsx
@@ -3,14 +3,17 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
-/** The impact read's shape (GET /api/graph/nodes/[id], node interlude):
- * counts of what full death would take with it, plus the referencing
- * session/source ids — the CLIENT joins display names from its
- * snapshot (chats map + imported-notes file paths). */
+/** The impact read's shape (GET /api/graph/nodes/[id]): counts of what
+ * full death would take with it, plus the referencing session/source
+ * ids — the CLIENT joins display names from its snapshot (chats map +
+ * imported-notes file paths). DRIFT FIX (2026-09-30, found via the
+ * chain-table delete): quizEvents/observations were removed from the
+ * server's NodeDeletionImpact when the quiz-events ledger died in the
+ * practice-arc schema slim — the dialog kept rendering the dead
+ * fields as undefined. The facts below now match what the server can
+ * honestly report: links + referencing sessions. */
export interface NodeImpact {
edges: number;
- quizEvents: number;
- observations: number;
sessions: string[];
}
@@ -54,7 +57,7 @@ export function countLine(n: number, one: string, many: string): string {
/** The dialog's pure body — exported for SSR pins (the settings-dialog
* body pattern). The wrapper owns state + effects + the portal. */
export function DeleteNodeBody({
- node,
+ nodes,
joins,
impact,
busy,
@@ -62,7 +65,10 @@ export function DeleteNodeBody({
onClose,
onRun,
}: {
- node: { id: string; name: string };
+ /** One node (the canvas context menu) or many (the chain table's
+ * selection, owner 2026-09-29 night: THE deletion view). One renders
+ * exactly as always; many pluralize the title and list the names. */
+ nodes: { id: string; name: string }[];
joins: { sessions: Record; fileSources: Record };
impact: NodeImpact | "loading" | "gone";
busy: boolean;
@@ -79,15 +85,26 @@ export function DeleteNodeBody({
onClick={(e) => e.stopPropagation()}
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
>
-
Delete node?
- {/* The node name below the title in JetBrains Mono (owner,
+
+ {/* The node name(s) below the title in JetBrains Mono (owner,
2026-09-24): the name is the payload — mono suits it, and
NO code-block chrome (the font already says code), sized up
- to text-lg so it reads. Same shape as the session dialog. */}
+ to text-lg so it reads. Same shape as the session dialog.
+ A multi-selection lists its names (capped at 8 + "and K
+ more" — the facts below already carry the full blast). */}
Referenced by{" "}
{impact.sessions.length === 0
@@ -182,22 +197,24 @@ export function DeleteNodeBody({
}
export function DeleteNodeDialog({
- node,
+ nodes,
joins,
onClose,
onDeleted,
}: {
- /** The node up for deletion; null = closed. Name from the client's
- * snapshot (what the user sees on the canvas is what the dialog
- * names). */
- node: { id: string; name: string } | null;
+ /** The nodes up for deletion; null/empty = closed. One (the canvas
+ * context menu) or many (the chain table's selection). Names from
+ * the client's snapshot — what the user sees is what the dialog
+ * names. */
+ nodes: { id: string; name: string }[] | null;
joins: { sessions: Record; fileSources: Record };
onClose: () => void;
/** Success path — the graph view dispatches lh:graph-revision so the
* snapshot refetches (node, edges, label vanish; clusters shrink or
* unrender below the server's minimum). graphRevision rides the
- * event; 0 when the response carried none (a vanished node). */
- onDeleted: (nodeId: string, graphRevision: number) => void;
+ * event; 0 when the response carried none (a vanished node). The
+ * deleted ids ride along so selection-side callers can prune. */
+ onDeleted: (nodeIds: string[], graphRevision: number) => void;
}) {
const [impact, setImpact] = useState("loading");
const [busy, setBusy] = useState(false);
@@ -205,29 +222,46 @@ export function DeleteNodeDialog({
// Fresh state per node: closing resets; opening another node refetches.
useEffect(() => {
- if (!node) {
+ if (!nodes || nodes.length === 0) {
setImpact("loading");
setBusy(false);
setFailure(null);
}
- }, [node]);
+ }, [nodes]);
// The pre-flight impact read — for the dialog the user just opened.
+ // A multi-selection reads every node and AGGREGATES: summed counts,
+ // unioned referencing sessions (deduped, first-seen order). All-404
+ // ("gone elsewhere meanwhile" for every one) is the honest gone state;
+ // a mix renders the living ones' blast.
useEffect(() => {
- if (!node) return;
+ if (!nodes || nodes.length === 0) return;
let cancelled = false;
setImpact("loading");
setFailure(null);
(async () => {
try {
- const res = await fetch(`/api/graph/nodes/${encodeURIComponent(node.id)}`);
+ const impacts = await Promise.all(
+ nodes.map(async (n) => {
+ const res = await fetch(`/api/graph/nodes/${encodeURIComponent(n.id)}`);
+ if (res.status === 404) return null;
+ if (!res.ok) throw new Error(String(res.status));
+ return (await res.json()) as NodeImpact;
+ }),
+ );
if (cancelled) return;
- if (res.status === 404) {
+ const live = impacts.filter((i): i is NodeImpact => i !== null);
+ if (live.length === 0) {
setImpact("gone");
return;
}
- if (!res.ok) throw new Error(String(res.status));
- setImpact((await res.json()) as NodeImpact);
+ const seen = new Set();
+ const sessions: string[] = [];
+ for (const i of live) for (const id of i.sessions) if (!seen.has(id)) { seen.add(id); sessions.push(id); }
+ setImpact({
+ edges: live.reduce((a, i) => a + i.edges, 0),
+ sessions,
+ });
} catch {
if (!cancelled) setImpact("gone"); // honest fallback, no facts invented
}
@@ -235,11 +269,11 @@ export function DeleteNodeDialog({
return () => {
cancelled = true;
};
- }, [node]);
+ }, [nodes]);
// Esc closes (claimed — a dialog Esc must not also abort a turn).
useEffect(() => {
- if (!node) return;
+ if (!nodes || nodes.length === 0) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
@@ -248,32 +282,45 @@ export function DeleteNodeDialog({
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
- }, [node, onClose]);
+ }, [nodes, onClose]);
+ // Sequential full deaths — never parallel (the store's revision
+ // counter and the sweep must observe them in order). A failure STOPS
+ // the run: what died, died; the failure line names why the rest
+ // still stands (extraction_running especially must not be plowed
+ // through). onDeleted fires with everything that actually died.
const run = async () => {
- if (!node || busy) return;
+ if (!nodes || nodes.length === 0 || busy) return;
setBusy(true);
setFailure(null);
+ const dead: string[] = [];
+ let revision = 0;
try {
- const res = await fetch(`/api/graph/nodes/${encodeURIComponent(node.id)}`, { method: "DELETE" });
- const body = (await res.json()) as {
- error?: string;
- code?: string;
- graphRevision?: number;
- };
- if (!res.ok) {
- setFailure({ code: body.code ?? "error", error: body.error ?? `Delete failed (${res.status})` });
- setBusy(false);
- return;
+ for (const n of nodes) {
+ const res = await fetch(`/api/graph/nodes/${encodeURIComponent(n.id)}`, { method: "DELETE" });
+ const body = (await res.json()) as {
+ error?: string;
+ code?: string;
+ graphRevision?: number;
+ };
+ if (!res.ok) {
+ setFailure({ code: body.code ?? "error", error: body.error ?? `Delete failed (${res.status})` });
+ setBusy(false);
+ if (dead.length > 0) onDeleted(dead, revision);
+ return;
+ }
+ dead.push(n.id);
+ revision = body.graphRevision ?? revision;
}
- onDeleted(node.id, body.graphRevision ?? 0);
+ onDeleted(dead, revision);
} catch {
setFailure({ code: "network", error: "Delete failed — network error. Try again." });
setBusy(false);
+ if (dead.length > 0) onDeleted(dead, revision);
}
};
- if (!node) return null;
+ if (!nodes || nodes.length === 0) return null;
return createPortal(
setDeletingNode(null)}
- onDeleted={(nodeId, graphRevision) => {
+ onDeleted={(nodeIds, graphRevision) => {
// Close the dialog; close the detail card if it pointed here
// (the refetch would close it anyway, but the same frame
// should not show a card for a node the user just killed).
setDeletingNode(null);
- if (selected?.node.id === nodeId) setSelected(null);
+ if (selected && nodeIds.includes(selected.node.id)) setSelected(null);
// The wave-6 chain: every listener refetches on this. Clusters
// shrink / unrender below the minimum on the server's read.
window.dispatchEvent(new CustomEvent("lh:graph-revision", { detail: { graphRevision } }));
diff --git a/docs/architecture.md b/docs/architecture.md
index 9b44297..c04339f 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 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` where NATIVE interactive children carve out AUTOMATICALLY (the globals.css auto carve-out: inside any drag band, `button/input/textarea/select/a/summary/[contenteditable]` are no-drag islands — owner standardization ruling 2026-09-29 night; manual `lh-no-drag` is only for clickable non-natives), `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). The overlay twin of the mask law rides with it: app-region subtraction resolves by DOM order, not z-index, so any surface containing a TopBar that COVERS other content (a full-bleed overlay) must be the LAST child of its container — born from the chains-overlay bug, where the graph chrome's lh-no-drag column (later in DOM, geometrically under the overlay) punched dead zones through the bar's drag band over the counts/search/close area 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 horizontal rules FRAME the table (owner round 5) — between cluster groups, under the header, and under the last group — with per-row lines and the vertical column hairlines still gone, and open folders/chains dropping a TREE THREAD (owner round 5, trial): a vertical guide at the expander arrow's column (37px clusters / 73px chains), BRACKETED by checkbox bottom edges (owner round 6): it starts at the parent row's own checkbox bottom — a visible gap below the arrow's stroke, never touching it — and terminates exactly at the last child row's checkbox bottom (live-measured anchors: 23/8px clusters, 22/8px chains); 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: ONLY the cluster FolderIcon (open-folder when expanded) survives (owner ruling, evening round 3: the chain waypoints icons are not needed — the chips + connectors already read as chains); chevrons rotate with expansion and share the checkbox's 14px width, which makes every column exactly 18px and the ALIGNMENT STAIRCASE exact (owner rulings, rounds 3–4): each level's checkbox sits under its parent's ICON slot — open a folder and the chain checkboxes line up with the FOLDER icon; the statements step to where a chain icon WOULD sit (the steps are TWO columns = 36px — "double the indentation"): clusters pl-3 → chains pl-12 → statements pl-21. 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.
+**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` where NATIVE interactive children carve out AUTOMATICALLY (the globals.css auto carve-out: inside any drag band, `button/input/textarea/select/a/summary/[contenteditable]` are no-drag islands — owner standardization ruling 2026-09-29 night; manual `lh-no-drag` is only for clickable non-natives), `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). The overlay twin of the mask law rides with it: app-region subtraction resolves by DOM order, not z-index, so any surface containing a TopBar that COVERS other content (a full-bleed overlay) must be the LAST child of its container — born from the chains-overlay bug, where the graph chrome's lh-no-drag column (later in DOM, geometrically under the overlay) punched dead zones through the bar's drag band over the counts/search/close area 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 horizontal rules FRAME the table (owner round 5) — between cluster groups, under the header, and under the last group — with per-row lines and the vertical column hairlines still gone, and open folders/chains dropping a TREE THREAD (owner round 5, trial): a vertical guide at the expander arrow's column (37px clusters / 73px chains), BRACKETED by checkbox bottom edges (owner round 6): it starts at the parent row's own checkbox bottom — a visible gap below the arrow's stroke, never touching it — and terminates exactly at the last child row's checkbox bottom (live-measured anchors: 23/8px clusters, 22/8px chains); 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: ONLY the cluster FolderIcon (open-folder when expanded) survives (owner ruling, evening round 3: the chain waypoints icons are not needed — the chips + connectors already read as chains); chevrons rotate with expansion and share the checkbox's 14px width, which makes every column exactly 18px and the ALIGNMENT STAIRCASE exact (owner rulings, rounds 3–4): each level's checkbox sits under its parent's ICON slot — open a folder and the chain checkboxes line up with the FOLDER icon; the statements step to where a chain icon WOULD sit (the steps are TWO columns = 36px — "double the indentation"): clusters pl-3 → chains pl-12 → statements pl-21. 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**, 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 bar also carries **Delete** (owner, 2026-09-29 night: "this is really the view deletion should be done in") FENCED at its right end — a divider + the destructive red pair + a trash icon, the safe actions and the kill switch never blending — and it opens the SAME full-death confirm dialog the canvas uses (`DeleteNodeDialog`, body-portal z-50): a multi-selection pluralizes the title (`Delete N propositions?`), lists the names in the mono block (capped at 8 + "and K more"), and aggregates the pre-flight impact read (summed counts, unioned referencing sessions); confirm runs the per-node DELETEs SEQUENTIALLY — a failure stops the run (extraction_running is never plowed through), what died stays dead — then prunes the selection and announces `lh:graph-revision` so the snapshot refetches. This ends the overlay's read-only stance: the chain table is the graph's MANAGEMENT surface, 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).
diff --git a/tests/delete-node-dialog.test.tsx b/tests/delete-node-dialog.test.tsx
index 3e7af8f..62bcf8a 100644
--- a/tests/delete-node-dialog.test.tsx
+++ b/tests/delete-node-dialog.test.tsx
@@ -27,7 +27,7 @@ const JOINS = { sessions: { seedplan: "Orbital mechanics — plan phase" }, file
function bodyAt(impact: NodeImpact | "loading" | "gone", extra?: { failure?: { code: string; error: string } | null }) {
return renderToStaticMarkup(
createElement(DeleteNodeBody, {
- node: { id: "kepler", name: "Kepler's three laws" },
+ nodes: [{ id: "kepler", name: "Kepler's three laws" }],
joins: JOINS,
impact,
busy: false,
@@ -65,7 +65,14 @@ describe("EXTRACTION_RETRY_LINE (the 409 phrasing, owner-locked)", () => {
describe("DeleteNodeDialog (thin SSR)", () => {
it("null node renders nothing", () => {
const html = renderToStaticMarkup(
- createElement(DeleteNodeDialog, { node: null, joins: { sessions: {}, fileSources: {} }, onClose: () => {}, onDeleted: () => {} }),
+ createElement(DeleteNodeDialog, { nodes: null, joins: { sessions: {}, fileSources: {} }, onClose: () => {}, onDeleted: () => {} }),
+ );
+ assert.equal(html, "");
+ });
+
+ it("empty nodes array also renders nothing (null and [] are both closed)", () => {
+ const html = renderToStaticMarkup(
+ createElement(DeleteNodeDialog, { nodes: [], joins: { sessions: {}, fileSources: {} }, onClose: () => {}, onDeleted: () => {} }),
);
assert.equal(html, "");
});
@@ -85,6 +92,45 @@ describe("DeleteNodeDialog (thin SSR)", () => {
assert.match(html, /data-testid="node-impact-loading"/);
});
+ it("multi-selection: plural title, the names listed in mono, capped at 8 + and-K-more", () => {
+ const three = renderToStaticMarkup(
+ createElement(DeleteNodeBody, {
+ nodes: [
+ { id: "kepler", name: "Kepler's three laws" },
+ { id: "newton", name: "Newton's second law" },
+ { id: "hook", name: "Hooke's law" },
+ ],
+ joins: JOINS,
+ impact: "loading",
+ busy: false,
+ failure: null,
+ onClose: () => {},
+ onRun: () => {},
+ }),
+ );
+ assert.match(three, /Delete 3 propositions\?/); // plural title
+ assert.match(three, /data-testid="node-names-code"/); // the names block
+ assert.match(three, /Kepler's three laws, Newton's second law, Hooke's law/);
+ assert.ok(!three.includes('data-testid="node-name-code"'), "the single-name testid is gone in multi");
+
+ // the cap: 10 names render 8 + ", and 2 more"
+ const ten = renderToStaticMarkup(
+ createElement(DeleteNodeBody, {
+ nodes: Array.from({ length: 10 }, (_, i) => ({ id: `n${i}`, name: `Prop ${i}` })),
+ joins: JOINS,
+ impact: "loading",
+ busy: false,
+ failure: null,
+ onClose: () => {},
+ onRun: () => {},
+ }),
+ );
+ assert.match(ten, /Delete 10 propositions\?/);
+ assert.match(ten, /Prop 7/); // the 8th name still listed
+ assert.doesNotMatch(ten, /Prop 8); // the 9th is not
+ assert.match(ten, /, and 2 more/);
+ });
+
it("the body at gone: honest already-removed state, a Close that rides the same refresh", () => {
const html = bodyAt("gone");
assert.match(html, /no longer in the graph/);
@@ -92,10 +138,11 @@ describe("DeleteNodeDialog (thin SSR)", () => {
});
it("the body at resolved impact: plain fact lines, joined names (chats + notes files), the cannot-be-undoned copy", () => {
- const html = bodyAt({ edges: 2, quizEvents: 3, observations: 0, sessions: ["seedplan", "src_plato", "chat_ghost"] });
+ // (drift fix 2026-09-30: quizEvents/observations died with the
+ // quiz-events ledger — the impact carries links + sessions only)
+ const html = bodyAt({ edges: 2, sessions: ["seedplan", "src_plato", "chat_ghost"] });
assert.match(html, /2 links to other topics/);
- assert.match(html, /3 quiz answers/);
- assert.match(html, /0 session notes/); // zeros stay visible — no understate
+ assert.ok(!html.includes("quiz answer"), "the dead quiz fact is gone");
// joined display names: chat title, notes file path, unknown id honestly itself
assert.match(html, /Orbital mechanics — plan phase/);
assert.match(html, /\/notes\/plato\.md/);
@@ -107,7 +154,7 @@ describe("DeleteNodeDialog (thin SSR)", () => {
});
it("the body at failure: the 409 renders the activity-dock line with a Try-again", () => {
- const html = bodyAt({ edges: 0, quizEvents: 0, observations: 0, sessions: [] }, { failure: { code: "extraction_running", error: "server line" } });
+ const html = bodyAt({ edges: 0, sessions: [] }, { failure: { code: "extraction_running", error: "server line" } });
assert.match(html, /data-testid="node-delete-failure"/);
assert.match(html, new RegExp(EXTRACTION_RETRY_LINE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.doesNotMatch(html, /server line/); // the dock phrasing REPLACES the raw line for the known code
diff --git a/tests/render-chain-table.test.tsx b/tests/render-chain-table.test.tsx
index 758b37f..163a547 100644
--- a/tests/render-chain-table.test.tsx
+++ b/tests/render-chain-table.test.tsx
@@ -79,6 +79,7 @@ const BASE = {
onToggleNodes: () => {},
onClear: () => {},
onGenerate: () => {},
+ onDelete: () => {},
onClose: () => {},
};
@@ -255,13 +256,30 @@ describe("ChainTableBody — the floating selection bar (never shifts layout)",
assert.ok(!html.includes('data-testid="chain-selection-bar"'));
});
- it("appears with the derived N propositions → M chains + Clear + Generate", () => {
+ it("appears with the derived N propositions → M chains + Clear + Generate + the FENCED Delete", () => {
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"));
assert.ok(html.includes('data-testid="chain-clear"'));
assert.ok(html.includes('data-testid="chain-generate"'));
+ // The danger fence (owner, 2026-09-29 night): a divider quarantines
+ // the destructive action at the bar's right end, red + trash icon,
+ // riding the same confirm dialog as the canvas delete.
+ assert.ok(html.includes('data-testid="chain-danger-divider"'));
+ const del = html.match(/