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:
parent
90255ab112
commit
ee87bb38dd
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue