feat(desktop): confirm before deleting a session

Deleting a session in the desktop app fired instantly on click — the CLI
path (hermes sessions delete) asks y/N by default, so one misclick (Archive
and Delete sit right next to each other) permanently destroyed a conversation
with no dialog and no undo (#61470).

Route every delete entry point (sidebar rows, tab menus, the chat header,
context menus — all share useSessionActions) through a shared
DeleteSessionDialog built on ConfirmDialog. ConfirmDialog gains an
onOpenAutoFocus prop so dialogs with no input keep focus off the close
button (a11y).

Tests: menu delete now asks; cancel keeps the session; Enter confirms;
Escape cancels; delete item disabled without onDelete; the same guard
applies via SessionContextMenu.
This commit is contained in:
Gorde Minchel
2026-08-06 15:28:03 +08:00
committed by Teknium
parent 8e35ff0a62
commit e6708af1f2
9 changed files with 221 additions and 8 deletions
@@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-li
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SessionActionsMenu } from './session-actions-menu'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
afterEach(cleanup)
@@ -20,7 +20,16 @@ vi.mock('@/hermes', () => ({ renameSession: vi.fn() }))
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
common: { cancel: 'Cancel', close: 'Close', delete: 'Delete', save: 'Save' },
common: {
cancel: 'Cancel',
close: 'Close',
confirm: 'Confirm',
delete: 'Delete',
done: 'Done',
loading: 'Loading…',
save: 'Save'
},
errors: { genericFailure: 'Something went wrong' },
sidebar: {
projects: {
menuAppearance: 'Appearance',
@@ -35,6 +44,10 @@ vi.mock('@/i18n', () => ({
branchFrom: 'Branch from here',
copyId: 'Copy ID',
copyIdFailed: 'Failed to copy ID',
deleteDesc: (title: string) => `Delete ${title}?`,
deleteTitle: 'Delete session?',
deleting: 'Deleting…',
deleted: 'Session deleted',
export: 'Export',
hideTabBar: 'Hide tab bar',
pin: 'Pin',
@@ -140,4 +153,119 @@ describe('SessionActionsMenu', () => {
// eslint-disable-next-line no-restricted-globals -- asserting real focus requires the live document
expect(document.activeElement).not.toBe(trigger)
})
it('confirms before deleting — cancel keeps the session, confirm deletes it', async () => {
const onDelete = vi.fn()
render(
<SessionActionsMenu onDelete={onDelete} sessionId="s1" title="My session">
<button aria-label="Session actions" type="button">
⋮
</button>
</SessionActionsMenu>
)
const trigger = screen.getByRole('button', { name: 'Session actions' })
fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.pointerUp(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.click(trigger)
const deleteItem = await screen.findByRole('menuitem', { name: /delete/i })
fireEvent.click(deleteItem)
// The confirm dialog is up and names the session being deleted.
expect(await screen.findByRole('dialog')).toBeTruthy()
expect(screen.getByText(/My session/)).toBeTruthy()
// Cancel: nothing is deleted.
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onDelete).not.toHaveBeenCalled()
// Re-open the menu and confirm: only now does the delete call fire.
fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.pointerUp(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.click(trigger)
const deleteItemAgain = await screen.findByRole('menuitem', { name: /delete/i })
fireEvent.click(deleteItemAgain)
expect(await screen.findByRole('dialog')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
// ConfirmDialog shows a done beat before auto-closing (600ms); awaiting it
// also drains the async run() update inside act().
expect(await screen.findByText('Session deleted')).toBeTruthy()
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('disables the delete item when no onDelete is provided', async () => {
render(
<SessionActionsMenu sessionId="s1" title="My session">
<button aria-label="Session actions" type="button">
⋮
</button>
</SessionActionsMenu>
)
const trigger = screen.getByRole('button', { name: 'Session actions' })
fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.pointerUp(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.click(trigger)
const deleteItem = await screen.findByRole('menuitem', { name: /delete/i })
expect(deleteItem.getAttribute('aria-disabled')).toBe('true')
})
it('confirms with the Enter key and cancels with Escape', async () => {
const onDelete = vi.fn()
render(
<SessionActionsMenu onDelete={onDelete} sessionId="s1" title="My session">
<button aria-label="Session actions" type="button">
⋮
</button>
</SessionActionsMenu>
)
const trigger = screen.getByRole('button', { name: 'Session actions' })
fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.pointerUp(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.click(trigger)
fireEvent.click(await screen.findByRole('menuitem', { name: /delete/i }))
const dialog = await screen.findByRole('dialog')
expect(dialog).toBeTruthy()
// Escape cancels: dialog closes, nothing is deleted.
fireEvent.keyDown(window.document, { key: 'Escape' })
expect(await screen.queryByRole('dialog')).toBeNull()
expect(onDelete).not.toHaveBeenCalled()
// Re-open and confirm with Enter: the delete call fires.
fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.pointerUp(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.click(trigger)
fireEvent.click(await screen.findByRole('menuitem', { name: /delete/i }))
fireEvent.keyDown(await screen.findByRole('dialog'), { key: 'Enter' })
expect(await screen.findByText('Session deleted')).toBeTruthy()
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('routes the same confirm guard through the context menu', async () => {
const onDelete = vi.fn()
render(
<SessionContextMenu onDelete={onDelete} sessionId="s1" title="My session">
<button aria-label="Session row" type="button">
Row
</button>
</SessionContextMenu>
)
const row = screen.getByRole('button', { name: 'Session row' })
fireEvent.contextMenu(row)
fireEvent.click(await screen.findByRole('menuitem', { name: /delete/i }))
expect(await screen.findByRole('dialog')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(await screen.findByText('Session deleted')).toBeTruthy()
expect(onDelete).toHaveBeenCalledTimes(1)
})
})
@@ -20,8 +20,9 @@ import {
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { CopyButton } from '@/components/ui/copy-button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, preventCloseButtonAutoFocus } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { useI18n } from '@/i18n'
@@ -198,6 +199,7 @@ function useSessionActions({
// action leaves the restore alone (it's the correct behavior for them). Mirrors
// the project menu's appearance-popover guard.
const suppressCloseFocusRef = useRef(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const tiles = useStore($sessionTiles)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const isRemote = useStore($connection)?.mode === 'remote'
@@ -399,7 +401,15 @@ function useSessionActions({
label: t.common.delete,
onSelect: () => {
triggerHaptic('warning')
onDelete?.()
// Deleting is irreversible (the CLI path asks y/N; the desktop used to
// fire instantly on click). Gate it behind an explicit confirm — see
// #61470. The dialog owns the delete call, so every surface that routes
// through this menu (sidebar rows, tab menus, the chat header) gets the
// guard for free.
if (onDelete) {
setDeleteOpen(true)
}
},
variant: 'destructive'
}
@@ -484,7 +494,50 @@ function useSessionActions({
}
}
return { onCloseAutoFocus, renameDialog, renderItems }
const deleteDialog = (
<DeleteSessionDialog
onConfirm={() => {
onDelete?.()
}}
onOpenChange={setDeleteOpen}
open={deleteOpen}
sessionTitle={title}
/>
)
return { deleteDialog, onCloseAutoFocus, renameDialog, renderItems }
}
interface DeleteSessionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
sessionTitle: string
}
// Thin wrapper over ConfirmDialog — the single choke point for every session
// delete entry point (sidebar rows, tab menus, the chat header). Deleting a
// session is irreversible and the desktop used to fire it instantly on click
// (#61470); this mirrors the CLI's y/N guard. onConfirm is the fire-and-forget
// delete call; ConfirmDialog owns the busy/done beat and Enter-to-confirm.
function DeleteSessionDialog({ open, onOpenChange, onConfirm, sessionTitle }: DeleteSessionDialogProps) {
const { t } = useI18n()
const r = t.sidebar.row
return (
<ConfirmDialog
busyLabel={r.deleting}
confirmLabel={t.common.delete}
description={r.deleteDesc(sessionTitle)}
destructive
doneLabel={r.deleted}
onClose={() => onOpenChange(false)}
onConfirm={onConfirm}
onOpenAutoFocus={preventCloseButtonAutoFocus}
open={open}
title={r.deleteTitle}
/>
)
}
interface SessionActionsMenuProps
@@ -494,7 +547,7 @@ interface SessionActionsMenuProps
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
const { t } = useI18n()
const { onCloseAutoFocus, renameDialog, renderItems } = useSessionActions(actions)
const { deleteDialog, onCloseAutoFocus, renameDialog, renderItems } = useSessionActions(actions)
return (
<>
@@ -509,6 +562,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
{children}
</ActionsMenu>
{renameDialog}
{deleteDialog}
</>
)
}
@@ -519,7 +573,7 @@ interface SessionContextMenuProps extends SessionActions {
export function SessionContextMenu({ children, ...actions }: SessionContextMenuProps) {
const { t } = useI18n()
const { onCloseAutoFocus, renameDialog, renderItems } = useSessionActions(actions)
const { deleteDialog, onCloseAutoFocus, renameDialog, renderItems } = useSessionActions(actions)
return (
<>
@@ -532,6 +586,7 @@ export function SessionContextMenu({ children, ...actions }: SessionContextMenuP
{children}
</ActionsContextMenu>
{renameDialog}
{deleteDialog}
</>
)
}
@@ -28,6 +28,10 @@ interface ConfirmDialogProps {
destructive?: boolean
/** Close as soon as onConfirm resolves — for optimistic actions that finish in the background. */
dismissOnConfirm?: boolean
/** Focus control for dialogs with no input. Pass `preventCloseButtonAutoFocus`
* so opening doesn't land focus on the close/cancel button (which would pop
* its tooltip with no pointer near it). */
onOpenAutoFocus?: (event: Event) => void
}
// Shared confirmation dialog: Enter confirms (from anywhere in the dialog),
@@ -44,7 +48,8 @@ export function ConfirmDialog({
doneLabel,
cancelLabel,
destructive = false,
dismissOnConfirm = false
dismissOnConfirm = false,
onOpenAutoFocus
}: ConfirmDialogProps) {
const { t } = useI18n()
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
@@ -104,6 +109,7 @@ export function ConfirmDialog({
void run()
}
}}
onOpenAutoFocus={onOpenAutoFocus}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
+4
View File
@@ -1668,6 +1668,10 @@ export const ar = defineLocale({
renameTitle: 'إعادة تسمية الجلسة',
renameDesc: '',
untitledPlaceholder: 'جلسة بلا عنوان',
deleteTitle: 'حذف الجلسة؟',
deleteDesc: title => `سيتم حذف «${title}» نهائيًا. لا يمكن التراجع عن هذا الإجراء.`,
deleting: 'جارٍ الحذف…',
deleted: 'تم حذف الجلسة',
ageNow: 'الآن',
ageDay: 'يوم',
ageHour: 'ساعة',
+4
View File
@@ -2040,6 +2040,10 @@ export const en: Translations = {
renameTitle: 'Rename session',
renameDesc: 'Leave empty to clear.',
untitledPlaceholder: 'Untitled session',
deleteTitle: 'Delete session?',
deleteDesc: title => `This will permanently delete “${title}”. This cannot be undone.`,
deleting: 'Deleting…',
deleted: 'Session deleted',
untitledChat: id => `Chat ${id}`,
messageCount: count => `${count} ${count === 1 ? 'message' : 'messages'}`,
todoProgress: 'Tasks completed',
+4
View File
@@ -1811,6 +1811,10 @@ export const ja = defineLocale({
renameTitle: 'セッションの名前を変更',
renameDesc: '空欄にするとクリアされます。',
untitledPlaceholder: '無題のセッション',
deleteTitle: 'セッションを削除しますか?',
deleteDesc: title => `「${title}」を完全に削除します。この操作は元に戻せません。`,
deleting: '削除中…',
deleted: 'セッションを削除しました',
untitledChat: id => `セッション ${id}`,
ageNow: 'たった今',
ageDay: '日',
+4
View File
@@ -1723,6 +1723,10 @@ export interface Translations {
renameTitle: string
renameDesc: string
untitledPlaceholder: string
deleteTitle: string
deleteDesc: (title: string) => string
deleting: string
deleted: string
untitledChat: (id: string) => string
messageCount: (count: number) => string
todoProgress: string
+4
View File
@@ -1753,6 +1753,10 @@ export const zhHant = defineLocale({
renameTitle: '重新命名工作階段',
renameDesc: '留空則清除。',
untitledPlaceholder: '未命名工作階段',
deleteTitle: '刪除會話?',
deleteDesc: title => `這將永久刪除「${title}」,且無法復原。`,
deleting: '正在刪除…',
deleted: '會話已刪除',
untitledChat: id => `工作階段 ${id}`,
ageNow: '剛才',
ageDay: '天',
+4
View File
@@ -2226,6 +2226,10 @@ export const zh: Translations = {
renameTitle: '重命名会话',
renameDesc: '留空则清除。',
untitledPlaceholder: '无标题会话',
deleteTitle: '删除会话?',
deleteDesc: title => `这将永久删除“${title}”,且无法撤销。`,
deleting: '正在删除…',
deleted: '会话已删除',
untitledChat: id => `会话 ${id}`,
messageCount: count => `${count} 条消息`,
todoProgress: '任务完成度',