From eb89255e8f4f2aba913e2e66651a8306b7bb2e98 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:13:03 -0700 Subject: [PATCH] fix: background-mount hidden automation worktrees before launch (#6568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless automation launches (launchAgentBackgroundSession) created an inactive tab via createTab(..., { activate: false }) without first telling the renderer to background-mount that worktree's terminal surface. As a result the hidden surface either never mounted (no entry in mountedWorktreeIdsRef) or mounted with display:none (zero-size, can't be measured/fit), so the eager PTY buffer never flushed on the first mount — the run tab showed only the shell prompt until an unmount/remount gave the off-screen xterm a real layout box. Dispatch BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT immediately before createTab, mirroring the established renderer-backed Codex startup path in useIpcEvents. The Terminal.tsx listener adds the worktree to mountedWorktreeIdsRef and marks it measurable for a 3000ms window, so the hidden surface renders with opacity-0/pointer-events-none (a measurable box) instead of display:none, letting the first xterm fit flush the buffer. Extract the inline measurable-mount block into background-terminal-worktree-visibility.ts (behavior-identical, now unit-tested) and add unit + E2E coverage. Fixes #6244 Co-authored-by: ChaDongWun <66347959+lovewave02@users.noreply.github.com> --- src/renderer/src/components/Terminal.tsx | 32 ++-- ...round-terminal-worktree-visibility.test.ts | 105 ++++++++++++ ...background-terminal-worktree-visibility.ts | 47 ++++++ .../launch-agent-background-session.test.ts | 9 ++ .../lib/launch-agent-background-session.ts | 9 ++ ...mation-hidden-terminal-first-mount.spec.ts | 151 ++++++++++++++++++ 6 files changed, 331 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/terminal/background-terminal-worktree-visibility.test.ts create mode 100644 src/renderer/src/components/terminal/background-terminal-worktree-visibility.ts create mode 100644 tests/e2e/automation-hidden-terminal-first-mount.spec.ts diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 34417220c..6da91641a 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -58,7 +58,7 @@ import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout' import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer' import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal' import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair' -import { addBackgroundMountedTerminalWorktree } from './terminal/background-terminal-worktree-mount' +import { scheduleBackgroundTerminalWorktreeMeasure } from './terminal/background-terminal-worktree-visibility' import { getEffectiveLayoutForWorktree as getEffectiveLayout, anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout @@ -716,27 +716,15 @@ function Terminal(): React.JSX.Element | null { const onBackgroundMountTerminalWorktree = (event: Event): void => { const customEvent = event as CustomEvent const worktreeId = customEvent.detail?.worktreeId - addBackgroundMountedTerminalWorktree(mountedWorktreeIdsRef.current, worktreeId, () => - setBackgroundMountRevision((revision) => revision + 1) - ) - if (!worktreeId) { - return - } - measurableBackgroundWorktreeIdsRef.current.add(worktreeId) - const existingTimer = timers.get(worktreeId) - if (existingTimer !== undefined) { - window.clearTimeout(existingTimer) - } - // Why: background renderer-backed terminal creation must be measurable - // for the first xterm fit, but it must not keep hidden worktrees laid - // out indefinitely after the PTY has started. - const timer = window.setTimeout(() => { - measurableBackgroundWorktreeIdsRef.current.delete(worktreeId) - timers.delete(worktreeId) - setBackgroundMountRevision((revision) => revision + 1) - }, 3000) - timers.set(worktreeId, timer) - setBackgroundMountRevision((revision) => revision + 1) + scheduleBackgroundTerminalWorktreeMeasure({ + mountedWorktreeIds: mountedWorktreeIdsRef.current, + measurableBackgroundWorktreeIds: measurableBackgroundWorktreeIdsRef.current, + timers, + worktreeId, + onRevision: () => setBackgroundMountRevision((revision) => revision + 1), + setTimeoutFn: window.setTimeout, + clearTimeoutFn: window.clearTimeout + }) } window.addEventListener( BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-visibility.test.ts b/src/renderer/src/components/terminal/background-terminal-worktree-visibility.test.ts new file mode 100644 index 000000000..878240e84 --- /dev/null +++ b/src/renderer/src/components/terminal/background-terminal-worktree-visibility.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + BACKGROUND_WORKTREE_MEASURE_WINDOW_MS, + scheduleBackgroundTerminalWorktreeMeasure +} from './background-terminal-worktree-visibility' + +describe('scheduleBackgroundTerminalWorktreeMeasure', () => { + it('marks a hidden worktree measurable for the first mount window', () => { + vi.useFakeTimers() + try { + const mountedWorktreeIds = new Set() + const measurableBackgroundWorktreeIds = new Set() + const timers = new Map() + const onRevision = vi.fn() + + const added = scheduleBackgroundTerminalWorktreeMeasure({ + mountedWorktreeIds, + measurableBackgroundWorktreeIds, + timers, + worktreeId: 'wt-1', + onRevision, + setTimeoutFn: setTimeout, + clearTimeoutFn: clearTimeout + }) + + expect(added).toBe(true) + expect(mountedWorktreeIds.has('wt-1')).toBe(true) + expect(measurableBackgroundWorktreeIds.has('wt-1')).toBe(true) + expect(timers.has('wt-1')).toBe(true) + expect(onRevision).toHaveBeenCalledTimes(2) + + vi.advanceTimersByTime(BACKGROUND_WORKTREE_MEASURE_WINDOW_MS - 1) + expect(measurableBackgroundWorktreeIds.has('wt-1')).toBe(true) + + vi.advanceTimersByTime(1) + expect(measurableBackgroundWorktreeIds.has('wt-1')).toBe(false) + expect(timers.has('wt-1')).toBe(false) + expect(onRevision).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + + it('refreshes the measurable timer for repeated background-mount events', () => { + vi.useFakeTimers() + try { + const mountedWorktreeIds = new Set() + const measurableBackgroundWorktreeIds = new Set() + const timers = new Map() + const onRevision = vi.fn() + const clearTimeoutFn = vi.fn(clearTimeout) + + scheduleBackgroundTerminalWorktreeMeasure({ + mountedWorktreeIds, + measurableBackgroundWorktreeIds, + timers, + worktreeId: 'wt-1', + onRevision, + setTimeoutFn: setTimeout, + clearTimeoutFn + }) + const firstTimer = timers.get('wt-1') + + scheduleBackgroundTerminalWorktreeMeasure({ + mountedWorktreeIds, + measurableBackgroundWorktreeIds, + timers, + worktreeId: 'wt-1', + onRevision, + setTimeoutFn: setTimeout, + clearTimeoutFn + }) + + expect(firstTimer).toBeDefined() + expect(clearTimeoutFn).toHaveBeenCalledWith(firstTimer) + expect(measurableBackgroundWorktreeIds.has('wt-1')).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('ignores missing worktree ids without creating measurable state', () => { + const mountedWorktreeIds = new Set() + const measurableBackgroundWorktreeIds = new Set() + const timers = new Map() + const onRevision = vi.fn() + + const added = scheduleBackgroundTerminalWorktreeMeasure({ + mountedWorktreeIds, + measurableBackgroundWorktreeIds, + timers, + worktreeId: undefined, + onRevision, + setTimeoutFn: setTimeout, + clearTimeoutFn: clearTimeout + }) + + expect(added).toBe(false) + expect(mountedWorktreeIds.size).toBe(0) + expect(measurableBackgroundWorktreeIds.size).toBe(0) + expect(timers.size).toBe(0) + expect(onRevision).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-visibility.ts b/src/renderer/src/components/terminal/background-terminal-worktree-visibility.ts new file mode 100644 index 000000000..2ab6cc839 --- /dev/null +++ b/src/renderer/src/components/terminal/background-terminal-worktree-visibility.ts @@ -0,0 +1,47 @@ +import { addBackgroundMountedTerminalWorktree } from './background-terminal-worktree-mount' + +export const BACKGROUND_WORKTREE_MEASURE_WINDOW_MS = 3000 + +type ScheduleMeasureArgs = { + mountedWorktreeIds: Set + measurableBackgroundWorktreeIds: Set + timers: Map + worktreeId: string | undefined + onRevision: () => void + setTimeoutFn: typeof window.setTimeout + clearTimeoutFn: typeof window.clearTimeout +} + +export function scheduleBackgroundTerminalWorktreeMeasure({ + mountedWorktreeIds, + measurableBackgroundWorktreeIds, + timers, + worktreeId, + onRevision, + setTimeoutFn, + clearTimeoutFn +}: ScheduleMeasureArgs): boolean { + const added = addBackgroundMountedTerminalWorktree(mountedWorktreeIds, worktreeId, onRevision) + if (!worktreeId) { + return added + } + + measurableBackgroundWorktreeIds.add(worktreeId) + const existingTimer = timers.get(worktreeId) + if (existingTimer !== undefined) { + clearTimeoutFn(existingTimer) + } + + // Why: background renderer-backed terminal creation must be measurable for the + // first xterm fit (the fit flushes the eager PTY buffer), but it must not keep + // hidden worktrees laid out indefinitely after the PTY has started. + const timer = setTimeoutFn(() => { + measurableBackgroundWorktreeIds.delete(worktreeId) + timers.delete(worktreeId) + onRevision() + }, BACKGROUND_WORKTREE_MEASURE_WINDOW_MS) + + timers.set(worktreeId, timer) + onRevision() + return added +} diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index bec781afb..f42f886fd 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: local/runtime launch tests share a mock harness. */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT } from '@/constants/terminal' import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' @@ -19,6 +20,7 @@ const mockSubscribeToPtyData = vi.fn() const mockSubscribeToPtyExit = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const mockMarkTrusted = vi.fn() +const mockDispatchEvent = vi.fn() const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ function expectStablePaneSpawn(): string { @@ -138,6 +140,7 @@ describe('launchAgentBackgroundSession', () => { mockSubscribeToPtyData.mockReturnValue(vi.fn()) mockSubscribeToPtyExit.mockReturnValue(vi.fn()) vi.stubGlobal('window', { + dispatchEvent: mockDispatchEvent, api: { pty: { spawn: mockSpawn, @@ -171,6 +174,12 @@ describe('launchAgentBackgroundSession', () => { activate: false, recordInteraction: false }) + expect(mockDispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + detail: { worktreeId: 'wt-1' } + }) + ) expect(mockSpawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/repo/worktree', diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 3278f686f..389c6972c 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -10,6 +10,7 @@ import { tuiAgentToAgentKind } from '@/lib/telemetry' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT } from '@/constants/terminal' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -106,6 +107,14 @@ export async function launchAgentBackgroundSession( // Why: automation runs should start without revealing the workspace. // Spawn the PTY immediately, then attach an inactive tab to the live session. + // Background-mount the hidden worktree first so its off-screen terminal surface + // gets a measurable layout box and the eager PTY buffer flushes on the first + // mount — mirroring the renderer-backed Codex startup path in useIpcEvents. + window.dispatchEvent( + new CustomEvent(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, { + detail: { worktreeId } + }) + ) const tab = store.createTab(worktreeId, undefined, undefined, { activate: false, recordInteraction: false diff --git a/tests/e2e/automation-hidden-terminal-first-mount.spec.ts b/tests/e2e/automation-hidden-terminal-first-mount.spec.ts new file mode 100644 index 000000000..8c80551e1 --- /dev/null +++ b/tests/e2e/automation-hidden-terminal-first-mount.spec.ts @@ -0,0 +1,151 @@ +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveWorktreeId, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { getTerminalContent, waitForActiveTerminalManager } from './helpers/terminal' +import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT } from '../../src/renderer/src/constants/terminal' + +async function waitForHiddenTabPtyId( + page: Parameters[0], + tabId: string +): Promise { + let ptyId: string | null = null + await expect + .poll( + async () => { + ptyId = await page.evaluate((targetTabId) => { + const state = window.__store?.getState() + if (!state) { + return null + } + return state.ptyIdsByTabId[targetTabId]?.[0] ?? null + }, tabId) + return ptyId + }, + { + timeout: 20_000, + message: `Hidden terminal tab ${tabId} did not receive a PTY binding` + } + ) + .not.toBeNull() + + if (!ptyId) { + throw new Error(`waitForHiddenTabPtyId: tab ${tabId} has no PTY id`) + } + return ptyId +} + +async function mainSnapshotContains( + page: Parameters[0], + ptyId: string, + text: string +): Promise { + return page.evaluate( + async ({ targetPtyId, expectedText }) => { + const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, { + scrollbackRows: 200 + }) + return snapshot?.data.includes(expectedText) ?? false + }, + { targetPtyId: ptyId, expectedText: text } + ) +} + +test.describe('Automation hidden terminal first mount', () => { + test('background-mounted hidden worktree replays startup output on the first visible mount', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'background first-mount repro needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + const runId = Date.now() + const marker = `AUTO_FIRST_MOUNT_${runId}` + const hiddenTabId = await orcaPage.evaluate( + ({ worktreeId, marker, eventName }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + + window.dispatchEvent( + new CustomEvent(eventName, { + detail: { worktreeId } + }) + ) + + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { + activate: false, + recordInteraction: false + }) + state.queueTabStartupCommand(tab.id, { + command: `node -e "console.log('${marker}')"`, + telemetry: { + launch_source: 'automation_hidden_first_mount_e2e', + request_kind: 'new' + } + }) + state.setTabCustomTitle(tab.id, 'Automation hidden shell', { + recordInteraction: false + }) + return tab.id + }, + { + worktreeId: secondWorktreeId, + marker, + eventName: BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT + } + ) + + const hiddenPtyId = await waitForHiddenTabPtyId(orcaPage, hiddenTabId) + await expect + .poll(() => mainSnapshotContains(orcaPage, hiddenPtyId, marker), { + timeout: 20_000, + message: 'Hidden automation terminal did not buffer startup output while off-screen' + }) + .toBe(true) + + await switchToWorktree(orcaPage, secondWorktreeId) + await expect + .poll(() => getActiveWorktreeId(orcaPage), { + timeout: 10_000, + message: 'Hidden worktree did not become active for first-mount verification' + }) + .toBe(secondWorktreeId) + + await orcaPage.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + state.setActiveTab(tabId) + state.setActiveTabType('terminal') + }, hiddenTabId) + + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + await expect + .poll(async () => (await getTerminalContent(orcaPage)).includes(marker), { + timeout: 10_000, + message: 'First visible mount did not replay the hidden automation terminal output' + }) + .toBe(true) + }) +})