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:
amosblomqvist
2026-09-30 01:19:03 +03:00
parent 40dc1b7f85
commit e7f4923159
13 changed files with 179 additions and 401 deletions
+6 -7
View File
@@ -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
+67 -187
View File
@@ -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}
-4
View File
@@ -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
+3 -3
View File
@@ -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>