diff --git a/src/renderer/src/store/slices/editor-close-workspace-selection.test.ts b/src/renderer/src/store/slices/editor-close-workspace-selection.test.ts new file mode 100644 index 00000000000..1a7e4769059 --- /dev/null +++ b/src/renderer/src/store/slices/editor-close-workspace-selection.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { createEditorTabsStore } from './editor-slice-test-harness' + +vi.mock('@/runtime/close-mirrored-editor-tab', () => ({ + notifyHostOfMirroredEditorClose: vi.fn() +})) + +const closeActions = ['closeFile', 'closeAllFiles'] as const +const workspaces = [ + { label: 'git worktree', id: 'wt-1' }, + { label: 'folder workspace', id: 'folder:folder-1' } +] as const + +function openMarkdown(store: ReturnType, worktreeId: string) { + return store.getState().openFile({ + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId, + language: 'markdown', + mode: 'edit' + }) +} + +describe.each(workspaces)('editor close selection in a $label', ({ id: worktreeId }) => { + it.each(closeActions)('%s reveals the surviving structured chat', (action) => { + const store = createEditorTabsStore() + store.setState({ activeWorktreeId: worktreeId }) + const chat = store.getState().createUnifiedTab(worktreeId, 'agent-session', { + entityId: 'session-1', + agentSessionAgent: 'codex', + label: 'Codex Chat' + }) + const fileId = openMarkdown(store, worktreeId) + + if (action === 'closeFile') { + store.getState().closeFile(fileId) + } else { + store.getState().openFile({ + filePath: '/repo/second.md', + relativePath: 'second.md', + worktreeId, + language: 'markdown', + mode: 'edit' + }) + store.getState().closeAllFiles() + } + + const state = store.getState() + expect(state.activeWorktreeId).toBe(worktreeId) + expect(state.activeTabType).toBe('agent-session') + expect(state.activeTabTypeByWorktree[worktreeId]).toBe('agent-session') + expect(state.unifiedTabsByWorktree[worktreeId]).toEqual([chat]) + expect(state.groupsByWorktree[worktreeId][0].activeTabId).toBe(chat.id) + expect(state.openFiles).toEqual([]) + expect(state.activeFileId).toBeNull() + }) + + it.each(closeActions)('%s returns to the welcome screen when no tab survives', (action) => { + const store = createEditorTabsStore() + store.setState({ activeWorktreeId: worktreeId }) + const fileId = openMarkdown(store, worktreeId) + + if (action === 'closeFile') { + store.getState().closeFile(fileId) + } else { + store.getState().closeAllFiles() + } + + expect(store.getState().activeWorktreeId).toBeNull() + expect(store.getState().unifiedTabsByWorktree[worktreeId]).toEqual([]) + }) +}) + +describe('editor close mixed tab history', () => { + it.each(closeActions)('%s reveals the most recently used remaining chat', (action) => { + const store = createEditorTabsStore() + const first = store.getState().createUnifiedTab('wt-1', 'agent-session', { + entityId: 'session-1', + agentSessionAgent: 'codex' + }) + const second = store.getState().createUnifiedTab('wt-1', 'agent-session', { + entityId: 'session-2', + agentSessionAgent: 'codex' + }) + store.getState().activateTab(first.id) + const fileId = openMarkdown(store, 'wt-1') + + if (action === 'closeFile') { + store.getState().closeFile(fileId) + } else { + store.getState().closeAllFiles() + } + + const state = store.getState() + expect(state.activeWorktreeId).toBe('wt-1') + expect(state.activeTabType).toBe('agent-session') + expect(state.groupsByWorktree['wt-1'][0].activeTabId).toBe(first.id) + expect(state.unifiedTabsByWorktree['wt-1'].map((tab) => tab.id)).toEqual([first.id, second.id]) + }) + + it.each(closeActions)('%s ignores chats in another workspace', (action) => { + const store = createEditorTabsStore() + const otherChat = store.getState().createUnifiedTab('wt-2', 'agent-session', { + entityId: 'session-2', + agentSessionAgent: 'codex', + activate: false + }) + const fileId = openMarkdown(store, 'wt-1') + + if (action === 'closeFile') { + store.getState().closeFile(fileId) + } else { + store.getState().closeAllFiles() + } + + expect(store.getState().activeWorktreeId).toBeNull() + expect(store.getState().unifiedTabsByWorktree['wt-2']).toEqual([otherChat]) + }) +}) diff --git a/src/renderer/src/store/slices/editor/actions/close-file-action.ts b/src/renderer/src/store/slices/editor/actions/close-file-action.ts index e130ff1d5b5..e84232d53a8 100644 --- a/src/renderer/src/store/slices/editor/actions/close-file-action.ts +++ b/src/renderer/src/store/slices/editor/actions/close-file-action.ts @@ -8,6 +8,7 @@ import { deleteUntouchedUntitledFile, shouldDeleteUntouchedUntitledFile } from '../tabs/untitled-file-cleanup' +import { unifiedTabsKeepWorktreeSelected } from './unified-tabs-keep-worktree-selected' export function createCloseFileAction( set: EditorSet, @@ -106,11 +107,19 @@ export function createCloseFileAction( newActiveTabTypeByWorktree[activeWorktreeId] = browserTabsForWorktree.length > 0 ? 'browser' : 'terminal' } + // Structured chats have no legacy terminal row to keep their workspace selected. + const hasRemainingUnifiedTabs = + activeWorktreeId !== null && + unifiedTabsKeepWorktreeSelected( + s.unifiedTabsByWorktree?.[activeWorktreeId], + new Set([fileId]) + ) const shouldDeactivateWorktree = activeWorktreeId !== null && remainingForWorktree.length === 0 && browserTabsForWorktree.length === 0 && - terminalTabsForWorktree.length === 0 + terminalTabsForWorktree.length === 0 && + !hasRemainingUnifiedTabs // Why: prune the closed id from tabBarOrderByWorktree so stale ids don't shift positions on the next reconcile. const worktreeId = closedFile?.worktreeId ?? activeWorktreeId diff --git a/src/renderer/src/store/slices/editor/actions/recently-closed-editor-tabs.ts b/src/renderer/src/store/slices/editor/actions/recently-closed-editor-tabs.ts index 38e427fb6ac..827a9a94c84 100644 --- a/src/renderer/src/store/slices/editor/actions/recently-closed-editor-tabs.ts +++ b/src/renderer/src/store/slices/editor/actions/recently-closed-editor-tabs.ts @@ -11,6 +11,7 @@ import { deleteUntouchedUntitledFile, shouldDeleteUntouchedUntitledFile } from '../tabs/untitled-file-cleanup' +import { unifiedTabsKeepWorktreeSelected } from './unified-tabs-keep-worktree-selected' export function createRecentlyClosedEditorTabs( set: EditorSet, @@ -122,14 +123,18 @@ export function createRecentlyClosedEditorTabs( const terminalTabsForWorktree = s.tabsByWorktree[activeWorktreeId] ?? [] newActiveTabTypeByWorktree[activeWorktreeId] = browserTabsForWorktree.length > 0 ? 'browser' : 'terminal' - const shouldDeactivateWorktree = - browserTabsForWorktree.length === 0 && terminalTabsForWorktree.length === 0 - // Why: mirrored tabs use host tab ids in tab order while local entries use file ids; remove both shapes. const closedFileIds = new Set( s.openFiles.filter((f) => f.worktreeId === activeWorktreeId).map((f) => f.id) ) const closedTabOrderIds = new Set([...closedFileIds, ...closingItemIds]) + const shouldDeactivateWorktree = + browserTabsForWorktree.length === 0 && + terminalTabsForWorktree.length === 0 && + !unifiedTabsKeepWorktreeSelected( + s.unifiedTabsByWorktree?.[activeWorktreeId], + closedTabOrderIds + ) const nextTabBarOrderByWorktree = s.tabBarOrderByWorktree ? { ...s.tabBarOrderByWorktree, diff --git a/src/renderer/src/store/slices/editor/actions/unified-tabs-keep-worktree-selected.ts b/src/renderer/src/store/slices/editor/actions/unified-tabs-keep-worktree-selected.ts new file mode 100644 index 00000000000..919f72aef85 --- /dev/null +++ b/src/renderer/src/store/slices/editor/actions/unified-tabs-keep-worktree-selected.ts @@ -0,0 +1,7 @@ +/** True when a non-closed unified tab (for example a structured chat) still belongs to the worktree. */ +export function unifiedTabsKeepWorktreeSelected( + tabs: readonly { id: string; entityId: string }[] | undefined, + closedIds: ReadonlySet +): boolean { + return (tabs ?? []).some((tab) => !closedIds.has(tab.id) && !closedIds.has(tab.entityId)) +}