From 18dbcf001f0abe7b568a4e299ddbc759e49cce67 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:08:09 -0700 Subject: [PATCH] fix(tabs): restore Cmd/Ctrl+Shift+T tab positions (#12236) --- src/renderer/src/store/slices/browser.ts | 22 ++- src/renderer/src/store/slices/editor.test.ts | 95 +++++++++++- src/renderer/src/store/slices/editor.ts | 81 +++++++---- .../store/slices/recently-closed-tabs.test.ts | 135 +++++++++++++++++- .../src/store/slices/recently-closed-tabs.ts | 119 +++++++++++++-- src/renderer/src/store/slices/terminals.ts | 13 +- 6 files changed, 422 insertions(+), 43 deletions(-) diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index c0f545307..d869d8447 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -24,7 +24,12 @@ 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 { + getRecentlyClosedTabPosition, + restoreRecentlyClosedTabPosition, + pushRecentlyClosedTabKind +} from './recently-closed-tabs' +import type { RecentlyClosedTabPosition } from './recently-closed-tabs' import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import type { @@ -84,6 +89,7 @@ type BrowserTabPageState = { type ClosedBrowserWorkspaceSnapshot = { workspace: BrowserWorkspace pages: BrowserPage[] + position?: RecentlyClosedTabPosition } function sanitizeBrowserPageAnnotation(annotation: BrowserPageAnnotation): BrowserPageAnnotation { @@ -771,8 +777,13 @@ export const createBrowserSlice: StateCreator = const nextRecentlyClosedBrowserTabsByWorktree = { ...s.recentlyClosedBrowserTabsByWorktree } const existingSnapshots = nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? [] + const position = getRecentlyClosedTabPosition(s, owningWorktreeId, tabId) nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [ - { workspace: closedWorkspace, pages: closedPages }, + { + workspace: closedWorkspace, + pages: closedPages, + ...(position ? { position } : {}) + }, ...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id) ].slice(0, 10) const nextRecentlyClosedTabKindsByWorktree = pushRecentlyClosedTabKind( @@ -890,8 +901,10 @@ export const createBrowserSlice: StateCreator = title: snap.title, activate: true, sessionProfileId, - sessionPartition + sessionPartition, + targetGroupId: entryToRestore.position?.groupId }) + restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null } @@ -902,6 +915,7 @@ export const createBrowserSlice: StateCreator = activate: true, sessionProfileId, sessionPartition, + targetGroupId: entryToRestore.position?.groupId, browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId }) @@ -924,6 +938,8 @@ export const createBrowserSlice: StateCreator = } } + restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) + return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null }, diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 89d43a388..d8a554a51 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -66,6 +66,13 @@ function createEditorTabsStore(): StoreApi { browserTabsByWorktree: {}, activeBrowserTabId: null, activeBrowserTabIdByWorktree: {}, + activeTabId: null, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + setTabBarOrder: (worktreeId: string, order: string[]) => + args[0]((state: AppState) => ({ + tabBarOrderByWorktree: { ...state.tabBarOrderByWorktree, [worktreeId]: order } + })), repos: [{ id: 'repo-1', path: '/repo' }], worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] }, folderWorkspaces: [], @@ -663,7 +670,7 @@ describe('createEditorSlice openDiff', () => { it('bumps fileContentReloadNonce when re-opening an existing clean file with reload requested', () => { const store = createEditorStore() - const openFileWithReloadRequest = (): void => + const openFileWithReloadRequest = (): void => { store.getState().openFile( { filePath: '/repo/file.ts', @@ -674,6 +681,7 @@ describe('createEditorSlice openDiff', () => { }, { forceContentReload: true } ) + } openFileWithReloadRequest() expect(store.getState().openFiles[0]?.fileContentReloadNonce).toBeUndefined() @@ -869,7 +877,7 @@ describe('createEditorSlice openDiff', () => { it('keeps an existing preview replaceable when it is opened as preview again', () => { const store = createEditorTabsStore() - const openPreviewFile = (): void => + const openPreviewFile = (): void => { store.getState().openFile( { filePath: '/repo/a.ts', @@ -880,6 +888,7 @@ describe('createEditorSlice openDiff', () => { }, { preview: true } ) + } openPreviewFile() openPreviewFile() @@ -1624,6 +1633,85 @@ describe('createEditorSlice recently closed editor tabs', () => { expect(store.getState().openFiles.at(-1)).toMatchObject({ filePath: '/repo/notes.md' }) expect(store.getState().openFiles.at(-1)).not.toHaveProperty('mirroredFromRuntimeSession') }) + + it('restores the exact same-path owner instead of moving the local editor', () => { + const store = createEditorTabsStore() + const localId = store.getState().openFile({ + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + const remoteId = store.getState().openFile({ + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + runtimeEnvironmentId: 'env-1', + mode: 'edit' + }) + store.getState().setTabBarOrder('wt-1', [localId, remoteId]) + + store.getState().closeFile(remoteId) + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + const openIds = store.getState().openFiles.map((file) => file.id) + expect(openIds).toEqual([localId, remoteId]) + expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([localId, remoteId]) + }) + + it('keeps same-path edit and diff tabs as separate entities on reopen', () => { + const store = createEditorTabsStore() + const editId = store.getState().openFile({ + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + store.getState().openDiff('wt-1', '/repo/notes.md', 'notes.md', 'markdown', false) + const diffId = 'wt-1::diff::unstaged::notes.md' + store.getState().setTabBarOrder('wt-1', [editId, diffId]) + + store.getState().closeFile(diffId) + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().openFiles.map((file) => file.id)).toEqual([editId, diffId]) + expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([editId, diffId]) + }) + + it('keeps close-all editor snapshots positioned for reopen', () => { + const store = createEditorTabsStore() + const firstId = store.getState().openFile({ + filePath: '/repo/first.md', + relativePath: 'first.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + const middleId = store.getState().openFile({ + filePath: '/repo/middle.md', + relativePath: 'middle.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + const lastId = store.getState().openFile({ + filePath: '/repo/last.md', + relativePath: 'last.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + store.getState().setTabBarOrder('wt-1', [firstId, middleId, lastId]) + + store.getState().closeAllFiles() + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().openFiles[0]?.id).toBe(firstId) + expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([firstId]) + }) }) describe('createEditorSlice markdown view state', () => { @@ -4941,7 +5029,7 @@ describe('closeFile host mirroring', () => { describe('read-only editor tabs (AI Vault View Log)', () => { const LOG_PATH = '/home/user/.claude/sessions/log.jsonl' - const openReadOnlyLog = (store: StoreApi): void => + const openReadOnlyLog = (store: StoreApi): void => { store.getState().openFile( { filePath: LOG_PATH, @@ -4955,6 +5043,7 @@ describe('read-only editor tabs (AI Vault View Log)', () => { }, { preview: false, forceContentReload: true, suppressActiveRuntimeFallback: true } ) + } it('creates a permanent read-only edit tab', () => { const store = createEditorStore() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index cc651ee6c..c3c2d6307 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -1,7 +1,12 @@ /* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import { pushRecentlyClosedTabKind } from './recently-closed-tabs' +import { + getRecentlyClosedTabPosition, + restoreRecentlyClosedTabPosition, + pushRecentlyClosedTabKind +} from './recently-closed-tabs' +import type { RecentlyClosedTabPosition } from './recently-closed-tabs' import { joinPath } from '@/lib/path' import { toast } from 'sonner' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' @@ -276,12 +281,15 @@ export type MarkdownViewMode = 'source' | 'rich' | 'preview' // Why: orthogonal to MarkdownViewMode; 'changes' renders diff-vs-HEAD in place of the editor without a separate tab. See reviews/changes-view-mode-plan.md. export type EditorViewMode = 'edit' | 'changes' -/** Enough state to restore a tab via `openFile` after `closeFile` (id is always filePath). */ +/** Enough state to restore a tab via `openFile` after `closeFile`. */ // Why: omit mirroredFromRuntimeSession so a user-reopened tab isn't treated as host-owned and culled by the next web session sync. export type ClosedEditorTabSnapshot = Omit< OpenFile, 'id' | 'isDirty' | 'mirroredFromRuntimeSession' -> +> & { + reopenId?: string + position?: RecentlyClosedTabPosition +} const MAX_RECENT_CLOSED_EDITOR_TABS = 10 @@ -464,8 +472,9 @@ export type EditorSlice = { suppressActiveRuntimeFallback?: boolean forceContentReload?: boolean focusEditor?: boolean + reopenId?: string } - ) => void + ) => string openNewMarkdownInActiveWorkspace: (groupId: string) => Promise // Why: sequences openFile/setMarkdownViewMode/reveal around an async Monaco remount. See docs/markdown-internal-link-opening-design.md. activateMarkdownLink: ( @@ -1668,13 +1677,16 @@ export const createEditorSlice: StateCreator = (s matchesEditorMode(f, reusableOpenFileModes) && isSameEditorOwner(f, worktreeId, runtimeEnvironmentId) ) - const id = resolveEditorFileIdForOwner( - s, - file.filePath, - worktreeId, - runtimeEnvironmentId, - reusableOpenFileModes - ) + const id = + options?.reopenId && !s.openFiles.some((candidate) => candidate.id === options.reopenId) + ? options.reopenId + : resolveEditorFileIdForOwner( + s, + file.filePath, + worktreeId, + runtimeEnvironmentId, + reusableOpenFileModes + ) editorItemFileId = id const isPreview = options?.preview ?? false const recordReplacedPreview = options?.recordReplacedPreview ?? false @@ -1799,12 +1811,17 @@ export const createEditorSlice: StateCreator = (s ...snap } = replacedPreview const stack = s.recentlyClosedEditorTabsByWorktree[worktreeId] ?? [] + const position = getRecentlyClosedTabPosition(s, worktreeId, replacedPreview.id) nextRecentlyClosed = { ...s.recentlyClosedEditorTabsByWorktree, - [worktreeId]: [snap as ClosedEditorTabSnapshot, ...stack].slice( - 0, - MAX_RECENT_CLOSED_EDITOR_TABS - ) + [worktreeId]: [ + { + ...(snap as ClosedEditorTabSnapshot), + reopenId: replacedPreview.id, + ...(position ? { position } : {}) + }, + ...stack + ].slice(0, MAX_RECENT_CLOSED_EDITOR_TABS) } nextRecentlyClosedKinds = pushRecentlyClosedTabKind( s.recentlyClosedTabKindsByWorktree, @@ -1886,6 +1903,7 @@ export const createEditorSlice: StateCreator = (s } }) } + return editorItemFileId }, openNewMarkdownInActiveWorkspace: async (groupId) => { @@ -2188,12 +2206,17 @@ export const createEditorSlice: StateCreator = (s ...snap } = closedFile const stack = s.recentlyClosedEditorTabsByWorktree[wtRecent] ?? [] + const position = getRecentlyClosedTabPosition(s, wtRecent, fileId) nextRecentlyClosed = { ...s.recentlyClosedEditorTabsByWorktree, - [wtRecent]: [snap as ClosedEditorTabSnapshot, ...stack].slice( - 0, - MAX_RECENT_CLOSED_EDITOR_TABS - ) + [wtRecent]: [ + { + ...(snap as ClosedEditorTabSnapshot), + reopenId: fileId, + ...(position ? { position } : {}) + }, + ...stack + ].slice(0, MAX_RECENT_CLOSED_EDITOR_TABS) } nextRecentlyClosedKinds = pushRecentlyClosedTabKind( s.recentlyClosedTabKindsByWorktree, @@ -2264,7 +2287,12 @@ export const createEditorSlice: StateCreator = (s [worktreeId]: (s.recentlyClosedEditorTabsByWorktree[worktreeId] ?? []).slice(1) } })) - get().openFile(next) + const { position, reopenId, ...file } = next + const restoredFileId = get().openFile(file, { + targetGroupId: position?.groupId, + reopenId + }) + restoreRecentlyClosedTabPosition(get, worktreeId, restoredFileId, position) return true }, @@ -2375,10 +2403,15 @@ export const createEditorSlice: StateCreator = (s continue } const { id: _id, isDirty: _dirty, mirroredFromRuntimeSession: _mirrored, ...snap } = f - nextRecentClosed = [snap as ClosedEditorTabSnapshot, ...nextRecentClosed].slice( - 0, - MAX_RECENT_CLOSED_EDITOR_TABS - ) + const position = getRecentlyClosedTabPosition(s, activeWorktreeId, f.id) + nextRecentClosed = [ + { + ...(snap as ClosedEditorTabSnapshot), + reopenId: f.id, + ...(position ? { position } : {}) + }, + ...nextRecentClosed + ].slice(0, MAX_RECENT_CLOSED_EDITOR_TABS) capturedCloseCount += 1 } diff --git a/src/renderer/src/store/slices/recently-closed-tabs.test.ts b/src/renderer/src/store/slices/recently-closed-tabs.test.ts index cca4ef693..8ca10f494 100644 --- a/src/renderer/src/store/slices/recently-closed-tabs.test.ts +++ b/src/renderer/src/store/slices/recently-closed-tabs.test.ts @@ -34,10 +34,18 @@ const mockApi = { // @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' +import { + createTestStore, + seedStore, + makeWorktree, + makeOpenFile, + makeTabGroup, + makeUnifiedTab +} from './store-test-helpers' import { pushRecentlyClosedTabKind, - remapClosedTerminalTabSnapshotCwds + remapClosedTerminalTabSnapshotCwds, + restoreRecentlyClosedTabPosition } from './recently-closed-tabs' const WT = 'repo1::/path/wt1' @@ -69,7 +77,12 @@ describe('terminal recently-closed capture', () => { store.getState().closeTab(tab.id) expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toEqual([ - { startupCwd: '/path/wt1/packages/app', customTitle: 'build shell', color: '#ff0000' } + expect.objectContaining({ + startupCwd: '/path/wt1/packages/app', + customTitle: 'build shell', + color: '#ff0000', + position: expect.objectContaining({ groupIndex: 0 }) + }) ]) expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual(['terminal']) }) @@ -140,6 +153,30 @@ describe('terminal snapshot cwd remapping', () => { }) describe('reopenClosedTerminalTab', () => { + it('restores the tab bar and group position of a closed middle tab', () => { + const store = makeSeededStore() + const first = store.getState().createTab(WT) + const middle = store.getState().createTab(WT) + const last = store.getState().createTab(WT) + store.getState().setTabBarOrder(WT, [first.id, middle.id, last.id]) + + store.getState().closeTab(middle.id) + expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true) + + const restored = store + .getState() + .tabsByWorktree[WT]?.find((tab) => tab.id !== first.id && tab.id !== last.id) + expect(restored).toBeDefined() + expect(store.getState().tabBarOrderByWorktree[WT]).toEqual([first.id, restored!.id, last.id]) + + const group = store.getState().groupsByWorktree[WT]?.[0] + expect( + group?.tabOrder.map( + (id) => store.getState().unifiedTabsByWorktree[WT]?.find((tab) => tab.id === id)?.entityId + ) + ).toEqual([first.id, restored!.id, last.id]) + }) + it('recreates a fresh terminal with the snapshot cwd, shell, title, and color', () => { const store = makeSeededStore() const tab = store @@ -232,6 +269,98 @@ describe('reopenClosedTerminalTab', () => { }) }) +describe('restoreRecentlyClosedTabPosition', () => { + it('selects the unified tab in the captured group when an entity is shared', () => { + const reordered = vi.fn() + const otherTab = makeUnifiedTab({ + id: 'other-tab', + entityId: 'shared-file', + groupId: 'other-group', + worktreeId: WT, + contentType: 'editor' + }) + const capturedTab = makeUnifiedTab({ + id: 'captured-tab', + entityId: 'shared-file', + groupId: 'captured-group', + worktreeId: WT, + contentType: 'editor' + }) + const capturedSibling = makeUnifiedTab({ + id: 'captured-sibling', + entityId: 'sibling-file', + groupId: 'captured-group', + worktreeId: WT, + contentType: 'editor' + }) + const state = { + tabBarOrderByWorktree: { [WT]: ['shared-file'] }, + groupsByWorktree: { + [WT]: [ + makeTabGroup({ + id: 'other-group', + worktreeId: WT, + activeTabId: 'other-tab', + tabOrder: ['other-tab'] + }), + makeTabGroup({ + id: 'captured-group', + worktreeId: WT, + activeTabId: 'captured-sibling', + tabOrder: ['captured-sibling', 'captured-tab'] + }) + ] + }, + unifiedTabsByWorktree: { [WT]: [otherTab, capturedTab, capturedSibling] } + } + + restoreRecentlyClosedTabPosition( + () => ({ + ...state, + setTabBarOrder: vi.fn(), + reorderUnifiedTabs: reordered + }), + WT, + 'shared-file', + { groupId: 'captured-group', groupIndex: 0 } + ) + + expect(reordered).toHaveBeenCalledWith('captured-group', ['captured-tab', 'captured-sibling'], { + recordInteraction: false + }) + }) + + it('does not reuse a stale flat index after group drag reordering', () => { + const store = makeSeededStore() + const first = store.getState().createTab(WT) + const middle = store.getState().createTab(WT) + const last = store.getState().createTab(WT) + const groupId = store.getState().groupsByWorktree[WT]?.[0]?.id + if (!groupId) { + throw new Error('Expected a root tab group') + } + store.getState().setTabBarOrder(WT, [first.id, middle.id, last.id]) + store.getState().reorderUnifiedTabs(groupId, [first.id, last.id, middle.id]) + + store.getState().closeTab(middle.id) + expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]?.[0]?.position).toEqual({ + groupId, + groupIndex: 2 + }) + + expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true) + const restored = store + .getState() + .tabsByWorktree[WT]?.find((tab) => tab.id !== first.id && tab.id !== last.id) + expect(store.getState().tabBarOrderByWorktree[WT]).toEqual([first.id, last.id, restored?.id]) + expect(store.getState().groupsByWorktree[WT]?.[0]?.tabOrder).toEqual([ + first.id, + last.id, + restored?.id + ]) + }) +}) + describe('reopenClosedTab cross-type MRU', () => { it('reopens closed tabs of mixed kinds in most-recent-first order', () => { const store = makeSeededStore() diff --git a/src/renderer/src/store/slices/recently-closed-tabs.ts b/src/renderer/src/store/slices/recently-closed-tabs.ts index f2fd9ff3d..7faebebfa 100644 --- a/src/renderer/src/store/slices/recently-closed-tabs.ts +++ b/src/renderer/src/store/slices/recently-closed-tabs.ts @@ -6,6 +6,114 @@ import { relativePathInsideRoot } from '../../../../shared/cross-platform-path' +export type RecentlyClosedTabPosition = { + tabBarIndex?: number + groupId?: string + groupIndex?: number +} + +export function getRecentlyClosedTabPosition( + state: Pick, + worktreeId: string, + entityId: string +): RecentlyClosedTabPosition | undefined { + const tabBarOrder = state.tabBarOrderByWorktree?.[worktreeId] + const unifiedTabs = state.unifiedTabsByWorktree?.[worktreeId] ?? [] + const tabBarIndex = tabBarOrder?.indexOf(entityId) ?? -1 + const unifiedTab = unifiedTabs.find((tab) => tab.entityId === entityId) + const group = unifiedTab + ? (state.groupsByWorktree?.[worktreeId] ?? []).find( + (candidate) => candidate.id === unifiedTab.groupId + ) + : undefined + const groupIndex = group?.tabOrder.indexOf(unifiedTab?.id ?? '') ?? -1 + const groupTabEntityIds = group + ? group.tabOrder.map((tabId) => unifiedTabs.find((tab) => tab.id === tabId)?.entityId) + : [] + const tabBarGroupEntityIds = group + ? (tabBarOrder ?? []) + .map((tabId) => unifiedTabs.find((tab) => tab.entityId === tabId)) + .filter((tab) => tab?.groupId === group.id) + .map((tab) => tab?.entityId) + : [] + const groupOrderMatchesTabBar = + !group || + (groupTabEntityIds.length === tabBarGroupEntityIds.length && + groupTabEntityIds.every((entityId, index) => entityId === tabBarGroupEntityIds[index])) + if (tabBarIndex < 0 && (!group || groupIndex < 0)) { + return undefined + } + + return { + ...(tabBarIndex >= 0 && groupOrderMatchesTabBar ? { tabBarIndex } : {}), + ...(group && groupIndex >= 0 ? { groupId: group.id, groupIndex } : {}) + } +} + +export function insertTabAtRecentlyClosedPosition( + order: readonly string[], + tabId: string, + position?: RecentlyClosedTabPosition +): string[] { + const nextOrder = order.filter((id) => id !== tabId) + const index = position?.tabBarIndex + if (index === undefined) { + return [...nextOrder, tabId] + } + nextOrder.splice(Math.min(Math.max(index, 0), nextOrder.length), 0, tabId) + return nextOrder +} + +export function restoreRecentlyClosedTabPosition( + getState: () => Pick< + AppState, + | 'tabBarOrderByWorktree' + | 'groupsByWorktree' + | 'unifiedTabsByWorktree' + | 'setTabBarOrder' + | 'reorderUnifiedTabs' + >, + worktreeId: string, + entityId: string, + position?: RecentlyClosedTabPosition +): void { + if (!position) { + return + } + const state = getState() + const order = state.tabBarOrderByWorktree?.[worktreeId] + if (order && typeof state.setTabBarOrder === 'function') { + state.setTabBarOrder(worktreeId, insertTabAtRecentlyClosedPosition(order, entityId, position)) + } + + if (position?.groupIndex === undefined) { + return + } + const unifiedTab = (getState().unifiedTabsByWorktree?.[worktreeId] ?? []).find( + (candidate) => + candidate.entityId === entityId && + (position.groupId === undefined || candidate.groupId === position.groupId) + ) + if (!unifiedTab) { + return + } + const group = (getState().groupsByWorktree?.[worktreeId] ?? []).find( + (candidate) => candidate.id === unifiedTab.groupId + ) + if (!group) { + return + } + if (typeof getState().reorderUnifiedTabs === 'function') { + getState().reorderUnifiedTabs( + group.id, + insertTabAtRecentlyClosedPosition(group.tabOrder, unifiedTab.id, { + tabBarIndex: position.groupIndex + }), + { recordInteraction: false } + ) + } +} + /** 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. */ @@ -14,6 +122,7 @@ export type ClosedTerminalTabSnapshot = { shellOverride?: string customTitle?: string color?: string + position?: RecentlyClosedTabPosition } export type RecentlyClosedTabKind = 'terminal' | 'browser' | 'editor' @@ -135,7 +244,7 @@ export const createRecentlyClosedTabsSlice: StateCreator< return false } - const tab = get().createTab(worktreeId, undefined, snapshot.shellOverride, { + const tab = get().createTab(worktreeId, snapshot.position?.groupId, snapshot.shellOverride, { ...(snapshot.startupCwd ? { startupCwd: snapshot.startupCwd } : {}), activate: true }) @@ -146,13 +255,7 @@ export const createRecentlyClosedTabsSlice: StateCreator< 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]) - } + restoreRecentlyClosedTabPosition(get, worktreeId, tab.id, snapshot.position) return true }, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 03077f9d0..8c41cedab 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -56,7 +56,11 @@ import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output- import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards' import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' -import { pushClosedTerminalTabSnapshot, pushRecentlyClosedTabKind } from './recently-closed-tabs' +import { + getRecentlyClosedTabPosition, + pushClosedTerminalTabSnapshot, + pushRecentlyClosedTabKind +} from './recently-closed-tabs' import { isClaudeAgent } from '@/lib/agent-status' import { recordTerminalInputActivity } from '@/lib/terminal-input-activity-coalescing' import { classifyTitleActivity } from '@/lib/pane-agent-evidence' @@ -1651,6 +1655,10 @@ export const createTerminalSlice: StateCreator } } // Why: only explicit user closes feed the Cmd+Shift+T reopen stack; cleanup/PTY-exit closes must not pollute undo history. + const closedPosition = + closedWorktreeId && closedTab + ? getRecentlyClosedTabPosition(s, closedWorktreeId, closedTab.id) + : undefined const capturedSnapshot = closeReason === 'user' && opts?.captureRecentlyClosed !== false && @@ -1660,7 +1668,8 @@ export const createTerminalSlice: StateCreator ...(closedTab.startupCwd ? { startupCwd: closedTab.startupCwd } : {}), ...(closedTab.shellOverride ? { shellOverride: closedTab.shellOverride } : {}), ...(closedTab.customTitle ? { customTitle: closedTab.customTitle } : {}), - ...(closedTab.color ? { color: closedTab.color } : {}) + ...(closedTab.color ? { color: closedTab.color } : {}), + ...(closedPosition ? { position: closedPosition } : {}) } : null const nextExpanded = { ...s.expandedPaneByTabId }