Reopen closed tabs with Cmd+Shift+T, including terminals

Cmd/Ctrl+Shift+T now reopens the most recently closed tab of any kind
(terminal, browser, or editor) in true recency order, walking back
through close history on repeated presses — Chrome/Ghostty semantics.
Terminal tabs previously had no reopen at all, and the shortcut picked
browser-vs-editor by focused surface instead of recency.

Terminal closes capture a snapshot (startupCwd, shellOverride,
customTitle, color) on explicit user closes only; PTY exits, launch
cleanup, onboarding, and floating-panel closes are excluded. Reopen
spawns a fresh shell through the same createTab path as the "+" button.
A per-worktree cross-type kind list (new recently-closed-tabs slice)
orders the three existing per-type stacks; both new maps are purged on
worktree removal and re-keyed on rename with startupCwd prefix remap.
createNewTerminalTab moved to terminal-tab-create.ts to keep
terminal-tab-actions.ts under the max-lines budget.

Requested via Discord feedback; STA-1287.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-05 11:48:06 -07:00
co-authored by Orca
parent f3e103ebe7
commit 5ccbf2deef
23 changed files with 638 additions and 94 deletions
+9 -13
View File
@@ -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
}
@@ -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)}
/>
</div>
@@ -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 })
}
/>
) : (
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
@@ -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)
@@ -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={() => {
@@ -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', () => {
@@ -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 {
@@ -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)
}
@@ -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)
@@ -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 })
}
}
}
@@ -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)
+3 -1
View File
@@ -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<AppState>()((...a) => ({
...createRuntimeStatusSlice(...a),
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a),
...createPinnedTabCloseConfirmSlice(...a)
...createPinnedTabCloseConfirmSlice(...a),
...createRecentlyClosedTabsSlice(...a)
}))
registerHttpLinkStoreAccessor(() => useAppStore.getState())
+7
View File
@@ -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<AppState, [], [], BrowserSlice> =
{ 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<AppState, [], [], BrowserSlice> =
pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree,
recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree,
recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace,
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId
@@ -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<AppState>()((...a) => ({
@@ -175,7 +176,8 @@ function createTestStore() {
...createRuntimeStatusSlice(...a),
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a),
...createPinnedTabCloseConfirmSlice(...a)
...createPinnedTabCloseConfirmSlice(...a),
...createRecentlyClosedTabsSlice(...a)
}))
}
+25 -2
View File
@@ -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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
MAX_RECENT_CLOSED_EDITOR_TABS
)
}
nextRecentlyClosedKinds = pushRecentlyClosedTabKind(
s.recentlyClosedTabKindsByWorktree,
worktreeId,
'editor'
)
}
return {
openFiles: newFiles,
@@ -1722,6 +1729,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
editorViewMode: nextEditorViewMode,
markdownFrontmatterVisible: nextMarkdownFrontmatterVisible,
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed,
recentlyClosedTabKindsByWorktree: nextRecentlyClosedKinds,
...previewTabBarUpdate,
...activeResult
}
@@ -2064,6 +2072,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
MAX_RECENT_CLOSED_EDITOR_TABS
)
}
nextRecentlyClosedKinds = pushRecentlyClosedTabKind(
s.recentlyClosedTabKindsByWorktree,
wtRecent,
'editor'
)
}
return {
@@ -2114,7 +2128,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
markdownFrontmatterVisible: newMarkdownFrontmatterVisible,
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
pendingEditorReveal: null,
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed,
recentlyClosedTabKindsByWorktree: nextRecentlyClosedKinds
}
})
@@ -2258,6 +2273,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
0,
MAX_RECENT_CLOSED_EDITOR_TABS
)
capturedCloseCount += 1
}
return {
@@ -2306,7 +2323,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
recentlyClosedEditorTabsByWorktree: {
...s.recentlyClosedEditorTabsByWorktree,
[activeWorktreeId]: nextRecentClosed
}
},
recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind(
s.recentlyClosedTabKindsByWorktree,
activeWorktreeId,
'editor',
capturedCloseCount
)
}
})
if (typeof window !== 'undefined') {
@@ -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<typeof AgentStatusModule>()
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<typeof createTestStore> {
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)
})
})
@@ -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<string, ClosedTerminalTabSnapshot[]> | undefined,
worktreeId: string,
snapshot: ClosedTerminalTabSnapshot
): Record<string, ClosedTerminalTabSnapshot[]> {
return {
...map,
[worktreeId]: [snapshot, ...(map?.[worktreeId] ?? [])].slice(0, MAX_RECENT_CLOSED_TERMINAL_TABS)
}
}
export function pushRecentlyClosedTabKind(
map: Record<string, RecentlyClosedTabKind[]> | undefined,
worktreeId: string,
kind: RecentlyClosedTabKind,
count = 1
): Record<string, RecentlyClosedTabKind[]> {
// 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<string, ClosedTerminalTabSnapshot[]>
/** 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<string, RecentlyClosedTabKind[]>
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
}
}
}
})
@@ -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)
}))
}
+39 -4
View File
@@ -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<void>
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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
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
@@ -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)
})
@@ -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)
+42 -1
View File
@@ -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<Ap
// Why: keyed by worktreeId; re-keyed on rename but missed by both removal
// paths, leaking the per-worktree editor-undo (Cmd/Ctrl+Shift+T) snapshots.
recentlyClosedEditorTabsByWorktree: omitByWorktree(s.recentlyClosedEditorTabsByWorktree),
recentlyClosedTerminalTabsByWorktree: omitByWorktree(s.recentlyClosedTerminalTabsByWorktree),
recentlyClosedTabKindsByWorktree: omitByWorktree(s.recentlyClosedTabKindsByWorktree),
// Top-level actives
openFiles: nextOpenFiles,
everActivatedWorktreeIds: nextEverActivatedWorktreeIds,
@@ -3237,6 +3265,19 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
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]
+3 -1
View File
@@ -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