diff --git a/apps/desktop/scripts/verify-background-preview.electron.cjs b/apps/desktop/scripts/verify-background-preview.electron.cjs
index 54a847a0189a0..eb28b1b227fb2 100644
--- a/apps/desktop/scripts/verify-background-preview.electron.cjs
+++ b/apps/desktop/scripts/verify-background-preview.electron.cjs
@@ -6,6 +6,10 @@ const NodePath = require("node:path");
const NodeURL = require("node:url");
const { app, BrowserWindow, nativeImage } = require("electron");
+const testHome = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-background-preview-"));
+app.setPath("userData", NodePath.join(testHome, "userdata"));
+app.on("quit", () => NodeFS.rmSync(testHome, { recursive: true, force: true }));
+
const CAPTURE_TIMEOUT_MS = 5_000;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const withTimeout = (promise, ms) =>
@@ -36,10 +40,7 @@ async function main() {
const { backgroundTabWindowOptions } = await import(
NodeURL.pathToFileURL(NodePath.join(__dirname, "../src/preview/BackgroundTabWindow.ts")).href
);
- const preload = NodePath.join(
- NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-background-preview-")),
- "preload.cjs",
- );
+ const preload = NodePath.join(testHome, "preload.cjs");
NodeFS.writeFileSync(preload, "");
// Each state closes its windows, which would otherwise quit the app.
app.on("window-all-closed", () => {});
@@ -47,6 +48,7 @@ async function main() {
const failures = [];
for (const state of ["hidden", "minimized"]) {
+ console.log(`Checking app window ${state}`);
const appWindow = new BrowserWindow({ width: 480, height: 320, show: true });
await appWindow.loadURL(page("
T3 Code"));
await sleep(500);
@@ -63,6 +65,7 @@ async function main() {
}),
);
await tabWindow.loadURL(page(""));
+ console.log(`Background tab loaded (${state})`);
const tab = tabWindow.webContents;
tab.debugger.attach("1.3");
for (const [step, rgb] of [
@@ -71,6 +74,7 @@ async function main() {
[3, [0, 200, 200]],
]) {
const failure = await captureColor(tab, rgb);
+ console.log(`App window ${state}, capture ${step}: ${failure ?? "fresh"}`);
if (failure) failures.push(`app window ${state}, capture ${step}: ${failure}`);
await sleep(1_000);
}
diff --git a/apps/desktop/src/preview/BackgroundTabWindow.ts b/apps/desktop/src/preview/BackgroundTabWindow.ts
index f9ceb472caf97..5b404e74ceab1 100644
--- a/apps/desktop/src/preview/BackgroundTabWindow.ts
+++ b/apps/desktop/src/preview/BackgroundTabWindow.ts
@@ -11,7 +11,8 @@ const BACKGROUND_TAB_HEIGHT = 800;
/**
* An agent tab's never-shown window. A panel `` that attaches while
* the app window is hidden or minimized never paints, so its screenshots never
- * return; a window of its own paints in every app window state.
+ * return. Offscreen rendering also avoids dependence on a mapped Wayland
+ * surface: a regular never-shown BrowserWindow can still stall captures there.
*/
export function backgroundTabWindowOptions(input: {
readonly partition: string;
@@ -34,6 +35,7 @@ export function backgroundTabWindowOptions(input: {
contextIsolation: false,
disableHtmlFullscreenWindowResize: true,
backgroundThrottling: false,
+ offscreen: true,
...(input.passkeyBridgeArgument === undefined
? {}
: { additionalArguments: [input.passkeyBridgeArgument] }),
diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts
index 6b67b010a57c9..f3f150a67fb36 100644
--- a/apps/desktop/src/preview/Manager.test.ts
+++ b/apps/desktop/src/preview/Manager.test.ts
@@ -600,9 +600,9 @@ describe("PreviewManager", () => {
),
);
- effectIt.effect(
- "hosts an agent tab in a never-shown window that paints and closes with the tab",
- () =>
+ effectIt.effect.each([undefined, "https://example.com/agent"])(
+ "hosts an agent tab in a never-shown window that initializes before attaching (%s)",
+ (url) =>
withManager((manager) =>
Effect.gen(function* () {
const tab = makeTestPreviewWebContents(
@@ -613,8 +613,20 @@ describe("PreviewManager", () => {
55,
null as unknown as TestHostWebContents,
);
- const loadURL = vi.fn(async () => undefined);
- Object.assign(tab, { getType: () => "window", once: vi.fn(), loadURL, reload: vi.fn() });
+ const firstDocument = Promise.withResolvers();
+ const debuggerStarted = Promise.withResolvers();
+ const loadURL = vi.fn(async () => firstDocument.resolve());
+ tab.debugger.sendCommand = vi.fn(async () => {
+ debuggerStarted.resolve();
+ await firstDocument.promise;
+ });
+ Object.assign(tab, {
+ getType: () => "window",
+ isDevToolsOpened: () => false,
+ once: vi.fn(),
+ loadURL,
+ reload: vi.fn(),
+ });
const window = {
webContents: tab,
once: vi.fn(),
@@ -630,11 +642,18 @@ describe("PreviewManager", () => {
});
fromId.mockReturnValue(tab);
- const state = yield* manager.createTab(
- "tab_agent",
- { serverTab: { threadId: "thread", tabId: "agent" } },
- { partition: "persist:t3code-preview-test", url: "https://example.com/agent" },
- );
+ const opening = yield* manager
+ .createTab(
+ "tab_agent",
+ { serverTab: { threadId: "thread", tabId: "agent" } },
+ { partition: "persist:t3code-preview-test", ...(url === undefined ? {} : { url }) },
+ )
+ .pipe(Effect.forkScoped);
+ yield* Effect.promise(() => debuggerStarted.promise);
+ const loadedBeforeDebugger = loadURL.mock.calls.length > 0;
+ firstDocument.resolve();
+ expect(loadedBeforeDebugger).toBe(true);
+ const state = yield* Fiber.join(opening);
expect(options).toMatchObject({
show: false,
@@ -642,10 +661,11 @@ describe("PreviewManager", () => {
webPreferences: {
partition: "persist:t3code-preview-test",
backgroundThrottling: false,
+ offscreen: true,
},
});
expect(state.webContentsId).toBe(55);
- expect(loadURL).toHaveBeenCalledWith("https://example.com/agent");
+ expect(loadURL).toHaveBeenCalledWith(url ?? "about:blank");
yield* manager.closeTab("tab_agent");
expect(window.destroy).toHaveBeenCalledOnce();
}),
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
index 70ce945061320..f5cdac5118fd6 100644
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -1775,6 +1775,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.on("render-process-gone", () => {
if (!wc.isDestroyed()) wc.reload();
});
+ // A new BrowserWindow has no document. Its debugger's background-color
+ // command waits for one, so initialize a local page before attaching.
+ yield* attemptPromise(
+ { operation: "backgroundWindow.initialize", tabId, webContentsId: wc.id },
+ () => wc.loadURL("about:blank"),
+ );
yield* prepareWebview(wc);
yield* registerWebviewUnlocked(tabId, wc.id, tabLifecycleGenerations.get(tabId));
if (background.url !== undefined) {