Use app-styled confirmations for PR actions (#2324)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
b1a8c68b9a
commit
4b86fe8292
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue