From 5df9cd27eaef1f69f8af732eb4e8a488d410bd83 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 23:13:17 -0500 Subject: [PATCH] feat(desktop): let ConfirmDialog carry a secondary action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worktree removal prompt offers a third way out — hide the lane but leave the worktree on disk — which is why it was still hand-rolled. One optional slot between Cancel and Confirm covers it, and it keeps Confirm as the focused button so Enter still means the destructive action. --- .../chat/sidebar/projects/entered-content.tsx | 67 ++++++------------- .../src/components/ui/confirm-dialog.test.tsx | 50 ++++++++++++++ .../src/components/ui/confirm-dialog.tsx | 24 ++++++- 3 files changed, 94 insertions(+), 47 deletions(-) create mode 100644 apps/desktop/src/components/ui/confirm-dialog.test.tsx diff --git a/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx b/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx index 6b09788157..7d2fc7117c 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx @@ -2,16 +2,8 @@ import { useStore } from '@nanostores/react' import type * as React from 'react' import { useMemo, useState } from 'react' -import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' import type { HermesGitWorktree } from '@/global' import type { SessionInfo } from '@/hermes' import { useI18n } from '@/i18n' @@ -190,43 +182,26 @@ function RepoFlatSection({ destructiveLabel: string, onDestructive: (group: SidebarSessionGroup) => void ) => ( - !isOpen && setTarget(null)} open={Boolean(target)}> - - - {`${s.projects.removeWorktree} "${target?.label ?? ''}"?`} - {description} - - - - - - - - + setTarget(null)} + onConfirm={() => { + if (target) { + onDestructive(target) + } + }} + open={Boolean(target)} + secondaryAction={{ + label: s.projects.removeFromSidebar, + onClick: () => target && dismissWorktree(target.id) + }} + title={`${s.projects.removeWorktree} "${target?.label ?? ''}"?`} + /> ) const removeDialog = ( diff --git a/apps/desktop/src/components/ui/confirm-dialog.test.tsx b/apps/desktop/src/components/ui/confirm-dialog.test.tsx new file mode 100644 index 0000000000..ad107dac7f --- /dev/null +++ b/apps/desktop/src/components/ui/confirm-dialog.test.tsx @@ -0,0 +1,50 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ConfirmDialog } from './confirm-dialog' + +afterEach(cleanup) + +describe('ConfirmDialog secondary action', () => { + function renderWithSecondary() { + const onConfirm = vi.fn() + const onClose = vi.fn() + const onSecondary = vi.fn() + + render( + + ) + + return { onClose, onConfirm, onSecondary } + } + + it('runs the secondary action and closes without confirming', async () => { + const { onClose, onConfirm, onSecondary } = renderWithSecondary() + + fireEvent.click(await screen.findByRole('button', { name: 'Remove from sidebar' })) + + expect(onSecondary).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('still opens focused on Confirm, so Enter confirms rather than picking the secondary', async () => { + const { onConfirm, onSecondary } = renderWithSecondary() + + const dialog = await screen.findByRole('dialog') + + // eslint-disable-next-line no-restricted-globals -- asserting real focus requires the live document + await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true)) + // eslint-disable-next-line no-restricted-globals -- asserting real focus requires the live document + fireEvent.keyDown(document.activeElement!, { key: 'Enter' }) + + await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1)) + expect(onSecondary).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/components/ui/confirm-dialog.tsx b/apps/desktop/src/components/ui/confirm-dialog.tsx index 3d60069c4b..9e30011b8e 100644 --- a/apps/desktop/src/components/ui/confirm-dialog.tsx +++ b/apps/desktop/src/components/ui/confirm-dialog.tsx @@ -28,6 +28,14 @@ interface ConfirmDialogProps { destructive?: boolean /** Close as soon as onConfirm resolves — for optimistic actions that finish in the background. */ dismissOnConfirm?: boolean + /** A third, non-destructive way out, shown between Cancel and Confirm (e.g. + * "Remove from sidebar" beside "Delete worktree"). Closes on click. */ + secondaryAction?: ConfirmSecondaryAction +} + +interface ConfirmSecondaryAction { + label: string + onClick: () => void } // Shared confirmation dialog: opens focused on Confirm, Enter confirms (from @@ -45,7 +53,8 @@ export function ConfirmDialog({ doneLabel, cancelLabel, destructive = false, - dismissOnConfirm = false + dismissOnConfirm = false, + secondaryAction }: ConfirmDialogProps) { const { t } = useI18n() const confirmRef = useRef(null) @@ -131,6 +140,19 @@ export function ConfirmDialog({ + {secondaryAction && ( + + )}