fix: background-mount hidden automation worktrees before launch (#6568)

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>
This commit is contained in:
Neil 2026-06-28 23:13:03 -07:00 committed by GitHub
parent 51159589a6
commit eb89255e8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 331 additions and 22 deletions

View File

@ -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<BackgroundMountTerminalWorktreeDetail>
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,

View File

@ -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<string>()
const measurableBackgroundWorktreeIds = new Set<string>()
const timers = new Map<string, number>()
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<string>()
const measurableBackgroundWorktreeIds = new Set<string>()
const timers = new Map<string, number>()
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<string>()
const measurableBackgroundWorktreeIds = new Set<string>()
const timers = new Map<string, number>()
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()
})
})

View File

@ -0,0 +1,47 @@
import { addBackgroundMountedTerminalWorktree } from './background-terminal-worktree-mount'
export const BACKGROUND_WORKTREE_MEASURE_WINDOW_MS = 3000
type ScheduleMeasureArgs = {
mountedWorktreeIds: Set<string>
measurableBackgroundWorktreeIds: Set<string>
timers: Map<string, number>
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
}

View File

@ -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',

View File

@ -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

View File

@ -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<typeof waitForSessionReady>[0],
tabId: string
): Promise<string> {
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<typeof waitForSessionReady>[0],
ptyId: string,
text: string
): Promise<boolean> {
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)
})
})