diff --git a/components/delete-node-dialog.tsx b/components/delete-node-dialog.tsx new file mode 100644 index 0000000..8c71e9d --- /dev/null +++ b/components/delete-node-dialog.tsx @@ -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, + fileSources: Record, +): 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; fileSources: Record }; + impact: NodeImpact | "loading" | "gone"; + busy: boolean; + failure: { code: string; error: string } | null; + onClose: () => void; + onRun: () => void; +}) { + return ( +
e.stopPropagation()} + className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl" + > +

Delete “{node.name}”?

+ {impact === "loading" && ( +

+ Reading what this would remove… +

+ )} + {impact === "gone" && ( +
+

+ This topic is no longer in the graph — it was already removed elsewhere. +

+
+ +
+
+ )} + {typeof impact === "object" && ( + <> +

+ This permanently removes the topic and everything attached to it — this cannot be undone: +

+
    +
  • {countLine(impact.edges, "link to another topic", "links to other topics")}
  • +
  • {countLine(impact.quizEvents, "quiz answer", "quiz answers")}
  • +
  • {countLine(impact.observations, "session note", "session notes")}
  • +
  • + Referenced by{" "} + {impact.sessions.length === 0 + ? "no session" + : impact.sessions + .map((id) => sessionNameOf(id, joins.sessions, joins.fileSources)) + .join(", ")} +
  • +
+ {failure && ( +
+ + {failure.code === "extraction_running" ? EXTRACTION_RETRY_LINE : failure.error} + + +
+ )} +
+ + +
+ + )} +
+ ); +} + +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; fileSources: Record }; + 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("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( +
{ + if (!busy) onClose(); + }} + > + void run()} + /> +
, + document.body, + ); +} diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 4a98f81..375713a 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -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; diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 370ca34..d13b2d3 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -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(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() { ) : ( -
+
{/* THE single canvas: every layer (hulls, edges, nodes, both label systems, hover) draws into it, once per frame. */} + {/* 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 && ( +
+ )}
)} + {/* 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 && ( + + + + )} + + {/* The full-death confirm dialog (node interlude) — portaled to + body by the component itself, lives here for state locality. */} + 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, diff --git a/tests/delete-node-dialog.test.tsx b/tests/delete-node-dialog.test.tsx new file mode 100644 index 0000000..1924874 --- /dev/null +++ b/tests/delete-node-dialog.test.tsx @@ -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"/); + }); +});