feat(tabs): confirm before closing pinned tabs (#5536)

Add a configurable confirmation before pinned tabs are closed, route terminal/browser/CLI close paths through the guard, and add a one-shot Don't ask again option backed by the existing setting.

Verified with focused unit tests, typecheck, lint, Electron validation, and the PR verify workflow.
This commit is contained in:
AJ
2026-06-17 18:27:01 -07:00
committed by GitHub
parent 1368ba84b8
commit becaea5734
31 changed files with 1474 additions and 76 deletions
@@ -125,6 +125,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
confirmClosePinnedTab: true,
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
terminalJISYenToBackslash: false,
+1
View File
@@ -129,6 +129,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
confirmClosePinnedTab: true,
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
terminalJISYenToBackslash: false,
+2
View File
@@ -147,6 +147,7 @@ import { isGitRepoKind } from '../../shared/repo-kind'
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state'
import { translate } from '@/i18n/i18n'
import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog'
const isMac = navigator.userAgent.includes('Mac')
const isWindows = !isMac && navigator.userAgent.includes('Windows')
@@ -2541,6 +2542,7 @@ function App(): React.JSX.Element {
</ConfirmationDialogProvider>
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
<PinnedTabCloseDialog />
{/* Why: rendered last so it sits after all -webkit-app-region:drag elements
in DOM order. Electron's hit-test for drag regions is DOM-order-based and
ignores z-index — placing WindowControls earlier caused the drag region to
@@ -5,6 +5,7 @@ import {
createHttpProxyUrlDraftState,
getDesktopPlatformFromUserAgent,
getGeneralPaneSearchEntries,
getTabOrderControlSearchKeywords,
setHttpProxyUrlDraftErrorState,
shouldCommitOpenInApplicationsDraft,
updateAutoSaveDelayDraftState,
@@ -133,6 +134,18 @@ describe('GeneralPane desktop platform detection', () => {
})
})
describe('GeneralPane navigation search keywords', () => {
it('keeps pinned-tab keywords out of the Tab Order control', () => {
const keywords = getTabOrderControlSearchKeywords()
expect(keywords).toContain('Tab Order')
expect(keywords).toContain('recent')
expect(keywords).not.toContain('pinned')
expect(keywords).not.toContain('confirm')
expect(keywords).not.toContain('close')
})
})
describe('GeneralPane search entries', () => {
it('includes the default project runtime setting', () => {
const entries = getGeneralPaneSearchEntries()
@@ -23,7 +23,8 @@ import {
import { getGeneralProjectRuntimeSearchEntries } from './general-project-runtime-search'
import { RecentTabOrderControl } from './RecentTabOrderControl'
import { matchesSettingsSearch } from './settings-search'
import { SettingsSubsectionHeader } from './SettingsFormControls'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls'
import { translate } from '@/i18n/i18n'
import { DefaultWindowsProjectRuntimeSetting } from './DefaultWindowsProjectRuntimeSetting'
@@ -43,6 +44,8 @@ export {
} from './GeneralNetworkSettingsSection'
export { shouldCommitOpenInApplicationsDraft } from './OpenInMenuSetting'
type GeneralSearchEntry = ReturnType<typeof getGeneralNavigationSearchEntries>[number]
export function getDesktopPlatformFromUserAgent(userAgent: string): 'darwin' | 'win32' | 'other' {
if (userAgent.includes('Mac')) {
return 'darwin'
@@ -55,6 +58,19 @@ export function getDesktopPlatformFromUserAgent(userAgent: string): 'darwin' | '
export { getGeneralPaneSearchEntries }
export function getTabOrderControlSearchKeywords(
navigationEntries: GeneralSearchEntry[] = getGeneralNavigationSearchEntries()
): string[] {
const tabOrderSearchEntry = navigationEntries[0]
return tabOrderSearchEntry
? [
tabOrderSearchEntry.title,
tabOrderSearchEntry.description ?? '',
...(tabOrderSearchEntry.keywords ?? [])
]
: []
}
const EMPTY_WSL_DISTROS: string[] = []
type GeneralPaneProps = {
@@ -75,25 +91,49 @@ export function GeneralPane({
wslCapabilitiesLoading
}: GeneralPaneProps): React.JSX.Element {
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
const generalNavigationSearchEntries = getGeneralNavigationSearchEntries()
const tabOrderKeywords = getTabOrderControlSearchKeywords(generalNavigationSearchEntries)
const projectRuntimeSearchEntries = wslSupportedPlatform
? getGeneralProjectRuntimeSearchEntries()
: []
const visibleSections = [
matchesSettingsSearch(searchQuery, getGeneralNavigationSearchEntries()) ? (
matchesSettingsSearch(searchQuery, generalNavigationSearchEntries) ? (
<section key="navigation" className="space-y-4">
<SettingsSubsectionHeader
title={translate('auto.components.settings.GeneralPane.d58fccfd84', 'Navigation')}
/>
<RecentTabOrderControl
ctrlTabOrderMode={settings.ctrlTabOrderMode ?? 'mru'}
keywords={getGeneralNavigationSearchEntries().flatMap((entry) => [
entry.title,
entry.description ?? '',
...(entry.keywords ?? [])
])}
keywords={tabOrderKeywords}
updateSettings={updateSettings}
/>
<SearchableSetting
title={translate(
'auto.components.settings.GeneralPane.5cb5475664',
'Confirm before closing pinned tabs'
)}
description={translate(
'auto.components.settings.GeneralPane.36b2a5dc6d',
'Show a confirmation dialog before a pinned tab is closed.'
)}
keywords={['pinned', 'tab', 'confirm', 'close']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.GeneralPane.5cb5475664',
'Confirm before closing pinned tabs'
)}
description={translate(
'auto.components.settings.GeneralPane.36b2a5dc6d',
'Show a confirmation dialog before a pinned tab is closed.'
)}
checked={settings.confirmClosePinnedTab ?? true}
onChange={() =>
updateSettings({ confirmClosePinnedTab: !(settings.confirmClosePinnedTab ?? true) })
}
/>
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, getGeneralWorkspaceSearchEntries()) ? (
@@ -148,6 +148,23 @@ export const getGeneralNavigationSearchEntries = createLocalizedCatalog(() => [
...translateSearchKeyword('auto.components.settings.general.search.f8f0ac213a', 'sequential'),
...translateSearchKeyword('auto.components.settings.general.search.fb84767421', 'switch')
]
},
{
title: translate(
'auto.components.settings.general.search.161a86a9da',
'Confirm before closing pinned tabs'
),
description: translate(
'auto.components.settings.general.search.8e593f04fc',
'Show a confirmation dialog before a pinned tab is closed.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.general.search.867dddea41', 'pinned'),
...translateSearchKeyword('auto.components.settings.general.search.5250cf0e48', 'pin'),
...translateSearchKeyword('auto.components.settings.general.search.2a254b725e', 'tab'),
...translateSearchKeyword('auto.components.settings.general.search.9f8558233a', 'confirm'),
...translateSearchKeyword('auto.components.settings.general.search.afa37a34e1', 'close')
]
}
])
@@ -0,0 +1,146 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import PinnedTabCloseDialog from './PinnedTabCloseDialog'
const initialState = useAppStore.getInitialState()
const mountedRoots: Root[] = []
async function renderDialog({
tabLabel = 'Docs',
onConfirm,
onCancel,
updateSettings
}: {
tabLabel?: string
onConfirm: () => void
onCancel?: () => void
updateSettings: AppState['updateSettings']
}): Promise<void> {
useAppStore.setState({
settings: { confirmClosePinnedTab: true } as AppState['settings'],
updateSettings
})
useAppStore.getState().requestPinnedTabCloseConfirm({
tabLabel,
onConfirm,
...(onCancel ? { onCancel } : {})
})
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
mountedRoots.push(root)
await act(async () => {
root.render(<PinnedTabCloseDialog />)
})
}
function getButton(label: string): HTMLButtonElement {
const button = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
(candidate) => candidate.textContent === label
)
if (!button) {
throw new Error(`Button not found: ${label}`)
}
return button
}
function getCheckbox(): HTMLButtonElement {
const checkbox = document.body.querySelector<HTMLButtonElement>('[role="checkbox"]')
if (!checkbox) {
throw new Error('Checkbox not found')
}
return checkbox
}
describe('PinnedTabCloseDialog', () => {
beforeEach(() => {
useAppStore.setState(initialState, true)
})
afterEach(async () => {
await act(async () => {
for (const root of mountedRoots.splice(0)) {
root.unmount()
}
})
document.body.innerHTML = ''
useAppStore.setState(initialState, true)
})
it('confirms without changing the preference by default', async () => {
const onConfirm = vi.fn()
const updateSettings = vi.fn().mockResolvedValue(undefined)
await renderDialog({ onConfirm, updateSettings })
expect(document.body.textContent).toContain('Close pinned tab?')
expect(document.body.textContent).toContain("Don't ask again for pinned tabs")
await act(async () => {
getButton('Close').click()
})
expect(updateSettings).not.toHaveBeenCalled()
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('turns off future pinned-tab confirmations when checked and confirmed', async () => {
const onConfirm = vi.fn()
const updateSettings = vi.fn().mockResolvedValue(undefined)
await renderDialog({ onConfirm, updateSettings })
await act(async () => {
getCheckbox().click()
})
await act(async () => {
getButton('Close').click()
})
expect(updateSettings).toHaveBeenCalledWith({ confirmClosePinnedTab: false })
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('does not persist the checkbox on cancel and resets it for the next request', async () => {
const firstOnConfirm = vi.fn()
const secondOnConfirm = vi.fn()
const onCancel = vi.fn()
const updateSettings = vi.fn().mockResolvedValue(undefined)
await renderDialog({ onConfirm: firstOnConfirm, onCancel, updateSettings })
await act(async () => {
getCheckbox().click()
})
await act(async () => {
getButton('Cancel').click()
})
expect(onCancel).toHaveBeenCalledTimes(1)
expect(firstOnConfirm).not.toHaveBeenCalled()
expect(updateSettings).not.toHaveBeenCalled()
await act(async () => {
useAppStore.getState().requestPinnedTabCloseConfirm({
tabLabel: 'Console',
onConfirm: secondOnConfirm
})
})
expect(getCheckbox().getAttribute('aria-checked')).toBe('false')
await act(async () => {
getButton('Close').click()
})
expect(updateSettings).not.toHaveBeenCalled()
expect(secondOnConfirm).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,95 @@
import { useEffect, useId, useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
/** Confirmation prompt shown when a pinned tab is about to be closed. Driven by
* store state so every close path (keyboard, native menu, CLI) can route a
* pinned tab through it without threading React context. */
export default function PinnedTabCloseDialog(): React.JSX.Element {
const checkboxId = useId()
const request = useAppStore((state) => state.pinnedTabCloseConfirm)
const confirmPinnedTabClose = useAppStore((state) => state.confirmPinnedTabClose)
const dismissPinnedTabClose = useAppStore((state) => state.dismissPinnedTabClose)
const updateSettings = useAppStore((state) => state.updateSettings)
const [dontAskAgain, setDontAskAgain] = useState(false)
const tabLabel = request?.tabLabel.trim()
useEffect(() => {
if (request !== null) {
setDontAskAgain(false)
}
}, [request])
const handleConfirm = (): void => {
if (dontAskAgain) {
void updateSettings({ confirmClosePinnedTab: false })
}
confirmPinnedTabClose()
}
return (
<Dialog
open={request !== null}
onOpenChange={(isOpen) => {
if (!isOpen) {
dismissPinnedTabClose()
}
}}
>
<DialogContent className="max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">
{translate(
'auto.components.terminal.pane.PinnedTabCloseDialog.6c190f295a',
'Close pinned tab?'
)}
</DialogTitle>
<DialogDescription className="text-xs">
{translate(
'auto.components.terminal.pane.PinnedTabCloseDialog.0d1963f4a6',
'This tab is pinned. Are you sure you want to close it?'
)}
</DialogDescription>
</DialogHeader>
{tabLabel ? (
<p className="truncate text-xs font-medium text-foreground" title={tabLabel}>
{tabLabel}
</p>
) : null}
<div className="flex items-center gap-2">
<Checkbox
id={checkboxId}
checked={dontAskAgain}
onCheckedChange={(checked) => setDontAskAgain(checked === true)}
/>
<Label htmlFor={checkboxId} className="text-xs font-normal text-muted-foreground">
{translate(
'auto.components.terminal.pane.PinnedTabCloseDialog.dont_ask_again',
"Don't ask again for pinned tabs"
)}
</Label>
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" size="sm" onClick={dismissPinnedTabClose}>
{translate('auto.components.terminal.pane.PinnedTabCloseDialog.0b38ee2f86', 'Cancel')}
</Button>
<Button type="button" variant="destructive" size="sm" autoFocus onClick={handleConfirm}>
{translate('auto.components.terminal.pane.PinnedTabCloseDialog.c337c9d75c', 'Close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -5,6 +5,7 @@ import type { CSSProperties } from 'react'
import type { IDisposable } from '@xterm/xterm'
import { X } from 'lucide-react'
import { useAppStore } from '../../store'
import { isUnifiedTabPinned } from '@/store/pinned-tab-close-guard'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog'
@@ -828,6 +829,19 @@ export default function TerminalPane({
const handleRequestClosePane = useCallback(
(paneId: number) => {
// Why: when closing the last pane of a pinned tab, the pin confirmation
// takes precedence over the running-process prompt — let executeClosePane
// fall through to closeTerminalTab, which raises the single pin dialog
// (confirming it kills the process). Non-pinned tabs keep the process prompt.
const isLastPane = (managerRef.current?.getPanes().length ?? 0) <= 1
if (isLastPane) {
const state = useAppStore.getState()
const confirmPinned = state.settings?.confirmClosePinnedTab ?? true
if (confirmPinned && isUnifiedTabPinned(state, worktreeId, tabId)) {
executeClosePane(paneId)
return
}
}
const transport = paneTransportsRef.current.get(paneId)
const ptyId = transport?.getPtyId()
if (!ptyId) {
@@ -849,7 +863,7 @@ export default function TerminalPane({
// had a child process. Matches the semantics of the !ptyId branch above.
.catch(() => executeClosePane(paneId))
},
[executeClosePane, getCloseDialogCopyKind]
[executeClosePane, tabId, worktreeId, getCloseDialogCopyKind]
)
const handleSearchSelectedText = useCallback((selectedText: string): void => {
@@ -10,6 +10,7 @@ import {
type ActivityTerminalPortalTarget
} from '../activity/activity-terminal-portal'
import TerminalPane from './TerminalPane'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
type TerminalOverlayAssignment = {
groupId: string
@@ -210,7 +211,10 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
leaveWorktreeIfEmpty()
}}
onCloseTab={() => {
closeTab(terminalTabId)
// Why: route through closeTerminalTab (not the raw store closeTab) so a
// pinned tab hits the confirmation guard. The overlay's direct
// store.closeTab was the path that closed pinned terminals silently.
closeTerminalTab(terminalTabId)
leaveWorktreeIfEmpty()
}}
/>
@@ -79,6 +79,7 @@ import {
} from '@/constants/terminal'
import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry'
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import { seedStartupSessionRestoredBanner } from './session-restored-banner-pane-state'
export function recordRuntimeCreatedTerminalPaneSplit(
@@ -1270,7 +1271,10 @@ export function useTerminalPaneLifecycle({
return
}
if (mgr.getPanes().length <= 1) {
useAppStore.getState().closeTab(tabId)
// Why: route through closeTerminalTab (not the raw store closeTab) so a
// pinned tab hits the confirmation guard. Closing the last pane here was
// the one path that silently dropped pinned tabs.
closeTerminalTab(tabId)
} else {
mgr.closePane(detail.paneRuntimeId)
scheduleRuntimeGraphSync()
@@ -267,6 +267,119 @@ describe('closeTerminalTab', () => {
expect(closeWebRuntimeSessionTabMock).not.toHaveBeenCalled()
expect(closeTab).toHaveBeenCalledWith('local-agent-tab')
})
function makePinnedTabState(
overrides: { confirmClosePinnedTab: boolean } & Record<string, unknown>
): Record<string, unknown> {
const { confirmClosePinnedTab, ...rest } = overrides
return {
settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab },
tabsByWorktree: {},
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'unified-pinned-1',
entityId: 'pinned-entity-1',
contentType: 'terminal',
groupId: 'group-1',
worktreeId: 'wt-1',
label: 'Server',
generatedLabel: null,
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0,
isPreview: false,
isPinned: true
}
]
},
activeWorktreeId: 'wt-1',
activeTabId: 'pinned-entity-1',
openFiles: [],
browserTabsByWorktree: {},
closeTab: vi.fn(),
closeUnifiedTab: vi.fn(),
setActiveTab: vi.fn(),
setActiveWorktree: vi.fn(),
requestPinnedTabCloseConfirm: vi.fn(),
...rest
}
}
it('routes a pinned tab through the confirmation guard instead of closing it', () => {
const requestPinnedTabCloseConfirm = vi.fn()
const closeUnifiedTab = vi.fn()
getStateMock.mockReturnValue(
makePinnedTabState({
confirmClosePinnedTab: true,
requestPinnedTabCloseConfirm,
closeUnifiedTab
})
)
closeTerminalTab('pinned-entity-1')
expect(closeUnifiedTab).not.toHaveBeenCalled()
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledTimes(1)
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledWith(
expect.objectContaining({ tabLabel: 'Server', onConfirm: expect.any(Function) })
)
})
it('closes the pinned tab when the confirmation callback runs', () => {
const requestPinnedTabCloseConfirm = vi.fn()
const closeUnifiedTab = vi.fn()
getStateMock.mockReturnValue(
makePinnedTabState({
confirmClosePinnedTab: true,
requestPinnedTabCloseConfirm,
closeUnifiedTab
})
)
closeTerminalTab('pinned-entity-1')
const { onConfirm } = requestPinnedTabCloseConfirm.mock.calls[0][0] as { onConfirm: () => void }
onConfirm()
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-pinned-1')
})
it('guards a pinned tab closed by its unified id (workspace overlay path)', () => {
const requestPinnedTabCloseConfirm = vi.fn()
const closeUnifiedTab = vi.fn()
getStateMock.mockReturnValue(
makePinnedTabState({
confirmClosePinnedTab: true,
requestPinnedTabCloseConfirm,
closeUnifiedTab
})
)
// Why: TerminalPaneOverlayLayer closes by terminalTab.id (the unified id),
// not the entityId. The guard must still recognize it as pinned.
closeTerminalTab('unified-pinned-1')
expect(closeUnifiedTab).not.toHaveBeenCalled()
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledTimes(1)
})
it('closes a pinned tab immediately when the confirmation setting is off', () => {
const requestPinnedTabCloseConfirm = vi.fn()
const closeUnifiedTab = vi.fn()
getStateMock.mockReturnValue(
makePinnedTabState({
confirmClosePinnedTab: false,
requestPinnedTabCloseConfirm,
closeUnifiedTab
})
)
closeTerminalTab('pinned-entity-1')
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-pinned-1')
})
})
describe('closeOtherTerminalTabs', () => {
@@ -11,6 +11,7 @@ import {
} from '@/runtime/web-runtime-session'
import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard'
const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
'editor',
@@ -141,7 +142,7 @@ export function createNewTerminalTab(
state.setTabBarOrder(activeWorktreeId, order)
}
export function closeTerminalTab(tabId: string): void {
export function closeTerminalTab(tabId: string, options?: { force?: boolean }): void {
const state = useAppStore.getState()
const target = resolveCloseTerminalTabTarget(state, tabId)
if (!target) {
@@ -149,7 +150,14 @@ export function closeTerminalTab(tabId: string): void {
}
const { worktreeId: owningWorktreeId, terminalTabId } = target
if (isPinnedVisibleTab(state, owningWorktreeId, terminalTabId)) {
// Why: a pinned tab routes through the confirmation guard instead of closing
// outright. `force` is the post-confirmation re-entry, which skips the guard.
if (!options?.force && isPinnedVisibleTab(state, owningWorktreeId, terminalTabId)) {
guardPinnedTabClose({
isPinned: true,
tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId),
onClose: () => closeTerminalTab(tabId, { force: true })
})
return
}
+444
View File
@@ -9,6 +9,14 @@ import {
} from './useIpcEvents'
import { makePaneKey } from '../../../shared/stable-pane-id'
const { closeTerminalTabMock } = vi.hoisted(() => ({
closeTerminalTabMock: vi.fn()
}))
vi.mock('@/components/terminal/terminal-tab-actions', () => ({
closeTerminalTab: closeTerminalTabMock
}))
const FUTURE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const STALE_LEAF_ID = '22222222-2222-4222-8222-222222222222'
const ORPHAN_LEAF_ID = '33333333-3333-4333-8333-333333333333'
@@ -1814,6 +1822,442 @@ describe('useIpcEvents browser tab close routing', () => {
beforeEach(() => {
vi.resetModules()
vi.unstubAllGlobals()
closeTerminalTabMock.mockReset()
})
type RequestTabCloseListener = (data: {
requestId: string
tabId: string | null
worktreeId?: string
}) => void
type CloseActiveTabListener = () => void
type CloseTerminalListener = (data: { tabId: string; paneRuntimeId?: number | null }) => void
async function useIpcEventsForCloseRouting({
closeActiveTabListenerRef,
closeTerminalListenerRef,
getState,
requestTabCloseListenerRef,
replyTabClose = vi.fn()
}: {
closeActiveTabListenerRef?: { current: CloseActiveTabListener | null }
closeTerminalListenerRef?: { current: CloseTerminalListener | null }
getState: () => Record<string, unknown>
requestTabCloseListenerRef?: { current: RequestTabCloseListener | null }
replyTabClose?: ReturnType<typeof vi.fn>
}): Promise<void> {
vi.doMock('react', async () => {
const actual = await vi.importActual<typeof ReactModule>('react')
return {
...actual,
useEffect: (effect: () => void | (() => void)) => {
effect()
}
}
})
const appStoreModule = {
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => ({
setUpdateStatus: vi.fn(),
fetchRepos: vi.fn(),
fetchWorktrees: vi.fn(),
setActiveView: vi.fn(),
activeModal: null,
closeModal: vi.fn(),
openModal: vi.fn(),
activeWorktreeId: 'wt-1',
activeView: 'terminal',
setActiveRepo: vi.fn(),
setActiveWorktree: vi.fn(),
revealWorktreeInSidebar: vi.fn(),
setIsFullScreen: vi.fn(),
updateBrowserTabPageState: vi.fn(),
activeTabType: 'browser',
editorFontZoomLevel: 0,
setEditorFontZoomLevel: vi.fn(),
setRateLimitsFromPush: vi.fn(),
setSshConnectionState: vi.fn(),
setSshTargetLabels: vi.fn(),
setPortForwards: vi.fn(),
clearPortForwards: vi.fn(),
setDetectedPorts: vi.fn(),
enqueueSshCredentialRequest: vi.fn(),
removeSshCredentialRequest: vi.fn(),
settings: { activeRuntimeEnvironmentId: null, terminalFontSize: 13 },
activeBrowserTabId: 'workspace-1',
activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' },
browserTabsByWorktree: { 'wt-1': [{ id: 'workspace-1' }] },
browserPagesByWorkspace: {},
unifiedTabsByWorktree: {},
closeBrowserTab: vi.fn(),
closeBrowserPage: vi.fn(),
requestPinnedTabCloseConfirm: vi.fn(),
...getState()
})
}
}
vi.doMock('../store', () => appStoreModule)
vi.doMock('@/store', () => appStoreModule)
vi.doMock('@/lib/ui-zoom', () => ({
applyUIZoom: vi.fn()
}))
vi.doMock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn(),
ensureWorktreeHasInitialTerminal: vi.fn()
}))
vi.doMock('@/components/sidebar/visible-worktrees', () => ({
getVisibleWorktreeIds: () => []
}))
vi.doMock('@/lib/editor-font-zoom', () => ({
nextEditorFontZoomLevel: vi.fn(() => 0),
computeEditorFontSize: vi.fn(() => 13)
}))
vi.doMock('@/components/settings/SettingsConstants', () => ({
zoomLevelToPercent: vi.fn(() => 100),
ZOOM_MIN: -3,
ZOOM_MAX: 3
}))
vi.doMock('@/lib/zoom-events', () => ({
dispatchZoomLevelChanged: vi.fn()
}))
vi.stubGlobal('window', {
dispatchEvent: vi.fn(),
api: {
repos: { onChanged: () => () => {} },
worktrees: {
onChanged: () => () => {},
onBaseStatus: () => () => {},
onRemoteBranchConflict: () => () => {}
},
ui: {
onStateChanged: () => () => {},
onOpenSettings: () => () => {},
onOpenFeatureTour: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
onToggleWorktreePalette: () => () => {},
onToggleFloatingTerminal: () => () => {},
onOpenQuickOpen: () => () => {},
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onJumpToTabIndex: () => () => {},
onWorktreeHistoryNavigate: () => () => {},
onActivateWorktree: () => () => {},
onCreateTerminal: () => () => {},
onRequestTerminalCreate: () => () => {},
replyTerminalCreate: () => {},
onSplitTerminal: () => () => {},
onRenameTerminal: () => () => {},
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: (listener: CloseTerminalListener) => {
if (closeTerminalListenerRef) {
closeTerminalListenerRef.current = listener
}
return () => {}
},
onSleepWorktree: () => () => {},
onNewBrowserTab: () => () => {},
onNewMarkdownTab: () => () => {},
onRequestTabCreate: () => () => {},
replyTabCreate: () => {},
onRequestTabClose: (listener: RequestTabCloseListener) => {
if (requestTabCloseListenerRef) {
requestTabCloseListenerRef.current = listener
}
return () => {}
},
replyTabClose,
onRequestTabSetProfile: () => () => {},
replyTabSetProfile: () => {},
onNewTerminalTab: () => () => {},
onCloseActiveTab: (listener: CloseActiveTabListener) => {
if (closeActiveTabListenerRef) {
closeActiveTabListenerRef.current = listener
}
return () => {}
},
onSwitchTab: () => () => {},
onSwitchTabAcrossAllTypes: () => () => {},
onSwitchRecentTab: () => () => {},
onSwitchTerminalTab: () => () => {},
onToggleStatusBar: () => () => {},
onFullscreenChanged: () => () => {},
onTerminalZoom: () => () => {},
getZoomLevel: () => 0,
set: vi.fn()
},
settings: {
onChanged: () => () => {}
},
updater: {
getStatus: () => Promise.resolve({ state: 'idle' }),
onStatus: () => () => {},
onClearDismissal: () => () => {}
},
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {},
onNavigationUpdate: () => () => {},
onActivateView: () => () => {},
onPaneFocus: () => () => {}
},
rateLimits: {
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
onUpdate: () => () => {}
},
ssh: {
listTargets: () => Promise.resolve([]),
listPortForwards: () => Promise.resolve([]),
listDetectedPorts: () => Promise.resolve([]),
getState: () => Promise.resolve(null),
onStateChanged: () => () => {},
onCredentialRequest: () => () => {},
onPortForwardsChanged: () => () => {},
onDetectedPortsChanged: () => () => {},
onCredentialResolved: () => () => {}
},
runtime: {
getTerminalFitOverrides: () => Promise.resolve([]),
getTerminalDrivers: () => Promise.resolve([]),
getBrowserDrivers: () => Promise.resolve([]),
onTerminalFitOverrideChanged: () => () => {},
onTerminalDriverChanged: () => () => {},
onBrowserDriverChanged: () => {}
},
agentStatus: { onSet: () => () => {} }
}
})
const { useIpcEvents: registerIpcEvents } = await import('./useIpcEvents')
registerIpcEvents()
}
it('delegates terminal close IPC without a pane id to the shared terminal close flow', async () => {
const closeTerminalListenerRef: { current: CloseTerminalListener | null } = { current: null }
await useIpcEventsForCloseRouting({
closeTerminalListenerRef,
getState: () => ({})
})
closeTerminalListenerRef.current?.({ tabId: 'terminal-1' })
expect(closeTerminalTabMock).toHaveBeenCalledWith('terminal-1')
})
it('confirms before closing a pinned active browser tab from the native close event', async () => {
const closeActiveTabListenerRef: { current: CloseActiveTabListener | null } = { current: null }
const closeBrowserTab = vi.fn()
const requestPinnedTabCloseConfirm = vi.fn()
await useIpcEventsForCloseRouting({
closeActiveTabListenerRef,
getState: () => ({
closeBrowserTab,
requestPinnedTabCloseConfirm,
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'browser-unified-1',
entityId: 'workspace-1',
contentType: 'browser',
label: 'Docs',
isPinned: true
}
]
}
})
})
closeActiveTabListenerRef.current?.()
expect(closeBrowserTab).not.toHaveBeenCalled()
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledWith(
expect.objectContaining({ tabLabel: 'Docs', onConfirm: expect.any(Function) })
)
const { onConfirm } = requestPinnedTabCloseConfirm.mock.calls[0][0] as { onConfirm: () => void }
onConfirm()
expect(closeBrowserTab).toHaveBeenCalledWith('workspace-1')
})
it('confirms CLI workspace browser closes and replies after confirmation', async () => {
const requestTabCloseListenerRef: { current: RequestTabCloseListener | null } = {
current: null
}
const closeBrowserTab = vi.fn()
const replyTabClose = vi.fn()
const requestPinnedTabCloseConfirm = vi.fn()
await useIpcEventsForCloseRouting({
requestTabCloseListenerRef,
replyTabClose,
getState: () => ({
closeBrowserTab,
requestPinnedTabCloseConfirm,
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'browser-unified-1',
entityId: 'workspace-1',
contentType: 'browser',
label: 'Docs',
isPinned: true
}
]
}
})
})
requestTabCloseListenerRef.current?.({ requestId: 'req-pinned', tabId: 'workspace-1' })
expect(closeBrowserTab).not.toHaveBeenCalled()
expect(replyTabClose).not.toHaveBeenCalledWith({ requestId: 'req-pinned' })
const request = requestPinnedTabCloseConfirm.mock.calls[0][0] as {
onConfirm: () => void
onCancel: () => void
}
request.onConfirm()
expect(closeBrowserTab).toHaveBeenCalledWith('workspace-1')
expect(replyTabClose).toHaveBeenCalledWith({ requestId: 'req-pinned' })
})
it('replies with the pinned error when a CLI browser close is canceled', async () => {
const requestTabCloseListenerRef: { current: RequestTabCloseListener | null } = {
current: null
}
const closeBrowserTab = vi.fn()
const replyTabClose = vi.fn()
const requestPinnedTabCloseConfirm = vi.fn()
await useIpcEventsForCloseRouting({
requestTabCloseListenerRef,
replyTabClose,
getState: () => ({
closeBrowserTab,
requestPinnedTabCloseConfirm,
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'browser-unified-1',
entityId: 'workspace-1',
contentType: 'browser',
label: 'Docs',
isPinned: true
}
]
}
})
})
requestTabCloseListenerRef.current?.({ requestId: 'req-cancel', tabId: 'workspace-1' })
const request = requestPinnedTabCloseConfirm.mock.calls[0][0] as {
onCancel: () => void
}
request.onCancel()
expect(closeBrowserTab).not.toHaveBeenCalled()
expect(replyTabClose).toHaveBeenCalledWith({
requestId: 'req-cancel',
error: 'Browser tab workspace-1 is pinned'
})
})
it('lets CLI browser closes bypass confirmation when the pinned-tab setting is off', async () => {
const requestTabCloseListenerRef: { current: RequestTabCloseListener | null } = {
current: null
}
const closeBrowserTab = vi.fn()
const replyTabClose = vi.fn()
const requestPinnedTabCloseConfirm = vi.fn()
await useIpcEventsForCloseRouting({
requestTabCloseListenerRef,
replyTabClose,
getState: () => ({
closeBrowserTab,
requestPinnedTabCloseConfirm,
settings: {
activeRuntimeEnvironmentId: null,
confirmClosePinnedTab: false,
terminalFontSize: 13
},
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'browser-unified-1',
entityId: 'workspace-1',
contentType: 'browser',
label: 'Docs',
isPinned: true
}
]
}
})
})
requestTabCloseListenerRef.current?.({ requestId: 'req-off', tabId: 'workspace-1' })
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
expect(closeBrowserTab).toHaveBeenCalledWith('workspace-1')
expect(replyTabClose).toHaveBeenCalledWith({ requestId: 'req-off' })
})
it('guards a CLI last-page close for a pinned browser workspace', async () => {
const requestTabCloseListenerRef: { current: RequestTabCloseListener | null } = {
current: null
}
const closeBrowserTab = vi.fn()
const closeBrowserPage = vi.fn()
const replyTabClose = vi.fn()
const requestPinnedTabCloseConfirm = vi.fn()
await useIpcEventsForCloseRouting({
requestTabCloseListenerRef,
replyTabClose,
getState: () => ({
closeBrowserTab,
closeBrowserPage,
requestPinnedTabCloseConfirm,
browserPagesByWorkspace: {
'workspace-1': [{ id: 'page-1', workspaceId: 'workspace-1' }]
},
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'browser-unified-1',
entityId: 'workspace-1',
contentType: 'browser',
label: 'Docs',
isPinned: true
}
]
}
})
})
requestTabCloseListenerRef.current?.({ requestId: 'req-page', tabId: 'page-1' })
expect(closeBrowserPage).not.toHaveBeenCalled()
expect(closeBrowserTab).not.toHaveBeenCalled()
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledWith(
expect.objectContaining({ tabLabel: 'Docs', onConfirm: expect.any(Function) })
)
})
it('closes the active browser tab for the requested worktree when main does not provide a tab id', async () => {
+73 -60
View File
@@ -88,6 +88,7 @@ import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name'
import type { RuntimeClientEvent } from '../../../shared/runtime-client-events'
import type { AppState } from '../store/types'
import { guardPinnedTabClose, resolvePinnedTabLabel } from '../store/pinned-tab-close-guard'
import {
closeWebRuntimeSessionTab,
createWebRuntimeSessionBrowserTab,
@@ -112,6 +113,7 @@ import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-tit
import { titleHasAgentName } from '../../../shared/agent-detection'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { translate } from '@/i18n/i18n'
import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions'
function getShortcutPlatform(): NodeJS.Platform {
if (navigator.userAgent.includes('Mac')) {
@@ -1605,16 +1607,18 @@ export function useIpcEvents(): void {
const store = useAppStore.getState()
const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId)
if (browserTarget) {
if (isPinnedSessionTab(store, worktreeId, browserTarget.workspaceId)) {
return
}
store.closeBrowserTab(browserTarget.workspaceId)
guardPinnedTabClose({
isPinned: isPinnedSessionTab(store, worktreeId, browserTarget.workspaceId),
tabLabel: resolvePinnedTabLabel(store, worktreeId, browserTarget.workspaceId),
onClose: () => useAppStore.getState().closeBrowserTab(browserTarget.workspaceId)
})
return
}
if (isPinnedSessionTab(store, worktreeId, tabId)) {
return
}
store.closeUnifiedTab(tabId)
guardPinnedTabClose({
isPinned: isPinnedSessionTab(store, worktreeId, tabId),
tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId),
onClose: () => useAppStore.getState().closeUnifiedTab(tabId)
})
})
)
@@ -1688,15 +1692,7 @@ export function useIpcEvents(): void {
const detail: CloseTerminalPaneDetail = { tabId, paneRuntimeId }
window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail }))
} else {
const store = useAppStore.getState()
const worktreeId =
Object.entries(store.tabsByWorktree).find(([, tabs]) =>
tabs.some((tab) => tab.id === tabId)
)?.[0] ?? null
if (worktreeId && isPinnedSessionTab(store, worktreeId, tabId)) {
return
}
store.closeTab(tabId)
closeTerminalTab(tabId)
}
})
)
@@ -2074,7 +2070,32 @@ export function useIpcEvents(): void {
}
const store = useAppStore.getState()
const explicitTargetId = data.tabId ?? null
let tabToClose =
const replyPinnedBrowserCloseCanceled = (tabId: string): void => {
window.api.ui.replyTabClose({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.2f6637fe6c',
'Browser tab {{value0}} is pinned',
{ value0: tabId }
)
})
}
const closeBrowserWorkspaceWithReply = (
worktreeId: string,
workspaceId: string
): void => {
const currentStore = useAppStore.getState()
guardPinnedTabClose({
isPinned: isPinnedSessionTab(currentStore, worktreeId, workspaceId),
tabLabel: resolvePinnedTabLabel(currentStore, worktreeId, workspaceId),
onClose: () => {
useAppStore.getState().closeBrowserTab(workspaceId)
window.api.ui.replyTabClose({ requestId: data.requestId })
},
onCancel: () => replyPinnedBrowserCloseCanceled(workspaceId)
})
}
const tabToClose =
explicitTargetId ??
(data.worktreeId
? (store.activeBrowserTabIdByWorktree?.[data.worktreeId] ?? null)
@@ -2108,15 +2129,8 @@ export function useIpcEvents(): void {
Object.entries(store.browserTabsByWorktree).find(([, tabs]) =>
tabs.some((tab) => tab.id === workspaceId)
)?.[0] ?? null
if (owningWorktreeId && isPinnedSessionTab(store, owningWorktreeId, workspaceId)) {
window.api.ui.replyTabClose({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.0e3cf53060',
'Browser tab {{value0}} is pinned',
{ value0: workspaceId }
)
})
if (owningWorktreeId) {
closeBrowserWorkspaceWithReply(owningWorktreeId, workspaceId)
return
}
store.closeBrowserTab(workspaceId)
@@ -2127,6 +2141,14 @@ export function useIpcEvents(): void {
return
}
}
const owningWorktreeId =
Object.entries(store.browserTabsByWorktree).find(([, tabs]) =>
tabs.some((tab) => tab.id === tabToClose)
)?.[0] ?? null
if (owningWorktreeId) {
closeBrowserWorkspaceWithReply(owningWorktreeId, tabToClose)
return
}
if (explicitTargetId) {
window.api.ui.replyTabClose({
requestId: data.requestId,
@@ -2138,21 +2160,6 @@ export function useIpcEvents(): void {
})
return
}
const owningWorktreeId =
Object.entries(store.browserTabsByWorktree).find(([, tabs]) =>
tabs.some((tab) => tab.id === tabToClose)
)?.[0] ?? null
if (owningWorktreeId && isPinnedSessionTab(store, owningWorktreeId, tabToClose)) {
window.api.ui.replyTabClose({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.0e3cf53060',
'Browser tab {{value0}} is pinned',
{ value0: tabToClose }
)
})
return
}
store.closeBrowserTab(tabToClose)
window.api.ui.replyTabClose({ requestId: data.requestId })
} catch (err) {
@@ -2223,28 +2230,34 @@ export function useIpcEvents(): void {
}
const store = useAppStore.getState()
if (store.activeTabType === 'browser' && store.activeBrowserTabId) {
if (
store.activeWorktreeId &&
isPinnedSessionTab(store, store.activeWorktreeId, store.activeBrowserTabId)
) {
return
}
const environmentId = getWorktreeRuntimeEnvironmentId(store.activeWorktreeId)
if (environmentId && store.activeWorktreeId) {
if (!isWebRuntimeSessionActive(environmentId)) {
store.closeBrowserTab(store.activeBrowserTabId)
return
}
void (async () => {
await closeWebRuntimeSessionTab({
worktreeId: store.activeWorktreeId!,
tabId: store.activeBrowserTabId!,
const tabId = store.activeBrowserTabId
const worktreeId = store.activeWorktreeId
const closeActiveBrowserTab = (): void => {
const currentStore = useAppStore.getState()
const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId)
if (environmentId && worktreeId) {
if (!isWebRuntimeSessionActive(environmentId)) {
currentStore.closeBrowserTab(tabId)
return
}
void closeWebRuntimeSessionTab({
worktreeId,
tabId,
environmentId
})
})()
return
}
currentStore.closeBrowserTab(tabId)
}
if (worktreeId && isPinnedSessionTab(store, worktreeId, tabId)) {
guardPinnedTabClose({
isPinned: true,
tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId),
onClose: closeActiveBrowserTab
})
return
}
store.closeBrowserTab(store.activeBrowserTabId)
closeActiveBrowserTab()
}
})
)
+12
View File
@@ -527,6 +527,7 @@
},
"useIpcEvents": {
"0e3cf53060": "Browser tab {{value0}} not found",
"2f6637fe6c": "Browser tab {{value0}} is pinned",
"a8d2bf8e9e": "No active browser tab to close",
"291c8ed902": "Browser tabs are unavailable while a remote runtime is active",
"f45fa2b03c": "Browser profiles are unavailable while a remote runtime is active",
@@ -2353,6 +2354,13 @@
}
}
}
},
"PinnedTabCloseDialog": {
"6c190f295a": "Close pinned tab?",
"0d1963f4a6": "This tab is pinned. Are you sure you want to close it?",
"dont_ask_again": "Don't ask again for pinned tabs",
"0b38ee2f86": "Cancel",
"c337c9d75c": "Close"
}
}
},
@@ -4784,6 +4792,8 @@
},
"GeneralPane": {
"d58fccfd84": "Navigation",
"5cb5475664": "Confirm before closing pinned tabs",
"36b2a5dc6d": "Show a confirmation dialog before a pinned tab is closed.",
"projectRuntime": "Project Runtime",
"projectRuntimeDescription": "Default runtime for local Windows projects that do not override it."
},
@@ -6991,6 +7001,8 @@
"7baf524b04": "workspace",
"d0bc793689": "Root directory where workspace folders are created.",
"4c95d08fa2": "Workspace Directory",
"161a86a9da": "Confirm before closing pinned tabs",
"8e593f04fc": "Show a confirmation dialog before a pinned tab is closed.",
"defaultProjectRuntime": "Default Project Runtime",
"defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects."
}
+12
View File
@@ -527,6 +527,7 @@
},
"useIpcEvents": {
"0e3cf53060": "Pestaña del navegador {{value0}} no encontrada",
"2f6637fe6c": "La pestaña del navegador {{value0}} está fijada",
"a8d2bf8e9e": "No hay ninguna pestaña activa del navegador para cerrar",
"291c8ed902": "Las pestañas del navegador no están disponibles mientras un tiempo de ejecución remoto está activo",
"f45fa2b03c": "Los perfiles del navegador no están disponibles mientras un tiempo de ejecución remoto está activo",
@@ -2353,6 +2354,13 @@
}
}
}
},
"PinnedTabCloseDialog": {
"6c190f295a": "¿Cerrar pestaña fijada?",
"0d1963f4a6": "Esta pestaña está fijada. ¿Seguro que quieres cerrarla?",
"dont_ask_again": "No volver a preguntar por pestañas fijadas",
"0b38ee2f86": "Cancelar",
"c337c9d75c": "Cerrar"
}
}
},
@@ -4784,6 +4792,8 @@
},
"GeneralPane": {
"d58fccfd84": "Navegación",
"5cb5475664": "Confirmar antes de cerrar pestañas fijadas",
"36b2a5dc6d": "Muestra un diálogo de confirmación antes de cerrar una pestaña fijada.",
"projectRuntime": "Project Runtime",
"projectRuntimeDescription": "Default runtime for local Windows projects that do not override it."
},
@@ -6954,6 +6964,8 @@
"7baf524b04": "espacio de trabajo",
"d0bc793689": "Directorio raíz donde se crean las carpetas del espacio de trabajo.",
"4c95d08fa2": "Directorio de espacio de trabajo",
"161a86a9da": "Confirmar antes de cerrar pestañas fijadas",
"8e593f04fc": "Muestra un diálogo de confirmación antes de cerrar una pestaña fijada.",
"defaultProjectRuntime": "Default Project Runtime",
"defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects."
}
+12
View File
@@ -527,6 +527,7 @@
},
"useIpcEvents": {
"0e3cf53060": "ブラウザタブ {{value0}} が見つかりません",
"2f6637fe6c": "ブラウザタブ {{value0}} はピン留めされています",
"a8d2bf8e9e": "閉じるためのアクティブなブラウザ タブがありません",
"291c8ed902": "リモート ランタイムがアクティブな間はブラウザ タブは使用できません",
"f45fa2b03c": "リモート ランタイムがアクティブな間はブラウザ プロファイルを使用できません",
@@ -2353,6 +2354,13 @@
}
}
}
},
"PinnedTabCloseDialog": {
"6c190f295a": "ピン留めしたタブを閉じますか?",
"0d1963f4a6": "このタブはピン留めされています。本当に閉じますか?",
"dont_ask_again": "ピン留めしたタブについて今後確認しない",
"0b38ee2f86": "キャンセル",
"c337c9d75c": "閉じる"
}
}
},
@@ -4769,6 +4777,8 @@
},
"GeneralPane": {
"d58fccfd84": "ナビゲーション",
"5cb5475664": "ピン留めしたタブを閉じる前に確認",
"36b2a5dc6d": "ピン留めしたタブを閉じる前に確認ダイアログを表示します。",
"projectRuntime": "Project Runtime",
"projectRuntimeDescription": "Default runtime for local Windows projects that do not override it."
},
@@ -6976,6 +6986,8 @@
"7baf524b04": "ワークスペース",
"d0bc793689": "ワークスペースフォルダーが作成されるルートディレクトリ。",
"4c95d08fa2": "ワークスペースディレクトリ",
"161a86a9da": "ピン留めしたタブを閉じる前に確認",
"8e593f04fc": "ピン留めしたタブを閉じる前に確認ダイアログを表示します。",
"defaultProjectRuntime": "Default Project Runtime",
"defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects."
}
+12
View File
@@ -527,6 +527,7 @@
},
"useIpcEvents": {
"0e3cf53060": "브라우저 탭 {{value0}}을(를) 찾을 수 없습니다",
"2f6637fe6c": "브라우저 탭 {{value0}}이(가) 고정되어 있습니다",
"a8d2bf8e9e": "닫을 활성 브라우저 탭이 없습니다.",
"291c8ed902": "원격 런타임이 활성화된 동안에는 브라우저 탭을 사용할 수 없습니다.",
"f45fa2b03c": "원격 런타임이 활성화된 동안에는 브라우저 프로필을 사용할 수 없습니다.",
@@ -2353,6 +2354,13 @@
}
}
}
},
"PinnedTabCloseDialog": {
"6c190f295a": "고정된 탭을 닫을까요?",
"0d1963f4a6": "이 탭은 고정되어 있습니다. 정말 닫으시겠어요?",
"dont_ask_again": "고정된 탭에 대해 다시 묻지 않기",
"0b38ee2f86": "취소",
"c337c9d75c": "닫기"
}
}
},
@@ -4769,6 +4777,8 @@
},
"GeneralPane": {
"d58fccfd84": "항해",
"5cb5475664": "고정된 탭을 닫기 전에 확인",
"36b2a5dc6d": "고정된 탭을 닫기 전에 확인 대화상자를 표시합니다.",
"projectRuntime": "Project Runtime",
"projectRuntimeDescription": "Default runtime for local Windows projects that do not override it."
},
@@ -6939,6 +6949,8 @@
"7baf524b04": "워크스페이스",
"d0bc793689": "워크스페이스 폴더가 생성되는 루트 디렉터리입니다.",
"4c95d08fa2": "워크스페이스 디렉토리",
"161a86a9da": "고정된 탭을 닫기 전에 확인",
"8e593f04fc": "고정된 탭을 닫기 전에 확인 대화상자를 표시합니다.",
"defaultProjectRuntime": "Default Project Runtime",
"defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects."
}
+12
View File
@@ -527,6 +527,7 @@
},
"useIpcEvents": {
"0e3cf53060": "未找到浏览器选项卡 {{value0}}",
"2f6637fe6c": "浏览器选项卡 {{value0}} 已固定",
"a8d2bf8e9e": "没有可关闭的活动浏览器选项卡",
"291c8ed902": "当远程运行时处于活动状态时,浏览器选项卡不可用",
"f45fa2b03c": "当远程运行时处于活动状态时,浏览器配置文件不可用",
@@ -2353,6 +2354,13 @@
}
}
}
},
"PinnedTabCloseDialog": {
"6c190f295a": "关闭固定的标签页?",
"0d1963f4a6": "此标签页已固定。确定要关闭它吗?",
"dont_ask_again": "固定标签页不再询问",
"0b38ee2f86": "取消",
"c337c9d75c": "关闭"
}
}
},
@@ -4769,6 +4777,8 @@
},
"GeneralPane": {
"d58fccfd84": "导航",
"5cb5475664": "关闭固定标签页前确认",
"36b2a5dc6d": "关闭固定标签页前显示确认对话框。",
"projectRuntime": "Project Runtime",
"projectRuntimeDescription": "Default runtime for local Windows projects that do not override it."
},
@@ -6939,6 +6949,8 @@
"7baf524b04": "工作区",
"d0bc793689": "创建工作区文件夹的根目录。",
"4c95d08fa2": "工作区目录",
"161a86a9da": "关闭固定标签页前确认",
"8e593f04fc": "关闭固定标签页前显示确认对话框。",
"defaultProjectRuntime": "Default Project Runtime",
"defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects."
}
+3 -1
View File
@@ -32,6 +32,7 @@ import { createWorkspaceCleanupSlice } from './slices/workspace-cleanup'
import { createRuntimeStatusSlice } from './slices/runtime-status'
import { createPullRequestGenerationSlice } from './slices/pull-request-generation'
import { createCommitMessageGenerationSlice } from './slices/commit-message-generation'
import { createPinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm'
import { e2eConfig } from '@/lib/e2e-config'
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
@@ -67,7 +68,8 @@ export const useAppStore = create<AppState>()((...a) => ({
...createWorkspaceCleanupSlice(...a),
...createRuntimeStatusSlice(...a),
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a)
...createCommitMessageGenerationSlice(...a),
...createPinnedTabCloseConfirmSlice(...a)
}))
registerHttpLinkStoreAccessor(() => useAppStore.getState())
@@ -0,0 +1,211 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getStateMock } = vi.hoisted(() => ({
getStateMock: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: getStateMock
}
}))
import {
guardPinnedTabClose,
isUnifiedTabPinned,
resolvePinnedTabLabel
} from './pinned-tab-close-guard'
import type { AppState } from './types'
function makeState(overrides: Partial<AppState>): AppState {
return {
settings: { confirmClosePinnedTab: true },
unifiedTabsByWorktree: {},
requestPinnedTabCloseConfirm: vi.fn(),
...overrides
} as unknown as AppState
}
describe('guardPinnedTabClose', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('closes immediately for a non-pinned tab without touching the store', () => {
const onClose = vi.fn()
const requestPinnedTabCloseConfirm = vi.fn()
getStateMock.mockReturnValue(makeState({ requestPinnedTabCloseConfirm }))
guardPinnedTabClose({ isPinned: false, tabLabel: 'Docs', onClose })
expect(onClose).toHaveBeenCalledTimes(1)
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
})
it('requests confirmation for a pinned tab when the setting is on', () => {
const onClose = vi.fn()
const requestPinnedTabCloseConfirm = vi.fn()
getStateMock.mockReturnValue(
makeState({
settings: { confirmClosePinnedTab: true } as AppState['settings'],
requestPinnedTabCloseConfirm
})
)
guardPinnedTabClose({ isPinned: true, tabLabel: 'Docs', onClose })
expect(onClose).not.toHaveBeenCalled()
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledWith({
tabLabel: 'Docs',
onConfirm: onClose
})
})
it('passes cancel callbacks to confirmation requests', () => {
const onClose = vi.fn()
const onCancel = vi.fn()
const requestPinnedTabCloseConfirm = vi.fn()
getStateMock.mockReturnValue(
makeState({
settings: { confirmClosePinnedTab: true } as AppState['settings'],
requestPinnedTabCloseConfirm
})
)
guardPinnedTabClose({ isPinned: true, tabLabel: 'Docs', onClose, onCancel })
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledWith({
tabLabel: 'Docs',
onConfirm: onClose,
onCancel
})
})
it('closes a pinned tab immediately when the setting is off', () => {
const onClose = vi.fn()
const requestPinnedTabCloseConfirm = vi.fn()
getStateMock.mockReturnValue(
makeState({
settings: { confirmClosePinnedTab: false } as AppState['settings'],
requestPinnedTabCloseConfirm
})
)
guardPinnedTabClose({ isPinned: true, tabLabel: 'Docs', onClose })
expect(onClose).toHaveBeenCalledTimes(1)
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
})
it('defaults to confirming when settings are not loaded yet', () => {
const onClose = vi.fn()
const requestPinnedTabCloseConfirm = vi.fn()
getStateMock.mockReturnValue(makeState({ settings: null, requestPinnedTabCloseConfirm }))
guardPinnedTabClose({ isPinned: true, tabLabel: 'Docs', onClose })
expect(onClose).not.toHaveBeenCalled()
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledTimes(1)
})
})
describe('resolvePinnedTabLabel', () => {
it('uses the same label priority as the tab strip', () => {
const state = makeState({
settings: {
confirmClosePinnedTab: true,
tabAutoGenerateTitle: true
} as AppState['settings'],
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'a',
entityId: 'ea',
customLabel: ' Custom ',
quickCommandLabel: 'Run tests',
generatedLabel: 'Gen',
label: 'Plain'
},
{
id: 'b',
entityId: 'eb',
customLabel: ' ',
quickCommandLabel: ' Run tests ',
generatedLabel: 'Gen',
label: 'Plain'
},
{
id: 'c',
entityId: 'ec',
customLabel: null,
quickCommandLabel: null,
generatedLabel: ' Gen ',
label: 'Plain'
},
{
id: 'd',
entityId: 'ed',
customLabel: null,
quickCommandLabel: null,
generatedLabel: null,
label: ' Plain '
}
]
} as unknown as AppState['unifiedTabsByWorktree']
})
expect(resolvePinnedTabLabel(state, 'wt-1', 'a')).toBe('Custom')
expect(resolvePinnedTabLabel(state, 'wt-1', 'b')).toBe('Run tests')
expect(resolvePinnedTabLabel(state, 'wt-1', 'ec')).toBe('Gen')
expect(resolvePinnedTabLabel(state, 'wt-1', 'ed')).toBe('Plain')
})
it('falls back to the live label when generated tab titles are disabled', () => {
const state = makeState({
settings: {
confirmClosePinnedTab: true,
tabAutoGenerateTitle: false
} as AppState['settings'],
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'a',
entityId: 'ea',
customLabel: null,
quickCommandLabel: null,
generatedLabel: 'Gen',
label: 'Plain'
}
]
} as unknown as AppState['unifiedTabsByWorktree']
})
expect(resolvePinnedTabLabel(state, 'wt-1', 'a')).toBe('Plain')
})
it('returns an empty string when the tab is not found', () => {
expect(resolvePinnedTabLabel(makeState({}), 'wt-1', 'missing')).toBe('')
})
})
describe('isUnifiedTabPinned', () => {
const state = makeState({
unifiedTabsByWorktree: {
'wt-1': [
{ id: 'uni-1', entityId: 'ent-1', isPinned: true },
{ id: 'uni-2', entityId: 'ent-2', isPinned: false }
]
} as unknown as AppState['unifiedTabsByWorktree']
})
it('matches a pinned tab by its unified id or entityId', () => {
expect(isUnifiedTabPinned(state, 'wt-1', 'uni-1')).toBe(true)
expect(isUnifiedTabPinned(state, 'wt-1', 'ent-1')).toBe(true)
})
it('returns false for unpinned or unknown tabs', () => {
expect(isUnifiedTabPinned(state, 'wt-1', 'uni-2')).toBe(false)
expect(isUnifiedTabPinned(state, 'wt-1', 'missing')).toBe(false)
expect(isUnifiedTabPinned(state, 'wt-unknown', 'uni-1')).toBe(false)
})
})
@@ -0,0 +1,54 @@
import { useAppStore } from '@/store'
import { resolveUnifiedTabLabel } from '../../../shared/tab-title-resolution'
import type { AppState } from './types'
/** Resolves the displayed tab-strip label for the destructive confirmation. */
export function resolvePinnedTabLabel(
state: AppState,
worktreeId: string,
visibleId: string
): string {
const tab = (state.unifiedTabsByWorktree?.[worktreeId] ?? []).find(
(candidate) => candidate.id === visibleId || candidate.entityId === visibleId
)
return resolveUnifiedTabLabel(tab, state.settings?.tabAutoGenerateTitle === true)
}
/** Whether the unified tab matching `tabId` (by id or entityId) in the given
* worktree is pinned. Used to let pin confirmation take precedence over the
* running-process close prompt. */
export function isUnifiedTabPinned(state: AppState, worktreeId: string, tabId: string): boolean {
return (state.unifiedTabsByWorktree?.[worktreeId] ?? []).some(
(tab) => (tab.id === tabId || tab.entityId === tabId) && tab.isPinned === true
)
}
/** Routes a pinned-tab close attempt through the confirmation dialog when the
* setting is on. Non-pinned tabs (and pinned tabs when the setting is off)
* close immediately. Keeping every close path behind this single helper is why
* the keyboard/native-menu paths can no longer silently drop a pinned tab. */
export function guardPinnedTabClose(params: {
isPinned: boolean
tabLabel: string
onClose: () => void
onCancel?: () => void
}): void {
const { isPinned, tabLabel, onClose, onCancel } = params
if (!isPinned) {
onClose()
return
}
const state = useAppStore.getState()
const shouldConfirm = state.settings?.confirmClosePinnedTab ?? true
if (!shouldConfirm) {
onClose()
return
}
state.requestPinnedTabCloseConfirm({
tabLabel,
onConfirm: onClose,
...(onCancel ? { onCancel } : {})
})
}
@@ -139,6 +139,7 @@ import { createWorkspaceCleanupSlice } from './workspace-cleanup'
import { createRuntimeStatusSlice } from './runtime-status'
import { createPullRequestGenerationSlice } from './pull-request-generation'
import { createCommitMessageGenerationSlice } from './commit-message-generation'
import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm'
function createTestStore() {
return create<AppState>()((...a) => ({
@@ -173,7 +174,8 @@ function createTestStore() {
...createWorkspaceCleanupSlice(...a),
...createRuntimeStatusSlice(...a),
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a)
...createCommitMessageGenerationSlice(...a),
...createPinnedTabCloseConfirmSlice(...a)
}))
}
@@ -0,0 +1,92 @@
import { create } from 'zustand'
import { describe, expect, it, vi } from 'vitest'
import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm'
import type { AppState } from '../types'
function makeStore() {
return create<
Pick<
AppState,
| 'pinnedTabCloseConfirm'
| 'requestPinnedTabCloseConfirm'
| 'confirmPinnedTabClose'
| 'dismissPinnedTabClose'
>
>()((...args) =>
createPinnedTabCloseConfirmSlice(
...(args as Parameters<typeof createPinnedTabCloseConfirmSlice>)
)
)
}
describe('createPinnedTabCloseConfirmSlice', () => {
it('starts with no pending request', () => {
expect(makeStore().getState().pinnedTabCloseConfirm).toBeNull()
})
it('stores the pending request when one is requested', () => {
const store = makeStore()
const onConfirm = vi.fn()
store.getState().requestPinnedTabCloseConfirm({ tabLabel: 'Docs', onConfirm })
expect(store.getState().pinnedTabCloseConfirm).toEqual({ tabLabel: 'Docs', onConfirm })
})
it('runs onConfirm and clears the request when confirmed', () => {
const store = makeStore()
const onConfirm = vi.fn()
store.getState().requestPinnedTabCloseConfirm({ tabLabel: 'Docs', onConfirm })
store.getState().confirmPinnedTabClose()
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(store.getState().pinnedTabCloseConfirm).toBeNull()
})
it('clears the request before running onConfirm so re-entrant closes do not loop', () => {
const store = makeStore()
const onConfirm = vi.fn(() => {
// Why: a close path may synchronously inspect the pending request; it must
// already be cleared by the time onConfirm runs.
expect(store.getState().pinnedTabCloseConfirm).toBeNull()
})
store.getState().requestPinnedTabCloseConfirm({ tabLabel: 'Docs', onConfirm })
store.getState().confirmPinnedTabClose()
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('does nothing when confirming with no pending request', () => {
const store = makeStore()
expect(() => store.getState().confirmPinnedTabClose()).not.toThrow()
expect(store.getState().pinnedTabCloseConfirm).toBeNull()
})
it('dismisses without running onConfirm', () => {
const store = makeStore()
const onConfirm = vi.fn()
store.getState().requestPinnedTabCloseConfirm({ tabLabel: 'Docs', onConfirm })
store.getState().dismissPinnedTabClose()
expect(onConfirm).not.toHaveBeenCalled()
expect(store.getState().pinnedTabCloseConfirm).toBeNull()
})
it('runs onCancel and clears the request when dismissed', () => {
const store = makeStore()
const onCancel = vi.fn()
store.getState().requestPinnedTabCloseConfirm({
tabLabel: 'Docs',
onConfirm: vi.fn(),
onCancel
})
store.getState().dismissPinnedTabClose()
expect(onCancel).toHaveBeenCalledTimes(1)
expect(store.getState().pinnedTabCloseConfirm).toBeNull()
})
})
@@ -0,0 +1,49 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
/** A pending request to confirm closing a pinned tab. `onConfirm` runs the
* original close once the user accepts; the label is shown in the dialog. */
export type PinnedTabCloseConfirmRequest = {
tabLabel: string
onConfirm: () => void
onCancel?: () => void
}
export type PinnedTabCloseConfirmSlice = {
pinnedTabCloseConfirm: PinnedTabCloseConfirmRequest | null
requestPinnedTabCloseConfirm: (request: PinnedTabCloseConfirmRequest) => void
confirmPinnedTabClose: () => void
dismissPinnedTabClose: () => void
}
export const createPinnedTabCloseConfirmSlice: StateCreator<
AppState,
[],
[],
PinnedTabCloseConfirmSlice
> = (set, get) => ({
pinnedTabCloseConfirm: null,
requestPinnedTabCloseConfirm: (request) => set({ pinnedTabCloseConfirm: request }),
confirmPinnedTabClose: () => {
const request = get().pinnedTabCloseConfirm
if (!request) {
return
}
// Why: clear before running onConfirm so a re-entrant close path can't see
// the stale request and re-open the dialog.
set({ pinnedTabCloseConfirm: null })
request.onConfirm()
},
dismissPinnedTabClose: () => {
const request = get().pinnedTabCloseConfirm
if (!request) {
return
}
// Why: CLI close requests wait for a response even when the user cancels.
set({ pinnedTabCloseConfirm: null })
request.onCancel?.()
}
})
@@ -40,6 +40,7 @@ import { createWorkspaceCleanupSlice } from './workspace-cleanup'
import { createRuntimeStatusSlice } from './runtime-status'
import { createPullRequestGenerationSlice } from './pull-request-generation'
import { createCommitMessageGenerationSlice } from './commit-message-generation'
import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm'
import { translate } from '@/i18n/i18n'
export const TEST_REPO = {
@@ -83,7 +84,8 @@ export function createTestStore() {
...createWorkspaceCleanupSlice(...a),
...createRuntimeStatusSlice(...a),
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a)
...createCommitMessageGenerationSlice(...a),
...createPinnedTabCloseConfirmSlice(...a)
}))
}
+3 -1
View File
@@ -30,6 +30,7 @@ import type { WorkspaceCleanupSlice } from './slices/workspace-cleanup'
import type { RuntimeStatusSlice } from './slices/runtime-status'
import type { PullRequestGenerationSlice } from './slices/pull-request-generation'
import type { CommitMessageGenerationSlice } from './slices/commit-message-generation'
import type { PinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm'
export type AppState = RepoSlice &
SparsePresetsSlice &
@@ -62,4 +63,5 @@ export type AppState = RepoSlice &
WorkspaceCleanupSlice &
RuntimeStatusSlice &
PullRequestGenerationSlice &
CommitMessageGenerationSlice
CommitMessageGenerationSlice &
PinnedTabCloseConfirmSlice
+4
View File
@@ -43,6 +43,10 @@ describe('getDefaultSettings', () => {
expect(getDefaultSettings('/tmp').uiLanguage).toBe('system')
})
it('confirms before closing pinned tabs by default', () => {
expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true)
})
it('enables Source Control AI by default without pinning a separate agent', () => {
expect(getDefaultSettings('/tmp').commitMessageAi).toMatchObject({
enabled: true,
+1
View File
@@ -310,6 +310,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
agentYoloDefaultsMigrated: true,
agentStatusHooksEnabled: true,
tabAutoGenerateTitle: false,
confirmClosePinnedTab: true,
keepComputerAwakeWhileAgentsRun: false,
// Why: 'auto' runs a layout-aware probe at boot (see
// src/renderer/src/lib/keyboard-layout/*) that picks 'true' for US and
+4
View File
@@ -2605,6 +2605,10 @@ export type GlobalSettings = {
/** Why: generated tab titles are semantic but subjective, so they stay opt-in
* and manual renames remain the stronger user intent. */
tabAutoGenerateTitle: boolean
/** Why: pinned tabs can still be closed via the keyboard/native-menu close
* path, so this gates that close behind a confirmation prompt to prevent
* accidental loss. Defaults on. */
confirmClosePinnedTab: boolean
/** When true, Orca requests local awake assertions while hook-reported agents are working. */
keepComputerAwakeWhileAgentsRun: boolean
/** Why: macOS terminals must choose between letting Option compose layout