2026-09-23 16:37:07 +03:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
|
import { createPortal } from "react-dom";
|
|
|
|
|
|
|
|
|
|
/** 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.";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
2026-09-30 01:19:03 +03:00
|
|
|
* touching it, every event naming it, its position.
|
2026-09-23 16:37:07 +03:00
|
|
|
*
|
2026-09-30 01:19:03 +03:00
|
|
|
* 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).
|
2026-09-23 16:37:07 +03:00
|
|
|
*/
|
|
|
|
|
/** The dialog's pure body — exported for SSR pins (the settings-dialog
|
|
|
|
|
* body pattern). The wrapper owns state + effects + the portal. */
|
|
|
|
|
export function DeleteNodeBody({
|
2026-09-30 01:03:59 +03:00
|
|
|
nodes,
|
2026-09-23 16:37:07 +03:00
|
|
|
busy,
|
|
|
|
|
failure,
|
|
|
|
|
onClose,
|
|
|
|
|
onRun,
|
|
|
|
|
}: {
|
2026-09-30 01:03:59 +03:00
|
|
|
/** One node (the canvas context menu) or many (the chain table's
|
2026-09-30 01:19:03 +03:00
|
|
|
* selection). One names it alone; many pluralize the title and
|
|
|
|
|
* list the names. */
|
2026-09-30 01:03:59 +03:00
|
|
|
nodes: { id: string; name: string }[];
|
2026-09-23 16:37:07 +03:00
|
|
|
busy: boolean;
|
|
|
|
|
failure: { code: string; error: string } | null;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onRun: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
2026-09-30 01:19:03 +03:00
|
|
|
aria-label="Delete node"
|
2026-09-23 16:37:07 +03:00
|
|
|
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"
|
|
|
|
|
>
|
2026-09-30 01:03:59 +03:00
|
|
|
<h2 className="font-title text-2xl tracking-tight text-destructive">
|
2026-09-30 01:19:03 +03:00
|
|
|
{nodes.length === 1 ? "Delete node?" : `Delete ${nodes.length} nodes?`}
|
2026-09-30 01:03:59 +03:00
|
|
|
</h2>
|
|
|
|
|
{/* The node name(s) below the title in JetBrains Mono (owner,
|
2026-09-24 20:06:22 +03:00
|
|
|
2026-09-24): the name is the payload — mono suits it, and
|
|
|
|
|
NO code-block chrome (the font already says code), sized up
|
2026-09-30 01:03:59 +03:00
|
|
|
to text-lg so it reads. Same shape as the session dialog.
|
|
|
|
|
A multi-selection lists its names (capped at 8 + "and K
|
2026-09-30 01:19:03 +03:00
|
|
|
more" — the count in the title carries the full number). */}
|
2026-09-23 18:53:51 +03:00
|
|
|
<div className="mt-1.5">
|
2026-09-30 01:03:59 +03:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-09-23 18:53:51 +03:00
|
|
|
</div>
|
2026-09-30 01:19:03 +03:00
|
|
|
<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>
|
2026-09-23 16:37:07 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-09-30 01:19:03 +03:00
|
|
|
<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>
|
2026-09-23 16:37:07 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function DeleteNodeDialog({
|
2026-09-30 01:03:59 +03:00
|
|
|
nodes,
|
2026-09-23 16:37:07 +03:00
|
|
|
onClose,
|
|
|
|
|
onDeleted,
|
|
|
|
|
}: {
|
2026-09-30 01:03:59 +03:00
|
|
|
/** 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;
|
2026-09-23 16:37:07 +03:00
|
|
|
onClose: () => void;
|
2026-09-30 01:19:03 +03:00
|
|
|
/** Success path — the callers announce lh:graph-revision so the
|
|
|
|
|
* snapshot refetches. The deleted ids ride along so
|
|
|
|
|
* selection-side callers can prune. */
|
2026-09-30 01:03:59 +03:00
|
|
|
onDeleted: (nodeIds: string[], graphRevision: number) => void;
|
2026-09-23 16:37:07 +03:00
|
|
|
}) {
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
const [failure, setFailure] = useState<{ code: string; error: string } | null>(null);
|
|
|
|
|
|
2026-09-30 01:19:03 +03:00
|
|
|
// Fresh state per dialog: closing resets.
|
2026-09-23 16:37:07 +03:00
|
|
|
useEffect(() => {
|
2026-09-30 01:03:59 +03:00
|
|
|
if (!nodes || nodes.length === 0) {
|
2026-09-23 16:37:07 +03:00
|
|
|
setBusy(false);
|
|
|
|
|
setFailure(null);
|
|
|
|
|
}
|
2026-09-30 01:03:59 +03:00
|
|
|
}, [nodes]);
|
2026-09-23 16:37:07 +03:00
|
|
|
|
|
|
|
|
// Esc closes (claimed — a dialog Esc must not also abort a turn).
|
|
|
|
|
useEffect(() => {
|
2026-09-30 01:03:59 +03:00
|
|
|
if (!nodes || nodes.length === 0) return;
|
2026-09-23 16:37:07 +03:00
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
if (e.key === "Escape") {
|
2026-10-07 00:08:49 +03:00
|
|
|
// THE ONE-ESC LAW (keyboard round 2026-10-06): claim at the
|
|
|
|
|
// document level — a window-level stopPropagation cannot stop
|
|
|
|
|
// the earlier-registered global window listener.
|
|
|
|
|
e.preventDefault();
|
2026-09-23 16:37:07 +03:00
|
|
|
e.stopPropagation();
|
|
|
|
|
onClose();
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-10-07 00:08:49 +03:00
|
|
|
document.addEventListener("keydown", onKeyDown);
|
|
|
|
|
return () => document.removeEventListener("keydown", onKeyDown);
|
2026-09-30 01:03:59 +03:00
|
|
|
}, [nodes, onClose]);
|
2026-09-23 16:37:07 +03:00
|
|
|
|
2026-09-30 01:03:59 +03:00
|
|
|
// 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.
|
2026-09-23 16:37:07 +03:00
|
|
|
const run = async () => {
|
2026-09-30 01:03:59 +03:00
|
|
|
if (!nodes || nodes.length === 0 || busy) return;
|
2026-09-23 16:37:07 +03:00
|
|
|
setBusy(true);
|
|
|
|
|
setFailure(null);
|
2026-09-30 01:03:59 +03:00
|
|
|
const dead: string[] = [];
|
|
|
|
|
let revision = 0;
|
2026-09-23 16:37:07 +03:00
|
|
|
try {
|
2026-09-30 01:03:59 +03:00
|
|
|
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;
|
2026-09-23 16:37:07 +03:00
|
|
|
}
|
2026-09-30 01:03:59 +03:00
|
|
|
onDeleted(dead, revision);
|
2026-09-23 16:37:07 +03:00
|
|
|
} catch {
|
|
|
|
|
setFailure({ code: "network", error: "Delete failed — network error. Try again." });
|
|
|
|
|
setBusy(false);
|
2026-09-30 01:03:59 +03:00
|
|
|
if (dead.length > 0) onDeleted(dead, revision);
|
2026-09-23 16:37:07 +03:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-30 01:03:59 +03:00
|
|
|
if (!nodes || nodes.length === 0) return null;
|
2026-09-23 16:37:07 +03:00
|
|
|
|
|
|
|
|
return createPortal(
|
|
|
|
|
<div
|
|
|
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!busy) onClose();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DeleteNodeBody
|
2026-09-30 01:03:59 +03:00
|
|
|
nodes={nodes}
|
2026-09-23 16:37:07 +03:00
|
|
|
busy={busy}
|
|
|
|
|
failure={failure}
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
onRun={() => void run()}
|
|
|
|
|
/>
|
|
|
|
|
</div>,
|
|
|
|
|
document.body,
|
|
|
|
|
);
|
|
|
|
|
}
|