Speed up startup with deferred remote catalogs (#7087)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-07-06 15:15:15 -07:00 committed by GitHub
parent 014fd3cf26
commit bc84f4b04b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 2368 additions and 319 deletions

View File

@ -112,10 +112,11 @@ import {
} from './lib/workspace-session'
import { createSessionWriteSubscriber } from './lib/session-write-subscriber'
import {
fetchWorkspaceSessionFromHosts,
fetchWorkspaceSessionWithRuntimeHostOwners,
patchWorkspaceSessionByHost,
persistWorkspaceSessionByHostSync
} from './lib/workspace-session-host-persistence'
import { collectFolderWorkspaceKeysFromSession } from './lib/workspace-session-hydration-keys'
import {
getStartupErrorFallbackUI,
hydratePersistedUIAfterStartupRead
@ -158,6 +159,7 @@ import {
type KeybindingContext,
type PhysicalModifierToken
} from '../../shared/keybindings'
import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import {
ModifierDoubleTapDetector,
toModifierDoubleTapEvent
@ -180,6 +182,17 @@ const hasCustomTitleBar = shouldRenderDesktopWindowChrome({
isWebClient: isPairedWebClientWindow()
})
async function listRuntimeSessionHostIdsForStartup(): Promise<ExecutionHostId[]> {
try {
return (await window.api.runtimeEnvironments.list()).map((environment) =>
toRuntimeExecutionHostId(environment.id)
)
} catch (err) {
console.warn('Failed to list runtime session hosts for startup:', err)
return []
}
}
function getKeybindingContext(target: EventTarget | null): KeybindingContext {
return target instanceof HTMLElement && target.classList.contains('xterm-helper-textarea')
? 'terminal'
@ -855,17 +868,15 @@ function App(): React.JSX.Element {
// Load settings first so a persisted remote runtime does not boot against
// the local filesystem and then hydrate stale local workspace state.
await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings())
// Why: these three reads are main-side store/file reads with no
// dependency on anything fetched below, so start them now and await
// them at their original positions — the round-trips overlap the
// repo/worktree scans instead of queuing after them. Browser session
// profiles are deliberately NOT started early: on a remote runtime
// they route through a runtime RPC that may not be connected this
// early, and a failed fetch clears the profile list. The floating
// .catch marks the rejection handled if an earlier awaited step
// throws first; each await still rethrows its own failure.
const uiGetPromise = timeRendererStartupStep('ui-get', () => window.api.ui.get())
uiGetPromise.catch(() => {})
// Why: keybindings + onboarding are main-side reads with no dependency
// on the catalog/session steps below, so start them now and await them
// at their original positions — the round-trips overlap the local
// catalog scans instead of queuing after them. Browser session profiles
// are deliberately NOT started early: on a remote runtime they route
// through a runtime RPC that may not be connected this early, and a
// failed fetch clears the profile list. The floating .catch marks the
// rejection handled if an earlier awaited step throws first; each await
// still rethrows its own failure.
const keybindingsPromise = timeRendererStartupStep('fetch-keybindings', () =>
actions.fetchKeybindings()
)
@ -874,36 +885,11 @@ function App(): React.JSX.Element {
window.api.onboarding.get()
)
onboardingPromise.catch(() => {})
// Why: load local + every configured runtime environment (not just the
// active one) so a cold start that restored a remote workspace doesn't
// hide local repos. The sidebar "All hosts" scope then shows them all.
await timeRendererStartupStep('fetch-repos', () => actions.fetchReposForAllHosts())
// Why: project-groups/folder-workspaces read neither the repos store nor
// worktrees, so once repos land they can overlap the per-repo
// `git worktree list` fan-out (#7225) instead of queuing ahead of it — a
// slow remote host's 15s scope RPCs no longer block the scan. folders
// still follow project-groups (they read projectGroups); all settle
// before the hydrate steps below.
const projectScopeChain = (async () => {
await timeRendererStartupStep('fetch-project-groups', () =>
actions.fetchProjectGroupsForAllHosts()
)
await timeRendererStartupStep('fetch-folder-workspaces', () =>
actions.fetchFolderWorkspacesForAllHosts()
)
})()
// Why: worktrees + lineage both fan out `git worktree list` per repo on
// the main process. Running them concurrently lets it share one
// in-flight scan per repo instead of paying the process-spawn fan-out
// twice back-to-back — the dominant renderer-chain cost on Windows
// (issue #7225). Lineage only reads settings + its own slice, so it
// does not depend on the worktrees fetch having landed.
await Promise.all([
projectScopeChain,
timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees()),
timeRendererStartupStep('fetch-worktree-lineage', () => actions.fetchWorktreeLineage())
])
const persistedUI = await uiGetPromise
// Why: hydrate persisted UI immediately after ui.get() so first paint
// reflects saved view settings before the catalog scans below. ui.get()
// is awaited (not overlapped) because the hydrate must land before the
// local-first catalog/session steps run.
const persistedUI = await timeRendererStartupStep('ui-get', () => window.api.ui.get())
uiHydrated = timeRendererStartupSyncStep('hydrate-persisted-ui', () =>
hydratePersistedUIAfterStartupRead({
persistedUI,
@ -911,20 +897,49 @@ function App(): React.JSX.Element {
hydratePersistedUI: actions.hydratePersistedUI
})
)
const startupRuntimeHostIds = await timeRendererStartupStep(
'list-runtime-session-hosts',
listRuntimeSessionHostIdsForStartup
)
// Why: first paint needs local data and persisted view settings, but
// saved remote runtimes can spend the full connect timeout. Load only
// the local catalog here; remotes refresh after hydration below.
await timeRendererStartupStep('fetch-repos-local', () =>
actions.fetchReposForAllHosts({ remoteHosts: 'skip' })
)
await timeRendererStartupStep('fetch-project-groups-local', () =>
actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })
)
await timeRendererStartupStep('fetch-folder-workspaces-local', () =>
actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })
)
await timeRendererStartupStep('fetch-worktrees', () =>
actions.fetchAllWorktrees({ hydrationPurge: 'defer' })
)
// Why: runtime-owned worktree slices live in per-host partitions.
// Repos were fetched above, so the known runtime hosts are derivable
// here; merge their slices into the unified session the hydrators
// expect. An unreadable host partition is skipped (fail-soft).
const session = await timeRendererStartupStep('session-get', () =>
fetchWorkspaceSessionFromHosts(window.api.session, useAppStore.getState().repos)
// Remote catalogs now load after first paint, so include saved runtime
// host ids from local settings to restore their persisted session slices
// without waiting on network reachability. Unreadable partitions skip.
const sessionRead = await timeRendererStartupStep('session-get', () =>
fetchWorkspaceSessionWithRuntimeHostOwners(
window.api.session,
useAppStore.getState().repos,
startupRuntimeHostIds
)
)
await keybindingsPromise
if (!cancelled) {
const sessionHydrationOptions = {
additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session)
}
timeRendererStartupSyncStep('hydrate-session-stores', () => {
actions.hydrateWorkspaceSession(session)
actions.hydrateTabsSession(session)
actions.hydrateEditorSession(session)
actions.hydrateBrowserSession(session)
actions.hydrateWorkspaceSession(sessionRead.session, {
...sessionHydrationOptions,
runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey
})
actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions)
actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions)
actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions)
})
// Why: prune lastVisitedAtByWorktreeId entries whose worktrees
// no longer exist. Must run AFTER hydration — before this point,
@ -952,7 +967,7 @@ function App(): React.JSX.Element {
// tabs through pty.attach on the relay. Passphrase-protected targets
// are deferred to tab focus to avoid stacking credential dialogs at
// startup before the user has context.
const connectionIds = session.activeConnectionIdsAtShutdown ?? []
const connectionIds = sessionRead.session.activeConnectionIdsAtShutdown ?? []
if (connectionIds.length > 0) {
try {
const SSH_RECONNECT_TIMEOUT_MS = 15_000
@ -1064,6 +1079,23 @@ function App(): React.JSX.Element {
logRendererStartupDiagnostic('startup-hydration-done', {
durationMs: Math.round(performance.now() - startupStartedAt)
})
void (async () => {
try {
await timeRendererStartupStep('remote-catalog-refresh', async () => {
await actions.fetchReposForAllHosts()
await actions.fetchProjectGroupsForAllHosts()
await actions.fetchFolderWorkspacesForAllHosts()
})
if (!cancelled) {
await timeRendererStartupStep('remote-worktree-refresh', async () => {
await actions.fetchAllWorktrees()
await actions.fetchWorktreeLineage()
})
}
} catch (err) {
console.warn('Remote startup catalog refresh failed:', err)
}
})()
}
} catch (error) {
// Why (issue #1158): previously this catch called hydrateWorkspaceSession

View File

@ -3,41 +3,64 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
describe('renderer startup runtime routing', () => {
it('loads settings before repo and worktree hydration', () => {
it('hydrates persisted UI before local catalog and worktree hydration', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const startupBlockStart = source.indexOf('void (async () => {')
const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise')
const startupBlockEnd = source.indexOf("timeRendererStartupStep('session-get'")
const startupBlock = source.slice(startupBlockStart, startupBlockEnd)
const settingsIndex = startupBlock.indexOf('actions.fetchSettings()')
expect(settingsIndex).toBeGreaterThanOrEqual(0)
expect(settingsIndex).toBeLessThan(startupBlock.indexOf('actions.fetchReposForAllHosts()'))
expect(settingsIndex).toBeLessThan(startupBlock.indexOf('actions.fetchAllWorktrees()'))
})
it('overlaps sidebar scope loads with worktree hydration before session hydration', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const startupBlockStart = source.indexOf('void (async () => {')
const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise')
const startupBlock = source.slice(startupBlockStart, startupBlockEnd)
const reposIndex = startupBlock.indexOf('actions.fetchReposForAllHosts()')
const scopeChainIndex = startupBlock.indexOf('const projectScopeChain = (async () => {')
const projectGroupsIndex = startupBlock.indexOf('actions.fetchProjectGroupsForAllHosts()')
const folderWorkspacesIndex = startupBlock.indexOf('actions.fetchFolderWorkspacesForAllHosts()')
const promiseAllIndex = startupBlock.indexOf('await Promise.all([')
const awaitedScopeChainIndex = startupBlock.indexOf('projectScopeChain', promiseAllIndex)
const worktreesIndex = startupBlock.indexOf('actions.fetchAllWorktrees()')
const uiGetIndex = startupBlock.indexOf("timeRendererStartupStep('ui-get'")
const hydrateUiIndex = startupBlock.indexOf(
"timeRendererStartupSyncStep('hydrate-persisted-ui'"
)
const localReposIndex = startupBlock.indexOf(
"actions.fetchReposForAllHosts({ remoteHosts: 'skip' })"
)
const localGroupsIndex = startupBlock.indexOf(
"actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })"
)
const localFoldersIndex = startupBlock.indexOf(
"actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })"
)
const localWorktreesIndex = startupBlock.indexOf(
"actions.fetchAllWorktrees({ hydrationPurge: 'defer' })"
)
const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()')
expect(reposIndex).toBeGreaterThanOrEqual(0)
expect(scopeChainIndex).toBeGreaterThan(reposIndex)
expect(projectGroupsIndex).toBeGreaterThan(scopeChainIndex)
expect(folderWorkspacesIndex).toBeGreaterThan(projectGroupsIndex)
expect(promiseAllIndex).toBeGreaterThan(scopeChainIndex)
expect(awaitedScopeChainIndex).toBeGreaterThan(promiseAllIndex)
expect(worktreesIndex).toBeGreaterThan(promiseAllIndex)
expect(lineageIndex).toBeGreaterThan(promiseAllIndex)
expect(settingsIndex).toBeGreaterThanOrEqual(0)
expect(startupBlockEnd).toBeGreaterThan(startupBlockStart)
expect(settingsIndex).toBeLessThan(uiGetIndex)
expect(uiGetIndex).toBeLessThan(hydrateUiIndex)
expect(hydrateUiIndex).toBeLessThan(localReposIndex)
expect(localReposIndex).toBeLessThan(localGroupsIndex)
expect(localGroupsIndex).toBeLessThan(localFoldersIndex)
expect(localFoldersIndex).toBeLessThan(localWorktreesIndex)
expect(lineageIndex).toBe(-1)
})
it('refreshes remote catalogs after startup hydration succeeds', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const hydrationDoneIndex = source.indexOf(
"logRendererStartupDiagnostic('startup-hydration-done'"
)
const remoteCatalogIndex = source.indexOf("timeRendererStartupStep('remote-catalog-refresh'")
const remoteWorktreeIndex = source.indexOf("timeRendererStartupStep('remote-worktree-refresh'")
const lineageIndex = source.indexOf('actions.fetchWorktreeLineage()')
expect(hydrationDoneIndex).toBeGreaterThanOrEqual(0)
expect(hydrationDoneIndex).toBeLessThan(remoteCatalogIndex)
expect(remoteCatalogIndex).toBeLessThan(remoteWorktreeIndex)
expect(remoteWorktreeIndex).toBeLessThan(lineageIndex)
expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain(
'actions.fetchReposForAllHosts()'
)
expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain(
'actions.fetchProjectGroupsForAllHosts()'
)
expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain(
'actions.fetchFolderWorkspacesForAllHosts()'
)
})
it('waits for first-window startup services before terminal reconnect', () => {

View File

@ -112,6 +112,13 @@ export function filterSetupScriptPromptDismissalsToValidRepos(
value: unknown,
validRepoIds: Set<string>
): string[] {
return sanitizeSetupScriptPromptDismissals(value).filter((entry) => {
const repoId = entry.slice(SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX.length)
return validRepoIds.has(repoId)
})
}
export function sanitizeSetupScriptPromptDismissals(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
@ -121,8 +128,7 @@ export function filterSetupScriptPromptDismissalsToValidRepos(
if (typeof entry !== 'string' || !entry.startsWith(SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX)) {
continue
}
const repoId = entry.slice(SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX.length)
if (validRepoIds.has(repoId) && !next.includes(entry)) {
if (!next.includes(entry)) {
next.push(entry)
}
}

View File

@ -0,0 +1,386 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import type { WorkspaceSessionState } from '../../../shared/types'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import {
buildHostIdByWorktreeId,
fetchWorkspaceSessionFromHosts,
fetchWorkspaceSessionWithRuntimeHostOwners,
patchWorkspaceSessionByHost
} from './workspace-session-host-persistence'
describe('fetchWorkspaceSessionFromHosts', () => {
it('reads saved runtime host partitions before runtime repos are loaded', async () => {
const worktreeId = 'remote-repo::/srv/remote-wt'
const localSession: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
activeWorktreeId: 'local-wt'
}
const remoteSession: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[worktreeId]: [
{
id: 'remote-tab',
ptyId: null,
worktreeId,
title: 'Remote',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
}
const get = vi.fn(async (hostId?: string) =>
hostId === 'runtime:env-1' ? remoteSession : localSession
)
const session = await fetchWorkspaceSessionFromHosts({ get }, [], ['runtime:env-1'])
expect(get).toHaveBeenCalledWith()
expect(get).toHaveBeenCalledWith('runtime:env-1')
expect(session.activeWorktreeId).toBe('local-wt')
expect(session.tabsByWorktree[worktreeId]).toEqual(remoteSession.tabsByWorktree[worktreeId])
})
it('returns runtime owners for worktrees loaded from runtime host partitions', async () => {
const worktreeId = 'remote-repo::/srv/remote-wt'
const get = vi.fn(async (hostId?: string): Promise<WorkspaceSessionState> => {
if (hostId === 'runtime:env-1') {
return {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[worktreeId]: [
{
id: 'remote-tab',
ptyId: null,
worktreeId,
title: 'Remote',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
}
}
return getDefaultWorkspaceSession()
})
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
expect(read.session.tabsByWorktree[worktreeId]).toHaveLength(1)
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' })
})
it('normalizes canonical worktree session keys in runtime owner maps', async () => {
const worktreeId = 'remote-repo::/srv/remote-wt'
const workspaceKey = worktreeWorkspaceKey(worktreeId)
const get = vi.fn(async (hostId?: string): Promise<WorkspaceSessionState> => {
if (hostId === 'runtime:env-1') {
return {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[workspaceKey]: [
{
id: 'remote-tab',
ptyId: null,
worktreeId,
title: 'Remote',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
}
}
return getDefaultWorkspaceSession()
})
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' })
})
it('returns runtime owners for folder workspace session keys', async () => {
const folderKey = folderWorkspaceKey('folder-1')
const get = vi.fn(async (hostId?: string): Promise<WorkspaceSessionState> => {
if (hostId === 'runtime:env-1') {
return {
...getDefaultWorkspaceSession(),
activeWorkspaceKey: folderKey,
tabsByWorktree: {
[folderKey]: [
{
id: 'remote-folder-tab',
ptyId: null,
worktreeId: folderKey,
title: 'Remote folder',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
}
}
return getDefaultWorkspaceSession()
})
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
expect(read.session.tabsByWorktree[folderKey]).toHaveLength(1)
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [folderKey]: 'runtime:env-1' })
})
it('returns runtime owners for sleeping-agent-only runtime worktrees', async () => {
const worktreeId = 'remote-repo::/srv/sleeping-wt'
const get = vi.fn(async (hostId?: string): Promise<WorkspaceSessionState> => {
if (hostId === 'runtime:env-1') {
return {
...getDefaultWorkspaceSession(),
sleepingAgentSessionsByPaneKey: {
'remote-tab:leaf-1': {
paneKey: 'remote-tab:leaf-1',
tabId: 'remote-tab',
worktreeId,
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session-1' },
prompt: 'finish the task',
state: 'working',
capturedAt: 1,
updatedAt: 1
}
}
}
}
return getDefaultWorkspaceSession()
})
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
expect(read.session.sleepingAgentSessionsByPaneKey?.['remote-tab:leaf-1']?.worktreeId).toBe(
worktreeId
)
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' })
})
it('routes restored runtime folder workspace patches back to the runtime host', async () => {
const folderKey = folderWorkspaceKey('folder-1')
const patch = vi.fn().mockResolvedValue(undefined)
await patchWorkspaceSessionByHost(
{ get: vi.fn(), patch, setSync: vi.fn() },
{
tabsByWorktree: {
[folderKey]: [
{
id: 'remote-folder-tab',
ptyId: null,
worktreeId: folderKey,
title: 'Remote folder',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
},
{
repos: [],
worktreesByRepo: {},
restoredRuntimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' }
}
)
expect(patch).toHaveBeenCalledWith(expect.objectContaining({ tabsByWorktree: {} }))
expect(patch).toHaveBeenCalledWith(
expect.objectContaining({
tabsByWorktree: expect.objectContaining({
[folderKey]: expect.any(Array)
})
}),
'runtime:env-1'
)
})
it('keeps catalog-known local folder workspace patches local over stale restored owners', async () => {
const folderKey = folderWorkspaceKey('folder-1')
const patch = vi.fn().mockResolvedValue(undefined)
await patchWorkspaceSessionByHost(
{ get: vi.fn(), patch, setSync: vi.fn() },
{
tabsByWorktree: {
[folderKey]: [
{
id: 'local-folder-tab',
ptyId: null,
worktreeId: folderKey,
title: 'Local folder',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
},
{
repos: [],
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group-1' }],
projectGroups: [{ id: 'group-1', executionHostId: 'local' }],
worktreesByRepo: {},
restoredRuntimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:stale-env' }
}
)
expect(patch).toHaveBeenCalledTimes(1)
expect(patch.mock.calls[0][0]).toEqual(
expect.objectContaining({
tabsByWorktree: expect.objectContaining({
[folderKey]: expect.any(Array)
})
})
)
expect(patch.mock.calls[0][1]).toBeUndefined()
})
it('routes placeholder-owned runtime worktree patches back to the runtime host', async () => {
const worktreeId = 'remote-repo::/srv/remote-wt'
const patch = vi.fn().mockResolvedValue(undefined)
await patchWorkspaceSessionByHost(
{ get: vi.fn(), patch, setSync: vi.fn() },
{
tabsByWorktree: {
[worktreeId]: [
{
id: 'remote-tab',
ptyId: null,
worktreeId,
title: 'Remote',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
},
{
repos: [{ id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' }],
worktreesByRepo: { 'remote-repo': [{ id: worktreeId, repoId: 'remote-repo' }] }
}
)
expect(patch).toHaveBeenCalledWith(expect.objectContaining({ tabsByWorktree: {} }))
expect(patch).toHaveBeenCalledWith(
expect.objectContaining({
tabsByWorktree: expect.objectContaining({
[worktreeId]: expect.any(Array)
})
}),
'runtime:env-1'
)
})
it('keeps same-id local repo worktrees in the local partition', async () => {
const localWorktreeId = 'same-repo::/Users/me/project'
const remoteWorktreeId = 'same-repo::/srv/project'
const patch = vi.fn().mockResolvedValue(undefined)
await patchWorkspaceSessionByHost(
{ get: vi.fn(), patch, setSync: vi.fn() },
{
tabsByWorktree: {
[localWorktreeId]: [
{
id: 'local-tab',
ptyId: null,
worktreeId: localWorktreeId,
title: 'Local',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
],
[remoteWorktreeId]: [
{
id: 'remote-tab',
ptyId: null,
worktreeId: remoteWorktreeId,
title: 'Remote',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
},
{
repos: [
{ id: 'same-repo', connectionId: null, executionHostId: 'local' },
{ id: 'same-repo', connectionId: null, executionHostId: 'runtime:env-1' }
],
worktreesByRepo: {
'same-repo': [
{ id: localWorktreeId, repoId: 'same-repo' },
{ id: remoteWorktreeId, repoId: 'same-repo', hostId: 'runtime:env-1' }
]
}
}
)
expect(patch).toHaveBeenCalledWith(
expect.objectContaining({
tabsByWorktree: {
[localWorktreeId]: expect.any(Array)
}
})
)
expect(patch).toHaveBeenCalledWith(
expect.objectContaining({
tabsByWorktree: expect.objectContaining({
[remoteWorktreeId]: expect.any(Array)
})
}),
'runtime:env-1'
)
})
it('defaults duplicate repo ids to local when the worktree has no host metadata', () => {
const owner = buildHostIdByWorktreeId({
repos: [
{ id: 'same-repo', connectionId: null, executionHostId: 'local' },
{ id: 'same-repo', connectionId: null, executionHostId: 'runtime:env-1' }
],
worktreesByRepo: {
'same-repo': [{ id: 'same-repo::/local-only', repoId: 'same-repo' }]
}
})
expect(owner('same-repo::/local-only')).toBe('local')
})
it('routes canonical worktree keys using their raw worktree owner', () => {
const worktreeId = 'remote-repo::/srv/remote-wt'
const owner = buildHostIdByWorktreeId({
repos: [],
worktreesByRepo: {
'remote-repo': [{ id: worktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }]
}
})
expect(owner(worktreeWorkspaceKey(worktreeId))).toBe('runtime:env-1')
})
})

View File

@ -10,6 +10,7 @@ import {
parseExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
import {
mergeWorkspaceSessionsFromHosts,
@ -20,7 +21,10 @@ import {
export type HostPersistenceState = {
repos: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
worktreesByRepo: Record<string, readonly Pick<Worktree, 'id' | 'repoId'>[]>
projectGroups?: readonly { id: string; executionHostId?: string | null }[]
folderWorkspaces?: readonly { id: string; projectGroupId: string }[]
worktreesByRepo: Record<string, readonly Pick<Worktree, 'id' | 'repoId' | 'hostId'>[]>
restoredRuntimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
}
type SessionApi = {
@ -29,6 +33,119 @@ type SessionApi = {
setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void
}
export type WorkspaceSessionHostRead = {
session: WorkspaceSessionState
runtimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
}
const WORKSPACE_SESSION_KEYED_FIELDS = [
'tabsByWorktree',
'openFilesByWorktree',
'activeFileIdByWorktree',
'activeBrowserTabIdByWorktree',
'activeTabTypeByWorktree',
'activeTabIdByWorktree',
'browserTabsByWorktree',
'unifiedTabs',
'tabGroups',
'tabGroupLayouts',
'activeGroupIdByWorktree',
'lastVisitedAtByWorktreeId',
'defaultTerminalTabsAppliedByWorktreeId'
] as const satisfies readonly (keyof WorkspaceSessionState)[]
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function normalizeWorkspaceSessionKeyForOwnerMap(value: string): string {
const scope = parseWorkspaceKey(value)
return scope?.type === 'worktree' ? scope.worktreeId : value
}
function addWorkspaceSessionKeyForOwnerMap(ids: Set<string>, value: unknown): void {
if (typeof value === 'string') {
ids.add(normalizeWorkspaceSessionKeyForOwnerMap(value))
}
}
function collectWorkspaceSessionKeysFromHostSession(session: WorkspaceSessionState): string[] {
const ids = new Set<string>()
for (const field of WORKSPACE_SESSION_KEYED_FIELDS) {
const value = session[field]
if (isPlainRecord(value)) {
for (const id of Object.keys(value)) {
addWorkspaceSessionKeyForOwnerMap(ids, id)
}
}
}
for (const id of session.activeWorktreeIdsOnShutdown ?? []) {
addWorkspaceSessionKeyForOwnerMap(ids, id)
}
for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) {
if (!Array.isArray(pages)) {
continue
}
for (const page of pages) {
addWorkspaceSessionKeyForOwnerMap(ids, page.worktreeId)
}
}
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
// Why: a hibernated agent can be the only restored session evidence for a
// runtime worktree before its remote catalog answers.
addWorkspaceSessionKeyForOwnerMap(ids, record.worktreeId)
}
return [...ids]
}
function buildRuntimeHostIdByWorkspaceSessionKey(
slices: HostSessionSlices
): Record<string, ExecutionHostId> {
const owners: Record<string, ExecutionHostId> = {}
for (const [hostId, slice] of nonLocalEntries(slices)) {
for (const worktreeId of collectWorkspaceSessionKeysFromHostSession(slice)) {
owners[worktreeId] = hostId
}
}
return owners
}
function getRestoredRuntimeHostId(
owners: Record<string, ExecutionHostId> | undefined,
key: string
): ExecutionHostId | null {
const hostId = owners?.[key]
return hostId && parseExecutionHostId(hostId)?.kind === 'runtime' ? hostId : null
}
function getFolderWorkspaceRuntimeHostId(
state: HostPersistenceState,
key: string
): ExecutionHostId {
const scope = parseWorkspaceKey(key)
if (scope?.type !== 'folder') {
return LOCAL_EXECUTION_HOST_ID
}
const workspace = state.folderWorkspaces?.find((entry) => entry.id === scope.folderWorkspaceId)
const group = workspace
? state.projectGroups?.find((entry) => entry.id === workspace.projectGroupId)
: null
const parsed = parseExecutionHostId(group?.executionHostId)
if (parsed) {
return parsed.kind === 'runtime' ? parsed.id : LOCAL_EXECUTION_HOST_ID
}
if (workspace && group) {
// Why: once the folder and group catalogs are both known, a missing runtime
// owner is authoritative local/SSH persistence, not a startup gap.
return LOCAL_EXECUTION_HOST_ID
}
const restoredHostId = getRestoredRuntimeHostId(
state.restoredRuntimeHostIdByWorkspaceSessionKey,
key
)
return restoredHostId ?? LOCAL_EXECUTION_HOST_ID
}
/** Map a worktree to the host partition it persists under.
*
* Why: only `runtime:*` worktrees are partitioned out. SSH-owned worktrees stay
@ -36,21 +153,43 @@ type SessionApi = {
* the unified blob) and separately mirrors them to each target's remote
* snapshot partitioning them too would double-own that data. */
export function buildHostIdByWorktreeId(state: HostPersistenceState): HostIdByWorktreeId {
const repoById = new Map(state.repos.map((repo) => [repo.id, repo]))
const repoHostById = new Map<string, ExecutionHostId | null>()
for (const repo of state.repos) {
const hostId = getRepoExecutionHostId(repo)
const existing = repoHostById.get(repo.id)
// Why: repo ids can repeat across hosts; ambiguous repo-only ownership
// must not let a runtime placeholder steal local session state.
repoHostById.set(repo.id, existing === undefined ? hostId : existing === hostId ? hostId : null)
}
const repoIdByWorktreeId = new Map<string, string>()
const runtimeHostIdByWorktreeId = new Map<string, ExecutionHostId>()
for (const worktrees of Object.values(state.worktreesByRepo)) {
for (const worktree of worktrees) {
repoIdByWorktreeId.set(worktree.id, worktree.repoId)
const parsedWorktreeHost = parseExecutionHostId(worktree.hostId)
if (parsedWorktreeHost?.kind === 'runtime') {
runtimeHostIdByWorktreeId.set(worktree.id, parsedWorktreeHost.id)
}
}
}
return (worktreeId: string): ExecutionHostId => {
const repoId = repoIdByWorktreeId.get(worktreeId) ?? getRepoIdFromWorktreeId(worktreeId)
const repo = repoId ? repoById.get(repoId) : undefined
if (!repo) {
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
return getFolderWorkspaceRuntimeHostId(state, worktreeId)
}
const rawWorktreeId =
workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId
const worktreeHostId = runtimeHostIdByWorktreeId.get(rawWorktreeId)
if (worktreeHostId) {
return worktreeHostId
}
const repoId = repoIdByWorktreeId.get(rawWorktreeId) ?? getRepoIdFromWorktreeId(rawWorktreeId)
const repoHostId = repoId ? repoHostById.get(repoId) : undefined
if (!repoHostId) {
return LOCAL_EXECUTION_HOST_ID
}
const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
const parsed = parseExecutionHostId(repoHostId)
return parsed?.kind === 'runtime' ? parsed.id : LOCAL_EXECUTION_HOST_ID
}
}
@ -112,21 +251,37 @@ export function listKnownRuntimeHostIds(
}
/** Boot-time hydration: fetch the local partition plus one partition per known
* runtime host (repos are already loaded before session hydration in App.tsx)
* and merge them into the unified session the hydrators expect.
* runtime host (from loaded repos and saved runtime ids), then merge them into
* the unified session the hydrators expect.
*
* Fail-soft: a partition whose fetch rejects is skipped boot proceeds with
* the rest. Corrupt partitions never reach here; persistence zod-validates
* each one and falls back to defaults on the main side. */
export async function fetchWorkspaceSessionFromHosts(
api: Pick<SessionApi, 'get'>,
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[]
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[],
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
): Promise<WorkspaceSessionState> {
return (await fetchWorkspaceSessionWithRuntimeHostOwners(api, repos, additionalRuntimeHostIds))
.session
}
export async function fetchWorkspaceSessionWithRuntimeHostOwners(
api: Pick<SessionApi, 'get'>,
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[],
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
): Promise<WorkspaceSessionHostRead> {
const slices: HostSessionSlices = {
[LOCAL_EXECUTION_HOST_ID]: await api.get()
}
// Why: startup can know saved runtime session hosts before their repo
// catalogs hydrate, so include those partitions in the first read.
const runtimeHostIds = new Set<ExecutionHostId>([
...listKnownRuntimeHostIds(repos),
...additionalRuntimeHostIds
])
await Promise.all(
listKnownRuntimeHostIds(repos).map(async (hostId) => {
[...runtimeHostIds].map(async (hostId) => {
try {
slices[hostId] = await api.get(hostId)
} catch (err) {
@ -134,5 +289,8 @@ export async function fetchWorkspaceSessionFromHosts(
}
})
)
return mergeWorkspaceSessionsFromHosts(slices)
return {
session: mergeWorkspaceSessionsFromHosts(slices),
runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(slices)
}
}

View File

@ -0,0 +1,81 @@
import type { WorkspaceKey, WorkspaceSessionState } from '../../../shared/types'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
export type WorkspaceSessionHydrationOptions = {
additionalValidWorkspaceKeys?: readonly WorkspaceKey[]
}
const WORKSPACE_KEYED_SESSION_FIELDS = [
'tabsByWorktree',
'openFilesByWorktree',
'activeFileIdByWorktree',
'activeBrowserTabIdByWorktree',
'activeTabTypeByWorktree',
'activeTabIdByWorktree',
'browserTabsByWorktree',
'unifiedTabs',
'tabGroups',
'tabGroupLayouts',
'activeGroupIdByWorktree',
'lastVisitedAtByWorktreeId',
'defaultTerminalTabsAppliedByWorktreeId'
] as const satisfies readonly (keyof WorkspaceSessionState)[]
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function addFolderWorkspaceKey(keys: Set<WorkspaceKey>, value: unknown): void {
if (typeof value !== 'string') {
return
}
const scope = parseWorkspaceKey(value)
if (scope?.type === 'folder') {
keys.add(value as WorkspaceKey)
}
}
export function collectFolderWorkspaceKeysFromSession(
session: WorkspaceSessionState
): WorkspaceKey[] {
const keys = new Set<WorkspaceKey>()
addFolderWorkspaceKey(keys, session.activeWorkspaceKey)
addFolderWorkspaceKey(keys, session.activeWorktreeId)
for (const field of WORKSPACE_KEYED_SESSION_FIELDS) {
const value = session[field]
if (!isPlainRecord(value)) {
continue
}
for (const key of Object.keys(value)) {
addFolderWorkspaceKey(keys, key)
}
}
for (const worktreeId of session.activeWorktreeIdsOnShutdown ?? []) {
addFolderWorkspaceKey(keys, worktreeId)
}
for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) {
if (!Array.isArray(pages)) {
continue
}
for (const page of pages) {
addFolderWorkspaceKey(keys, page.worktreeId)
}
}
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
addFolderWorkspaceKey(keys, record.worktreeId)
}
return [...keys]
}
export function addAdditionalValidWorkspaceKeys(
validWorkspaceIds: Set<string>,
options?: WorkspaceSessionHydrationOptions
): void {
for (const key of options?.additionalValidWorkspaceKeys ?? []) {
validWorkspaceIds.add(key)
}
}

View File

@ -62,11 +62,46 @@ describe('getSettingsForWorktreeRuntimeOwner', () => {
expect(getExecutionHostIdForWorktree(state, 'folder:runtime-folder')).toBe('runtime:folder-env')
})
it('routes restored runtime folder workspaces before their catalog loads', () => {
const restoredFolderState: WorktreeRuntimeOwnerState = {
settings: { activeRuntimeEnvironmentId: 'focused-env' },
folderWorkspaces: [],
projectGroups: [],
restoredRuntimeHostIdByWorkspaceSessionKey: {
'folder:restored-folder': 'runtime:restored-env'
}
}
expect(
getSettingsForWorktreeRuntimeOwner(restoredFolderState, 'folder:restored-folder')
).toEqual({
activeRuntimeEnvironmentId: 'restored-env'
})
expect(getRuntimeEnvironmentIdForWorktree(restoredFolderState, 'folder:restored-folder')).toBe(
'restored-env'
)
expect(
getExplicitRuntimeEnvironmentIdForWorktree(restoredFolderState, 'folder:restored-folder')
).toBe('restored-env')
expect(getExecutionHostIdForWorktree(restoredFolderState, 'folder:restored-folder')).toBe(
'runtime:restored-env'
)
})
it('keeps explicit-local folder workspaces local even while a runtime is focused', () => {
expect(getSettingsForWorktreeRuntimeOwner(state, 'folder:local-folder')).toEqual({
activeRuntimeEnvironmentId: null
})
expect(getExecutionHostIdForWorktree(state, 'folder:local-folder')).toBe('local')
const restoredOwnerState: WorktreeRuntimeOwnerState = {
...state,
restoredRuntimeHostIdByWorkspaceSessionKey: {
'folder:local-folder': 'runtime:stale-env'
}
}
expect(getRuntimeEnvironmentIdForWorktree(restoredOwnerState, 'folder:local-folder')).toBeNull()
expect(getExecutionHostIdForWorktree(restoredOwnerState, 'folder:local-folder')).toBe('local')
})
it('keeps folder workspaces with their own SSH target off the focused runtime', () => {
@ -188,6 +223,17 @@ describe('getRuntimeSessionMirrorEnvironmentIds', () => {
])
})
it('includes restored runtime folder owners before their catalog loads', () => {
expect(
getRuntimeSessionMirrorEnvironmentIds({
settings: { activeRuntimeEnvironmentId: 'focused-env' },
restoredRuntimeHostIdByWorkspaceSessionKey: {
'folder:restored-folder': 'runtime:restored-env'
}
})
).toEqual(['focused-env', 'restored-env'])
})
it('does not include local or SSH owners', () => {
const localOnlyState: WorktreeRuntimeOwnerState = {
settings: { activeRuntimeEnvironmentId: null },

View File

@ -3,7 +3,7 @@ import {
parseExecutionHostId,
toSshExecutionHostId
} from '../../../shared/execution-host'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { ExecutionHostId, ParsedExecutionHost } from '../../../shared/execution-host'
import type {
FolderWorkspace,
GlobalSettings,
@ -11,16 +11,19 @@ import type {
Repo,
Worktree
} from '../../../shared/types'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
type RuntimeExecutionHost = Extract<ParsedExecutionHost, { kind: 'runtime' }>
export type WorktreeRuntimeOwnerState = {
repos?: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
worktreesByRepo?: Record<string, readonly Pick<Worktree, 'id' | 'repoId' | 'hostId'>[]>
folderWorkspaces?: readonly Pick<FolderWorkspace, 'id' | 'projectGroupId' | 'connectionId'>[]
projectGroups?: readonly Pick<ProjectGroup, 'id' | 'connectionId' | 'executionHostId'>[]
restoredRuntimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
}
function findWorktreeRecord(
@ -72,9 +75,26 @@ function getRuntimeEnvironmentIdForFolderWorkspace(
) {
return null
}
const restoredRuntimeHost = getRestoredRuntimeHostForFolderWorkspace(state, folderWorkspaceId)
if (restoredRuntimeHost) {
return restoredRuntimeHost.environmentId
}
return state.settings?.activeRuntimeEnvironmentId?.trim() || null
}
function getRestoredRuntimeHostForFolderWorkspace(
state: WorktreeRuntimeOwnerState,
folderWorkspaceId: string
): RuntimeExecutionHost | null {
// Why: runtime folder catalogs load after session hydration; the saved
// per-host session partition is the only owner evidence during that gap.
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
const parsed = parseExecutionHostId(
state.restoredRuntimeHostIdByWorkspaceSessionKey?.[workspaceKey]
)
return parsed?.kind === 'runtime' ? parsed : null
}
function getExplicitRuntimeEnvironmentIdFromHost(
executionHostId: string | null | undefined
): string | null {
@ -101,9 +121,16 @@ function getExplicitRuntimeEnvironmentIdForFolderWorkspace(
state: WorktreeRuntimeOwnerState,
folderWorkspaceId: string
): string | null {
return getExplicitRuntimeEnvironmentIdFromHost(
findFolderProjectGroup(state, folderWorkspaceId)?.executionHostId
)
const folderWorkspace = findFolderWorkspace(state, folderWorkspaceId)
const projectGroup = findFolderProjectGroup(state, folderWorkspaceId)
const parsed = parseExecutionHostId(projectGroup?.executionHostId)
if (parsed) {
return parsed.kind === 'runtime' ? parsed.environmentId : null
}
if (folderWorkspace?.connectionId?.trim() || projectGroup?.connectionId?.trim()) {
return null
}
return getRestoredRuntimeHostForFolderWorkspace(state, folderWorkspaceId)?.environmentId ?? null
}
function getExecutionHostIdForFolderWorkspace(
@ -120,6 +147,10 @@ function getExecutionHostIdForFolderWorkspace(
if (connectionId) {
return toSshExecutionHostId(connectionId)
}
const restoredRuntimeHost = getRestoredRuntimeHostForFolderWorkspace(state, folderWorkspaceId)
if (restoredRuntimeHost) {
return restoredRuntimeHost.id
}
const environmentId = state.settings?.activeRuntimeEnvironmentId?.trim()
return environmentId ? `runtime:${encodeURIComponent(environmentId)}` : 'local'
}
@ -209,6 +240,12 @@ export function getRuntimeSessionMirrorEnvironmentIds(state: WorktreeRuntimeOwne
ids.add(environmentId)
}
}
for (const hostId of Object.values(state.restoredRuntimeHostIdByWorkspaceSessionKey ?? {})) {
const parsed = parseExecutionHostId(hostId)
if (parsed?.kind === 'runtime') {
ids.add(parsed.environmentId)
}
}
return [...ids].sort()
}

View File

@ -136,6 +136,133 @@ describe('runtime RPC client routing', () => {
])
})
it('reuses recent remote compatibility failures during startup catalog bursts', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'status',
ok: false,
error: { code: 'runtime_unavailable', message: 'offline' },
_meta: { runtimeId: 'remote-runtime' }
})
const target = { kind: 'environment', environmentId: 'env-offline' } as const
await expect(
callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true })
).rejects.toThrow('offline')
await expect(
callRuntimeRpc(target, 'projectGroup.list', undefined, {
reuseRecentCompatibilityFailure: true
})
).rejects.toThrow('offline')
await expect(
callRuntimeRpc(target, 'folderWorkspace.list', undefined, {
reuseRecentCompatibilityFailure: true
})
).rejects.toThrow('offline')
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual(['status.get'])
})
it('expires startup compatibility failures at the TTL boundary', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(0))
try {
let statusCalls = 0
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => {
if (method === 'status.get') {
statusCalls += 1
if (statusCalls === 1) {
return Promise.resolve({
id: 'status',
ok: false,
error: { code: 'runtime_unavailable', message: 'offline' },
_meta: { runtimeId: 'remote-runtime' }
})
}
return Promise.resolve({
id: 'status',
ok: true,
result: {
runtimeId: 'remote-runtime',
graphStatus: 'ready',
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
},
_meta: { runtimeId: 'remote-runtime' }
})
}
return Promise.resolve({
id: method,
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
})
const target = { kind: 'environment', environmentId: 'env-ttl' } as const
await expect(
callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true })
).rejects.toThrow('offline')
vi.setSystemTime(new Date(60_000))
await expect(
callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true })
).resolves.toEqual({ ok: true })
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([
'status.get',
'status.get',
'repo.list'
])
} finally {
vi.useRealTimers()
}
})
it('retries normal remote calls after a catalog-burst compatibility failure', async () => {
let statusCalls = 0
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => {
if (method === 'status.get') {
statusCalls += 1
if (statusCalls === 1) {
return Promise.resolve({
id: 'status',
ok: false,
error: { code: 'runtime_unavailable', message: 'offline' },
_meta: { runtimeId: 'remote-runtime' }
})
}
return Promise.resolve({
id: 'status',
ok: true,
result: {
runtimeId: 'remote-runtime',
graphStatus: 'ready',
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
},
_meta: { runtimeId: 'remote-runtime' }
})
}
return Promise.resolve({
id: method,
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
})
const target = { kind: 'environment', environmentId: 'env-recovers' } as const
await expect(
callRuntimeRpc(target, 'repo.list', undefined, { reuseRecentCompatibilityFailure: true })
).rejects.toThrow('offline')
await expect(callRuntimeRpc(target, 'git.status')).resolves.toEqual({ ok: true })
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([
'status.get',
'status.get',
'git.status'
])
})
it('checks advertised runtime capabilities after protocol compatibility', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'status',

View File

@ -8,7 +8,15 @@ import { assertRuntimeStatusCompatible } from './runtime-protocol-compat'
export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string }
const RUNTIME_COMPATIBILITY_CACHE_MAX = 32
const compatibleRuntimeEnvironments = new Map<string, Promise<void>>()
const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000
type RuntimeCompatibilityCacheEntry = {
check: Promise<void>
failedAt: number | null
reuseFailure: boolean
}
const runtimeCompatibilityChecks = new Map<string, RuntimeCompatibilityCacheEntry>()
export class RuntimeRpcCallError extends Error {
readonly code: string
@ -54,10 +62,14 @@ export async function callRuntimeRpc<TResult>(
target: RuntimeClientTarget,
method: string,
params?: unknown,
options: { timeoutMs?: number; suppressFeatureInteraction?: boolean } = {}
options: {
timeoutMs?: number
suppressFeatureInteraction?: boolean
reuseRecentCompatibilityFailure?: boolean
} = {}
): Promise<TResult> {
if (target.kind === 'environment' && method !== 'status.get') {
await ensureRuntimeEnvironmentCompatible(target.environmentId, options.timeoutMs)
await ensureRuntimeEnvironmentCompatible(target.environmentId, options)
}
const nextParams = addFeatureInteractionSource(params, options)
const response =
@ -84,61 +96,93 @@ function addFeatureInteractionSource(
async function ensureRuntimeEnvironmentCompatible(
environmentId: string,
timeoutMs?: number
options: { timeoutMs?: number; reuseRecentCompatibilityFailure?: boolean } = {}
): Promise<void> {
const cached = compatibleRuntimeEnvironments.get(environmentId)
const cached = getCachedRuntimeCompatibilityCheck(environmentId, options)
if (cached) {
compatibleRuntimeEnvironments.delete(environmentId)
compatibleRuntimeEnvironments.set(environmentId, cached)
await cached
await cached.check
return
}
const entry: RuntimeCompatibilityCacheEntry = {
check: Promise.resolve(),
failedAt: null,
reuseFailure: options.reuseRecentCompatibilityFailure === true
}
const check = (async () => {
const response = await window.api.runtimeEnvironments.call({
selector: environmentId,
method: 'status.get',
timeoutMs
timeoutMs: options.timeoutMs
})
const status = unwrapRuntimeRpcResult<RuntimeStatus>(
response as RuntimeRpcResponse<RuntimeStatus>
)
assertRuntimeStatusCompatible(status)
})()
rememberRuntimeEnvironmentCompatibility(environmentId, check)
entry.check = check
rememberRuntimeEnvironmentCompatibility(environmentId, entry)
try {
await check
} catch (error) {
if (compatibleRuntimeEnvironments.get(environmentId) === check) {
compatibleRuntimeEnvironments.delete(environmentId)
if (runtimeCompatibilityChecks.get(environmentId) === entry) {
// Why: startup asks each remote for repos, groups, then folders; an
// offline runtime should pay one timeout during that burst, not three.
entry.failedAt = Date.now()
}
throw error
}
}
function getCachedRuntimeCompatibilityCheck(
environmentId: string,
options: { reuseRecentCompatibilityFailure?: boolean }
): RuntimeCompatibilityCacheEntry | null {
const cached = runtimeCompatibilityChecks.get(environmentId)
if (!cached) {
return null
}
if (
cached.failedAt !== null &&
Date.now() - cached.failedAt >= RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS
) {
runtimeCompatibilityChecks.delete(environmentId)
return null
}
if (
cached.failedAt !== null &&
(!cached.reuseFailure || options.reuseRecentCompatibilityFailure !== true)
) {
return null
}
runtimeCompatibilityChecks.delete(environmentId)
runtimeCompatibilityChecks.set(environmentId, cached)
return cached
}
function rememberRuntimeEnvironmentCompatibility(
environmentId: string,
check: Promise<void>
entry: RuntimeCompatibilityCacheEntry
): void {
// Why: saved/removed remote runtimes can churn through unique ids in long
// renderer sessions; successful compatibility promises should not grow forever.
compatibleRuntimeEnvironments.delete(environmentId)
compatibleRuntimeEnvironments.set(environmentId, check)
while (compatibleRuntimeEnvironments.size > RUNTIME_COMPATIBILITY_CACHE_MAX) {
const oldest = compatibleRuntimeEnvironments.keys().next().value
// renderer sessions; compatibility cache entries should not grow forever.
runtimeCompatibilityChecks.delete(environmentId)
runtimeCompatibilityChecks.set(environmentId, entry)
while (runtimeCompatibilityChecks.size > RUNTIME_COMPATIBILITY_CACHE_MAX) {
const oldest = runtimeCompatibilityChecks.keys().next().value
if (oldest === undefined) {
break
}
compatibleRuntimeEnvironments.delete(oldest)
runtimeCompatibilityChecks.delete(oldest)
}
}
export function clearRuntimeCompatibilityCache(environmentId?: string | null): void {
const trimmed = environmentId?.trim()
if (trimmed) {
compatibleRuntimeEnvironments.delete(trimmed)
runtimeCompatibilityChecks.delete(trimmed)
return
}
compatibleRuntimeEnvironments.clear()
runtimeCompatibilityChecks.clear()
}
export function markRuntimeEnvironmentCompatible(environmentId: string): void {
@ -146,7 +190,11 @@ export function markRuntimeEnvironmentCompatible(environmentId: string): void {
if (!trimmed) {
return
}
rememberRuntimeEnvironmentCompatibility(trimmed, Promise.resolve())
rememberRuntimeEnvironmentCompatibility(trimmed, {
check: Promise.resolve(),
failedAt: null,
reuseFailure: false
})
}
export async function getRuntimeEnvironmentStatus(

View File

@ -49,6 +49,10 @@ import {
getExecutionHostIdForWorktree,
getRuntimeEnvironmentIdForWorktree
} from '@/lib/worktree-runtime-owner'
import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
type CreateBrowserTabOptions = {
activate?: boolean
@ -168,7 +172,10 @@ export type BrowserSlice = {
addBrowserPageAnnotation: (annotation: BrowserPageAnnotation) => void
deleteBrowserPageAnnotation: (pageId: string, annotationId: string) => void
clearBrowserPageAnnotations: (pageId: string) => void
hydrateBrowserSession: (session: WorkspaceSessionState) => void
hydrateBrowserSession: (
session: WorkspaceSessionState,
options?: WorkspaceSessionHydrationOptions
) => void
switchBrowserTabProfile: (
workspaceId: string,
profileId: string | null,
@ -1504,7 +1511,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
return { browserAnnotationsByPageId: nextByPageId }
}),
hydrateBrowserSession: (session) => {
hydrateBrowserSession: (session, options) => {
const persistedTabsByWorktree = session.browserTabsByWorktree ?? {}
const currentState = get()
const validWorktreeIdsForCleanup = new Set(
@ -1516,6 +1523,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
for (const workspace of currentState.folderWorkspaces) {
validWorktreeIdsForCleanup.add(folderWorkspaceKey(workspace.id))
}
addAdditionalValidWorkspaceKeys(validWorktreeIdsForCleanup, options)
// Why: mirror closeBrowserTab's contract — reducers are pure, imperative
// side effects bracket them. Compute dropped workspaces first, destroy
@ -1548,6 +1556,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
for (const workspace of s.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
}
addAdditionalValidWorkspaceKeys(validWorktreeIds, options)
const browserTabsByWorktree: Record<string, BrowserWorkspace[]> = {}
const browserPagesByWorkspace: Record<string, BrowserPage[]> = {}

View File

@ -62,6 +62,10 @@ import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import { notifyHostOfMirroredEditorClose } from '@/runtime/close-mirrored-editor-tab'
import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { translate } from '@/i18n/i18n'
@ -711,7 +715,10 @@ export type EditorSlice = {
setPendingEditorReveal: (reveal: PendingEditorReveal | null) => void
// Session hydration — restore editor files from persisted workspace session
hydrateEditorSession: (session: WorkspaceSessionState) => void
hydrateEditorSession: (
session: WorkspaceSessionState,
options?: WorkspaceSessionHydrationOptions
) => void
}
function openWorkspaceEditorItem(
@ -4222,7 +4229,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// Why: only edit-mode files are restored — diffs and conflict views depend on
// transient git state that may have changed between sessions. Restoring them
// would show stale data or fail to load entirely.
hydrateEditorSession: (session) => {
hydrateEditorSession: (session, options) => {
set((s) => {
const openFilesByWorktree = session.openFilesByWorktree ?? {}
const persistedActiveFileIdByWorktree = session.activeFileIdByWorktree ?? {}
@ -4241,6 +4248,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
for (const workspace of s.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
}
addAdditionalValidWorkspaceKeys(validWorktreeIds, options)
const openFiles: OpenFile[] = []
const editorDrafts: Record<string, string> = {}

View File

@ -0,0 +1,265 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestStore } from './store-test-helpers'
import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
const localRepo: Repo = {
id: 'local-repo',
path: '/local',
displayName: 'Local',
badgeColor: '#000',
addedAt: 1
}
const remoteRepo: Repo = {
id: 'remote-repo',
path: '/srv/repo',
displayName: 'Remote',
badgeColor: '#000',
addedAt: 1
}
const localProjectGroup: ProjectGroup = {
id: 'local-group',
name: 'Local group',
parentPath: '/local',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const remoteProjectGroup: ProjectGroup = {
id: 'remote-group',
name: 'Remote group',
parentPath: '/srv',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const localFolderWorkspace: FolderWorkspace = {
id: 'local-folder',
projectGroupId: 'local-group',
name: 'Local folder',
folderPath: '/local',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
const remoteFolderWorkspace: FolderWorkspace = {
id: 'remote-folder',
projectGroupId: 'remote-group',
name: 'Remote folder',
folderPath: '/srv',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
const reposList = vi.fn()
const projectsList = vi.fn()
const listHostSetups = vi.fn()
const projectGroupsList = vi.fn()
const folderWorkspacesList = vi.fn()
const runtimeEnvironmentsList = vi.fn()
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
const dispatchEventMock = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
reposList.mockReset()
projectsList.mockReset()
listHostSetups.mockReset()
projectGroupsList.mockReset()
folderWorkspacesList.mockReset()
runtimeEnvironmentsList.mockReset()
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
dispatchEventMock.mockReset()
reposList.mockResolvedValue([localRepo])
projectsList.mockResolvedValue([])
listHostSetups.mockResolvedValue([])
projectGroupsList.mockResolvedValue([localProjectGroup])
folderWorkspacesList.mockResolvedValue([localFolderWorkspace])
runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'lobster' }])
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
if (args.method === 'repo.list') {
return {
id: 'rpc-repo-list',
ok: true,
result: { repos: [remoteRepo] },
_meta: { runtimeId: 'runtime-remote' }
}
}
if (args.method === 'projectGroup.list') {
return {
id: 'rpc-project-group-list',
ok: true,
result: { groups: [remoteProjectGroup] },
_meta: { runtimeId: 'runtime-remote' }
}
}
if (args.method === 'folderWorkspace.list') {
return {
id: 'rpc-folder-workspace-list',
ok: true,
result: { folderWorkspaces: [remoteFolderWorkspace] },
_meta: { runtimeId: 'runtime-remote' }
}
}
return {
id: 'rpc-other',
ok: true,
result: { projects: [], setups: [] },
_meta: { runtimeId: 'runtime-remote' }
}
})
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
})
vi.stubGlobal('window', {
api: {
repos: { list: reposList },
projects: { list: projectsList, listHostSetups },
projectGroups: { list: projectGroupsList },
folderWorkspaces: { list: folderWorkspacesList },
runtimeEnvironments: {
call: runtimeEnvironmentTransportCall,
list: runtimeEnvironmentsList
}
},
dispatchEvent: dispatchEventMock
})
})
describe('all-host folder workspace startup catalogs', () => {
it('loads project groups and folder workspaces for every host', async () => {
const store = createTestStore()
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
const restoredFolderKey = folderWorkspaceKey('remote-folder')
store.setState({
restoredRuntimeHostIdByWorkspaceSessionKey: {
[restoredFolderKey]: 'runtime:env-1',
'remote-repo::/srv/repo': 'runtime:env-1'
}
})
await store.getState().fetchProjectGroupsForAllHosts()
await store.getState().fetchFolderWorkspacesForAllHosts()
expect(store.getState().projectGroups).toEqual([
{ ...localProjectGroup, executionHostId: 'local' },
{ ...remoteProjectGroup, executionHostId: 'runtime:env-1' }
])
expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([
'local-folder',
'remote-folder'
])
expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({
'remote-repo::/srv/repo': 'runtime:env-1'
})
const missingGroupStore = createTestStore()
missingGroupStore.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
projectGroups: [localProjectGroup],
restoredRuntimeHostIdByWorkspaceSessionKey: { [restoredFolderKey]: 'runtime:env-1' }
})
await missingGroupStore.getState().fetchFolderWorkspacesForAllHosts()
expect(missingGroupStore.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({
[restoredFolderKey]: 'runtime:env-1'
})
})
it('keeps local project groups and folder workspaces when a runtime is unreachable', async () => {
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
if (args.method === 'projectGroup.list' || args.method === 'folderWorkspace.list') {
throw new Error('runtime_unreachable')
}
return {
id: 'rpc-other',
ok: true,
result: { repos: [], projects: [], setups: [] },
_meta: { runtimeId: 'runtime-remote' }
}
})
const store = createTestStore()
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
await store.getState().fetchProjectGroupsForAllHosts()
await store.getState().fetchFolderWorkspacesForAllHosts()
expect(store.getState().projectGroups).toEqual([
{ ...localProjectGroup, executionHostId: 'local' }
])
expect(store.getState().folderWorkspaces).toEqual([localFolderWorkspace])
})
it('does not repeat offline runtime compatibility probes across startup catalog loads', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
runtimeEnvironmentTransportCall.mockResolvedValue({
id: 'status',
ok: false,
error: { code: 'runtime_unavailable', message: 'offline' },
_meta: { runtimeId: 'runtime-remote' }
})
const store = createTestStore()
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
const restoredFolderKey = folderWorkspaceKey('remote-folder')
store.setState({
restoredRuntimeHostIdByWorkspaceSessionKey: {
[restoredFolderKey]: 'runtime:env-1'
}
})
try {
await store.getState().fetchReposForAllHosts()
await store.getState().fetchProjectGroupsForAllHosts()
await store.getState().fetchFolderWorkspacesForAllHosts()
expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }])
expect(store.getState().projectGroups).toEqual([
{ ...localProjectGroup, executionHostId: 'local' }
])
expect(store.getState().folderWorkspaces).toEqual([localFolderWorkspace])
expect(runtimeEnvironmentTransportCall.mock.calls.map((call) => call[0].method)).toEqual([
'status.get'
])
expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({
[restoredFolderKey]: 'runtime:env-1'
})
} finally {
warn.mockRestore()
}
})
})

View File

@ -12,6 +12,7 @@ import {
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt'
const localRepo: Repo = {
id: 'local-repo',
@ -684,44 +685,109 @@ describe('fetchReposForAllHosts', () => {
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo'])
})
it('loads project groups and folder workspaces for every host', async () => {
it('can load only the local catalog slice for first-paint startup', async () => {
const store = createTestStore()
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
await store.getState().fetchProjectGroupsForAllHosts()
await store.getState().fetchFolderWorkspacesForAllHosts()
expect(store.getState().projectGroups).toEqual([
{ ...localProjectGroup, executionHostId: 'local' },
{ ...remoteProjectGroup, executionHostId: 'runtime:env-1' }
])
expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([
'local-folder',
'remote-folder'
])
})
it('keeps local project groups and folder workspaces when a runtime is unreachable', async () => {
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
if (args.method === 'projectGroup.list' || args.method === 'folderWorkspace.list') {
throw new Error('runtime_unreachable')
}
return {
id: 'rpc-other',
ok: true,
result: { repos: [], projects: [], setups: [] },
_meta: { runtimeId: 'runtime-remote' }
}
})
const store = createTestStore()
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
await store.getState().fetchProjectGroupsForAllHosts()
await store.getState().fetchFolderWorkspacesForAllHosts()
await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
await store.getState().fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })
await store.getState().fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })
expect(runtimeEnvironmentsList).not.toHaveBeenCalled()
expect(runtimeEnvironmentTransportCall).not.toHaveBeenCalled()
expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }])
expect(store.getState().projectGroups).toEqual([
{ ...localProjectGroup, executionHostId: 'local' }
])
expect(store.getState().folderWorkspaces).toEqual([localFolderWorkspace])
})
it('preserves remote repo filters during first-paint local catalog refresh', async () => {
const store = createTestStore()
const remoteDismissalKey = getSetupScriptPromptDismissalKey('remote-repo')
const staleDismissalKey = getSetupScriptPromptDismissalKey('stale-repo')
store.setState({
activeRepoId: 'remote-repo',
filterRepoIds: ['remote-repo', 'stale-repo'],
setupScriptPromptDismissedRepoIds: [remoteDismissalKey, staleDismissalKey],
trustedOrcaHooks: {
'remote-repo': { all: { approvedAt: 1 } },
'stale-repo': { all: { approvedAt: 2 } }
}
})
await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
expect(store.getState().activeRepoId).toBe('remote-repo')
expect(store.getState().filterRepoIds).toEqual(['remote-repo', 'stale-repo'])
expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([
remoteDismissalKey,
staleDismissalKey
])
expect(store.getState().trustedOrcaHooks).toEqual({
'remote-repo': { all: { approvedAt: 1 } },
'stale-repo': { all: { approvedAt: 2 } }
})
await store.getState().fetchReposForAllHosts()
expect(store.getState().activeRepoId).toBe('remote-repo')
expect(store.getState().filterRepoIds).toEqual(['remote-repo'])
expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([remoteDismissalKey])
expect(store.getState().trustedOrcaHooks).toEqual({
'remote-repo': { all: { approvedAt: 1 } }
})
})
it('starts remote repo catalog loads concurrently for all configured runtimes', async () => {
runtimeEnvironmentsList.mockResolvedValue([
{ id: 'env-1', name: 'first' },
{ id: 'env-2', name: 'second' }
])
const firstStatusResolvers = new Map<string, (value: unknown) => void>()
let resolveBothStatusProbes = (): void => {}
const bothStatusProbesStarted = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error('Timed out waiting for both runtime probes')),
1_000
)
resolveBothStatusProbes = () => {
clearTimeout(timeout)
resolve()
}
})
runtimeEnvironmentTransportCall.mockImplementation(
(args: RuntimeEnvironmentCallRequest & { selector?: string }) => {
if (
args.method === 'status.get' &&
args.selector &&
!firstStatusResolvers.has(args.selector)
) {
return new Promise((resolve) => {
firstStatusResolvers.set(args.selector!, resolve)
if (firstStatusResolvers.size === 2) {
resolveBothStatusProbes()
}
})
}
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
}
)
const store = createTestStore()
const load = store.getState().fetchReposForAllHosts()
await bothStatusProbesStarted
expect([...firstStatusResolvers.keys()].sort()).toEqual(['env-1', 'env-2'])
for (const resolve of firstStatusResolvers.values()) {
resolve(createCompatibleRuntimeStatusResponseIfNeeded({ method: 'status.get' }))
}
await load
expect(
store
.getState()
.repos.map((repo) => `${repo.id}:${repo.executionHostId}`)
.sort()
).toEqual(['local-repo:local', 'remote-repo:runtime:env-1', 'remote-repo:runtime:env-2'])
})
})

View File

@ -76,7 +76,7 @@ import {
toSshExecutionHostId
} from '../../../../shared/execution-host'
import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status'
const ERROR_TOAST_DURATION = 60_000
@ -126,6 +126,10 @@ export type DeleteProjectGroupWithContainedProjectsOptions = {
removeContainedProjects: boolean
}
type AllHostCatalogFetchOptions = {
remoteHosts?: 'include' | 'skip'
}
export type ProjectRemovalFailure = {
projectId: string
reason: string
@ -830,6 +834,93 @@ function mergeFetchedFolderWorkspacesForHost({
return mergeById(preserved, fetched)
}
type FetchedRepoCatalog = {
repos: Repo[]
projectHostSetupCompatibility: ProjectHostSetupProjection
hostId: ReturnType<typeof getRuntimeTargetHostId>
}
type FetchedProjectGroupCatalog = {
projectGroups: ProjectGroup[]
hostId: ReturnType<typeof getRuntimeTargetHostId>
}
type FetchedFolderWorkspaceCatalog = {
folderWorkspaces: FolderWorkspace[]
hostId: ReturnType<typeof getRuntimeTargetHostId>
}
async function fetchRepoCatalogForTarget(
target: ReturnType<typeof getActiveRuntimeTarget>
): Promise<FetchedRepoCatalog> {
const fetchedRepos =
target.kind === 'local'
? await window.api.repos.list()
: (
await callRuntimeRpc<{ repos: Repo[] }>(target, 'repo.list', undefined, {
timeoutMs: 15_000,
reuseRecentCompatibilityFailure: true
})
).repos
const repos = fetchedRepos.map((repo) => repoWithFetchedOwner(repo, target))
return {
repos,
projectHostSetupCompatibility: await fetchProjectHostSetupCompatibility(target, repos),
hostId: getRuntimeTargetHostId(target)
}
}
function mergeFetchedRepoCatalog(
catalog: FetchedRepoCatalog,
currentRepos: readonly Repo[]
): {
repos: Repo[]
projectCompatibility: Pick<RepoSlice, 'projects' | 'projectHostSetups'>
hostId: ReturnType<typeof getRuntimeTargetHostId>
} {
const repos = mergeFetchedReposForHost(currentRepos, catalog.repos, catalog.hostId)
const projectCompatibility = mergeProjectHostSetupCompatibility(
projectCompatibilityFromRepos(repos),
catalog.projectHostSetupCompatibility
)
return { repos, projectCompatibility, hostId: catalog.hostId }
}
function filterTrustedOrcaHooksToValidRepos(
trust: AppState['trustedOrcaHooks'],
validRepoIds: Set<string>
): AppState['trustedOrcaHooks'] {
const next: AppState['trustedOrcaHooks'] = {}
for (const [repoId, entry] of Object.entries(trust)) {
if (validRepoIds.has(repoId)) {
next[repoId] = entry
}
}
return next
}
function clearRestoredFolderWorkspaceSessionOwners(
owners: AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] | undefined,
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups'>
): AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] {
const next: AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] = {}
for (const [key, hostId] of Object.entries(owners ?? {})) {
const scope = parseWorkspaceKey(key)
if (scope?.type !== 'folder') {
next[key] = hostId
continue
}
const workspace = state.folderWorkspaces.find((entry) => entry.id === scope.folderWorkspaceId)
if (workspace && !state.projectGroups.some((group) => group.id === workspace.projectGroupId)) {
// Why: folder workspace ownership is resolved through its project group.
// If that catalog is still missing, keep the restored host owner so a
// session write before the next retry does not move runtime tabs local.
next[key] = hostId
}
}
return next
}
async function fetchReposForTarget(
target: ReturnType<typeof getActiveRuntimeTarget>,
currentRepos: readonly Repo[]
@ -838,49 +929,87 @@ async function fetchReposForTarget(
projectCompatibility: Pick<RepoSlice, 'projects' | 'projectHostSetups'>
hostId: ReturnType<typeof getRuntimeTargetHostId>
}> {
const fetchedRepos =
target.kind === 'local'
? await window.api.repos.list()
: (
await callRuntimeRpc<{ repos: Repo[] }>(target, 'repo.list', undefined, {
timeoutMs: 15_000
})
).repos
const hostId = getRuntimeTargetHostId(target)
const repos = fetchedRepos.map((repo) => repoWithFetchedOwner(repo, target))
const fetchedProjectCompatibility = await fetchProjectHostSetupCompatibility(target, repos)
const reconciledRepos = mergeFetchedReposForHost(currentRepos, repos, hostId)
const projectCompatibility =
target.kind === 'local'
? mergeProjectHostSetupCompatibility(
projectCompatibilityFromRepos(reconciledRepos),
fetchedProjectCompatibility
)
: mergeProjectHostSetupCompatibility(
projectCompatibilityFromRepos(reconciledRepos),
fetchedProjectCompatibility
)
return mergeFetchedRepoCatalog(await fetchRepoCatalogForTarget(target), currentRepos)
}
return { repos: reconciledRepos, projectCompatibility, hostId }
async function fetchProjectGroupCatalogForTarget(
target: ReturnType<typeof getActiveRuntimeTarget>
): Promise<FetchedProjectGroupCatalog> {
const fetchedGroups =
target.kind === 'local'
? await window.api.projectGroups.list()
: (
await callRuntimeRpc<{ groups: ProjectGroup[] }>(target, 'projectGroup.list', undefined, {
timeoutMs: 15_000,
reuseRecentCompatibilityFailure: true
})
).groups
return {
projectGroups: fetchedGroups.map((group) => projectGroupWithFetchedOwner(group, target)),
hostId: getRuntimeTargetHostId(target)
}
}
function mergeFetchedProjectGroupCatalog(
catalog: FetchedProjectGroupCatalog,
currentProjectGroups: readonly ProjectGroup[]
): { projectGroups: ProjectGroup[]; hostId: ReturnType<typeof getRuntimeTargetHostId> } {
return {
projectGroups: mergeFetchedProjectGroupsForHost(
currentProjectGroups,
catalog.projectGroups,
catalog.hostId
),
hostId: catalog.hostId
}
}
async function fetchProjectGroupsForTarget(
target: ReturnType<typeof getActiveRuntimeTarget>,
currentProjectGroups: readonly ProjectGroup[]
): Promise<{ projectGroups: ProjectGroup[]; hostId: ReturnType<typeof getRuntimeTargetHostId> }> {
const fetchedGroups =
return mergeFetchedProjectGroupCatalog(
await fetchProjectGroupCatalogForTarget(target),
currentProjectGroups
)
}
async function fetchFolderWorkspaceCatalogForTarget(
target: ReturnType<typeof getActiveRuntimeTarget>
): Promise<FetchedFolderWorkspaceCatalog> {
const fetchedFolderWorkspaces =
target.kind === 'local'
? await window.api.projectGroups.list()
? await window.api.folderWorkspaces.list()
: (
await callRuntimeRpc<{ groups: ProjectGroup[] }>(target, 'projectGroup.list', undefined, {
timeoutMs: 15_000
})
).groups
const hostId = getRuntimeTargetHostId(target)
const ownedGroups = fetchedGroups.map((group) => projectGroupWithFetchedOwner(group, target))
await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>(
target,
'folderWorkspace.list',
undefined,
{ timeoutMs: 15_000, reuseRecentCompatibilityFailure: true }
)
).folderWorkspaces
return {
projectGroups: mergeFetchedProjectGroupsForHost(currentProjectGroups, ownedGroups, hostId),
hostId
folderWorkspaces: fetchedFolderWorkspaces,
hostId: getRuntimeTargetHostId(target)
}
}
function mergeFetchedFolderWorkspaceCatalog(
catalog: FetchedFolderWorkspaceCatalog,
currentFolderWorkspaces: readonly FolderWorkspace[],
projectGroups: readonly ProjectGroup[]
): {
folderWorkspaces: FolderWorkspace[]
hostId: ReturnType<typeof getRuntimeTargetHostId>
} {
return {
folderWorkspaces: mergeFetchedFolderWorkspacesForHost({
previous: currentFolderWorkspaces,
fetched: catalog.folderWorkspaces,
projectGroups,
hostId: catalog.hostId
}),
hostId: catalog.hostId
}
}
@ -892,27 +1021,11 @@ async function fetchFolderWorkspacesForTarget(
folderWorkspaces: FolderWorkspace[]
hostId: ReturnType<typeof getRuntimeTargetHostId>
}> {
const fetchedFolderWorkspaces =
target.kind === 'local'
? await window.api.folderWorkspaces.list()
: (
await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>(
target,
'folderWorkspace.list',
undefined,
{ timeoutMs: 15_000 }
)
).folderWorkspaces
const hostId = getRuntimeTargetHostId(target)
return {
folderWorkspaces: mergeFetchedFolderWorkspacesForHost({
previous: currentFolderWorkspaces,
fetched: fetchedFolderWorkspaces,
projectGroups,
hostId
}),
hostId
}
return mergeFetchedFolderWorkspaceCatalog(
await fetchFolderWorkspaceCatalogForTarget(target),
currentFolderWorkspaces,
projectGroups
)
}
async function listRuntimeEnvironmentsForAllHostLoad(): Promise<{ id: string }[]> {
@ -1155,12 +1268,12 @@ export type RepoSlice = {
// Monotonic sequence so an overlapping fetchRepos can drop its own stale result (#7020).
reposFetchGeneration: number
fetchRepos: () => Promise<void>
fetchReposForAllHosts: () => Promise<void>
fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise<Repo[]>
fetchProjectGroups: () => Promise<void>
fetchProjectGroupsForAllHosts: () => Promise<void>
fetchProjectGroupsForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
fetchFolderWorkspaces: () => Promise<void>
fetchFolderWorkspacesForAllHosts: () => Promise<void>
fetchFolderWorkspacesForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
addRepo: () => Promise<Repo | null>
addRepoPath: (
path: string,
@ -1366,7 +1479,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
},
fetchReposForAllHosts: async () => {
fetchReposForAllHosts: async (options) => {
// Why: a cold start that restores a remote workspace re-activates that
// remote runtime environment, and fetching only the active host hides every
// other host's repos (notably all local repos), which reads as "my projects
@ -1374,9 +1487,10 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
// sidebar "All hosts" scope shows them together regardless of which
// environment is active. Each host fails soft: an unreachable/disconnected
// host is skipped without blocking the others.
const applyResult = (result: Awaited<ReturnType<typeof fetchReposForTarget>>): void => {
const validRepoIds = new Set(result.repos.map((repo) => repo.id))
const applyCatalog = (catalog: FetchedRepoCatalog): void => {
let hostRepos: Repo[] = []
set((s) => {
const result = mergeFetchedRepoCatalog(catalog, s.repos)
const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({
previous: {
projects: s.projects,
@ -1386,48 +1500,71 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
repos: result.repos,
hostId: result.hostId
})
hostRepos = result.repos.filter((repo) => getRepoExecutionHostId(repo) === result.hostId)
return {
repos: result.repos,
...mergedProjectCompatibility,
folderWorkspacePathStatuses: {},
activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null,
filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)),
setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos(
s.setupScriptPromptDismissedRepoIds,
validRepoIds
)
activeRepoId: s.activeRepoId,
filterRepoIds: s.filterRepoIds,
setupScriptPromptDismissedRepoIds: s.setupScriptPromptDismissedRepoIds
}
})
// Why: preserve the safe-auto fork sync that fetchRepos /
// fetchRuntimeEnvironmentRepos schedule after merging each host, so
// cold-start (which now routes through here) keeps updating safe-auto forks.
scheduleSafeAutoForkSync(
get,
result.repos.filter((repo) => getRepoExecutionHostId(repo) === result.hostId)
)
scheduleSafeAutoForkSync(get, hostRepos)
}
const validateRepoScopedUi = (): void => {
set((s) => {
const validRepoIds = new Set(s.repos.map((repo) => repo.id))
return {
activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null,
filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)),
setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos(
s.setupScriptPromptDismissedRepoIds,
validRepoIds
),
trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(s.trustedOrcaHooks, validRepoIds)
}
})
}
// Local first so local repos are present even if a remote fetch stalls.
let failed = false
try {
applyResult(await fetchReposForTarget({ kind: 'local' }, get().repos))
applyCatalog(await fetchRepoCatalogForTarget({ kind: 'local' }))
} catch (err) {
failed = true
console.error('Failed to fetch local repos for all-host load:', err)
}
if (options?.remoteHosts === 'skip') {
return
}
const environments = await listRuntimeEnvironmentsForAllHostLoad()
// Sequential to avoid concurrent set() races on the merged repos array.
for (const environment of environments) {
try {
applyResult(
await fetchReposForTarget(
{ kind: 'environment', environmentId: environment.id },
get().repos
// Why: unreachable remotes can spend the full connect timeout; merge each
// resolved host through the state updater so parallel loads do not clobber.
await Promise.all(
environments.map(async (environment) => {
try {
applyCatalog(
await fetchRepoCatalogForTarget({
kind: 'environment',
environmentId: environment.id
})
)
)
} catch (err) {
console.warn(`Skipped repos for runtime environment ${environment.id}:`, err)
}
} catch (err) {
failed = true
console.warn(`Skipped repos for runtime environment ${environment.id}:`, err)
}
})
)
// Why: first-paint startup intentionally loads only local repos before
// remotes answer. Validate repo-scoped UI only once every configured host has
// answered; otherwise an offline runtime would erase its saved filters.
if (!failed) {
validateRepoScopedUi()
}
},
@ -1444,35 +1581,40 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
},
fetchProjectGroupsForAllHosts: async () => {
fetchProjectGroupsForAllHosts: async (options) => {
// Why: startup renders an all-host sidebar; replacing groups with only the
// active host would leave repos from other hosts visible but ungrouped.
const applyResult = (result: Awaited<ReturnType<typeof fetchProjectGroupsForTarget>>): void => {
set({
projectGroups: result.projectGroups,
const applyCatalog = (catalog: FetchedProjectGroupCatalog): void => {
set((s) => ({
projectGroups: mergeFetchedProjectGroupCatalog(catalog, s.projectGroups).projectGroups,
folderWorkspacePathStatuses: {}
})
}))
}
try {
applyResult(await fetchProjectGroupsForTarget({ kind: 'local' }, get().projectGroups))
applyCatalog(await fetchProjectGroupCatalogForTarget({ kind: 'local' }))
} catch (err) {
console.error('Failed to fetch local project groups for all-host load:', err)
}
if (options?.remoteHosts === 'skip') {
return
}
const environments = await listRuntimeEnvironmentsForAllHostLoad()
for (const environment of environments) {
try {
applyResult(
await fetchProjectGroupsForTarget(
{ kind: 'environment', environmentId: environment.id },
get().projectGroups
await Promise.all(
environments.map(async (environment) => {
try {
applyCatalog(
await fetchProjectGroupCatalogForTarget({
kind: 'environment',
environmentId: environment.id
})
)
)
} catch (err) {
console.warn(`Skipped project groups for runtime environment ${environment.id}:`, err)
}
}
} catch (err) {
console.warn(`Skipped project groups for runtime environment ${environment.id}:`, err)
}
})
)
},
fetchFolderWorkspaces: async () => {
@ -1489,43 +1631,54 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
},
fetchFolderWorkspacesForAllHosts: async () => {
fetchFolderWorkspacesForAllHosts: async (options) => {
// Why: folder workspaces are owned through their project groups, so startup
// must fetch groups first and then merge each host's folder slice.
const applyResult = (
result: Awaited<ReturnType<typeof fetchFolderWorkspacesForTarget>>
): void => {
set({
folderWorkspaces: result.folderWorkspaces,
const applyCatalog = (catalog: FetchedFolderWorkspaceCatalog): void => {
set((s) => ({
folderWorkspaces: mergeFetchedFolderWorkspaceCatalog(
catalog,
s.folderWorkspaces,
s.projectGroups
).folderWorkspaces,
folderWorkspacePathStatuses: {}
})
}))
}
let failed = false
try {
applyResult(
await fetchFolderWorkspacesForTarget(
{ kind: 'local' },
get().folderWorkspaces,
get().projectGroups
)
)
applyCatalog(await fetchFolderWorkspaceCatalogForTarget({ kind: 'local' }))
} catch (err) {
failed = true
console.error('Failed to fetch local folder workspaces for all-host load:', err)
}
if (options?.remoteHosts === 'skip') {
return
}
const environments = await listRuntimeEnvironmentsForAllHostLoad()
for (const environment of environments) {
try {
applyResult(
await fetchFolderWorkspacesForTarget(
{ kind: 'environment', environmentId: environment.id },
get().folderWorkspaces,
get().projectGroups
await Promise.all(
environments.map(async (environment) => {
try {
applyCatalog(
await fetchFolderWorkspaceCatalogForTarget({
kind: 'environment',
environmentId: environment.id
})
)
} catch (err) {
failed = true
console.warn(`Skipped folder workspaces for runtime environment ${environment.id}:`, err)
}
})
)
if (!failed) {
set((s) => ({
restoredRuntimeHostIdByWorkspaceSessionKey: clearRestoredFolderWorkspaceSessionOwners(
s.restoredRuntimeHostIdByWorkspaceSessionKey,
s
)
} catch (err) {
console.warn(`Skipped folder workspaces for runtime environment ${environment.id}:`, err)
}
}))
}
},

View File

@ -3,7 +3,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import type { BrowserTab, DetectedWorktreeListResult, Worktree } from '../../../../shared/types'
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import {
FLOATING_TERMINAL_WORKTREE_ID,
getDefaultWorkspaceSession
} from '../../../../shared/constants'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
// Mock sonner (imported by repos.ts)
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
@ -531,6 +535,143 @@ describe('hydrateWorkspaceSession', () => {
})
})
describe('restored folder workspace hydration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('keeps runtime folder workspace tabs, files, and browsers before remote catalogs load', () => {
const store = createTestStore()
const folderKey = folderWorkspaceKey('remote-folder')
const groupId = 'group-folder'
const editorFileId = '/srv/app/src/App.tsx'
const session = {
...getDefaultWorkspaceSession(),
activeWorkspaceKey: folderKey,
activeWorktreeId: folderKey,
activeTabId: 'terminal-folder',
tabsByWorktree: {
[folderKey]: [makeTab({ id: 'terminal-folder', worktreeId: folderKey })]
},
openFilesByWorktree: {
[folderKey]: [
{
filePath: editorFileId,
relativePath: 'src/App.tsx',
worktreeId: folderKey,
language: 'typescript'
}
]
},
activeFileIdByWorktree: { [folderKey]: editorFileId },
browserTabsByWorktree: {
[folderKey]: [
makeBrowserTab({
id: 'browser-folder',
worktreeId: folderKey,
url: 'https://example.com'
})
]
},
browserPagesByWorkspace: {
'browser-folder': [
{
id: 'browser-page-folder',
workspaceId: 'browser-folder',
worktreeId: folderKey,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
},
activeBrowserTabIdByWorktree: { [folderKey]: 'browser-folder' },
activeTabTypeByWorktree: { [folderKey]: 'browser' as const },
unifiedTabs: {
[folderKey]: [
{
id: 'terminal-folder',
entityId: 'terminal-folder',
groupId,
worktreeId: folderKey,
contentType: 'terminal' as const,
label: 'Terminal',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
},
{
id: editorFileId,
entityId: editorFileId,
groupId,
worktreeId: folderKey,
contentType: 'editor' as const,
label: 'App.tsx',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 2
},
{
id: 'browser-folder',
entityId: 'browser-folder',
groupId,
worktreeId: folderKey,
contentType: 'browser' as const,
label: 'Example',
customLabel: null,
color: null,
sortOrder: 2,
createdAt: 3
}
]
},
tabGroups: {
[folderKey]: [
{
id: groupId,
worktreeId: folderKey,
activeTabId: 'browser-folder',
tabOrder: ['terminal-folder', editorFileId, 'browser-folder'],
recentTabIds: ['terminal-folder', editorFileId, 'browser-folder']
}
]
},
activeGroupIdByWorktree: { [folderKey]: groupId }
}
const options = {
additionalValidWorkspaceKeys: [folderKey],
runtimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' as const }
}
store.getState().hydrateWorkspaceSession(session, options)
store.getState().hydrateTabsSession(session, options)
store.getState().hydrateEditorSession(session, options)
store.getState().hydrateBrowserSession(session, options)
const state = store.getState()
expect(state.activeWorktreeId).toBe(folderKey)
expect(state.activeWorkspaceKey).toBe(folderKey)
expect(state.tabsByWorktree[folderKey]?.map((tab) => tab.id)).toEqual(['terminal-folder'])
expect(state.unifiedTabsByWorktree[folderKey]?.map((tab) => tab.id)).toEqual([
'terminal-folder',
editorFileId,
'browser-folder'
])
expect(state.openFiles.map((file) => file.worktreeId)).toEqual([folderKey])
expect(state.activeFileIdByWorktree[folderKey]).toBe(editorFileId)
expect(state.browserTabsByWorktree[folderKey]?.map((tab) => tab.id)).toEqual(['browser-folder'])
expect(state.browserPagesByWorkspace['browser-folder']?.[0]?.worktreeId).toBe(folderKey)
expect(state.activeTabTypeByWorktree[folderKey]).toBe('browser')
})
})
describe('hydrateBrowserSession', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@ -35,6 +35,10 @@ import { createBrowserUuid } from '@/lib/browser-uuid'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
export type TabSplitDirection = 'left' | 'right' | 'up' | 'down'
@ -172,7 +176,10 @@ export type TabsSlice = {
renderableTabCount: number
activeRenderableTabId: string | null
}
hydrateTabsSession: (session: WorkspaceSessionState) => void
hydrateTabsSession: (
session: WorkspaceSessionState,
options?: WorkspaceSessionHydrationOptions
) => void
}
// Why: keep the TerminalTab (tabsByWorktree) pin in sync with the unified-tab
@ -2036,7 +2043,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
}
},
hydrateTabsSession: (session) => {
hydrateTabsSession: (session, options) => {
const state = get()
const validWorktreeIds = new Set(
Object.values(state.worktreesByRepo)
@ -2047,6 +2054,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
for (const workspace of state.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
}
addAdditionalValidWorkspaceKeys(validWorktreeIds, options)
set(buildHydratedTabState(session, validWorktreeIds))
}
})

View File

@ -92,7 +92,11 @@ const mockApi = {
globalThis.window = { api: mockApi }
import type { WorkspaceSessionState } from '../../../../shared/types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import {
FLOATING_TERMINAL_WORKTREE_ID,
getDefaultWorkspaceSession
} from '../../../../shared/constants'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
import { createTestStore, makeLayout, makeTab, makeWorktree, seedStore } from './store-test-helpers'
import { canGoBackWorktreeHistory } from './worktree-nav-history'
@ -138,6 +142,145 @@ describe('hydrateWorkspaceSession', () => {
})
})
it('hydrates runtime-owned tabs from host partitions before remote catalogs load', () => {
const store = createTestStore()
const worktreeId = 'remote-repo::/srv/remote-wt'
const session: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
activeRepoId: 'remote-repo',
activeWorktreeId: worktreeId,
activeTabId: 'remote-tab',
activeWorktreeIdsOnShutdown: [worktreeId],
tabsByWorktree: {
[worktreeId]: [
makeTab({
id: 'remote-tab',
worktreeId,
ptyId: 'remote-session'
})
]
},
remoteSessionIdsByTabId: { 'remote-tab': 'remote-session' }
}
store.getState().hydrateWorkspaceSession(session, {
runtimeHostIdByWorkspaceSessionKey: { [worktreeId]: 'runtime:env-1' }
})
expect(store.getState().tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([
'remote-tab'
])
expect(store.getState().activeWorktreeId).toBe(worktreeId)
expect(store.getState().activeRepoId).toBe('remote-repo')
expect(store.getState().pendingReconnectWorktreeIds).toEqual([worktreeId])
expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({
'remote-tab': 'remote-session'
})
expect(store.getState().repos).toEqual([
expect.objectContaining({
id: 'remote-repo',
executionHostId: 'runtime:env-1'
})
])
expect(store.getState().worktreesByRepo['remote-repo']).toEqual([
expect.objectContaining({
id: worktreeId,
hostId: 'runtime:env-1'
})
])
})
it('avoids duplicate repo placeholders when a same-id local repo is already loaded', () => {
const store = createTestStore()
const worktreeId = 'same-repo::/srv/remote-wt'
store.setState({
repos: [
{
id: 'same-repo',
path: '/Users/me/same-repo',
displayName: 'Same repo',
badgeColor: '#000',
addedAt: 1,
connectionId: null,
executionHostId: 'local'
}
],
worktreesByRepo: {}
})
const session: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
activeRepoId: 'same-repo',
activeWorktreeId: worktreeId,
activeTabId: 'remote-tab',
activeWorktreeIdsOnShutdown: [worktreeId],
tabsByWorktree: {
[worktreeId]: [
makeTab({
id: 'remote-tab',
worktreeId,
ptyId: 'remote-session'
})
]
}
}
store.getState().hydrateWorkspaceSession(session, {
runtimeHostIdByWorkspaceSessionKey: {
[worktreeWorkspaceKey(worktreeId)]: 'runtime:env-1'
}
})
expect(store.getState().repos.map((repo) => `${repo.id}:${repo.executionHostId}`)).toEqual([
'same-repo:local'
])
expect(store.getState().worktreesByRepo['same-repo']).toEqual([
expect.objectContaining({ id: worktreeId, hostId: 'runtime:env-1' })
])
expect(store.getState().tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([
'remote-tab'
])
})
it('hydrates runtime folder workspace tabs before remote folder catalogs load', () => {
const store = createTestStore()
const folderKey = folderWorkspaceKey('folder-1')
const session: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
activeWorkspaceKey: folderKey,
activeWorktreeId: folderKey,
activeTabId: 'remote-folder-tab',
activeWorktreeIdsOnShutdown: [folderKey],
tabsByWorktree: {
[folderKey]: [
makeTab({
id: 'remote-folder-tab',
worktreeId: folderKey,
ptyId: 'remote-folder-session'
})
]
},
remoteSessionIdsByTabId: { 'remote-folder-tab': 'remote-folder-session' }
}
store.getState().hydrateWorkspaceSession(session, {
additionalValidWorkspaceKeys: [folderKey],
runtimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' }
})
expect(store.getState().tabsByWorktree[folderKey]?.map((tab) => tab.id)).toEqual([
'remote-folder-tab'
])
expect(store.getState().activeWorktreeId).toBe(folderKey)
expect(store.getState().activeWorkspaceKey).toBe(folderKey)
expect(store.getState().pendingReconnectWorktreeIds).toEqual([folderKey])
expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({
'remote-folder-tab': 'remote-folder-session'
})
expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({
[folderKey]: 'runtime:env-1'
})
})
it('moves restored active focus from a dead split leaf to a pty-backed sibling', () => {
const store = createTestStore()
const worktreeId = 'repo1::/wt-1'

View File

@ -2,6 +2,7 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type {
Repo,
SetupSplitDirection,
Tab,
TerminalLayoutSnapshot,
@ -15,7 +16,11 @@ import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../../../shared/agent-session-resume'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import {
DEFAULT_REPO_BADGE_COLOR,
FLOATING_TERMINAL_WORKTREE_ID
} from '../../../../shared/constants'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import {
folderWorkspaceKey,
parseWorkspaceKey,
@ -65,6 +70,10 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
import {
addAdditionalValidWorkspaceKeys,
type WorkspaceSessionHydrationOptions
} from '@/lib/workspace-session-hydration-keys'
import {
collectHibernatedCompletionEvidenceForWorktree,
collectSleepingAgentSessionRecordsForWorktree,
@ -120,6 +129,89 @@ function getFallbackTabTitle(tab: TerminalTab, index?: number): string {
)
}
function getPathDisplayName(path: string, fallback: string): string {
const normalized = path.trim().replace(/[\\/]+$/g, '')
const basename = normalized.split(/[\\/]/).findLast(Boolean)?.trim()
return basename || fallback
}
function buildRuntimeSessionPlaceholders({
repos,
runtimeHostIdByWorkspaceSessionKey,
worktreesByRepo
}: {
repos: readonly Repo[]
runtimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
worktreesByRepo: Record<string, Worktree[]>
}): { repos: Repo[]; worktreesByRepo: Record<string, Worktree[]> } {
let nextRepos = repos.slice()
let nextWorktreesByRepo = worktreesByRepo
for (const workspaceSessionKey of Object.keys(runtimeHostIdByWorkspaceSessionKey)) {
const hostId = runtimeHostIdByWorkspaceSessionKey[workspaceSessionKey]
if (parseExecutionHostId(hostId)?.kind !== 'runtime') {
continue
}
const workspaceScope = parseWorkspaceKey(workspaceSessionKey)
if (workspaceScope?.type === 'folder') {
continue
}
const worktreeId =
workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : workspaceSessionKey
const parsed = splitWorktreeId(worktreeId)
if (!parsed) {
continue
}
const existingRepo = nextRepos.some((repo) => repo.id === parsed.repoId)
if (!existingRepo) {
// Why: remote catalogs load after hydration, but host-split session
// writes need owner metadata. If any repo with this id already exists,
// avoid duplicate ids; the worktree placeholder below carries hostId.
nextRepos = [
...nextRepos,
{
id: parsed.repoId,
path: parsed.worktreePath,
displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
addedAt: 0,
connectionId: null,
executionHostId: hostId
}
]
}
const current = nextWorktreesByRepo[parsed.repoId] ?? []
if (current.some((worktree) => worktree.id === worktreeId)) {
continue
}
const placeholder: Worktree = {
id: worktreeId,
repoId: parsed.repoId,
hostId,
displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId),
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
path: parsed.worktreePath,
head: '',
branch: '',
isBare: false,
isMainWorktree: false
}
nextWorktreesByRepo =
nextWorktreesByRepo === worktreesByRepo ? { ...worktreesByRepo } : nextWorktreesByRepo
nextWorktreesByRepo[parsed.repoId] = [...current, placeholder]
}
return { repos: nextRepos, worktreesByRepo: nextWorktreesByRepo }
}
let terminalTabOwnerCacheSource: Record<string, TerminalTab[]> | null = null
let terminalTabOwnerCache = new Map<string, string>()
@ -415,6 +507,7 @@ export type TerminalSlice = {
pendingIssueCommandSplitByTabId: Record<string, { command: string; env?: Record<string, string> }>
tabBarOrderByWorktree: Record<string, string[]>
workspaceSessionReady: boolean
restoredRuntimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
markDefaultTerminalTabsApplied: (worktreeId: string) => void
/** True only after hydrateWorkspaceSession ran from a real load of
@ -618,10 +711,17 @@ export type TerminalSlice = {
setDeferredSshReconnectTargets: (targetIds: string[]) => void
removeDeferredSshReconnectTarget: (targetId: string) => void
removeDeferredSshSessionId: (tabId: string) => void
hydrateWorkspaceSession: (session: WorkspaceSessionState) => void
hydrateWorkspaceSession: (
session: WorkspaceSessionState,
options?: HydrateWorkspaceSessionOptions
) => void
reconnectPersistedTerminals: (signal?: AbortSignal) => Promise<void>
}
export type HydrateWorkspaceSessionOptions = {
runtimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
} & WorkspaceSessionHydrationOptions
export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice> = (set, get) => ({
tabsByWorktree: {},
activeTabId: null,
@ -645,6 +745,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
nativeChatLaunchPromptByTabId: {},
tabBarOrderByWorktree: {},
workspaceSessionReady: false,
restoredRuntimeHostIdByWorkspaceSessionKey: {},
defaultTerminalTabsAppliedByWorktreeId: {},
markDefaultTerminalTabsApplied: (worktreeId) =>
set((s) => {
@ -2741,16 +2842,21 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
return data
},
hydrateWorkspaceSession: (session) => {
hydrateWorkspaceSession: (session, options) => {
set((s) => {
const runtimeSessionPlaceholders = buildRuntimeSessionPlaceholders({
repos: s.repos,
runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {},
worktreesByRepo: s.worktreesByRepo
})
const validWorktreeIds = new Set(
Object.values(s.worktreesByRepo)
Object.values(runtimeSessionPlaceholders.worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
)
const knownRepoIds = new Set(s.repos.map((r) => r.id))
const knownRepoIds = new Set(runtimeSessionPlaceholders.repos.map((r) => r.id))
const repoIdsWithLoadedWorktrees = new Set(
Object.entries(s.worktreesByRepo)
Object.entries(runtimeSessionPlaceholders.worktreesByRepo)
.filter(([, worktrees]) => worktrees.length > 0)
.map(([repoId]) => repoId)
)
@ -2766,6 +2872,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
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') {
@ -2841,9 +2948,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
)
const fallbackActiveWorktreeId =
!session.activeWorktreeId && session.activeRepoId && knownRepoIds.has(session.activeRepoId)
? (s.worktreesByRepo[session.activeRepoId]?.find((worktree) => worktree.isMainWorktree)
?.id ??
s.worktreesByRepo[session.activeRepoId]?.[0]?.id ??
? (runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.find(
(worktree) => worktree.isMainWorktree
)?.id ??
runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.[0]?.id ??
null)
: null
const activeWorktreeId = (() => {
@ -2866,7 +2974,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const activeTabId =
session.activeTabId && validTabIds.has(session.activeTabId) ? session.activeTabId : null
const activeRepoId =
session.activeRepoId && s.repos.some((repo) => repo.id === session.activeRepoId)
session.activeRepoId &&
runtimeSessionPlaceholders.repos.some((repo) => repo.id === session.activeRepoId)
? session.activeRepoId
: null
@ -2908,10 +3017,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// createOrAttach RPC, triggering reattach instead of a fresh spawn.
const pendingReconnectPtyIdByTabId: Record<string, string> = {}
for (const worktreeId of pendingReconnectWorktreeIds) {
const worktree = Object.values(s.worktreesByRepo)
const worktree = Object.values(runtimeSessionPlaceholders.worktreesByRepo)
.flat()
.find((entry) => entry.id === worktreeId)
const repo = worktree ? s.repos.find((entry) => entry.id === worktree.repoId) : null
const repo = worktree
? runtimeSessionPlaceholders.repos.find((entry) => entry.id === worktree.repoId)
: null
if (repo?.connectionId) {
continue
}
@ -2963,8 +3074,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// data once SSH reconnects and fetchWorktrees runs.
// Why: only SSH needs placeholders; local metadata should come from the
// next successful fetch so the sidebar does not render synthetic entries.
const sshRepoIds = new Set(s.repos.filter((r) => r.connectionId).map((r) => r.id))
const worktreesByRepo = { ...s.worktreesByRepo }
const sshRepoIds = new Set(
runtimeSessionPlaceholders.repos.filter((r) => r.connectionId).map((r) => r.id)
)
const worktreesByRepo = { ...runtimeSessionPlaceholders.worktreesByRepo }
for (const worktreeId of Object.keys(tabsByWorktree)) {
const repoId = getRepoIdFromWorktreeId(worktreeId)
if (!sshRepoIds.has(repoId)) {
@ -3017,6 +3130,9 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
activeWorkspaceKey,
activeTabId,
activeTabIdByWorktree,
restoredRuntimeHostIdByWorkspaceSessionKey:
options?.runtimeHostIdByWorkspaceSessionKey ?? {},
repos: runtimeSessionPlaceholders.repos,
tabsByWorktree,
worktreesByRepo,
// Why: restore the per-worktree focus-recency map. Pruning of stale

View File

@ -21,6 +21,7 @@ import { makePaneKey } from '../../../../shared/stable-pane-id'
import { buildAgentNotificationId } from '../../../../shared/agent-notification-id'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt'
const mocks = vi.hoisted(() => ({
sendNotesToActiveAgentSession: vi.fn(),
@ -763,6 +764,56 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().rightSidebarExplorerView).toBe('files')
})
it('preserves persisted repo filters until repos are loaded', () => {
const store = createUIStore()
const remoteDismissalKey = getSetupScriptPromptDismissalKey('remote-repo')
store.getState().hydratePersistedUI(
makePersistedUI({
filterRepoIds: ['remote-repo', 12 as never, 'stale-repo'],
trustedOrcaHooks: {
'remote-repo': { all: { approvedAt: 1 } },
'bad-shape': 'yes' as never
},
setupScriptPromptDismissedRepoIds: [remoteDismissalKey, 'remote-repo', remoteDismissalKey]
})
)
expect(store.getState().filterRepoIds).toEqual(['remote-repo', 'stale-repo'])
expect(store.getState().trustedOrcaHooks).toEqual({
'remote-repo': { all: { approvedAt: 1 } }
})
expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([remoteDismissalKey])
})
it('validates persisted repo filters when repos are already loaded', () => {
const store = createUIStore()
const localDismissalKey = getSetupScriptPromptDismissalKey('local-repo')
const staleDismissalKey = getSetupScriptPromptDismissalKey('stale-repo')
store.setState({
repos: [
{ id: 'local-repo', path: '/local', displayName: 'Local', badgeColor: '#000', addedAt: 1 }
]
} as Partial<AppState>)
store.getState().hydratePersistedUI(
makePersistedUI({
filterRepoIds: ['local-repo', 'stale-repo'],
trustedOrcaHooks: {
'local-repo': { all: { approvedAt: 1 } },
'stale-repo': { all: { approvedAt: 2 } }
},
setupScriptPromptDismissedRepoIds: [localDismissalKey, staleDismissalKey]
})
)
expect(store.getState().filterRepoIds).toEqual(['local-repo'])
expect(store.getState().trustedOrcaHooks).toEqual({
'local-repo': { all: { approvedAt: 1 } }
})
expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([localDismissalKey])
})
it('hydrates legacy persisted search tab as Explorer search', () => {
const store = createUIStore()

View File

@ -89,7 +89,8 @@ import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust'
import type { SettingsNavTarget } from '@/lib/settings-navigation-types'
import {
filterSetupScriptPromptDismissalsToValidRepos,
getSetupScriptPromptDismissalKey
getSetupScriptPromptDismissalKey,
sanitizeSetupScriptPromptDismissals
} from '../../lib/setup-script-prompt'
import { DEFAULT_PET_ID, isBundledPetId } from '../../components/pet/pet-models'
import { revokeCustomPetBlobUrl } from '../../components/pet/pet-blob-cache'
@ -349,12 +350,38 @@ function collectAcknowledgedAgentNotificationId({
}
}
function filterTrustedOrcaHooksToValidRepos(
trust: PersistedTrustedOrcaHooks,
validRepoIds: Set<string>
): PersistedTrustedOrcaHooks {
function isPlainPersistedRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function sanitizePersistedRepoIds(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value.filter((repoId): repoId is string => typeof repoId === 'string')
}
function sanitizeTrustedOrcaHooks(trust: unknown): PersistedTrustedOrcaHooks {
if (!isPlainPersistedRecord(trust)) {
return {}
}
const next: PersistedTrustedOrcaHooks = {}
for (const [repoId, entry] of Object.entries(trust)) {
if (!isSafePersistedRecordKey(repoId) || !isPlainPersistedRecord(entry)) {
continue
}
next[repoId] = entry as PersistedTrustedOrcaHooks[string]
}
return next
}
function filterTrustedOrcaHooksToValidRepos(
trust: unknown,
validRepoIds: Set<string>
): PersistedTrustedOrcaHooks {
const sanitized = sanitizeTrustedOrcaHooks(trust)
const next: PersistedTrustedOrcaHooks = {}
for (const [repoId, entry] of Object.entries(sanitized)) {
if (validRepoIds.has(repoId)) {
next[repoId] = entry
}
@ -362,6 +389,17 @@ function filterTrustedOrcaHooksToValidRepos(
return next
}
function hydrateTrustedOrcaHooks(
trust: unknown,
validRepoIds: Set<string>
): PersistedTrustedOrcaHooks {
const sanitized = sanitizeTrustedOrcaHooks(trust)
if (validRepoIds.size === 0) {
return sanitized
}
return filterTrustedOrcaHooksToValidRepos(sanitized, validRepoIds)
}
function isSafePersistedRecordKey(key: string): boolean {
return key !== '__proto__' && key !== 'constructor' && key !== 'prototype'
}
@ -2192,6 +2230,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
hydratePersistedUI: (ui) =>
set((s) => {
const validRepoIds = new Set(s.repos.map((repo) => repo.id))
const persistedFilterRepoIds = sanitizePersistedRepoIds(ui.filterRepoIds)
// Why: persisted UI from pre-rename builds used sidekick* keys. Read
// those only as fallbacks so new pet* writes win immediately after upgrade.
const customPets = Array.isArray(ui.customPets)
@ -2283,7 +2322,12 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false,
hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true,
showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree),
filterRepoIds: (ui.filterRepoIds ?? []).filter((repoId) => validRepoIds.has(repoId)),
// Why: startup hydrates UI before repo catalogs now. With no catalog
// loaded yet, defer repo-filter validation to the all-host repo refresh.
filterRepoIds:
validRepoIds.size === 0
? persistedFilterRepoIds
: persistedFilterRepoIds.filter((repoId) => validRepoIds.has(repoId)),
collapsedGroups: new Set(ui.collapsedGroups ?? []),
uiZoomLevel: ui.uiZoomLevel ?? 0,
editorFontZoomLevel: ui.editorFontZoomLevel ?? 0,
@ -2332,14 +2376,14 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
typeof ui.contextualToursAutoEligible === 'boolean'
? ui.contextualToursAutoEligible
: null,
trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(
ui.trustedOrcaHooks ?? {},
validRepoIds
),
setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos(
ui.setupScriptPromptDismissedRepoIds,
validRepoIds
),
trustedOrcaHooks: hydrateTrustedOrcaHooks(ui.trustedOrcaHooks, validRepoIds),
setupScriptPromptDismissedRepoIds:
validRepoIds.size === 0
? sanitizeSetupScriptPromptDismissals(ui.setupScriptPromptDismissedRepoIds)
: filterSetupScriptPromptDismissalsToValidRepos(
ui.setupScriptPromptDismissedRepoIds,
validRepoIds
),
setupGuideSidebarDismissed: ui.setupGuideSidebarDismissed === true,
setupGuideBrowserMilestoneMigrated: ui.setupGuideBrowserMilestoneMigrated === true,
setupGuideBrowserMilestoneLegacyComplete:

View File

@ -112,7 +112,7 @@ export type WorktreeSlice = {
hasHydratedWorktreePurge: boolean
fetchDetectedWorktrees: (repoId: string) => Promise<DetectedWorktreeListResult | null>
fetchWorktrees: (repoId: string, options?: { requireAuthoritative?: boolean }) => Promise<boolean>
fetchAllWorktrees: () => Promise<void>
fetchAllWorktrees: (options?: { hydrationPurge?: 'allow' | 'defer' }) => Promise<void>
fetchWorktreeLineage: () => Promise<void>
updateWorktreeLineage: (
worktreeId: string,

View File

@ -5493,6 +5493,8 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
const store = createTestStore()
const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' })
const wtB = makeWorktree({ id: 'repoB::/b/wt1', repoId: 'repoB', path: '/b/wt1' })
const folderWorkspace = makeFolderWorkspace({ id: 'folder-keep' })
const folderKey = folderWorkspaceKey(folderWorkspace.id)
mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) =>
repoId === 'repoA' ? [wtA] : [wtB]
@ -5500,15 +5502,18 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
store.setState({
repos: [repoA, repoB],
folderWorkspaces: [folderWorkspace],
tabsByWorktree: {
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
'repoA::/a/zombie': [{ id: 'tab-zombie', worktreeId: 'repoA::/a/zombie' }],
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }],
[folderKey]: [{ id: 'tab-folder', worktreeId: folderKey }]
},
gitIgnoredPathsByWorktree: {
'repoA::/a/wt1': ['dist/'],
'repoA::/a/zombie': ['coverage/'],
'repoB::/b/wt1': ['build/']
'repoB::/b/wt1': ['build/'],
[folderKey]: ['tmp/']
}
} as unknown as Partial<AppState>)
@ -5518,11 +5523,13 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
expect(mockApi.worktrees.list).toHaveBeenCalledTimes(2)
expect(store.getState().tabsByWorktree).toEqual({
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }],
[folderKey]: [{ id: 'tab-folder', worktreeId: folderKey }]
})
expect(store.getState().gitIgnoredPathsByWorktree).toEqual({
'repoA::/a/wt1': ['dist/'],
'repoB::/b/wt1': ['build/']
'repoB::/b/wt1': ['build/'],
[folderKey]: ['tmp/']
})
// Second call must not re-run the purge even if new stale ids appear.
@ -5539,6 +5546,79 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
expect(store.getState().tabsByWorktree['repoA::/a/new-zombie']).toBeDefined()
})
it('can defer the first successful purge during local-only startup refresh', async () => {
const store = createTestStore()
const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' })
const wtB = makeWorktree({ id: 'repoB::/b/wt1', repoId: 'repoB', path: '/b/wt1' })
mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) =>
repoId === 'repoA' ? [wtA] : [wtB]
)
store.setState({
repos: [repoA, repoB],
tabsByWorktree: {
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
'repoA::/a/zombie': [{ id: 'tab-zombie', worktreeId: 'repoA::/a/zombie' }],
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
}
} as unknown as Partial<AppState>)
await store.getState().fetchAllWorktrees({ hydrationPurge: 'defer' })
expect(store.getState().hasHydratedWorktreePurge).toBe(false)
expect(store.getState().tabsByWorktree['repoA::/a/zombie']).toBeDefined()
await store.getState().fetchAllWorktrees()
expect(store.getState().hasHydratedWorktreePurge).toBe(true)
expect(store.getState().tabsByWorktree).toEqual({
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
})
})
it('does not consume the one-shot purge before clean workspace session hydration', async () => {
const store = createTestStore()
const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' })
const wtB = makeWorktree({ id: 'repoB::/b/wt1', repoId: 'repoB', path: '/b/wt1' })
mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) =>
repoId === 'repoA' ? [wtA] : [wtB]
)
store.setState({
workspaceSessionReady: false,
hydrationSucceeded: false,
repos: [repoA, repoB],
tabsByWorktree: {
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
'repoA::/a/zombie': [{ id: 'tab-zombie', worktreeId: 'repoA::/a/zombie' }],
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
}
} as unknown as Partial<AppState>)
await store.getState().fetchAllWorktrees()
expect(store.getState().hasHydratedWorktreePurge).toBe(false)
expect(store.getState().tabsByWorktree['repoA::/a/zombie']).toBeDefined()
store.setState({ workspaceSessionReady: true } as Partial<AppState>)
await store.getState().fetchAllWorktrees()
expect(store.getState().hasHydratedWorktreePurge).toBe(false)
expect(store.getState().tabsByWorktree['repoA::/a/zombie']).toBeDefined()
store.setState({ hydrationSucceeded: true } as Partial<AppState>)
await store.getState().fetchAllWorktrees()
expect(store.getState().hasHydratedWorktreePurge).toBe(true)
expect(store.getState().tabsByWorktree).toEqual({
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
})
})
// Why: multi-host regression — once hydration has fired, a mid-session
// fetchAllWorktrees (e.g. triggered by switching focus) must NEVER purge
// terminal state, even if a host transiently reports zero worktrees. The

View File

@ -2295,7 +2295,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
},
fetchAllWorktrees: async () => {
fetchAllWorktrees: async (options) => {
const { repos } = get()
// Why: once the one-shot hydration-time purge has fired, subsequent
@ -2437,10 +2437,31 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
// Defer; try again on the next fetchAllWorktrees call.
return
}
if (
options?.hydrationPurge === 'defer' ||
get().workspaceSessionReady === false ||
get().hydrationSucceeded === false
) {
// Why: startup first refreshes only local repos so the app can paint
// before remote runtime timeouts. Keep the one-shot purge available for
// the later all-host refresh, after a clean session hydrate and when
// remote worktree ids are known too.
return
}
const validIds = new Set<string>()
// Why: floating is persisted renderer state, but not a repo worktree that
// authoritative runtime scans can return.
validIds.add(FLOATING_TERMINAL_WORKTREE_ID)
// Why: folder workspaces persist terminal tabs under `folder:<id>` keys,
// but authoritative repo scans can never return those synthetic ids.
for (const workspace of get().folderWorkspaces ?? []) {
validIds.add(folderWorkspaceKey(workspace.id))
}
for (const key of Object.keys(get().restoredRuntimeHostIdByWorkspaceSessionKey ?? {})) {
if (parseWorkspaceKey(key)?.type === 'folder') {
validIds.add(key)
}
}
for (const result of Object.values(get().detectedWorktreesByRepo)) {
if (!result.authoritative) {
continue