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 index 0cd0793a266..3c285761cf5 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildMobileSessionTabSnapshots } from './sync-runtime-graph' +import { buildMobileSessionTabSnapshots, registerRuntimeTerminalTab } from './sync-runtime-graph' import type { AppState } from '../store/types' // Why: getBrowserTabsByWorktree reads this slice once per worktree inside the build loop, @@ -207,3 +207,175 @@ describe('mobile session publication cost', () => { expect(rebuiltWorktrees).toEqual(['repo::/title-wt-7']) }) }) + +// Distinct ids per test: the snapshot memo is module state shared in this file. +const MOUNTED_WT = 'repo::/mounted-wt-0' +const CONTROL_WT = 'repo::/mounted-wt-1' +const MOUNTED_LEAF_ID = 'aaaaaaaa-1111-4111-8111-111111111111' +const SPLIT_LEAF_ID = 'bbbbbbbb-1111-4111-8111-111111111111' + +function makeMountedState(tabIdPrefix: string): { + state: AppState + mountedTabId: string + titleReads: () => number + resetTitleReads: () => void +} { + let titleReads = 0 + const mountedTabId = `${tabIdPrefix}-term-0` + const state = { + tabsByWorktree: { + [MOUNTED_WT]: [ + { + id: mountedTabId, + customTitle: null, + ptyId: null, + get title() { + titleReads++ + return 'Agent mounted' + } + } + ], + [CONTROL_WT]: [ + { id: `${tabIdPrefix}-term-1`, title: 'Agent control', customTitle: null, ptyId: null } + ] + }, + terminalLayoutsByTabId: { + [mountedTabId]: { + root: { type: 'leaf', leafId: MOUNTED_LEAF_ID }, + activeLeafId: MOUNTED_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [MOUNTED_LEAF_ID]: 'pty-saved-1' } + } + }, + runtimePaneTitlesByTabId: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeTabId: null, + agentStatusByPaneKey: {}, + browserTabsByWorktree: {} + } as unknown as AppState + return { + state, + mountedTabId, + titleReads: () => titleReads, + resetTitleReads: () => { + titleReads = 0 + } + } +} + +function makeLiveSurface(tabId: string): { + registration: Parameters[0] + setPtyId: (paneId: number, ptyId: string) => void + addPane: (paneId: number, leafId: string) => void +} { + const panes = [{ id: 1, leafId: MOUNTED_LEAF_ID }] + const ptyIdByPaneId = new Map([[1, 'pty-live-1']]) + const manager = { + getPanes: () => panes.map((pane) => ({ ...pane })), + getActivePane: () => panes[0] ?? null, + getLeafId: (paneId: number) => panes.find((pane) => pane.id === paneId)?.leafId ?? null, + getNumericIdForLeaf: (leafId: string) => + panes.find((pane) => pane.leafId === leafId)?.id ?? null + } + const registration = { + tabId, + worktreeId: MOUNTED_WT, + getManager: () => manager, + getContainer: () => null, + getPtyIdForPane: (paneId: number) => ptyIdByPaneId.get(paneId) ?? null + } as unknown as Parameters[0] + return { + registration, + setPtyId: (paneId, ptyId) => { + ptyIdByPaneId.set(paneId, ptyId) + }, + addPane: (paneId, leafId) => { + panes.push({ id: paneId, leafId }) + ptyIdByPaneId.set(paneId, `pty-live-${paneId}`) + } + } +} + +function snapshotsByWorktree( + state: AppState +): Map[number]> { + return new Map( + buildMobileSessionTabSnapshots(state).map((snapshot) => [snapshot.worktree, snapshot]) + ) +} + +describe('mounted terminal surface memoization', () => { + it('reuses a mounted worktree snapshot when its live pane state is unchanged', () => { + const { state, mountedTabId, titleReads, resetTitleReads } = makeMountedState('mounted-a') + const unregister = registerRuntimeTerminalTab(makeLiveSurface(mountedTabId).registration) + try { + const before = snapshotsByWorktree(state) + resetTitleReads() + + const after = snapshotsByWorktree(state) + + expect(titleReads()).toBe(0) + expect(after.get(MOUNTED_WT)).toBe(before.get(MOUNTED_WT)) + } finally { + unregister() + } + }) + + it('publishes a live pty rebinding on a mounted worktree', () => { + const { state, mountedTabId } = makeMountedState('mounted-b') + const surface = makeLiveSurface(mountedTabId) + const unregister = registerRuntimeTerminalTab(surface.registration) + try { + const before = snapshotsByWorktree(state) + + surface.setPtyId(1, 'pty-live-2') + const after = snapshotsByWorktree(state) + + expect(after.get(MOUNTED_WT)).not.toBe(before.get(MOUNTED_WT)) + expect(after.get(MOUNTED_WT)?.tabs).toEqual([ + expect.objectContaining({ ptyId: 'pty-live-2' }) + ]) + expect(after.get(CONTROL_WT)).toBe(before.get(CONTROL_WT)) + } finally { + unregister() + } + }) + + it('publishes a live pane split on a mounted worktree', () => { + const { state, mountedTabId } = makeMountedState('mounted-c') + const surface = makeLiveSurface(mountedTabId) + const unregister = registerRuntimeTerminalTab(surface.registration) + try { + snapshotsByWorktree(state) + + surface.addPane(2, SPLIT_LEAF_ID) + const after = snapshotsByWorktree(state) + + expect( + after.get(MOUNTED_WT)?.tabs.map((tab) => (tab.type === 'terminal' ? tab.leafId : null)) + ).toEqual([MOUNTED_LEAF_ID, SPLIT_LEAF_ID]) + } finally { + unregister() + } + }) + + it('falls back to the persisted pty binding after the surface unmounts', () => { + const { state, mountedTabId, titleReads, resetTitleReads } = makeMountedState('mounted-d') + const unregister = registerRuntimeTerminalTab(makeLiveSurface(mountedTabId).registration) + snapshotsByWorktree(state) + + unregister() + resetTitleReads() + const after = snapshotsByWorktree(state) + + expect(titleReads()).toBeGreaterThan(0) + expect(after.get(MOUNTED_WT)?.tabs).toEqual([expect.objectContaining({ ptyId: 'pty-saved-1' })]) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 0f8e28b7fe6..1966bed1df1 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -31,6 +31,7 @@ import type { TabGroup, TabGroupLayoutNode, TerminalLayoutSnapshot, + TerminalPaneLayoutNode, TerminalTab } from '../../../shared/types' import { resolveTerminalTabTitle } from '../../../shared/tab-title-resolution' @@ -92,6 +93,22 @@ type MobileSessionPublicationInputs = { generatedTitlesEnabled: boolean terminalTheme: RuntimeMobileTerminalTheme | undefined } +/** + * Live PaneManager/DOM reads for one mounted terminal tab, captured once per + * publication. + * + * Why: builders read this instead of the registry, so live state the memo + * cannot witness is unrepresentable — mounted worktrees memoize like the rest + * instead of rebuilding on every publication. + */ +type MountedTerminalSurfaceCapture = { + paneLeafIds: readonly string[] + hasLiveActivePane: boolean + liveActiveLeafId: string | null + liveLayoutRoot: TerminalPaneLayoutNode | null + numericPaneIdByLeafId: ReadonlyMap + ptyIdByNumericPaneId: ReadonlyMap +} /** * One worktree's complete mobile-snapshot input set. * @@ -131,8 +148,7 @@ type MobileSessionWorktreeInputs = { 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 + mountedSurfaceCaptureByTabId: ReadonlyMap } const registeredTabs = new Map() @@ -1026,11 +1042,99 @@ function buildMobileSessionWorktreeInputs( activeBrowserWorkspaceId: state.activeBrowserTabIdByWorktree?.[worktreeId] ?? null, generatedTitlesEnabled: publication.generatedTitlesEnabled, terminalTheme: publication.terminalTheme, - hasMountedTerminalSurface: terminalTabs.some((tab) => registeredTabs.has(tab.id)) + mountedSurfaceCaptureByTabId: captureMountedTerminalSurfaces( + terminalTabs, + state.terminalLayoutsByTabId + ) } } -function narrowedEntriesEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { +function captureMountedTerminalSurfaces( + terminalTabs: AppState['tabsByWorktree'][string], + terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] +): ReadonlyMap { + let captures: Map | null = null + for (const tab of terminalTabs) { + const registered = registeredTabs.get(tab.id) + if (!registered) { + continue + } + captures ??= new Map() + captures.set(tab.id, captureMountedTerminalSurface(registered, terminalLayoutsByTabId[tab.id])) + } + return captures ?? EMPTY_NARROWED_BY_KEY +} + +function captureMountedTerminalSurface( + registered: RegisteredTerminalTab, + savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined +): MountedTerminalSurfaceCapture { + const manager = registered.getManager() + const paneLeafIds = manager?.getPanes().map((pane) => pane.leafId) ?? [] + const activePane = manager?.getActivePane() ?? null + const firstChild = registered.getContainer()?.firstElementChild + // Why: mirrors getRuntimeLeafIdsForTerminal so every leaf the builder resolves has a captured pane id. + const effectiveLeafIds = + paneLeafIds.length > 0 + ? paneLeafIds + : collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) + const numericPaneIdByLeafId = new Map() + const ptyIdByNumericPaneId = new Map() + for (const leafId of effectiveLeafIds) { + const numericPaneId = manager?.getNumericIdForLeaf(leafId) ?? null + numericPaneIdByLeafId.set(leafId, numericPaneId) + if (numericPaneId !== null) { + ptyIdByNumericPaneId.set(numericPaneId, registered.getPtyIdForPane(numericPaneId)) + } + } + return { + paneLeafIds, + hasLiveActivePane: activePane !== null, + liveActiveLeafId: activePane !== null ? (manager?.getLeafId(activePane.id) ?? null) : null, + liveLayoutRoot: serializePaneTree( + typeof HTMLElement !== 'undefined' && firstChild instanceof HTMLElement ? firstChild : null + ), + numericPaneIdByLeafId, + ptyIdByNumericPaneId + } +} + +function mountedTerminalSurfaceCaptureEquals( + a: MountedTerminalSurfaceCapture, + b: MountedTerminalSurfaceCapture +): boolean { + return ( + a.hasLiveActivePane === b.hasLiveActivePane && + a.liveActiveLeafId === b.liveActiveLeafId && + a.paneLeafIds.length === b.paneLeafIds.length && + a.paneLeafIds.every((leafId, index) => b.paneLeafIds[index] === leafId) && + narrowedEntriesEqual(a.numericPaneIdByLeafId, b.numericPaneIdByLeafId) && + narrowedEntriesEqual(a.ptyIdByNumericPaneId, b.ptyIdByNumericPaneId) && + // Why: serialization allocates a fresh tree per capture, so it compares by content. + jsonContentEquals(a.liveLayoutRoot, b.liveLayoutRoot) + ) +} + +function mountedSurfaceCapturesEqual( + a: ReadonlyMap, + b: ReadonlyMap +): boolean { + if (a === b) { + return true + } + if (a.size !== b.size) { + return false + } + for (const [tabId, capture] of a) { + const other = b.get(tabId) + if (!other || !mountedTerminalSurfaceCaptureEquals(capture, other)) { + return false + } + } + return true +} + +function narrowedEntriesEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { if (a === b) { return true } @@ -1056,9 +1160,6 @@ function canReuseMobileSessionSnapshot( 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 && @@ -1084,6 +1185,12 @@ function canReuseMobileSessionSnapshot( narrowedEntriesEqual( previous.certificateFailureByBrowserPageId, next.certificateFailureByBrowserPageId + ) && + // Why: live DOM/PaneManager state is invisible to store references; the + // capture makes it comparable instead of forcing mounted worktrees to rebuild. + mountedSurfaceCapturesEqual( + previous.mountedSurfaceCaptureByTabId, + next.mountedSurfaceCaptureByTabId ) ) } @@ -1683,12 +1790,10 @@ function resolveMobileTerminalTheme( } function getRuntimeLeafIdsForTerminal( - tabId: string, + capture: MountedTerminalSurfaceCapture | undefined, savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined -): string[] { - const registered = registeredTabs.get(tabId) - const manager = registered?.getManager() - const liveLeafIds = manager?.getPanes().map((pane) => pane.leafId) ?? [] +): readonly string[] { + const liveLeafIds = capture?.paneLeafIds ?? [] if (liveLeafIds.length > 0) { return liveLeafIds } @@ -1707,18 +1812,15 @@ function buildMobileTerminalSurfaceTabs( terminal: NonNullable[number], unifiedTabId?: string ): RuntimeMobileSessionSnapshotTab[] { - const registered = registeredTabs.get(terminal.id) + const capture = inputs.mountedSurfaceCaptureByTabId.get(terminal.id) const isDesktopTabActive = unifiedTabId ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) : inputs.activeTerminalTabId === terminal.id - const manager = registered?.getManager() - const liveActivePaneId = manager?.getActivePane()?.id ?? null const savedLayout = inputs.terminalLayoutByTabId.get(terminal.id) - const leafIds = getRuntimeLeafIdsForTerminal(terminal.id, savedLayout) - const activeLeafId = - liveActivePaneId !== null - ? (manager?.getLeafId(liveActivePaneId) ?? null) - : (savedLayout?.activeLeafId ?? leafIds[0] ?? null) + const leafIds = getRuntimeLeafIdsForTerminal(capture, savedLayout) + const activeLeafId = capture?.hasLiveActivePane + ? capture.liveActiveLeafId + : (savedLayout?.activeLeafId ?? leafIds[0] ?? null) const paneTitles = inputs.paneTitlesByTabId.get(terminal.id) ?? {} const generatedTitlesEnabled = inputs.generatedTitlesEnabled const sanitizedSavedLayout = savedLayout @@ -1736,11 +1838,7 @@ function buildMobileTerminalSurfaceTabs( ? seededLaunchDraft : null const publishedLaunchDraft = launchDraftEntry?.text.trim() ? launchDraftEntry : null - const container = registered?.getContainer() - const firstChild = container?.firstElementChild - const liveLayoutRoot = serializePaneTree( - typeof HTMLElement !== 'undefined' && firstChild instanceof HTMLElement ? firstChild : null - ) + const liveLayoutRoot = capture?.liveLayoutRoot ?? null const parentLayout = normalizeTerminalLayoutSnapshot({ // Why: live DOM tree is authoritative when mounted, else the saved tree; synthesize only as a last resort, never re-guess. root: resolveTerminalLayoutRoot({ @@ -1760,11 +1858,11 @@ function buildMobileTerminalSurfaceTabs( : {}) } satisfies TerminalLayoutSnapshot).snapshot return leafIds.map((leafId) => { - const numericPaneId = manager?.getNumericIdForLeaf(leafId) ?? null + const numericPaneId = capture?.numericPaneIdByLeafId.get(leafId) ?? null const ptyId = numericPaneId === null ? (savedPtyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? terminal.ptyId : null)) - : (registered?.getPtyIdForPane(numericPaneId) ?? savedPtyIdsByLeafId[leafId] ?? null) + : (capture?.ptyIdByNumericPaneId.get(numericPaneId) ?? savedPtyIdsByLeafId[leafId] ?? null) const legacyPaneId = numericPaneId === null ? /^pane:(\d+)$/.exec(leafId)?.[1] : null const paneTitle = numericPaneId !== null