Files
learnharness/components/delete-session-dialog.tsx
T
amosblomqvist 6896c23cc2 Session deletion dialog matches the node deletion dialog: red title, mono name below, checkbox flow
Owner polish round (2026-09-24): the two destructive-confirmation
surfaces must read as ONE design. The node dialog was the good one —
the session dialog now wears its exact look and flow.

components/delete-session-dialog.tsx (full rework):
- Red serif "Delete session?" (was neutral "Delete "title"?") — the
  node dialog's destructive title, text-destructive on font-title 2xl.
- The session name below the title in JetBrains Mono at text-lg,
  NO code-block chrome — the font already says code. (The node
  dialog's name got the same treatment: text-xs bg-muted code block
  dies, mono text-lg line lives. Owner chose below-the-title over
  parens-in-the-title: long titles wrap safely below, never inside
  the 25px headline.)
- TWO OPTION BUTTONS -> CHECKBOX FLOW: the locked two-button design
  (preserve-graph / delete-nodes) collapses to ONE unchecked-by-default
  checkbox "Also remove its graph traces" (accent-primary, the settings
  panel's native-checkbox idiom) carrying the graph-touching option's
  consequence copy verbatim. Unchecked = preserve-graph; checked =
  delete-nodes. The unchecked promise ("everything it added to the
  graph stays unless you check below") lives in the intro line now.
- Bottom row = the node dialog's exact pair: quiet Cancel + destructive
  red Delete (border + text, fill on hover), "Deleting…" on the Delete
  button while busy. Try-again re-derives the mode from the checkbox
  (retry IS Delete pressed again; the checkbox is disabled while busy).
- DeleteSessionBody exported pure (the settings/node dialog body
  pattern) for SSR pins; TRACES_COPY exported for the copy pin.
  DELETE_OPTIONS dies with the two-button design.

Tests: session-context.test.tsx's two-option pins rewritten for the
node-dialog shape (red title, mono name no-chrome, unchecked checkbox
default, red Delete pair, old testids absent); delete-node-dialog
pins the chrome-less text-lg name. 905/905. Live-verified on :3001:
both dialogs render 25px red serif titles, 16px mono names, matching
red Delete buttons; Esc closes both, nothing deleted in verification.
2026-09-24 20:06:22 +03:00

231 lines
7.8 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") {
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.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>
);
}