deletion screen minimal + 'node' as THE UI word (owner ruling)
Owner 2026-09-30: the confirm dialog's pre-flight impact read had to go — what full death takes is obvious, the per-open fetch flickered, and richer facts would only confuse as deletion semantics evolve (rewiring is on the table). The screen is now: Delete node? / Delete N nodes?, the names in the mono block, one permanence line, Cancel / Delete. No loading state, no gone state (a 404 mid-run lands in the honest failure line), no sessions join — the joins prop died too. The dead path is deleted end to end: GET /api/graph/nodes/[id], graphNodeImpact (server), nodeDeletionImpact (core) and their tests. WORDING: node/node(s) is THE UI word for graph objects, standardized across every surface's copy (owner, same ruling): the dialog titles, the chain table's counts/search/summary/delete aria-labels, the practice dialog's cluster counts, selection line, and empty-cluster hint. Internal identifiers (selection.propositions) unchanged. Rewritten dialog pins (tests/delete-node-dialog.test.tsx): minimal contract — no facts/loading/fetch copy, singular/plural titles, the cap, 409 dock phrasing, busy states, no 'proposition' left in the dialog.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -250,7 +250,7 @@ export function ChainTableBody({
|
||||
<TopBar>
|
||||
<span className="min-w-0 flex-1 truncate font-title text-3xl tracking-tight">Chains</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} propositions
|
||||
{chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} {nodes.length === 1 ? "node" : "nodes"}
|
||||
</span>
|
||||
<div className="relative shrink-0">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -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}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-xs leading-relaxed text-muted-foreground">
|
||||
{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"
|
||||
>
|
||||
<span data-testid="chain-selected-summary" className="text-xs font-medium">
|
||||
{selection.size} {selection.size === 1 ? "proposition" : "propositions"} →{" "}
|
||||
{selection.size} {selection.size === 1 ? "node" : "nodes"} →{" "}
|
||||
{derived.length} {derived.length === 1 ? "chain" : "chains"}
|
||||
</span>
|
||||
<button
|
||||
@@ -503,7 +503,7 @@ export function ChainTableBody({
|
||||
type="button"
|
||||
data-testid="chain-delete"
|
||||
onClick={onDelete}
|
||||
aria-label={`Delete ${selection.size} selected ${selection.size === 1 ? "proposition" : "propositions"}`}
|
||||
aria-label={`Delete ${selection.size} selected ${selection.size === 1 ? "node" : "nodes"}`}
|
||||
className="flex items-center gap-1.5 rounded-full border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" className="size-3.5" />
|
||||
@@ -668,7 +668,6 @@ export function ChainTableOverlay({
|
||||
{deleteOpen && (
|
||||
<DeleteNodeDialog
|
||||
nodes={selectedNodes.map((n) => ({ id: n.id, name: n.name }))}
|
||||
joins={{ sessions, fileSources }}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onDeleted={(deadIds, graphRevision) => {
|
||||
// Close the dialog; prune the dead ids from the
|
||||
|
||||
@@ -3,74 +3,42 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/** 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;
|
||||
sessions: string[];
|
||||
}
|
||||
|
||||
/** Join an id to its display name: chat titles from the snapshot's
|
||||
* sessions map, imported-notes paths from fileSources; an unknown id
|
||||
* renders honestly as itself. Exported pure for render pins. */
|
||||
export function sessionNameOf(
|
||||
id: string,
|
||||
sessions: Record<string, string>,
|
||||
fileSources: Record<string, string>,
|
||||
): string {
|
||||
return sessions[id] ?? fileSources[id] ?? id;
|
||||
}
|
||||
|
||||
/** The 409 extraction_running line (owner phrasing, node interlude):
|
||||
* the server's error names the extraction; the ACTIVITY DOCK is the
|
||||
* surface the user actually watches drain. Exported pure for pins. */
|
||||
export const EXTRACTION_RETRY_LINE = "An extraction is running — try again when the activity dock drains.";
|
||||
|
||||
/** The fact line for one count — honest plurals, no theater ("0 edges"
|
||||
* reads plainly; hiding a zero fact would understate the blast). */
|
||||
export function countLine(n: number, one: string, many: string): string {
|
||||
return `${n} ${n === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node deletion (node interlude, Platform's 50bfcba engine) — the
|
||||
* confirm surface, portaled z-50 like the session-delete dialog whose
|
||||
* tone it follows. FULL DEATH by owner ruling: the node, every edge
|
||||
* touching it, every quiz/observation event naming it, its mapping
|
||||
* lines, its position. The impact facts render plainly so the user
|
||||
* weighs the blast radius; "this cannot be undone" says the permanence.
|
||||
* touching it, every event naming it, its position.
|
||||
*
|
||||
* The 409 extraction_running gets the dock phrasing (the server's
|
||||
* honest line names the extraction; the ACTIVITY DOCK is the surface
|
||||
* the user actually watches drain) with a Try-again — the run is never
|
||||
* aborted server-side, it clears on its own. The 404 (deleted elsewhere
|
||||
* meanwhile) states it plainly and closes into the same refresh: the
|
||||
* next snapshot read tells the truth.
|
||||
* MINIMAL BY OWNER RULING (2026-09-30): NO pre-flight impact read —
|
||||
* what full death takes is obvious (the edges die with the node), the
|
||||
* fetch flickered the dialog open-closed, and richer facts would only
|
||||
* grow more confusing as deletion semantics evolve (rewiring talk is
|
||||
* on the table). The screen is: title, names, one permanence line,
|
||||
* Cancel / Delete. The 409 extraction_running still gets the dock
|
||||
* phrasing with a Try-again — the run is never aborted server-side,
|
||||
* it clears on its own; other failures state themselves plainly.
|
||||
*
|
||||
* WORDING: "node"/"nodes" — THE UI word for graph objects (owner
|
||||
* ruling, same day, standardized everywhere in the UI).
|
||||
*/
|
||||
/** The dialog's pure body — exported for SSR pins (the settings-dialog
|
||||
* body pattern). The wrapper owns state + effects + the portal. */
|
||||
export function DeleteNodeBody({
|
||||
nodes,
|
||||
joins,
|
||||
impact,
|
||||
busy,
|
||||
failure,
|
||||
onClose,
|
||||
onRun,
|
||||
}: {
|
||||
/** 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. */
|
||||
* selection). One names it alone; many pluralize the title and
|
||||
* list the names. */
|
||||
nodes: { id: string; name: string }[];
|
||||
joins: { sessions: Record<string, string>; fileSources: Record<string, string> };
|
||||
impact: NodeImpact | "loading" | "gone";
|
||||
busy: boolean;
|
||||
failure: { code: string; error: string } | null;
|
||||
onClose: () => void;
|
||||
@@ -80,20 +48,20 @@ export function DeleteNodeBody({
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Delete topic"
|
||||
aria-label="Delete node"
|
||||
data-testid="delete-node-dialog"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
|
||||
>
|
||||
<h2 className="font-title text-2xl tracking-tight text-destructive">
|
||||
{nodes.length === 1 ? "Delete node?" : `Delete ${nodes.length} propositions?`}
|
||||
{nodes.length === 1 ? "Delete node?" : `Delete ${nodes.length} nodes?`}
|
||||
</h2>
|
||||
{/* 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.
|
||||
A multi-selection lists its names (capped at 8 + "and K
|
||||
more" — the facts below already carry the full blast). */}
|
||||
more" — the count in the title carries the full number). */}
|
||||
<div className="mt-1.5">
|
||||
{nodes.length === 1 ? (
|
||||
<code data-testid="node-name-code" className="font-mono text-lg text-foreground">
|
||||
@@ -106,99 +74,60 @@ export function DeleteNodeBody({
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
{impact === "loading" && (
|
||||
<p className="mt-2 text-sm text-muted-foreground" data-testid="node-impact-loading">
|
||||
Reading what this would remove…
|
||||
</p>
|
||||
)}
|
||||
{impact === "gone" && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This topic is no longer in the graph — it was already removed elsewhere.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="node-delete-vanished-close"
|
||||
onClick={onClose}
|
||||
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground" data-testid="node-permanence-line">
|
||||
{nodes.length === 1
|
||||
? "This permanently deletes the node — this cannot be undone."
|
||||
: `This permanently deletes all ${nodes.length} nodes — this cannot be undone.`}
|
||||
</p>
|
||||
{failure && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="node-delete-failure"
|
||||
className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs"
|
||||
>
|
||||
<span className="text-foreground">
|
||||
{failure.code === "extraction_running" ? EXTRACTION_RETRY_LINE : failure.error}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="node-delete-retry"
|
||||
onClick={onRun}
|
||||
disabled={busy}
|
||||
className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{typeof impact === "object" && (
|
||||
<>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This permanently removes the topic and everything attached to it — this cannot be undone:
|
||||
</p>
|
||||
<ul
|
||||
data-testid="node-impact-facts"
|
||||
className="mt-3 space-y-1 rounded-lg border border-border/60 px-4 py-3 text-sm"
|
||||
>
|
||||
<li>{countLine(impact.edges, "link to another topic", "links to other topics")}</li>
|
||||
<li className="text-muted-foreground">
|
||||
Referenced by{" "}
|
||||
{impact.sessions.length === 0
|
||||
? "no session"
|
||||
: impact.sessions
|
||||
.map((id) => sessionNameOf(id, joins.sessions, joins.fileSources))
|
||||
.join(", ")}
|
||||
</li>
|
||||
</ul>
|
||||
{failure && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="node-delete-failure"
|
||||
className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs"
|
||||
>
|
||||
<span className="text-foreground">
|
||||
{failure.code === "extraction_running" ? EXTRACTION_RETRY_LINE : failure.error}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="node-delete-retry"
|
||||
onClick={onRun}
|
||||
disabled={busy}
|
||||
className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="node-delete-cancel"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="node-delete-confirm"
|
||||
onClick={onRun}
|
||||
disabled={busy}
|
||||
// Destructive red (owner, polish round) — the
|
||||
// app's established destructive pair (chat's
|
||||
// refusal banner): border + text, fill on hover.
|
||||
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20 disabled:opacity-40"
|
||||
>
|
||||
{busy ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="node-delete-cancel"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="node-delete-confirm"
|
||||
onClick={onRun}
|
||||
disabled={busy}
|
||||
// Destructive red (owner, polish round) — the
|
||||
// app's established destructive pair (chat's
|
||||
// refusal banner): border + text, fill on hover.
|
||||
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20 disabled:opacity-40"
|
||||
>
|
||||
{busy ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string>; fileSources: Record<string, string> };
|
||||
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<NodeImpact | "loading" | "gone">("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<string>();
|
||||
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({
|
||||
>
|
||||
<DeleteNodeBody
|
||||
nodes={nodes}
|
||||
joins={joins}
|
||||
impact={impact}
|
||||
busy={busy}
|
||||
failure={failure}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -1538,10 +1538,6 @@ export function GraphView() {
|
||||
body by the component itself, lives here for state locality. */}
|
||||
<DeleteNodeDialog
|
||||
nodes={deletingNode ? [deletingNode] : null}
|
||||
joins={{
|
||||
sessions: snapshot?.sessions ?? {},
|
||||
fileSources: snapshot?.fileSources ?? {},
|
||||
}}
|
||||
onClose={() => setDeletingNode(null)}
|
||||
onDeleted={(nodeIds, graphRevision) => {
|
||||
// Close the dialog; close the detail card if it pointed here
|
||||
|
||||
@@ -140,14 +140,14 @@ export function PracticeDialogBody({
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{cluster.kind === "notes" ? "notes · " : ""}
|
||||
{cluster.nodeCount} propositions
|
||||
{cluster.nodeCount} {cluster.nodeCount === 1 ? "node" : "nodes"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{clusters.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
No clusters yet — a cluster appears once a session births its
|
||||
first proposition.
|
||||
first node.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -166,7 +166,7 @@ export function PracticeDialogBody({
|
||||
<div className="rounded-lg border bg-accent/30 px-3 py-2 text-sm">
|
||||
<div className="text-muted-foreground">Practice — {new Date().toISOString().slice(0, 10)}</div>
|
||||
<div data-testid="practice-dialog-selection-line">
|
||||
{selection?.propositions ?? 0} {selection?.propositions === 1 ? "proposition" : "propositions"} →{" "}
|
||||
{selection?.propositions ?? 0} {selection?.propositions === 1 ? "node" : "nodes"} →{" "}
|
||||
{selection?.chains ?? 0} {selection?.chains === 1 ? "chain" : "chains"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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<string>();
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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</); // the name in mono
|
||||
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/);
|
||||
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</);
|
||||
assert.match(html, /data-testid="node-permanence-line"[^>]*>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</); // the 9th is not
|
||||
assert.match(ten, /, and 2 more/);
|
||||
});
|
||||
|
||||
it("failure: the 409 renders the activity-dock line with a Try-again; other codes state themselves", () => {
|
||||
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…</);
|
||||
assert.match(html, /data-testid="node-delete-cancel"[^>]*disabled/);
|
||||
assert.match(html, /data-testid="node-delete-confirm"[^>]*disabled/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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(/<button[^>]*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", () => {
|
||||
|
||||
@@ -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(<PracticeDialogBody {...base} selectedKey="src_ab12" />);
|
||||
@@ -161,15 +161,15 @@ describe("PracticeDialogBody — creation (cluster picker)", () => {
|
||||
it("an empty graph is an honest empty list", () => {
|
||||
const html = renderToStaticMarkup(<PracticeDialogBody {...base} clusters={[]} />);
|
||||
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(<PracticeDialogBody {...base} clusters={smallClusters()} />);
|
||||
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 →"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user