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:
parent
51159589a6
commit
eb89255e8f
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue