Files
learnharness/components/delete-session-dialog.tsx
T
amosblomqvist e523bea670 keyboard: the one-Esc law — a surfaced layer claims at the document level (keyboard 4 of 4)
Owner ruling (keyboard round 2026-10-06): Esc (or any key) does ONE
action, on the topmost active layer. The plan-open-while-dictating
double-act was the symptom: the plan popup listened at WINDOW level
with no claim, and same-node window listeners all fire — the global
chain (registered first) cancelled the dictation while the plan's own
listener closed the plan. One Esc, two actions.

The law (now the house pattern, documented at the chain):
- field-level handlers claim at their element (focus in a field: the
  field's action) — unchanged, already correct
- every SURFACED layer claims at the DOCUMENT level with preventDefault
  + stopPropagation — document fires before the window chain, so the
  chain stays silent under any surface
- the window chain is the bottom-up fallback: dictation (a live capture,
  NOT a surface — it survives the Esc that closes a plan or overlay),
  then abort (escAbortTarget layers its own targets)

Converted to the claim: plan popup, session overlay, chain table,
settings dialog, setup dialog, the delete/abort dialogs (window-level
stopPropagation was never enough — same-node listeners all fire), and
the stats popover. Popovers (popover-dismiss), menus, model picker, and
the sidebar context menu already claimed correctly.

1427 tests, tsc, lint, build green.
2026-10-07 00:08:49 +03:00

235 lines
8.0 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 graph-traces checkbox copy (owner, 2026-09-24 flow rework — was
* the delete-nodes option's body in the two-button design): the
* graph-touching consequence says so, shared-topic survival included.
* Exported pure for render pins: the copy is design, not implementation. */
export const TRACES_COPY = {
testid: "delete-traces",
checkbox: "delete-traces-checkbox",
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; owner flow rework 2026-09-24) — THE
* destructive-confirmation surface, portaled z-50 like the setup dialog,
* now in the node dialog's shape (the two must read as one design):
*
* - red serif title "Delete session?", the session name below it in
* JetBrains Mono, sized up, no code-block chrome;
* - ONE unchecked-by-default checkbox "Also remove its graph traces"
* (unchecked = preserve-graph: the session record, its jobs, and its
* extraction bookkeeping die; the graph keeps every contribution.
* checked = delete-nodes: the session's ledger traces go too);
* - a bottom row Cancel + destructive-red Delete, exactly the node
* dialog's pair.
*
* 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. Try-again is just
* Delete pressed again — the mode re-derives from the checkbox.
*/
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 [traces, setTraces] = useState(false);
const [busy, setBusy] = useState(false);
// Retry IS pressing Delete again: the mode always derives from the
// checkbox's current state (a toggle between failure and retry is a
// change of intent, honored as such — the checkbox is disabled while
// busy, so no mid-flight switches).
const [failure, setFailure] = useState<{ code: string; error: string } | null>(null);
// Fresh state per session: closing resets, opening a new chat starts clean.
useEffect(() => {
if (!chat) {
setTraces(false);
setBusy(false);
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") {
// 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();
e.stopPropagation();
onClose();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [chat, onClose]);
const run = async () => {
if (!chat || busy) return;
const mode: ChatDeleteMode = traces ? "delete-nodes" : "preserve-graph";
setBusy(true);
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})` });
setBusy(false);
return;
}
onDeleted(chat.id, mode, body.graphRevision ?? 0);
} catch {
setFailure({ code: "network", error: "Delete failed — network error. Try again." });
setBusy(false);
}
};
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();
}}
>
<DeleteSessionBody
chat={chat}
traces={traces}
busy={busy}
failure={failure}
onTraces={(next) => setTraces(next)}
onClose={onClose}
onRun={() => void run()}
/>
</div>,
document.body,
);
}
/** The dialog's pure body — exported for SSR pins (the settings-dialog /
* node-dialog body pattern). The wrapper owns state + effects + portal. */
export function DeleteSessionBody({
chat,
traces,
busy,
failure,
onTraces,
onClose,
onRun,
}: {
chat: Pick<ChatListItem, "id" | "title">;
/** The graph-traces checkbox — unchecked by default (preserve-graph). */
traces: boolean;
busy: boolean;
failure: { code: string; error: string } | null;
onTraces: (next: boolean) => void;
onClose: () => void;
onRun: () => void;
}) {
return (
<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 text-destructive">Delete session?</h2>
{/* The session name below the title in JetBrains Mono (owner,
2026-09-24): no code-block chrome — the font already says
code — sized up to text-lg. Same shape as the node dialog. */}
<div className="mt-1.5">
<code data-testid="session-name-code" className="font-mono text-lg text-foreground">
{chat.title}
</code>
</div>
<p className="mt-2 text-sm text-muted-foreground">
This permanently removes the session — its messages, background jobs, and extraction records. Everything
it added to the graph stays unless you check below. This cannot be undone.
</p>
<label
data-testid={TRACES_COPY.testid}
className="mt-4 flex cursor-pointer items-start gap-2.5 rounded-lg border border-border/60 px-4 py-3"
>
<input
type="checkbox"
data-testid={TRACES_COPY.checkbox}
checked={traces}
onChange={(e) => onTraces(e.target.checked)}
disabled={busy}
className="mt-0.5 size-4 shrink-0 accent-primary"
/>
<span className="min-w-0">
<span className="block text-sm text-foreground">{TRACES_COPY.title}</span>
<span className="mt-1 block text-xs text-muted-foreground">{TRACES_COPY.body}</span>
</span>
</label>
{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={onRun}
disabled={busy}
className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40"
>
Try again
</button>
</div>
)}
{/* The node dialog's exact bottom row: quiet Cancel, destructive
red Delete (border + text, fill on hover — the app's
established destructive pair). */}
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
data-testid="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="delete-confirm"
onClick={onRun}
disabled={busy}
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>
);
}