From 34291f07e9266e57d9da92d0c4976e34d7d8d805 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 12:09:32 -0700 Subject: [PATCH] fix(runtime): skip unchanged worktrees when publishing mobile session snapshots (#12207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(runtime): prove mobile session publication rebuilds every worktree buildMobileSessionTabSnapshots consults its per-worktree cache after building the content, so a republish saves the fanout but none of the work. With 300 worktrees, an unchanged republish still does 601 units of per-worktree work, and a single changed worktree does 602. Publication is keyed on agentStatusByPaneKey/agentStatusEpoch, so this runs on every agent status tick. On a multi-client runtime host with 381 worktrees this allocated ~350 MB/min and rode the renderer into repeated 4 GB OOMs. Tests are marked it.fails so the branch stays green; drop .fails when the build loop skips worktrees whose inputs are unchanged. * refactor(runtime): make mobile session snapshot inputs explicit per worktree Every per-worktree builder in buildMobileSessionTabSnapshots took the whole AppState, so a worktree's real input set was the transitive closure of seven helpers and could not be memoized safely. Introduce MobileSessionWorktreeInputs — built once per worktree — and thread it through the group projection and the terminal/markdown/file/browser tab builders so the compiler proves the input set. Tab- and pane-keyed slices are narrowed to this worktree's tab ids, file ids, browser workspace/page ids, and pane keys; agent statuses are bucketed per worktree once per publication via a tab-id index. No behavior change. Dropping AppState from the projection path also removes the second per-worktree read of browserTabsByWorktree, so the publication-cost counter falls from 601 to 1 per publication and its two cases now pass. * fix(runtime): skip unchanged worktrees before building mobile session content buildMobileSessionTabSnapshots consulted its per-worktree cache only after building that worktree's three Maps, group projection, and full tab array, so the cache suppressed the fanout but none of the computation. Every agent-status tick therefore rebuilt every worktree, which drove sustained 4 GB renderer working sets on a host holding 381 worktrees. Cache MobileSessionWorktreeInputs alongside each snapshot and reuse the snapshot when every input field is reference-equal, before any intermediate structure is allocated. Worktrees with a mounted TerminalPane always rebuild: their live DOM/PaneManager state is invisible to store references. Absent per-worktree slices now resolve to shared empty values so an empty worktree can compare equal to its last publication. jsonContentEquals stays as the backstop on the rebuild path for inputs that churn by reference without changing output. With 300 worktrees, per-worktree content builds go from 300 to 0 on an unchanged republish and from 300 to 1 when one worktree changes. * test(runtime): cover agent status publication cost --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- ...ync-runtime-graph-publication-cost.test.ts | 209 +++++++ .../src/runtime/sync-runtime-graph.ts | 541 +++++++++++++----- 2 files changed, 615 insertions(+), 135 deletions(-) create mode 100644 src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts diff --git a/src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts b/src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts new file mode 100644 index 000000000..0cd0793a2 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest' +import { buildMobileSessionTabSnapshots } from './sync-runtime-graph' +import type { AppState } from '../store/types' + +// Why: getBrowserTabsByWorktree reads this slice once per worktree inside the build loop, +// so a counting accessor measures per-worktree work deterministically (no timing flake). +function makeCountingState(worktreeCount: number): { + state: AppState + reads: () => number + resetReads: () => void +} { + let reads = 0 + const tabsByWorktree: Record = {} + for (let i = 0; i < worktreeCount; i++) { + tabsByWorktree[`repo::/wt-${i}`] = [ + { id: `term-${i}`, title: `Agent ${i}`, customTitle: null, type: 'terminal' } + ] + } + + const state = { + tabsByWorktree, + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeTabId: null, + agentStatusByPaneKey: {}, + get browserTabsByWorktree() { + reads++ + return {} + } + } as unknown as AppState + + return { + state, + reads: () => reads, + resetReads: () => { + reads = 0 + } + } +} + +// Why: tab.title is read only while a worktree's snapshot content is built, so a counting +// getter measures rebuilds rather than reads — the reads above survive a cheap hoist alone. +function makeTitleCountingState(worktreeCount: number): { + state: AppState + titleReads: () => number + resetTitleReads: () => void + withOneAgentStatusChanged: () => AppState +} { + let titleReads = 0 + const leafIdFor = (index: number): string => + `${String(index).padStart(8, '0')}-1111-4111-8111-111111111111` + const makeTab = (index: number, label: string): unknown => ({ + id: `title-term-${index}`, + customTitle: null, + ptyId: null, + get title() { + titleReads++ + return label + } + }) + + const tabsByWorktree: Record = {} + const terminalLayoutsByTabId: Record = {} + for (let i = 0; i < worktreeCount; i++) { + tabsByWorktree[`repo::/title-wt-${i}`] = [makeTab(i, `Agent ${i}`)] + terminalLayoutsByTabId[`title-term-${i}`] = { + root: { type: 'leaf', leafId: leafIdFor(i) }, + activeLeafId: leafIdFor(i), + expandedLeafId: null + } + } + const changedPaneKey = `title-term-7:${leafIdFor(7)}` + const agentStatusByPaneKey: AppState['agentStatusByPaneKey'] = { + [changedPaneKey]: { + state: 'working', + prompt: 'Investigate publication pressure', + updatedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + agentType: 'codex', + paneKey: changedPaneKey, + terminalTitle: 'codex [working]', + stateHistory: [] + } + } + + const state = { + tabsByWorktree, + terminalLayoutsByTabId, + runtimePaneTitlesByTabId: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeTabId: null, + agentStatusByPaneKey, + browserTabsByWorktree: {} + } as unknown as AppState + + return { + state, + titleReads: () => titleReads, + resetTitleReads: () => { + titleReads = 0 + }, + withOneAgentStatusChanged: () => + ({ + ...state, + agentStatusByPaneKey: { + ...agentStatusByPaneKey, + [changedPaneKey]: { + ...agentStatusByPaneKey[changedPaneKey], + state: 'waiting', + updatedAt: 1_700_000_001_000 + } + } + }) as unknown as AppState + } +} + +describe('mobile session publication cost', () => { + it('does not redo per-worktree work when nothing changed', () => { + const WORKTREES = 300 + const { state, reads, resetReads } = makeCountingState(WORKTREES) + + buildMobileSessionTabSnapshots(state) + resetReads() + + // Same state object, no mutation: a republish should do no per-worktree work. + buildMobileSessionTabSnapshots(state) + + expect(reads()).toBeLessThan(WORKTREES / 10) + }) + + it('rebuilds only the worktrees whose inputs changed', () => { + const WORKTREES = 300 + const { state, reads, resetReads } = makeCountingState(WORKTREES) + + buildMobileSessionTabSnapshots(state) + resetReads() + + // One worktree's tabs change — the other 299 are untouched. + const next = { + ...state, + tabsByWorktree: { + ...state.tabsByWorktree, + 'repo::/wt-7': [ + { id: 'term-7', title: 'Agent 7 (done)', customTitle: null, type: 'terminal' } + ] + }, + get browserTabsByWorktree() { + return (state as unknown as { browserTabsByWorktree: unknown }).browserTabsByWorktree + } + } as unknown as AppState + + buildMobileSessionTabSnapshots(next) + + expect(reads()).toBeLessThan(WORKTREES / 10) + }) + + it('builds no worktree content when nothing changed', () => { + const { state, titleReads, resetTitleReads } = makeTitleCountingState(300) + + buildMobileSessionTabSnapshots(state) + expect(titleReads()).toBeGreaterThan(0) + resetTitleReads() + + buildMobileSessionTabSnapshots(state) + + expect(titleReads()).toBe(0) + }) + + it('builds content only for the worktree whose agent status changed', () => { + const WORKTREES = 300 + const { state, titleReads, resetTitleReads, withOneAgentStatusChanged } = + makeTitleCountingState(WORKTREES) + + const beforeByWorktree = new Map( + buildMobileSessionTabSnapshots(state).map((snapshot) => [snapshot.worktree, snapshot]) + ) + const fullBuildReads = titleReads() + resetTitleReads() + + const afterByWorktree = new Map( + buildMobileSessionTabSnapshots(withOneAgentStatusChanged()).map((snapshot) => [ + snapshot.worktree, + snapshot + ]) + ) + const rebuiltWorktrees = [...afterByWorktree] + .filter(([worktreeId, snapshot]) => snapshot !== beforeByWorktree.get(worktreeId)) + .map(([worktreeId]) => worktreeId) + + expect(titleReads()).toBeGreaterThan(0) + expect(titleReads()).toBeLessThan(fullBuildReads / WORKTREES + 1) + expect(rebuiltWorktrees).toEqual(['repo::/title-wt-7']) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 410645c99..63f578c7d 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -22,7 +22,7 @@ import type { RuntimeMobileSessionTabsSnapshot, RuntimeSyncWindowGraph } from '../../../shared/runtime-types' -import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' +import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import { isClaudeManagementTitle } from '../../../shared/agent-detection' import { parseWorkspaceKey } from '../../../shared/workspace-scope' @@ -79,6 +79,61 @@ type AgentStatusProjectionCache = { entries: Map projection: string } +type MobileSessionAgentStatusByWorktree = ReadonlyMap< + string, + ReadonlyMap +> +/** Slices shared by every worktree in one publication; derived from `AppState` exactly once. */ +type MobileSessionPublicationInputs = { + browserTabsByWorktree: AppState['browserTabsByWorktree'] + openFileIndexes: OpenFileIndexes + editorDraftVersionByFileId: ReadonlyMap + agentStatusByWorktreeId: MobileSessionAgentStatusByWorktree + generatedTitlesEnabled: boolean + terminalTheme: RuntimeMobileTerminalTheme | undefined +} +/** + * One worktree's complete mobile-snapshot input set. + * + * Why: every builder below takes this instead of `AppState`, so the compiler — + * not a reviewer — proves what a worktree's snapshot actually depends on. + */ +type MobileSessionWorktreeInputs = { + worktreeId: string + terminalTabs: AppState['tabsByWorktree'][string] + browserWorkspaces: AppState['browserTabsByWorktree'][string] + unifiedTabs: AppState['unifiedTabsByWorktree'][string] + groups: AppState['groupsByWorktree'][string] + tabBarOrder: AppState['tabBarOrderByWorktree'][string] | undefined + activeGroupId: string | null + tabGroupLayout: TabGroupLayoutNode | undefined + openFilesById: ReadonlyMap | undefined + openFileIds: readonly string[] + terminalLayoutByTabId: ReadonlyMap + paneTitlesByTabId: ReadonlyMap + launchDraftByTabId: ReadonlyMap< + string, + NonNullable[string] + > + agentStatusByPaneKey: ReadonlyMap + editorDraftVersionByFileId: ReadonlyMap + pagesByBrowserWorkspaceId: ReadonlyMap< + string, + NonNullable[string] + > + certificateFailureByBrowserPageId: ReadonlyMap< + string, + NonNullable[string] + > + activeEditorFileId: string | null + activeEditorTabType: AppState['activeTabType'] | null + activeTerminalTabId: string | null + activeBrowserWorkspaceId: string | null + generatedTitlesEnabled: boolean + terminalTheme: RuntimeMobileTerminalTheme | undefined + // Why: a mounted TerminalPane feeds live DOM/PaneManager state that no store reference can witness. + hasMountedTerminalSurface: boolean +} const registeredTabs = new Map() // Why: registration time suppresses the "no live transport" warning during the async PTY-connect window; after the grace period it's a real stuck state. @@ -104,7 +159,11 @@ let mobileSessionSnapshotVersion = 0 // unchanged, and bump the version only for worktrees that actually changed. const mobileSessionSnapshotCacheByWorktree = new Map< string, - { content: unknown; snapshot: RuntimeMobileSessionTabsSnapshot } + { + inputs: MobileSessionWorktreeInputs + content: unknown + snapshot: RuntimeMobileSessionTabsSnapshot + } >() // Structural equality under JSON-serialization semantics (undefined-valued @@ -145,6 +204,18 @@ let cachedOpenFileIndexesSource: AppState['openFiles'] | null = null let cachedOpenFileIndexes: OpenFileIndexes | null = null let cachedEditorDraftsSource: AppState['editorDrafts'] | null = null let cachedEditorDraftVersionByFileId: Map | null = null +let cachedMobileTerminalThemeSettings: AppState['settings'] | null = null +let cachedMobileTerminalThemeSystemPrefersDark: boolean | null = null +let cachedMobileTerminalTheme: RuntimeMobileTerminalTheme | undefined +let hasCachedMobileTerminalTheme = false +const EMPTY_NARROWED_BY_KEY: ReadonlyMap = new Map() +// Why: absent per-worktree slices must resolve to one shared value, or every empty +// worktree would present a fresh `[]` and never compare equal to its last publication. +const EMPTY_WORKTREE_TERMINAL_TABS: AppState['tabsByWorktree'][string] = [] +const EMPTY_WORKTREE_BROWSER_WORKSPACES: AppState['browserTabsByWorktree'][string] = [] +const EMPTY_WORKTREE_UNIFIED_TABS: AppState['unifiedTabsByWorktree'][string] = [] +const EMPTY_WORKTREE_TAB_GROUPS: AppState['groupsByWorktree'][string] = [] +const EMPTY_WORKTREE_OPEN_FILE_IDS: readonly string[] = [] const mobileSessionPublicationEpoch = `renderer:${createBrowserUuid()}` export function setRuntimeGraphStoreStateGetter(getter: (() => AppState) | null): void { @@ -638,7 +709,7 @@ async function syncRuntimeGraph(): Promise { if (!tab) { continue } - if (isWebOnlyMirroredTerminalTab(state, tab)) { + if (isWebOnlyMirroredTerminalTab(tab, state.terminalLayoutsByTabId[tabId])) { continue } @@ -691,10 +762,10 @@ async function syncRuntimeGraph(): Promise { // Why: inactive automation tabs never mount a TerminalPane; publish their leaf+ptyId from persisted layout (gated on a live buffer) or the live PTY looks orphaned. for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { for (const tab of tabs) { - if (registeredTabs.has(tab.id) || isWebOnlyMirroredTerminalTab(state, tab)) { + const layout = state.terminalLayoutsByTabId[tab.id] + if (registeredTabs.has(tab.id) || isWebOnlyMirroredTerminalTab(tab, layout)) { continue } - const layout = state.terminalLayoutsByTabId[tab.id] const savedPtyIdsByLeafId = layout?.ptyIdsByLeafId if (!savedPtyIdsByLeafId) { continue @@ -755,13 +826,235 @@ async function syncRuntimeGraph(): Promise { } } +function narrowRecordByKeys( + source: Record | undefined, + keys: readonly string[] +): ReadonlyMap { + if (!source || keys.length === 0) { + return EMPTY_NARROWED_BY_KEY + } + let narrowed: Map | null = null + for (const key of keys) { + const value = source[key] + if (value === undefined) { + continue + } + narrowed ??= new Map() + narrowed.set(key, value) + } + return narrowed ?? EMPTY_NARROWED_BY_KEY +} + +function narrowMapByKeys( + source: ReadonlyMap, + keys: readonly string[] +): ReadonlyMap { + if (source.size === 0 || keys.length === 0) { + return EMPTY_NARROWED_BY_KEY + } + let narrowed: Map | null = null + for (const key of keys) { + if (!source.has(key)) { + continue + } + narrowed ??= new Map() + narrowed.set(key, source.get(key) as T) + } + return narrowed ?? EMPTY_NARROWED_BY_KEY +} + +function getMobileTerminalTheme( + state: AppState, + systemPrefersDark: boolean +): RuntimeMobileTerminalTheme | undefined { + // Why: resolving per terminal tab allocated a fresh theme per surface; one instance per publication is byte-identical downstream. + if ( + hasCachedMobileTerminalTheme && + cachedMobileTerminalThemeSettings === state.settings && + cachedMobileTerminalThemeSystemPrefersDark === systemPrefersDark + ) { + return cachedMobileTerminalTheme + } + cachedMobileTerminalTheme = resolveMobileTerminalTheme(state, systemPrefersDark) + cachedMobileTerminalThemeSettings = state.settings + cachedMobileTerminalThemeSystemPrefersDark = systemPrefersDark + hasCachedMobileTerminalTheme = true + return cachedMobileTerminalTheme +} + +function buildMobileSessionAgentStatusByWorktree( + agentStatusByPaneKey: AppState['agentStatusByPaneKey'], + tabsByWorktree: AppState['tabsByWorktree'] +): MobileSessionAgentStatusByWorktree { + const byWorktreeId = new Map>() + const paneKeys = Object.keys(agentStatusByPaneKey) + if (paneKeys.length === 0) { + return byWorktreeId + } + const worktreeIdByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + for (const tab of tabs) { + worktreeIdByTabId.set(tab.id, worktreeId) + } + } + for (const paneKey of paneKeys) { + // Why: every key a builder can look up is makePaneKey output, so an unparseable key is unreachable state, not a missed input. + const tabId = parsePaneKey(paneKey)?.tabId + const worktreeId = tabId === undefined ? undefined : worktreeIdByTabId.get(tabId) + if (worktreeId === undefined) { + continue + } + let bucket = byWorktreeId.get(worktreeId) + if (!bucket) { + bucket = new Map() + byWorktreeId.set(worktreeId, bucket) + } + bucket.set(paneKey, agentStatusByPaneKey[paneKey]) + } + return byWorktreeId +} + +function buildMobileSessionWorktreeInputs( + state: AppState, + worktreeId: string, + publication: MobileSessionPublicationInputs +): MobileSessionWorktreeInputs { + const terminalTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_TERMINAL_TABS + const terminalTabIds = terminalTabs.map((tab) => tab.id) + const browserWorkspaces = + publication.browserTabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_BROWSER_WORKSPACES + const pagesByBrowserWorkspaceId = narrowRecordByKeys( + state.browserPagesByWorkspace, + browserWorkspaces.map((workspace) => workspace.id) + ) + const browserPageIds: string[] = [] + for (const pages of pagesByBrowserWorkspaceId.values()) { + for (const page of pages) { + browserPageIds.push(page.id) + } + } + const openFilesById = publication.openFileIndexes.byWorktreeAndId.get(worktreeId) + const openFileIds = + publication.openFileIndexes.idsByWorktree.get(worktreeId) ?? EMPTY_WORKTREE_OPEN_FILE_IDS + // Why: the global activeFileId/activeTabType fallbacks only matter when the active file lives here, so resolve them per worktree. + const resolvedActiveFileId = state.activeFileIdByWorktree?.[worktreeId] ?? state.activeFileId + const activeEditorFileId = + resolvedActiveFileId && openFilesById?.has(resolvedActiveFileId) ? resolvedActiveFileId : null + const activeTabId = state.activeTabId + return { + worktreeId, + terminalTabs, + browserWorkspaces, + unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_UNIFIED_TABS, + groups: state.groupsByWorktree[worktreeId] ?? EMPTY_WORKTREE_TAB_GROUPS, + tabBarOrder: state.tabBarOrderByWorktree[worktreeId], + activeGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null, + tabGroupLayout: (state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE)[worktreeId], + openFilesById, + openFileIds, + terminalLayoutByTabId: narrowRecordByKeys(state.terminalLayoutsByTabId, terminalTabIds), + paneTitlesByTabId: narrowRecordByKeys(state.runtimePaneTitlesByTabId, terminalTabIds), + launchDraftByTabId: narrowRecordByKeys(state.nativeChatLaunchDraftByTabId, terminalTabIds), + agentStatusByPaneKey: + publication.agentStatusByWorktreeId.get(worktreeId) ?? EMPTY_NARROWED_BY_KEY, + editorDraftVersionByFileId: narrowMapByKeys( + publication.editorDraftVersionByFileId, + openFileIds + ), + pagesByBrowserWorkspaceId, + certificateFailureByBrowserPageId: narrowRecordByKeys( + state.browserCertificateFailuresByPageId, + browserPageIds + ), + activeEditorFileId, + activeEditorTabType: activeEditorFileId + ? (state.activeTabTypeByWorktree?.[worktreeId] ?? state.activeTabType) + : null, + activeTerminalTabId: + activeTabId !== null && terminalTabIds.includes(activeTabId) ? activeTabId : null, + activeBrowserWorkspaceId: state.activeBrowserTabIdByWorktree?.[worktreeId] ?? null, + generatedTitlesEnabled: publication.generatedTitlesEnabled, + terminalTheme: publication.terminalTheme, + hasMountedTerminalSurface: terminalTabs.some((tab) => registeredTabs.has(tab.id)) + } +} + +function narrowedEntriesEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { + if (a === b) { + return true + } + if (a.size !== b.size) { + return false + } + for (const [key, value] of a) { + if (b.get(key) !== value) { + return false + } + } + return true +} + +/** + * True when a worktree's snapshot can be reused without rebuilding its content. + * + * Every field of `MobileSessionWorktreeInputs` is compared, so a missed input + * is a compile error rather than a stale publication to paired clients. + */ +function canReuseMobileSessionSnapshot( + previous: MobileSessionWorktreeInputs, + next: MobileSessionWorktreeInputs +): boolean { + return ( + // Why: live DOM/PaneManager state is invisible to store references, so a mounted surface always rebuilds. + !previous.hasMountedTerminalSurface && + !next.hasMountedTerminalSurface && + previous.worktreeId === next.worktreeId && + previous.terminalTabs === next.terminalTabs && + previous.browserWorkspaces === next.browserWorkspaces && + previous.unifiedTabs === next.unifiedTabs && + previous.groups === next.groups && + previous.tabBarOrder === next.tabBarOrder && + previous.activeGroupId === next.activeGroupId && + previous.tabGroupLayout === next.tabGroupLayout && + previous.openFilesById === next.openFilesById && + previous.openFileIds === next.openFileIds && + previous.activeEditorFileId === next.activeEditorFileId && + previous.activeEditorTabType === next.activeEditorTabType && + previous.activeTerminalTabId === next.activeTerminalTabId && + previous.activeBrowserWorkspaceId === next.activeBrowserWorkspaceId && + previous.generatedTitlesEnabled === next.generatedTitlesEnabled && + previous.terminalTheme === next.terminalTheme && + narrowedEntriesEqual(previous.terminalLayoutByTabId, next.terminalLayoutByTabId) && + narrowedEntriesEqual(previous.paneTitlesByTabId, next.paneTitlesByTabId) && + narrowedEntriesEqual(previous.launchDraftByTabId, next.launchDraftByTabId) && + narrowedEntriesEqual(previous.agentStatusByPaneKey, next.agentStatusByPaneKey) && + narrowedEntriesEqual(previous.editorDraftVersionByFileId, next.editorDraftVersionByFileId) && + narrowedEntriesEqual(previous.pagesByBrowserWorkspaceId, next.pagesByBrowserWorkspaceId) && + narrowedEntriesEqual( + previous.certificateFailureByBrowserPageId, + next.certificateFailureByBrowserPageId + ) + ) +} + export function buildMobileSessionTabSnapshots( state: AppState, systemPrefersDark = getSystemPrefersDark() ): RuntimeMobileSessionTabsSnapshot[] { // Why: high-frequency title ticks fire mobile sync; cache indexes/hashes by store-slice ref to skip rescanning editor state. const openFileIndexes = getOpenFileIndexes(state.openFiles) - const editorDraftVersionByFileId = getEditorDraftVersionByFileId(state.editorDrafts) + const browserTabsByWorktree = getBrowserTabsByWorktree(state) + const publicationInputs: MobileSessionPublicationInputs = { + browserTabsByWorktree, + openFileIndexes, + editorDraftVersionByFileId: getEditorDraftVersionByFileId(state.editorDrafts), + agentStatusByWorktreeId: buildMobileSessionAgentStatusByWorktree( + state.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY, + state.tabsByWorktree + ), + generatedTitlesEnabled: state.settings?.tabAutoGenerateTitle === true, + terminalTheme: getMobileTerminalTheme(state, systemPrefersDark) + } const liveFolderWorkspaceIds = new Set( (state.folderWorkspaces ?? []).map((workspace) => workspace.id) ) @@ -769,7 +1062,7 @@ export function buildMobileSessionTabSnapshots( ...Object.keys(state.tabsByWorktree), ...Object.keys(state.groupsByWorktree), ...Object.keys(state.unifiedTabsByWorktree), - ...Object.keys(getBrowserTabsByWorktree(state)), + ...Object.keys(browserTabsByWorktree), ...state.openFiles.map((file) => file.worktreeId) ]) @@ -783,28 +1076,32 @@ export function buildMobileSessionTabSnapshots( mobileSessionSnapshotCacheByWorktree.delete(worktreeId) continue } - const activeGroupId = state.activeGroupIdByWorktree[worktreeId] ?? null - const terminalTabByIdForWorktree = new Map( - (state.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab]) - ) + const inputs = buildMobileSessionWorktreeInputs(state, worktreeId, publicationInputs) + const cached = mobileSessionSnapshotCacheByWorktree.get(worktreeId) + // Why: invalidate before computing — building the maps, projection, and tab + // array first made the cache save the fanout but none of the per-worktree work. + if (cached && canReuseMobileSessionSnapshot(cached.inputs, inputs)) { + snapshots.push(cached.snapshot) + continue + } + const activeGroupId = inputs.activeGroupId + const terminalTabByIdForWorktree = new Map(inputs.terminalTabs.map((tab) => [tab.id, tab])) const browserWorkspaceByIdForWorktree = new Map( - (getBrowserTabsByWorktree(state)[worktreeId] ?? []).map((workspace) => [ - workspace.id, - workspace - ]) + inputs.browserWorkspaces.map((workspace) => [workspace.id, workspace]) ) - const unifiedTabByIdForWorktree = new Map( - (state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab]) - ) - const openFilesForWorktree = openFileIndexes.byWorktreeAndId.get(worktreeId) - const editorIds = (openFileIndexes.idsByWorktree.get(worktreeId) ?? []).filter((fileId) => { + const unifiedTabByIdForWorktree = new Map(inputs.unifiedTabs.map((tab) => [tab.id, tab])) + const openFilesForWorktree = inputs.openFilesById + const editorIds = inputs.openFileIds.filter((fileId) => { const file = openFilesForWorktree?.get(fileId) return file ? isMobilePublishableOpenFile(file) : false }) const publishableTerminalIds = [...terminalTabByIdForWorktree.values()] - .filter((terminal) => !isWebOnlyMirroredTerminalTab(state, terminal)) + .filter( + (terminal) => + !isWebOnlyMirroredTerminalTab(terminal, inputs.terminalLayoutByTabId.get(terminal.id)) + ) .map((terminal) => terminal.id) - const groupProjection = buildMobileSessionGroupProjection(state, worktreeId, { + const groupProjection = buildMobileSessionGroupProjection(inputs, { terminalIds: publishableTerminalIds, editorIds, browserIds: [...browserWorkspaceByIdForWorktree.keys()] @@ -819,27 +1116,17 @@ export function buildMobileSessionTabSnapshots( if (!terminal) { continue } - if (isWebOnlyMirroredTerminalTab(state, terminal)) { + if (isWebOnlyMirroredTerminalTab(terminal, inputs.terminalLayoutByTabId.get(terminal.id))) { continue } - tabs.push( - ...buildMobileTerminalSurfaceTabs( - state, - terminal, - worktreeId, - systemPrefersDark, - item.tabId - ) - ) + tabs.push(...buildMobileTerminalSurfaceTabs(inputs, terminal, item.tabId)) } else if (item.type === 'editor') { const file = openFilesForWorktree?.get(item.id) if (!file || !isMobilePublishableOpenFile(file)) { continue } const markdown = buildMobileMarkdownTab( - state, - openFileIndexes.byWorktreeAndId, - editorDraftVersionByFileId, + inputs, file, item.tabId ? unifiedTabByIdForWorktree.get(item.tabId) : undefined ) @@ -848,7 +1135,7 @@ export function buildMobileSessionTabSnapshots( } else { tabs.push( buildMobileFileTab( - state, + inputs, file, item.tabId ? unifiedTabByIdForWorktree.get(item.tabId) : undefined ) @@ -863,7 +1150,7 @@ export function buildMobileSessionTabSnapshots( } tabs.push( buildMobileBrowserTab( - state, + inputs, workspace, item.tabId ? unifiedTabByIdForWorktree.get(item.tabId) : undefined ) @@ -874,7 +1161,7 @@ export function buildMobileSessionTabSnapshots( // Why: split-group projection can miss plain editor files during hydration; publish them so mobile/web still mirror. const fallbackEditorTabs: FallbackEditorTabTarget[] = [] if (openFilesForWorktree) { - const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(state, worktreeId) + const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(inputs) const unifiedEditorFileIds = new Set(unifiedEditorTabs.map((tab) => tab.entityId)) for (const unifiedTab of unifiedEditorTabs) { if (emittedEditorTabIds.has(unifiedTab.id)) { @@ -884,14 +1171,8 @@ export function buildMobileSessionTabSnapshots( if (!file || !isMobilePublishableOpenFile(file)) { continue } - const markdown = buildMobileMarkdownTab( - state, - openFileIndexes.byWorktreeAndId, - editorDraftVersionByFileId, - file, - unifiedTab - ) - const fallbackTab = markdown ?? buildMobileFileTab(state, file, unifiedTab) + const markdown = buildMobileMarkdownTab(inputs, file, unifiedTab) + const fallbackTab = markdown ?? buildMobileFileTab(inputs, file, unifiedTab) tabs.push(fallbackTab) fallbackEditorTabs.push({ tabId: fallbackTab.id, @@ -910,13 +1191,8 @@ export function buildMobileSessionTabSnapshots( emittedEditorFileIds.add(file.id) continue } - const markdown = buildMobileMarkdownTab( - state, - openFileIndexes.byWorktreeAndId, - editorDraftVersionByFileId, - file - ) - const fallbackTab = markdown ?? buildMobileFileTab(state, file) + const markdown = buildMobileMarkdownTab(inputs, file) + const fallbackTab = markdown ?? buildMobileFileTab(inputs, file) tabs.push(fallbackTab) fallbackEditorTabs.push({ tabId: fallbackTab.id, @@ -929,17 +1205,14 @@ export function buildMobileSessionTabSnapshots( const active = tabs.find((tab) => tab.isActive) ?? null const tabGroups = appendFallbackEditorTabsToGroups( groupProjection.tabGroups, - state.groupsByWorktree[worktreeId] ?? [], + inputs.groups, activeGroupId, fallbackEditorTabs, active?.id ?? null ) const tabGroupLayout = tabGroups && tabGroups.length > 0 - ? pruneTabGroupLayout( - (state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE)[worktreeId], - new Set(tabGroups.map((group) => group.id)) - ) + ? pruneTabGroupLayout(inputs.tabGroupLayout, new Set(tabGroups.map((group) => group.id))) : groupProjection.tabGroupLayout const content = { activeGroupId, @@ -950,12 +1223,16 @@ export function buildMobileSessionTabSnapshots( tabs } // Why: main suppresses per-worktree fanout on an unchanged (epoch, version) - // pair, so reuse the cached version for structurally-identical content. The - // global counter still advances per worktree per build (as before caching) - // so a changed worktree's fresh version stays ahead of main's +1 bumps. + // pair, so reuse the cached version for structurally-identical content — + // the backstop for inputs that churn by reference without changing output. + // The counter only ever advances, so a later real change still outranks it. const candidateVersion = ++mobileSessionSnapshotVersion - const cached = mobileSessionSnapshotCacheByWorktree.get(worktreeId) if (cached && jsonContentEquals(cached.content, content)) { + mobileSessionSnapshotCacheByWorktree.set(worktreeId, { + inputs, + content, + snapshot: cached.snapshot + }) snapshots.push(cached.snapshot) continue } @@ -965,7 +1242,7 @@ export function buildMobileSessionTabSnapshots( snapshotVersion: candidateVersion, ...content } - mobileSessionSnapshotCacheByWorktree.set(worktreeId, { content, snapshot }) + mobileSessionSnapshotCacheByWorktree.set(worktreeId, { inputs, content, snapshot }) snapshots.push(snapshot) } @@ -984,18 +1261,16 @@ function isEditorSurfaceTab(tab: Pick): boolean { } function getEditorUnifiedTabsForWorktree( - state: Pick, - worktreeId: string + inputs: Pick ): Tab[] { - return (state.unifiedTabsByWorktree[worktreeId] ?? []).filter(isEditorSurfaceTab) + return inputs.unifiedTabs.filter(isEditorSurfaceTab) } function applyUnifiedEditorTabIdsToLegacyOrder( order: readonly VisibleTabRef[], - state: Pick, - worktreeId: string + inputs: Pick ): VisibleTabRef[] { - const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(state, worktreeId) + const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(inputs) if (unifiedEditorTabs.length === 0) { return [...order] } @@ -1101,13 +1376,13 @@ function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { } function isWebOnlyMirroredTerminalTab( - state: Pick, - tab: Pick[number], 'id' | 'ptyId'> + tab: Pick[number], 'id' | 'ptyId'>, + layout: AppState['terminalLayoutsByTabId'][string] | undefined ): boolean { if (!isWebTerminalSurfaceTabId(tab.id)) { return false } - const layoutPtyIds = Object.values(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}) + const layoutPtyIds = Object.values(layout?.ptyIdsByLeafId ?? {}) const ptyIds = [tab.ptyId, ...layoutPtyIds].filter( (ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0 ) @@ -1202,9 +1477,24 @@ function getOrderedTabGroups( return ordered } +// Why: getActiveTabNavOrder only reads the [worktreeId] entry of each slice, so a single-key view keeps this path off AppState. +function buildLegacyNavOrderView( + inputs: MobileSessionWorktreeInputs +): Parameters[0] { + const { worktreeId } = inputs + return { + activeGroupIdByWorktree: inputs.activeGroupId ? { [worktreeId]: inputs.activeGroupId } : {}, + groupsByWorktree: { [worktreeId]: inputs.groups }, + unifiedTabsByWorktree: { [worktreeId]: inputs.unifiedTabs }, + tabBarOrderByWorktree: inputs.tabBarOrder ? { [worktreeId]: inputs.tabBarOrder } : {}, + tabsByWorktree: { [worktreeId]: inputs.terminalTabs }, + openFiles: inputs.openFilesById ? [...inputs.openFilesById.values()] : [], + browserTabsByWorktree: { [worktreeId]: inputs.browserWorkspaces } + } +} + function buildMobileSessionGroupProjection( - state: AppState, - worktreeId: string, + inputs: MobileSessionWorktreeInputs, ids: { terminalIds: string[] editorIds: string[] @@ -1215,15 +1505,14 @@ function buildMobileSessionGroupProjection( tabGroups?: RuntimeMobileSessionTabGroup[] tabGroupLayout?: TabGroupLayoutNode | null } { - const groups = state.groupsByWorktree[worktreeId] ?? [] + const groups = inputs.groups if (groups.length === 0) { return { order: applyUnifiedEditorTabIdsToLegacyOrder( - getActiveTabNavOrder(state, worktreeId, { + getActiveTabNavOrder(buildLegacyNavOrderView(inputs), inputs.worktreeId, { editorIds: ids.editorIds }), - state, - worktreeId + inputs ) } } @@ -1231,12 +1520,11 @@ function buildMobileSessionGroupProjection( const terminalIds = new Set(ids.terminalIds) const editorIds = new Set(ids.editorIds) const browserIds = new Set(ids.browserIds) - const tabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const tabs = inputs.unifiedTabs const order: VisibleTabRef[] = [] const tabGroups: RuntimeMobileSessionTabGroup[] = [] - const layoutByWorktree = state.layoutByWorktree ?? {} - for (const group of getOrderedTabGroups(groups, layoutByWorktree[worktreeId])) { + for (const group of getOrderedTabGroups(groups, inputs.tabGroupLayout)) { const groupTabs = tabs.filter((tab) => tab.groupId === group.id) const visibleOrder = getGroupVisibleTabOrder( group, @@ -1267,7 +1555,7 @@ function buildMobileSessionGroupProjection( return { order, tabGroups, - tabGroupLayout: pruneTabGroupLayout(layoutByWorktree[worktreeId], validGroupIds) + tabGroupLayout: pruneTabGroupLayout(inputs.tabGroupLayout, validGroupIds) } } @@ -1343,7 +1631,10 @@ function resolveMobileTerminalTheme( return { mode: appearance.mode, theme: theme as RuntimeMobileTerminalTheme['theme'] } } -function getRuntimeLeafIdsForTerminal(tabId: string, state: AppState): string[] { +function getRuntimeLeafIdsForTerminal( + tabId: string, + savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined +): string[] { const registered = registeredTabs.get(tabId) const manager = registered?.getManager() const liveLeafIds = manager?.getPanes().map((pane) => pane.leafId) ?? [] @@ -1351,8 +1642,7 @@ function getRuntimeLeafIdsForTerminal(tabId: string, state: AppState): string[] return liveLeafIds } - const layout = state.terminalLayoutsByTabId[tabId] - const persistedLeafIds = collectLeafIdsInOrder(layout?.root).filter(isTerminalLeafId) + const persistedLeafIds = collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) if (persistedLeafIds.length > 0) { return persistedLeafIds } @@ -1362,38 +1652,32 @@ function getRuntimeLeafIdsForTerminal(tabId: string, state: AppState): string[] } function buildMobileTerminalSurfaceTabs( - state: AppState, + inputs: MobileSessionWorktreeInputs, terminal: NonNullable[number], - worktreeId: string, - systemPrefersDark: boolean, unifiedTabId?: string ): RuntimeMobileSessionSnapshotTab[] { const registered = registeredTabs.get(terminal.id) const isDesktopTabActive = unifiedTabId - ? state.groupsByWorktree[worktreeId]?.some( - (group) => - group.id === state.activeGroupIdByWorktree[worktreeId] && - group.activeTabId === unifiedTabId - ) === true - : state.activeTabId === terminal.id + ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) + : inputs.activeTerminalTabId === terminal.id const manager = registered?.getManager() const liveActivePaneId = manager?.getActivePane()?.id ?? null - const leafIds = getRuntimeLeafIdsForTerminal(terminal.id, state) + const savedLayout = inputs.terminalLayoutByTabId.get(terminal.id) + const leafIds = getRuntimeLeafIdsForTerminal(terminal.id, savedLayout) const activeLeafId = liveActivePaneId !== null ? (manager?.getLeafId(liveActivePaneId) ?? null) - : (state.terminalLayoutsByTabId[terminal.id]?.activeLeafId ?? leafIds[0] ?? null) - const paneTitles = state.runtimePaneTitlesByTabId[terminal.id] ?? {} - const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true - const savedLayout = state.terminalLayoutsByTabId[terminal.id] + : (savedLayout?.activeLeafId ?? leafIds[0] ?? null) + const paneTitles = inputs.paneTitlesByTabId.get(terminal.id) ?? {} + const generatedTitlesEnabled = inputs.generatedTitlesEnabled const sanitizedSavedLayout = savedLayout ? sanitizeTerminalLayoutPaneTitles(savedLayout, terminal) : undefined const savedPtyIdsByLeafId = sanitizedSavedLayout?.ptyIdsByLeafId ?? {} - const terminalTheme = resolveMobileTerminalTheme(state, systemPrefersDark) + const terminalTheme = inputs.terminalTheme // Agent-matched like the desktop consumer: a pane whose agent changed keeps its // tab id, so an unmatched seed would prefill the new agent's chat with stale text. - const seededLaunchDraft = state.nativeChatLaunchDraftByTabId?.[terminal.id] + const seededLaunchDraft = inputs.launchDraftByTabId.get(terminal.id) const launchDraftEntry = seededLaunchDraft && !seededLaunchDraft.resolved && @@ -1446,7 +1730,7 @@ function buildMobileTerminalSurfaceTabs( const agentStatusTitle = paneTitle ?? terminal.title ?? '' const agentStatus = paneKey && !isClaudeManagementTitle(agentStatusTitle) - ? state.agentStatusByPaneKey?.[paneKey] + ? inputs.agentStatusByPaneKey.get(paneKey) : undefined return { type: 'terminal' as const, @@ -1476,9 +1760,7 @@ function buildMobileTerminalSurfaceTabs( } function buildMobileMarkdownTab( - state: AppState, - openFileByWorktreeAndId: OpenFileByWorktreeAndId, - editorDraftVersionByFileId: ReadonlyMap, + inputs: MobileSessionWorktreeInputs, file: AppState['openFiles'][number], unifiedTab?: Tab ): RuntimeMobileSessionMarkdownTab | null { @@ -1491,10 +1773,9 @@ function buildMobileMarkdownTab( const sourceFile = file.mode === 'markdown-preview' && file.markdownPreviewSourceFileId - ? (openFileByWorktreeAndId.get(file.worktreeId)?.get(file.markdownPreviewSourceFileId) ?? - file) + ? (inputs.openFilesById?.get(file.markdownPreviewSourceFileId) ?? file) : file - const draftVersion = editorDraftVersionByFileId.get(sourceFile.id) + const draftVersion = inputs.editorDraftVersionByFileId.get(sourceFile.id) const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'Markdown' const unifiedTabId = unifiedTab?.id @@ -1508,8 +1789,8 @@ function buildMobileMarkdownTab( mode: file.mode, isDirty: file.isDirty || sourceFile.isDirty, isActive: unifiedTabId - ? isUnifiedTabActiveInActiveGroup(state, file.worktreeId, unifiedTabId) - : isFileActiveEditorSurface(state, file), + ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) + : isFileActiveEditorSurface(inputs, file), sourceFileId: sourceFile.id, sourceFilePath: sourceFile.filePath, sourceRelativePath: sourceFile.relativePath, @@ -1520,7 +1801,7 @@ function buildMobileMarkdownTab( } function buildMobileFileTab( - state: AppState, + inputs: MobileSessionWorktreeInputs, file: AppState['openFiles'][number], unifiedTab?: Tab ): RuntimeMobileSessionFileTab { @@ -1541,23 +1822,16 @@ function buildMobileFileTab( color: unifiedTab?.color ?? null, isPinned: unifiedTab?.isPinned === true, isActive: unifiedTabId - ? isUnifiedTabActiveInActiveGroup(state, file.worktreeId, unifiedTabId) - : isFileActiveEditorSurface(state, file) + ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) + : isFileActiveEditorSurface(inputs, file) } } function isFileActiveEditorSurface( - state: Pick< - AppState, - 'activeFileId' | 'activeFileIdByWorktree' | 'activeTabType' | 'activeTabTypeByWorktree' - >, - file: Pick + inputs: Pick, + file: Pick ): boolean { - const activeType = state.activeTabTypeByWorktree?.[file.worktreeId] ?? state.activeTabType - return ( - activeType === 'editor' && - (state.activeFileIdByWorktree?.[file.worktreeId] ?? state.activeFileId) === file.id - ) + return inputs.activeEditorTabType === 'editor' && inputs.activeEditorFileId === file.id } function isMobileFileDiffSource( @@ -1582,12 +1856,13 @@ function isMobilePublishableOpenFile(file: AppState['openFiles'][number]): boole return !isMobileUnsupportedCombinedDiffSource(file.diffSource) } +// Why: the store buckets a workspace under its own worktreeId, so this worktree's scoped inputs are the workspace's own scope. function buildMobileBrowserTab( - state: AppState, + inputs: MobileSessionWorktreeInputs, workspace: NonNullable[number], unifiedTab?: Tab ): RuntimeMobileSessionBrowserTab { - const pages = state.browserPagesByWorkspace[workspace.id] ?? [] + const pages = inputs.pagesByBrowserWorkspaceId.get(workspace.id) ?? [] const activePage = pages.find((page) => page.id === workspace.activePageId) ?? pages[0] ?? null const title = activePage?.title || workspace.title || activePage?.url || workspace.url || 'Browser' @@ -1606,26 +1881,22 @@ function buildMobileBrowserTab( // Why: null means the active page cleared its failure; ?? would resurrect a stale workspace-level error. loadError: activePage ? activePage.loadError : workspace.loadError, certificateFailure: activePage - ? (state.browserCertificateFailuresByPageId?.[activePage.id] ?? null) + ? (inputs.certificateFailureByBrowserPageId.get(activePage.id) ?? null) : null, color: unifiedTab?.color ?? null, isPinned: unifiedTab?.isPinned === true, isActive: unifiedTabId - ? isUnifiedTabActiveInActiveGroup(state, workspace.worktreeId, unifiedTabId) - : state.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspace.id + ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) + : inputs.activeBrowserWorkspaceId === workspace.id } } function isUnifiedTabActiveInActiveGroup( - state: AppState, - worktreeId: string, + inputs: Pick, unifiedTabId: string ): boolean { - const activeGroupId = state.activeGroupIdByWorktree[worktreeId] - return ( - state.groupsByWorktree[worktreeId]?.some( - (group) => group.id === activeGroupId && group.activeTabId === unifiedTabId - ) === true + return inputs.groups.some( + (group) => group.id === inputs.activeGroupId && group.activeTabId === unifiedTabId ) }