From 3dbbcc037de429f067aa89921d618afd59cce8a3 Mon Sep 17 00:00:00 2001 From: Michael Zhou Date: Thu, 12 Nov 2026 09:26:05 +0800 Subject: [PATCH 1/3] fix(desktop): inactive preview guests stop painting On macOS an inactive preview guest stayed visible while parked offscreen, so animated pages kept their guest renderer and the GPU busy after the preview panel closed (#14730: 228% CPU across four guests, ~10 GB GPU footprint). The exception dated from Electron 43 blanking hidden macOS webviews (#9001). Inactive guests are hidden on every platform now. An agent's turn holds the existing painting lease, so automation still drives a visible guest, and a pending screenshot or stream keeps its guest painted. A hidden guest keeps its timers while the app is on screen, so a parked page still sends its heartbeats, and the guests are throttled again once the window is hidden or minimized. Fixes pingdotgg/t3code#14730 Co-Authored-By: Claude Code --- .../src/preview/DesktopBrowserHost.test.ts | 32 ++++++++ .../desktop/src/preview/DesktopBrowserHost.ts | 18 +++-- apps/desktop/src/window/DesktopWindow.test.ts | 75 +++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 18 +++++ .../ElectronBrowserHost.browser.test.tsx | 16 ++++ apps/web/src/browser/HostedBrowserWebview.tsx | 4 +- .../src/browser/hostedBrowserWebviewStyle.ts | 13 +--- packages/contracts/src/ipc.ts | 2 +- 8 files changed, 158 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 94809f0d10cfa..660492048f2d2 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -721,6 +721,38 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); + it.effect("keeps painting while the agent's turn is using the tab", () => + 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); + yield* command(host, 1, "Runtime.evaluate"); + yield* settle; + expect(painting).toEqual([true]); + + yield* turn(false); + yield* settle; + expect(painting).toEqual([true, false]); + + // A command can arrive before the server announces the turn; the announcement paints its tab. + yield* command(host, 2, "Runtime.evaluate"); + yield* settle; + expect(painting).toEqual([true, false]); + yield* turn(true); + yield* settle; + expect(painting).toEqual([true, false, true]); + + yield* turn(false); + yield* settle; + expect(painting).toEqual([true, false, true, false]); + }).pipe(Effect.scoped), + ); + it.effect("lets a guest throttle when Chromium refuses a viewer's screencast", () => 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..8ae5161dacc62 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -232,15 +232,17 @@ export const make = Effect.gen(function* () { /** * 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. + * that never comes, and an agent command reaches a hidden page that drops its input. The + * client paints the guest behind the app while one is pending or the turn is using the tab; + * the work then finishes, and the guest goes back offscreen once nothing needs it. */ const syncPainting = (tab: AttachedTab) => { const active = tabs.get(keyOf(tab.key)) === tab && (tab.screencasts.size > 0 || tab.capturing.size > 0 || - tab.waitingScreenshots.some((waiting) => waiting.live())); + tab.waitingScreenshots.some((waiting) => waiting.live()) || + (tab.agentUsed && agentTurns.has(tab.key.threadId))); if (tab.painting === active) return; tab.painting = active; runFork(PubSub.publish(painting, { key: tab.key, active })); @@ -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) => { @@ -548,8 +551,13 @@ export const make = Effect.gen(function* () { const command = decodeCommand(line); if (Option.isNone(command)) return; if (command.value.type === "turn") { - if (command.value.active) agentTurns.add(command.value.threadId); - else endAgentTurn(command.value.threadId); + if (command.value.active) { + agentTurns.add(command.value.threadId); + // A command may arrive before the server announces the turn; its tab paints on the announcement. + for (const tab of tabs.values()) { + if (tab.key.threadId === command.value.threadId) syncPainting(tab); + } + } else endAgentTurn(command.value.threadId); return; } const tab = tabs.get(keyOf(command.value)); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 497b315ef5225..d5d476f71624a 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -757,6 +757,81 @@ describe("DesktopWindow", () => { }), ); + it.effect("keeps preview guests on Chromium's background throttling", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.ensureMain; + const attach = fakeWindow.webContentsListeners.get("will-attach-webview"); + if (!attach) { + return yield* Effect.die("will-attach-webview listener was not registered"); + } + + const previewEvent = { preventDefault: vi.fn() }; + const previewPreferences: Record = {}; + attach(previewEvent, previewPreferences, { partition: "persist:t3code-preview-test" }); + assert.equal(previewEvent.preventDefault.mock.calls.length, 0); + // A parked preview keeps its timers while the app is on screen. + assert.strictEqual(previewPreferences.backgroundThrottling, false); + assert.strictEqual(previewPreferences.sandbox, true); + + const refused = { preventDefault: vi.fn() }; + const otherPreferences: Record = {}; + attach(refused, otherPreferences, { partition: "persist:default" }); + assert.equal(refused.preventDefault.mock.calls.length, 1); + assert.isUndefined(otherPreferences.backgroundThrottling); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("throttles preview guests while the app is hidden", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.ensureMain; + const attached = fakeWindow.webContentsListeners.get("did-attach-webview"); + if (!attached) { + return yield* Effect.die("did-attach-webview listener was not registered"); + } + const guest = { + isDestroyed: vi.fn(() => false), + setBackgroundThrottling: vi.fn(), + once: vi.fn(), + on: vi.fn(), + }; + attached({}, guest); + + const hide = fakeWindow.windowListeners.get("hide"); + const show = fakeWindow.windowListeners.get("show"); + if (!hide || !show) { + return yield* Effect.die("the window did not register its visibility listeners"); + } + hide(); + assert.deepEqual(guest.setBackgroundThrottling.mock.calls, [[true]]); + show(); + assert.deepEqual(guest.setBackgroundThrottling.mock.calls, [[true], [false]]); + }).pipe(Effect.provide(layer)); + }), + ); + // Chromium hands the main window's zoom level down to embedded preview // guests, so every app zoom has to put the preview browser back at its own // zoom or zooming the UI drags the previewed page with it. diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 43404e2d6cfb9..edca512ab2198 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -617,6 +617,15 @@ export const make = Effect.gen(function* () { yield* electronWindow.destroyAll; return Option.none(); } + const previewGuests = new Set(); + // An off-screen preview keeps its timers while the app is on screen, so a parked page + // keeps its heartbeats alive. A hidden app must not pay for them. + const setPreviewGuestThrottling = (enabled: boolean) => { + for (const guest of previewGuests) { + if (guest.isDestroyed()) continue; + guest.setBackgroundThrottling(enabled); + } + }; window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if ( typeof params.partition !== "string" || @@ -630,6 +639,8 @@ export const make = Effect.gen(function* () { webPreferences.nodeIntegrationInSubFrames = false; webPreferences.contextIsolation = false; webPreferences.disableHtmlFullscreenWindowResize = true; + // A suspended page's heartbeats keep running while the app is on screen. + webPreferences.backgroundThrottling = false; if (previewPasskeys.bridgeEnabled) { webPreferences.additionalArguments = [ ...(webPreferences.additionalArguments ?? []), @@ -643,6 +654,10 @@ export const make = Effect.gen(function* () { ]; } }); + window.on("hide", () => setPreviewGuestThrottling(true)); + window.on("show", () => setPreviewGuestThrottling(false)); + window.on("minimize", () => setPreviewGuestThrottling(true)); + window.on("restore", () => setPreviewGuestThrottling(false)); const contextMenuContents = new WeakSet(); const installContextMenu = ( @@ -735,6 +750,9 @@ export const make = Effect.gen(function* () { }; installContextMenu(window, window.webContents); window.webContents.on("did-attach-webview", (_event, contents) => { + previewGuests.add(contents); + contents.once("destroyed", () => previewGuests.delete(contents)); + if (!window.isVisible()) contents.setBackgroundThrottling(true); installContextMenu(window, contents); void runPromise(previewManager.prepareWebview(contents)); }); diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 29c217e064adc..4ba3d707494e1 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -160,4 +160,20 @@ describe("ElectronBrowserHost", () => { expect(after).toHaveLength(2); expect(after.every((node, index) => node.isSameNode(before[index]!))).toBe(true); }); + + it("hides an inactive guest on macOS instead of keeping it paintable", async () => { + using _state = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + reconcilePreviewServerSessions(threadRef, { + sessions: [snapshot("tab_1", "2026-01-01T00:00:01.000Z")], + serverEpoch: "server-a", + revision: 1, + }); + await using screen = await renderHost(); + + const wrapper = screen.container.querySelector("[data-preview-viewport]"); + expect(wrapper).not.toBeNull(); + expect(wrapper?.dataset.previewRendering).toBe("suspended"); + expect(wrapper?.style.visibility).toBe("hidden"); + }); }); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 44de1196b2b7c..e0b71beb0508b 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -13,7 +13,7 @@ import { } from "~/components/preview/thumbButtons"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { useClientSettingsHydrated } from "~/hooks/useSettings"; -import { cn, isMacPlatform } from "~/lib/utils"; +import { cn } from "~/lib/utils"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; import { useActiveBrowserRecordingTabIds } from "./browserRecording"; @@ -316,8 +316,6 @@ export function HostedBrowserWebview(props: { const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ active, renderingActive, - // Electron 43 can permanently blank a macOS webview after `visibility: hidden`. - keepPaintableWhenInactive: isMacPlatform(navigator.platform), cornerRadius: presentation.cornerRadius, zIndex: presentation.zIndex, rect: lastRect, diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index f37c222b08e9c..628ff302e1d3a 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -21,21 +21,12 @@ const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly active: boolean; readonly renderingActive: boolean; - readonly keepPaintableWhenInactive?: boolean; readonly cornerRadius?: number; readonly zIndex?: number; readonly rect: BrowserSurfaceRect | null; readonly hiddenSize: HostedBrowserWebviewSize; }): HostedBrowserWebviewWrapperStyle { - const { - active, - cornerRadius = 0, - hiddenSize, - keepPaintableWhenInactive = false, - rect, - renderingActive, - zIndex = 30, - } = input; + const { active, cornerRadius = 0, hiddenSize, rect, renderingActive, zIndex = 30 } = input; if (active && rect) { return { left: rect.x, @@ -68,6 +59,6 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { height: hiddenSize.height, zIndex: -1, pointerEvents: "none", - visibility: keepPaintableWhenInactive ? "visible" : "hidden", + visibility: "hidden", }; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 14e58a42a9fa1..4870738a23371 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -610,7 +610,7 @@ export interface DesktopPreviewTabState { /** Chromium reports playback even when audioMuted is true. */ audible: boolean; controller: "human" | "agent" | "none"; - /** A screenshot or stream of the guest is pending, so the client keeps it painting while hidden. */ + /** A screenshot, stream, or agent command needs the guest, so the client keeps it painting while hidden. */ capturing: boolean; favicon?: DesktopPreviewFavicon; updatedAt: string; From 20c4d9a51b90a762fc2fe84727b142ce69e63b62 Mon Sep 17 00:00:00 2001 From: Michael Zhou Date: Fri, 13 Nov 2026 01:06:42 +0800 Subject: [PATCH 2/3] fix(desktop): an agent's turn paints its tab before the first command A guest the client keeps hidden drops the agent's input, so the painting lease now starts when the turn is announced for a tab the agent owns: a tab it has used, or one that appeared while its thread's agent ran. Co-Authored-By: Claude Code --- .../src/preview/DesktopBrowserHost.test.ts | 50 +++++++++++++++++++ .../desktop/src/preview/DesktopBrowserHost.ts | 13 +++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 660492048f2d2..7b2bdcf6155b6 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -753,6 +753,56 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); + it.effect("paints a tab the agent owns before a later turn's first command", () => + 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); + yield* command(host, 1, "Runtime.evaluate"); + yield* settle; + expect(painting).toEqual([true]); + + yield* turn(false); + yield* settle; + expect(painting).toEqual([true, false]); + + // The next turn paints the tab before any command, so the command finds a live guest. + yield* turn(true); + yield* settle; + expect(painting).toEqual([true, false, true]); + + yield* turn(false); + yield* settle; + expect(painting).toEqual([true, false, true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("paints a tab the agent opens during its turn", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + const painting = yield* watchPainting(host); + + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: true }), + ); + host.attach(key, debuggee.tab); + yield* settle; + expect(painting).toEqual([true]); + + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: false }), + ); + yield* settle; + expect(painting).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + it.effect("lets a guest throttle when Chromium refuses a viewer's screencast", () => 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 8ae5161dacc62..c97c77257a42a 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -74,6 +74,8 @@ interface AttachedTab { * turn's start can reach us after its first command, so this holds focus once it does. */ agentUsed: boolean; + /** Whether the agent owns this tab, so a turn paints it before that turn's first command. */ + agentOwned: boolean; focusReleasing: boolean; /** The sessions streaming the guest; one stopping leaves the others' screencasts running. */ readonly screencasts: Set; @@ -233,8 +235,8 @@ export const make = Effect.gen(function* () { /** * Electron composites no offscreen guest, so its screenshot or screencast waits for a frame * that never comes, and an agent command reaches a hidden page that drops its input. The - * client paints the guest behind the app while one is pending or the turn is using the tab; - * the work then finishes, and the guest goes back offscreen once nothing needs it. + * client paints the guest behind the app while one is pending or while the agent owns the + * tab and its thread runs; the guest goes back offscreen once nothing needs it. */ const syncPainting = (tab: AttachedTab) => { const active = @@ -242,7 +244,7 @@ export const make = Effect.gen(function* () { (tab.screencasts.size > 0 || tab.capturing.size > 0 || tab.waitingScreenshots.some((waiting) => waiting.live()) || - (tab.agentUsed && agentTurns.has(tab.key.threadId))); + (tab.agentOwned && agentTurns.has(tab.key.threadId))); if (tab.painting === active) return; tab.painting = active; runFork(PubSub.publish(painting, { key: tab.key, active })); @@ -346,6 +348,7 @@ export const make = Effect.gen(function* () { (owner === undefined || !streamingOwners.has(owner)) ) { tab.agentUsed = true; + tab.agentOwned = true; syncPainting(tab); } // Waits for focus emulation; insertText and other non-CDP input would not queue behind it. @@ -465,6 +468,8 @@ export const make = Effect.gen(function* () { capturing: new Set(), painting: false, agentUsed: false, + // A tab that appears while the thread's agent runs is the agent's own. + agentOwned: agentTurns.has(key.threadId), focusReleasing: false, screencasts: new Set(), onMessage: (_event, method, params, sessionId) => { @@ -485,6 +490,8 @@ export const make = Effect.gen(function* () { stopWaiting: null, }; tabs.set(id, tab); + // A tab the agent opened during its turn paints from the start, before its first command. + syncPainting(tab); debuggee.debugger.on("message", tab.onMessage); debuggee.webContents.on("did-navigate", tab.onNewDocument); const announce = () => { From d4a8ca3c226b1ec840f9e0746aeddbd5417c385e Mon Sep 17 00:00:00 2001 From: Michael Zhou Date: Fri, 13 Nov 2026 01:25:36 +0800 Subject: [PATCH 3/3] fix(desktop): match the maintainer's paint lease for the agent's guest The host now paints a guest while the agent drives it and stops when the agent goes idle, which is the change #35160 carries for the platforms that already hide inactive guests. This branch hides macOS guests too, so it carries the same two lines and the two converge. Co-Authored-By: Claude Code --- .../src/preview/DesktopBrowserHost.test.ts | 64 ++----------------- .../desktop/src/preview/DesktopBrowserHost.ts | 30 +++------ 2 files changed, 15 insertions(+), 79 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 7b2bdcf6155b6..a3e9d31bb5000 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -731,39 +731,8 @@ describe("DesktopBrowserHost", () => { host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); yield* turn(true); - yield* command(host, 1, "Runtime.evaluate"); - yield* settle; - expect(painting).toEqual([true]); - - yield* turn(false); - yield* settle; - expect(painting).toEqual([true, false]); - - // A command can arrive before the server announces the turn; the announcement paints its tab. - yield* command(host, 2, "Runtime.evaluate"); - yield* settle; - expect(painting).toEqual([true, false]); - yield* turn(true); - yield* settle; - expect(painting).toEqual([true, false, true]); - - yield* turn(false); - yield* settle; - expect(painting).toEqual([true, false, true, false]); - }).pipe(Effect.scoped), - ); - - it.effect("paints a tab the agent owns before a later turn's first command", () => - 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); - yield* command(host, 1, "Runtime.evaluate"); + // Playwright's actionability check waits for the page's next frame. + yield* command(host, 1, "Runtime.callFunctionOn"); yield* settle; expect(painting).toEqual([true]); @@ -771,38 +740,17 @@ describe("DesktopBrowserHost", () => { yield* settle; expect(painting).toEqual([true, false]); - // The next turn paints the tab before any command, so the command finds a live guest. - yield* turn(true); + // Outside a turn the guest paints until the agent goes idle. + yield* command(host, 2, "Input.dispatchMouseEvent"); yield* settle; expect(painting).toEqual([true, false, true]); - - yield* turn(false); + debuggee.release(); yield* settle; + yield* TestClock.adjust("1 minute"); expect(painting).toEqual([true, false, true, false]); }).pipe(Effect.scoped), ); - it.effect("paints a tab the agent opens during its turn", () => - Effect.gen(function* () { - const host = yield* DesktopBrowserHost.make; - const debuggee = makeDebuggee(); - const painting = yield* watchPainting(host); - - yield* host.handleCommandLine( - encodeJson({ type: "turn", threadId: key.threadId, active: true }), - ); - host.attach(key, debuggee.tab); - yield* settle; - expect(painting).toEqual([true]); - - yield* host.handleCommandLine( - encodeJson({ type: "turn", threadId: key.threadId, active: false }), - ); - yield* settle; - expect(painting).toEqual([true, false]); - }).pipe(Effect.scoped), - ); - it.effect("lets a guest throttle when Chromium refuses a viewer's screencast", () => 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 c97c77257a42a..6c6fad3be4ad5 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -74,8 +74,6 @@ interface AttachedTab { * turn's start can reach us after its first command, so this holds focus once it does. */ agentUsed: boolean; - /** Whether the agent owns this tab, so a turn paints it before that turn's first command. */ - agentOwned: boolean; focusReleasing: boolean; /** The sessions streaming the guest; one stopping leaves the others' screencasts running. */ readonly screencasts: Set; @@ -227,24 +225,24 @@ 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, and an agent command reaches a hidden page that drops its input. The - * client paints the guest behind the app while one is pending or while the agent owns the - * tab and its thread runs; the guest goes back offscreen once nothing needs it. + * 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()) || - (tab.agentOwned && agentTurns.has(tab.key.threadId))); + tab.waitingScreenshots.some((waiting) => waiting.live())); if (tab.painting === active) return; tab.painting = active; runFork(PubSub.publish(painting, { key: tab.key, active })); @@ -348,7 +346,6 @@ export const make = Effect.gen(function* () { (owner === undefined || !streamingOwners.has(owner)) ) { tab.agentUsed = true; - tab.agentOwned = true; syncPainting(tab); } // Waits for focus emulation; insertText and other non-CDP input would not queue behind it. @@ -468,8 +465,6 @@ export const make = Effect.gen(function* () { capturing: new Set(), painting: false, agentUsed: false, - // A tab that appears while the thread's agent runs is the agent's own. - agentOwned: agentTurns.has(key.threadId), focusReleasing: false, screencasts: new Set(), onMessage: (_event, method, params, sessionId) => { @@ -490,8 +485,6 @@ export const make = Effect.gen(function* () { stopWaiting: null, }; tabs.set(id, tab); - // A tab the agent opened during its turn paints from the start, before its first command. - syncPainting(tab); debuggee.debugger.on("message", tab.onMessage); debuggee.webContents.on("did-navigate", tab.onNewDocument); const announce = () => { @@ -558,13 +551,8 @@ export const make = Effect.gen(function* () { const command = decodeCommand(line); if (Option.isNone(command)) return; if (command.value.type === "turn") { - if (command.value.active) { - agentTurns.add(command.value.threadId); - // A command may arrive before the server announces the turn; its tab paints on the announcement. - for (const tab of tabs.values()) { - if (tab.key.threadId === command.value.threadId) syncPainting(tab); - } - } else endAgentTurn(command.value.threadId); + if (command.value.active) agentTurns.add(command.value.threadId); + else endAgentTurn(command.value.threadId); return; } const tab = tabs.get(keyOf(command.value));