Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions apps/desktop/src/preview/DesktopBrowserHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 7 additions & 4 deletions apps/desktop/src/preview/DesktopBrowserHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
75 changes: 75 additions & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.Option<Electron.BrowserWindow>>(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<string, unknown> = {};
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<string, unknown> = {};
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.Option<Electron.BrowserWindow>>(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.
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,15 @@ export const make = Effect.gen(function* () {
yield* electronWindow.destroyAll;
return Option.none();
}
const previewGuests = new Set<Electron.WebContents>();
// 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" ||
Expand All @@ -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 ?? []),
Expand All @@ -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<Electron.WebContents>();
const installContextMenu = (
Expand Down Expand Up @@ -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));
});
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/browser/ElectronBrowserHost.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>("[data-preview-viewport]");
expect(wrapper).not.toBeNull();
expect(wrapper?.dataset.previewRendering).toBe("suspended");
expect(wrapper?.style.visibility).toBe("hidden");
});
});
4 changes: 1 addition & 3 deletions apps/web/src/browser/HostedBrowserWebview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 2 additions & 11 deletions apps/web/src/browser/hostedBrowserWebviewStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -68,6 +59,6 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: {
height: hiddenSize.height,
zIndex: -1,
pointerEvents: "none",
visibility: keepPaintableWhenInactive ? "visible" : "hidden",
visibility: "hidden",
};
}
2 changes: 1 addition & 1 deletion packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading