diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index d6f25d66aa810..1cff9ac4d944b 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -746,6 +746,36 @@ describe("DesktopBrowserHost", () => { }), ); + 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); + // Playwright's actionability check waits for the page's next frame. + yield* command(host, 1, "Runtime.callFunctionOn"); + yield* settle; + expect(painting).toEqual([true]); + + yield* turn(false); + yield* settle; + expect(painting).toEqual([true, false]); + + // 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]); + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + 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 296be2fa6709f..dfb7ab7c8d366 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -231,20 +231,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; @@ -361,6 +363,7 @@ export const make = Effect.gen(function* () { if (owner === undefined || !streamingOwners.has(owner)) { tab.agentUsed = true; tab.agentLedAt = performance.now(); + syncPainting(tab); } else if (method.startsWith("Input.") && !POINTER_TRAVEL.has(params["type"])) { tab.agentLedAt = Number.NEGATIVE_INFINITY; } 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 28edc42d406a8..36f432923e280 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"; @@ -325,8 +325,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 83ed8a813ece4..e010fb8afcc1a 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;