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:
amosblomqvist
2026-09-30 01:03:59 +03:00
parent 4d58aa74b1
commit 40dc1b7f85
7 changed files with 221 additions and 65 deletions
+50 -6
View File
@@ -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 } }));
}}
/>
)}
</>
);
}
+94 -47
View File
@@ -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}
+3 -3
View File
@@ -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 } }));