diff --git a/src/renderer/src/store/slices/degraded-repo-hydration.test.ts b/src/renderer/src/store/slices/degraded-repo-hydration.test.ts index bac0144f9e4..93902bb9d49 100644 --- a/src/renderer/src/store/slices/degraded-repo-hydration.test.ts +++ b/src/renderer/src/store/slices/degraded-repo-hydration.test.ts @@ -117,9 +117,25 @@ function makeDegradedRepoSession(): WorkspaceSessionState { } } -function hydrateWithFailedScan( +function makeTerminalFreeDegradedRepoSession(): WorkspaceSessionState { + const session = makeDegradedRepoSession() + session.activeTabId = null + session.tabsByWorktree = {} + session.unifiedTabs![WORKTREE_ID] = session.unifiedTabs![WORKTREE_ID].filter( + (tab) => tab.contentType !== 'terminal' + ) + session.tabGroups![WORKTREE_ID] = session.tabGroups![WORKTREE_ID].map((group) => ({ + ...group, + tabOrder: [EDITOR_FILE_ID, BROWSER_ID], + recentTabIds: [EDITOR_FILE_ID, BROWSER_ID] + })) + return session +} + +function hydrateWithRepoScan( store: ReturnType, - session: WorkspaceSessionState + session: WorkspaceSessionState, + authoritative = false ): void { store.setState({ repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], @@ -127,8 +143,8 @@ function hydrateWithFailedScan( detectedWorktreesByRepo: { repo1: { repoId: 'repo1', - authoritative: false, - source: 'metadata-fallback', + authoritative, + source: authoritative ? 'git' : 'metadata-fallback', worktrees: [] } } @@ -141,14 +157,14 @@ function hydrateWithFailedScan( it('keeps tab, editor, and browser chrome through degraded hydration and persistence', () => { const firstStore = createTestStore() - hydrateWithFailedScan(firstStore, makeDegradedRepoSession()) + hydrateWithRepoScan(firstStore, makeDegradedRepoSession()) firstStore.setState({ workspaceSessionReady: true }) firstStore.getState().setHydrationSucceeded(true) expect(shouldPersistWorkspaceSession(firstStore.getState())).toBe(true) const persisted = buildWorkspaceSessionPayload(firstStore.getState()) const restoredStore = createTestStore() - hydrateWithFailedScan(restoredStore, persisted) + hydrateWithRepoScan(restoredStore, persisted) const restored = restoredStore.getState() expect(restored.unifiedTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ @@ -159,3 +175,37 @@ it('keeps tab, editor, and browser chrome through degraded hydration and persist expect(restored.openFiles.map((file) => file.id)).toEqual([EDITOR_FILE_ID]) expect(restored.browserTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([BROWSER_ID]) }) + +it('keeps a terminal-free degraded workspace selected through hydration and persistence', () => { + const firstStore = createTestStore() + hydrateWithRepoScan(firstStore, makeTerminalFreeDegradedRepoSession()) + + const first = firstStore.getState() + expect(first.activeWorktreeId).toBe(WORKTREE_ID) + expect(first.activeTabType).toBe('browser') + + firstStore.setState({ workspaceSessionReady: true }) + firstStore.getState().setHydrationSucceeded(true) + const persisted = buildWorkspaceSessionPayload(firstStore.getState()) + const restoredStore = createTestStore() + hydrateWithRepoScan(restoredStore, persisted) + + const restored = restoredStore.getState() + expect(restored.activeWorktreeId).toBe(WORKTREE_ID) + expect(restored.unifiedTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ + EDITOR_FILE_ID, + BROWSER_ID + ]) + expect(restored.activeTabType).toBe('browser') +}) + +it('drops terminal-free chrome when an authoritative scan proves deletion', () => { + const store = createTestStore() + hydrateWithRepoScan(store, makeTerminalFreeDegradedRepoSession(), true) + + const state = store.getState() + expect(state.activeWorktreeId).toBeNull() + expect(state.unifiedTabsByWorktree[WORKTREE_ID]).toBeUndefined() + expect(state.openFiles).toEqual([]) + expect(state.browserTabsByWorktree[WORKTREE_ID]).toBeUndefined() +}) diff --git a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts index a330fa44463..0fb4e42fd19 100644 --- a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts +++ b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts @@ -1,4 +1,9 @@ -import type { DetectedWorktreeListResult, Repo, Worktree } from '../../../../shared/types' +import type { + DetectedWorktreeListResult, + Repo, + WorkspaceSessionState, + Worktree +} from '../../../../shared/types' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' @@ -10,6 +15,25 @@ type WorktreeValidityCatalog = { > } +export function collectPersistedWorktreeIdsForSessionHydration( + session: WorkspaceSessionState +): Set { + const persistedWorktreeIds = new Set() + for (const worktreeId of Object.keys(session.tabsByWorktree)) { + persistedWorktreeIds.add(worktreeId) + } + for (const worktreeId of Object.keys(session.unifiedTabs ?? {})) { + persistedWorktreeIds.add(worktreeId) + } + for (const worktreeId of Object.keys(session.openFilesByWorktree ?? {})) { + persistedWorktreeIds.add(worktreeId) + } + for (const worktreeId of Object.keys(session.browserTabsByWorktree ?? {})) { + persistedWorktreeIds.add(worktreeId) + } + return persistedWorktreeIds +} + export function buildValidWorktreeIdsForSessionHydration( catalog: WorktreeValidityCatalog, persistedWorktreeIds: Iterable diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index f591e06594b..8391485997f 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -36,7 +36,10 @@ import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' -import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity' +import { + buildValidWorktreeIdsForSessionHydration, + collectPersistedWorktreeIdsForSessionHydration +} from './degraded-repo-worktree-validity' export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' @@ -1967,11 +1970,7 @@ export const createTabsSlice: StateCreator = (set, hydrateTabsSession: (session, options) => { const state = get() - const persistedWorktreeIds = new Set([ - ...Object.keys(session.tabsByWorktree), - ...Object.keys(session.unifiedTabs ?? {}), - ...Object.keys(session.openFilesByWorktree ?? {}) - ]) + const persistedWorktreeIds = collectPersistedWorktreeIdsForSessionHydration(session) const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(state, persistedWorktreeIds) validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) for (const workspace of state.folderWorkspaces) { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index eb8623373fd..50af773290b 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -99,7 +99,10 @@ import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' -import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity' +import { + buildValidWorktreeIdsForSessionHydration, + collectPersistedWorktreeIdsForSessionHydration +} from './degraded-repo-worktree-validity' import { collectHibernatedCompletionEvidenceForWorktree, collectSleepingAgentSessionRecordsForWorktree, @@ -3044,7 +3047,7 @@ export const createTerminalSlice: StateCreator worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo, detectedWorktreesByRepo: s.detectedWorktreesByRepo }, - Object.keys(session.tabsByWorktree) + collectPersistedWorktreeIdsForSessionHydration(session) ) const knownRepoIds = new Set(runtimeSessionPlaceholders.repos.map((r) => r.id)) // Why: the Floating Workspace isn't a repo worktree, but its tabs use the normal session pipeline so daemon PTYs survive app restart.