Files
learnharness/components/delete-session-dialog.tsx
T
amosblomqvist 1c27694dbc Wave 6 chunk A: sidebar context menu (Rename/Delete) + the two-option delete dialog
- Right-click any session row: MenuShell context menu, exactly two actions
  (owner ruling): Rename and Delete… — MenuShell discipline (placement,
  focus, arrow cycling, lh:menu-open announcements), agent-section's
  outside-click/Esc contract with [data-lh-menu] carve-outs.
- Rename is INLINE in the row (owner ruling): select-all entry, Enter/blur
  commits through the existing plumbing — PUT /api/chats/:id then
  lh:chat-renamed. The open chat header now LISTENS for the event too, so
  renaming the active session from the sidebar updates the header in place
  (it only dispatched before).
- Delete opens the two-option dialog (locked design, DELETE_OPTIONS pure
  seam for render pins): "Delete the session only" (preserve-graph — the
  graph keeps every contribution) vs "Also remove its graph traces"
  (delete-nodes — shared topics survive, only-this-session topics die).
  409 turn_in_flight/jobs_running and 503 extraction_winding_down render
  the server's line verbatim with a same-mode Try-again.
- Success: the sidebar patches its snapshot instantly, the active session
  navigates home (chat unmounts — drafts die with it), and lh:graph-revision
  announces the returned revision — the graph view refetches the snapshot
  AND costs (deletion is the one writer the status poll can't see).
- ChatDeleteMode type re-exported through the core barrel (the shell's one
  needed seam from core/graph-deletion.ts).

Gates: tsc clean, 909/909, lint 0, build ok. Live-verified on :3001:
context menu open/Esc, inline rename (row + API + active header), both
delete modes on scratch sessions, simulated 409 + retry-then-succeed,
active-session navigation home, graph+costs refetch deltas on home.
2026-09-23 12:45:12 +03:00

184 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import type { ChatDeleteMode, ChatListItem } from "@/src/core";
/** The locked two-option design (owner 2026-09-22/23) — plain-language
* consequences, the graph-touching option says so. Exported pure for
* render pins: the copy is design, not implementation. */
export const DELETE_OPTIONS: {
mode: ChatDeleteMode;
testid: string;
title: string;
body: string;
}[] = [
{
mode: "preserve-graph",
testid: "delete-option-preserve",
title: "Delete the session only",
body: "Everything this session added to the graph stays — topics, links, and notes it produced remain untouched.",
},
{
mode: "delete-nodes",
testid: "delete-option-nodes",
title: "Also remove its graph traces",
body: "This session’s topics, links, and events leave the graph too. Topics other sessions or imported notes also cover survive; topics only this session created are removed entirely.",
},
];
/**
* Session deletion (wave 6 chunk A) — THE destructive-confirmation surface,
* portaled z-50 like the setup dialog. Two options per the locked design,
* consequences in plain language; the one that touches the graph says so:
*
* preserve-graph the session record, its jobs, and its extraction
* bookkeeping die; the graph keeps every contribution.
* delete-nodes the session's ledger traces go too — shared topics
* survive (other sessions / imported notes still
* reference them), topics only this session created
* are removed entirely.
*
* Retryable failures render honestly with a Try-again: 409 turn_in_flight /
* jobs_running ("wait for running work") and 503 extraction_winding_down
* ("in a moment") both clear on their own — the server's error line is
* already written for the user; we show it verbatim.
*/
export function DeleteSessionDialog({
chat,
onClose,
onDeleted,
}: {
/** The session up for deletion; null = closed. */
chat: Pick<ChatListItem, "id" | "title"> | null;
onClose: () => void;
/** Success path — the sidebar owns navigation + list patching. */
onDeleted: (id: string, mode: ChatDeleteMode, graphRevision: number) => void;
}) {
const [busy, setBusy] = useState<ChatDeleteMode | null>(null);
// The failure carries the mode it came from — Try-again re-runs THAT
// mode (busy is back to null by then, so it can't be read off busy).
const [failure, setFailure] = useState<{ code: string; error: string; mode: ChatDeleteMode } | null>(null);
// Fresh state per session: closing resets, opening a new chat starts clean.
useEffect(() => {
if (!chat) {
setBusy(null);
setFailure(null);
}
}, [chat]);
// Esc closes (claimed — a menu/dialog Esc must not also abort a turn).
useEffect(() => {
if (!chat) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [chat, onClose]);
const run = async (mode: ChatDeleteMode) => {
if (!chat || busy) return;
setBusy(mode);
setFailure(null);
try {
const res = await fetch(`/api/chats/${chat.id}?mode=${mode}`, { method: "DELETE" });
const body = (await res.json()) as {
ok?: boolean;
error?: string;
code?: string;
graphRevision?: number;
};
if (!res.ok) {
// 409 (turn/jobs running) and 503 (extraction winding down) are
// both retryable — keep the dialog open, show the server's line.
setFailure({
code: body.code ?? "error",
error: body.error ?? `Delete failed (${res.status})`,
mode,
});
setBusy(null);
return;
}
onDeleted(chat.id, mode, body.graphRevision ?? 0);
} catch {
setFailure({ code: "network", error: "Delete failed — network error. Try again.", mode });
setBusy(null);
}
};
if (!chat) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
onClick={() => {
if (!busy) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="Delete session"
data-testid="delete-session-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 “{chat.title}”?</h2>
<p className="mt-2 text-sm text-muted-foreground">
This permanently removes the session — its messages, background jobs, and extraction records. Pick how
far it reaches:
</p>
<div className="mt-4 flex flex-col gap-2">
{DELETE_OPTIONS.map((option) => (
<button
key={option.mode}
type="button"
data-testid={option.testid}
disabled={busy !== null}
onClick={() => void run(option.mode)}
className="flex flex-col items-start rounded-lg border border-border/60 px-4 py-3 text-left transition-colors hover:bg-accent disabled:opacity-40"
>
<span className="text-sm text-foreground">{option.title}</span>
<span className="mt-1 text-xs text-muted-foreground">{option.body}</span>
</button>
))}
</div>
{failure && (
<div
role="alert"
data-testid="delete-failure"
className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs"
>
<span className="text-foreground">{failure.error}</span>
<button
type="button"
data-testid="delete-retry"
onClick={() => failure && void run(failure.mode)}
disabled={busy !== null}
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">
<button
type="button"
data-testid="delete-cancel"
onClick={onClose}
disabled={busy !== null}
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
>
{busy ? "Deleting…" : "Cancel"}
</button>
</div>
</div>
</div>,
document.body,
);
}