fix(session): retain terminal-free degraded workspaces

This commit is contained in:
Brennan Benson
2026-07-22 15:42:35 -07:00
parent f27a7d8a38
commit cd168bf465
4 changed files with 91 additions and 15 deletions
@@ -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<typeof createTestStore>,
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()
})
@@ -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<string> {
const persistedWorktreeIds = new Set<string>()
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<string>
+5 -6
View File
@@ -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<AppState, [], [], TabsSlice> = (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) {
+5 -2
View File
@@ -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<AppState, [], [], TerminalSlice>
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.