diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx
index 335d5f15308..98ecd4a8930 100644
--- a/src/renderer/src/components/Terminal.tsx
+++ b/src/renderer/src/components/Terminal.tsx
@@ -1068,9 +1068,12 @@ function Terminal(): React.JSX.Element | null {
if (consumeSuppressedPtyExit(ptyId)) {
return
}
- handleCloseTab(tabId)
+ // Why: a pty exiting on its own is not a user close — it must not join
+ // the Cmd+Shift+T reopen stack (mirrors TerminalPaneOverlayLayer's
+ // pty-exit/user-close split).
+ closeTerminalTab(tabId, { captureRecentlyClosed: false })
},
- [consumeSuppressedPtyExit, handleCloseTab]
+ [consumeSuppressedPtyExit]
)
const handleCloseOthers = useCallback(
@@ -1357,20 +1360,13 @@ function Terminal(): React.JSX.Element | null {
}
}
- // Cmd/Ctrl+Shift+T — reopen closed browser tab when browser is active,
- // otherwise reopen the most recently closed editor tab.
+ // Cmd/Ctrl+Shift+T — reopen the most recently closed tab of any kind
+ // (terminal, browser, or editor), Chrome/Ghostty-style. Repeated presses
+ // walk back through the close history.
if (!e.repeat && matchShortcut('tab.reopenClosed')) {
e.preventDefault()
notifyTerminalCapture('tab.reopenClosed')
- const state = useAppStore.getState()
- if (state.activeTabType === 'browser') {
- const restored = state.reopenClosedBrowserTab(activeWorktreeId)
- if (restored === null) {
- state.reopenClosedEditorTab(activeWorktreeId)
- }
- } else {
- state.reopenClosedEditorTab(activeWorktreeId)
- }
+ useAppStore.getState().reopenClosedTab(activeWorktreeId)
return
}
diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
index 8fa5e6a5258..112e06ab188 100644
--- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
+++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
@@ -766,7 +766,7 @@ export function FloatingTerminalPanel({
const dirtyEditorFileIds: string[] = []
for (const item of items) {
if (item.contentType === 'terminal') {
- closeTab(item.entityId)
+ closeTab(item.entityId, { captureRecentlyClosed: false })
} else if (item.contentType === 'browser') {
destroyWorkspaceWebviews(state.browserPagesByWorkspace, item.entityId)
closeBrowserTab(item.entityId)
@@ -1537,7 +1537,7 @@ export function FloatingTerminalPanel({
// atlas to corrupt) while hidden, and the resume on
// reopen rebuilds the renderer from scratch.
isVisible={isActive && open}
- onPtyExit={() => closeTab(tab.id)}
+ onPtyExit={() => closeTab(tab.id, { captureRecentlyClosed: false })}
onCloseTab={() => closeFloatingItem(tab.id)}
/>
diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx
index 9f7c8da1398..6639381685c 100644
--- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx
+++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx
@@ -103,7 +103,7 @@ export function OnboardingInlineCommandTerminal({
return () => {
// Why: inline setup panels can disappear after detection succeeds; close
// the backing tab so installer shells do not keep running invisibly.
- closeTab(tab.id, { recordInteraction: false })
+ closeTab(tab.id, { recordInteraction: false, captureRecentlyClosed: false })
}
}, [
closeTab,
@@ -296,9 +296,11 @@ export function OnboardingInlineCommandTerminal({
isVisible
onPtyExit={() => {
onTerminalExit?.()
- closeTab(tabId, { recordInteraction: false })
+ closeTab(tabId, { recordInteraction: false, captureRecentlyClosed: false })
}}
- onCloseTab={() => closeTab(tabId, { recordInteraction: false })}
+ onCloseTab={() =>
+ closeTab(tabId, { recordInteraction: false, captureRecentlyClosed: false })
+ }
/>
) : (
diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx
index 83defe0e715..0dcbe538f8b 100644
--- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx
+++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx
@@ -54,7 +54,7 @@ import { translate } from '@/i18n/i18n'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab'
import type { RightSidebarExplorerView } from '../../../../shared/types'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
-import { createNewTerminalTab } from '@/components/terminal/terminal-tab-actions'
+import { createNewTerminalTab } from '@/components/terminal/terminal-tab-create'
function FileExplorerFiles(): React.JSX.Element {
const explorerView = useAppStore((s) => s.rightSidebarExplorerView)
diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
index 0e68af9126f..3b18f333ed0 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
@@ -58,7 +58,7 @@ type TerminalOverlaySlotProps = {
activityTerminalPortal: ActivityTerminalPortalTarget | null
onFocusOwningGroup: ((groupId: string) => void) | undefined
consumeSuppressedPtyExit: (ptyId: string) => boolean
- closeTab: (tabId: string) => void
+ closeTab: (tabId: string, opts?: { captureRecentlyClosed?: boolean }) => void
leaveWorktreeIfEmpty: () => void
}
@@ -237,7 +237,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
if (consumeSuppressedPtyExit(ptyId)) {
return
}
- closeTab(terminalTabId)
+ closeTab(terminalTabId, { captureRecentlyClosed: false })
leaveWorktreeIfEmpty()
}}
onCloseTab={() => {
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 a070cdc9331..288f3d7788c 100644
--- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts
+++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts
@@ -42,9 +42,9 @@ vi.mock('@/runtime/web-session-tabs-sync', () => ({
import {
closeOtherTerminalTabs,
closeTerminalTab,
- closeTerminalTabsToRight,
- createNewTerminalTab
+ closeTerminalTabsToRight
} from './terminal-tab-actions'
+import { createNewTerminalTab } from './terminal-tab-create'
describe('createNewTerminalTab', () => {
beforeEach(() => {
@@ -439,6 +439,50 @@ describe('closeTerminalTab', () => {
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-pinned-1')
})
+
+ it('threads captureRecentlyClosed:false through to closeTab for pty-exit closes', () => {
+ const closeTab = vi.fn()
+ getStateMock.mockReturnValue({
+ settings: { activeRuntimeEnvironmentId: null },
+ tabsByWorktree: {
+ 'wt-1': [{ id: 'tab-1' }, { id: 'tab-2' }]
+ },
+ unifiedTabsByWorktree: {},
+ activeWorktreeId: 'wt-1',
+ activeTabId: 'tab-2',
+ openFiles: [],
+ browserTabsByWorktree: {},
+ closeTab,
+ setActiveTab: vi.fn()
+ })
+
+ // Why: the legacy no-layout surface routes pty exits through
+ // closeTerminalTab; a self-exited shell must not join the reopen stack.
+ closeTerminalTab('tab-1', { captureRecentlyClosed: false })
+
+ expect(closeTab).toHaveBeenCalledWith('tab-1', { captureRecentlyClosed: false })
+ })
+
+ it('keeps the plain user-close call shape when no capture option is given', () => {
+ const closeTab = vi.fn()
+ getStateMock.mockReturnValue({
+ settings: { activeRuntimeEnvironmentId: null },
+ tabsByWorktree: {
+ 'wt-1': [{ id: 'tab-1' }, { id: 'tab-2' }]
+ },
+ unifiedTabsByWorktree: {},
+ activeWorktreeId: 'wt-1',
+ activeTabId: 'tab-2',
+ openFiles: [],
+ browserTabsByWorktree: {},
+ closeTab,
+ setActiveTab: vi.fn()
+ })
+
+ closeTerminalTab('tab-1')
+
+ expect(closeTab).toHaveBeenCalledWith('tab-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 9991b7648ea..d6e0932548f 100644
--- a/src/renderer/src/components/terminal/terminal-tab-actions.ts
+++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts
@@ -5,7 +5,6 @@ import { reconcileTabOrder } from '../tab-bar/reconcile-order'
import {
activateWebRuntimeSessionTab,
closeWebRuntimeSessionTab,
- createWebRuntimeSessionTerminal,
isWebRuntimeSessionActive,
toHostSessionTabId
} from '@/runtime/web-runtime-session'
@@ -65,12 +64,19 @@ function getWorktreeTerminalTabIds(state: TerminalTabActionState, worktreeId: st
return [...ids]
}
-function closeLocalTerminalTabState(terminalTabId: string): void {
+function closeLocalTerminalTabState(
+ terminalTabId: string,
+ opts?: { captureRecentlyClosed?: boolean }
+): void {
const state = useAppStore.getState()
if (
Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tab.id === terminalTabId))
) {
- state.closeTab(terminalTabId)
+ if (opts) {
+ state.closeTab(terminalTabId, opts)
+ } else {
+ state.closeTab(terminalTabId)
+ }
return
}
@@ -99,63 +105,20 @@ function isPinnedVisibleTab(
)
}
-export function createNewTerminalTab(
- activeWorktreeId: string | null,
- shellOverride?: string,
- options?: { startupCwd?: string }
+export function closeTerminalTab(
+ tabId: string,
+ options?: { force?: boolean; captureRecentlyClosed?: boolean }
): void {
- if (!activeWorktreeId) {
- return
- }
- const state = useAppStore.getState()
- const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId)
- if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
- // Why: paired web clients receive host-owned terminal tabs through
- // session.tabs. Creating a local tab first races the host snapshot and can
- // leave stale remote handles in the web store.
- void createWebRuntimeSessionTerminal({
- worktreeId: activeWorktreeId,
- environmentId: runtimeEnvironmentId,
- command: shellOverride,
- ...(options?.startupCwd ? { cwd: options.startupCwd } : {}),
- activate: true
- })
- return
- }
- const newTab = state.createTab(
- activeWorktreeId,
- undefined,
- shellOverride,
- options?.startupCwd ? { startupCwd: options.startupCwd } : undefined
- )
- state.setActiveTabType('terminal')
- // Why: persist the tab bar order with the new terminal at the end of the
- // current visual order. Without this, reconcileTabOrder falls back to
- // terminals-first when tabBarOrderByWorktree is unset, causing a new
- // terminal to jump to index 0 instead of appending after editor tabs.
- const freshState = useAppStore.getState()
- const termIds = (freshState.tabsByWorktree[activeWorktreeId] ?? []).map((t) => t.id)
- const editorIds = freshState.openFiles
- .filter((f) => f.worktreeId === activeWorktreeId)
- .map((f) => f.id)
- const base = reconcileTabOrder(
- freshState.tabBarOrderByWorktree[activeWorktreeId],
- termIds,
- editorIds
- )
- // The new tab is already in base via termIds; move it to the end
- const order = base.filter((id) => id !== newTab.id)
- order.push(newTab.id)
- state.setTabBarOrder(activeWorktreeId, order)
-}
-
-export function closeTerminalTab(tabId: string, options?: { force?: boolean }): void {
const state = useAppStore.getState()
const target = resolveCloseTerminalTabTarget(state, tabId)
if (!target) {
return
}
const { worktreeId: owningWorktreeId, terminalTabId } = target
+ const captureOpts =
+ options?.captureRecentlyClosed === undefined
+ ? undefined
+ : { captureRecentlyClosed: options.captureRecentlyClosed }
// Why: a pinned tab routes through the confirmation guard instead of closing
// outright. `force` is the post-confirmation re-entry, which skips the guard.
@@ -163,7 +126,7 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
guardPinnedTabClose({
isPinned: true,
tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId),
- onClose: () => closeTerminalTab(tabId, { force: true })
+ onClose: () => closeTerminalTab(tabId, { ...captureOpts, force: true })
})
return
}
@@ -187,7 +150,7 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
}) ?? toHostSessionTabId(terminalTabId)
// Why: prune local mirrors immediately so close feels responsive while the
// host session snapshot catches up.
- closeLocalTerminalTabState(terminalTabId)
+ closeLocalTerminalTabState(terminalTabId, captureOpts)
void closeWebRuntimeSessionTab({
worktreeId: owningWorktreeId,
tabId: hostBackedTabId,
@@ -198,7 +161,7 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
const currentTerminalTabIds = getWorktreeTerminalTabIds(state, owningWorktreeId)
if (currentTerminalTabIds.length <= 1) {
- closeLocalTerminalTabState(terminalTabId)
+ closeLocalTerminalTabState(terminalTabId, captureOpts)
if (state.activeWorktreeId === owningWorktreeId) {
// Why: only deactivate the worktree when no tabs of any kind remain.
// Editor files are a separate tab type; closing the last terminal tab
@@ -229,7 +192,7 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
}
}
- closeLocalTerminalTabState(terminalTabId)
+ closeLocalTerminalTabState(terminalTabId, captureOpts)
}
export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | null): void {
diff --git a/src/renderer/src/components/terminal/terminal-tab-create.ts b/src/renderer/src/components/terminal/terminal-tab-create.ts
new file mode 100644
index 00000000000..ed54255b3fc
--- /dev/null
+++ b/src/renderer/src/components/terminal/terminal-tab-create.ts
@@ -0,0 +1,57 @@
+import { useAppStore } from '@/store'
+import { reconcileTabOrder } from '../tab-bar/reconcile-order'
+import {
+ createWebRuntimeSessionTerminal,
+ isWebRuntimeSessionActive
+} from '@/runtime/web-runtime-session'
+import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
+
+export function createNewTerminalTab(
+ activeWorktreeId: string | null,
+ shellOverride?: string,
+ options?: { startupCwd?: string }
+): void {
+ if (!activeWorktreeId) {
+ return
+ }
+ const state = useAppStore.getState()
+ const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId)
+ if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
+ // Why: paired web clients receive host-owned terminal tabs through
+ // session.tabs. Creating a local tab first races the host snapshot and can
+ // leave stale remote handles in the web store.
+ void createWebRuntimeSessionTerminal({
+ worktreeId: activeWorktreeId,
+ environmentId: runtimeEnvironmentId,
+ command: shellOverride,
+ ...(options?.startupCwd ? { cwd: options.startupCwd } : {}),
+ activate: true
+ })
+ return
+ }
+ const newTab = state.createTab(
+ activeWorktreeId,
+ undefined,
+ shellOverride,
+ options?.startupCwd ? { startupCwd: options.startupCwd } : undefined
+ )
+ state.setActiveTabType('terminal')
+ // Why: persist the tab bar order with the new terminal at the end of the
+ // current visual order. Without this, reconcileTabOrder falls back to
+ // terminals-first when tabBarOrderByWorktree is unset, causing a new
+ // terminal to jump to index 0 instead of appending after editor tabs.
+ const freshState = useAppStore.getState()
+ const termIds = (freshState.tabsByWorktree[activeWorktreeId] ?? []).map((t) => t.id)
+ const editorIds = freshState.openFiles
+ .filter((f) => f.worktreeId === activeWorktreeId)
+ .map((f) => f.id)
+ const base = reconcileTabOrder(
+ freshState.tabBarOrderByWorktree[activeWorktreeId],
+ termIds,
+ editorIds
+ )
+ // The new tab is already in base via termIds; move it to the end
+ const order = base.filter((id) => id !== newTab.id)
+ order.push(newTab.id)
+ state.setTabBarOrder(activeWorktreeId, order)
+}
diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts
index 389c6972cf1..600c9a9ce30 100644
--- a/src/renderer/src/lib/launch-agent-background-session.ts
+++ b/src/renderer/src/lib/launch-agent-background-session.ts
@@ -221,7 +221,7 @@ export async function launchAgentBackgroundSession(
}
}
} catch (error) {
- store.closeTab(tab.id, { recordInteraction: false })
+ store.closeTab(tab.id, { recordInteraction: false, captureRecentlyClosed: false })
throw error
}
store.updateTabPtyId(tab.id, ptyId)
diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts
index 41ded040d48..0b73ed67a37 100644
--- a/src/renderer/src/lib/launch-agent-in-new-tab.ts
+++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts
@@ -59,7 +59,7 @@ function removeStaleLocalAgentTabsForWebHostLaunch(worktreeId: string): void {
const state = useAppStore.getState()
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
if (tab.launchAgent && !isWebTerminalSurfaceTabId(tab.id)) {
- state.closeTab(tab.id)
+ state.closeTab(tab.id, { captureRecentlyClosed: false })
}
}
}
diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts
index 896da11f8e5..cb6c3cbf797 100644
--- a/src/renderer/src/lib/launch-worktree-background-terminals.ts
+++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts
@@ -177,7 +177,7 @@ async function createBackgroundTab(args: {
env: args.launch.env
})
} catch (error) {
- store.closeTab(tab.id, { recordInteraction: false })
+ store.closeTab(tab.id, { recordInteraction: false, captureRecentlyClosed: false })
throw error
}
store.updateTabPtyId(tab.id, ptyId)
diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts
index df81ab4f36e..d5d59a1ae2f 100644
--- a/src/renderer/src/store/index.ts
+++ b/src/renderer/src/store/index.ts
@@ -33,6 +33,7 @@ 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 { createRecentlyClosedTabsSlice } from './slices/recently-closed-tabs'
import { e2eConfig } from '@/lib/e2e-config'
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
@@ -69,7 +70,8 @@ export const useAppStore = create
()((...a) => ({
...createRuntimeStatusSlice(...a),
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a),
- ...createPinnedTabCloseConfirmSlice(...a)
+ ...createPinnedTabCloseConfirmSlice(...a),
+ ...createRecentlyClosedTabsSlice(...a)
}))
registerHttpLinkStoreAccessor(() => useAppStore.getState())
diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts
index 53d07f58127..ccb007d2f56 100644
--- a/src/renderer/src/store/slices/browser.ts
+++ b/src/renderer/src/store/slices/browser.ts
@@ -23,6 +23,7 @@ import {
} from '../../../../shared/workspace-session-browser-history'
import { pickNeighbor } from './tab-group-state'
import { destroyWorkspaceWebviews } from './browser-webview-cleanup'
+import { pushRecentlyClosedTabKind } from './recently-closed-tabs'
import {
callRuntimeRpc,
getActiveRuntimeTarget,
@@ -719,6 +720,11 @@ export const createBrowserSlice: StateCreator =
{ workspace: closedWorkspace, pages: closedPages },
...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id)
].slice(0, 10)
+ const nextRecentlyClosedTabKindsByWorktree = pushRecentlyClosedTabKind(
+ s.recentlyClosedTabKindsByWorktree,
+ owningWorktreeId,
+ 'browser'
+ )
const nextRecentlyClosedBrowserPagesByWorkspace = {
...s.recentlyClosedBrowserPagesByWorkspace
@@ -750,6 +756,7 @@ export const createBrowserSlice: StateCreator =
pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree,
+ recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree,
recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace,
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId
diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts
index dbb200d89a0..1b6cc5e1ec7 100644
--- a/src/renderer/src/store/slices/diffComments.test.ts
+++ b/src/renderer/src/store/slices/diffComments.test.ts
@@ -140,6 +140,7 @@ 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 { createRecentlyClosedTabsSlice } from './recently-closed-tabs'
function createTestStore() {
return create()((...a) => ({
@@ -175,7 +176,8 @@ function createTestStore() {
...createRuntimeStatusSlice(...a),
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a),
- ...createPinnedTabCloseConfirmSlice(...a)
+ ...createPinnedTabCloseConfirmSlice(...a),
+ ...createRecentlyClosedTabsSlice(...a)
}))
}
diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts
index 54013b30a1f..9f25302ff66 100644
--- a/src/renderer/src/store/slices/editor.ts
+++ b/src/renderer/src/store/slices/editor.ts
@@ -1,6 +1,7 @@
/* eslint-disable max-lines */
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
+import { pushRecentlyClosedTabKind } from './recently-closed-tabs'
import { joinPath } from '@/lib/path'
import { toast } from 'sonner'
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
@@ -1698,6 +1699,7 @@ export const createEditorSlice: StateCreator = (s
// recordReplacedPreview so file-explorer single-click (which
// semantically *wants* silent eviction) is unaffected.
let nextRecentlyClosed = s.recentlyClosedEditorTabsByWorktree
+ let nextRecentlyClosedKinds = s.recentlyClosedTabKindsByWorktree
if (recordReplacedPreview && replacedPreview.id !== id) {
const {
id: _rid,
@@ -1713,6 +1715,11 @@ export const createEditorSlice: StateCreator = (s
MAX_RECENT_CLOSED_EDITOR_TABS
)
}
+ nextRecentlyClosedKinds = pushRecentlyClosedTabKind(
+ s.recentlyClosedTabKindsByWorktree,
+ worktreeId,
+ 'editor'
+ )
}
return {
openFiles: newFiles,
@@ -1722,6 +1729,7 @@ export const createEditorSlice: StateCreator = (s
editorViewMode: nextEditorViewMode,
markdownFrontmatterVisible: nextMarkdownFrontmatterVisible,
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed,
+ recentlyClosedTabKindsByWorktree: nextRecentlyClosedKinds,
...previewTabBarUpdate,
...activeResult
}
@@ -2064,6 +2072,7 @@ export const createEditorSlice: StateCreator = (s
: s.tabBarOrderByWorktree
let nextRecentlyClosed = s.recentlyClosedEditorTabsByWorktree
+ let nextRecentlyClosedKinds = s.recentlyClosedTabKindsByWorktree
const wtRecent = closedFile?.worktreeId
// Why: untitled files that were never edited will be deleted from disk
// after close. Adding them to the reopen stack would let Cmd+Shift+T
@@ -2089,6 +2098,11 @@ export const createEditorSlice: StateCreator = (s
MAX_RECENT_CLOSED_EDITOR_TABS
)
}
+ nextRecentlyClosedKinds = pushRecentlyClosedTabKind(
+ s.recentlyClosedTabKindsByWorktree,
+ wtRecent,
+ 'editor'
+ )
}
return {
@@ -2114,7 +2128,8 @@ export const createEditorSlice: StateCreator = (s
markdownFrontmatterVisible: newMarkdownFrontmatterVisible,
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
pendingEditorReveal: null,
- recentlyClosedEditorTabsByWorktree: nextRecentlyClosed
+ recentlyClosedEditorTabsByWorktree: nextRecentlyClosed,
+ recentlyClosedTabKindsByWorktree: nextRecentlyClosedKinds
}
})
@@ -2258,6 +2273,7 @@ export const createEditorSlice: StateCreator = (s
const closingFiles = s.openFiles.filter((f) => f.worktreeId === activeWorktreeId)
let nextRecentClosed = s.recentlyClosedEditorTabsByWorktree[activeWorktreeId] ?? []
+ let capturedCloseCount = 0
for (const f of [...closingFiles].toReversed()) {
// Why: untitled non-dirty files are deleted from disk after close —
// skip them so the reopen stack doesn't reference vanished paths.
@@ -2273,6 +2289,7 @@ export const createEditorSlice: StateCreator = (s
0,
MAX_RECENT_CLOSED_EDITOR_TABS
)
+ capturedCloseCount += 1
}
return {
@@ -2306,7 +2323,13 @@ export const createEditorSlice: StateCreator = (s
recentlyClosedEditorTabsByWorktree: {
...s.recentlyClosedEditorTabsByWorktree,
[activeWorktreeId]: nextRecentClosed
- }
+ },
+ recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind(
+ s.recentlyClosedTabKindsByWorktree,
+ activeWorktreeId,
+ 'editor',
+ capturedCloseCount
+ )
}
})
if (typeof window !== 'undefined') {
diff --git a/src/renderer/src/store/slices/recently-closed-tabs.test.ts b/src/renderer/src/store/slices/recently-closed-tabs.test.ts
new file mode 100644
index 00000000000..ac76ca0a6a7
--- /dev/null
+++ b/src/renderer/src/store/slices/recently-closed-tabs.test.ts
@@ -0,0 +1,192 @@
+/**
+ * Cmd/Ctrl+Shift+T reopen behavior: terminal tabs join the recently-closed
+ * stacks, and the unified dispatcher pops the most recently closed tab of any
+ * kind (terminal/browser/editor) in true cross-type MRU order.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import type * as AgentStatusModule from '@/lib/agent-status'
+
+vi.mock('sonner', () => ({
+ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
+}))
+
+vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
+ restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
+ unregisterPtyDataHandlers: vi.fn()
+}))
+
+vi.mock('@/lib/agent-status', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
+})
+
+const mockApi = {
+ worktrees: {
+ list: vi.fn().mockResolvedValue([]),
+ remove: vi.fn().mockResolvedValue(undefined),
+ updateMeta: vi.fn().mockResolvedValue({})
+ },
+ pty: { kill: vi.fn().mockResolvedValue(undefined) },
+ runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) }
+}
+
+// @ts-expect-error -- minimal window.api stub for the store under test
+globalThis.window = { api: mockApi }
+
+import { createTestStore, seedStore, makeWorktree, makeOpenFile } from './store-test-helpers'
+
+const WT = 'repo1::/path/wt1'
+
+function makeSeededStore(): ReturnType {
+ const store = createTestStore()
+ seedStore(store, {
+ worktreesByRepo: {
+ repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })]
+ },
+ activeWorktreeId: WT
+ })
+ return store
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('terminal recently-closed capture', () => {
+ it('captures a snapshot and kind entry on user close', () => {
+ const store = makeSeededStore()
+ const tab = store
+ .getState()
+ .createTab(WT, undefined, undefined, { startupCwd: '/path/wt1/packages/app' })
+ store.getState().setTabCustomTitle(tab.id, 'build shell')
+ store.getState().setTabColor(tab.id, '#ff0000')
+
+ store.getState().closeTab(tab.id)
+
+ expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toEqual([
+ { startupCwd: '/path/wt1/packages/app', customTitle: 'build shell', color: '#ff0000' }
+ ])
+ expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual(['terminal'])
+ })
+
+ it('skips capture when captureRecentlyClosed is false (system closes)', () => {
+ const store = makeSeededStore()
+ const tab = store.getState().createTab(WT)
+
+ store.getState().closeTab(tab.id, { captureRecentlyClosed: false })
+
+ expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toBeUndefined()
+ expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toBeUndefined()
+ })
+
+ it('caps the terminal stack at 10 snapshots, newest first', () => {
+ const store = makeSeededStore()
+ for (let i = 0; i < 12; i++) {
+ const tab = store
+ .getState()
+ .createTab(WT, undefined, undefined, { startupCwd: `/path/wt1/dir-${i}` })
+ store.getState().closeTab(tab.id)
+ }
+
+ const stack = store.getState().recentlyClosedTerminalTabsByWorktree[WT]
+ expect(stack).toHaveLength(10)
+ expect(stack[0]?.startupCwd).toBe('/path/wt1/dir-11')
+ expect(stack[9]?.startupCwd).toBe('/path/wt1/dir-2')
+ })
+})
+
+describe('reopenClosedTerminalTab', () => {
+ it('recreates a fresh terminal with the snapshot cwd, shell, title, and color', () => {
+ const store = makeSeededStore()
+ const tab = store
+ .getState()
+ .createTab(WT, undefined, 'zsh', { startupCwd: '/path/wt1/packages/app' })
+ store.getState().setTabCustomTitle(tab.id, 'build shell')
+ store.getState().setTabColor(tab.id, '#ff0000')
+ store.getState().closeTab(tab.id)
+ expect(store.getState().tabsByWorktree[WT]).toHaveLength(0)
+
+ expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true)
+
+ const restored = store.getState().tabsByWorktree[WT]
+ expect(restored).toHaveLength(1)
+ expect(restored[0]).toMatchObject({
+ startupCwd: '/path/wt1/packages/app',
+ shellOverride: 'zsh',
+ customTitle: 'build shell',
+ color: '#ff0000'
+ })
+ // Why: a reopened tab is a fresh surface — never the old PTY.
+ expect(restored[0].id).not.toBe(tab.id)
+ expect(restored[0].ptyId).toBeNull()
+ expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toHaveLength(0)
+ })
+
+ it('returns false when the stack is empty (no double-restore)', () => {
+ const store = makeSeededStore()
+ const tab = store.getState().createTab(WT)
+ store.getState().closeTab(tab.id)
+
+ expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true)
+ expect(store.getState().reopenClosedTerminalTab(WT)).toBe(false)
+ expect(store.getState().tabsByWorktree[WT]).toHaveLength(1)
+ })
+})
+
+describe('reopenClosedTab cross-type MRU', () => {
+ it('reopens closed tabs of mixed kinds in most-recent-first order', () => {
+ const store = makeSeededStore()
+
+ // Close order: editor → terminal → browser.
+ store.setState({ openFiles: [makeOpenFile({ id: '/repo/a.ts', worktreeId: WT })] })
+ store.getState().closeFile('/repo/a.ts')
+ const terminal = store
+ .getState()
+ .createTab(WT, undefined, undefined, { startupCwd: '/path/wt1/api' })
+ store.getState().closeTab(terminal.id)
+ const browser = store.getState().createBrowserTab(WT, 'https://example.com', { title: 'Ex' })
+ store.getState().closeBrowserTab(browser.id)
+
+ expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual([
+ 'browser',
+ 'terminal',
+ 'editor'
+ ])
+
+ // First press: browser comes back.
+ expect(store.getState().reopenClosedTab(WT)).toBe(true)
+ expect(store.getState().browserTabsByWorktree[WT]?.[0]?.url).toBe('https://example.com')
+ expect(store.getState().tabsByWorktree[WT] ?? []).toHaveLength(0)
+
+ // Second press: terminal comes back.
+ expect(store.getState().reopenClosedTab(WT)).toBe(true)
+ expect(store.getState().tabsByWorktree[WT]?.[0]?.startupCwd).toBe('/path/wt1/api')
+ expect(store.getState().openFiles).toHaveLength(0)
+
+ // Third press: editor comes back.
+ expect(store.getState().reopenClosedTab(WT)).toBe(true)
+ expect(store.getState().openFiles[0]?.filePath).toBe('/repo/a.ts')
+
+ // Nothing left.
+ expect(store.getState().reopenClosedTab(WT)).toBe(false)
+ })
+
+ it('skips kind entries whose per-type snapshot has drained', () => {
+ const store = makeSeededStore()
+ store.setState({
+ recentlyClosedTabKindsByWorktree: { [WT]: ['browser', 'terminal'] },
+ recentlyClosedTerminalTabsByWorktree: { [WT]: [{ startupCwd: '/path/wt1' }] },
+ recentlyClosedBrowserTabsByWorktree: { [WT]: [] }
+ })
+
+ expect(store.getState().reopenClosedTab(WT)).toBe(true)
+
+ expect(store.getState().tabsByWorktree[WT]?.[0]?.startupCwd).toBe('/path/wt1')
+ expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual([])
+ })
+
+ it('returns false when no tab was ever closed', () => {
+ const store = makeSeededStore()
+ expect(store.getState().reopenClosedTab(WT)).toBe(false)
+ })
+})
diff --git a/src/renderer/src/store/slices/recently-closed-tabs.ts b/src/renderer/src/store/slices/recently-closed-tabs.ts
new file mode 100644
index 00000000000..d861207830d
--- /dev/null
+++ b/src/renderer/src/store/slices/recently-closed-tabs.ts
@@ -0,0 +1,150 @@
+import type { StateCreator } from 'zustand'
+import type { AppState } from '../types'
+
+/** Snapshot of a terminal tab captured at user-initiated close time. Reopen
+ * recreates a fresh shell in the same startup directory (Ghostty semantics) —
+ * never the old PTY, scrollback, or a relaunched agent session. */
+export type ClosedTerminalTabSnapshot = {
+ startupCwd?: string
+ shellOverride?: string
+ customTitle?: string
+ color?: string
+}
+
+export type RecentlyClosedTabKind = 'terminal' | 'browser' | 'editor'
+
+const MAX_RECENT_CLOSED_TERMINAL_TABS = 10
+// Why: wider than the per-type stacks (10) so cross-type ordering survives a
+// full per-type stack; kind entries whose snapshot aged out are skipped on pop.
+const MAX_RECENT_CLOSED_TAB_KINDS = 30
+
+// Why: the map params tolerate undefined because several test harnesses build
+// partial stores (single-slice spreads) that lack this slice's state.
+export function pushClosedTerminalTabSnapshot(
+ map: Record | undefined,
+ worktreeId: string,
+ snapshot: ClosedTerminalTabSnapshot
+): Record {
+ return {
+ ...map,
+ [worktreeId]: [snapshot, ...(map?.[worktreeId] ?? [])].slice(0, MAX_RECENT_CLOSED_TERMINAL_TABS)
+ }
+}
+
+export function pushRecentlyClosedTabKind(
+ map: Record | undefined,
+ worktreeId: string,
+ kind: RecentlyClosedTabKind,
+ count = 1
+): Record {
+ // Why: preserve the original reference on no-op pushes so unrelated
+ // subscribers don't re-evaluate (mirrors the closeTab unread-map pattern).
+ if (count <= 0) {
+ return map ?? {}
+ }
+ return {
+ ...map,
+ [worktreeId]: [
+ ...(Array(count).fill(kind) as RecentlyClosedTabKind[]),
+ ...(map?.[worktreeId] ?? [])
+ ].slice(0, MAX_RECENT_CLOSED_TAB_KINDS)
+ }
+}
+
+export type RecentlyClosedTabsSlice = {
+ /** Newest-first snapshots of user-closed terminal tabs, per worktree. */
+ recentlyClosedTerminalTabsByWorktree: Record
+ /** Newest-first close order across the terminal/browser/editor reopen stacks
+ * so Cmd+Shift+T pops true cross-type MRU (Chrome/Ghostty semantics). */
+ recentlyClosedTabKindsByWorktree: Record
+ reopenClosedTerminalTab: (worktreeId: string) => boolean
+ reopenClosedTab: (worktreeId: string) => boolean
+}
+
+export const createRecentlyClosedTabsSlice: StateCreator<
+ AppState,
+ [],
+ [],
+ RecentlyClosedTabsSlice
+> = (set, get) => ({
+ recentlyClosedTerminalTabsByWorktree: {},
+ recentlyClosedTabKindsByWorktree: {},
+
+ reopenClosedTerminalTab: (worktreeId) => {
+ // Why: read and pop atomically inside set() to prevent a TOCTOU race where
+ // two rapid Cmd+Shift+T presses both restore the same entry (mirrors
+ // reopenClosedBrowserTab).
+ let snapshot: ClosedTerminalTabSnapshot | undefined
+ set((s) => {
+ const stack = s.recentlyClosedTerminalTabsByWorktree[worktreeId] ?? []
+ snapshot = stack[0]
+ if (!snapshot) {
+ return s
+ }
+ return {
+ recentlyClosedTerminalTabsByWorktree: {
+ ...s.recentlyClosedTerminalTabsByWorktree,
+ [worktreeId]: stack.slice(1)
+ }
+ }
+ })
+ if (!snapshot) {
+ return false
+ }
+
+ const tab = get().createTab(worktreeId, undefined, snapshot.shellOverride, {
+ ...(snapshot.startupCwd ? { startupCwd: snapshot.startupCwd } : {}),
+ activate: true
+ })
+ if (snapshot.customTitle) {
+ get().setTabCustomTitle(tab.id, snapshot.customTitle)
+ }
+ if (snapshot.color) {
+ get().setTabColor(tab.id, snapshot.color)
+ }
+ get().setActiveTabType('terminal')
+ // Why: with a stored order the strip appends unknown ids last already, but
+ // an explicit append keeps the reopened tab at the end even after future
+ // reorders write the stored order back.
+ const order = get().tabBarOrderByWorktree[worktreeId]
+ if (order && !order.includes(tab.id)) {
+ get().setTabBarOrder(worktreeId, [...order, tab.id])
+ }
+ return true
+ },
+
+ reopenClosedTab: (worktreeId) => {
+ // Why: a kind entry can outlive its snapshot (per-type caps are tighter,
+ // and the browser stack dedupes by workspace id), so skip drained kinds
+ // instead of giving up. Each iteration shifts one entry, so the loop is
+ // bounded by the kind list length.
+ for (;;) {
+ let kind: RecentlyClosedTabKind | undefined
+ set((s) => {
+ const kinds = s.recentlyClosedTabKindsByWorktree[worktreeId] ?? []
+ kind = kinds[0]
+ if (!kind) {
+ return s
+ }
+ return {
+ recentlyClosedTabKindsByWorktree: {
+ ...s.recentlyClosedTabKindsByWorktree,
+ [worktreeId]: kinds.slice(1)
+ }
+ }
+ })
+ if (!kind) {
+ return false
+ }
+ const reopened =
+ kind === 'terminal'
+ ? get().reopenClosedTerminalTab(worktreeId)
+ : kind === 'browser'
+ ? get().reopenClosedBrowserTab(worktreeId) !== null
+ : get().reopenClosedEditorTab(worktreeId)
+ if (reopened) {
+ return true
+ }
+ }
+ }
+})
diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts
index 9045837ae3f..e5533e5a05d 100644
--- a/src/renderer/src/store/slices/store-test-helpers.ts
+++ b/src/renderer/src/store/slices/store-test-helpers.ts
@@ -41,6 +41,7 @@ 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 { createRecentlyClosedTabsSlice } from './recently-closed-tabs'
import { translate } from '@/i18n/i18n'
export const TEST_REPO = {
@@ -85,7 +86,8 @@ export function createTestStore() {
...createRuntimeStatusSlice(...a),
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a),
- ...createPinnedTabCloseConfirmSlice(...a)
+ ...createPinnedTabCloseConfirmSlice(...a),
+ ...createRecentlyClosedTabsSlice(...a)
}))
}
diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts
index c4a91508fca..bab08f0d194 100644
--- a/src/renderer/src/store/slices/terminals.ts
+++ b/src/renderer/src/store/slices/terminals.ts
@@ -35,6 +35,7 @@ import type { AgentStartedTelemetry } from '../../lib/worktree-activation'
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity'
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
+import { pushClosedTerminalTabSnapshot, pushRecentlyClosedTabKind } from './recently-closed-tabs'
import { isClaudeAgent, detectAgentStatusFromTitle } from '@/lib/agent-status'
import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers'
import {
@@ -474,7 +475,10 @@ export type TerminalSlice = {
}
) => TerminalTab
openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise
- closeTab: (tabId: string, opts?: { recordInteraction?: boolean }) => void
+ closeTab: (
+ tabId: string,
+ opts?: { recordInteraction?: boolean; captureRecentlyClosed?: boolean }
+ ) => void
reorderTabs: (worktreeId: string, tabIds: string[]) => void
setTabBarOrder: (worktreeId: string, order: string[]) => void
setActiveTab: (tabId: string) => void
@@ -1032,16 +1036,33 @@ export const createTerminalSlice: StateCreator
set((s) => {
const next = { ...s.tabsByWorktree }
let closingPtyId: string | null = null
+ let closedTab: TerminalTab | null = null
+ let closedWorktreeId: string | null = null
for (const wId of Object.keys(next)) {
const before = next[wId]
- if (!closingPtyId) {
- closingPtyId = before.find((t) => t.id === tabId)?.ptyId ?? null
+ const closing = before.find((t) => t.id === tabId)
+ if (closing && !closedTab) {
+ closedTab = closing
+ closedWorktreeId = wId
+ closingPtyId = closing.ptyId ?? null
}
const after = before.filter((t) => t.id !== tabId)
if (after.length !== before.length) {
next[wId] = after
}
}
+ // Why: only explicit user closes feed the Cmd+Shift+T reopen stack.
+ // System closes (pty exit, launch cleanup, onboarding, floating panel)
+ // pass captureRecentlyClosed: false so they can't pollute it.
+ const capturedSnapshot =
+ opts?.captureRecentlyClosed !== false && closedTab && closedWorktreeId
+ ? {
+ ...(closedTab.startupCwd ? { startupCwd: closedTab.startupCwd } : {}),
+ ...(closedTab.shellOverride ? { shellOverride: closedTab.shellOverride } : {}),
+ ...(closedTab.customTitle ? { customTitle: closedTab.customTitle } : {}),
+ ...(closedTab.color ? { color: closedTab.color } : {})
+ }
+ : null
const nextExpanded = { ...s.expandedPaneByTabId }
delete nextExpanded[tabId]
const nextCanExpand = { ...s.canExpandPaneByTabId }
@@ -1174,7 +1195,21 @@ export const createTerminalSlice: StateCreator
cacheTimerByKey: nextCacheTimer,
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
pendingSnapshotByPtyId: nextSnapshots,
- pendingColdRestoreByPtyId: nextColdRestores
+ pendingColdRestoreByPtyId: nextColdRestores,
+ ...(capturedSnapshot && closedWorktreeId
+ ? {
+ recentlyClosedTerminalTabsByWorktree: pushClosedTerminalTabSnapshot(
+ s.recentlyClosedTerminalTabsByWorktree,
+ closedWorktreeId,
+ capturedSnapshot
+ ),
+ recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind(
+ s.recentlyClosedTabKindsByWorktree,
+ closedWorktreeId,
+ 'terminal'
+ )
+ }
+ : {})
}
})
// Why: sweep live AND retained agent-status entries for this tab — closing
diff --git a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts
index e603ae94740..b1a9f028be7 100644
--- a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts
+++ b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts
@@ -121,6 +121,11 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously
[WT1]: [makeOpenFile({ id: '/path/wt1/closed.ts', worktreeId: WT1 })],
[WT2]: [makeOpenFile({ id: '/path/wt2/closed.ts', worktreeId: WT2 })]
},
+ recentlyClosedTerminalTabsByWorktree: {
+ [WT1]: [{ startupCwd: '/path/wt1' }],
+ [WT2]: [{ startupCwd: '/path/wt2' }]
+ },
+ recentlyClosedTabKindsByWorktree: { [WT1]: ['terminal'], [WT2]: ['terminal'] },
defaultTerminalTabsAppliedByWorktreeId: { [WT1]: true, [WT2]: true }
})
}
@@ -135,10 +140,14 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously
// Evicted for the removed worktree.
expect(s.remoteStatusesByWorktree[WT1]).toBeUndefined()
expect(s.recentlyClosedEditorTabsByWorktree[WT1]).toBeUndefined()
+ expect(s.recentlyClosedTerminalTabsByWorktree[WT1]).toBeUndefined()
+ expect(s.recentlyClosedTabKindsByWorktree[WT1]).toBeUndefined()
expect(s.defaultTerminalTabsAppliedByWorktreeId[WT1]).toBeUndefined()
// Retained for the surviving worktree (guard over-eviction).
expect(s.remoteStatusesByWorktree[WT2]).toBeDefined()
expect(s.recentlyClosedEditorTabsByWorktree[WT2]).toBeDefined()
+ expect(s.recentlyClosedTerminalTabsByWorktree[WT2]).toBeDefined()
+ expect(s.recentlyClosedTabKindsByWorktree[WT2]).toBeDefined()
expect(s.defaultTerminalTabsAppliedByWorktreeId[WT2]).toBe(true)
})
@@ -152,9 +161,13 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously
const s = store.getState()
expect(s.remoteStatusesByWorktree[WT1]).toBeUndefined()
expect(s.recentlyClosedEditorTabsByWorktree[WT1]).toBeUndefined()
+ expect(s.recentlyClosedTerminalTabsByWorktree[WT1]).toBeUndefined()
+ expect(s.recentlyClosedTabKindsByWorktree[WT1]).toBeUndefined()
expect(s.defaultTerminalTabsAppliedByWorktreeId[WT1]).toBeUndefined()
expect(s.remoteStatusesByWorktree[WT2]).toBeDefined()
expect(s.recentlyClosedEditorTabsByWorktree[WT2]).toBeDefined()
+ expect(s.recentlyClosedTerminalTabsByWorktree[WT2]).toBeDefined()
+ expect(s.recentlyClosedTabKindsByWorktree[WT2]).toBeDefined()
expect(s.defaultTerminalTabsAppliedByWorktreeId[WT2]).toBe(true)
})
diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts
index ce3ba3176b5..a68f117e674 100644
--- a/src/renderer/src/store/slices/worktrees.test.ts
+++ b/src/renderer/src/store/slices/worktrees.test.ts
@@ -6330,6 +6330,10 @@ describe('migrateWorktreeIdentity', () => {
lastVisitedAtByWorktreeId: { [OLD]: 123 },
defaultTerminalTabsAppliedByWorktreeId: { [OLD]: true },
recentlyClosedEditorTabsByWorktree: { [OLD]: [{ id: 'f1', worktreeId: OLD }] },
+ recentlyClosedTerminalTabsByWorktree: {
+ [OLD]: [{ startupCwd: '/ws/cunner/packages/app' }, { startupCwd: '/elsewhere/dir' }]
+ },
+ recentlyClosedTabKindsByWorktree: { [OLD]: ['terminal', 'editor'] },
remoteStatusesByWorktree: { [OLD]: { ahead: 1 } },
everActivatedWorktreeIds: new Set([OLD]),
openFiles: [{ id: 'f1', worktreeId: OLD }],
@@ -6376,6 +6380,15 @@ describe('migrateWorktreeIdentity', () => {
expect(s.defaultTerminalTabsAppliedByWorktreeId[NEW]).toBe(true)
// The two maps absent from the purge list are still re-keyed.
expect(s.recentlyClosedEditorTabsByWorktree[NEW]).toEqual([{ id: 'f1', worktreeId: NEW }])
+ // Terminal reopen snapshots re-key AND remap startupCwd under the old
+ // worktree path; paths outside the renamed folder stay as-is.
+ expect(s.recentlyClosedTerminalTabsByWorktree[OLD]).toBeUndefined()
+ expect(s.recentlyClosedTerminalTabsByWorktree[NEW]).toEqual([
+ { startupCwd: '/ws/worktree-creation-spinner/packages/app' },
+ { startupCwd: '/elsewhere/dir' }
+ ])
+ expect(s.recentlyClosedTabKindsByWorktree[OLD]).toBeUndefined()
+ expect(s.recentlyClosedTabKindsByWorktree[NEW]).toEqual(['terminal', 'editor'])
expect(s.remoteStatusesByWorktree[NEW]).toEqual({ ahead: 1 })
expect(s.everActivatedWorktreeIds.has(NEW)).toBe(true)
expect(s.everActivatedWorktreeIds.has(OLD)).toBe(false)
diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts
index 2ddc8100aca..3abc262a8ea 100644
--- a/src/renderer/src/store/slices/worktrees.ts
+++ b/src/renderer/src/store/slices/worktrees.ts
@@ -25,6 +25,8 @@ import {
getRepoIdFromWorktreeId,
type WorktreeSlice
} from './worktree-helpers'
+import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
+import type { ClosedTerminalTabSnapshot } from './recently-closed-tabs'
import { findRepoForHost } from './repo-host-identity'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup'
@@ -1667,7 +1669,8 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [
'showDotfilesByWorktree',
'expandedDirs',
'lastVisitedAtByWorktreeId',
- 'defaultTerminalTabsAppliedByWorktreeId'
+ 'defaultTerminalTabsAppliedByWorktreeId',
+ 'recentlyClosedTabKindsByWorktree'
] as const satisfies readonly (keyof AppState)[]
/**
@@ -1728,6 +1731,29 @@ function buildWorktreeRenameState(
renameKey('recentlyClosedEditorTabsByWorktree', (files: { worktreeId: string }[]) =>
files.map(withNewWorktreeId)
)
+ // Why: terminal reopen snapshots carry absolute startupCwd paths under the
+ // old worktree folder; remap them or Cmd+Shift+T respawns into a directory
+ // that no longer exists after the rename. Paths outside the renamed folder
+ // are untouched by the move and stay valid as-is.
+ const oldWorktreePath = splitWorktreeIdForFilesystem(oldWorktreeId)?.worktreePath
+ const newWorktreePath = splitWorktreeIdForFilesystem(newWorktreeId)?.worktreePath
+ renameKey('recentlyClosedTerminalTabsByWorktree', (snapshots: ClosedTerminalTabSnapshot[]) =>
+ oldWorktreePath && newWorktreePath
+ ? snapshots.map((snapshot) => {
+ const cwd = snapshot.startupCwd
+ if (!cwd) {
+ return snapshot
+ }
+ if (cwd === oldWorktreePath) {
+ return { ...snapshot, startupCwd: newWorktreePath }
+ }
+ if (cwd.startsWith(`${oldWorktreePath}/`) || cwd.startsWith(`${oldWorktreePath}\\`)) {
+ return { ...snapshot, startupCwd: newWorktreePath + cwd.slice(oldWorktreePath.length) }
+ }
+ return snapshot
+ })
+ : snapshots
+ )
renameKey('remoteStatusesByWorktree')
const openFiles = s.openFiles?.some((f) => f.worktreeId === oldWorktreeId)
@@ -2038,6 +2064,8 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial
delete next[worktreeId]
return next
})(),
+ recentlyClosedTerminalTabsByWorktree: (() => {
+ const next = { ...s.recentlyClosedTerminalTabsByWorktree }
+ delete next[worktreeId]
+ return next
+ })(),
+ // Why: shutdownWorktreeBrowsers/Terminals above already pushed kind
+ // entries for this worktree's tabs; a deleted worktree's tabs can
+ // never be reopened, so purge symmetrically with the snapshot stacks.
+ recentlyClosedTabKindsByWorktree: (() => {
+ const next = { ...s.recentlyClosedTabKindsByWorktree }
+ delete next[worktreeId]
+ return next
+ })(),
defaultTerminalTabsAppliedByWorktreeId: (() => {
const next = { ...s.defaultTerminalTabsAppliedByWorktreeId }
delete next[worktreeId]
diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts
index ad1a7089d8c..768b125f0bc 100644
--- a/src/renderer/src/store/types.ts
+++ b/src/renderer/src/store/types.ts
@@ -31,6 +31,7 @@ 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'
+import type { RecentlyClosedTabsSlice } from './slices/recently-closed-tabs'
export type AppState = RepoSlice &
SparsePresetsSlice &
@@ -64,4 +65,5 @@ export type AppState = RepoSlice &
RuntimeStatusSlice &
PullRequestGenerationSlice &
CommitMessageGenerationSlice &
- PinnedTabCloseConfirmSlice
+ PinnedTabCloseConfirmSlice &
+ RecentlyClosedTabsSlice