chain table: the fenced Delete action + multi-node delete dialog (owner ruling)
"This is really the view deletion should be done in" — the chain table
becomes the graph's MANAGEMENT surface. The floating selection bar
gains Delete FENCED at its right end (owner-approved placement): a
divider quarantines the destructive action from Clear and Generate,
red destructive pair + trash icon, opening the SAME full-death confirm
dialog the canvas uses.
DeleteNodeDialog extended from one node to an array:
- one node renders exactly as always; many pluralize the title
(Delete N propositions?) and list the names in the mono block,
capped at 8 + 'and K more'
- the pre-flight impact read aggregates: summed edge counts, unioned
referencing sessions (deduped, first-seen order); all-404 = gone
- confirm runs the per-node DELETEs SEQUENTIALLY — a failure stops
the run (extraction_running is never plowed through); onDeleted
now carries every id that actually died
- the chain table prunes its selection on delete and announces
lh:graph-revision so the snapshot refetches
Drift fix found en route (surfaced as NaN in the aggregated facts):
the server's NodeDeletionImpact has been {edges, sessions} only since
the quiz-events ledger died in the practice-arc schema slim — the
dialog kept rendering quizEvents/observations as undefined. The dead
fact lines are removed; the dialog now shows exactly what the server
can honestly report.
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. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
|
||||
**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N propositions → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
|
||||
|
||||
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server.
|
||||
|
||||
|
||||
@@ -51,8 +51,13 @@
|
||||
* the Google-Photos pattern — appears only when something is selected,
|
||||
* showing the agents' ACTUAL input (chainsForSelection over the flat
|
||||
* node-id selection: a premise without its conclusion counts as the
|
||||
* singleton it becomes) + Clear + Generate problem set (the one action
|
||||
* for now; the bar is a neutral action slot for future ones). The fuzzy
|
||||
* singleton it becomes) + Clear + Generate problem set. DELETE (owner,
|
||||
* 2026-09-29 night: "this is really the view deletion should be done
|
||||
* in") rides the same bar FENCED at the right end — divider + the
|
||||
* destructive red pair — and opens the SAME full-death confirm dialog
|
||||
* the canvas uses (multi-selections list their names + the unioned
|
||||
* impact); this ends the overlay's read-only stance: it is the graph's
|
||||
* MANAGEMENT surface now. The fuzzy
|
||||
* search (names, aliases, statements) filters chains grouped +
|
||||
* auto-expanded under their clusters (non-matching clusters hide; the
|
||||
* collapsed view returns on clear).
|
||||
@@ -68,6 +73,7 @@ import {
|
||||
ChevronUpIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
Trash2Icon,
|
||||
ListTreeIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
@@ -76,6 +82,7 @@ import { chainsForSelection, formatChainLine, fuzzyScore } from "@/src/core";
|
||||
import type { GraphEdge, GraphNode } from "@/src/core";
|
||||
import { TopBar } from "@/components/top-bar";
|
||||
import { PracticeDialog } from "@/components/practice-dialog";
|
||||
import { DeleteNodeDialog } from "@/components/delete-node-dialog";
|
||||
|
||||
/** The sortable columns (owner ruling: all three — consistency). */
|
||||
export type ChainSortCol = "name" | "props" | "created";
|
||||
@@ -117,6 +124,7 @@ export function ChainTableBody({
|
||||
onToggleNodes,
|
||||
onClear,
|
||||
onGenerate,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
nodes: GraphNode[];
|
||||
@@ -137,6 +145,8 @@ export function ChainTableBody({
|
||||
onToggleNodes: (ids: string[], next: boolean) => void;
|
||||
onClear: () => void;
|
||||
onGenerate: () => void;
|
||||
/** Opens the full-death confirm dialog (owner, 2026-09-29 night). */
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
|
||||
@@ -484,6 +494,21 @@ export function ChainTableBody({
|
||||
>
|
||||
Generate problem set
|
||||
</button>
|
||||
{/* The danger fence (owner ruling): a divider quarantines
|
||||
the destructive action at the bar's right end — the
|
||||
safe actions and the kill switch never blend. The
|
||||
full-death confirm dialog catches the misclick. */}
|
||||
<span data-testid="chain-danger-divider" aria-hidden="true" className="h-4 w-px bg-border/70" />
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chain-delete"
|
||||
onClick={onDelete}
|
||||
aria-label={`Delete ${selection.size} selected ${selection.size === 1 ? "proposition" : "propositions"}`}
|
||||
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" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -564,17 +589,18 @@ export function ChainTableOverlay({
|
||||
const [expandedClusters, setExpandedClusters] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [expandedChains, setExpandedChains] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
// Esc dismisses (unless the practice dialog has focus — its own Esc
|
||||
// owns that layer; both listen on window, so the guard is explicit).
|
||||
// Esc dismisses (unless a dialog has focus — each dialog's own Esc
|
||||
// owns its layer; all listen on window, so the guards are explicit).
|
||||
useEffect(() => {
|
||||
if (!open || dialogOpen) return;
|
||||
if (!open || dialogOpen || deleteOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [open, dialogOpen, onClose]);
|
||||
}, [open, dialogOpen, deleteOpen, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -628,6 +654,7 @@ export function ChainTableOverlay({
|
||||
onToggleNodes={toggleNodes}
|
||||
onClear={() => setSelection(new Set())}
|
||||
onGenerate={() => setDialogOpen(true)}
|
||||
onDelete={() => setDeleteOpen(true)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
@@ -638,6 +665,23 @@ export function ChainTableOverlay({
|
||||
onClose={() => setDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{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
|
||||
// selection (the rest of a big selection survives);
|
||||
// announce the revision so GraphView refetches the
|
||||
// snapshot — fresh nodes/edges flow back in as props.
|
||||
setDeleteOpen(false);
|
||||
const dead = new Set(deadIds);
|
||||
setSelection((prev) => new Set([...prev].filter((id) => !dead.has(id))));
|
||||
window.dispatchEvent(new CustomEvent("lh:graph-revision", { detail: { graphRevision } }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/** The impact read's shape (GET /api/graph/nodes/[id], node interlude):
|
||||
* counts of what full death would take with it, plus the referencing
|
||||
* session/source ids — the CLIENT joins display names from its
|
||||
* snapshot (chats map + imported-notes file paths). */
|
||||
/** The impact read's shape (GET /api/graph/nodes/[id]): counts of what
|
||||
* full death would take with it, plus the referencing session/source
|
||||
* ids — the CLIENT joins display names from its snapshot (chats map +
|
||||
* imported-notes file paths). DRIFT FIX (2026-09-30, found via the
|
||||
* chain-table delete): quizEvents/observations were removed from the
|
||||
* server's NodeDeletionImpact when the quiz-events ledger died in the
|
||||
* practice-arc schema slim — the dialog kept rendering the dead
|
||||
* fields as undefined. The facts below now match what the server can
|
||||
* honestly report: links + referencing sessions. */
|
||||
export interface NodeImpact {
|
||||
edges: number;
|
||||
quizEvents: number;
|
||||
observations: number;
|
||||
sessions: string[];
|
||||
}
|
||||
|
||||
@@ -54,7 +57,7 @@ export function countLine(n: number, one: string, many: string): string {
|
||||
/** The dialog's pure body — exported for SSR pins (the settings-dialog
|
||||
* body pattern). The wrapper owns state + effects + the portal. */
|
||||
export function DeleteNodeBody({
|
||||
node,
|
||||
nodes,
|
||||
joins,
|
||||
impact,
|
||||
busy,
|
||||
@@ -62,7 +65,10 @@ export function DeleteNodeBody({
|
||||
onClose,
|
||||
onRun,
|
||||
}: {
|
||||
node: { id: string; name: string };
|
||||
/** One node (the canvas context menu) or many (the chain table's
|
||||
* selection, owner 2026-09-29 night: THE deletion view). One renders
|
||||
* exactly as always; many pluralize the title and list the names. */
|
||||
nodes: { id: string; name: string }[];
|
||||
joins: { sessions: Record<string, string>; fileSources: Record<string, string> };
|
||||
impact: NodeImpact | "loading" | "gone";
|
||||
busy: boolean;
|
||||
@@ -79,15 +85,26 @@ export function DeleteNodeBody({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
|
||||
>
|
||||
<h2 className="font-title text-2xl tracking-tight text-destructive">Delete node?</h2>
|
||||
{/* The node name below the title in JetBrains Mono (owner,
|
||||
<h2 className="font-title text-2xl tracking-tight text-destructive">
|
||||
{nodes.length === 1 ? "Delete node?" : `Delete ${nodes.length} propositions?`}
|
||||
</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. */}
|
||||
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). */}
|
||||
<div className="mt-1.5">
|
||||
<code data-testid="node-name-code" className="font-mono text-lg text-foreground">
|
||||
{node.name}
|
||||
</code>
|
||||
{nodes.length === 1 ? (
|
||||
<code data-testid="node-name-code" className="font-mono text-lg text-foreground">
|
||||
{nodes[0]?.name}
|
||||
</code>
|
||||
) : (
|
||||
<code data-testid="node-names-code" className="font-mono text-lg text-foreground">
|
||||
{nodes.slice(0, 8).map((n) => n.name).join(", ")}
|
||||
{nodes.length > 8 ? `, and ${nodes.length - 8} more` : ""}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
{impact === "loading" && (
|
||||
<p className="mt-2 text-sm text-muted-foreground" data-testid="node-impact-loading">
|
||||
@@ -121,8 +138,6 @@ export function DeleteNodeBody({
|
||||
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>{countLine(impact.quizEvents, "quiz answer", "quiz answers")}</li>
|
||||
<li>{countLine(impact.observations, "session note", "session notes")}</li>
|
||||
<li className="text-muted-foreground">
|
||||
Referenced by{" "}
|
||||
{impact.sessions.length === 0
|
||||
@@ -182,22 +197,24 @@ export function DeleteNodeBody({
|
||||
}
|
||||
|
||||
export function DeleteNodeDialog({
|
||||
node,
|
||||
nodes,
|
||||
joins,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: {
|
||||
/** The node up for deletion; null = closed. Name from the client's
|
||||
* snapshot (what the user sees on the canvas is what the dialog
|
||||
* names). */
|
||||
node: { id: string; name: string } | null;
|
||||
/** The nodes up for deletion; null/empty = closed. One (the canvas
|
||||
* context menu) or many (the chain table's selection). Names from
|
||||
* the client's snapshot — what the user sees is what the dialog
|
||||
* names. */
|
||||
nodes: { id: string; name: string }[] | null;
|
||||
joins: { sessions: Record<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). */
|
||||
onDeleted: (nodeId: string, graphRevision: number) => void;
|
||||
* event; 0 when the response carried none (a vanished node). The
|
||||
* deleted ids ride along so selection-side callers can prune. */
|
||||
onDeleted: (nodeIds: string[], graphRevision: number) => void;
|
||||
}) {
|
||||
const [impact, setImpact] = useState<NodeImpact | "loading" | "gone">("loading");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -205,29 +222,46 @@ export function DeleteNodeDialog({
|
||||
|
||||
// Fresh state per node: closing resets; opening another node refetches.
|
||||
useEffect(() => {
|
||||
if (!node) {
|
||||
if (!nodes || nodes.length === 0) {
|
||||
setImpact("loading");
|
||||
setBusy(false);
|
||||
setFailure(null);
|
||||
}
|
||||
}, [node]);
|
||||
}, [nodes]);
|
||||
|
||||
// The pre-flight impact read — for the dialog the user just opened.
|
||||
// A multi-selection reads every node and AGGREGATES: summed counts,
|
||||
// unioned referencing sessions (deduped, first-seen order). All-404
|
||||
// ("gone elsewhere meanwhile" for every one) is the honest gone state;
|
||||
// a mix renders the living ones' blast.
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
if (!nodes || nodes.length === 0) return;
|
||||
let cancelled = false;
|
||||
setImpact("loading");
|
||||
setFailure(null);
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/graph/nodes/${encodeURIComponent(node.id)}`);
|
||||
const impacts = await Promise.all(
|
||||
nodes.map(async (n) => {
|
||||
const res = await fetch(`/api/graph/nodes/${encodeURIComponent(n.id)}`);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
return (await res.json()) as NodeImpact;
|
||||
}),
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (res.status === 404) {
|
||||
const live = impacts.filter((i): i is NodeImpact => i !== null);
|
||||
if (live.length === 0) {
|
||||
setImpact("gone");
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
setImpact((await res.json()) as NodeImpact);
|
||||
const seen = new Set<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
|
||||
}
|
||||
@@ -235,11 +269,11 @@ export function DeleteNodeDialog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [node]);
|
||||
}, [nodes]);
|
||||
|
||||
// Esc closes (claimed — a dialog Esc must not also abort a turn).
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
if (!nodes || nodes.length === 0) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
@@ -248,32 +282,45 @@ export function DeleteNodeDialog({
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [node, onClose]);
|
||||
}, [nodes, onClose]);
|
||||
|
||||
// Sequential full deaths — never parallel (the store's revision
|
||||
// counter and the sweep must observe them in order). A failure STOPS
|
||||
// the run: what died, died; the failure line names why the rest
|
||||
// still stands (extraction_running especially must not be plowed
|
||||
// through). onDeleted fires with everything that actually died.
|
||||
const run = async () => {
|
||||
if (!node || busy) return;
|
||||
if (!nodes || nodes.length === 0 || busy) return;
|
||||
setBusy(true);
|
||||
setFailure(null);
|
||||
const dead: string[] = [];
|
||||
let revision = 0;
|
||||
try {
|
||||
const res = await fetch(`/api/graph/nodes/${encodeURIComponent(node.id)}`, { method: "DELETE" });
|
||||
const body = (await res.json()) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
graphRevision?: number;
|
||||
};
|
||||
if (!res.ok) {
|
||||
setFailure({ code: body.code ?? "error", error: body.error ?? `Delete failed (${res.status})` });
|
||||
setBusy(false);
|
||||
return;
|
||||
for (const n of nodes) {
|
||||
const res = await fetch(`/api/graph/nodes/${encodeURIComponent(n.id)}`, { method: "DELETE" });
|
||||
const body = (await res.json()) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
graphRevision?: number;
|
||||
};
|
||||
if (!res.ok) {
|
||||
setFailure({ code: body.code ?? "error", error: body.error ?? `Delete failed (${res.status})` });
|
||||
setBusy(false);
|
||||
if (dead.length > 0) onDeleted(dead, revision);
|
||||
return;
|
||||
}
|
||||
dead.push(n.id);
|
||||
revision = body.graphRevision ?? revision;
|
||||
}
|
||||
onDeleted(node.id, body.graphRevision ?? 0);
|
||||
onDeleted(dead, revision);
|
||||
} catch {
|
||||
setFailure({ code: "network", error: "Delete failed — network error. Try again." });
|
||||
setBusy(false);
|
||||
if (dead.length > 0) onDeleted(dead, revision);
|
||||
}
|
||||
};
|
||||
|
||||
if (!node) return null;
|
||||
if (!nodes || nodes.length === 0) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -283,7 +330,7 @@ export function DeleteNodeDialog({
|
||||
}}
|
||||
>
|
||||
<DeleteNodeBody
|
||||
node={node}
|
||||
nodes={nodes}
|
||||
joins={joins}
|
||||
impact={impact}
|
||||
busy={busy}
|
||||
|
||||
@@ -1537,18 +1537,18 @@ export function GraphView() {
|
||||
{/* The full-death confirm dialog (node interlude) — portaled to
|
||||
body by the component itself, lives here for state locality. */}
|
||||
<DeleteNodeDialog
|
||||
node={deletingNode}
|
||||
nodes={deletingNode ? [deletingNode] : null}
|
||||
joins={{
|
||||
sessions: snapshot?.sessions ?? {},
|
||||
fileSources: snapshot?.fileSources ?? {},
|
||||
}}
|
||||
onClose={() => setDeletingNode(null)}
|
||||
onDeleted={(nodeId, graphRevision) => {
|
||||
onDeleted={(nodeIds, graphRevision) => {
|
||||
// Close the dialog; close the detail card if it pointed here
|
||||
// (the refetch would close it anyway, but the same frame
|
||||
// should not show a card for a node the user just killed).
|
||||
setDeletingNode(null);
|
||||
if (selected?.node.id === nodeId) setSelected(null);
|
||||
if (selected && nodeIds.includes(selected.node.id)) setSelected(null);
|
||||
// The wave-6 chain: every listener refetches on this. Clusters
|
||||
// shrink / unrender below the minimum on the server's read.
|
||||
window.dispatchEvent(new CustomEvent("lh:graph-revision", { detail: { graphRevision } }));
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -27,7 +27,7 @@ const JOINS = { sessions: { seedplan: "Orbital mechanics — plan phase" }, file
|
||||
function bodyAt(impact: NodeImpact | "loading" | "gone", extra?: { failure?: { code: string; error: string } | null }) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(DeleteNodeBody, {
|
||||
node: { id: "kepler", name: "Kepler's three laws" },
|
||||
nodes: [{ id: "kepler", name: "Kepler's three laws" }],
|
||||
joins: JOINS,
|
||||
impact,
|
||||
busy: false,
|
||||
@@ -65,7 +65,14 @@ describe("EXTRACTION_RETRY_LINE (the 409 phrasing, owner-locked)", () => {
|
||||
describe("DeleteNodeDialog (thin SSR)", () => {
|
||||
it("null node renders nothing", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(DeleteNodeDialog, { node: null, joins: { sessions: {}, fileSources: {} }, onClose: () => {}, onDeleted: () => {} }),
|
||||
createElement(DeleteNodeDialog, { nodes: null, joins: { sessions: {}, fileSources: {} }, onClose: () => {}, onDeleted: () => {} }),
|
||||
);
|
||||
assert.equal(html, "");
|
||||
});
|
||||
|
||||
it("empty nodes array also renders nothing (null and [] are both closed)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(DeleteNodeDialog, { nodes: [], joins: { sessions: {}, fileSources: {} }, onClose: () => {}, onDeleted: () => {} }),
|
||||
);
|
||||
assert.equal(html, "");
|
||||
});
|
||||
@@ -85,6 +92,45 @@ describe("DeleteNodeDialog (thin SSR)", () => {
|
||||
assert.match(html, /data-testid="node-impact-loading"/);
|
||||
});
|
||||
|
||||
it("multi-selection: plural title, the names listed in mono, capped at 8 + and-K-more", () => {
|
||||
const three = renderToStaticMarkup(
|
||||
createElement(DeleteNodeBody, {
|
||||
nodes: [
|
||||
{ id: "kepler", name: "Kepler's three laws" },
|
||||
{ id: "newton", name: "Newton's second law" },
|
||||
{ id: "hook", name: "Hooke's law" },
|
||||
],
|
||||
joins: JOINS,
|
||||
impact: "loading",
|
||||
busy: false,
|
||||
failure: null,
|
||||
onClose: () => {},
|
||||
onRun: () => {},
|
||||
}),
|
||||
);
|
||||
assert.match(three, /Delete 3 propositions\?/); // plural title
|
||||
assert.match(three, /data-testid="node-names-code"/); // the names block
|
||||
assert.match(three, /Kepler's three laws, Newton's second law, Hooke's law/);
|
||||
assert.ok(!three.includes('data-testid="node-name-code"'), "the single-name testid is gone in multi");
|
||||
|
||||
// the cap: 10 names render 8 + ", and 2 more"
|
||||
const ten = renderToStaticMarkup(
|
||||
createElement(DeleteNodeBody, {
|
||||
nodes: Array.from({ length: 10 }, (_, i) => ({ id: `n${i}`, name: `Prop ${i}` })),
|
||||
joins: JOINS,
|
||||
impact: "loading",
|
||||
busy: false,
|
||||
failure: null,
|
||||
onClose: () => {},
|
||||
onRun: () => {},
|
||||
}),
|
||||
);
|
||||
assert.match(ten, /Delete 10 propositions\?/);
|
||||
assert.match(ten, /Prop 7/); // the 8th name still listed
|
||||
assert.doesNotMatch(ten, /Prop 8</); // the 9th is not
|
||||
assert.match(ten, /, and 2 more/);
|
||||
});
|
||||
|
||||
it("the body at gone: honest already-removed state, a Close that rides the same refresh", () => {
|
||||
const html = bodyAt("gone");
|
||||
assert.match(html, /no longer in the graph/);
|
||||
@@ -92,10 +138,11 @@ describe("DeleteNodeDialog (thin SSR)", () => {
|
||||
});
|
||||
|
||||
it("the body at resolved impact: plain fact lines, joined names (chats + notes files), the cannot-be-undoned copy", () => {
|
||||
const html = bodyAt({ edges: 2, quizEvents: 3, observations: 0, sessions: ["seedplan", "src_plato", "chat_ghost"] });
|
||||
// (drift fix 2026-09-30: quizEvents/observations died with the
|
||||
// quiz-events ledger — the impact carries links + sessions only)
|
||||
const html = bodyAt({ edges: 2, sessions: ["seedplan", "src_plato", "chat_ghost"] });
|
||||
assert.match(html, /2 links to other topics/);
|
||||
assert.match(html, /3 quiz answers/);
|
||||
assert.match(html, /0 session notes/); // zeros stay visible — no understate
|
||||
assert.ok(!html.includes("quiz answer"), "the dead quiz fact is gone");
|
||||
// joined display names: chat title, notes file path, unknown id honestly itself
|
||||
assert.match(html, /Orbital mechanics — plan phase/);
|
||||
assert.match(html, /\/notes\/plato\.md/);
|
||||
@@ -107,7 +154,7 @@ describe("DeleteNodeDialog (thin SSR)", () => {
|
||||
});
|
||||
|
||||
it("the body at failure: the 409 renders the activity-dock line with a Try-again", () => {
|
||||
const html = bodyAt({ edges: 0, quizEvents: 0, observations: 0, sessions: [] }, { failure: { code: "extraction_running", error: "server line" } });
|
||||
const html = bodyAt({ edges: 0, sessions: [] }, { failure: { code: "extraction_running", error: "server line" } });
|
||||
assert.match(html, /data-testid="node-delete-failure"/);
|
||||
assert.match(html, new RegExp(EXTRACTION_RETRY_LINE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.doesNotMatch(html, /server line/); // the dock phrasing REPLACES the raw line for the known code
|
||||
|
||||
@@ -79,6 +79,7 @@ const BASE = {
|
||||
onToggleNodes: () => {},
|
||||
onClear: () => {},
|
||||
onGenerate: () => {},
|
||||
onDelete: () => {},
|
||||
onClose: () => {},
|
||||
};
|
||||
|
||||
@@ -255,13 +256,30 @@ describe("ChainTableBody — the floating selection bar (never shifts layout)",
|
||||
assert.ok(!html.includes('data-testid="chain-selection-bar"'));
|
||||
});
|
||||
|
||||
it("appears with the derived N propositions → M chains + Clear + Generate", () => {
|
||||
it("appears with the derived N propositions → M chains + Clear + Generate + the FENCED Delete", () => {
|
||||
const html = render({ selection: new Set(["a", "b", "c1"]) });
|
||||
assert.ok(html.includes('data-testid="chain-selection-bar"'));
|
||||
assert.ok(html.includes('data-testid="chain-selected-summary"'));
|
||||
assert.ok(html.includes("3 propositions → 1 chain"));
|
||||
assert.ok(html.includes('data-testid="chain-clear"'));
|
||||
assert.ok(html.includes('data-testid="chain-generate"'));
|
||||
// The danger fence (owner, 2026-09-29 night): a divider quarantines
|
||||
// the destructive action at the bar's right end, red + trash icon,
|
||||
// riding the same confirm dialog as the canvas delete.
|
||||
assert.ok(html.includes('data-testid="chain-danger-divider"'));
|
||||
const del = html.match(/<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.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"'));
|
||||
assert.ok(html.indexOf('chain-danger-divider"') < html.indexOf('chain-delete"'));
|
||||
});
|
||||
|
||||
it("the Delete rides ONLY with a selection — no bar, no kill switch", () => {
|
||||
const html = render({ expandedClusters: new Set(["chat1"]) });
|
||||
assert.ok(!html.includes('data-testid="chain-delete"'));
|
||||
assert.ok(!html.includes('data-testid="chain-danger-divider"'));
|
||||
});
|
||||
|
||||
it("a premise WITHOUT its conclusion derives as the singleton it becomes", () => {
|
||||
|
||||
Reference in New Issue
Block a user