diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 68642e47e..13a8edf6b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -617,11 +617,10 @@ function App(): React.JSX.Element { useEffect(() => { let previousKey = getRuntimeMobileSessionSyncKey(useAppStore.getState()) return useAppStore.subscribe((state, previousState) => { - // Why: skip the key build entirely when no input field has changed by - // reference. Mirrors every field used by getRuntimeMobileSessionSyncKey - // so this gate covers every "could the key have changed?" case. - // — if any field's reference is unchanged, neither the projection - // serialized from it nor the reference-compared map can have changed. + // Why: skip the key build entirely when every input field is unchanged + // by reference. Mirrors every field used by + // getRuntimeMobileSessionSyncKey so this gate covers every "could the + // key have changed?" case. if ( state.tabsByWorktree === previousState.tabsByWorktree && state.groupsByWorktree === previousState.groupsByWorktree && @@ -638,7 +637,7 @@ function App(): React.JSX.Element { ) { return } - const nextKey = getRuntimeMobileSessionSyncKey(state) + const nextKey = getRuntimeMobileSessionSyncKey(state, previousState, previousKey) if (runtimeMobileSessionSyncKeysEqual(nextKey, previousKey)) { return } diff --git a/src/renderer/src/components/tab-bar/group-tab-order.ts b/src/renderer/src/components/tab-bar/group-tab-order.ts index 983e51d08..b0d4fa2f1 100644 --- a/src/renderer/src/components/tab-bar/group-tab-order.ts +++ b/src/renderer/src/components/tab-bar/group-tab-order.ts @@ -8,6 +8,13 @@ export type VisibleTabRef = { tabId?: string } +export type ActiveTabNavOrderIds = { + terminalIds?: string[] + editorIds?: string[] + browserIds?: string[] + notesIds?: string[] +} + /** * Compute the visible tab-strip order for a single group. * @@ -116,14 +123,19 @@ export function getActiveTabNavOrder( | 'openFiles' | 'browserTabsByWorktree' >, - worktreeId: string + worktreeId: string, + ids: ActiveTabNavOrderIds = {} ): VisibleTabRef[] { - const terminalIds = (state.tabsByWorktree[worktreeId] ?? []).map((t) => t.id) - const editorIds = state.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id) - const browserIds = (state.browserTabsByWorktree[worktreeId] ?? []).map((t) => t.id) - const notesIds = (state.unifiedTabsByWorktree[worktreeId] ?? []) - .filter((tab) => tab.contentType === 'notes') - .map((tab) => tab.id) + const terminalIds = ids.terminalIds ?? (state.tabsByWorktree[worktreeId] ?? []).map((t) => t.id) + const editorIds = + ids.editorIds ?? state.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id) + const browserIds = + ids.browserIds ?? (state.browserTabsByWorktree?.[worktreeId] ?? []).map((t) => t.id) + const notesIds = + ids.notesIds ?? + (state.unifiedTabsByWorktree?.[worktreeId] ?? []) + .filter((tab) => tab.contentType === 'notes') + .map((tab) => tab.id) const activeGroupId = state.activeGroupIdByWorktree[worktreeId] const group = activeGroupId diff --git a/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts b/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts new file mode 100644 index 000000000..4a5294521 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + buildMobileSessionTabSnapshots, + getRuntimeMobileSessionSyncKey, + runtimeMobileSessionSyncKeysEqual +} from './sync-runtime-graph' +import type { AppState } from '../store/types' + +function makeState(overrides: Partial = {}): AppState { + return { + tabsByWorktree: {}, + terminalLayoutsByTabId: {} as AppState['terminalLayoutsByTabId'], + runtimePaneTitlesByTabId: {} as AppState['runtimePaneTitlesByTabId'], + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeTabId: null, + ...overrides + } as AppState +} + +function makeOpenMarkdownFile(): AppState['openFiles'][number] { + return { + id: '/repo/README.md', + filePath: '/repo/README.md', + relativePath: 'README.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isDirty: false + } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('runtime mobile session sync key projection reuse', () => { + it('reuses serialized projections when only runtime pane titles change', () => { + const base = makeState({ + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Codex working', customTitle: null }] + } as unknown as AppState['tabsByWorktree'], + runtimePaneTitlesByTabId: { + 'term-1': { 1: 'Codex working' } + } as unknown as AppState['runtimePaneTitlesByTabId'], + openFiles: [makeOpenMarkdownFile()], + editorDrafts: { '/repo/README.md': '# draft' } + }) + const baseKey = getRuntimeMobileSessionSyncKey(base) + const titleTick = makeState({ + ...base, + runtimePaneTitlesByTabId: { + 'term-1': { 1: 'Codex spinner frame' } + } as unknown as AppState['runtimePaneTitlesByTabId'] + }) + + const stringifySpy = vi.spyOn(JSON, 'stringify') + stringifySpy.mockClear() + const titleTickKey = getRuntimeMobileSessionSyncKey(titleTick, base, baseKey) + + expect(stringifySpy).not.toHaveBeenCalled() + expect(titleTickKey.tabsProjection).toBe(baseKey.tabsProjection) + expect(titleTickKey.openFilesProjection).toBe(baseKey.openFilesProjection) + expect(titleTickKey.editorDraftsProjection).toBe(baseKey.editorDraftsProjection) + expect(runtimeMobileSessionSyncKeysEqual(baseKey, titleTickKey)).toBe(false) + }) + + it('only rebuilds the tab projection when a terminal tab title changes', () => { + const base = makeState({ + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Codex working', customTitle: null }] + } as unknown as AppState['tabsByWorktree'], + openFiles: [makeOpenMarkdownFile()], + editorDrafts: { '/repo/README.md': '# draft' } + }) + const baseKey = getRuntimeMobileSessionSyncKey(base) + const titleTick = makeState({ + ...base, + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Codex spinner frame', customTitle: null }] + } as unknown as AppState['tabsByWorktree'] + }) + + const stringifySpy = vi.spyOn(JSON, 'stringify') + stringifySpy.mockClear() + const titleTickKey = getRuntimeMobileSessionSyncKey(titleTick, base, baseKey) + + expect(stringifySpy).toHaveBeenCalledTimes(1) + expect(titleTickKey.tabsProjection).not.toBe(baseKey.tabsProjection) + expect(titleTickKey.openFilesProjection).toBe(baseKey.openFilesProjection) + expect(titleTickKey.editorDraftsProjection).toBe(baseKey.editorDraftsProjection) + expect(runtimeMobileSessionSyncKeysEqual(baseKey, titleTickKey)).toBe(false) + }) +}) + +describe('mobile session snapshot reuse', () => { + it('reuses draft document versions when only runtime pane titles change', () => { + const draft = { + length: 1, + charCodeAt: vi.fn(() => 120) + } as unknown as string + const draftSpy = (draft as unknown as { charCodeAt: ReturnType }).charCodeAt + const base = makeState({ + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Codex working', customTitle: null }] + } as unknown as AppState['tabsByWorktree'], + tabBarOrderByWorktree: { 'wt-1': ['term-1', '/repo/README.md'] }, + runtimePaneTitlesByTabId: { + 'term-1': { 1: 'Codex working' } + } as unknown as AppState['runtimePaneTitlesByTabId'], + openFiles: [makeOpenMarkdownFile()], + editorDrafts: { '/repo/README.md': draft }, + activeFileId: '/repo/README.md' + }) + + buildMobileSessionTabSnapshots(base) + expect(draftSpy).toHaveBeenCalled() + draftSpy.mockClear() + + buildMobileSessionTabSnapshots({ + ...base, + runtimePaneTitlesByTabId: { + 'term-1': { 1: 'Codex spinner frame' } + } as unknown as AppState['runtimePaneTitlesByTabId'] + }) + + expect(draftSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index e6fc07c91..7d8de532d 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -25,6 +25,10 @@ type RegisteredTerminalTab = { } type OpenFileByWorktreeAndId = Map> +type OpenFileIndexes = { + byWorktreeAndId: OpenFileByWorktreeAndId + idsByWorktree: Map +} const registeredTabs = new Map() // Why: track when each tab was registered so we can suppress the "no live @@ -38,6 +42,10 @@ let syncScheduled = false let syncEnabled = false let getStoreState: (() => AppState) | null = null let mobileSessionSnapshotVersion = 0 +let cachedOpenFileIndexesSource: AppState['openFiles'] | null = null +let cachedOpenFileIndexes: OpenFileIndexes | null = null +let cachedEditorDraftsSource: AppState['editorDrafts'] | null = null +let cachedEditorDraftVersionByFileId: Map | null = null const mobileSessionPublicationEpoch = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() @@ -120,7 +128,13 @@ export type RuntimeMobileSessionSyncKey = { editorDraftsProjection: string } -export function getRuntimeMobileSessionSyncKey(state: AppState): RuntimeMobileSessionSyncKey { +export function getRuntimeMobileSessionSyncKey( + state: AppState, + previousState?: AppState, + previousKey?: RuntimeMobileSessionSyncKey +): RuntimeMobileSessionSyncKey { + const canReusePrevious = previousState !== undefined && previousKey !== undefined + return { terminalLayoutsByTabId: state.terminalLayoutsByTabId, runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId, @@ -130,43 +144,66 @@ export function getRuntimeMobileSessionSyncKey(state: AppState): RuntimeMobileSe tabBarOrderByWorktree: state.tabBarOrderByWorktree, activeFileId: state.activeFileId, activeFileIdByWorktree: state.activeFileIdByWorktree, - tabsProjection: JSON.stringify( - Object.fromEntries( - Object.entries(state.tabsByWorktree).map(([worktreeId, tabs]) => [ - worktreeId, - tabs.map((tab) => ({ - id: tab.id, - title: tab.title, - customTitle: tab.customTitle, - active: state.activeTabId === tab.id - })) - ]) - ) - ), - openFilesProjection: JSON.stringify( - state.openFiles.map((file) => ({ - id: file.id, - filePath: file.filePath, - relativePath: file.relativePath, - worktreeId: file.worktreeId, - language: file.language, - mode: file.mode, - isDirty: file.isDirty, - isUntitled: file.isUntitled, - markdownPreviewSourceFileId: file.markdownPreviewSourceFileId - })) - ), - editorDraftsProjection: JSON.stringify( - Object.fromEntries( - Object.entries(state.editorDrafts).map(([fileId, content]) => [ - fileId, - stableHashString(content) - ]) - ) - ) + // Why: background agent title ticks can change runtimePaneTitlesByTabId + // many times per second while the user types elsewhere. Reuse unchanged + // projections so those ticks do not rescan all tabs, files, and drafts. + tabsProjection: + canReusePrevious && + state.tabsByWorktree === previousState.tabsByWorktree && + state.activeTabId === previousState.activeTabId + ? previousKey.tabsProjection + : buildRuntimeMobileTabsProjection(state), + openFilesProjection: + canReusePrevious && state.openFiles === previousState.openFiles + ? previousKey.openFilesProjection + : buildRuntimeMobileOpenFilesProjection(state.openFiles), + editorDraftsProjection: + canReusePrevious && state.editorDrafts === previousState.editorDrafts + ? previousKey.editorDraftsProjection + : buildRuntimeMobileEditorDraftsProjection(state.editorDrafts) } } +function buildRuntimeMobileTabsProjection(state: AppState): string { + return JSON.stringify( + Object.fromEntries( + Object.entries(state.tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => ({ + id: tab.id, + title: tab.title, + customTitle: tab.customTitle, + active: state.activeTabId === tab.id + })) + ]) + ) + ) +} + +function buildRuntimeMobileOpenFilesProjection(openFiles: AppState['openFiles']): string { + return JSON.stringify( + openFiles.map((file) => ({ + id: file.id, + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + language: file.language, + mode: file.mode, + isDirty: file.isDirty, + isUntitled: file.isUntitled, + markdownPreviewSourceFileId: file.markdownPreviewSourceFileId + })) + ) +} + +function buildRuntimeMobileEditorDraftsProjection(editorDrafts: AppState['editorDrafts']): string { + return JSON.stringify( + Object.fromEntries( + Object.entries(editorDrafts).map(([fileId, content]) => [fileId, stableHashString(content)]) + ) + ) +} + export function runtimeMobileSessionSyncKeysEqual( a: RuntimeMobileSessionSyncKey, b: RuntimeMobileSessionSyncKey @@ -267,10 +304,11 @@ async function syncRuntimeGraph(): Promise { export function buildMobileSessionTabSnapshots( state: AppState ): RuntimeMobileSessionTabsSnapshot[] { - // Why: mobile publication walks the tab order for every worktree. A single - // worktree-scoped file map keeps large editor sessions linear without - // collapsing SSH worktrees that expose the same absolute remote path. - const openFileByWorktreeAndId = indexOpenFilesByWorktreeAndId(state.openFiles) + // Why: mobile publication can run on high-frequency background agent title + // ticks. Cache open-file indexes and draft hashes by immutable store-slice + // reference so title-only syncs do not rescan or rehash editor state. + const openFileIndexes = getOpenFileIndexes(state.openFiles) + const editorDraftVersionByFileId = getEditorDraftVersionByFileId(state.editorDrafts) const worktreeIds = new Set([ ...Object.keys(state.tabsByWorktree), ...Object.keys(state.groupsByWorktree), @@ -281,7 +319,9 @@ export function buildMobileSessionTabSnapshots( const snapshots: RuntimeMobileSessionTabsSnapshot[] = [] for (const worktreeId of worktreeIds) { const activeGroupId = state.activeGroupIdByWorktree[worktreeId] ?? null - const order = getActiveTabNavOrder(state, worktreeId) + const order = getActiveTabNavOrder(state, worktreeId, { + editorIds: openFileIndexes.idsByWorktree.get(worktreeId) ?? [] + }) const terminalTabByIdForWorktree = new Map( (state.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab]) ) @@ -295,11 +335,17 @@ export function buildMobileSessionTabSnapshots( } tabs.push(...buildMobileTerminalSurfaceTabs(state, terminal, worktreeId, item.tabId)) } else if (item.type === 'editor') { - const file = openFileByWorktreeAndId.get(worktreeId)?.get(item.id) + const file = openFileIndexes.byWorktreeAndId.get(worktreeId)?.get(item.id) if (!file) { continue } - const markdown = buildMobileMarkdownTab(state, openFileByWorktreeAndId, file, item.tabId) + const markdown = buildMobileMarkdownTab( + state, + openFileIndexes.byWorktreeAndId, + editorDraftVersionByFileId, + file, + item.tabId + ) if (markdown) { tabs.push(markdown) } else { @@ -323,19 +369,49 @@ export function buildMobileSessionTabSnapshots( return snapshots } -function indexOpenFilesByWorktreeAndId(openFiles: AppState['openFiles']): OpenFileByWorktreeAndId { +function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIndexes { + if (cachedOpenFileIndexesSource === openFiles && cachedOpenFileIndexes) { + return cachedOpenFileIndexes + } + const byWorktreeAndId: OpenFileByWorktreeAndId = new Map() + const idsByWorktree = new Map() for (const file of openFiles) { let filesById = byWorktreeAndId.get(file.worktreeId) if (!filesById) { filesById = new Map() byWorktreeAndId.set(file.worktreeId, filesById) } + let ids = idsByWorktree.get(file.worktreeId) + if (!ids) { + ids = [] + idsByWorktree.set(file.worktreeId, ids) + } if (!filesById.has(file.id)) { filesById.set(file.id, file) + ids.push(file.id) } } - return byWorktreeAndId + + cachedOpenFileIndexesSource = openFiles + cachedOpenFileIndexes = { byWorktreeAndId, idsByWorktree } + return cachedOpenFileIndexes +} + +function getEditorDraftVersionByFileId( + editorDrafts: AppState['editorDrafts'] +): Map { + if (cachedEditorDraftsSource === editorDrafts && cachedEditorDraftVersionByFileId) { + return cachedEditorDraftVersionByFileId + } + + const versions = new Map() + for (const [fileId, content] of Object.entries(editorDrafts)) { + versions.set(fileId, stableHashString(content)) + } + cachedEditorDraftsSource = editorDrafts + cachedEditorDraftVersionByFileId = versions + return versions } function mobileTerminalSurfaceId(parentTabId: string, leafId: string): string { @@ -399,6 +475,7 @@ function buildMobileTerminalSurfaceTabs( function buildMobileMarkdownTab( state: AppState, openFileByWorktreeAndId: OpenFileByWorktreeAndId, + editorDraftVersionByFileId: ReadonlyMap, file: AppState['openFiles'][number], unifiedTabId?: string ): RuntimeMobileSessionMarkdownTab | null { @@ -414,7 +491,7 @@ function buildMobileMarkdownTab( ? (openFileByWorktreeAndId.get(file.worktreeId)?.get(file.markdownPreviewSourceFileId) ?? file) : file - const draftContent = state.editorDrafts[sourceFile.id] + const draftVersion = editorDraftVersionByFileId.get(sourceFile.id) const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'Markdown' return { @@ -434,8 +511,7 @@ function buildMobileMarkdownTab( sourceFileId: sourceFile.id, sourceFilePath: sourceFile.filePath, sourceRelativePath: sourceFile.relativePath, - documentVersion: - draftContent !== undefined ? stableHashString(draftContent) : `file:${sourceFile.id}` + documentVersion: draftVersion ?? `file:${sourceFile.id}` } }