diff --git a/README.md b/README.md index 51b9f68..fa341e1 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 — 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. +**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 nodes → 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/app/api/graph/nodes/[id]/route.ts b/app/api/graph/nodes/[id]/route.ts index a32581b..48bda7a 100644 --- a/app/api/graph/nodes/[id]/route.ts +++ b/app/api/graph/nodes/[id]/route.ts @@ -1,19 +1,4 @@ -import { deleteGraphNode, graphNodeImpact } from "@/src/server"; - -/** - * GET /api/graph/nodes/[id] — the deletion confirm dialog's pre-flight - * read (node interlude, owner-locked full-death semantics): what deleting - * this node would take with it. Edges touching it, its quiz/observation - * history, and the sessions that reference it — the facts the user weighs - * before pointing a destructive operation at the graph. 404 when the node - * does not exist. - */ -export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const impact = graphNodeImpact(id); - if (impact === null) return Response.json({ error: "No such node" }, { status: 404 }); - return Response.json(impact); -} +import { deleteGraphNode } from "@/src/server"; /** * DELETE /api/graph/nodes/[id] — single-node deletion, FULL DEATH (owner diff --git a/components/chain-table.tsx b/components/chain-table.tsx index 3320360..c540b08 100644 --- a/components/chain-table.tsx +++ b/components/chain-table.tsx @@ -250,7 +250,7 @@ export function ChainTableBody({ Chains - {chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} propositions + {chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} {nodes.length === 1 ? "node" : "nodes"}
@@ -258,8 +258,8 @@ export function ChainTableBody({ value={query} data-testid="chain-table-search" onChange={(e) => onQuery(e.target.value)} - placeholder="Search propositions…" - aria-label="Search propositions" + placeholder="Search nodes…" + aria-label="Search nodes" spellCheck={false} className="lh-no-drag h-8 w-56 rounded-lg border bg-background pl-8 pr-3 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" /> @@ -447,7 +447,7 @@ export function ChainTableBody({ checked={selection.has(id)} onToggle={(next) => onToggleNodes([id], next)} testid={`prop-checkbox-${id}`} - label={`Select proposition ${node.name}`} + label={`Select node ${node.name}`} /> {node.statement} @@ -475,7 +475,7 @@ export function ChainTableBody({ className="pointer-events-auto flex items-center gap-3 rounded-full border bg-card px-4 py-2 shadow-lg" > - {selection.size} {selection.size === 1 ? "proposition" : "propositions"} →{" "} + {selection.size} {selection.size === 1 ? "node" : "nodes"} →{" "} {derived.length} {derived.length === 1 ? "chain" : "chains"} -
+

+ {nodes.length === 1 + ? "This permanently deletes the node — this cannot be undone." + : `This permanently deletes all ${nodes.length} nodes — this cannot be undone.`} +

+ {failure && ( +
+ + {failure.code === "extraction_running" ? EXTRACTION_RETRY_LINE : failure.error} + +
)} - {typeof impact === "object" && ( - <> -

- This permanently removes the topic and everything attached to it — this cannot be undone: -

- - {failure && ( -
- - {failure.code === "extraction_running" ? EXTRACTION_RETRY_LINE : failure.error} - - -
- )} -
- - -
- - )} +
+ + +
); } export function DeleteNodeDialog({ nodes, - joins, onClose, onDeleted, }: { @@ -207,70 +136,23 @@ export function DeleteNodeDialog({ * 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). The - * deleted ids ride along so selection-side callers can prune. */ + /** Success path — the callers announce lh:graph-revision so the + * snapshot refetches. 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); const [failure, setFailure] = useState<{ code: string; error: string } | null>(null); - // Fresh state per node: closing resets; opening another node refetches. + // Fresh state per dialog: closing resets. useEffect(() => { if (!nodes || nodes.length === 0) { - setImpact("loading"); setBusy(false); setFailure(null); } }, [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 (!nodes || nodes.length === 0) return; - let cancelled = false; - setImpact("loading"); - setFailure(null); - (async () => { - try { - 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; - const live = impacts.filter((i): i is NodeImpact => i !== null); - if (live.length === 0) { - setImpact("gone"); - return; - } - 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 - } - })(); - return () => { - cancelled = true; - }; - }, [nodes]); - // Esc closes (claimed — a dialog Esc must not also abort a turn). useEffect(() => { if (!nodes || nodes.length === 0) return; @@ -331,8 +213,6 @@ export function DeleteNodeDialog({ > setDeletingNode(null)} onDeleted={(nodeIds, graphRevision) => { // Close the dialog; close the detail card if it pointed here diff --git a/components/practice-dialog.tsx b/components/practice-dialog.tsx index 1b76d26..ff7fce4 100644 --- a/components/practice-dialog.tsx +++ b/components/practice-dialog.tsx @@ -140,14 +140,14 @@ export function PracticeDialogBody({ {cluster.kind === "notes" ? "notes · " : ""} - {cluster.nodeCount} propositions + {cluster.nodeCount} {cluster.nodeCount === 1 ? "node" : "nodes"} ))} {clusters.length === 0 && (
No clusters yet — a cluster appears once a session births its - first proposition. + first node.
)} @@ -166,7 +166,7 @@ export function PracticeDialogBody({
Practice — {new Date().toISOString().slice(0, 10)}
- {selection?.propositions ?? 0} {selection?.propositions === 1 ? "proposition" : "propositions"} →{" "} + {selection?.propositions ?? 0} {selection?.propositions === 1 ? "node" : "nodes"} →{" "} {selection?.chains ?? 0} {selection?.chains === 1 ? "chain" : "chains"}
diff --git a/docs/architecture.md b/docs/architecture.md index c04339f..e609484 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**, 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. +**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 nodes → M chains` (NODE is THE UI word for graph objects — owner ruling, 2026-09-30, standardized across every surface's copy), 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) — MINIMAL by owner ruling (2026-09-30): NO pre-flight impact read (what full death takes is obvious, the fetch flickered, and richer facts would only confuse as deletion semantics evolve toward rewiring), just `Delete node?` / `Delete N nodes?` + the names in the mono block (capped at 8 + "and K more") + one permanence line + Cancel/Delete (the `GET /api/graph/nodes/[id]` impact route and its engine died with the read); 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/src/core/graph-deletion.ts b/src/core/graph-deletion.ts index eef8fd2..849b25a 100644 --- a/src/core/graph-deletion.ts +++ b/src/core/graph-deletion.ts @@ -260,33 +260,10 @@ export interface NodeDeletionStats { edgesRemoved: number; } -/** The confirm dialog's pre-flight facts (the GET on the same node id): - * what full death would take with it. sessions is the deduped set of - * chats/notes sources that reference the node — provenance points count. */ -export interface NodeDeletionImpact { - edges: number; - sessions: string[]; -} - -/** A node exists iff a ledger line carries its id. */ function nodeLinesOf(graphStore: GraphStore, nodeId: string): GraphNode[] { return graphStore.readNodes().filter((line) => line.id === nodeId); } -/** The impact facts for one node, or null when no such node exists. */ -export function nodeDeletionImpact(graphStore: GraphStore, nodeId: string): NodeDeletionImpact | null { - if (nodeLinesOf(graphStore, nodeId).length === 0) return null; - const edges = graphStore.readEdges().filter((line) => line.from === nodeId || line.to === nodeId); - const sessions = new Set(); - for (const line of nodeLinesOf(graphStore, nodeId)) { - for (const pt of line.provenance) sessions.add(pt.chatId); - } - return { - edges: edges.length, - sessions: [...sessions].sort(), - }; -} - /** * Full-death one node. Returns null when no such node exists (the * orchestration maps that to 404); rewrites only the ledgers that diff --git a/src/server/graph.ts b/src/server/graph.ts index 8878c8f..9dbc015 100644 --- a/src/server/graph.ts +++ b/src/server/graph.ts @@ -7,8 +7,6 @@ import { graphSnapshot } from "../core/graph.ts"; import { birthSessionPartition } from "../core/session-clusters.ts"; import { deleteNodeTraces, - nodeDeletionImpact, - type NodeDeletionImpact, type NodeDeletionStats, } from "../core/graph-deletion.ts"; import { extractionStatuses } from "../agent/subagents/extraction.ts"; @@ -140,9 +138,3 @@ export function deleteGraphNode(nodeId: string): NodeDeleteOutcome { return { ok: true, stats, graphRevision: graphRevision() }; } -/** The confirm dialog's pre-flight read (GET /api/graph/nodes/[id]): - * what full death would take with it. 404-shaped null when the node - * does not exist — the UI shows the dialog only for nodes it can see. */ -export function graphNodeImpact(nodeId: string): NodeDeletionImpact | null { - return nodeDeletionImpact(stores.graphStore, nodeId); -} diff --git a/tests/delete-node-dialog.test.tsx b/tests/delete-node-dialog.test.tsx index 62bcf8a..6c94436 100644 --- a/tests/delete-node-dialog.test.tsx +++ b/tests/delete-node-dialog.test.tsx @@ -4,33 +4,29 @@ import { renderToStaticMarkup } from "react-dom/server"; import { createElement } from "react"; /** - * The node-deletion affordance (node interlude, Platform's 50bfcba - * engine): render pins for the full-death confirm dialog — the copy - * and fact-joining are design, not implementation. The delete path - * itself is live-verified against the real API (dev-verification - * notes); SSR here pins what static rendering CAN pin. + * The node-deletion confirm dialog (node interlude, Platform's 50bfcba + * engine; multi-node extension + MINIMAL REWRITE, owner 2026-09-30): + * render pins for the copy — the copy is design, not implementation. + * The delete path itself is live-verified against the real API + * (dev-verification notes); SSR here pins what static rendering CAN + * pin. + * + * The minimal contract (owner ruling, 2026-09-30): NO pre-flight + * impact read — what full death takes is obvious, the fetch + * flickered, and richer facts would only confuse as deletion + * semantics evolve (rewiring talk is on the table). Title + names + + * one permanence line + Cancel/Delete. And "node"/"nodes" is THE UI + * word for graph objects (standardized everywhere, same ruling). */ -import { - DeleteNodeBody, - DeleteNodeDialog, - EXTRACTION_RETRY_LINE, - countLine, - sessionNameOf, - type NodeImpact, -} from "../components/delete-node-dialog.tsx"; +import { DeleteNodeBody, DeleteNodeDialog, EXTRACTION_RETRY_LINE } from "../components/delete-node-dialog.tsx"; -const JOINS = { sessions: { seedplan: "Orbital mechanics — plan phase" }, fileSources: { src_plato: "/notes/plato.md" } }; - -/** The body at a given impact state — the wrapper owns effects, the body - * is pure, so SSR pins everything the live dialog shows. */ -function bodyAt(impact: NodeImpact | "loading" | "gone", extra?: { failure?: { code: string; error: string } | null }) { +/** The body — pure, so SSR pins everything the live dialog shows. */ +function bodyAt(nodes: { id: string; name: string }[], extra?: { failure?: { code: string; error: string } | null; busy?: boolean }) { return renderToStaticMarkup( createElement(DeleteNodeBody, { - nodes: [{ id: "kepler", name: "Kepler's three laws" }], - joins: JOINS, - impact, - busy: false, + nodes, + busy: extra?.busy ?? false, failure: extra?.failure ?? null, onClose: () => {}, onRun: () => {}, @@ -38,24 +34,6 @@ function bodyAt(impact: NodeImpact | "loading" | "gone", extra?: { failure?: { c ); } -describe("sessionNameOf (the client-side join)", () => { - it("chat titles from sessions, imported-notes paths from fileSources, unknown ids honestly", () => { - const sessions = { chat_a1: "Orbital mechanics" }; - const fileSources = { src_notes1: "/users/amos/notes/plato.md" }; - assert.equal(sessionNameOf("chat_a1", sessions, fileSources), "Orbital mechanics"); - assert.equal(sessionNameOf("src_notes1", sessions, fileSources), "/users/amos/notes/plato.md"); - assert.equal(sessionNameOf("chat_gone", sessions, fileSources), "chat_gone"); - }); -}); - -describe("countLine (honest plurals)", () => { - it("1 reads singular, 0 and n read plural — zeros stay visible", () => { - assert.equal(countLine(1, "link to another topic", "links to other topics"), "1 link to another topic"); - assert.equal(countLine(0, "quiz answer", "quiz answers"), "0 quiz answers"); - assert.equal(countLine(3, "session note", "session notes"), "3 session notes"); - }); -}); - describe("EXTRACTION_RETRY_LINE (the 409 phrasing, owner-locked)", () => { it("names the activity dock — the surface the user watches drain", () => { assert.equal(EXTRACTION_RETRY_LINE, "An extraction is running — try again when the activity dock drains."); @@ -63,101 +41,85 @@ describe("EXTRACTION_RETRY_LINE (the 409 phrasing, owner-locked)", () => { }); describe("DeleteNodeDialog (thin SSR)", () => { - it("null node renders nothing", () => { - const html = renderToStaticMarkup( - createElement(DeleteNodeDialog, { nodes: null, joins: { sessions: {}, fileSources: {} }, onClose: () => {}, onDeleted: () => {} }), - ); - assert.equal(html, ""); + it("null and empty nodes both render nothing — closed either way", () => { + for (const nodes of [null, []] as ({ id: string; name: string }[] | null)[]) { + const html = renderToStaticMarkup( + createElement(DeleteNodeDialog, { nodes, 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, ""); - }); - - it("the body at loading: the frame + the pre-flight read pending (effects never fire in SSR — the wrapper's own state)", () => { - const html = bodyAt("loading"); +describe("DeleteNodeBody — the minimal screen (owner, 2026-09-30)", () => { + it("single: 'Delete node?' title, the name alone in mono, the one permanence line", () => { + const html = bodyAt([{ id: "kepler", name: "Kepler's three laws" }]); assert.match(html, /data-testid="delete-node-dialog"/); - // (owner, 2026-09-24) the serif title is just "Delete node?" in - // destructive red; the NAME rides below in JetBrains Mono, sized up - // (text-lg), NO code-block chrome — same shape as the session dialog. + // (owner, 2026-09-24) the serif title in destructive red; the + // NAME rides below in JetBrains Mono, text-lg, NO code-block + // chrome — same shape as the session dialog. assert.match(html, /Delete node\?/); - assert.match(html, /text-destructive/); // the red title + assert.match(html, /text-destructive/); const name = html.match(/data-testid="node-name-code"[^>]*>/)?.[0] ?? ""; - assert.match(name, /font-mono text-lg/); // bigger, mono - assert.ok(!name.includes("bg-muted"), "no code-block chrome — the font already says code") - assert.match(html, /data-testid="node-name-code"[^>]*>Kepler's three laws { - 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 { - const html = bodyAt("gone"); - assert.match(html, /no longer in the graph/); - assert.match(html, /data-testid="node-delete-vanished-close"/); - }); - - it("the body at resolved impact: plain fact lines, joined names (chats + notes files), the cannot-be-undoned copy", () => { - // (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.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/); - assert.match(html, /chat_ghost/); - assert.match(html, /cannot be undone/); + assert.match(name, /font-mono text-lg/); + assert.ok(!name.includes("bg-muted"), "no code-block chrome — the font already says code"); + assert.match(html, /data-testid="node-name-code"[^>]*>Kepler's three laws]*>This permanently deletes the node — this cannot be undone\./); + assert.match(html, /data-testid="node-delete-cancel"/); assert.match(html, /data-testid="node-delete-confirm"/); // the Delete button carries the destructive red (owner, polish round) assert.match(html, /border-destructive\/50[^>]*text-destructive/); }); - it("the body at failure: the 409 renders the activity-dock line with a Try-again", () => { - 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 - assert.match(html, /data-testid="node-delete-retry"/); + it("NO impact facts, NO loading line, NO sessions join — the pre-flight read is dead", () => { + const html = bodyAt([{ id: "kepler", name: "Kepler's three laws" }]); + assert.ok(!html.includes("node-impact-facts"), "no facts list"); + assert.ok(!html.includes("node-impact-loading"), "no loading line"); + assert.ok(!html.includes("Reading what this would remove"), "no fetch copy"); + assert.ok(!html.includes("Referenced by"), "no sessions join"); + assert.ok(!html.includes("links to other topics"), "no edge count — it is obvious"); + }); + + it("multi: 'Delete N nodes?' (the UI word is node, owner 2026-09-30), names capped at 8 + and-K-more", () => { + const three = bodyAt([ + { id: "kepler", name: "Kepler's three laws" }, + { id: "newton", name: "Newton's second law" }, + { id: "hook", name: "Hooke's law" }, + ]); + assert.match(three, /Delete 3 nodes\?/); // plural title, NODES + assert.match(three, /data-testid="node-names-code"/); + 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"); + assert.match(three, /This permanently deletes all 3 nodes — this cannot be undone\./); + assert.ok(!three.includes("proposition"), "the old word is gone from the dialog"); + + // the cap: 10 names render 8 + ", and 2 more" + const ten = bodyAt(Array.from({ length: 10 }, (_, i) => ({ id: `n${i}`, name: `Prop ${i}` }))); + assert.match(ten, /Delete 10 nodes\?/); + assert.match(ten, /Prop 7/); // the 8th name still listed + assert.doesNotMatch(ten, /Prop 8 { + const conflict = bodyAt([{ id: "kepler", name: "Kepler's three laws" }], { + failure: { code: "extraction_running", error: "server line" }, + }); + assert.match(conflict, /data-testid="node-delete-failure"/); + assert.match(conflict, new RegExp(EXTRACTION_RETRY_LINE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.doesNotMatch(conflict, /server line/); // the dock phrasing REPLACES the raw line for the known code + assert.match(conflict, /data-testid="node-delete-retry"/); + + const plain = bodyAt([{ id: "kepler", name: "Kepler's three laws" }], { + failure: { code: "not_found", error: "No such node" }, + }); + assert.match(plain, /No such node/); // honest raw line otherwise + }); + + it("busy: the confirm reads 'Deleting…' and both buttons disable", () => { + const html = bodyAt([{ id: "kepler", name: "Kepler's three laws" }], { busy: true }); + assert.match(html, /Deleting…]*disabled/); + assert.match(html, /data-testid="node-delete-confirm"[^>]*disabled/); }); }); diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 42d6c36..31f2301 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -591,7 +591,6 @@ import { beginTurn, endTurn } from "../src/server/turn-liveness.ts"; import { jobStore } from "../src/store/jobs.ts"; import { deleteNodeTraces, - nodeDeletionImpact, type NodeDeletionStats, } from "../src/core/graph-deletion.ts"; import { deleteGraphNode, graphRevision } from "../src/server/graph.ts"; @@ -810,18 +809,6 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu graphStore.writePositions({ n_dead: { x: 1, y: 1 }, n_keep: { x: 2, y: 2 } }); } - it("nodeDeletionImpact: the confirm dialog's pre-flight facts", () => { - buildNodeScenario(); - assert.deepEqual(nodeDeletionImpact(graphStore, "n_dead"), { - edges: 2, - // The re-encounter — the only session provenance can name (the - // legacy empty-provenance line names none). - sessions: ["chat_node_a"], - }); - // Unknown node: null (the route's 404). - assert.equal(nodeDeletionImpact(graphStore, "n_missing"), null); - }); - it("deleteNodeTraces: FULL DEATH — the node, its edges, its evidence, its anchors, its position", () => { buildNodeScenario(); const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep")); diff --git a/tests/render-chain-table.test.tsx b/tests/render-chain-table.test.tsx index 163a547..740a967 100644 --- a/tests/render-chain-table.test.tsx +++ b/tests/render-chain-table.test.tsx @@ -256,11 +256,11 @@ 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 + the FENCED Delete", () => { + it("appears with the derived N nodes → 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("3 nodes → 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 @@ -269,7 +269,7 @@ describe("ChainTableBody — the floating selection bar (never shifts layout)", assert.ok(html.includes('data-testid="chain-danger-divider"')); const del = html.match(/]*data-testid="chain-delete"[^>]*>/)?.[0] ?? ""; assert.match(del, /border-destructive\/50/); // the destructive pair - assert.match(del, /aria-label="Delete 3 selected propositions"/); + assert.match(del, /aria-label="Delete 3 selected nodes"/); assert.ok(html.includes("lucide-trash-2"), "the trash icon"); // the fence is AFTER generate in the DOM (right end of the bar) assert.ok(html.indexOf('chain-generate"') < html.indexOf('chain-danger-divider"')); @@ -284,7 +284,7 @@ describe("ChainTableBody — the floating selection bar (never shifts layout)", 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("2 nodes → 2 chains"), "no follows-edge survives inside the selection"); }); }); @@ -303,7 +303,7 @@ describe("ChainTableBody — the fuzzy search (grouped + auto-expand)", () => { 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("3 nodes → 1 chain")); }); it("no matches: the honest empty line", () => { diff --git a/tests/render-practice.test.tsx b/tests/render-practice.test.tsx index f41148a..d112a63 100644 --- a/tests/render-practice.test.tsx +++ b/tests/render-practice.test.tsx @@ -144,8 +144,8 @@ describe("PracticeDialogBody — creation (cluster picker)", () => { assert.ok(html.includes('data-testid="practice-dialog-clusters"')); assert.ok(html.includes('data-testid="practice-cluster-chat_1"')); assert.ok(html.includes("Ethics lesson")); - assert.match(html, /notes · 3 propositions|notes · 3 propositions/); - assert.ok(html.includes("4 propositions"), "chat cluster shows its count"); + assert.match(html, /notes · 3 nodes|notes · 3 nodes/); + assert.ok(html.includes("4 nodes"), "chat cluster shows its count"); assert.ok(html.includes('disabled=""'), "no cluster picked — Create disabled"); const picked = renderToStaticMarkup(); @@ -161,15 +161,15 @@ describe("PracticeDialogBody — creation (cluster picker)", () => { it("an empty graph is an honest empty list", () => { const html = renderToStaticMarkup(); assert.ok(html.includes("No clusters yet")); - assert.ok(html.includes("once a session births its first proposition"), "no 3+ mechanics in copy"); + assert.ok(html.includes("once a session births its first node"), "no 3+ mechanics in copy"); }); it("1- and 2-proposition groups appear — no hull threshold in the picker", () => { const html = renderToStaticMarkup(); assert.ok(html.includes('data-testid="practice-cluster-chat_2"'), "singleton group listed"); - assert.ok(html.includes("1 propositions")); + assert.ok(html.includes("1 node")); assert.ok(html.includes('data-testid="practice-cluster-chat_3"'), "pair group listed"); - assert.ok(html.includes("2 propositions")); + assert.ok(html.includes("2 nodes")); }); it("empty selection (vanished nodes) renders the honest inline line", () => { @@ -217,7 +217,7 @@ describe("PracticeDialogBody — creation (CUSTOM mode, the chains overlay's han assert.ok(html.includes('data-testid="practice-dialog-selection"')); assert.match(html, /Practice — ()?\d{4}-\d{2}-\d{2}/, "the dated default title (the pipeline's own stamp)"); assert.ok(html.includes('data-testid="practice-dialog-selection-line"')); - assert.ok(html.includes("4 propositions")); + assert.ok(html.includes("4 nodes")); assert.ok(html.includes("2 chains")); assert.ok(!html.includes('disabled=""'), "a non-empty selection enables Create immediately"); }); @@ -228,7 +228,7 @@ describe("PracticeDialogBody — creation (CUSTOM mode, the chains overlay's han ); assert.ok(html.includes('data-testid="param-ladder"')); assert.ok(html.includes('data-testid="practice-note"')); - assert.ok(html.includes("1 proposition →")); + assert.ok(html.includes("1 node →")); }); });