Use app-styled confirmations for PR actions (#2324)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-19 14:01:04 -04:00 committed by GitHub
parent b1a8c68b9a
commit 4b86fe8292
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 391 additions and 207 deletions

View File

@ -58,6 +58,7 @@ import {
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import { DictationController } from './components/dictation/DictationController'
import { CrashReportDialog } from './components/crash-report/CrashReportDialog'
import { ConfirmationDialogProvider } from './components/confirmation-dialog'
import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher'
import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling'
import { useEditorExternalWatch } from './hooks/useEditorExternalWatch'
@ -1351,66 +1352,67 @@ function App(): React.JSX.Element {
}
>
<TooltipProvider delayDuration={400}>
{/* Why: leaf-mounted retention sync keeps agent-status retention
<ConfirmationDialogProvider>
{/* Why: leaf-mounted retention sync keeps agent-status retention
subscriptions from re-rendering the App tree. */}
<RetainedAgentsSyncGate />
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{/* Why: the non-workspace titlebar lives inside this left+center
<RetainedAgentsSyncGate />
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{/* Why: the non-workspace titlebar lives inside this left+center
wrapper so it does not span over the right-sidebar column
when the right sidebar is open, its own header anchors at the
top alongside the titlebar instead of being pushed below it. */}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{/* Why: in workspace view (split groups always enabled), the
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{/* Why: in workspace view (split groups always enabled), the
full-width titlebar is removed so tab groups + terminal extend
to the top of the window. Left titlebar controls move to a
header above the sidebar. Settings, landing, and the tasks
page keep the titlebar. */}
{!workspaceActive ? (
<div className="titlebar">
<div
className={`flex items-center${showSidebar && sidebarOpen ? ' overflow-hidden shrink-0' : ' shrink-0 mr-2'}`}
style={{ width: showSidebar && sidebarOpen ? sidebarWidth : undefined }}
>
{titlebarLeftControls}
</div>
{activeView === 'activity' ? (
<ActivityTitlebarControls />
) : (
{!workspaceActive ? (
<div className="titlebar">
<div
id="titlebar-tabs"
className={`flex flex-1 min-w-0 self-stretch${activeView !== 'terminal' || !activeWorktreeId ? ' invisible pointer-events-none' : ''}`}
/>
)}
{showTitlebarExpandButton && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="titlebar-icon-button"
onClick={handleToggleExpand}
aria-label="Collapse pane"
disabled={!activeTabCanExpand}
>
<Minimize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Collapse pane
</TooltipContent>
</Tooltip>
)}
{/* Why: when the right sidebar is open, its own header renders
className={`flex items-center${showSidebar && sidebarOpen ? ' overflow-hidden shrink-0' : ' shrink-0 mr-2'}`}
style={{ width: showSidebar && sidebarOpen ? sidebarWidth : undefined }}
>
{titlebarLeftControls}
</div>
{activeView === 'activity' ? (
<ActivityTitlebarControls />
) : (
<div
id="titlebar-tabs"
className={`flex flex-1 min-w-0 self-stretch${activeView !== 'terminal' || !activeWorktreeId ? ' invisible pointer-events-none' : ''}`}
/>
)}
{showTitlebarExpandButton && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="titlebar-icon-button"
onClick={handleToggleExpand}
aria-label="Collapse pane"
disabled={!activeTabCanExpand}
>
<Minimize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Collapse pane
</TooltipContent>
</Tooltip>
)}
{/* Why: when the right sidebar is open, its own header renders
an identical close button hide this copy so only one is
visible at a time. */}
{!rightSidebarOpen && rightSidebarToggle}
{/* Why: reserve space so content is not obscured by the
{!rightSidebarOpen && rightSidebarToggle}
{/* Why: reserve space so content is not obscured by the
fixed-position window-controls overlay on Windows. */}
{isWindows && <div className="window-controls-titlebar-spacer" />}
</div>
) : null}
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{showSidebar ? (
workspaceActive ? (
/* Why: left column wraps the sidebar with a titlebar-height
{isWindows && <div className="window-controls-titlebar-spacer" />}
</div>
) : null}
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{showSidebar ? (
workspaceActive ? (
/* Why: left column wraps the sidebar with a titlebar-height
header above it. The header holds the same controls
(traffic lights, sidebar toggle, "Orca" title, agent badge)
that the full-width titlebar held while the center and right
@ -1418,173 +1420,176 @@ function App(): React.JSX.Element {
When the sidebar is collapsed, take this header out of flex
layout so the terminal/editor reclaim the left edge instead of
leaving behind a content-width blank strip. */
<div
className={`flex min-h-0 flex-col shrink-0${sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
>
<div
// Why: when the sidebar is collapsed, titlebar-left floats
// absolutely on top of the center column's own `border-l`
// (see TabGroupSplitLayout), occluding that seam. Add a
// `border-r` in the floating state so the vertical line
// between the traffic-light/nav cluster and the tab strip
// stays visible in both states. w-max keeps the floating
// header sized to its own controls instead of the w-0
// sidebar wrapper.
className={`titlebar-left${
sidebarOpen
? ''
: ' absolute top-0 left-0 z-10 w-max border-r border-border'
}`}
style={{
// Why: the Sidebar resize hook updates the sidebar DOM width
// directly during drag and only persists to Zustand on
// mouseup. In workspace view, size this header from the
// wrapper's live width so it tracks those in-flight resizes
// instead of leaving a stale-width gap until the drag ends.
width: sidebarOpen ? '100%' : undefined
}}
className={`flex min-h-0 flex-col shrink-0${sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
>
{titlebarLeftControls}
</div>
<div className="flex min-h-0 flex-1">
{/* Why: the workspace-view wrapper adds a fixed 36px header
<div
// Why: when the sidebar is collapsed, titlebar-left floats
// absolutely on top of the center column's own `border-l`
// (see TabGroupSplitLayout), occluding that seam. Add a
// `border-r` in the floating state so the vertical line
// between the traffic-light/nav cluster and the tab strip
// stays visible in both states. w-max keeps the floating
// header sized to its own controls instead of the w-0
// sidebar wrapper.
className={`titlebar-left${
sidebarOpen
? ''
: ' absolute top-0 left-0 z-10 w-max border-r border-border'
}`}
style={{
// Why: the Sidebar resize hook updates the sidebar DOM width
// directly during drag and only persists to Zustand on
// mouseup. In workspace view, size this header from the
// wrapper's live width so it tracks those in-flight resizes
// instead of leaving a stale-width gap until the drag ends.
width: sidebarOpen ? '100%' : undefined
}}
>
{titlebarLeftControls}
</div>
<div className="flex min-h-0 flex-1">
{/* Why: the workspace-view wrapper adds a fixed 36px header
above the sidebar. Without a flex-1/min-h-0 slot here,
the sidebar falls back to its content height, so the
worktree list loses its scroll viewport and the fixed
bottom toolbar (including Add Project) gets pushed offscreen. */}
<Sidebar
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
/>
<Sidebar
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
/>
</div>
</div>
</div>
) : (
<Sidebar
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
/>
)
) : null}
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
{/* Why: right sidebar toggle floats at the top-right of the center
) : (
<Sidebar
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
/>
)
) : null}
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
{/* Why: right sidebar toggle floats at the top-right of the center
column so it's always accessible whether the right sidebar is
open or closed. Match the RightSidebar header's 36px height and
top-0 anchor so the icon's vertical center is identical between
open and closed states otherwise toggling makes the icon jump
a few pixels, which reads as layout jitter. */}
{workspaceActive && !rightSidebarOpen && (
<div
className="absolute top-0 z-10 flex items-center h-[36px]"
style={
{
// Why: right: var(--window-controls-width) is the single
// mechanism that keeps the toggle clear of the
// fixed-position window-controls overlay on Windows (138px)
// and sits at the right edge on non-Windows (0px). No
// internal spacer needed — adding one would push the button
// a further 138px to the left and cover the pane-actions
// Ellipsis button with an un-clickable div.
right: 'var(--window-controls-width)',
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
>
{rightSidebarToggle}
{workspaceActive && !rightSidebarOpen && (
<div
className="absolute top-0 z-10 flex items-center h-[36px]"
style={
{
// Why: right: var(--window-controls-width) is the single
// mechanism that keeps the toggle clear of the
// fixed-position window-controls overlay on Windows (138px)
// and sits at the right edge on non-Windows (0px). No
// internal spacer needed — adding one would push the button
// a further 138px to the left and cover the pane-actions
// Ellipsis button with an un-clickable div.
right: 'var(--window-controls-width)',
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
>
{rightSidebarToggle}
</div>
)}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
<div
className={
activeView !== 'terminal' || !activeWorktreeId
? 'hidden flex-1 min-w-0 min-h-0'
: 'flex flex-1 min-w-0 min-h-0'
}
>
<Terminal />
</div>
<Suspense fallback={null}>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'skills' ? <SkillsPage /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'automations' ? <AutomationsPage /> : null}
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
{activeView === 'space' ? <WorkspaceSpacePage /> : null}
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
</Suspense>
</div>
)}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
<div
className={
activeView !== 'terminal' || !activeWorktreeId
? 'hidden flex-1 min-w-0 min-h-0'
: 'flex flex-1 min-w-0 min-h-0'
}
>
<Terminal />
</div>
<Suspense fallback={null}>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'skills' ? <SkillsPage /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'automations' ? <AutomationsPage /> : null}
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
{activeView === 'space' ? <WorkspaceSpacePage /> : null}
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
</Suspense>
{showFloatingTerminalButton ? (
<FloatingTerminalToggleButton
// Why: anchor the floating trigger to the center surface so it
// cannot cover the worktree sidebar or right sidebar.
className="absolute bottom-8 right-3"
open={floatingTerminalOpen}
onToggle={() => setFloatingTerminalOpenWithFocus((open) => !open)}
/>
) : null}
</div>
{showFloatingTerminalButton ? (
<FloatingTerminalToggleButton
// Why: anchor the floating trigger to the center surface so it
// cannot cover the worktree sidebar or right sidebar.
className="absolute bottom-8 right-3"
open={floatingTerminalOpen}
onToggle={() => setFloatingTerminalOpenWithFocus((open) => !open)}
/>
) : null}
</div>
</div>
</div>
{/* Why: keep RightSidebar mounted even when closed so that its
{/* Why: keep RightSidebar mounted even when closed so that its
child components (FileExplorer, SourceControl, etc.) and their
filesystem watchers + cached directory trees survive across
open/close toggles. Unmount on the tasks view since that
surface is intentionally distraction-free. */}
{showRightSidebarControls ? <RightSidebar /> : null}
</div>
{floatingTerminalEnabled ? (
<FloatingTerminalPanel
open={floatingTerminalOpen}
onOpenChange={setFloatingTerminalOpenWithFocus}
/>
) : null}
<StatusBar floatingTerminalOpen={floatingTerminalOpen} />
{/* Why: root overlays can render Radix <Tooltip>s; keep them inside
{showRightSidebarControls ? <RightSidebar /> : null}
</div>
{floatingTerminalEnabled ? (
<FloatingTerminalPanel
open={floatingTerminalOpen}
onOpenChange={setFloatingTerminalOpenWithFocus}
/>
) : null}
<StatusBar floatingTerminalOpen={floatingTerminalOpen} />
{/* Why: root overlays can render Radix <Tooltip>s; keep them inside
the shared provider so lazy surfaces mount safely from any entry point. */}
<Suspense fallback={null}>
{mountedLazyModalIds.has('new-workspace-composer') ? <NewWorkspaceComposerModal /> : null}
{mountedLazyModalIds.has('workspace-cleanup') ? <WorkspaceCleanupDialog /> : null}
</Suspense>
<Suspense fallback={null}>
{mountedLazyModalIds.has('quick-open') ? <QuickOpen /> : null}
{mountedLazyModalIds.has('worktree-palette') ? <WorktreeJumpPalette /> : null}
{mountedLazyModalIds.has('feature-wall') ? <FeatureWallModal /> : null}
{mountedLazyModalIds.has('feature-tips') ? <FeatureTipsModal /> : null}
</Suspense>
{/* Why: mount PetOverlay only when the experimental flag is on AND
<Suspense fallback={null}>
{mountedLazyModalIds.has('new-workspace-composer') ? (
<NewWorkspaceComposerModal />
) : null}
{mountedLazyModalIds.has('workspace-cleanup') ? <WorkspaceCleanupDialog /> : null}
</Suspense>
<Suspense fallback={null}>
{mountedLazyModalIds.has('quick-open') ? <QuickOpen /> : null}
{mountedLazyModalIds.has('worktree-palette') ? <WorktreeJumpPalette /> : null}
{mountedLazyModalIds.has('feature-wall') ? <FeatureWallModal /> : null}
{mountedLazyModalIds.has('feature-tips') ? <FeatureTipsModal /> : null}
</Suspense>
{/* Why: mount PetOverlay only when the experimental flag is on AND
the user hasn't hit "Hide pet" in the status-bar menu. Both
conditions must be true see design doc (pet-overlay.md) on why
the two toggles are kept independent. */}
{petEnabled && petVisible ? (
<Suspense fallback={null}>
<PetOverlay />
</Suspense>
) : null}
<UpdateCard />
<FeatureTourNudge />
<StarNagCard />
{/* Why: the existing-user opt-in banner mounts at App root so it
{petEnabled && petVisible ? (
<Suspense fallback={null}>
<PetOverlay />
</Suspense>
) : null}
<UpdateCard />
<FeatureTourNudge />
<StarNagCard />
{/* Why: the existing-user opt-in banner mounts at App root so it
renders once per renderer session, not per view. It gates
internally on the cohort markers populated by the migration,
so it only shows for users who installed before the telemetry
release and have not yet resolved consent. New users get no
first-launch surface see telemetry-plan.md §First-launch
experience. */}
<TelemetryFirstLaunchSurface />
<ZoomOverlay />
<SshPassphraseDialog />
<DeleteWorktreeDialog />
<CrashReportDialog />
{onboarding && shouldShowOnboarding(onboarding) && !onboardingSettingsDetour ? (
<Suspense fallback={null}>
<OnboardingFlow
onboarding={onboarding}
onOnboardingChange={setOnboarding}
onSettingsDetourStart={beginOnboardingSettingsDetour}
/>
</Suspense>
) : null}
<DictationController />
<RecentTabSwitcher />
<TelemetryFirstLaunchSurface />
<ZoomOverlay />
<SshPassphraseDialog />
<DeleteWorktreeDialog />
<CrashReportDialog />
{onboarding && shouldShowOnboarding(onboarding) && !onboardingSettingsDetour ? (
<Suspense fallback={null}>
<OnboardingFlow
onboarding={onboarding}
onOnboardingChange={setOnboarding}
onSettingsDetourStart={beginOnboardingSettingsDetour}
/>
</Suspense>
) : null}
<DictationController />
<RecentTabSwitcher />
</ConfirmationDialogProvider>
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
{/* Why: rendered last so it sits after all -webkit-app-region:drag elements

View File

@ -41,6 +41,7 @@ import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import { Input } from '@/components/ui/input'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { VisuallyHidden } from 'radix-ui'
import {
@ -2101,6 +2102,7 @@ function PRActionsPanel({
const [mergePending, setMergePending] = useState(false)
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
const confirm = useConfirmationDialog()
const actionItem = { ...item, state: localState }
const canMutateState = localState !== 'merged' && (!!repoPath || !!projectOrigin)
const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed'
@ -2135,7 +2137,16 @@ function PRActionsPanel({
return
}
const label = nextState === 'closed' ? 'Close' : 'Reopen'
if (!window.confirm(`${label} PR #${item.number}?`)) {
const confirmed = await confirm({
title: `${label} PR #${item.number}?`,
description:
nextState === 'closed'
? 'This will close the pull request on GitHub.'
: 'This will reopen the pull request on GitHub.',
confirmLabel: label,
confirmVariant: nextState === 'closed' ? 'destructive' : 'default'
})
if (!confirmed) {
return
}
const previousState = localState
@ -2165,7 +2176,12 @@ function PRActionsPanel({
}
const label =
method === 'squash' ? 'Squash and merge' : method === 'rebase' ? 'Rebase and merge' : 'Merge'
if (!window.confirm(`${label} PR #${item.number}?`)) {
const confirmed = await confirm({
title: `${label} PR #${item.number}?`,
description: 'This will update the pull request on GitHub.',
confirmLabel: label
})
if (!confirmed) {
return
}
setMergePending(true)

View File

@ -76,6 +76,7 @@ import RepoDotLabel from '@/components/repo/RepoDotLabel'
import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator'
import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector'
import { reconcileLinearTeamSelection } from '@/components/task-page-linear-team-selection'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import {
getGitHubPRPrimaryReviewer,
getGitHubPRReviewLabel,
@ -1411,6 +1412,7 @@ function PRMergeCell({
onRefresh: () => void
}): React.JSX.Element {
const [merging, setMerging] = useState(false)
const confirm = useConfirmationDialog()
if (item.type !== 'pr') {
return <span className="text-[11px] text-muted-foreground">Issue</span>
}
@ -1425,11 +1427,13 @@ function PRMergeCell({
if (!repo || mergeDisabled) {
return
}
const confirmed = window.confirm(
method === 'squash'
? `Squash and merge PR #${item.number}?`
: `${method === 'rebase' ? 'Rebase and merge' : 'Merge'} PR #${item.number}?`
)
const label =
method === 'squash' ? 'Squash and merge' : method === 'rebase' ? 'Rebase and merge' : 'Merge'
const confirmed = await confirm({
title: `${label} PR #${item.number}?`,
description: 'This will update the pull request on GitHub.',
confirmLabel: label
})
if (!confirmed) {
return
}

View File

@ -0,0 +1,114 @@
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
type ConfirmationDialogOptions = {
title: string
description?: string
confirmLabel?: string
cancelLabel?: string
confirmVariant?: 'default' | 'destructive'
}
type ConfirmationDialogRequest = {
id: number
options: ConfirmationDialogOptions
resolve: (confirmed: boolean) => void
}
type ConfirmationDialogContextValue = (options: ConfirmationDialogOptions) => Promise<boolean>
const ConfirmationDialogContext = createContext<ConfirmationDialogContextValue | null>(null)
export function ConfirmationDialogProvider({
children
}: {
children: React.ReactNode
}): React.JSX.Element {
const nextIdRef = useRef(0)
const [queue, setQueue] = useState<ConfirmationDialogRequest[]>([])
const activeRequest = queue[0] ?? null
const [renderedRequest, setRenderedRequest] = useState<ConfirmationDialogRequest | null>(null)
const activeRequestRef = useRef<ConfirmationDialogRequest | null>(activeRequest)
const displayedRequest = activeRequest ?? renderedRequest
useEffect(() => {
activeRequestRef.current = activeRequest
if (activeRequest) {
setRenderedRequest(activeRequest)
}
}, [activeRequest])
const confirm = useCallback<ConfirmationDialogContextValue>((options) => {
return new Promise((resolve) => {
const request: ConfirmationDialogRequest = {
id: nextIdRef.current,
options,
resolve
}
nextIdRef.current += 1
setQueue((currentQueue) => [...currentQueue, request])
})
}, [])
const settleActiveRequest = useCallback((confirmed: boolean) => {
const request = activeRequestRef.current
if (!request) {
return
}
request.resolve(confirmed)
setQueue((currentQueue) => {
if (currentQueue[0]?.id === request.id) {
return currentQueue.slice(1)
}
return currentQueue.filter((queuedRequest) => queuedRequest.id !== request.id)
})
}, [])
return (
<ConfirmationDialogContext.Provider value={confirm}>
{children}
<Dialog
open={activeRequest !== null}
onOpenChange={(open) => !open && settleActiveRequest(false)}
>
<DialogContent showCloseButton={false} className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{displayedRequest?.options.title}</DialogTitle>
{displayedRequest?.options.description ? (
<DialogDescription>{displayedRequest.options.description}</DialogDescription>
) : null}
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => settleActiveRequest(false)}>
{displayedRequest?.options.cancelLabel ?? 'Cancel'}
</Button>
<Button
type="button"
variant={displayedRequest?.options.confirmVariant ?? 'default'}
onClick={() => settleActiveRequest(true)}
>
{displayedRequest?.options.confirmLabel ?? 'Confirm'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ConfirmationDialogContext.Provider>
)
}
export function useConfirmationDialog(): ConfirmationDialogContextValue {
const confirm = useContext(ConfirmationDialogContext)
if (!confirm) {
throw new Error('useConfirmationDialog must be used inside ConfirmationDialogProvider')
}
return confirm
}

View File

@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { dirname } from '@/lib/path'
import { getConnectionId } from '@/lib/connection-context'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
@ -49,6 +50,7 @@ export function useFileDeletion({
isMac,
isWindows
}: UseFileDeletionParams): UseFileDeletionResult {
const confirm = useConfirmationDialog()
// Why: track in-flight deletes per-path so repeated Del presses on the same
// node don't issue duplicate IPC calls; the map is a ref to avoid re-renders.
const inFlightRef = useRef<Set<string>>(new Set())
@ -82,7 +84,13 @@ export function useFileDeletion({
const message = node.isDirectory
? `Permanently delete '${node.name}' and all its contents? This cannot be undone.`
: `Permanently delete '${node.name}'? This cannot be undone.`
if (!window.confirm(message)) {
const confirmed = await confirm({
title: `Permanently delete '${node.name}'?`,
description: message,
confirmLabel: 'Delete',
confirmVariant: 'destructive'
})
if (!confirmed) {
inFlightRef.current.delete(node.path)
return
}
@ -193,7 +201,16 @@ export function useFileDeletion({
inFlightRef.current.delete(node.path)
}
},
[activeWorktreeId, closeFile, isWindows, openFiles, refreshDir, selectedPath, setSelectedPath]
[
activeWorktreeId,
closeFile,
confirm,
isWindows,
openFiles,
refreshDir,
selectedPath,
setSelectedPath
]
)
const requestDelete = useCallback(

View File

@ -31,6 +31,7 @@ import { useAppStore } from '../../store'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
import { applyDocumentTheme } from '@/lib/document-theme'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants'
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
import { GeneralPane, GENERAL_PANE_SEARCH_ENTRIES } from './GeneralPane'
@ -277,6 +278,7 @@ function Settings(): React.JSX.Element {
const [pendingNavRequestTick, setPendingNavRequestTick] = useState(0)
const [hasUnsavedCommitPromptChanges, setHasUnsavedCommitPromptChanges] = useState(false)
const [commitPromptDiscardSignal, setCommitPromptDiscardSignal] = useState(0)
const confirm = useConfirmationDialog()
// Why: the hidden-experimental group is an unlock — Shift-clicking the
// Experimental sidebar entry reveals it for the remainder of the session.
// Not persisted on purpose: it's a power-user affordance we don't want to
@ -291,22 +293,25 @@ function Settings(): React.JSX.Element {
const repoHooksRequestSeqRef = useRef(0)
const repoHooksRuntimeIdentityRef = useRef<string>('local')
const confirmDiscardCommitPromptChanges = useCallback((): boolean => {
const confirmDiscardCommitPromptChanges = useCallback(async (): Promise<boolean> => {
if (!hasUnsavedCommitPromptChanges) {
return true
}
const shouldDiscard = window.confirm(
'You have unsaved AI commit prompt changes. Leave without saving?'
)
const shouldDiscard = await confirm({
title: 'Discard unsaved commit prompt changes?',
description: 'You have unsaved AI commit prompt changes. Leaving will discard them.',
confirmLabel: 'Discard',
confirmVariant: 'destructive'
})
if (shouldDiscard) {
setCommitPromptDiscardSignal((signal) => signal + 1)
setHasUnsavedCommitPromptChanges(false)
}
return shouldDiscard
}, [hasUnsavedCommitPromptChanges])
}, [confirm, hasUnsavedCommitPromptChanges])
const closeSettingsPageWithPromptGuard = useCallback((): void => {
if (!confirmDiscardCommitPromptChanges()) {
const closeSettingsPageWithPromptGuard = useCallback(async (): Promise<void> => {
if (!(await confirmDiscardCommitPromptChanges())) {
return
}
closeSettingsPage()
@ -319,10 +324,33 @@ function Settings(): React.JSX.Element {
const runtimeTargetIdentity = getRuntimeTargetIdentity(settings)
useEffect(() => {
const hasVisibleOverlay = (): boolean =>
Array.from(
document.querySelectorAll('[role="dialog"], [role="listbox"], [role="menu"]')
).some((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
if (element.closest('[aria-hidden="true"]')) {
return false
}
const style = window.getComputedStyle(element)
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
element.getClientRects().length > 0
)
})
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape' || event.defaultPrevented) {
return
}
// Why: nested dialogs and menus own Escape before Settings page-level
// navigation, including the unsaved commit prompt confirmation dialog.
if (hasVisibleOverlay()) {
return
}
// Why: Escape in an editable control usually means "cancel this edit",
// not "close Settings". Closing the entire page would discard the user's
// in-progress typing. Defer to the field's own handler when focus is on
@ -331,7 +359,7 @@ function Settings(): React.JSX.Element {
if (isEditableTarget(event.target)) {
return
}
closeSettingsPageWithPromptGuard()
void closeSettingsPageWithPromptGuard()
}
document.addEventListener('keydown', handleKeyDown)
@ -949,11 +977,11 @@ function Settings(): React.JSX.Element {
}, [visibleNavSections])
const scrollToSection = useCallback(
(
async (
sectionId: string,
modifiers?: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
) => {
if (sectionId !== activeSectionId && !confirmDiscardCommitPromptChanges()) {
): Promise<void> => {
if (sectionId !== activeSectionId && !(await confirmDiscardCommitPromptChanges())) {
return
}
// Why: Shift-clicking the Experimental sidebar entry unlocks a hidden
@ -971,8 +999,8 @@ function Settings(): React.JSX.Element {
[activeSectionId, confirmDiscardCommitPromptChanges]
)
const openComputerUseFromBrowser = useCallback(() => {
if (!confirmDiscardCommitPromptChanges()) {
const openComputerUseFromBrowser = useCallback(async () => {
if (!(await confirmDiscardCommitPromptChanges())) {
return
}
pendingNavSectionRef.current = 'computer-use'