From e4fd65e27d3bab4a7b8ed4ede1154d7a1dadde41 Mon Sep 17 00:00:00 2001 From: rayzalzero Date: Mon, 28 Sep 2026 10:38:40 +0700 Subject: [PATCH] feat(sessions): delete a session from the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a trash action to every session row, behind a confirmation dialog, that removes the session and everything keyed to it. Archive stays the reversible path; this is the destructive one. The row's actions also collapse into one overflow menu. Three side-by-side hover icons cost about a third of the title's width in the 268px sidebar (measured: title column 146px -> 204px), and every further action would take another slice. The trigger is hover/focus-revealed on desktop and always visible on the phone, and a right-click on a row opens the same menu. DELETE /api/sessions/:sessionId destroys a live omp child first and AWAITS its exit — omp flushes session state on shutdown and would otherwise recreate the transcript just removed — then purges side questions, the transcript and its `.bak-*` rewind copies, the sibling artifacts directory, and the chamber's archived_sessions / session_ui_state / session_stream_state / queued_messages / chat_sessions rows. A session mid-run (a turn, a live subagent, or a dialog omp is blocked on) is refused with 409 rather than silently killing work; a pending `new-...` chat is refused because nothing is stored for it yet. An id that is not one path segment is refused at every entry point (route, purgeSessionData, purgeBtwForSession) via the shared fs/path-segment guard. `join(btwRoot, '.')` normalizes back to the btw root, so a `.` id deleted every session's side questions and still answered 404; the guard asserts `normalize(id) === id`, the same operation the callers' join performs. The sidebar dataset and the session-file caches are invalidated with the mutation: the TTL'd dataset would otherwise serve the deleted row back to the refresh that follows the delete. --- .../common/session-actions-menu/index.tsx | 201 +++++++++++++++++ .../common/session-delete-modal/index.tsx | 128 +++++++++++ .../layout/session-sidebar/CategoryItem.tsx | 18 ++ .../layout/session-sidebar/SessionItem.tsx | 208 +++++++++--------- .../mobile/mobile-session-sidebar/Item.tsx | 18 ++ .../mobile-session-sidebar/SessionRow.tsx | 146 ++++++------ src/client/hooks/workspace/session-delete.ts | 94 ++++++++ src/server/lib/btw/purge.server.ts | 56 +++++ src/server/lib/fs/path-segment.test.ts | 100 +++++++++ src/server/lib/fs/path-segment.ts | 45 ++++ src/server/lib/omp/session/delete.server.ts | 136 ++++++++++++ src/server/lib/omp/session/reader.ts | 13 ++ src/server/routes/sessions/delete.test.ts | 84 +++++++ src/server/routes/sessions/delete.ts | 83 +++++++ src/server/routes/sessions/index.ts | 5 + .../lib/workspace/session-state/store.ts | 20 ++ 16 files changed, 1192 insertions(+), 163 deletions(-) create mode 100644 src/client/components/common/session-actions-menu/index.tsx create mode 100644 src/client/components/common/session-delete-modal/index.tsx create mode 100644 src/client/hooks/workspace/session-delete.ts create mode 100644 src/server/lib/btw/purge.server.ts create mode 100644 src/server/lib/fs/path-segment.test.ts create mode 100644 src/server/lib/fs/path-segment.ts create mode 100644 src/server/lib/omp/session/delete.server.ts create mode 100644 src/server/routes/sessions/delete.test.ts create mode 100644 src/server/routes/sessions/delete.ts diff --git a/src/client/components/common/session-actions-menu/index.tsx b/src/client/components/common/session-actions-menu/index.tsx new file mode 100644 index 00000000..9fb27506 --- /dev/null +++ b/src/client/components/common/session-actions-menu/index.tsx @@ -0,0 +1,201 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * One overflow menu for a session row, shared by the desktop sidebar and the + * mobile one. + * + * The row used to carry its actions as side-by-side hover icons (rename, + * archive, delete). That does not scale: three icons cost roughly a third of + * the title's width in the 268px sidebar, and every action added from here + * takes another slice. One trigger whose contents grow is the shape that + * survives the next action. + * + * Two ways in, one menu out: + * - the `⋯` button, which is the discoverable affordance; + * - a right-click anywhere on the row, which is the accelerator. Both are + * wired by the row component through `useRowMenu`. + * + * The menu positions itself from its OWN measured size. The item list is + * conditional (a pending `new-…` chat has no Rename or Delete), so a constant + * height would put the last row off-screen at the viewport edge — the same + * class of bug the file explorer's context menu works around with hand-tuned + * numbers. + */ + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; +import type { TargetedMouseEvent } from 'preact'; +import { createPortal } from 'preact/compat'; +import { Archive, ArchiveRestore, Pencil, Trash2 } from 'lucide-preact'; + +/** Viewport coordinates the menu hangs off, plus which edge it aligns to. */ +export interface MenuAnchor { + top: number; + right: number; + bottom: number; + left: number; + /** + * `end` hangs the menu's right edge off the anchor's — what a row's overflow + * button expects, since it sits at the row's end. `start` puts the menu's + * left edge at the anchor's, so a context-menu click opens away from the + * pointer rather than under it. + */ + align: 'start' | 'end'; +} + +export interface RowMenuHandle { + /** Non-null while the menu is open. */ + anchor: MenuAnchor | null; + /** Open below the clicked element (the `⋯` button). */ + openBelow: (event: TargetedMouseEvent) => void; + /** Open at the pointer (a right-click on the row). */ + openAtCursor: (event: TargetedMouseEvent) => void; + close: () => void; +} + +/** Anchor state for one row's overflow menu. */ +export function useRowMenu(): RowMenuHandle { + const [anchor, setAnchor] = useState(null); + + const openBelow = useCallback((event: TargetedMouseEvent) => { + event.preventDefault(); + // The row underneath selects the session on click; opening its menu is not + // a selection. + event.stopPropagation(); + const rect = event.currentTarget.getBoundingClientRect(); + setAnchor({ top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left, align: 'end' }); + }, []); + + const openAtCursor = useCallback((event: TargetedMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const { clientX, clientY } = event; + setAnchor({ top: clientY, right: clientX, bottom: clientY, left: clientX, align: 'start' }); + }, []); + + const close = useCallback(() => setAnchor(null), []); + + return { anchor, openBelow, openAtCursor, close }; +} + +export interface SessionActionsMenuProps { + anchor: MenuAnchor; + isArchived?: boolean; + /** Omitted when the row cannot be renamed (a pending `new-…` chat). */ + onRename?: () => void; + onArchive?: () => void; + onDelete?: () => void; + onClose: () => void; +} + +export function SessionActionsMenu({ + anchor, + isArchived = false, + onRename, + onArchive, + onDelete, + onClose, +}: SessionActionsMenuProps) { + const menuRef = useRef(null); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + + useLayoutEffect(() => { + const el = menuRef.current; + if (!el) return; + const { width, height } = el.getBoundingClientRect(); + const margin = 8; + const below = anchor.bottom + 4; + // Flip above the trigger rather than overflowing the bottom edge. + const top = below + height + margin <= window.innerHeight + ? below + : Math.max(margin, anchor.top - height - 4); + const desiredLeft = anchor.align === 'end' ? anchor.right - width : anchor.left; + setPos({ + top, + left: Math.max(margin, Math.min(desiredLeft, window.innerWidth - width - margin)), + }); + }, [anchor]); + + useEffect(() => { + const onKeyDown = (e: globalThis.KeyboardEvent) => { + if (e.key !== 'Escape') return; + e.preventDefault(); + onClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onClose]); + + // Every item closes the menu first: Rename opens an inline editor in the row, + // and leaving the menu over it would cover the input. + const run = (action?: () => void) => () => { + onClose(); + action?.(); + }; + + return createPortal( +
{ + e.preventDefault(); + onClose(); + }} + > + , + document.body, + ); +} diff --git a/src/client/components/common/session-delete-modal/index.tsx b/src/client/components/common/session-delete-modal/index.tsx new file mode 100644 index 00000000..5278384d --- /dev/null +++ b/src/client/components/common/session-delete-modal/index.tsx @@ -0,0 +1,128 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect } from 'preact/hooks'; +import { AlertTriangle, Loader2, Trash2 } from 'lucide-preact'; +import { Modal } from '@/client/components/common/Modal'; +import type { SessionItemData } from '@/shared/types'; + +export interface SessionDeleteModalProps { + /** The session awaiting confirmation, or null when the dialog is closed. */ + session: SessionItemData | null; + /** True while the DELETE request is in flight. */ + isDeleting: boolean; + /** Why the server refused, shown in place of the warning strip. */ + error: string | null; + onClose: () => void; + onConfirm: () => void; +} + +/** + * Confirmation for deleting a session. + * + * The dialog names what actually disappears — the transcript on disk, the + * subagent transcripts beside it, the side questions asked in it, and the + * chamber's own rows for it — because none of it is recoverable from the UI. + * That is the whole difference between this button and the archive button next + * to it, and a bare "Delete?" would leave the user guessing which one they + * pressed. + * + * A refusal is rendered INSIDE the dialog rather than as a toast: the reasons + * the server gives (the session is mid-run, or the chat has not been created + * yet) are actionable, and the user is standing in the place where they act on + * them. + */ +export function SessionDeleteModal({ + session, + isDeleting, + error, + onClose, + onConfirm, +}: SessionDeleteModalProps) { + useEffect(() => { + // No dismissing mid-delete: the dialog resolves itself on success, and an + // early close would let a second delete be queued against a stale row. + const handler = (e: globalThis.KeyboardEvent) => { + if (e.key !== 'Escape' || isDeleting) return; + e.preventDefault(); + onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [onClose, isDeleting]); + + if (!session) return null; + + return ( + {} : onClose} + zClass="z-[70]" + maxWidthClass="max-w-[460px]" + header={ +
+ +
+

Delete session?

+

{session.title}

+
+
+ } + footer={ +
+ + +
+ } + > +
+
    +
  • + Its transcript is removed from{' '} + ~/.omp/agent/sessions — the conversation is + gone from the sidebar and from oh-my-pi. +
  • +
  • The subagent transcripts and any side questions asked in it are removed with it.
  • +
  • Its queued messages, archive state, and saved panel layout go too.
  • +
+ + {error ? ( +
+ + {error} +
+ ) : ( +
+ + This cannot be undone from the UI. Archive it instead to keep the transcript. +
+ )} +
+
+ ); +} diff --git a/src/client/components/layout/session-sidebar/CategoryItem.tsx b/src/client/components/layout/session-sidebar/CategoryItem.tsx index ee58f625..6bf81d18 100644 --- a/src/client/components/layout/session-sidebar/CategoryItem.tsx +++ b/src/client/components/layout/session-sidebar/CategoryItem.tsx @@ -5,6 +5,8 @@ import { useFetcher } from '@/client/lib/router/fetcher'; import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside'; import { useShowMore } from '@/client/hooks/ui/show-more'; import { useWorkspaceFolderActions } from '@/client/hooks/workspace/workspace-folder-actions'; +import { useSessionDelete } from '@/client/hooks/workspace/session-delete'; +import { SessionDeleteModal } from '@/client/components/common/session-delete-modal'; import { SessionItem } from '@/client/components/layout/session-sidebar/SessionItem'; import { SubagentList } from '@/client/components/common/subagent-list'; import { WorkspaceOptionsMenu } from '@/client/components/common/workspace-options-menu'; @@ -58,6 +60,7 @@ export function Category({ handleArchive, handleRename, } = useWorkspaceFolderActions(folder, refresh); + const sessionDelete = useSessionDelete(); // Desktop's expand toggle keeps its own fetcher: unlike pin/delete it // dispatches on the *response* (not immediately) and carries no folderId. @@ -203,6 +206,13 @@ export function Category({ awaitingInput={Boolean(session.awaitingInput)} onClick={() => onSelectSession(session.id)} onArchive={() => handleArchive(session)} + onDelete={ + // A pending `new-…` chat has no transcript anywhere yet — + // there is nothing to delete, and the server refuses it. + String(session.id).startsWith('new-') + ? undefined + : () => sessionDelete.requestDelete(session) + } onRename={String(session.id).startsWith('new-') ? undefined : (name) => void handleRename(session, name)} expandable={hasSubagents} hasSubagents={hasSubagents} @@ -236,6 +246,14 @@ export function Category({ )}
)} + + void sessionDelete.confirmDelete()} + /> ); } diff --git a/src/client/components/layout/session-sidebar/SessionItem.tsx b/src/client/components/layout/session-sidebar/SessionItem.tsx index 1344aa3d..b8c21f73 100644 --- a/src/client/components/layout/session-sidebar/SessionItem.tsx +++ b/src/client/components/layout/session-sidebar/SessionItem.tsx @@ -1,6 +1,7 @@ import type { TargetedMouseEvent } from 'preact'; -import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, Pencil } from 'lucide-preact'; +import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, MoreHorizontal } from 'lucide-preact'; import { useInlineRename } from '@/client/hooks/ui/inline-rename'; +import { SessionActionsMenu, useRowMenu } from '@/client/components/common/session-actions-menu'; export interface SessionItemProps { title: string; @@ -11,6 +12,9 @@ export interface SessionItemProps { awaitingInput?: boolean; onClick?: () => void; onArchive?: () => void; + /** Omitted for a session that cannot be deleted yet (a pending `new-…` chat, + * which has no transcript anywhere). */ + onDelete?: () => void; onRename?: (name: string) => void; expandable?: boolean; isExpanded?: boolean; @@ -26,6 +30,7 @@ export function SessionItem({ awaitingInput = false, onClick, onArchive, + onDelete, onRename, expandable = false, isExpanded = false, @@ -51,116 +56,121 @@ export function SessionItem({ handleKeyDown, handleBlur, } = useInlineRename(title, onRename); + // One trigger for every row action; also reachable by right-clicking the row. + const menu = useRowMenu(); + const hasActions = Boolean(onRename || onArchive || onDelete); return ( -
- {/* One 16px slot owns both icons — never two side by side. A live run - signal (blocked question, run spinner) holds the slot; otherwise the - chevron is pinned, collapsed or expanded. While the signal holds it, - hovering hands the slot to the chevron so the roster stays reachable - mid-run. - Both layers are pointer-inert for the opposite state: the toggle - while hidden, the status glyph ALWAYS (it is painted after the - toggle, so without that it wins the hit test over the revealed - chevron and every click on a badged row selects the session instead - of expanding it). */} - - {showChevron && onToggleExpand && ( - - )} - {showStatus && ( - - {/* Waiting on an answer outranks the run spinner: the spinner says - work is happening, the question mark says it is YOUR turn. */} - {awaitingInput ? ( - - ) : status === 'stream' ? ( - - ) : ( - - )} - - )} - - - {/* Gap between icon and text */} - - - {/* Session Title - aligned straight with Folder Name */} - {isEditing ? ( - setDraft(e.currentTarget.value)} - onClick={(e) => e.stopPropagation()} - onKeyDown={handleKeyDown} - onBlur={handleBlur} - className="flex-1 min-w-0 bg-paper border border-ink/25 rounded px-1 py-0.5 text-xs text-ink outline-none focus:border-ink/50" - /> - ) : ( - - {title.charAt(0).toUpperCase() + title.slice(1)} - - )} - - {/* Quick Actions: Rename / Archive on Hover */} - {!isEditing && (onRename || onArchive) && ( -
- {onRename && ( + <> +
+ {/* One 16px slot owns both icons — never two side by side. A live run + signal (blocked question, run spinner) holds the slot; otherwise the + chevron is pinned, collapsed or expanded. While the signal holds it, + hovering hands the slot to the chevron so the roster stays reachable + mid-run. + Both layers are pointer-inert for the opposite state: the toggle + while hidden, the status glyph ALWAYS (it is painted after the + toggle, so without that it wins the hit test over the revealed + chevron and every click on a badged row selects the session instead + of expanding it). */} + + {showChevron && onToggleExpand && ( )} - {onArchive && ( - + {/* Waiting on an answer outranks the run spinner: the spinner says + work is happening, the question mark says it is YOUR turn. */} + {awaitingInput ? ( + + ) : status === 'stream' ? ( + + ) : ( + + )} + )} -
+ + + {/* Gap between icon and text */} + + + {/* Session Title - aligned straight with Folder Name */} + {isEditing ? ( + setDraft(e.currentTarget.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className="flex-1 min-w-0 bg-paper border border-ink/25 rounded px-1 py-0.5 text-xs text-ink outline-none focus:border-ink/50" + /> + ) : ( + + {title.charAt(0).toUpperCase() + title.slice(1)} + + )} + + {/* One trigger, whatever the row can do. The three side-by-side hover + icons this replaced cost a third of the title's width in a 268px + sidebar, and each further action took another slice — the menu grows + instead. Revealed on hover/focus; a right-click anywhere on the row + opens the same menu. */} + {!isEditing && hasActions && ( + + )} + +
+ + {menu.anchor && ( + )} -
+ ); } diff --git a/src/client/components/mobile/mobile-session-sidebar/Item.tsx b/src/client/components/mobile/mobile-session-sidebar/Item.tsx index b9775670..818c75f8 100644 --- a/src/client/components/mobile/mobile-session-sidebar/Item.tsx +++ b/src/client/components/mobile/mobile-session-sidebar/Item.tsx @@ -7,6 +7,8 @@ import { WorkspaceOptionsMenu } from '@/client/components/common/workspace-optio import { useShowMore } from '@/client/hooks/ui/show-more'; import { useExpandedSessions } from '@/client/hooks/workspace/expanded-sessions'; import { useWorkspaceFolderActions } from '@/client/hooks/workspace/workspace-folder-actions'; +import { useSessionDelete } from '@/client/hooks/workspace/session-delete'; +import { SessionDeleteModal } from '@/client/components/common/session-delete-modal'; import { getProjectIcon } from '@/shared/lib/workspace/project-icon'; import { relativeTimeAgo } from '@/shared/lib/workspace/relative-time'; import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside'; @@ -53,6 +55,7 @@ export function MobileSessionCategory({ handleArchive, handleRename, } = useWorkspaceFolderActions(folder, refresh); + const sessionDelete = useSessionDelete(); useOnClickOutside(menuRef, () => { setShowMenu(false); @@ -195,6 +198,13 @@ export function MobileSessionCategory({ onToggleExpand={() => toggleSession(sessionKey)} onSelect={() => onSelectSession(session.id)} onArchive={() => handleArchive(session)} + onDelete={ + // A pending `new-…` chat has no transcript anywhere yet — + // there is nothing to delete, and the server refuses it. + sessionKey.startsWith('new-') + ? undefined + : () => sessionDelete.requestDelete(session) + } onRename={sessionKey.startsWith('new-') ? undefined : (name) => void handleRename(session, name)} /> {isRosterOpen && ( @@ -216,6 +226,14 @@ export function MobileSessionCategory({ )} )} + + void sessionDelete.confirmDelete()} + /> ); } diff --git a/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx b/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx index 55632335..9dd28374 100644 --- a/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +++ b/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx @@ -1,6 +1,7 @@ -import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, Pencil } from 'lucide-preact'; +import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, MoreHorizontal } from 'lucide-preact'; import type { SessionItemData } from '@/shared/types'; import { useInlineRename } from '@/client/hooks/ui/inline-rename'; +import { SessionActionsMenu, useRowMenu } from '@/client/components/common/session-actions-menu'; export interface MobileSessionRowProps { session: SessionItemData; @@ -16,6 +17,9 @@ export interface MobileSessionRowProps { onToggleExpand?: () => void; onSelect: () => void; onArchive: () => void; + /** Omitted for a session that cannot be deleted yet (a pending `new-…` chat, + * which has no transcript anywhere). */ + onDelete?: () => void; onRename?: (name: string) => void; } @@ -30,6 +34,7 @@ export function MobileSessionRow({ onToggleExpand, onSelect, onArchive, + onDelete, onRename, }: MobileSessionRowProps) { const { @@ -41,6 +46,9 @@ export function MobileSessionRow({ handleKeyDown, handleBlur, } = useInlineRename(session.title, onRename); + // One trigger for every row action; also reachable by long-press contextmenu. + const menu = useRowMenu(); + const hasActions = Boolean(onRename || onArchive || onDelete); // The roster toggle is the row's LAST element, so its glyph lands in the same // column as the folder header's expand chevron — one vertical line of @@ -71,76 +79,86 @@ export function MobileSessionRow({ } return ( -
- - - {onRename && ( - )} - {/* A row WITHOUT a roster toggle still has to keep the toggle's column - empty, or its archive glyph would sit where the toggle is and the - trailing icons would step in and out row to row. This `mr-1` is what - reserves that column: it moves the 14px glyph's centre from x=363 to - x=359, the exact x the toggle uses on a row that has one. */} - + {/* One trigger for rename / archive / delete, always visible: a touch + surface has no hover, and the always-on action buttons this replaced + consumed a third of a phone's row width. + `mr-1` only when the row has NO roster toggle — it reserves that + column so the trailing glyph's centre stays 19px from the row's right + edge whether the last element is this trigger or the chevron. Without + it the glyphs step in and out from row to row. */} + {hasActions && ( + + )} - {/* Last element, so its glyph sits in the folder header's chevron column. - `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a - touch-sized tap target. */} - {showChevron && ( - + {/* Last element, so its glyph sits in the folder header's chevron column. + `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a + touch-sized tap target. */} + {showChevron && ( + + )} +
+ + {menu.anchor && ( + )} - + ); } diff --git a/src/client/hooks/workspace/session-delete.ts b/src/client/hooks/workspace/session-delete.ts new file mode 100644 index 00000000..01a87d32 --- /dev/null +++ b/src/client/hooks/workspace/session-delete.ts @@ -0,0 +1,94 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Session deletion, shared by the desktop session row and the mobile one. + * + * Both sidebars render the same confirmation dialog and issue the same request, + * so the state machine (which row is pending, in-flight, why it was refused) + * lives here rather than in two components that would drift on the URL, the + * verb, or the event they dispatch afterwards. + * + * Two follow-ups are load-bearing and both happen only on success: + * + * - `omp:workspace-updated` refreshes the sidebar list. The deleted row is + * gone from disk, but the client still holds the previous snapshot. + * - when the deleted session is the one on screen, `?sessionId=` is dropped. + * Leaving the URL pointing at a removed session renders the chat's "not + * found" state over a row the user just deleted, and every panel keyed to + * that id keeps fetching for it. + */ + +import { useCallback, useState } from 'preact/hooks'; +import { useSearchParams } from '@/client/lib/router/search-params'; +import { forgetSession } from '@/shared/lib/workspace/session-state/store'; +import type { SessionItemData } from '@/shared/types'; + +export interface SessionDeleteActions { + /** The session awaiting confirmation, or null when the dialog is closed. */ + pending: SessionItemData | null; + /** True while the DELETE request is in flight. */ + isDeleting: boolean; + /** The server's refusal reason, shown inside the dialog. */ + error: string | null; + /** Open the confirmation for this session. */ + requestDelete: (session: SessionItemData) => void; + /** Close it without deleting. */ + cancelDelete: () => void; + /** Issue the delete. Resolves once the request settled. */ + confirmDelete: () => Promise; +} + +export function useSessionDelete(): SessionDeleteActions { + const [pending, setPending] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + + const requestDelete = useCallback((session: SessionItemData) => { + setError(null); + setPending(session); + }, []); + + const cancelDelete = useCallback(() => { + setError(null); + setPending(null); + }, []); + + const confirmDelete = useCallback(async () => { + if (!pending || isDeleting) return; + const sessionId = String(pending.id); + setIsDeleting(true); + setError(null); + try { + const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }); + const body = (await res.json().catch(() => null)) as { error?: string } | null; + if (!res.ok) { + // The refusal reasons the server gives (mid-run, pending chat) are + // actionable, so they stay on screen instead of closing the dialog. + setError(body?.error || `Could not delete the session (HTTP ${res.status}).`); + return; + } + forgetSession(sessionId); + if (searchParams.get('sessionId') === sessionId) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.delete('sessionId'); + // A subagent transcript belongs to the session being removed. + next.delete('subagent'); + return next; + }, { replace: true }); + } + setPending(null); + window.dispatchEvent(new CustomEvent('omp:workspace-updated')); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not reach the server.'); + } finally { + setIsDeleting(false); + } + }, [pending, isDeleting, searchParams, setSearchParams]); + + return { pending, isDeleting, error, requestDelete, cancelDelete, confirmDelete }; +} diff --git a/src/server/lib/btw/purge.server.ts b/src/server/lib/btw/purge.server.ts new file mode 100644 index 00000000..a12c7211 --- /dev/null +++ b/src/server/lib/btw/purge.server.ts @@ -0,0 +1,56 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Deleting a session's side questions. + * + * A side question is keyed to the parent session but stored outside it — rows + * in `btw_topics`/`btw_turns`, a live omp child per topic, and a private + * transcript directory under `/btw///`. Deleting + * the chat session therefore leaves all three behind unless they are purged + * with it: orphan turns still readable in the database, and on disk a full + * copy of the very transcript the user just deleted. + * + * Kept out of `service.server.ts` (which owns the interactive topic + * operations) so that file's growth stays attributable. + */ + +import path from 'path'; +import { listBtwTopics, deleteBtwTopic } from '@/server/lib/btw/store.server'; +import { forgetBtwRuntime, getBtwRuntime } from '@/server/lib/btw/registry.server'; +import { getBtwRoot, removeBtwWorkspace, resolveBtwWorkspacePaths } from '@/server/lib/btw/session-copy.server'; +import { isSinglePathSegment } from '@/server/lib/fs/path-segment'; + +/** + * Drop every topic of `sessionId`, its child, its rows, and its workspace. + * + * `sessionId` MUST name one child of the btw root. The last step below removes + * `join(getBtwRoot(), sessionId)` unconditionally, and `join(root, '.')` is + * `root` itself — so an id of `.` (or any id with a separator) turns this + * per-session purge into "delete every session's side questions on disk". The + * route validates its id; this check is the independent second gate, because + * this function is reachable on its own and its failure mode is other + * sessions' data. + */ +export async function purgeBtwForSession(sessionId: string): Promise { + if (!isSinglePathSegment(sessionId)) return 0; + + const topics = await listBtwTopics(sessionId); + for (const topic of topics) { + const runtime = getBtwRuntime(topic.id); + if (runtime) { + // Disposes the child; a running turn is settled as cancelled rather than + // left to write into a transcript this purge is about to remove. + await runtime.dispose(); + forgetBtwRuntime(topic.id); + } + await deleteBtwTopic(topic.id); + await removeBtwWorkspace((await resolveBtwWorkspacePaths(sessionId, topic.id)).dir); + } + // The per-session directory as a whole: a topic whose row is already gone can + // still have a workspace (a crash between the two, a manual row delete). + await removeBtwWorkspace(path.join(await getBtwRoot(), sessionId)); + return topics.length; +} diff --git a/src/server/lib/fs/path-segment.test.ts b/src/server/lib/fs/path-segment.test.ts new file mode 100644 index 00000000..e14da41e --- /dev/null +++ b/src/server/lib/fs/path-segment.test.ts @@ -0,0 +1,100 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The session-delete blast radius. + * + * `DELETE /api/sessions/:sessionId` removes a directory under the sessions root + * and another under the btw root, both by joining the id onto the root. The + * case that shipped broken was `.`: `join(root, '.')` is `root` itself, so the + * btw purge's unconditional workspace removal deleted EVERY session's side + * questions and the route still answered 404 — an invisible, root-wide wipe + * from a request that looked like a miss. + * + * These tests pin the two properties that made it possible: the guard rejects + * every value that is not one segment, and the btw purge refuses a non-segment + * even when called directly (the route is not the only entry point). + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import { isSinglePathSegment } from '@/server/lib/fs/path-segment'; +import { purgeBtwForSession } from '@/server/lib/btw/purge.server'; + +describe('isSinglePathSegment', () => { + test('accepts the id shapes the app actually produces', () => { + // omp session ids are UUIDs; mock ids are integers; pending chats are `new-…`. + expect(isSinglePathSegment('11111111-2222-3333-4444-555555555555')).toBe(true); + expect(isSinglePathSegment('3')).toBe(true); + expect(isSinglePathSegment('new-1758800000000')).toBe(true); + // A dot INSIDE a name is fine — only a name that resolves to a directory is not. + expect(isSinglePathSegment('session.v2')).toBe(true); + }); + + test('refuses the values that resolve somewhere other than one child', () => { + // The shipped bug: join(root, '.') === root. + expect(isSinglePathSegment('.')).toBe(false); + expect(isSinglePathSegment('..')).toBe(false); + expect(isSinglePathSegment('a/b')).toBe(false); + expect(isSinglePathSegment('..\\a')).toBe(false); + expect(isSinglePathSegment('')).toBe(false); + // A NUL byte truncates at the syscall boundary. + expect(isSinglePathSegment('a\0b')).toBe(false); + }); + + test('the guard agrees with the join it is guarding', () => { + // The property that matters: whenever the guard says yes, joining onto a + // root stays directly inside it. Asserted against `path.join` itself rather + // than a restatement of the rule. + const root = '/tmp/root'; + for (const id of ['11111111-2222-3333-4444-555555555555', '3', 'new-1', 'session.v2']) { + expect(isSinglePathSegment(id)).toBe(true); + expect(path.join(root, id).startsWith(`${root}${path.sep}`)).toBe(true); + expect(path.dirname(path.join(root, id))).toBe(root); + } + for (const id of ['.', '..', 'a/b', '../x']) { + expect(isSinglePathSegment(id)).toBe(false); + } + }); +}); + +describe('purgeBtwForSession refuses a non-segment id', () => { + const root = '/tmp/omc-path-segment-test'; + const btwRoot = path.join(root, 'btw'); + const seeded = [ + path.join(btwRoot, 'sessA', 'topicA', 'session.jsonl'), + path.join(btwRoot, 'sessB', 'topicB', 'session.jsonl'), + ]; + + beforeAll(async () => { + await fs.promises.rm(root, { recursive: true, force: true }); + // Two unrelated sessions' side questions, exactly as the bug destroyed them. + // The btw root is derived from the database path, so pointing that at this + // tree is what puts them where the purge would look. + for (const file of seeded) { + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + await Bun.write(file, '{"type":"session"}\n'); + } + Bun.env.OMPCHAMBER_DB_PATH = path.join(root, 'db.sqlite'); + }); + + afterAll(async () => { + delete Bun.env.OMPCHAMBER_DB_PATH; + await fs.promises.rm(root, { recursive: true, force: true }); + }); + + test('`.` does not remove the btw root', async () => { + // The guard returns before `listBtwTopics`, so no database is touched. + expect(await purgeBtwForSession('.')).toBe(0); + // Both sessions' transcripts must survive — this is the regression. + for (const file of seeded) expect(fs.existsSync(file)).toBe(true); + }); + + test('a separator does not reach outside the root', async () => { + expect(await purgeBtwForSession('../btw')).toBe(0); + for (const file of seeded) expect(fs.existsSync(file)).toBe(true); + }); +}); diff --git a/src/server/lib/fs/path-segment.ts b/src/server/lib/fs/path-segment.ts new file mode 100644 index 00000000..ca4ba61e --- /dev/null +++ b/src/server/lib/fs/path-segment.ts @@ -0,0 +1,45 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * One rule, two callers: does this id name exactly one child of a root? + * + * The purge paths join a session id onto a root to find the directory it owns — + * `join(sessionsRoot, …)`, `join(btwRoot, sessionId)` — and then remove it + * recursively. `path.join` NORMALIZES, so a value that is not a plain segment + * silently retargets the removal: + * + * join(root, '.') === root → deletes every child of the root + * join(root, '..') === dirname(root) → deletes outside the root + * join(root, 'a/b') → reaches into a sibling subtree + * + * Measured: `DELETE /api/sessions/.` (curl needs `--path-as-is`; it strips the + * trailing dot itself) passed a `/^[A-Za-z0-9._-]+$/` guard, normalized to the + * btw root, and removed two unrelated sessions' side-question transcripts. + * + * The check is deliberately structural rather than an enumeration of dangerous + * spellings: an id is safe exactly when it is one non-dot segment. Real ids + * satisfy that by construction — omp session ids are UUIDs and mock ids are + * integers — so nothing legitimate is refused. + * + * `normalize` is compared, not just scanned for separators, because it is the + * same operation the callers' `join` performs: asserting the two agree is what + * makes this a proof about the path that will actually be built, rather than a + * guess about which inputs look suspicious. + */ + +import { normalize } from 'node:path'; + +/** True when `id` names one child of a root, and nothing else. */ +export function isSinglePathSegment(id: string): boolean { + if (id.length === 0) return false; + if (id === '.' || id === '..') return false; + if (id.includes('/') || id.includes('\\')) return false; + // A NUL byte would truncate the path at the syscall boundary. + if (id.includes('\0')) return false; + // The load-bearing assertion: normalization must be a no-op, so `join(root, id)` + // cannot resolve anywhere but directly under `root`. + return normalize(id) === id; +} diff --git a/src/server/lib/omp/session/delete.server.ts b/src/server/lib/omp/session/delete.server.ts new file mode 100644 index 00000000..33ed263a --- /dev/null +++ b/src/server/lib/omp/session/delete.server.ts @@ -0,0 +1,136 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Deleting a session: the transcript on disk, its subagent artifacts, and every + * chamber-side row keyed to the session id. + * + * The transcript is authoritative — omp's own session listing is derived from + * the JSONL alone — so a delete that removed only the chamber's rows would see + * the session come straight back on the next sidebar scan. What "delete" means + * here, in one place: + * + * - the session JSONL, plus any `.jsonl.bak-*` copy an earlier rewind + * left beside it (a full copy of the same transcript: leaving it behind + * would mean the conversation was not actually deleted); + * - its sibling artifacts directory, which holds the subagent transcripts; + * - the `archived_sessions`, `session_ui_state`, `session_stream_state`, + * `queued_messages` and `chat_sessions` rows for that id; + * - in mock mode the demo `sessions` row and its `files` tree. + * + * The caller owns the process: a live omp child MUST be destroyed before this + * runs, because omp flushes its session state on shutdown and would otherwise + * recreate the file this just removed. Side-question rows and workspaces are + * the btw module's business (`purgeBtwForSession`). + */ + +import fs from 'fs'; +import path from 'path'; +import { getDb } from '@/server/db.server'; +import type { DbClient } from '@/server/lib/db/client'; +import { findSessionFileById } from '@/server/lib/omp/session/locator'; +import { clearSessionFileCaches } from '@/server/lib/omp/session/files'; +import { invalidateOmpSidebarData } from '@/server/lib/omp/session/reader'; +import { siblingDirForSession } from '@/server/lib/omp/subagent/history/paths'; +import { isSinglePathSegment } from '@/server/lib/fs/path-segment'; + +export interface SessionPurgeResult { + /** Something keyed to this id existed — a transcript, a mock session row, or + * a chamber row. False means the id resolved nowhere at all. */ + found: boolean; + fileRemoved: boolean; + artifactsRemoved: boolean; +} + +/** + * Chamber rows keyed by a session id. Every statement is a no-op when the id + * matches nothing, which is what makes one purge serve both modes: real + * sessions (omp UUIDs, present on disk only) and mock ones (numeric ids in + * `sessions`). + * + * `files` carries a FOREIGN KEY to `sessions` and `foreign_keys` is ON, so its + * children go before the session row or the delete is refused. + */ +const SESSION_ROW_DELETES: readonly (readonly [string, string])[] = [ + ['archived_sessions', 'session_id'], + ['session_ui_state', 'session_id'], + ['session_stream_state', 'session_id'], + ['queued_messages', 'session_id'], + ['chat_sessions', 'session_id'], + ['files', 'session_id'], +]; + +async function purgeChamberRows(db: DbClient, sessionId: string): Promise { + let changes = 0; + for (const [table, column] of SESSION_ROW_DELETES) { + const result = await db.run(`DELETE FROM ${table} WHERE ${column} = ?`, [sessionId]); + changes += result.changes ?? 0; + } + const mockRow = await db.run('DELETE FROM sessions WHERE id = ?', [sessionId]); + return changes + (mockRow.changes ?? 0); +} + +/** Remove the transcript and the `.bak-*` copies beside it. */ +async function removeTranscript(filePath: string): Promise { + const existed = await Bun.file(filePath).exists(); + await fs.promises.rm(filePath, { force: true }); + const dir = path.dirname(filePath); + const prefix = `${path.basename(filePath)}.bak-`; + try { + for (const entry of await fs.promises.readdir(dir)) { + if (entry.startsWith(prefix)) await fs.promises.rm(path.join(dir, entry), { force: true }); + } + } catch { + // A backup that cannot be listed is not worth failing a delete the + // transcript half of which already succeeded. + } + return existed; +} + +/** + * Purge everything the chamber and the agent directory hold for `sessionId`. + * `sessionId` must be a single safe path segment — it names the artifacts + * directory — which the route validates before calling. + */ +export async function purgeSessionData(sessionId: string): Promise { + // Second gate, independent of the route's: an id that is not one segment + // would make `siblingDirForSession` and the btw purge resolve outside the + // session it names. Refusing here means a future caller that forgets the + // route's guard still cannot widen the blast radius. + if (!isSinglePathSegment(sessionId)) { + return { found: false, fileRemoved: false, artifactsRemoved: false }; + } + + const filePath = await findSessionFileById(sessionId); + let fileRemoved = false; + let artifactsRemoved = false; + if (filePath) { + fileRemoved = await removeTranscript(filePath); + const artifactsDir = siblingDirForSession(filePath); + // `stat`, not `Bun.file().exists()`: the latter answers for regular files + // only and reports a directory as absent, which would claim every session + // had no subagent transcripts. + artifactsRemoved = await fs.promises + .stat(artifactsDir) + .then((stats) => stats.isDirectory(), () => false); + await fs.promises.rm(artifactsDir, { recursive: true, force: true }); + } + + const db = await getDb(); + const rowChanges = await purgeChamberRows(db, sessionId); + + // Both caches outlive the mutation by their own keys: the file list/scan pair + // is mtime-keyed (a delete bumps the parent directory, but the memo can still + // answer first) and the sidebar dataset is TTL'd, so without this the deleted + // row is served back to the refresh that follows the delete. + clearSessionFileCaches(); + invalidateOmpSidebarData(); + + return { + found: Boolean(filePath) || rowChanges > 0, + fileRemoved, + artifactsRemoved, + }; +} diff --git a/src/server/lib/omp/session/reader.ts b/src/server/lib/omp/session/reader.ts index 80a01960..de652635 100644 --- a/src/server/lib/omp/session/reader.ts +++ b/src/server/lib/omp/session/reader.ts @@ -60,6 +60,19 @@ export async function loadOmpSidebarData(): Promise { return slot.inFlight; } +/** + * Drop the cached dataset so the next load re-scans disk. For a mutation the + * TTL cannot see: deleting a session removes a row rather than changing one, so + * nothing in the cached snapshot is stale — it is simply wrong, and the refresh + * that follows the delete would otherwise serve it back for up to the TTL. + */ +export function invalidateOmpSidebarData(): void { + const slot = globalThis.__ompChamberSidebarDataCache; + if (!slot) return; + slot.data = undefined as unknown as OmpSidebarData; + slot.expiresAt = 0; +} + /** Resolve each unique cwd to its project root; bounded concurrency so 100+ * unique cwds don't spawn 100 parallel git processes. */ async function resolveRootsByCwd(cwds: string[]): Promise> { diff --git a/src/server/routes/sessions/delete.test.ts b/src/server/routes/sessions/delete.test.ts new file mode 100644 index 00000000..079a7584 --- /dev/null +++ b/src/server/routes/sessions/delete.test.ts @@ -0,0 +1,84 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The delete route's refusal branches. + * + * Each one guards a different failure, and two of them are about blast radius + * rather than tidiness: the id names a directory under the sessions root and + * another under the btw root, so a value that is not one segment deletes far + * more than the session it names (`.` normalized to the btw root itself and + * wiped every session's side questions — see `fs/path-segment`). + * + * The route is called directly, as the other route tests do: these are pure + * request-shape checks that must not reach the filesystem or spawn an omp + * child, and asserting that is half the point. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import { deleteSession } from '@/server/routes/sessions/delete'; + +/** + * The 404 branch resolves the id by scanning the sessions directory, so the + * agent dir is pointed at an empty temp tree. Without this the test walks the + * developer's real `~/.omp/agent/sessions` (read-only, but seconds of I/O and a + * result that depends on what happens to be installed). + */ +const AGENT_DIR = '/tmp/omc-delete-route-test/agent'; + +beforeAll(() => { + fs.rmSync(AGENT_DIR, { recursive: true, force: true }); + fs.mkdirSync(path.join(AGENT_DIR, 'sessions'), { recursive: true }); + Bun.env.PI_CODING_AGENT_DIR = AGENT_DIR; +}); + +afterAll(() => { + delete Bun.env.PI_CODING_AGENT_DIR; + fs.rmSync('/tmp/omc-delete-route-test', { recursive: true, force: true }); +}); + +function del(sessionId: string): Promise { + return deleteSession({ + request: new Request(`http://localhost/api/sessions/${sessionId}`, { method: 'DELETE' }), + params: { sessionId }, + } as never) as Promise; +} + +describe('deleteSession refusals', () => { + test('a pending chat is refused — nothing is stored for it yet', async () => { + const res = await del('new-1758800000000'); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ code: 'session_pending' }); + }); + + test('`.` is refused instead of wiping the btw root', async () => { + const res = await del('.'); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ error: 'Invalid session id' }); + }); + + test('a traversal is refused', async () => { + for (const id of ['..', '../x', 'a/b', '..\\a']) { + expect((await del(id)).status).toBe(400); + } + }); + + test('an unknown but well-formed id is a 404, not a 400', async () => { + // Distinct from the branches above: the request is valid, the session is + // not there. Collapsing the two would report a typo as a malformed request. + const res = await del('00000000-0000-0000-0000-000000000000'); + expect(res.status).toBe(404); + }); + + test('a wrong verb on the path is a 405', async () => { + const res = (await deleteSession({ + request: new Request('http://localhost/api/sessions/x', { method: 'GET' }), + params: { sessionId: 'x' }, + } as never)) as Response; + expect(res.status).toBe(405); + }); +}); diff --git a/src/server/routes/sessions/delete.ts b/src/server/routes/sessions/delete.ts new file mode 100644 index 00000000..fe56dcad --- /dev/null +++ b/src/server/routes/sessions/delete.ts @@ -0,0 +1,83 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * DELETE /api/sessions/:sessionId — remove a session and everything keyed to + * it. The mirror of `archiveSession`: archive is reversible and keeps the + * transcript, this one is the destructive path the user opts into from the + * sidebar's trash button. + * + * Order matters and is enforced here, not by the callers: + * 1. refuse a session whose omp child is mid-turn (a run, a live subagent, or + * a dialog the agent is blocked on) — the delete would kill work the user + * can still see, and omp would flush the file back on shutdown anyway; + * 2. destroy the child and AWAIT its exit — omp writes session state on + * shutdown and would otherwise recreate the transcript just removed; + * 3. purge side questions, then the transcript, artifacts and chamber rows. + * + * A pending `new-…` session is refused: it is a client-side placeholder with + * nothing stored anywhere yet, so deleting it is the same as navigating away. + */ + +import { json } from '@/server/lib/remix-compat'; +import type { ActionFunctionArgs } from '@/server/lib/remix-compat'; +import { methodNotAllowed } from '@/server/lib/route-adapter'; +import { getRpcSession } from '@/server/lib/omp/rpc/session-registry'; +import { purgeSessionData } from '@/server/lib/omp/session/delete.server'; +import { purgeBtwForSession } from '@/server/lib/btw/purge.server'; +import { isSinglePathSegment } from '@/server/lib/fs/path-segment'; +import { isMockMode } from '@/server/mock.server'; + +export async function deleteSession({ request, params }: ActionFunctionArgs) { + if (request.method !== 'DELETE') { + return methodNotAllowed({ request, params }); + } + + const sessionId = params.sessionId; + if (!sessionId) return json({ error: 'Missing session id' }, { status: 400 }); + if (sessionId.startsWith('new-')) { + return json({ error: 'This chat has not been created yet.', code: 'session_pending' }, { status: 400 }); + } + // The id names a directory under the sessions root and another under the btw + // root, so it must be exactly one path segment — see `isSinglePathSegment` + // for the `.` case that made this load-bearing. Both purges re-check it + // independently; refusing here is what turns the attempt into a 400. + if (!isSinglePathSegment(sessionId)) { + return json({ error: 'Invalid session id' }, { status: 400 }); + } + + // A live child is the only other writer of the transcript. Busy means a turn, + // a live subagent, or a dialog omp is parked on — all things the user can + // still act on, so the delete is refused with a reason rather than silently + // taking them down. + const live = getRpcSession(sessionId); + if (live?.isAlive()) { + if (live.isBusy()) { + return json( + { error: 'This session is working. Stop the run before deleting it.', code: 'session_busy' }, + { status: 409 }, + ); + } + await live.destroyAndWait(); + } + + // Side questions live outside the session file and are removed first: their + // children must be gone before the parent transcript they were snapshotted + // from disappears. + const btwTopicsRemoved = isMockMode() ? 0 : await purgeBtwForSession(sessionId); + const purged = await purgeSessionData(sessionId); + + if (!purged.found) { + return json({ error: 'Session not found' }, { status: 404 }); + } + + return json({ + success: true, + sessionId, + fileRemoved: purged.fileRemoved, + artifactsRemoved: purged.artifactsRemoved, + btwTopicsRemoved, + }); +} diff --git a/src/server/routes/sessions/index.ts b/src/server/routes/sessions/index.ts index 7bce8eef..3a870b33 100644 --- a/src/server/routes/sessions/index.ts +++ b/src/server/routes/sessions/index.ts @@ -10,10 +10,15 @@ import { renameSession, } from '@/server/routes/sessions/session'; import { listSubagents, readSubagentTranscript } from '@/server/routes/sessions/subagents'; +import { deleteSession } from '@/server/routes/sessions/delete'; export const sessionsBindings: HandlerBinding[] = [ ...bindingsFor(sessionsList, '/api/sessions/list'), ...bindingsFor(sessionsFolder, '/api/sessions/:sessionId'), + // DELETE-only on the same path the folder loader answers GET on: mounting it + // through `actionBindings` would register a 405 GET fallback that Elysia + // applies after the loader, replacing the workspace listing with it. + { method: 'DELETE', path: '/api/sessions/:sessionId', handler: deleteSession }, ...actionBindings(archiveSession, '/api/sessions/:sessionId/archive'), // Per-item queue routes. `actionBindings` mounts ALL four mutating verbs on // one path, and Elysia lets a later registration override an earlier one — diff --git a/src/shared/lib/workspace/session-state/store.ts b/src/shared/lib/workspace/session-state/store.ts index 4cf4860f..6ef1f283 100644 --- a/src/shared/lib/workspace/session-state/store.ts +++ b/src/shared/lib/workspace/session-state/store.ts @@ -247,6 +247,26 @@ export function getLastOpenedAt(sessionId: string): number | undefined { return lastTouched.get(sessionId); } +/** + * Drop a session's cached UI state after it was deleted. + * + * Deliberately does NOT persist: the session no longer exists, and the persist + * endpoint upserts, so flushing here would write the row straight back. The + * pending debounce timer is cancelled for the same reason — it would fire after + * the delete and resurrect the state. + */ +export function forgetSession(sessionId: string): void { + const timer = persistTimers.get(sessionId); + if (timer) { + clearTimeout(timer); + persistTimers.delete(sessionId); + } + cache.delete(sessionId); + readySessions.delete(sessionId); + dirtySessions.delete(sessionId); + lastTouched.delete(sessionId); +} + /** * Move a pending `new-…` session's state onto the real session id adopted * by a spawn, then discard the transient slot.