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:
@@ -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>
|
||||
|
||||
@@ -1668,6 +1668,10 @@ export const ar = defineLocale({
|
||||
renameTitle: 'إعادة تسمية الجلسة',
|
||||
renameDesc: '',
|
||||
untitledPlaceholder: 'جلسة بلا عنوان',
|
||||
deleteTitle: 'حذف الجلسة؟',
|
||||
deleteDesc: title => `سيتم حذف «${title}» نهائيًا. لا يمكن التراجع عن هذا الإجراء.`,
|
||||
deleting: 'جارٍ الحذف…',
|
||||
deleted: 'تم حذف الجلسة',
|
||||
ageNow: 'الآن',
|
||||
ageDay: 'يوم',
|
||||
ageHour: 'ساعة',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1811,6 +1811,10 @@ export const ja = defineLocale({
|
||||
renameTitle: 'セッションの名前を変更',
|
||||
renameDesc: '空欄にするとクリアされます。',
|
||||
untitledPlaceholder: '無題のセッション',
|
||||
deleteTitle: 'セッションを削除しますか?',
|
||||
deleteDesc: title => `「${title}」を完全に削除します。この操作は元に戻せません。`,
|
||||
deleting: '削除中…',
|
||||
deleted: 'セッションを削除しました',
|
||||
untitledChat: id => `セッション ${id}`,
|
||||
ageNow: 'たった今',
|
||||
ageDay: '日',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1753,6 +1753,10 @@ export const zhHant = defineLocale({
|
||||
renameTitle: '重新命名工作階段',
|
||||
renameDesc: '留空則清除。',
|
||||
untitledPlaceholder: '未命名工作階段',
|
||||
deleteTitle: '刪除會話?',
|
||||
deleteDesc: title => `這將永久刪除「${title}」,且無法復原。`,
|
||||
deleting: '正在刪除…',
|
||||
deleted: '會話已刪除',
|
||||
untitledChat: id => `工作階段 ${id}`,
|
||||
ageNow: '剛才',
|
||||
ageDay: '天',
|
||||
|
||||
@@ -2226,6 +2226,10 @@ export const zh: Translations = {
|
||||
renameTitle: '重命名会话',
|
||||
renameDesc: '留空则清除。',
|
||||
untitledPlaceholder: '无标题会话',
|
||||
deleteTitle: '删除会话?',
|
||||
deleteDesc: title => `这将永久删除“${title}”,且无法撤销。`,
|
||||
deleting: '正在删除…',
|
||||
deleted: '会话已删除',
|
||||
untitledChat: id => `会话 ${id}`,
|
||||
messageCount: count => `${count} 条消息`,
|
||||
todoProgress: '任务完成度',
|
||||
|
||||
Reference in New Issue
Block a user