diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index 9f43ee59cf9..2516e67782c 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -173,6 +173,7 @@ async function startHost(): Promise { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, + resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), diff --git a/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.test.tsx index ce2a634da82..b8a44433fea 100644 --- a/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.test.tsx @@ -3,6 +3,7 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DeleteNestedWorktreesDialog } from './DeleteNestedWorktreesDialog' +import { toast } from 'sonner' const mocks = vi.hoisted(() => ({ preview: vi.fn(), @@ -25,6 +26,7 @@ vi.mock('@/store/selectors', () => ({ getWorktreeOnHostFromState: () => null })) vi.mock('./active-worktree-focus-after-delete', () => ({ prepareActiveWorktreeFocusAfterDelete: () => mocks.commitFocus })) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) const target = { id: 'repo::/workspaces/parent', executionHostId: 'local' as const } const plan = [ @@ -91,22 +93,66 @@ describe('nested deletion confirmation', () => { expect(onDeleted).toHaveBeenCalledOnce() }) - it('refreshes partial deletions and requires a fresh review after failure', async () => { - mocks.remove.mockResolvedValue({ ok: false, error: 'Child archive hook failed' }) + it('closes immediately while deletion is pending and prevents a second submission', async () => { + let finish: (value: { ok: boolean }) => void = () => {} + mocks.remove.mockReturnValue( + new Promise<{ ok: boolean }>((resolve) => { + finish = resolve + }) + ) + const dismissToast = vi.fn() + const onDeleted = vi.fn() render( - + ) await review() fireEvent.click(screen.getByRole('button', { name: 'Delete all listed worktrees' })) - expect(await screen.findByRole('alert')).toHaveTextContent('Child archive hook failed') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Deleting…' })).toBeDisabled() + expect(mocks.refresh).not.toHaveBeenCalled() + expect(onDeleted).not.toHaveBeenCalled() + await act(async () => { + finish({ ok: true }) + }) + expect(mocks.remove).toHaveBeenCalledOnce() expect(mocks.refresh).toHaveBeenCalledOnce() - expect( - screen.queryByRole('button', { name: 'Delete all listed worktrees' }) - ).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Review again' })) - await vi.waitFor(() => expect(mocks.preview).toHaveBeenCalledTimes(2)) + expect(onDeleted).toHaveBeenCalledOnce() + expect(dismissToast).toHaveBeenCalledOnce() }) + it.each(['refused', 'rejected'])( + 'reports a %s deletion in a toast and requires a fresh review', + async (outcome) => { + if (outcome === 'refused') { + mocks.remove.mockResolvedValue({ ok: false, error: 'Child archive hook failed' }) + } else { + mocks.remove.mockRejectedValue(new Error('Child archive hook failed')) + } + render( + + ) + await review() + fireEvent.click(screen.getByRole('button', { name: 'Delete all listed worktrees' })) + await vi.waitFor(() => + expect(toast.error).toHaveBeenCalledWith('Failed to delete workspace', { + description: 'Child archive hook failed' + }) + ) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(mocks.refresh).toHaveBeenCalledOnce() + expect( + screen.queryByRole('button', { name: 'Delete all listed worktrees' }) + ).not.toBeInTheDocument() + await review() + await vi.waitFor(() => expect(mocks.preview).toHaveBeenCalledTimes(2)) + } + ) + it('ignores a late preview after cancellation', async () => { let finish: (value: typeof plan) => void = () => {} mocks.preview.mockReturnValue( diff --git a/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.tsx b/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.tsx index d47aba273a0..2cefbac67c2 100644 --- a/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.tsx @@ -65,6 +65,7 @@ export function DeleteNestedWorktreesDialog({ return } setDeleting(true) + setOpen(false) setError(null) const state = useAppStore.getState() const repoId = getRepoIdFromWorktreeId(target.id) @@ -82,15 +83,30 @@ export function DeleteNestedWorktreesDialog({ const result = await state.removeWorktree(target, true, { approvedNestedWorktrees: plan }) if (!result.ok) { setPlan(null) - setError(result.error) + toast.error( + translate( + 'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4', + 'Failed to delete workspace' + ), + { + description: result.error + } + ) return } - setOpen(false) dismissToast() onDeleted?.() } catch (failure) { setPlan(null) - setError(failure instanceof Error ? failure.message : String(failure)) + toast.error( + translate( + 'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4', + 'Failed to delete workspace' + ), + { + description: failure instanceof Error ? failure.message : String(failure) + } + ) } finally { try { await useAppStore.getState().fetchAllWorktrees() @@ -112,8 +128,17 @@ export function DeleteNestedWorktreesDialog({ return ( <> -