Node deletion affordance (node interlude): right-click menu + full-death confirm

Platform's 50bfcba engine, surfaced. Three pieces:

1. THE RIGHT-CLICK (graph-canvas.ts): a contextmenu listener — always
   preventDefault (the native menu on a full-bleed custom canvas is
   never useful) — picks the node under the pointer and hands the
   canvas-relative position to a new onContext callback (null on empty
   stage). pickAt already respected the visibility filters: a hidden
   node cannot be right-clicked, same as it cannot be clicked. The
   view opens a MenuShell anchored at a zero-size point-div positioned
   at the pointer — MenuShell's placement machine measures it like any
   element, so the menu gets the full discipline: placement, focus on
   open, arrow cycling, honest-fit flip. Delete… is its one action;
   the dismissal contract is the sidebar's (wave 6): outside-click /
   Esc (claimed) close, and a camera move closes it too — a menu
   floating over a moved graph is anchored to nothing.

2. THE CONFIRM (delete-node-dialog.tsx, portaled z-50 like the
   session-delete dialog whose tone it follows): the pre-flight impact
   read (GET /api/graph/nodes/[id]) renders the plain facts — link
   count, quiz-answer count, session-note count (zeros stay visible —
   hiding a zero fact would understate the blast), and the referencing
   sessions with display names joined CLIENT-SIDE from the snapshot
   (chat titles; imported-notes file paths; unknown ids honestly
   themselves). "This cannot be undone" says the permanence. The 409
   extraction_running renders the dock phrasing (EXTRACTION_RETRY_LINE —
   the server names the extraction, the ACTIVITY DOCK is the surface
   the user actually watches drain) with a Try-again; every other
   failure shows the server's line verbatim. The honest 404 (deleted
   elsewhere meanwhile) states it plainly and its Close rides the same
   refresh. The pure body is exported separately (the settings-dialog
   pattern) — SSR pins everything the live dialog shows.

3. SUCCESS: onDeleted dispatches lh:graph-revision with the returned
   graphRevision — the wave-6 chain refetches snapshot + costs. The
   detail card closes if it pointed at the dying node (the refetch
   would close it anyway; the same frame should not show a card for a
   node the user just killed). Clusters shrink / unrender below the
   minimum on the server's read — the client just renders the
   refreshed snapshot.

Tests: 867/867 (+8 — the join helper, honest plurals, the 409 phrasing
pin, the dialog body at loading/gone/impact/failure states).
Live-verified on :3001 against the real engine, scratch data: impact
facts match the API, the intercepted 409 shows the dock line and
Try-again recovers into a real delete, the three-member cluster died
below MIN_CLUSTER_SIZE server-side, the labeled seedplan cluster +
its canvas label died with it (cold load draws nothing stale; the
in-session refresh refetches and the dying node's label stops
drawing), other nodes survived, and the full menu contract holds
(empty stage → no menu; Esc, outside-click, camera move all close).
This commit is contained in:
amosblomqvist
2026-09-23 16:37:07 +03:00
parent 2d133f1133
commit 92943864c4
4 changed files with 514 additions and 2 deletions
+285
View File
@@ -0,0 +1,285 @@
"use client";
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). */
export interface NodeImpact {
edges: number;
quizEvents: number;
observations: 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.
*
* 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.
*/
/** 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,
joins,
impact,
busy,
failure,
onClose,
onRun,
}: {
node: { 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;
onRun: () => void;
}) {
return (
<div
role="dialog"
aria-modal="true"
aria-label="Delete topic"
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">Delete “{node.name}”?</h2>
{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>
</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>{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
? "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}
className="rounded-sm border border-border/60 px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-accent disabled:opacity-40"
>
{busy ? "Deleting…" : "Delete"}
</button>
</div>
</>
)}
</div>
);
}
export function DeleteNodeDialog({
node,
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;
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;
}) {
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.
useEffect(() => {
if (!node) {
setImpact("loading");
setBusy(false);
setFailure(null);
}
}, [node]);
// The pre-flight impact read — for the dialog the user just opened.
useEffect(() => {
if (!node) return;
let cancelled = false;
setImpact("loading");
setFailure(null);
(async () => {
try {
const res = await fetch(`/api/graph/nodes/${encodeURIComponent(node.id)}`);
if (cancelled) return;
if (res.status === 404) {
setImpact("gone");
return;
}
if (!res.ok) throw new Error(String(res.status));
setImpact((await res.json()) as NodeImpact);
} catch {
if (!cancelled) setImpact("gone"); // honest fallback, no facts invented
}
})();
return () => {
cancelled = true;
};
}, [node]);
// Esc closes (claimed — a dialog Esc must not also abort a turn).
useEffect(() => {
if (!node) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [node, onClose]);
const run = async () => {
if (!node || busy) return;
setBusy(true);
setFailure(null);
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;
}
onDeleted(node.id, body.graphRevision ?? 0);
} catch {
setFailure({ code: "network", error: "Delete failed — network error. Try again." });
setBusy(false);
}
};
if (!node) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
onClick={() => {
if (!busy) onClose();
}}
>
<DeleteNodeBody
node={node}
joins={joins}
impact={impact}
busy={busy}
failure={failure}
onClose={onClose}
onRun={() => void run()}
/>
</div>,
document.body,
);
}
+18
View File
@@ -124,6 +124,10 @@ export interface CanvasData {
export interface RendererCallbacks {
/** A node was clicked (screen position included for card anchoring). */
onSelect: (nodeId: string, screenPos: Pt) => void;
/** Right-click: the picked node (canvas-relative screen position) or
* null when the pointer hit empty stage — the caller opens/closes
* its context menu accordingly. (node interlude, wave 7) */
onContext: (nodeId: string | null, screenPos: Pt) => void;
/** The empty stage was clicked. */
onStageClick: () => void;
/** Any camera move (pan/zoom) — the caller dismisses anchored UI and
@@ -635,6 +639,10 @@ export class GraphCanvasRenderer {
canvas.addEventListener("pointerdown", this.onPointerDown);
canvas.addEventListener("pointerup", this.onPointerUp);
canvas.addEventListener("pointerleave", this.onPointerLeave);
// Right-click → the caller's context menu (node interlude). Always
// preventDefault: the native menu on a full-bleed custom canvas is
// never useful; the caller decides what (if anything) opens.
canvas.addEventListener("contextmenu", this.onContextMenu);
// The persisted camera: replace the default with wherever the user
// left the graph. Applied through the zoom behavior so d3's internal
@@ -809,6 +817,7 @@ export class GraphCanvasRenderer {
this.canvas.removeEventListener("pointerdown", this.onPointerDown);
this.canvas.removeEventListener("pointerup", this.onPointerUp);
this.canvas.removeEventListener("pointerleave", this.onPointerLeave);
this.canvas.removeEventListener("contextmenu", this.onContextMenu);
this.canvas.style.cursor = "";
}
@@ -926,6 +935,15 @@ export class GraphCanvasRenderer {
else this.callbacks.onStageClick();
};
/** Right-click: pick the node under the pointer, hand the position to
* the caller (empty stage = null — the caller closes any open menu). */
private onContextMenu = (e: MouseEvent) => {
e.preventDefault();
const pos = this.eventPos(e);
const node = this.pickAt(pos.x, pos.y);
this.callbacks.onContext(node?.id ?? null, pos);
};
private onPointerLeave = () => {
if (this.dragId) return; // pointer capture keeps the drag alive
this.downPos = null;
+105 -2
View File
@@ -69,6 +69,8 @@ import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts
import { GraphCoverageControl } from "./graph-coverage.tsx";
import { usePopoverDismiss } from "@/components/popover-dismiss";
import { useActivity } from "@/components/activity-dock";
import { MenuShell } from "@/components/menu-shell.tsx";
import { DeleteNodeDialog } from "@/components/delete-node-dialog.tsx";
import {
ARROW_LENGTH_LADDER,
CHARGE_RANGE,
@@ -945,6 +947,36 @@ export function GraphView() {
return () => window.removeEventListener("lh:chat-renamed", onRenamed);
}, []);
// The node context menu (node interlude): right-click a node → Delete.
// The anchor is a zero-size div positioned at the pointer inside the
// canvas container — MenuShell's placement machine measures it like
// any element. Camera moves close the menu (it would float stale);
// the dismissal contract is the sidebar's (wave 6).
const [ctxNode, setCtxNode] = useState<{ id: string; name: string; x: number; y: number } | null>(null);
const ctxAnchorRef = useRef<HTMLDivElement | null>(null);
const [deletingNode, setDeletingNode] = useState<{ id: string; name: string } | null>(null);
useEffect(() => {
if (ctxNode === null) return;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node;
if (target instanceof Element && target.closest("[data-lh-menu]")) return;
if (ctxAnchorRef.current?.contains(target)) return;
setCtxNode(null);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
setCtxNode(null);
}
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [ctxNode]);
// Layout (Option A): the positions ledger (written by the server's
// incremental settle after every merge) IS the layout — the renderer
// receives it as frozen data. The local one-shot forceAtlas2 pass runs
@@ -1036,10 +1068,26 @@ export function GraphView() {
});
},
onStageClick: () => setSelected(null),
// Right-click → the node context menu (node interlude). Null
// (empty stage) closes a stale menu; a pick opens one anchored
// at the pointer. The name comes from the snapshot — what the
// canvas shows is what the menu and dialog speak.
onContext: (nodeId, pos) => {
if (nodeId === null) {
setCtxNode(null);
return;
}
const node = snapshotRef.current?.nodes.find((n) => n.id === nodeId);
if (!node) return;
setCtxNode({ id: node.id, name: node.name, x: pos.x, y: pos.y });
},
// Any camera move detaches the card from its node — close
// it, and (debounced) remember where the camera stands.
// it, and (debounced) remember where the camera stands. The
// context menu closes with it: a menu floating over a moved
// graph is anchored to nothing.
onCameraMove: (t) => {
setSelected(null);
setCtxNode(null);
persistCamera(t);
},
// Live physics settled: persist the new rest state. Fire-and-
@@ -1168,13 +1216,68 @@ export function GraphView() {
</div>
</div>
) : (
<div ref={containerRef} className="h-full w-full">
<div ref={containerRef} className="relative h-full w-full">
{/* THE single canvas: every layer (hulls, edges, nodes, both
label systems, hover) draws into it, once per frame. */}
<canvas ref={canvasRef} className="block h-full w-full touch-none" />
{/* The context menu's anchor: a zero-size point at the
right-click position (canvas-relative == container-relative,
the canvas fills this div). */}
{ctxNode !== null && (
<div
ref={ctxAnchorRef}
style={{ position: "absolute", left: ctxNode.x, top: ctxNode.y, width: 0, height: 0 }}
/>
)}
</div>
)}
{/* The node context menu (node interlude): Delete is its one action
(the same MenuShell discipline as the sidebar's session menu —
placement, focus, arrow cycling; the dismissal effect above owns
the contract). */}
{ctxNode !== null && (
<MenuShell
anchorRef={ctxAnchorRef}
testid="node-context-menu"
preferredSide="down"
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl"
>
<button
type="button"
data-testid="ctx-node-delete"
onClick={() => {
setDeletingNode({ id: ctxNode.id, name: ctxNode.name });
setCtxNode(null);
}}
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
>
Delete…
</button>
</MenuShell>
)}
{/* The full-death confirm dialog (node interlude) — portaled to
body by the component itself, lives here for state locality. */}
<DeleteNodeDialog
node={deletingNode}
joins={{
sessions: snapshot?.sessions ?? {},
fileSources: snapshot?.fileSources ?? {},
}}
onClose={() => setDeletingNode(null)}
onDeleted={(nodeId, 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);
// 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 } }));
}}
/>
{/* Legend + filters: search box and per-state toggle rows. */}
{/* Top-right controls — separate floating containers (owner,
graph-view round 2). Row order (2026-09-17): search,
+106
View File
@@ -0,0 +1,106 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
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.
*/
import {
DeleteNodeBody,
DeleteNodeDialog,
EXTRACTION_RETRY_LINE,
countLine,
sessionNameOf,
type NodeImpact,
} 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 }) {
return renderToStaticMarkup(
createElement(DeleteNodeBody, {
node: { id: "kepler", name: "Kepler's three laws" },
joins: JOINS,
impact,
busy: false,
failure: extra?.failure ?? null,
onClose: () => {},
onRun: () => {},
}),
);
}
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.");
});
});
describe("DeleteNodeDialog (thin SSR)", () => {
it("null node renders nothing", () => {
const html = renderToStaticMarkup(
createElement(DeleteNodeDialog, { node: null, 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");
assert.match(html, /data-testid="delete-node-dialog"/);
assert.match(html, /Delete.*Kepler.*three laws.*\?/); // the title names the node
assert.match(html, /data-testid="node-impact-loading"/);
});
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", () => {
const html = bodyAt({ edges: 2, quizEvents: 3, observations: 0, 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
// 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(html, /data-testid="node-delete-confirm"/);
});
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" } });
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"/);
});
});