fix(session): preserve tab/editor/browser chrome when a repo's startup scan degrades (#9991)

* fix(session): preserve chrome after degraded repo scan

* fix(session): retain terminal-free degraded workspaces
This commit is contained in:
Brennan Benson 2026-07-23 16:30:49 -07:00 committed by GitHub
parent 90255ab112
commit ee87bb38dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 314 additions and 49 deletions

View File

@ -52,6 +52,7 @@ import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity'
type CreateBrowserTabOptions = {
activate?: boolean
@ -1582,10 +1583,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
hydrateBrowserSession: (session, options) => {
const persistedTabsByWorktree = session.browserTabsByWorktree ?? {}
const currentState = get()
const validWorktreeIdsForCleanup = new Set(
Object.values(currentState.worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
const validWorktreeIdsForCleanup = buildValidWorktreeIdsForSessionHydration(
currentState,
Object.keys(persistedTabsByWorktree)
)
validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of currentState.folderWorkspaces) {
@ -1610,10 +1610,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {}
const persistedActiveBrowserTabIdByWorktree = session.activeBrowserTabIdByWorktree ?? {}
const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {}
const validWorktreeIds = new Set(
Object.values(s.worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(
s,
Object.keys(persistedTabsByWorktree)
)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of s.folderWorkspaces) {

View File

@ -0,0 +1,211 @@
import { expect, it, vi } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import type { BrowserTab, WorkspaceSessionState } from '../../../../shared/types'
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})
// @ts-expect-error -- mocked browser preload API
globalThis.window = { api: {} }
import {
buildWorkspaceSessionPayload,
shouldPersistWorkspaceSession
} from '@/lib/workspace-session'
import { createTestStore, makeTab } from './store-test-helpers'
const WORKTREE_ID = 'repo1::/path/degraded'
const TERMINAL_ID = 'terminal-degraded'
const EDITOR_FILE_ID = '/path/degraded/src/App.tsx'
const BROWSER_ID = 'browser-degraded'
const GROUP_ID = 'group-degraded'
function makeBrowserTab(): BrowserTab {
return {
id: BROWSER_ID,
worktreeId: WORKTREE_ID,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 3
}
}
function makeDegradedRepoSession(): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
activeRepoId: 'repo1',
activeWorktreeId: WORKTREE_ID,
activeTabId: TERMINAL_ID,
tabsByWorktree: {
[WORKTREE_ID]: [makeTab({ id: TERMINAL_ID, worktreeId: WORKTREE_ID })]
},
openFilesByWorktree: {
[WORKTREE_ID]: [
{
filePath: EDITOR_FILE_ID,
relativePath: 'src/App.tsx',
worktreeId: WORKTREE_ID,
language: 'typescript'
}
]
},
activeFileIdByWorktree: { [WORKTREE_ID]: EDITOR_FILE_ID },
browserTabsByWorktree: { [WORKTREE_ID]: [makeBrowserTab()] },
activeBrowserTabIdByWorktree: { [WORKTREE_ID]: BROWSER_ID },
activeTabTypeByWorktree: { [WORKTREE_ID]: 'browser' },
unifiedTabs: {
[WORKTREE_ID]: [
{
id: TERMINAL_ID,
entityId: TERMINAL_ID,
groupId: GROUP_ID,
worktreeId: WORKTREE_ID,
contentType: 'terminal',
label: 'Terminal',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
},
{
id: EDITOR_FILE_ID,
entityId: EDITOR_FILE_ID,
groupId: GROUP_ID,
worktreeId: WORKTREE_ID,
contentType: 'editor',
label: 'App.tsx',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 2
},
{
id: BROWSER_ID,
entityId: BROWSER_ID,
groupId: GROUP_ID,
worktreeId: WORKTREE_ID,
contentType: 'browser',
label: 'Example',
customLabel: null,
color: null,
sortOrder: 2,
createdAt: 3
}
]
},
tabGroups: {
[WORKTREE_ID]: [
{
id: GROUP_ID,
worktreeId: WORKTREE_ID,
activeTabId: BROWSER_ID,
tabOrder: [TERMINAL_ID, EDITOR_FILE_ID, BROWSER_ID],
recentTabIds: [TERMINAL_ID, EDITOR_FILE_ID, BROWSER_ID]
}
]
},
activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID }
}
}
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,
authoritative = false
): void {
store.setState({
repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }],
worktreesByRepo: { repo1: [] },
detectedWorktreesByRepo: {
repo1: {
repoId: 'repo1',
authoritative,
source: authoritative ? 'git' : 'metadata-fallback',
worktrees: []
}
}
})
store.getState().hydrateWorkspaceSession(session)
store.getState().hydrateTabsSession(session)
store.getState().hydrateEditorSession(session)
store.getState().hydrateBrowserSession(session)
}
it('keeps tab, editor, and browser chrome through degraded hydration and persistence', () => {
const firstStore = createTestStore()
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()
hydrateWithRepoScan(restoredStore, persisted)
const restored = restoredStore.getState()
expect(restored.unifiedTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([
TERMINAL_ID,
EDITOR_FILE_ID,
BROWSER_ID
])
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()
})

View File

@ -0,0 +1,75 @@
import type {
DetectedWorktreeListResult,
Repo,
WorkspaceSessionState,
Worktree
} from '../../../../shared/types'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
type WorktreeValidityCatalog = {
repos: readonly Pick<Repo, 'id'>[]
worktreesByRepo: Readonly<Record<string, readonly Pick<Worktree, 'id'>[]>>
detectedWorktreesByRepo?: Readonly<
Record<string, Pick<DetectedWorktreeListResult, 'authoritative'> | undefined>
>
}
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>
): Set<string> {
const worktreesByRepo = catalog.worktreesByRepo
const validWorktreeIds = new Set(
Object.values(worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
)
const knownRepoIds = new Set(catalog.repos.map((repo) => repo.id))
const repoIdsWithLoadedWorktrees = new Set(
Object.entries(worktreesByRepo)
.filter(([, worktrees]) => worktrees.length > 0)
.map(([repoId]) => repoId)
)
const repoIdsWithAuthoritativeDetectedWorktrees = new Set(
Object.entries(catalog.detectedWorktreesByRepo ?? {})
.filter(([, detected]) => detected?.authoritative)
.map(([repoId]) => repoId)
)
for (const worktreeId of persistedWorktreeIds) {
if (validWorktreeIds.has(worktreeId) || parseWorkspaceKey(worktreeId)?.type === 'folder') {
continue
}
const repoId = getRepoIdFromWorktreeId(worktreeId)
// Why (#1158): a failed scan cannot prove deletion, while loaded worktrees or an authoritative scan can.
if (
knownRepoIds.has(repoId) &&
!repoIdsWithLoadedWorktrees.has(repoId) &&
!repoIdsWithAuthoritativeDetectedWorktrees.has(repoId)
) {
validWorktreeIds.add(worktreeId)
}
}
return validWorktreeIds
}

View File

@ -69,6 +69,7 @@ import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity'
import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { translate } from '@/i18n/i18n'
@ -4407,11 +4408,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {}
const persistedMarkdownFrontmatterVisible = session.markdownFrontmatterVisible ?? {}
// Why: worktrees may have been deleted between sessions; drop files for worktrees that no longer exist.
const validWorktreeIds = new Set(
Object.values(s.worktreesByRepo)
.flat()
.map((w) => w.id)
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(
s,
Object.keys(openFilesByWorktree)
)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of s.folderWorkspaces) {

View File

@ -40,6 +40,10 @@ import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
import {
buildValidWorktreeIdsForSessionHydration,
collectPersistedWorktreeIdsForSessionHydration
} from './degraded-repo-worktree-validity'
export type TabSplitDirection = 'left' | 'right' | 'up' | 'down'
@ -1971,11 +1975,8 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
hydrateTabsSession: (session, options) => {
const state = get()
const validWorktreeIds = new Set(
Object.values(state.worktreesByRepo)
.flat()
.map((w) => w.id)
)
const persistedWorktreeIds = collectPersistedWorktreeIdsForSessionHydration(session)
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(state, persistedWorktreeIds)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of state.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))

View File

@ -104,6 +104,10 @@ import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
import {
buildValidWorktreeIdsForSessionHydration,
collectPersistedWorktreeIdsForSessionHydration
} from './degraded-repo-worktree-validity'
import {
collectHibernatedCompletionEvidenceForWorktree,
collectSleepingAgentSessionRecordsForWorktree,
@ -3074,45 +3078,21 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {},
worktreesByRepo: s.worktreesByRepo
})
const validWorktreeIds = new Set(
Object.values(runtimeSessionPlaceholders.worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(
{
repos: runtimeSessionPlaceholders.repos,
worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo,
detectedWorktreesByRepo: s.detectedWorktreesByRepo
},
collectPersistedWorktreeIdsForSessionHydration(session)
)
const knownRepoIds = new Set(runtimeSessionPlaceholders.repos.map((r) => r.id))
const repoIdsWithLoadedWorktrees = new Set(
Object.entries(runtimeSessionPlaceholders.worktreesByRepo)
.filter(([, worktrees]) => worktrees.length > 0)
.map(([repoId]) => repoId)
)
const repoIdsWithAuthoritativeDetectedWorktrees = new Set(
Object.entries(s.detectedWorktreesByRepo)
.filter(([, detected]) => detected.authoritative)
.map(([repoId]) => repoId)
)
// Why: the Floating Workspace isn't a repo worktree, but its tabs use the normal session pipeline so daemon PTYs survive app restart.
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of s.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
}
addAdditionalValidWorkspaceKeys(validWorktreeIds, options)
for (const worktreeId of Object.keys(session.tabsByWorktree)) {
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
if (parsedWorkspaceKey?.type === 'folder') {
continue
}
if (!validWorktreeIds.has(worktreeId)) {
const repoId = getRepoIdFromWorktreeId(worktreeId)
// Why (#1158): an empty/missing list can mean degraded hydration; a non-empty repo list is authoritative for deleted-worktree cleanup.
if (
knownRepoIds.has(repoId) &&
!repoIdsWithLoadedWorktrees.has(repoId) &&
!repoIdsWithAuthoritativeDetectedWorktrees.has(repoId)
) {
validWorktreeIds.add(worktreeId)
}
}
}
// Why pendingActivationSpawn: a restored worktree's first mount calls updateTabPtyId, which would bump lastActivityAt and bounce it to the top of Recent; the tag (consumed on the first pty update) suppresses that so only real activity bumps.
const tabsByWorktree: Record<string, TerminalTab[]> = Object.fromEntries(
Object.entries(session.tabsByWorktree)