fix(session): preserve chrome after degraded repo scan

This commit is contained in:
Brennan Benson
2026-07-22 13:12:24 -07:00
parent 0121f571e4
commit f27a7d8a38
6 changed files with 238 additions and 49 deletions
+7 -8
View File
@@ -55,6 +55,7 @@ import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity'
type CreateBrowserTabOptions = {
activate?: boolean
@@ -1502,10 +1503,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) {
@@ -1530,10 +1530,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) {
@@ -0,0 +1,161 @@
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 hydrateWithFailedScan(
store: ReturnType<typeof createTestStore>,
session: WorkspaceSessionState
): void {
store.setState({
repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }],
worktreesByRepo: { repo1: [] },
detectedWorktreesByRepo: {
repo1: {
repoId: 'repo1',
authoritative: false,
source: '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()
hydrateWithFailedScan(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)
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])
})
@@ -0,0 +1,51 @@
import type { DetectedWorktreeListResult, Repo, 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 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
}
+4 -5
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'
@@ -4364,11 +4365,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) {
+7 -5
View File
@@ -36,6 +36,7 @@ import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity'
export type TabSplitDirection = 'left' | 'right' | 'up' | 'down'
@@ -1966,11 +1967,12 @@ 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 = new Set([
...Object.keys(session.tabsByWorktree),
...Object.keys(session.unifiedTabs ?? {}),
...Object.keys(session.openFilesByWorktree ?? {})
])
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(state, persistedWorktreeIds)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of state.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
+8 -31
View File
@@ -99,6 +99,7 @@ import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity'
import {
collectHibernatedCompletionEvidenceForWorktree,
collectSleepingAgentSessionRecordsForWorktree,
@@ -3037,45 +3038,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
},
Object.keys(session.tabsByWorktree)
)
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)