diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 01836b7955a..c0a7827fa50 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -643,7 +643,8 @@ describe('applyWebSessionTabsSnapshot', () => { expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ freshness: 1, - hostMappings: 1 + hostMappings: 1, + hostMappingWorktrees: 1 }) applyFreshWebSessionTabsSnapshot( @@ -664,7 +665,8 @@ describe('applyWebSessionTabsSnapshot', () => { expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ freshness: 0, - hostMappings: 0 + hostMappings: 0, + hostMappingWorktrees: 0 }) }) @@ -720,17 +722,89 @@ describe('applyWebSessionTabsSnapshot', () => { expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ freshness: 2, - hostMappings: 2 + hostMappings: 2, + hostMappingWorktrees: 2 }) clearWebSessionTabsTrackingForEnvironment(ENV) expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ freshness: 1, - hostMappings: 1 + hostMappings: 1, + hostMappingWorktrees: 1 }) }) + it('clears one worktree mapping without dropping a sibling in the same environment', () => { + // Why: POSIX paths may contain ':', so this sibling's worktree id is prefixed by WT's — the case a prefix scan wiped. + const secondWorktree = `${WT}:2` + const terminalSnapshot = makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'host shell', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1' + } + ]) + const secondSnapshot = makeSnapshot( + [ + { + type: 'terminal', + id: `host-tab-2::${SECOND_LEAF_ID}`, + title: 'second shell', + parentTabId: 'host-tab-2', + leafId: SECOND_LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-2' + } + ], + { worktree: secondWorktree } + ) + applyFreshWebSessionTabsSnapshot(makeState(), terminalSnapshot, ENV, NOW) + applyFreshWebSessionTabsSnapshot(makeState(), secondSnapshot, ENV, NOW) + + applyFreshWebSessionTabsSnapshot( + makeState(), + { + ...makeSnapshot([], { + publicationEpoch: 'removed-epoch', + snapshotVersion: 0, + activeGroupId: null, + activeTabId: null, + activeTabType: null + }), + removed: true + } as RuntimeMobileSessionTabsResult, + ENV, + NOW + 1 + ) + + expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ + freshness: 1, + hostMappings: 1, + hostMappingWorktrees: 1 + }) + expect( + resolveHostSessionTabIdForWebSessionTab(makeState(), { + environmentId: ENV, + worktreeId: WT, + tabId: toWebTerminalSurfaceTabId('host-tab-1') + }) + ).toBeNull() + expect( + resolveHostSessionTabIdForWebSessionTab(makeState(), { + environmentId: ENV, + worktreeId: secondWorktree, + tabId: toWebTerminalSurfaceTabId('host-tab-2') + }) + ).toBe('host-tab-2') + }) + it('keeps a provisional Claude tab when the host Claude surface is unrelated', () => { const staleLocalAgentTab: TerminalTab = { id: 'local-agent-tab', @@ -2273,6 +2347,157 @@ describe('applyWebSessionTabsSnapshot', () => { ) }) + it('keeps an empty snapshot batch as an identity no-op', () => { + const state = makeState() + expect(applyWebSessionTabsSnapshots(state, [], ENV, NOW)).toBe(state) + }) + + it('matches sequential reconciliation across duplicate-worktree mixed snapshots', () => { + const secondWorktree = 'repo::/other-worktree' + const snapshots: RuntimeMobileSessionTabsResult[] = [ + makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'first agent', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'working', + prompt: 'first task', + updatedAt: NOW, + stateStartedAt: NOW, + agentType: 'codex', + paneKey: HOST_SURFACE_ID, + stateHistory: [] + } + } + ]), + makeSnapshot( + [ + { + type: 'browser', + id: 'host-browser-unified', + title: 'Example Domain', + browserWorkspaceId: 'host-browser-workspace', + browserPageId: 'host-browser-page', + url: 'https://example.com/', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: true + } + ], + { + worktree: secondWorktree, + activeGroupId: 'host-group-2', + activeTabId: 'host-browser-unified', + activeTabType: 'browser' + } + ), + makeSnapshot([], { + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null + }), + makeSnapshot( + [ + { + type: 'terminal', + id: `host-tab-3::${THIRD_LEAF_ID}`, + title: 'replacement agent', + parentTabId: 'host-tab-3', + leafId: THIRD_LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-3', + agentStatus: { + state: 'waiting', + prompt: 'replacement question', + updatedAt: NOW + 1, + stateStartedAt: NOW + 1, + agentType: 'codex', + paneKey: `host-tab-3::${THIRD_LEAF_ID}`, + stateHistory: [] + } + } + ], + { snapshotVersion: 3 } + ), + makeSnapshot( + [ + { + type: 'markdown', + id: 'host-readme-unified', + title: 'README.md', + filePath: '/repo/README.md', + relativePath: 'README.md', + language: 'markdown', + mode: 'edit', + isDirty: false, + isActive: true, + sourceFileId: '/repo/README.md', + sourceFilePath: '/repo/README.md', + sourceRelativePath: 'README.md', + documentVersion: 'file:/repo/README.md' + } + ], + { + worktree: secondWorktree, + snapshotVersion: 2, + activeGroupId: 'host-group-2', + activeTabId: 'host-readme-unified', + activeTabType: 'markdown' + } + ) + ] + const provisionalTab: TerminalTab = { + id: 'host-tab-1', + ptyId: null, + worktreeId: WT, + title: 'Codex', + defaultTitle: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW, + launchAgent: 'codex' + } + const initial = makeState({ + activeWorktreeId: null, + tabsByWorktree: { [WT]: [provisionalTab] }, + pendingStartupByTabId: { + [provisionalTab.id]: { command: 'codex' } + }, + automaticAgentResumeClaimsByTabId: { + [provisionalTab.id]: { + worktreeId: WT, + launchAgent: 'codex', + providerSession: { key: 'session_id', id: 'session-a' } + } + } + }) + const initialCopy = structuredClone(initial) + let sequential = initial + for (const snapshot of snapshots) { + const patch = applyWebSessionTabsSnapshot(sequential, snapshot, ENV, NOW) + if (patch !== sequential) { + sequential = { ...sequential, ...patch } + } + } + + resetWebSessionTabsSnapshotFreshnessForTests() + const batchPatch = applyWebSessionTabsSnapshots(initial, snapshots, ENV, NOW) + const batched = { ...initial, ...batchPatch } + + expect(batched).toEqual(sequential) + expect(initial).toEqual(initialCopy) + }) + it('replaces temporary web-created tabs once the host publishes the same PTY', () => { const localTab: TerminalTab = { id: 'local-web-tab', diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 37ab1feedd2..6224cca5ffb 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -105,6 +105,10 @@ const latestSessionTabsSnapshotByWorktree = new Map() const replayableSessionTabsSnapshotByWorktree = new Map() const lastHostTerminalTabCountByWorktree = new Map() const hostSessionTabIdByLocalKey = new Map() +const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< + string, + Map> +>() type TerminalSurface = RuntimeMobileSessionTerminalClientTab type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'ready' } @@ -165,6 +169,33 @@ export type WebSessionTabsSyncState = Pick< > & Partial> +type WebSessionTabsBatchRecordKey = + | 'activeBrowserTabIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeGroupIdByWorktree' + | 'activeTabIdByWorktree' + | 'activeTabTypeByWorktree' + | 'agentStatusByPaneKey' + | 'automaticAgentResumeClaimsByTabId' + | 'browserCertificateFailuresByPageId' + | 'browserPagesByWorkspace' + | 'browserTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'pendingStartupByTabId' + | 'ptyIdsByTabId' + | 'remoteBrowserPageHandlesByPageId' + | 'tabBarOrderByWorktree' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'unifiedTabsByWorktree' + | 'unreadTerminalTabs' + +type WebSessionTabsBatchContext = { + agentPaneKeysByTabId: Map> | null + changedRecords: Set +} + function isSessionTabsListAllResult(value: unknown): value is SessionTabsListAllResult { return ( Boolean(value) && @@ -331,15 +362,23 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void { replayableSessionTabsSnapshotByWorktree.clear() lastHostTerminalTabCountByWorktree.clear() hostSessionTabIdByLocalKey.clear() + hostSessionTabMappingKeysByEnvironmentAndWorktree.clear() } export function _getWebSessionTabsTrackingCountsForTest(): { freshness: number hostMappings: number + hostMappingWorktrees: number } { + let hostMappingWorktrees = 0 + for (const mappingKeysByWorktree of hostSessionTabMappingKeysByEnvironmentAndWorktree.values()) { + hostMappingWorktrees += mappingKeysByWorktree.size + } return { freshness: latestSessionTabsSnapshotByWorktree.size, - hostMappings: hostSessionTabIdByLocalKey.size + hostMappings: hostSessionTabIdByLocalKey.size, + // Why: the mapping index is a parallel structure, so leak tests must see it drain alongside the flat map. + hostMappingWorktrees } } @@ -352,12 +391,7 @@ function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeI clearWebSessionReorderIntentsForWorktree({ environmentId }, worktreeId) clearWebSessionCloseIntentsForWorktree({ environmentId }, worktreeId) clearWebAgentSessionHandoffsForWorktree(environmentId, worktreeId) - const keyPrefix = `${environmentId}:${worktreeId}:` - for (const key of hostSessionTabIdByLocalKey.keys()) { - if (key.startsWith(keyPrefix)) { - hostSessionTabIdByLocalKey.delete(key) - } - } + clearHostSessionTabIdMappings(environmentId, worktreeId) } export function clearWebSessionTabsTrackingForEnvironment(environmentId: string): void { @@ -381,10 +415,15 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) lastHostTerminalTabCountByWorktree.delete(key) } } - for (const key of hostSessionTabIdByLocalKey.keys()) { - if (key.startsWith(keyPrefix)) { - hostSessionTabIdByLocalKey.delete(key) + const mappingKeysByWorktree = + hostSessionTabMappingKeysByEnvironmentAndWorktree.get(trimmedEnvironmentId) + if (mappingKeysByWorktree) { + for (const mappingKeys of mappingKeysByWorktree.values()) { + for (const mappingKey of mappingKeys) { + hostSessionTabIdByLocalKey.delete(mappingKey) + } } + hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(trimmedEnvironmentId) } clearWebAgentSessionHandoffsForEnvironment(trimmedEnvironmentId) clearAllWebRuntimeWakeTerminalRespawn() @@ -398,6 +437,35 @@ function hostSessionTabMappingKey(args: { return `${args.environmentId}:${args.worktreeId}:${args.tabId}` } +function clearHostSessionTabIdMappings(environmentId: string, worktreeId: string): void { + const mappingKeysByWorktree = hostSessionTabMappingKeysByEnvironmentAndWorktree.get(environmentId) + const mappingKeys = mappingKeysByWorktree?.get(worktreeId) + if (!mappingKeys) { + return + } + for (const mappingKey of mappingKeys) { + hostSessionTabIdByLocalKey.delete(mappingKey) + } + mappingKeysByWorktree?.delete(worktreeId) + if (mappingKeysByWorktree?.size === 0) { + hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(environmentId) + } +} + +function setHostSessionTabIdMapping( + args: { environmentId: string; worktreeId: string; tabId: string }, + hostTabId: string +): void { + const mappingKey = hostSessionTabMappingKey(args) + hostSessionTabIdByLocalKey.set(mappingKey, hostTabId) + const mappingKeysByWorktree = + hostSessionTabMappingKeysByEnvironmentAndWorktree.get(args.environmentId) ?? new Map() + const mappingKeys = mappingKeysByWorktree.get(args.worktreeId) ?? new Set() + mappingKeys.add(mappingKey) + mappingKeysByWorktree.set(args.worktreeId, mappingKeys) + hostSessionTabMappingKeysByEnvironmentAndWorktree.set(args.environmentId, mappingKeysByWorktree) +} + export function resolveHostSessionTabIdForWebSessionTab( _state: WebSessionTabsSyncState, args: { @@ -735,13 +803,60 @@ function isFencedClientAgentStatus( return isClientOwnedAgentStatus(paneKey, existing) && isAgentStatusFresh(existing, now) } +function batchAgentPaneKeysForTabs( + state: WebSessionTabsSyncState, + tabIds: ReadonlySet, + batchContext?: WebSessionTabsBatchContext +): string[] { + if (!batchContext) { + return Object.keys(state.agentStatusByPaneKey) + } + if (!batchContext.agentPaneKeysByTabId) { + batchContext.agentPaneKeysByTabId = new Map() + for (const paneKey of Object.keys(state.agentStatusByPaneKey)) { + const tabId = parsePaneKey(paneKey)?.tabId + if (!tabId) { + continue + } + const paneKeys = batchContext.agentPaneKeysByTabId.get(tabId) ?? new Set() + paneKeys.add(paneKey) + batchContext.agentPaneKeysByTabId.set(tabId, paneKeys) + } + } + return [...tabIds].flatMap((tabId) => [...(batchContext.agentPaneKeysByTabId?.get(tabId) ?? [])]) +} + +function updateBatchAgentPaneKey( + paneKey: string, + present: boolean, + batchContext?: WebSessionTabsBatchContext +): void { + const tabId = parsePaneKey(paneKey)?.tabId + const index = batchContext?.agentPaneKeysByTabId + if (!tabId || !index) { + return + } + if (present) { + const paneKeys = index.get(tabId) ?? new Set() + paneKeys.add(paneKey) + index.set(tabId, paneKeys) + return + } + const paneKeys = index.get(tabId) + paneKeys?.delete(paneKey) + if (paneKeys?.size === 0) { + index.delete(tabId) + } +} + /** Generates a state patch for mirrored agent statuses, merging host entries with client overrides. */ function buildMirroredAgentStatusPatch( state: WebSessionTabsSyncState, currentTerminalTabs: readonly TerminalTab[], terminalSurfaceTabs: readonly TerminalSurface[], mirroredTerminalTabs: readonly MirroredTerminalTab[], - now: number + now: number, + batchContext?: WebSessionTabsBatchContext ): Pick | null { const mirroredTabIds = new Set() for (const tab of currentTerminalTabs) { @@ -812,7 +927,7 @@ function buildMirroredAgentStatusPatch( let aggregateRelevantChange = false let sortRelevantChange = false - for (const paneKey of Object.keys(state.agentStatusByPaneKey)) { + for (const paneKey of batchAgentPaneKeysForTabs(state, mirroredTabIds, batchContext)) { if (!isMirroredAgentPaneKeyForTabs(paneKey, mirroredTabIds)) { continue } @@ -830,9 +945,14 @@ function buildMirroredAgentStatusPatch( continue } if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { - nextAgentStatusByPaneKey = { ...state.agentStatusByPaneKey } + nextAgentStatusByPaneKey = writableWebSessionTabsRecord( + state, + 'agentStatusByPaneKey', + batchContext + ) } delete nextAgentStatusByPaneKey[paneKey] + updateBatchAgentPaneKey(paneKey, false, batchContext) changed = true aggregateRelevantChange = true sortRelevantChange = true @@ -844,9 +964,14 @@ function buildMirroredAgentStatusPatch( continue } if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { - nextAgentStatusByPaneKey = { ...state.agentStatusByPaneKey } + nextAgentStatusByPaneKey = writableWebSessionTabsRecord( + state, + 'agentStatusByPaneKey', + batchContext + ) } nextAgentStatusByPaneKey[paneKey] = entry + updateBatchAgentPaneKey(paneKey, true, batchContext) changed = true const entryAttributionChanged = existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId @@ -1285,34 +1410,20 @@ function updateHostSessionTabIdMappings(args: { browserTabs: readonly MirroredBrowserTab[] editorTabs: readonly MirroredEditorTab[] }): void { - const keyPrefix = `${args.environmentId}:${args.worktreeId}:` - for (const key of hostSessionTabIdByLocalKey.keys()) { - if (key.startsWith(keyPrefix)) { - hostSessionTabIdByLocalKey.delete(key) - } - } + clearHostSessionTabIdMappings(args.environmentId, args.worktreeId) const mirroredTerminalIds = new Set(args.terminalTabs.map((tab) => tab.id)) for (const surface of args.terminalSurfaces) { const localId = toWebTerminalSurfaceTabId(surface.parentTabId) if (mirroredTerminalIds.has(localId)) { - hostSessionTabIdByLocalKey.set( - hostSessionTabMappingKey({ ...args, tabId: localId }), - surface.parentTabId - ) + setHostSessionTabIdMapping({ ...args, tabId: localId }, surface.parentTabId) } } for (const entry of args.browserTabs) { - hostSessionTabIdByLocalKey.set( - hostSessionTabMappingKey({ ...args, tabId: entry.unifiedTab.id }), - entry.hostTabId - ) + setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) } for (const entry of args.editorTabs) { - hostSessionTabIdByLocalKey.set( - hostSessionTabMappingKey({ ...args, tabId: entry.unifiedTab.id }), - entry.hostTabId - ) + setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) } } @@ -1500,20 +1611,47 @@ function pushRecentTabId(recent: string[] | undefined, tabId: string): string[] return [...base.filter((id) => id !== tabId), tabId] } +function writableWebSessionTabsRecord( + state: WebSessionTabsSyncState, + recordKey: K, + batchContext?: WebSessionTabsBatchContext +): NonNullable { + const record = (state[recordKey] ?? {}) as NonNullable + if (!batchContext) { + return { ...record } as NonNullable + } + // Why: one batch owns its record copies, so later snapshots can update them without recopying every workspace. + if (batchContext.changedRecords.has(recordKey)) { + return record + } + const next = { ...record } as NonNullable + const mutableState = state as unknown as Record< + WebSessionTabsBatchRecordKey, + Record + > + mutableState[recordKey] = next as Record + batchContext.changedRecords.add(recordKey) + return next +} + function withWorktreeEntry( - record: Record, + state: WebSessionTabsSyncState, + recordKey: WebSessionTabsBatchRecordKey, key: string, value: T | null, - equal: (a: T | undefined, b: T | null) => boolean + equal: (a: T | undefined, b: T | null) => boolean, + batchContext?: WebSessionTabsBatchContext, + deleteNull = true ): Record { + const record = (state[recordKey] ?? {}) as Record if (equal(record[key], value)) { return record } - const next = { ...record } - if (value === null) { + const next = writableWebSessionTabsRecord(state, recordKey, batchContext) as Record + if (value === null && deleteNull) { delete next[key] } else { - next[key] = value + next[key] = value as T } return next } @@ -1763,11 +1901,12 @@ function findCurrentVisibleUnifiedTabId(args: { return null } -export function applyWebSessionTabsSnapshot( +function applyWebSessionTabsSnapshotWithContext( state: WebSessionTabsSyncState, rawSnapshot: RuntimeMobileSessionTabsResult, environmentId: string, - now = Date.now() + now = Date.now(), + batchContext?: WebSessionTabsBatchContext ): WebSessionTabsSyncState | Partial { const worktreeId = rawSnapshot.worktree if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { @@ -2262,7 +2401,9 @@ export function applyWebSessionTabsSnapshot( for (const removedId of removedTerminalIds) { if (nextPtyIdsByTabId[removedId]) { nextPtyIdsByTabId = - nextPtyIdsByTabId === state.ptyIdsByTabId ? { ...state.ptyIdsByTabId } : nextPtyIdsByTabId + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId delete nextPtyIdsByTabId[removedId] } } @@ -2270,7 +2411,9 @@ export function applyWebSessionTabsSnapshot( const current = nextPtyIdsByTabId[tab.id] ?? [] if (!sameStringArray(current, ptyIds)) { nextPtyIdsByTabId = - nextPtyIdsByTabId === state.ptyIdsByTabId ? { ...state.ptyIdsByTabId } : nextPtyIdsByTabId + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId nextPtyIdsByTabId[tab.id] = ptyIds } } @@ -2280,7 +2423,7 @@ export function applyWebSessionTabsSnapshot( if (nextTerminalLayoutsByTabId[removedId]) { nextTerminalLayoutsByTabId = nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId - ? { ...state.terminalLayoutsByTabId } + ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) : nextTerminalLayoutsByTabId delete nextTerminalLayoutsByTabId[removedId] } @@ -2289,7 +2432,7 @@ export function applyWebSessionTabsSnapshot( if (!terminalLayoutEqual(nextTerminalLayoutsByTabId[tab.id], layout)) { nextTerminalLayoutsByTabId = nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId - ? { ...state.terminalLayoutsByTabId } + ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) : nextTerminalLayoutsByTabId nextTerminalLayoutsByTabId[tab.id] = layout } @@ -2300,7 +2443,7 @@ export function applyWebSessionTabsSnapshot( if (nextUnreadTerminalTabs[removedId]) { nextUnreadTerminalTabs = nextUnreadTerminalTabs === state.unreadTerminalTabs - ? { ...state.unreadTerminalTabs } + ? writableWebSessionTabsRecord(state, 'unreadTerminalTabs', batchContext) : nextUnreadTerminalTabs delete nextUnreadTerminalTabs[removedId] } @@ -2314,14 +2457,14 @@ export function applyWebSessionTabsSnapshot( if (nextPendingStartupByTabId[removedId]) { nextPendingStartupByTabId = nextPendingStartupByTabId === pendingStartupByTabId - ? { ...pendingStartupByTabId } + ? writableWebSessionTabsRecord(state, 'pendingStartupByTabId', batchContext) : nextPendingStartupByTabId delete nextPendingStartupByTabId[removedId] } if (nextAutomaticAgentResumeClaimsByTabId[removedId]) { nextAutomaticAgentResumeClaimsByTabId = nextAutomaticAgentResumeClaimsByTabId === automaticAgentResumeClaimsByTabId - ? { ...automaticAgentResumeClaimsByTabId } + ? writableWebSessionTabsRecord(state, 'automaticAgentResumeClaimsByTabId', batchContext) : nextAutomaticAgentResumeClaimsByTabId delete nextAutomaticAgentResumeClaimsByTabId[removedId] } @@ -2335,7 +2478,7 @@ export function applyWebSessionTabsSnapshot( if (nextBrowserPagesByWorkspace[removedWorkspaceId]) { nextBrowserPagesByWorkspace = nextBrowserPagesByWorkspace === state.browserPagesByWorkspace - ? { ...state.browserPagesByWorkspace } + ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) : nextBrowserPagesByWorkspace delete nextBrowserPagesByWorkspace[removedWorkspaceId] } @@ -2343,14 +2486,18 @@ export function applyWebSessionTabsSnapshot( if (nextBrowserCertificateFailuresByPageId[page.id]) { nextBrowserCertificateFailuresByPageId = nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId - ? { ...state.browserCertificateFailuresByPageId } + ? writableWebSessionTabsRecord( + state, + 'browserCertificateFailuresByPageId', + batchContext + ) : nextBrowserCertificateFailuresByPageId delete nextBrowserCertificateFailuresByPageId[page.id] } if (nextRemoteBrowserPageHandlesByPageId[page.id]) { nextRemoteBrowserPageHandlesByPageId = nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId - ? { ...state.remoteBrowserPageHandlesByPageId } + ? writableWebSessionTabsRecord(state, 'remoteBrowserPageHandlesByPageId', batchContext) : nextRemoteBrowserPageHandlesByPageId delete nextRemoteBrowserPageHandlesByPageId[page.id] } @@ -2361,7 +2508,7 @@ export function applyWebSessionTabsSnapshot( if (!sameBrowserPages(current, [page])) { nextBrowserPagesByWorkspace = nextBrowserPagesByWorkspace === state.browserPagesByWorkspace - ? { ...state.browserPagesByWorkspace } + ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) : nextBrowserPagesByWorkspace nextBrowserPagesByWorkspace[page.workspaceId] = [page] } @@ -2372,7 +2519,7 @@ export function applyWebSessionTabsSnapshot( ) { nextRemoteBrowserPageHandlesByPageId = nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId - ? { ...state.remoteBrowserPageHandlesByPageId } + ? writableWebSessionTabsRecord(state, 'remoteBrowserPageHandlesByPageId', batchContext) : nextRemoteBrowserPageHandlesByPageId nextRemoteBrowserPageHandlesByPageId[page.id] = { environmentId, @@ -2387,7 +2534,7 @@ export function applyWebSessionTabsSnapshot( ) { nextBrowserCertificateFailuresByPageId = nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId - ? { ...state.browserCertificateFailuresByPageId } + ? writableWebSessionTabsRecord(state, 'browserCertificateFailuresByPageId', batchContext) : nextBrowserCertificateFailuresByPageId if (certificateFailure) { nextBrowserCertificateFailuresByPageId[page.id] = certificateFailure @@ -2398,28 +2545,36 @@ export function applyWebSessionTabsSnapshot( } const nextTabsByWorktree = withWorktreeEntry( - state.tabsByWorktree, + state, + 'tabsByWorktree', worktreeId, nextTerminalTabs, - sameTerminalTabs + sameTerminalTabs, + batchContext ) const nextBrowserTabsByWorktree = withWorktreeEntry( - state.browserTabsByWorktree, + state, + 'browserTabsByWorktree', worktreeId, nextBrowserTabs, - sameBrowserTabs + sameBrowserTabs, + batchContext ) const nextUnifiedTabsByWorktree = withWorktreeEntry( - state.unifiedTabsByWorktree, + state, + 'unifiedTabsByWorktree', worktreeId, nextUnifiedTabs, - sameUnifiedTabs + sameUnifiedTabs, + batchContext ) const nextGroupsByWorktree = withWorktreeEntry( - state.groupsByWorktree, + state, + 'groupsByWorktree', worktreeId, nextGroups, - sameGroups + sameGroups, + batchContext ) const nextActiveGroupId = // Why: status/title snapshots carry the host's last active tab; a client that already switched panes keeps its local group focus. @@ -2429,7 +2584,14 @@ export function applyWebSessionTabsSnapshot( null const nextActiveGroupIdByWorktree = nextGroups && state.activeGroupIdByWorktree[worktreeId] !== nextActiveGroupId - ? { ...state.activeGroupIdByWorktree, [worktreeId]: nextActiveGroupId ?? targetGroupId } + ? withWorktreeEntry( + state, + 'activeGroupIdByWorktree', + worktreeId, + nextActiveGroupId ?? targetGroupId, + (current, next) => current === next, + batchContext + ) : state.activeGroupIdByWorktree const nextLayoutByWorktree = (() => { if (!nextGroups) { @@ -2467,28 +2629,58 @@ export function applyWebSessionTabsSnapshot( if (tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], fallbackLayout)) { return state.layoutByWorktree } - return { - ...state.layoutByWorktree, - [worktreeId]: fallbackLayout - } + return withWorktreeEntry( + state, + 'layoutByWorktree', + worktreeId, + fallbackLayout, + (current, next) => current === next, + batchContext + ) })() const nextTabBarOrderByWorktree = withWorktreeEntry( - state.tabBarOrderByWorktree, + state, + 'tabBarOrderByWorktree', worktreeId, nextTabBarOrder.length > 0 ? nextTabBarOrder : null, - (a, b) => sameStringArray(a ?? [], b ?? []) + (a, b) => sameStringArray(a ?? [], b ?? []), + batchContext ) const nextActiveTabIdByWorktree = (state.activeTabIdByWorktree[worktreeId] ?? null) !== nextActiveTerminalId - ? { ...state.activeTabIdByWorktree, [worktreeId]: nextActiveTerminalId } + ? withWorktreeEntry( + state, + 'activeTabIdByWorktree', + worktreeId, + nextActiveTerminalId, + (current, next) => (current ?? null) === next, + batchContext, + false + ) : state.activeTabIdByWorktree const nextActiveBrowserTabIdByWorktree = (state.activeBrowserTabIdByWorktree[worktreeId] ?? null) !== nextActiveBrowserWorkspaceId - ? { ...state.activeBrowserTabIdByWorktree, [worktreeId]: nextActiveBrowserWorkspaceId } + ? withWorktreeEntry( + state, + 'activeBrowserTabIdByWorktree', + worktreeId, + nextActiveBrowserWorkspaceId, + (current, next) => (current ?? null) === next, + batchContext, + false + ) : state.activeBrowserTabIdByWorktree const nextActiveFileIdByWorktree = (state.activeFileIdByWorktree[worktreeId] ?? null) !== nextActiveEditorFileId - ? { ...state.activeFileIdByWorktree, [worktreeId]: nextActiveEditorFileId } + ? withWorktreeEntry( + state, + 'activeFileIdByWorktree', + worktreeId, + nextActiveEditorFileId, + (current, next) => (current ?? null) === next, + batchContext, + false + ) : state.activeFileIdByWorktree const isActiveWorktree = state.activeWorktreeId === worktreeId const focusIntentVisibleTabType = @@ -2564,14 +2756,22 @@ export function applyWebSessionTabsSnapshot( const nextActiveTabType = isActiveWorktree ? nextVisibleTabType : state.activeTabType const nextActiveTabTypeByWorktree = state.activeTabTypeByWorktree[worktreeId] !== nextVisibleTabType - ? { ...state.activeTabTypeByWorktree, [worktreeId]: nextVisibleTabType } + ? withWorktreeEntry( + state, + 'activeTabTypeByWorktree', + worktreeId, + nextVisibleTabType, + (current, next) => current === next, + batchContext + ) : state.activeTabTypeByWorktree const agentStatusPatch = buildMirroredAgentStatusPatch( state, currentTerminalTabs, terminalSurfaceTabs, mirroredTerminalTabs, - now + now, + batchContext ) const patch: Partial = { @@ -2641,21 +2841,45 @@ export function applyWebSessionTabsSnapshot( return Object.keys(patch).length === 0 ? state : patch } +export function applyWebSessionTabsSnapshot( + state: WebSessionTabsSyncState, + rawSnapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + now = Date.now() +): WebSessionTabsSyncState | Partial { + return applyWebSessionTabsSnapshotWithContext(state, rawSnapshot, environmentId, now) +} + export function applyWebSessionTabsSnapshots( state: WebSessionTabsSyncState, snapshots: readonly RuntimeMobileSessionTabsResult[], environmentId: string, now = Date.now() ): WebSessionTabsSyncState | Partial { - let nextState = state + const nextState = { ...state } + const batchContext: WebSessionTabsBatchContext = { + agentPaneKeysByTabId: null, + changedRecords: new Set() + } let mergedPatch: Partial = {} for (const snapshot of snapshots) { - const patch = applyWebSessionTabsSnapshot(nextState, snapshot, environmentId, now) + const patch = applyWebSessionTabsSnapshotWithContext( + nextState, + snapshot, + environmentId, + now, + batchContext + ) if (patch === nextState) { continue } mergedPatch = { ...mergedPatch, ...patch } - nextState = { ...nextState, ...patch } + Object.assign(nextState, patch) + } + const mutableMergedPatch = mergedPatch as Record + const mutableNextState = nextState as unknown as Record + for (const recordKey of batchContext.changedRecords) { + mutableMergedPatch[recordKey] = mutableNextState[recordKey] } return Object.keys(mergedPatch).length === 0 ? state : mergedPatch }