diff --git a/docs/renderer-memory-profile-2026-06-01.md b/docs/renderer-memory-profile-2026-06-01.md index e9d28282b4c..cedca5a15fb 100644 --- a/docs/renderer-memory-profile-2026-06-01.md +++ b/docs/renderer-memory-profile-2026-06-01.md @@ -102,3 +102,17 @@ run with `--json`, including host memory, Orca app process buckets, worktree terminal memory, per-session process roots, and history samples. Text output prints a compact point-in-time summary and the top worktrees by retained terminal memory. + +## Follow-up: Agent-Browser Paintability Guard + +The browser parking fix depends on automation-visible panes staying paintable +without activating the user's worktree. The renderer bridge previously waited +for two animation frames before creating the automation visibility lease, so the +paint wait happened while the parked webview was still hidden. Non-screenshot +agent-browser commands could therefore start immediately after the lease was +created, before React had made the hidden pane paintable. + +The follow-up changes the order: create the automation visibility lease first, +then wait for paint while the pane is actually visible to automation. A +renderer-side timeout releases the lease if paint never arrives, so a hung RAF +does not pin an inactive browser pane indefinitely. diff --git a/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts b/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts index 90ef44f8341..dd15257ac87 100644 --- a/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts +++ b/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts @@ -34,7 +34,7 @@ describe('browser automation visibility leases', () => { expect(isBrowserAutomationVisible('page-1')).toBe(false) }) - it('installs a main-process bridge that waits for paint before returning a token', async () => { + it('installs a main-process bridge that keeps the page visible while waiting for paint', async () => { const animationFrameCallbacks: FrameRequestCallback[] = [] vi.stubGlobal('window', { requestAnimationFrame: (callback: FrameRequestCallback) => { @@ -50,13 +50,13 @@ describe('browser automation visibility leases', () => { const acquirePromise = bridge?.acquire('page-2') await Promise.resolve() - expect(isBrowserAutomationVisible('page-2')).toBe(false) + expect(isBrowserAutomationVisible('page-2')).toBe(true) expect(animationFrameCallbacks).toHaveLength(1) animationFrameCallbacks.shift()?.(0) await Promise.resolve() - expect(isBrowserAutomationVisible('page-2')).toBe(false) + expect(isBrowserAutomationVisible('page-2')).toBe(true) expect(animationFrameCallbacks).toHaveLength(1) animationFrameCallbacks.shift()?.(16) @@ -68,18 +68,28 @@ describe('browser automation visibility leases', () => { expect(isBrowserAutomationVisible('page-2')).toBe(false) }) - it('does not allocate a main-process bridge lease when the paint wait hangs', async () => { + it('releases the main-process bridge lease when the paint wait hangs', async () => { + vi.useFakeTimers() vi.stubGlobal('window', { requestAnimationFrame: () => 1 }) - const { isBrowserAutomationVisible } = await import('./browser-automation-visibility') + try { + const { isBrowserAutomationVisible } = await import('./browser-automation-visibility') - const bridge = window.__orcaBrowserAutomationVisibility - expect(bridge).toBeTruthy() + const bridge = window.__orcaBrowserAutomationVisibility + expect(bridge).toBeTruthy() - void bridge?.acquire('page-hung-paint') - await Promise.resolve() + const acquirePromise = bridge?.acquire('page-hung-paint') + await Promise.resolve() - expect(isBrowserAutomationVisible('page-hung-paint')).toBe(false) + expect(isBrowserAutomationVisible('page-hung-paint')).toBe(true) + + await vi.advanceTimersByTimeAsync(2_000) + await expect(acquirePromise).resolves.toBeNull() + + expect(isBrowserAutomationVisible('page-hung-paint')).toBe(false) + } finally { + vi.useRealTimers() + } }) }) diff --git a/src/renderer/src/components/browser-pane/browser-automation-visibility.ts b/src/renderer/src/components/browser-pane/browser-automation-visibility.ts index 1e2510e5b7f..9ffede560e8 100644 --- a/src/renderer/src/components/browser-pane/browser-automation-visibility.ts +++ b/src/renderer/src/components/browser-pane/browser-automation-visibility.ts @@ -18,6 +18,7 @@ const listeners = new Set<() => void>() let version = 0 let nextLeaseId = 0 +const AUTOMATION_VISIBILITY_PAINT_TIMEOUT_MS = 2_000 function emitChange(): void { version += 1 @@ -48,6 +49,25 @@ function nextAnimationFrame(): Promise { return new Promise((resolve) => window.requestAnimationFrame(() => resolve())) } +async function waitForAutomationVisiblePaint(): Promise { + let timeout: ReturnType | null = null + const paint = (async () => { + await nextAnimationFrame() + await nextAnimationFrame() + return true + })() + const timedOut = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), AUTOMATION_VISIBILITY_PAINT_TIMEOUT_MS) + }) + try { + return await Promise.race([paint, timedOut]) + } finally { + if (timeout !== null) { + clearTimeout(timeout) + } + } +} + export function isBrowserAutomationVisible(browserPageId: string): boolean { return (leaseCountsByPageId.get(browserPageId) ?? 0) > 0 } @@ -107,11 +127,15 @@ async function acquireForMainProcess(browserPageId: string): Promise