diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 1431ca67a02..92b6f898738 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -125,6 +125,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, + confirmClosePinnedTab: true, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, terminalJISYenToBackslash: false, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 80e71ee72dc..577e0af28d9 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -129,6 +129,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, + confirmClosePinnedTab: true, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, terminalJISYenToBackslash: false, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0e5cf3a97af..1ec4d78566b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { + {/* 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 diff --git a/src/renderer/src/components/settings/GeneralPane.test.ts b/src/renderer/src/components/settings/GeneralPane.test.ts index 6a9badc0e60..ef05b730bcb 100644 --- a/src/renderer/src/components/settings/GeneralPane.test.ts +++ b/src/renderer/src/components/settings/GeneralPane.test.ts @@ -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() diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index ede32815049..264ca9c3306 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -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[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) ? (
[ - entry.title, - entry.description ?? '', - ...(entry.keywords ?? []) - ])} + keywords={tabOrderKeywords} updateSettings={updateSettings} /> + + + updateSettings({ confirmClosePinnedTab: !(settings.confirmClosePinnedTab ?? true) }) + } + /> +
) : null, matchesSettingsSearch(searchQuery, getGeneralWorkspaceSearchEntries()) ? ( diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index 32ba94ba7dc..6cc08767dc7 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -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') + ] } ]) diff --git a/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.test.tsx b/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.test.tsx new file mode 100644 index 00000000000..6162934ca3c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.test.tsx @@ -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 { + 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() + }) +} + +function getButton(label: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll('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('[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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.tsx b/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.tsx new file mode 100644 index 00000000000..d8b9ea64678 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.tsx @@ -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 ( + { + if (!isOpen) { + dismissPinnedTabClose() + } + }} + > + + + + {translate( + 'auto.components.terminal.pane.PinnedTabCloseDialog.6c190f295a', + 'Close pinned tab?' + )} + + + {translate( + 'auto.components.terminal.pane.PinnedTabCloseDialog.0d1963f4a6', + 'This tab is pinned. Are you sure you want to close it?' + )} + + + {tabLabel ? ( +

+ {tabLabel} +

+ ) : null} +
+ setDontAskAgain(checked === true)} + /> + +
+ + + + +
+
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index dcb0adbe25d..1b385056daa 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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 => { diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 5bf9a8d7b7a..4bcf72a9350 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -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() }} /> diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index f187c363468..9e80206fa5d 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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() diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index 4740bd7f1b6..995aeeaf6d4 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -267,6 +267,119 @@ describe('closeTerminalTab', () => { expect(closeWebRuntimeSessionTabMock).not.toHaveBeenCalled() expect(closeTab).toHaveBeenCalledWith('local-agent-tab') }) + + function makePinnedTabState( + overrides: { confirmClosePinnedTab: boolean } & Record + ): Record { + 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', () => { diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 639d7cd0624..dac79be45e6 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -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([ '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 } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 1b2ee8722ee..055490e6188 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -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 + requestTabCloseListenerRef?: { current: RequestTabCloseListener | null } + replyTabClose?: ReturnType + }): Promise { + vi.doMock('react', async () => { + const actual = await vi.importActual('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 () => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 8514fbac053..7ecae27ecb1 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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() } }) ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3ad404d2806..8892699bd7d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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." } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 218df43e153..da3c5132152 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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." } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9e9e7447a4f..e36066f876c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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." } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 137e57f97f4..9cefc11b3aa 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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." } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 49078d06db4..22421b19044 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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." } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 0e43297c579..df81ab4f36e 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -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()((...a) => ({ ...createWorkspaceCleanupSlice(...a), ...createRuntimeStatusSlice(...a), ...createPullRequestGenerationSlice(...a), - ...createCommitMessageGenerationSlice(...a) + ...createCommitMessageGenerationSlice(...a), + ...createPinnedTabCloseConfirmSlice(...a) })) registerHttpLinkStoreAccessor(() => useAppStore.getState()) diff --git a/src/renderer/src/store/pinned-tab-close-guard.test.ts b/src/renderer/src/store/pinned-tab-close-guard.test.ts new file mode 100644 index 00000000000..16a9931b6db --- /dev/null +++ b/src/renderer/src/store/pinned-tab-close-guard.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/renderer/src/store/pinned-tab-close-guard.ts b/src/renderer/src/store/pinned-tab-close-guard.ts new file mode 100644 index 00000000000..9603abd60f1 --- /dev/null +++ b/src/renderer/src/store/pinned-tab-close-guard.ts @@ -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 } : {}) + }) +} diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 198ec1bd38c..dbb200d89a0 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -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()((...a) => ({ @@ -173,7 +174,8 @@ function createTestStore() { ...createWorkspaceCleanupSlice(...a), ...createRuntimeStatusSlice(...a), ...createPullRequestGenerationSlice(...a), - ...createCommitMessageGenerationSlice(...a) + ...createCommitMessageGenerationSlice(...a), + ...createPinnedTabCloseConfirmSlice(...a) })) } diff --git a/src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts b/src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts new file mode 100644 index 00000000000..50f70d178f0 --- /dev/null +++ b/src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts @@ -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) + ) + ) +} + +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() + }) +}) diff --git a/src/renderer/src/store/slices/pinned-tab-close-confirm.ts b/src/renderer/src/store/slices/pinned-tab-close-confirm.ts new file mode 100644 index 00000000000..58f257298a3 --- /dev/null +++ b/src/renderer/src/store/slices/pinned-tab-close-confirm.ts @@ -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?.() + } +}) diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 34fac9864db..9045837ae3f 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -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) })) } diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index 39d264fb11f..ad1a7089d8c 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -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 diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 6c33a358ff8..62e3467021a 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -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, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 376a86541af..510652d0bd9 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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 diff --git a/src/shared/types.ts b/src/shared/types.ts index cb2acf5804c..a798f8f1e8c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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