2026-09-23 12:45:12 +03:00
"use client" ;
import { useEffect , useState } from "react" ;
import { createPortal } from "react-dom" ;
import type { ChatDeleteMode , ChatListItem } from "@/src/core" ;
2026-09-24 20:06:22 +03:00
/** 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." ,
};
2026-09-23 12:45:12 +03:00
/**
2026-09-24 20:06:22 +03:00
* 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):
2026-09-23 12:45:12 +03:00
*
2026-09-24 20:06:22 +03:00
* - 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.
2026-09-23 12:45:12 +03:00
*
* 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
2026-09-24 20:06:22 +03:00
* already written for the user; we show it verbatim. Try-again is just
* Delete pressed again — the mode re-derives from the checkbox.
2026-09-23 12:45:12 +03:00
*/
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 ;
}) {
2026-09-24 20:06:22 +03:00
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 );
2026-09-23 12:45:12 +03:00
// Fresh state per session: closing resets, opening a new chat starts clean.
useEffect (() => {
if ( ! chat ) {
2026-09-24 20:06:22 +03:00
setTraces ( false );
setBusy ( false );
2026-09-23 12:45:12 +03:00
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" ) {
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 12:45:12 +03:00
e . stopPropagation ();
onClose ();
}
};
2026-10-07 00:08:49 +03:00
document . addEventListener ( "keydown" , onKeyDown );
return () => document . removeEventListener ( "keydown" , onKeyDown );
2026-09-23 12:45:12 +03:00
}, [ chat , onClose ]);
2026-09-24 20:06:22 +03:00
const run = async () => {
2026-09-23 12:45:12 +03:00
if ( ! chat || busy ) return ;
2026-09-24 20:06:22 +03:00
const mode : ChatDeleteMode = traces ? "delete-nodes" : "preserve-graph" ;
setBusy ( true );
2026-09-23 12:45:12 +03:00
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.
2026-09-24 20:06:22 +03:00
setFailure ({ code : body.code ?? "error" , error : body.error ?? `Delete failed ( ${ res . status } )` });
setBusy ( false );
2026-09-23 12:45:12 +03:00
return ;
}
onDeleted ( chat . id , mode , body . graphRevision ?? 0 );
} catch {
2026-09-24 20:06:22 +03:00
setFailure ({ code : "network" , error : "Delete failed — network error. Try again." });
setBusy ( false );
2026-09-23 12:45:12 +03:00
}
};
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 ();
}}
>
2026-09-24 20:06:22 +03:00
< DeleteSessionBody
chat = { chat }
traces = { traces }
busy = { busy }
failure = { failure }
onTraces = {( next ) => setTraces ( next )}
onClose = { onClose }
onRun = {() => void run ()}
/>
2026-09-23 12:45:12 +03:00
</ div >,
document . body ,
);
2026-09-24 20:06:22 +03:00
}
/** 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 >
);
2026-09-23 12:45:12 +03:00
}