diff --git a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts index d72692a1042..fbbbad77f34 100644 --- a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -27,14 +27,21 @@ describe('terminal.subscribe blank-tab background mount', () => { it('requests a renderer tab mount when a mobile subscribe has no headless model', async () => { // Why: stale preview text must not hide the missing live model/attachment. const cleanups = new Map void>() - const requestRendererTerminalTabMount = vi.fn() + const callOrder: string[] = [] + const unsubscribeData = vi.fn() + const requestRendererTerminalTabMount = vi.fn(() => { + callOrder.push('request-mount') + }) const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), requestRendererTerminalTabMount, hasHeadlessTerminalState: vi.fn().mockReturnValue(false), handleMobileSubscribe: vi.fn().mockResolvedValue(true), handleMobileUnsubscribe: vi.fn(), - subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalData: vi.fn(() => { + callOrder.push('subscribe-data') + return unsubscribeData + }), readTerminal: vi.fn().mockResolvedValue({ tail: ['stale preview'], truncated: false }), serializeTerminalBuffer: vi .fn() @@ -73,9 +80,11 @@ describe('terminal.subscribe blank-tab background mount', () => { await vi.waitFor(() => expect(requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') ) + expect(callOrder).toEqual(['subscribe-data', 'request-mount']) runtime.cleanupSubscription('terminal-1:phone-1') await dispatchPromise + expect(unsubscribeData).toHaveBeenCalledTimes(1) }) it('does not request a renderer tab mount when an attached terminal is legitimately blank', async () => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index e9d8641de9d..b3e2dae6b17 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -15,6 +15,7 @@ import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoa import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' +import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' @@ -1676,11 +1677,17 @@ export function useIpcEvents(): void { } // Why: synthetic pty handles need persisted-tab resolution, but a miss // must not mount every saved terminal in a large hidden worktree. - const mount = planMobileTerminalTabMount(useAppStore.getState(), { - worktreeId, - ...(tabId ? { tabId } : {}), - ...(ptyId ? { ptyId } : {}) - }) + const mount = planMobileTerminalTabMount( + useAppStore.getState(), + { + worktreeId, + ...(tabId ? { tabId } : {}), + ...(ptyId ? { ptyId } : {}) + }, + { + isTabMounted: hasRegisteredRuntimeTerminalTab + } + ) if (mount) { requestBackgroundTerminalWorktreeMount(mount) } diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts index 1107f098207..500a1abedae 100644 --- a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store/types' import { planMobileTerminalTabMount } from './mobile-terminal-tab-mount' @@ -36,4 +36,18 @@ describe('planMobileTerminalTabMount', () => { planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@missing' }) ).toBeNull() }) + + it('does not schedule hidden layout work for an already-mounted tab', () => { + const isTabMounted = vi.fn().mockReturnValue(true) + + expect( + planMobileTerminalTabMount( + state(200), + { worktreeId: 'wt', ptyId: 'wt@@173' }, + { isTabMounted } + ) + ).toBeNull() + expect(isTabMounted).toHaveBeenCalledTimes(1) + expect(isTabMounted).toHaveBeenCalledWith('tab-173') + }) }) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.ts index ead0ade6b31..0daa8df5549 100644 --- a/src/renderer/src/lib/mobile-terminal-tab-mount.ts +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.ts @@ -8,10 +8,15 @@ export type MobileTerminalTabMountRequest = { ptyId?: string } +type MobileTerminalTabMountOptions = { + isTabMounted?: (tabId: string) => boolean +} + /** Why: exact-tab planning prevents a stale ptyId from mounting every saved xterm (#8597). */ export function planMobileTerminalTabMount( state: Pick, - request: MobileTerminalTabMountRequest + request: MobileTerminalTabMountRequest, + options: MobileTerminalTabMountOptions = {} ): BackgroundMountTerminalWorktreeDetail | null { if (!request.worktreeId) { return null @@ -19,5 +24,9 @@ export function planMobileTerminalTabMount( const tabId = request.tabId ?? (request.ptyId ? resolveTerminalTabIdForPtyId(state, request.worktreeId, request.ptyId) : null) - return tabId ? { worktreeId: request.worktreeId, tabIds: [tabId] } : null + // Why: replaying the background-mount event for a live pane restarts its + // three-second hidden measurement window on every mobile reconnect. + return tabId && !options.isTabMounted?.(tabId) + ? { worktreeId: request.worktreeId, tabIds: [tabId] } + : null }