diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 94809f0d10cfa..2e43e4a7f99c3 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -964,6 +964,40 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); + it.effect("paints a hidden guest the agent drives until its turn ends", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const painting = yield* watchPainting(host); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + yield* turn(true); + // Playwright's actionability check awaits the page's next frame. + yield* command(host, 1, "Runtime.callFunctionOn"); + yield* settle; + expect(painting).toEqual([true]); + + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(painting).toEqual([true]); + + yield* turn(false); + expect(painting).toEqual([true, false]); + + // Outside a turn it paints until the agent goes idle. + yield* command(host, 2, "Input.dispatchMouseEvent"); + yield* settle; + debuggee.release(); + yield* settle; + expect(painting).toEqual([true, false, true]); + yield* TestClock.adjust("1 minute"); + expect(painting).toEqual([true, false, true, false]); + }).pipe(Effect.scoped), + ); + it.effect("stops painting for a screenshot Chromium never answers once its document goes", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index a53fc0e3e6ec2..6c6fad3be4ad5 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -225,20 +225,22 @@ export const make = Effect.gen(function* () { // The agent pauses to think between steps; its tab stays focused until the turn ends. if (tab.agentUsed && agentTurns.has(tab.key.threadId)) return; tab.agentUsed = false; + syncPainting(tab); if (tabs.get(keyOf(tab.key)) === tab) setFocusEmulation(tab, false); }), ); }; /** - * Electron composites no offscreen guest, so its screenshot or screencast waits for a frame - * that never comes. The client paints the guest behind the app while one is pending; the - * capture then finishes on its own, and the guest goes back offscreen once none is. + * Electron composites no offscreen guest, so a capture, or agent input that waits on the + * next frame, waits forever. The client paints the guest behind the app while one is pending + * or the agent drives it, and the guest goes back offscreen once neither holds. */ const syncPainting = (tab: AttachedTab) => { const active = tabs.get(keyOf(tab.key)) === tab && - (tab.screencasts.size > 0 || + (tab.agentUsed || + tab.screencasts.size > 0 || tab.capturing.size > 0 || tab.waitingScreenshots.some((waiting) => waiting.live())); if (tab.painting === active) return; @@ -344,6 +346,7 @@ export const make = Effect.gen(function* () { (owner === undefined || !streamingOwners.has(owner)) ) { tab.agentUsed = true; + syncPainting(tab); } // Waits for focus emulation; insertText and other non-CDP input would not queue behind it. const forward = (current?: () => boolean) => {