From 6a3a2cd473c404b17eaaa97a472080fad23b58dc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 00:38:04 -0700 Subject: [PATCH 1/8] fix(desktop): agent preview tabs keep working while T3 is hidden A guest that attaches while the app window is hidden or minimized never paints, and one parked off-screen stops painting, so agent snapshots waited on a frame that never came. Agent-opened tabs now live in a never-shown window of their own, which paints in every window state; the panel streams them like any other server tab. Co-Authored-By: Claude Opus 5.5 --- .github/actions/ci-changes/action.yml | 2 + .github/scripts/ci-changes.cjs | 6 + .github/workflows/ci.yml | 34 +++++ .../verify-background-preview.electron.cjs | 94 ++++++++++++++ .../scripts/verify-background-preview.mjs | 27 ++++ apps/desktop/src/ipc/methods/preview.ts | 18 ++- apps/desktop/src/preload.ts | 1 + .../src/preview/BackgroundTabWindow.ts | 42 +++++++ .../src/preview/DesktopBrowserHost.test.ts | 12 +- .../desktop/src/preview/DesktopBrowserHost.ts | 19 ++- apps/desktop/src/preview/Manager.test.ts | 52 ++++++++ apps/desktop/src/preview/Manager.ts | 87 ++++++++++++- .../src/preview/DesktopBrowserChannel.ts | 9 +- apps/server/src/preview/ServerBrowser.test.ts | 25 ++++ apps/server/src/preview/ServerBrowser.ts | 35 ++++-- .../ElectronBrowserHost.browser.test.tsx | 36 ++++++ apps/web/src/browser/ElectronBrowserHost.tsx | 116 ++++++++++++++---- apps/web/src/browser/desktopTabLifetime.ts | 4 + apps/web/src/browser/previewRuntime.ts | 36 +++++- packages/contracts/src/desktopBrowser.ts | 10 +- packages/contracts/src/ipc.ts | 18 +++ packages/contracts/src/previewAutomation.ts | 6 +- 22 files changed, 627 insertions(+), 62 deletions(-) create mode 100644 apps/desktop/scripts/verify-background-preview.electron.cjs create mode 100644 apps/desktop/scripts/verify-background-preview.mjs create mode 100644 apps/desktop/src/preview/BackgroundTabWindow.ts diff --git a/.github/actions/ci-changes/action.yml b/.github/actions/ci-changes/action.yml index ecf787404657a..b531653fab4b7 100644 --- a/.github/actions/ci-changes/action.yml +++ b/.github/actions/ci-changes/action.yml @@ -19,6 +19,8 @@ outputs: value: ${{ steps.detect.outputs.release_smoke }} mobile_native: value: ${{ steps.detect.outputs.mobile_native }} + desktop_preview: + value: ${{ steps.detect.outputs.desktop_preview }} runs: using: composite steps: diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index 6a7fc4a6d5b5f..fd32f99e0db17 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -64,6 +64,11 @@ const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] }; const MOBILE_NATIVE = /^apps\/mobile\/.*\.(swift|kt|kts)$|^apps\/mobile\/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts\/mobile-native-static-check\.ts$|^package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//; +// Real-Electron check that agent preview tabs paint with the app window hidden. +// Electron is pinned in the desktop manifest, so its upgrades rerun it too. +const DESKTOP_PREVIEW = + /^apps\/desktop\/src\/preview\/|^apps\/desktop\/scripts\/verify-background-preview|^apps\/desktop\/package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//; + // Jobs that test every package except these, which have jobs of their own. const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]); @@ -181,6 +186,7 @@ function decide(files, packages, full) { rust: everything || rust, release_smoke: scriptsChanged, mobile_native: files === null || files.some((file) => MOBILE_NATIVE.test(file)), + desktop_preview: files === null || files.some((file) => DESKTOP_PREVIEW.test(file)), }, }; } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d29d970c75ff1..d600877437794 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ jobs: rust: ${{ steps.detect.outputs.rust }} release_smoke: ${{ steps.detect.outputs.release_smoke }} mobile_native: ${{ steps.detect.outputs.mobile_native }} + desktop_preview: ${{ steps.detect.outputs.desktop_preview }} steps: # Only the package manifests the script reads to build the dependency graph. # Depth 2 brings the base commit the change is diffed against. @@ -470,6 +471,38 @@ jobs: - name: Lint mobile native sources run: vp run lint:mobile + # Unit tests mock Electron, so only a real one with a real window manager can + # prove agent preview tabs still paint while the app window is hidden. + desktop_preview: + name: Desktop Background Preview + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.desktop_preview != 'false' }} + runs-on: blacksmith-6vcpu-macos-26 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/desktop + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + + - name: Verify background preview tabs paint + run: node apps/desktop/scripts/verify-background-preview.mjs + release_smoke: name: Release Smoke needs: changes @@ -510,6 +543,7 @@ jobs: test_server, rust, mobile_native_static_analysis, + desktop_preview, release_smoke, ] runs-on: blacksmith-2vcpu-ubuntu-2404 diff --git a/apps/desktop/scripts/verify-background-preview.electron.cjs b/apps/desktop/scripts/verify-background-preview.electron.cjs new file mode 100644 index 0000000000000..54a847a0189a0 --- /dev/null +++ b/apps/desktop/scripts/verify-background-preview.electron.cjs @@ -0,0 +1,94 @@ +// Electron entry for verify-background-preview.mjs. CommonJS, because an ESM +// entry delays `ready` until its top-level await settles. +const NodeFS = require("node:fs"); +const NodeOS = require("node:os"); +const NodePath = require("node:path"); +const NodeURL = require("node:url"); +const { app, BrowserWindow, nativeImage } = require("electron"); + +const CAPTURE_TIMEOUT_MS = 5_000; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const withTimeout = (promise, ms) => + Promise.race([ + promise.then((value) => ({ ok: true, value })), + sleep(ms).then(() => ({ ok: false })), + ]); +const page = (html) => `data:text/html,${encodeURIComponent(html)}`; + +async function captureColor(tab, rgb) { + await tab.debugger.sendCommand("Runtime.evaluate", { + expression: `document.body.style.background = 'rgb(${rgb.join(",")})'`, + }); + const shot = await withTimeout( + tab.debugger.sendCommand("Page.captureScreenshot", { format: "png" }), + CAPTURE_TIMEOUT_MS, + ); + if (!shot.ok) return "timed out"; + const pixels = nativeImage.createFromBuffer(Buffer.from(shot.value.data, "base64")).toBitmap(); + const got = [pixels[2], pixels[1], pixels[0]]; + // Color management can shift a channel by a few levels. + return got.every((value, index) => Math.abs(value - rgb[index]) <= 6) + ? null + : `stale: rgb(${got.join(",")})`; +} + +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", + ); + NodeFS.writeFileSync(preload, ""); + // Each state closes its windows, which would otherwise quit the app. + app.on("window-all-closed", () => {}); + await app.whenReady(); + + const failures = []; + for (const state of ["hidden", "minimized"]) { + const appWindow = new BrowserWindow({ width: 480, height: 320, show: true }); + await appWindow.loadURL(page("T3 Code")); + await sleep(500); + if (state === "hidden") appWindow.hide(); + else appWindow.minimize(); + await sleep(1_500); + + // Created only after the app window is gone, as an agent's tab would be. + const tabWindow = new BrowserWindow( + backgroundTabWindowOptions({ + partition: `t3-background-preview-${state}`, + preload, + passkeyBridgeArgument: undefined, + }), + ); + await tabWindow.loadURL(page("")); + const tab = tabWindow.webContents; + tab.debugger.attach("1.3"); + for (const [step, rgb] of [ + [1, [0, 200, 0]], + [2, [200, 200, 0]], + [3, [0, 200, 200]], + ]) { + const failure = await captureColor(tab, rgb); + if (failure) failures.push(`app window ${state}, capture ${step}: ${failure}`); + await sleep(1_000); + } + tabWindow.destroy(); + appWindow.destroy(); + } + + if (failures.length > 0) { + console.error("Background preview tabs stopped painting:"); + for (const failure of failures) console.error(` - ${failure}`); + app.exit(1); + return; + } + console.log("Background preview tabs paint while the app window is hidden or minimized."); + app.exit(0); +} + +main().catch((error) => { + console.error(error); + app.exit(1); +}); diff --git a/apps/desktop/scripts/verify-background-preview.mjs b/apps/desktop/scripts/verify-background-preview.mjs new file mode 100644 index 0000000000000..1907efb3e4a31 --- /dev/null +++ b/apps/desktop/scripts/verify-background-preview.mjs @@ -0,0 +1,27 @@ +// Proves agent preview tabs still paint while the app window is hidden or +// minimized, using the exact window options the preview manager ships. +// Agents open tabs whenever they like; a tab that cannot paint makes every +// screenshot of it time out. Unit tests mock Electron, so only a real one can +// catch this. +import * as NodeChildProcess from "node:child_process"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const RUN_TIMEOUT_MS = 90_000; +const entry = NodePath.join( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "verify-background-preview.electron.cjs", +); +// The plain binary: the development launcher's renamed app bundle refuses data: URLs. +const electronPath = NodeModule.createRequire(import.meta.url)("electron"); +const { ELECTRON_RUN_AS_NODE: _ignored, ...env } = process.env; +const child = NodeChildProcess.spawn(electronPath, [entry], { stdio: "inherit", env }); +const timer = setTimeout(() => { + console.error(`Background preview check did not finish within ${RUN_TIMEOUT_MS}ms.`); + child.kill("SIGKILL"); +}, RUN_TIMEOUT_MS); +child.on("exit", (code, signal) => { + clearTimeout(timer); + process.exit(code ?? (signal ? 1 : 0)); +}); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index b659054540c23..985d1bfa7177c 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -80,9 +80,25 @@ const createTab = DesktopIpc.makeIpcMethod({ zoomFactor, colorScheme, serverTab, + background, }) { const manager = yield* PreviewManager.PreviewManager; - yield* manager.createTab(tabId, { zoomFactor, colorScheme, serverTab }); + if (background === undefined) { + yield* manager.createTab(tabId, { zoomFactor, colorScheme, serverTab }); + return; + } + const { scope, persistent, namespace } = resolvePartitionScope( + background.environmentId, + background.profileId, + ); + // Loading the session installs its UA rewrite and permission handlers. + yield* manager.getBrowserSession(scope, persistent, namespace); + const partition = yield* manager.getBrowserPartition(scope, persistent, namespace); + yield* manager.createTab( + tabId, + { zoomFactor, colorScheme, serverTab }, + { partition, url: background.url }, + ); }), }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index bffa02d9ca435..aec27911c40a0 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -327,6 +327,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { zoomFactor: defaults?.zoomFactor, serverTab: defaults?.serverTab, colorScheme: defaults?.colorScheme, + background: defaults?.background, }), closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }), registerWebview: (tabId, webContentsId) => diff --git a/apps/desktop/src/preview/BackgroundTabWindow.ts b/apps/desktop/src/preview/BackgroundTabWindow.ts new file mode 100644 index 0000000000000..f9ceb472caf97 --- /dev/null +++ b/apps/desktop/src/preview/BackgroundTabWindow.ts @@ -0,0 +1,42 @@ +import type { BrowserWindowConstructorOptions } from "electron"; + +// Kept free of runtime imports: scripts/verify-background-preview.mjs loads it +// straight into Electron to prove these options still paint while the app +// window is hidden. + +// The server sizes the page through emulation; this only seeds the window. +const BACKGROUND_TAB_WIDTH = 1280; +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. + */ +export function backgroundTabWindowOptions(input: { + readonly partition: string; + readonly preload: string; + readonly passkeyBridgeArgument: string | undefined; +}): BrowserWindowConstructorOptions { + return { + width: BACKGROUND_TAB_WIDTH, + height: BACKGROUND_TAB_HEIGHT, + show: false, + paintWhenInitiallyHidden: true, + skipTaskbar: true, + webPreferences: { + partition: input.partition, + preload: input.preload, + // Mirrors `will-attach-webview` for panel guests. + sandbox: true, + nodeIntegration: false, + nodeIntegrationInSubFrames: false, + contextIsolation: false, + disableHtmlFullscreenWindowResize: true, + backgroundThrottling: false, + ...(input.passkeyBridgeArgument === undefined + ? {} + : { additionalArguments: [input.passkeyBridgeArgument] }), + }, + }; +} diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 04dcd22cf3b2c..762c7d3a3e2e2 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -55,10 +55,10 @@ describe("DesktopBrowserHost", () => { it.effect("announces tabs already attached to a backend that starts later", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; - host.attach(key, makeDebuggee().tab); + host.attach(key, makeDebuggee().tab, false); - expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]); - expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]); + expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key, background: false }]); + expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key, background: false }]); }), ); @@ -66,7 +66,7 @@ describe("DesktopBrowserHost", () => { Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); - host.attach(key, debuggee.tab); + host.attach(key, debuggee.tab, false); const reader = yield* takeEvents(host, 2).pipe(Effect.forkScoped); yield* Effect.yieldNow; @@ -93,7 +93,7 @@ describe("DesktopBrowserHost", () => { Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); - host.attach(key, debuggee.tab); + host.attach(key, debuggee.tab, false); const paths: Array = []; const item = { setSavePath: (path: string) => void paths.push(path), @@ -121,7 +121,7 @@ describe("DesktopBrowserHost", () => { Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); - host.attach(key, debuggee.tab); + host.attach(key, debuggee.tab, false); yield* host.handleCommandLine( encodeJson({ diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index cef42d4f35fc3..2c9f6f1d8dbbd 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -42,6 +42,7 @@ const keyOf = ({ threadId, tabId }: DesktopBrowserTabKey) => `${threadId}\u0000$ interface AttachedTab { readonly key: DesktopBrowserTabKey; readonly debuggee: DesktopBrowserTabDebugger; + readonly background: boolean; relay: CdpRelayConnection | null; downloadDirectory: string | null; pendingDownloadGuid: string | null; @@ -59,7 +60,12 @@ export class DesktopBrowserHost extends Context.Service< { readonly events: Stream.Stream; readonly handleCommandLine: (line: string) => Effect.Effect; - readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; + /** `background` tabs live in a never-shown window that no panel sizes. */ + readonly attach: ( + key: DesktopBrowserTabKey, + debuggee: DesktopBrowserTabDebugger, + background: boolean, + ) => void; readonly detach: (key: DesktopBrowserTabKey) => void; readonly humanStartedDownload: (source: Electron.WebContents) => boolean; readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; @@ -137,13 +143,18 @@ export const make = Effect.gen(function* () { emit({ type: "detached", ...key }); }; - const attach = (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => { + const attach = ( + key: DesktopBrowserTabKey, + debuggee: DesktopBrowserTabDebugger, + background: boolean, + ) => { const id = keyOf(key); if (tabs.get(id)?.debuggee.webContents === debuggee.webContents) return; detach(key); const tab: AttachedTab = { key, debuggee, + background, relay: null, downloadDirectory: null, pendingDownloadGuid: null, @@ -158,7 +169,7 @@ export const make = Effect.gen(function* () { }; tabs.set(id, tab); debuggee.debugger.on("message", tab.onMessage); - emit({ type: "attached", ...key }); + emit({ type: "attached", ...key, background }); }; const handleCommandLine = (line: string) => @@ -190,7 +201,7 @@ export const make = Effect.gen(function* () { [...tabs.values()], (tab) => { tab.relay = null; - return PubSub.publish(outbox, { type: "attached", ...tab.key }); + return PubSub.publish(outbox, { type: "attached", ...tab.key, background: tab.background }); }, { discard: true }, ), diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6ded53bfaf1ba..6b67b010a57c9 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -600,6 +600,58 @@ describe("PreviewManager", () => { ), ); + effectIt.effect( + "hosts an agent tab in a never-shown window that paints and closes with the tab", + () => + withManager((manager) => + Effect.gen(function* () { + const tab = makeTestPreviewWebContents( + async () => ({ + toJPEG: () => Buffer.from(""), + getSize: () => ({ width: 1, height: 1 }), + }), + 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 window = { + webContents: tab, + once: vi.fn(), + isDestroyed: () => false, + destroy: vi.fn(), + }; + let options: Electron.BrowserWindowConstructorOptions | undefined; + browserWindowConstructor.mockImplementation(function ( + next: Electron.BrowserWindowConstructorOptions, + ) { + options = next; + return window; + }); + 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" }, + ); + + expect(options).toMatchObject({ + show: false, + paintWhenInitiallyHidden: true, + webPreferences: { + partition: "persist:t3code-preview-test", + backgroundThrottling: false, + }, + }); + expect(state.webContentsId).toBe(55); + expect(loadURL).toHaveBeenCalledWith("https://example.com/agent"); + yield* manager.closeTab("tab_agent"); + expect(window.destroy).toHaveBeenCalledOnce(); + }), + ), + ); + effectIt.effect("keeps editing and forwarded shortcuts in whichever tab holds focus", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index e9578c711ae40..70ce945061320 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -66,6 +66,8 @@ import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts" import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; import * as PreviewPasskeys from "./Passkeys.ts"; +import { PASSKEY_BRIDGE_ARGUMENT } from "./GuestProtocol.ts"; +import { backgroundTabWindowOptions } from "./BackgroundTabWindow.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, @@ -114,6 +116,12 @@ export interface PreviewTabState { updatedAt: string; } +/** An agent tab's never-shown window: the main-process-derived partition it browses with. */ +export interface PreviewBackgroundTab { + readonly partition: string; + readonly url?: string | undefined; +} + const ZOOM_LEVELS: ReadonlyArray = [ 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, ]; @@ -549,6 +557,7 @@ const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( artifactDirectory: string, pictureInPicturePreloadPath: string, + guestPreloadPath: string, ) { const fileSystem = yield* FileSystem.FileSystem; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; @@ -1676,6 +1685,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (currentMainWindow !== window) return; currentMainWindow = undefined; frameCaptureWindowOpen = false; + // Off macOS the app quits once its last window closes, which a + // background tab would otherwise hold open. + if (hostPlatform !== "darwin") { + for (const tabId of backgroundWindows.keys()) closeBackgroundWindow(tabId); + } mainWindowCleanupFiber = runFork( Effect.all([closeAllPictureInPicture(), stopAllRecordings()], { concurrency: "unbounded", @@ -1733,11 +1747,67 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return result.state; }); + /** Agent tabs' never-shown windows; see backgroundTabWindowOptions. */ + const backgroundWindows = new Map(); + + const openBackgroundWindow = Effect.fn("PreviewManager.openBackgroundWindow")(function* ( + tabId: string, + background: PreviewBackgroundTab, + ) { + const window = yield* attempt( + { operation: "backgroundWindow.create", tabId }, + () => + new BrowserWindow( + backgroundTabWindowOptions({ + partition: background.partition, + preload: guestPreloadPath, + passkeyBridgeArgument: passkeys.bridgeEnabled ? PASSKEY_BRIDGE_ARGUMENT : undefined, + }), + ), + ); + backgroundWindows.set(tabId, window); + const wc = window.webContents; + window.once("closed", () => { + if (backgroundWindows.get(tabId) === window) backgroundWindows.delete(tabId); + }); + // A panel guest is remounted by the renderer after a crash; nothing else + // would bring this one back. + wc.on("render-process-gone", () => { + if (!wc.isDestroyed()) wc.reload(); + }); + yield* prepareWebview(wc); + yield* registerWebviewUnlocked(tabId, wc.id, tabLifecycleGenerations.get(tabId)); + if (background.url !== undefined) { + runFork( + attemptPromise({ operation: "backgroundWindow.load", tabId, webContentsId: wc.id }, () => + wc.loadURL(background.url!), + ).pipe(Effect.ignore), + ); + } + }); + + const closeBackgroundWindow = (tabId: string) => { + const window = backgroundWindows.get(tabId); + backgroundWindows.delete(tabId); + if (window && !window.isDestroyed()) window.destroy(); + }; + const createTab = Effect.fn("PreviewManager.createTab")(function* ( tabId: string, defaults?: DesktopPreviewTabDefaults, + background?: PreviewBackgroundTab, ) { - return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId, defaults)); + return yield* withTabLifecycleLock( + tabId, + Effect.gen(function* () { + const state = yield* createTabUnlocked(tabId, defaults); + if (background === undefined || backgroundWindows.has(tabId)) return state; + yield* openBackgroundWindow(tabId, background).pipe( + Effect.onError(() => Effect.sync(() => closeBackgroundWindow(tabId))), + ); + return (yield* SynchronizedRef.get(tabsRef)).get(tabId) ?? state; + }), + ); }); const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { @@ -1776,6 +1846,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { concurrency: 2, discard: true }, ); } + closeBackgroundWindow(tabId); const updatedAt = yield* currentIso; const closed: PreviewTabState = { ...closedTab, @@ -1827,11 +1898,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const wc = webContents.fromId(webContentsId); const mainWindow = yield* Ref.get(mainWindowRef); + const ownBackgroundWindow = backgroundWindows.get(tabId)?.webContents === wc; if ( !wc || wc.isDestroyed() || - wc.getType() !== "webview" || - (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) + (!ownBackgroundWindow && + (wc.getType() !== "webview" || + (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents))) ) { return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId }); } @@ -2387,7 +2460,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } if (afterAttach.serverTab) { yield* listenForAgentPointers; - browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger }); + browserHost.attach( + afterAttach.serverTab, + { webContents: wc, debugger: control.debugger }, + backgroundWindows.get(tabId)?.webContents === wc, + ); } if (afterAttach.colorScheme !== "system") { yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => @@ -3595,6 +3672,7 @@ export class PreviewManager extends Context.Service< readonly createTab: ( tabId: string, defaults?: DesktopPreviewTabDefaults, + background?: PreviewBackgroundTab, ) => Effect.Effect; readonly closeTab: (tabId: string) => Effect.Effect; readonly registerWebview: ( @@ -3730,6 +3808,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { const operations = yield* makeNativeOperations( environment.browserArtifactsDir, environment.path.join(environment.dirname, "preview-pip-preload.cjs"), + environment.path.join(environment.dirname, "preview-pick-preload.cjs"), ); return PreviewManager.of({ diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 0d1db856c1e6b..999ac681cbbb8 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -52,6 +52,8 @@ export class DesktopBrowserChannel extends Context.Service< readonly detached: Stream.Stream; readonly attached: Stream.Stream; readonly isAttached: (key: DesktopTabKey) => Effect.Effect; + /** The tab lives in a never-shown desktop window, so no panel sizes it. */ + readonly isBackground: (key: DesktopTabKey) => Effect.Effect; readonly endpoint: (key: DesktopTabKey) => Effect.Effect; readonly pointer: ( key: DesktopTabKey, @@ -65,7 +67,8 @@ const make = Effect.gen(function* () { const inputFd = config.desktopBrowserFd; const controlFd = config.desktopBrowserControlFd; const changes = yield* PubSub.unbounded<{ key: DesktopTabKey; attached: boolean }>(); - const attachedTabs = new Set(); + /** Attached tab ids, mapped to whether each is hosted in the background. */ + const attachedTabs = new Map(); const inbound = new Map>(); const writeLock = yield* Semaphore.make(1); @@ -76,6 +79,7 @@ const make = Effect.gen(function* () { detached: Stream.empty, attached: Stream.empty, isAttached: () => Effect.succeed(false), + isBackground: () => Effect.succeed(false), endpoint: () => Effect.die("No desktop app is attached to this server."), pointer: () => Effect.void, }); @@ -115,7 +119,7 @@ const make = Effect.gen(function* () { return queue ? Queue.offer(queue, event.message).pipe(Effect.asVoid) : Effect.void; } case "attached": - attachedTabs.add(id); + attachedTabs.set(id, event.background === true); return PubSub.publish(changes, { key, attached: true }); case "detached": { attachedTabs.delete(id); @@ -214,6 +218,7 @@ const make = Effect.gen(function* () { Stream.map((change) => change.key), ), isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))), + isBackground: (key) => Effect.sync(() => attachedTabs.get(keyOf(key)) === true), endpoint, pointer: (key, pointer) => command({ type: "pointer", ...key, ...pointer }), }); diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index ac4a785f207f3..29b326bd3e252 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -221,6 +221,7 @@ let focusedFrame: ReturnType | null = null; let contextFailure: Error | null = null; let initialNavigation: (() => Promise) | null = null; let desktopRendersNext = false; +let desktopBackground = false; const desktopDetaches = new NodeEvents.EventEmitter(); const desktopTabs = new Set(); const desktopRenders = (tabId: string) => { @@ -286,6 +287,7 @@ const dependencies = Layer.mergeAll( ), ), isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), + isBackground: () => Effect.sync(() => desktopBackground), endpoint: (key) => Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => Effect.sync(() => releasedDesktopTabs.push(key.tabId)), @@ -329,6 +331,7 @@ beforeEach(() => { focusedFrame = null; desktopTabs.clear(); desktopRendersNext = false; + desktopBackground = false; releasedDesktopTabs.length = 0; desktopConnections.length = 0; }); @@ -494,6 +497,28 @@ it.live("enforces provider ownership and explicit targets when a session has mul ).pipe(Effect.provide(layer)), ); +it.live.each([ + { background: true, sized: true }, + { background: false, sized: false }, +])( + "a desktop tab is sized by the server only when no panel webview sizes it (background $background)", + ({ background, sized }) => + Effect.scoped( + Effect.gen(function* () { + desktopRendersNext = true; + desktopBackground = background; + const { browser, tabId } = yield* ready; + const page = desktopConnections[0]!.context.page; + page.setViewportSize.mockClear(); + const operator = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* operator.input({ type: "takeControl" }); + yield* operator.input({ type: "resize", width: 390, height: 844 }); + if (sized) expect(page.setViewportSize).toHaveBeenCalledWith({ width: 390, height: 844 }); + else expect(page.setViewportSize).not.toHaveBeenCalled(); + }), + ).pipe(Effect.provide(layer)), +); + it.live("streams to a read-only viewer without allowing takeover, input, or viewport changes", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 5b386e5f0797a..53cb50ddad9c0 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -291,7 +291,12 @@ interface ServerTab { readonly isolatedContext: boolean; - readonly desktop: { readonly close: () => Promise } | null; + /** + * A desktop-rendered page. `background` pages live in a never-shown window, + * so the server sizes them like headless pages; the others follow the + * panel's ``. + */ + readonly desktop: { readonly close: () => Promise; readonly background: boolean } | null; readonly profileId: string | undefined; readonly openerTabId: string | undefined; @@ -505,10 +510,12 @@ const make = Effect.gen(function* () { } : null; + const inPanel = (tab: ServerTab) => tab.desktop !== null && !tab.desktop.background; + const updateFileChooserInterception = (tab: ServerTab) => { // Native desktop views use the OS picker. Only agents and streamed controllers // have an uploader that can answer an intercepted request. - const intercept = !tab.desktop || tab.agentOperating || tab.control.controller !== null; + const intercept = !inPanel(tab) || tab.agentOperating || tab.control.controller !== null; if (intercept === tab.fileChooserIntercepted) return; tab.fileChooserIntercepted = intercept; if (intercept) tab.page.on("filechooser", tab.onFileChooser); @@ -599,7 +606,7 @@ const make = Effect.gen(function* () { const layoutSize = (tab: ServerTab) => { const size = tab.page.viewportSize(); - if (!size || tab.desktop) return size; + if (!size || inPanel(tab)) return size; return { width: Math.max(1, Math.round(size.width / tab.zoomFactor)), height: Math.max(1, Math.round(size.height / tab.zoomFactor)), @@ -607,7 +614,7 @@ const make = Effect.gen(function* () { }; const applyZoom = async (tab: ServerTab) => { - if (tab.desktop) return; + if (inPanel(tab)) return; const size = tab.page.viewportSize(); if (!size) return; if (tab.zoomFactor === 1) { @@ -632,7 +639,7 @@ const make = Effect.gen(function* () { const applySetting = async (tab: ServerTab, setting: PreviewViewportSetting) => { tab.setting = setting; - if (tab.desktop) return; + if (inPanel(tab)) return; const size = fixedViewportSize(setting) ?? [...tab.viewers] @@ -652,7 +659,7 @@ const make = Effect.gen(function* () { await tab.page.emulateMedia({ colorScheme: colorScheme === "system" ? null : colorScheme }); } const zoomFactor = snapshot.zoomFactor ?? 1; - if (zoomFactor !== tab.zoomFactor && !tab.desktop) { + if (zoomFactor !== tab.zoomFactor && !inPanel(tab)) { tab.zoomFactor = zoomFactor; await applyZoom(tab); broadcastViewport(tab); @@ -701,8 +708,12 @@ const make = Effect.gen(function* () { .pipe(Scope.provide(scope)), ); const connected = await contexts.connectDesktopPage(endpoint); + const background = await Effect.runPromise( + desktopChannel.isBackground({ threadId: snapshot.threadId, tabId: snapshot.tabId }), + ); return { page: connected.page, + background, close: async () => { await connected.browser.close().catch(constVoid); await Effect.runPromise(Scope.close(scope, Exit.void)); @@ -764,7 +775,7 @@ const make = Effect.gen(function* () { fileChooserIntercepted: false, onFileChooser: (chooser) => void offerFileChooser(tab, chooser), isolatedContext, - desktop: desktop === null ? null : { close: desktop.close }, + desktop: desktop === null ? null : { close: desktop.close, background: desktop.background }, profileId: snapshot.profileId, openerTabId: adopted?.openerTabId, downloads: [], @@ -783,7 +794,7 @@ const make = Effect.gen(function* () { captureLock: Promise.resolve(), capturing: 0, }; - if (!desktop) { + if (!inPanel(tab)) { await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT); } await applyRendering(tab, snapshot); @@ -1273,14 +1284,14 @@ const make = Effect.gen(function* () { ); }; - // Desktop browsing bypasses streamed viewers and server input tracking, - // so these idle signals only apply to headless tabs. + // Panel browsing bypasses streamed viewers and server input tracking, + // so these idle signals only apply to tabs the panel does not render. const closeIdleAgentTabs = () => { const cutoff = Date.now() - AGENT_TAB_IDLE_MS; for (const tab of tabs.values()) { if ( tab.control.agentId !== null && - tab.desktop === null && + !inPanel(tab) && tab.viewers.size === 0 && tab.usedAt < cutoff ) @@ -2198,7 +2209,7 @@ const make = Effect.gen(function* () { const height = Math.min(Math.round(num(message.height)), 2160); if (width < 100 || height < 100) return; viewer.requestedSize = { width, height, order: ++viewerResizeOrder }; - if (tab.setting._tag !== "fill" || tab.desktop) return; + if (tab.setting._tag !== "fill" || inPanel(tab)) return; const current = tab.page.viewportSize(); if (current?.width === width && current.height === height) return; await tab.page.setViewportSize({ width, height }); diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 29c217e064adc..f5aa7e06b8389 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -22,6 +22,12 @@ const mocks = vi.hoisted(() => ({ vi.mock(import("~/env"), async (original) => ({ ...(await original()), isElectron: true })); +// Server tabs render on the desktop only for its own environment. +vi.mock(import("~/state/primaryEnvironment"), async () => { + const { Atom } = await import("effect/reactivity"); + return { primaryEnvironmentIdAtom: Atom.make(EnvironmentId.make("host-webview-order")) }; +}); + vi.mock(import("~/state/session"), async (original) => ({ ...(await original()), useEnvironmentScope: () => true, @@ -160,4 +166,34 @@ describe("ElectronBrowserHost", () => { expect(after).toHaveLength(2); expect(after.every((node, index) => node.isSameNode(before[index]!))).toBe(true); }); + + it("hosts an agent's tab in a background window instead of a panel webview", async () => { + using _state = setup(); + reconcilePreviewServerSessions(threadRef, { + sessions: [ + { + ...snapshot("tab_agent", "2026-01-01T00:00:01.000Z"), + runtime: "server", + automationOwner: "agent-session", + }, + ], + serverEpoch: "server-a", + revision: 1, + }); + await using screen = await renderHost(); + + await vi.waitFor(() => + expect(mocks.createTab).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + serverTab: { threadId: threadRef.threadId, tabId: "tab_agent" }, + background: expect.objectContaining({ + environmentId: threadRef.environmentId, + url: "https://example.com/tab_agent", + }), + }), + ), + ); + expect(screen.container.querySelectorAll("webview")).toHaveLength(0); + }); }); diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 8818b377f5e3b..8ee85a341e239 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -3,6 +3,7 @@ import { AuthPreviewOperateScope, type DesktopPreviewOpenLinkEvent, FILL_PREVIEW_VIEWPORT, + type ScopedThreadRef, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { type ComponentProps, useEffect, useEffectEvent, useState } from "react"; @@ -22,7 +23,8 @@ import { useBrowserDefaults } from "./browserDefaults"; import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; import { openUrlInPreview } from "./openFileInPreview"; -import { rendersServerTabNatively } from "./previewRuntime"; +import { acquireDesktopTab } from "./desktopTabLifetime"; +import { hostsServerTabInBackground, rendersServerTabNatively } from "./previewRuntime"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; export function ElectronBrowserHost() { @@ -36,11 +38,17 @@ export function ElectronBrowserHost() { .filter( (snapshot) => snapshot.runtime !== "server" || - rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot), + rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot) || + hostsServerTabInBackground(threadRef.environmentId, primaryEnvironmentId, snapshot), ) .map((snapshot) => ({ threadRef, snapshot, + background: hostsServerTabInBackground( + threadRef.environmentId, + primaryEnvironmentId, + snapshot, + ), runtimeTabId: previewRuntimeTabId(threadRef, previewState.serverEpoch, snapshot.tabId), pictureInPicture: previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false, @@ -116,35 +124,89 @@ export function ElectronBrowserHost() { if (!isElectron) return null; return (
- {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => { - const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; - return ( - - ); - })} + {sessions.map( + ({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor, background }) => { + const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; + if (background) { + return ( + + ); + } + return ( + + ); + }, + )}
); } +/** Holds the desktop tab open in its own window; the panel streams it from the server. */ +function BackgroundBrowserTab(props: { + readonly threadRef: ScopedThreadRef; + readonly tabId: string; + readonly runtimeTabId: string; + readonly initialUrl: string | null; + readonly profileId: string | undefined; +}) { + const { tabId, runtimeTabId } = props; + const { environmentId, threadId } = props.threadRef; + const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); + const profileId = useTabProfileId(props.profileId); + const hydrated = useClientSettingsHydrated(); + // Fixed at creation, like a webview's partition and first URL. + const [initialUrl] = useState(props.initialUrl); + useEffect(() => { + if (!canOperatePreview || !hydrated) return; + const lease = acquireDesktopTab( + runtimeTabId, + { threadId, tabId }, + { + environmentId, + ...(profileId === undefined ? {} : { profileId }), + ...(initialUrl === null ? {} : { url: initialUrl }), + }, + ); + return lease.release; + }, [ + canOperatePreview, + hydrated, + environmentId, + threadId, + tabId, + runtimeTabId, + profileId, + initialUrl, + ]); + return null; +} + function AuthorizedBrowserWebview(props: ComponentProps) { const canOperatePreview = useEnvironmentScope( props.threadRef.environmentId, diff --git a/apps/web/src/browser/desktopTabLifetime.ts b/apps/web/src/browser/desktopTabLifetime.ts index 5b5ed29eaa883..cdc95da9360f9 100644 --- a/apps/web/src/browser/desktopTabLifetime.ts +++ b/apps/web/src/browser/desktopTabLifetime.ts @@ -1,3 +1,5 @@ +import type { DesktopPreviewTabDefaults } from "@t3tools/contracts"; + import { previewBridge } from "~/components/preview/previewBridge"; import { browserDefaultTabState, resolveBrowserDefaults } from "./browserDefaults"; @@ -47,6 +49,7 @@ export interface AcquiredDesktopTab { export function acquireDesktopTab( tabId: string, serverTab?: { readonly threadId: string; readonly tabId: string }, + background?: DesktopPreviewTabDefaults["background"], ): AcquiredDesktopTab { const current = leases.get(tabId) ?? @@ -59,6 +62,7 @@ export function acquireDesktopTab( previewBridge?.createTab(tabId, { ...browserDefaultTabState(await resolveBrowserDefaults()), ...(serverTab === undefined ? {} : { serverTab }), + ...(background === undefined ? {} : { background }), }), ), } satisfies DesktopTabLease); diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts index 3d8e1d0a1f43d..e4804ccfe2240 100644 --- a/apps/web/src/browser/previewRuntime.ts +++ b/apps/web/src/browser/previewRuntime.ts @@ -41,10 +41,13 @@ export function usePreviewAvailable(environmentId: EnvironmentId | null): boolea return isPreviewSupportedInRuntime() || serverBrowser; } -export function rendersServerTabNatively( +type RuntimeSnapshot = Pick; + +/** The desktop app hosts this server tab, in a panel `` or a background window. */ +function desktopHostsServerTab( environmentId: EnvironmentId, primaryEnvironmentId: EnvironmentId | null, - snapshot: Pick | null | undefined, + snapshot: RuntimeSnapshot | null | undefined, ): boolean { return ( isElectron && @@ -54,9 +57,36 @@ export function rendersServerTabNatively( ); } +export function rendersServerTabNatively( + environmentId: EnvironmentId, + primaryEnvironmentId: EnvironmentId | null, + snapshot: RuntimeSnapshot | null | undefined, +): boolean { + return ( + desktopHostsServerTab(environmentId, primaryEnvironmentId, snapshot) && + snapshot?.automationOwner === undefined + ); +} + +/** + * Agent tabs run in a never-shown desktop window, because a panel `` + * created while the app is hidden never paints and agents open tabs whenever + * they like. The panel streams them like any other server tab. + */ +export function hostsServerTabInBackground( + environmentId: EnvironmentId, + primaryEnvironmentId: EnvironmentId | null, + snapshot: RuntimeSnapshot | null | undefined, +): boolean { + return ( + desktopHostsServerTab(environmentId, primaryEnvironmentId, snapshot) && + snapshot?.automationOwner !== undefined + ); +} + export function useRendersServerTabNatively( environmentId: EnvironmentId, - snapshot: Pick | null | undefined, + snapshot: RuntimeSnapshot | null | undefined, ): boolean { return rendersServerTabNatively(environmentId, useAtomValue(primaryEnvironmentIdAtom), snapshot); } diff --git a/packages/contracts/src/desktopBrowser.ts b/packages/contracts/src/desktopBrowser.ts index c70b374910079..4e16d1f00712d 100644 --- a/packages/contracts/src/desktopBrowser.ts +++ b/packages/contracts/src/desktopBrowser.ts @@ -8,7 +8,15 @@ const TabKey = { }; export const DesktopBrowserEvent = Schema.Union([ - Schema.Struct({ type: Schema.Literal("attached"), ...TabKey }), + Schema.Struct({ + type: Schema.Literal("attached"), + ...TabKey, + /** + * Hosted in a never-shown window rather than a panel ``, so no + * panel sizes it and the server must. + */ + background: Schema.optional(Schema.Boolean), + }), /** Its `` went away: closed, crashed, swapped, or devtools took the debugger. */ Schema.Struct({ type: Schema.Literal("detached"), ...TabKey }), Schema.Struct({ type: Schema.Literal("cdp"), ...TabKey, message: Schema.String }), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index d1f6b349397ee..307e08416c0e2 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -955,12 +955,30 @@ export const DesktopPreviewCreateTabInputSchema = Schema.Struct({ serverTab: Schema.optional( Schema.Struct({ threadId: TrimmedNonEmptyString, tabId: TrimmedNonEmptyString }), ), + /** + * Host the tab in a never-shown window instead of a panel ``. Main + * derives the partition from these, as it does for webview config. + */ + background: Schema.optional( + Schema.Struct({ + environmentId: EnvironmentId, + profileId: Schema.optional(BrowserProfileId), + url: Schema.optional(Schema.String), + }), + ), }); export interface DesktopPreviewTabDefaults { readonly zoomFactor?: number | undefined; readonly colorScheme?: DesktopPreviewColorScheme | undefined; readonly serverTab?: { readonly threadId: string; readonly tabId: string } | undefined; + readonly background?: + | { + readonly environmentId: EnvironmentId; + readonly profileId?: string | undefined; + readonly url?: string | undefined; + } + | undefined; } export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({ diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 0e9ff94d6ffe5..12506dee42342 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -949,8 +949,10 @@ export class PreviewAutomationTimeoutError extends Schema.TaggedError Date: Fri, 13 Nov 2026 23:11:19 +0000 Subject: [PATCH 2/8] fix(desktop): settle background preview tabs on main's preview shape Background tabs are streamed, so the server sizes them and counts only panel tabs as visible, pointer-drawn, or measured. isBackground becomes a plain check like isShown, the attached event omits the flag for panel tabs, and the Electron check entry is a knip entry. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/scripts/verify-background-preview.mjs | 7 ++----- apps/server/src/preview/DesktopBrowserChannel.ts | 8 +++----- apps/server/src/preview/ServerBrowser.test.ts | 2 +- apps/server/src/preview/ServerBrowser.ts | 8 ++++---- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/apps/desktop/scripts/verify-background-preview.mjs b/apps/desktop/scripts/verify-background-preview.mjs index 1907efb3e4a31..9d4dd0c58b74e 100644 --- a/apps/desktop/scripts/verify-background-preview.mjs +++ b/apps/desktop/scripts/verify-background-preview.mjs @@ -1,8 +1,5 @@ -// Proves agent preview tabs still paint while the app window is hidden or -// minimized, using the exact window options the preview manager ships. -// Agents open tabs whenever they like; a tab that cannot paint makes every -// screenshot of it time out. Unit tests mock Electron, so only a real one can -// catch this. +// Proves agent preview tabs still paint while the app window is hidden or minimized, +// using the window options the preview manager ships. Unit tests mock Electron. import * as NodeChildProcess from "node:child_process"; import * as NodeModule from "node:module"; import * as NodePath from "node:path"; diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 2654ea847eea5..400403d080cbf 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -46,7 +46,7 @@ export class DesktopBrowserChannel extends Context.Service< readonly attached: Stream.Stream; readonly isAttached: (key: DesktopTabKey) => Effect.Effect; /** The tab lives in a never-shown desktop window, so no panel sizes it. */ - readonly isBackground: (key: DesktopTabKey) => Effect.Effect; + readonly isBackground: (key: DesktopTabKey) => boolean; /** Whether a desktop window draws the tab on screen, which no streamed viewer counts. */ readonly isShown: (key: DesktopTabKey) => boolean; /** A load failure the desktop saw, possibly before the server connected to the tab. */ @@ -94,7 +94,7 @@ const make = Effect.gen(function* () { pointer: Effect.fn("DesktopBrowserChannel.pointer")(function () { return Effect.void; }), - isBackground: () => Effect.succeed(false), + isBackground: () => false, isShown: () => false, loadFailure: () => undefined, }); @@ -256,9 +256,7 @@ const make = Effect.gen(function* () { isAttached: Effect.fn("DesktopBrowserChannel.isAttached")(function (key) { return Effect.sync(() => attachedTabs.has(keyOf(key))); }), - isBackground: Effect.fn("DesktopBrowserChannel.isBackground")(function (key) { - return Effect.sync(() => attachedTabs.get(keyOf(key)) === true); - }), + isBackground: (key) => attachedTabs.get(keyOf(key)) === true, isShown: (key) => shownTabs.get(keyOf(key)) ?? true, loadFailure: (key) => loadFailures.get(keyOf(key)), endpoint, diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 1b51a089b0a05..74dc0ba62aabd 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -500,7 +500,7 @@ const dependencies = Layer.mergeAll( ), ), isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), - isBackground: () => Effect.sync(() => desktopBackground), + isBackground: () => desktopBackground, isShown: (key) => shownDesktopTabs.has(key.tabId), loadFailure: (key) => desktopLoadFailures.get(key.tabId), endpoint: (key) => diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 3da984c04f857..5d1717b82e8ce 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -954,12 +954,12 @@ const make = Effect.gen(function* () { .pipe(Scope.provide(scope)), ); const connected = await contexts.connectDesktopPage(endpoint); - const background = await Effect.runPromise( - desktopChannel.isBackground({ threadId: snapshot.threadId, tabId: snapshot.tabId }), - ); return { page: connected.page, - background, + background: desktopChannel.isBackground({ + threadId: snapshot.threadId, + tabId: snapshot.tabId, + }), close: async () => { await connected.browser.close().catch(constVoid); await runPromise(Scope.close(scope, Exit.void)); From 9b6bc6a181f8e0e7e0ce59ae5d4f9840a9fa1c28 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 23:21:01 +0000 Subject: [PATCH 3/8] fix(desktop): background preview windows stay out of app window fallback Agent tab windows are created as auxiliary windows, so activating the app after the main window closes builds a new main window instead of revealing an agent page. Zoom on server-sized tabs now survives viewer resizes and a return to 100%, and the Electron paint check fails unless it finishes. Co-Authored-By: Claude Opus 5.5 --- .../verify-background-preview.electron.cjs | 1 + .../scripts/verify-background-preview.mjs | 16 ++++++++++- apps/desktop/src/preview/Manager.test.ts | 2 ++ apps/desktop/src/preview/Manager.ts | 27 ++++++++++++------- apps/server/src/preview/ServerBrowser.ts | 6 ++--- 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/apps/desktop/scripts/verify-background-preview.electron.cjs b/apps/desktop/scripts/verify-background-preview.electron.cjs index 54a847a0189a0..04e95d0ce6afe 100644 --- a/apps/desktop/scripts/verify-background-preview.electron.cjs +++ b/apps/desktop/scripts/verify-background-preview.electron.cjs @@ -85,6 +85,7 @@ async function main() { return; } console.log("Background preview tabs paint while the app window is hidden or minimized."); + NodeFS.writeFileSync(process.env.T3_BACKGROUND_PREVIEW_DONE, "ok"); app.exit(0); } diff --git a/apps/desktop/scripts/verify-background-preview.mjs b/apps/desktop/scripts/verify-background-preview.mjs index 9d4dd0c58b74e..4b332b48a374a 100644 --- a/apps/desktop/scripts/verify-background-preview.mjs +++ b/apps/desktop/scripts/verify-background-preview.mjs @@ -1,7 +1,9 @@ // Proves agent preview tabs still paint while the app window is hidden or minimized, // using the window options the preview manager ships. Unit tests mock Electron. import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; @@ -13,12 +15,24 @@ const entry = NodePath.join( // The plain binary: the development launcher's renamed app bundle refuses data: URLs. const electronPath = NodeModule.createRequire(import.meta.url)("electron"); const { ELECTRON_RUN_AS_NODE: _ignored, ...env } = process.env; -const child = NodeChildProcess.spawn(electronPath, [entry], { stdio: "inherit", env }); +// Only a run that finished every capture writes this, so an early clean quit still fails. +const done = NodePath.join( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-background-preview-")), + "done", +); +const child = NodeChildProcess.spawn(electronPath, [entry], { + stdio: "inherit", + env: { ...env, T3_BACKGROUND_PREVIEW_DONE: done }, +}); const timer = setTimeout(() => { console.error(`Background preview check did not finish within ${RUN_TIMEOUT_MS}ms.`); child.kill("SIGKILL"); }, RUN_TIMEOUT_MS); child.on("exit", (code, signal) => { clearTimeout(timer); + if (code === 0 && !NodeFS.existsSync(done)) { + console.error("Background preview check quit before it finished."); + process.exit(1); + } process.exit(code ?? (signal ? 1 : 0)); }); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index d65ed15325cf5..0d20564c74d28 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -242,6 +242,7 @@ const managerLayer = (platform: NodeJS.Platform = "darwin") => }), ), Layer.provideMerge(DesktopBrowserHost.layer), + Layer.provideMerge(ElectronWindow.layer), Layer.provideMerge(layerBrowserSession), Layer.provideMerge( Layer.mock(PreviewPasskeys.PreviewPasskeys)({ @@ -622,6 +623,7 @@ describe("PreviewManager", () => { agentLedPage: () => false, placeDownload: () => true, }), + ElectronWindow.layer, Layer.mock(PreviewPasskeys.PreviewPasskeys)({ bridgeEnabled: false, installSessionHandlers: () => {}, diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 1ead7dcbd11f6..33876d5563457 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -69,6 +69,7 @@ import { PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, } from "../ipc/channels.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; import * as PreviewPasskeys from "./Passkeys.ts"; import { PASSKEY_BRIDGE_ARGUMENT } from "./GuestProtocol.ts"; @@ -577,6 +578,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; const browserSession = yield* BrowserSession.BrowserSession; const passkeys = yield* PreviewPasskeys.PreviewPasskeys; + const electronWindow = yield* ElectronWindow.ElectronWindow; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); @@ -1785,17 +1787,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId: string, background: PreviewBackgroundTab, ) { - const window = yield* attempt( - { operation: "backgroundWindow.create", tabId }, - () => - new BrowserWindow( - backgroundTabWindowOptions({ - partition: background.partition, - preload: guestPreloadPath, - passkeyBridgeArgument: passkeys.bridgeEnabled ? PASSKEY_BRIDGE_ARGUMENT : undefined, - }), + // Auxiliary, so activating the app never picks it as the main window. + const window = yield* electronWindow + .create( + backgroundTabWindowOptions({ + partition: background.partition, + preload: guestPreloadPath, + passkeyBridgeArgument: passkeys.bridgeEnabled ? PASSKEY_BRIDGE_ARGUMENT : undefined, + }), + { auxiliary: true }, + ) + .pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ operation: "backgroundWindow.create", tabId, cause }), ), - ); + ); backgroundWindows.set(tabId, window); const wc = window.webContents; window.once("closed", () => { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 5d1717b82e8ce..603fb6e676518 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -817,10 +817,7 @@ const make = Effect.gen(function* () { if (inPanel(tab)) return; const size = tab.page.viewportSize(); if (!size) return; - if (tab.zoomFactor === 1) { - await tab.page.setViewportSize(size); - return; - } + // Playwright skips a size it already set, so 100% must replace the zoomed override itself. const layout = (await layoutSize(tab))!; await tab.cdp.send("Emulation.setDeviceMetricsOverride", { width: layout.width, @@ -2951,6 +2948,7 @@ const make = Effect.gen(function* () { const current = tab.page.viewportSize(); if (current?.width === width && current.height === height) return; await tab.page.setViewportSize({ width, height }); + await applyZoom(tab); await broadcastViewport(tab); return; } From a623078e840a42a84ded89492e27c1d1dea5c52e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 23:23:16 +0000 Subject: [PATCH 4/8] fix(desktop): a navigation during background window setup wins over its first URL Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/preview/Manager.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 33876d5563457..125e1f9ec1f6f 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1815,7 +1815,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); yield* prepareWebview(wc); yield* registerWebviewUnlocked(tabId, wc.id, tabLifecycleGenerations.get(tabId)); - if (background.url !== undefined) { + // A navigation that arrived during setup is newer; registration already loads it. + const navStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus; + if (background.url !== undefined && navStatus?.kind !== "Loading") { runFork( attemptPromise({ operation: "backgroundWindow.load", tabId, webContentsId: wc.id }, () => wc.loadURL(background.url!), From ad8c5a418ed96412e4002abce27498d8de842fa2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 23:41:53 +0000 Subject: [PATCH 5/8] fix(desktop): agent tabs use a background window only while T3 is hidden An agent tab that appears while the app window is on screen stays in a panel webview, so Pick element, Capture, DevTools, Find, picture-in-picture and direct input keep working. Only tabs that first appear while the window is hidden or minimized, whose webview would never paint, get a never-shown window. The choice is fixed per tab, since moving a page between hosts would replace it. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.ts | 10 +++ apps/desktop/src/preload.ts | 1 + .../ElectronBrowserHost.browser.test.tsx | 76 ++++++++++++------- apps/web/src/browser/previewRuntime.ts | 41 ++++++---- packages/contracts/src/ipc.ts | 2 + 7 files changed, 93 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 9f2f68be61520..84590f419ebea 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -42,6 +42,7 @@ import { getPreferredSystemLanguages, getPrimaryPasteEnabled, getWindowFullscreenState, + getWindowShown, openExternal, readMedia, openSystemSettings, @@ -102,6 +103,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getPreferredSystemLanguages); yield* ipc.handleSync(getPrimaryPasteEnabled); yield* ipc.handleSync(getWindowFullscreenState); + yield* ipc.handleSync(getWindowShown); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0e422f9bc7356..fdf3fedec301f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -19,6 +19,7 @@ export const TRACKPAD_SCROLL_BEGIN_CHANNEL = "desktop:trackpad-scroll-begin"; export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; +export const GET_WINDOW_SHOWN_CHANNEL = "desktop:get-window-shown"; export const SYNC_HTML_FULLSCREEN_CHANNEL = "desktop:sync-html-fullscreen"; export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready"; export const DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL = "desktop:app-activation-complete"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 53a1138d71a99..9a4b44ff285b2 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -135,6 +135,16 @@ export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getWindowShown = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_WINDOW_SHOWN_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.getWindowShown")(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.currentMainOrFirst; + return Option.isSome(window) && window.value.isVisible() && !window.value.isMinimized(); + }), +}); + export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3fdcf1154f140..3066d7ee31bb5 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -304,6 +304,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, getWindowFullscreenState: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, + getWindowShown: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_SHOWN_CHANNEL) !== false, getWindowZoomFactor: () => webFrame.getZoomFactor(), onWindowFullscreenStateChange: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, fullscreen: unknown) => { diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 1375dab3dcaba..7b37fd5253ae7 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -102,6 +102,15 @@ function setup(): Disposable { }; } +function stubDesktopWindowShown(shown: boolean): Disposable { + window.desktopBridge = { getWindowShown: () => shown, getWindowZoomFactor: () => 1 } as never; + return { + [Symbol.dispose]() { + delete window.desktopBridge; + }, + }; +} + async function renderHost() { await using stack = new AsyncDisposableStack(); const screen = stack.use( @@ -170,33 +179,46 @@ describe("ElectronBrowserHost", () => { expect(after.every((node, index) => node.isSameNode(before[index]!))).toBe(true); }); - it("hosts an agent's tab in a background window instead of a panel webview", async () => { - using _state = setup(); - reconcilePreviewServerSessions(threadRef, { - sessions: [ - { - ...snapshot("tab_agent", "2026-01-01T00:00:01.000Z"), - runtime: "server", - automationOwner: "agent-session", - }, - ], - serverEpoch: "server-a", - revision: 1, - }); - await using screen = await renderHost(); + it.each([ + { shown: false, tabId: "tab_agent_hidden" }, + { shown: true, tabId: "tab_agent_shown" }, + ])( + "hosts an agent's tab in a background window only if the app window is hidden (shown $shown)", + async ({ shown, tabId }) => { + using _state = setup(); + using _bridge = stubDesktopWindowShown(shown); + reconcilePreviewServerSessions(threadRef, { + sessions: [ + { + ...snapshot(tabId, "2026-01-01T00:00:01.000Z"), + runtime: "server", + automationOwner: "agent-session", + }, + ], + serverEpoch: "server-a", + revision: 1, + }); + await using screen = await renderHost(); - await vi.waitFor(() => - expect(mocks.createTab).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - serverTab: { threadId: threadRef.threadId, tabId: "tab_agent" }, - background: expect.objectContaining({ - environmentId: threadRef.environmentId, - url: "https://example.com/tab_agent", + await vi.waitFor(() => + expect(mocks.createTab).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + serverTab: { threadId: threadRef.threadId, tabId }, }), - }), - ), - ); - expect(screen.container.querySelectorAll("webview")).toHaveLength(0); - }); + ), + ); + const [, defaults] = mocks.createTab.mock.calls[0]!; + if (shown) { + expect(defaults?.background).toBeUndefined(); + expect(screen.container.querySelectorAll("webview")).toHaveLength(1); + } else { + expect(defaults?.background).toMatchObject({ + environmentId: threadRef.environmentId, + url: `https://example.com/${tabId}`, + }); + expect(screen.container.querySelectorAll("webview")).toHaveLength(0); + } + }, + ); }); diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts index 181cf884a6604..10d0897ef697d 100644 --- a/apps/web/src/browser/previewRuntime.ts +++ b/apps/web/src/browser/previewRuntime.ts @@ -41,7 +41,10 @@ export function usePreviewAvailable(environmentId: EnvironmentId | null): boolea return isPreviewSupportedInRuntime() || serverBrowser; } -type RuntimeSnapshot = Pick; +type RuntimeSnapshot = Pick< + PreviewSessionSnapshot, + "threadId" | "tabId" | "runtime" | "headless" | "automationOwner" +>; /** The desktop app hosts this server tab, in a panel `` or a background window. */ function desktopHostsServerTab( @@ -58,30 +61,42 @@ function desktopHostsServerTab( ); } -export function rendersServerTabNatively( +// Fixed when a tab first appears: moving a page between hosts would replace it. +const backgroundHostedTabs = new Map(); + +/** + * A panel `` created while the app window is hidden or minimized never paints, so an + * agent tab that appears then runs in a never-shown desktop window and the panel streams it. + */ +export function hostsServerTabInBackground( environmentId: EnvironmentId, primaryEnvironmentId: EnvironmentId | null, snapshot: RuntimeSnapshot | null | undefined, ): boolean { - return ( - desktopHostsServerTab(environmentId, primaryEnvironmentId, snapshot) && - snapshot?.automationOwner === undefined - ); + if ( + !snapshot || + snapshot.automationOwner === undefined || + !desktopHostsServerTab(environmentId, primaryEnvironmentId, snapshot) + ) { + return false; + } + const key = `${environmentId}\u0000${snapshot.threadId}\u0000${snapshot.tabId}`; + let background = backgroundHostedTabs.get(key); + if (background === undefined) { + background = window.desktopBridge?.getWindowShown?.() === false; + backgroundHostedTabs.set(key, background); + } + return background; } -/** - * Agent tabs run in a never-shown desktop window, because a panel `` - * created while the app is hidden never paints and agents open tabs whenever - * they like. The panel streams them like any other server tab. - */ -export function hostsServerTabInBackground( +export function rendersServerTabNatively( environmentId: EnvironmentId, primaryEnvironmentId: EnvironmentId | null, snapshot: RuntimeSnapshot | null | undefined, ): boolean { return ( desktopHostsServerTab(environmentId, primaryEnvironmentId, snapshot) && - snapshot?.automationOwner !== undefined + !hostsServerTabInBackground(environmentId, primaryEnvironmentId, snapshot) ); } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 6adcedfb7b41f..81cc28b5e1ca5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1165,6 +1165,8 @@ export interface DesktopBridge { onSnapShotEvent: (listener: (event: DesktopSnapShotEvent) => void) => () => void; onQuitShortcut: (listener: (event: QuitShortcutHintEvent) => void) => () => void; getWindowFullscreenState: () => boolean; + /** Whether the app window is on screen: not hidden and not minimized. */ + getWindowShown: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getWindowZoomFactor: () => number; getUpdateState: () => Promise; From 06510f526bb76b33ba89cb1a83efeefd65638d97 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 00:01:58 +0000 Subject: [PATCH 6/8] fix(desktop): background agent tabs move into the panel once T3 is shown A tab hosted in a never-shown window while T3 was hidden now moves into a panel webview when T3's window takes focus again, so Pick element, Capture, DevTools, Find, picture-in-picture and direct input come back. Registering that webview closes the background window. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/preview/Manager.test.ts | 11 +++- apps/desktop/src/preview/Manager.ts | 5 +- .../ElectronBrowserHost.browser.test.tsx | 3 ++ apps/web/src/browser/ElectronBrowserHost.tsx | 31 +++++++++-- apps/web/src/browser/previewRuntime.ts | 54 +++++++++++++++---- .../preview/ThreadPreviewMiniPlayer.tsx | 10 +++- 6 files changed, 97 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 0d20564c74d28..526712ba88653 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -722,7 +722,7 @@ describe("PreviewManager", () => { ); effectIt.effect( - "hosts an agent tab in a never-shown window that paints and closes with the tab", + "hosts an agent tab in a never-shown window until a panel webview takes it over", () => withManager((manager) => Effect.gen(function* () { @@ -767,6 +767,15 @@ describe("PreviewManager", () => { }); expect(state.webContentsId).toBe(55); expect(loadURL).toHaveBeenCalledWith("https://example.com/agent"); + // Back on screen, the renderer moves the tab into a panel webview. + fromId.mockReturnValue( + makeTestPreviewWebContents(async () => ({ + toJPEG: () => Buffer.from(""), + getSize: () => ({ width: 1, height: 1 }), + })), + ); + yield* manager.registerWebview("tab_agent", 42); + expect(window.destroy).toHaveBeenCalledOnce(); 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 125e1f9ec1f6f..87756a3076528 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2084,7 +2084,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const expectedGeneration = tabLifecycleGenerations.get(tabId); return yield* withTabLifecycleLock( tabId, - registerWebviewUnlocked(tabId, webContentsId, expectedGeneration), + registerWebviewUnlocked(tabId, webContentsId, expectedGeneration).pipe( + // The renderer moved a background tab into the panel once T3 was on screen again. + Effect.tap(() => Effect.sync(() => closeBackgroundWindow(tabId))), + ), ); }); diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 7b37fd5253ae7..37ed137b3cb8f 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -218,6 +218,9 @@ describe("ElectronBrowserHost", () => { url: `https://example.com/${tabId}`, }); expect(screen.container.querySelectorAll("webview")).toHaveLength(0); + // Back on screen, the tab moves into the panel, where every desktop tool works. + await act(() => window.dispatchEvent(new Event("focus"))); + expect(screen.container.querySelectorAll("webview")).toHaveLength(1); } }, ); diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index c2cb6045102ec..a1b00a3e21ff7 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -31,13 +31,20 @@ import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; import { openUrlInPreview } from "./openFileInPreview"; import { acquireDesktopTab } from "./desktopTabLifetime"; -import { hostsServerTabInBackground, rendersServerTabNatively } from "./previewRuntime"; +import { + type BackgroundHosting, + hostsServerTabInBackground, + readBackgroundHosting, + rendersServerTabNatively, + useBackgroundHosting, +} from "./previewRuntime"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; /** Every session this window renders as a hosted webview, in a fixed (non-display) order. */ function hostedBrowserSessions( previewByThreadKey: Record, primaryEnvironmentId: EnvironmentId | null, + hosting: BackgroundHosting, ) { const activeSessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { const threadRef = parseScopedThreadKey(threadKey); @@ -46,8 +53,18 @@ function hostedBrowserSessions( .filter( (snapshot) => snapshot.runtime !== "server" || - rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot) || - hostsServerTabInBackground(threadRef.environmentId, primaryEnvironmentId, snapshot), + rendersServerTabNatively( + threadRef.environmentId, + primaryEnvironmentId, + snapshot, + hosting, + ) || + hostsServerTabInBackground( + threadRef.environmentId, + primaryEnvironmentId, + snapshot, + hosting, + ), ) .map((snapshot) => ({ threadRef, @@ -56,6 +73,7 @@ function hostedBrowserSessions( threadRef.environmentId, primaryEnvironmentId, snapshot, + hosting, ), runtimeTabId: previewRuntimeTabId(threadRef, previewState.serverEpoch, snapshot.tabId), pictureInPicture: @@ -106,6 +124,7 @@ function openLinkInPreview({ tabId, url, background }: DesktopPreviewOpenLinkEve const source = hostedBrowserSessions( readActivePreviewSessions(), appAtomRegistry.get(primaryEnvironmentIdAtom), + readBackgroundHosting(), ).find((session) => session.runtimeTabId === tabId); if (!source) return; void openUrlInPreview({ @@ -137,7 +156,11 @@ export function ElectronBrowserHost() { const previewByThreadKey = useActivePreviewSessions(); const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); useDesktopHostedPreviewSessions(primaryEnvironmentId); - const sessions = hostedBrowserSessions(previewByThreadKey, primaryEnvironmentId); + const sessions = hostedBrowserSessions( + previewByThreadKey, + primaryEnvironmentId, + useBackgroundHosting(), + ); if (!isElectron) return null; return ( diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts index 10d0897ef697d..7e8e4d766503e 100644 --- a/apps/web/src/browser/previewRuntime.ts +++ b/apps/web/src/browser/previewRuntime.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { useSyncExternalStore } from "react"; import type { EnvironmentId, PreviewRuntime, PreviewSessionSnapshot } from "@t3tools/contracts"; import { isElectron } from "~/env"; @@ -61,17 +62,47 @@ function desktopHostsServerTab( ); } -// Fixed when a tab first appears: moving a page between hosts would replace it. -const backgroundHostedTabs = new Map(); +// Fixed when a tab first appears, until T3 is on screen again: moving a page between hosts replaces it. +let backgroundHostedTabs = new Map(); +const hostingListeners = new Set<() => void>(); + +// Its window takes focus when T3 is shown again. Background tabs then move to panel webviews, where +// every desktop tool works, and a new map makes memoized callers look again. +const returnBackgroundTabsToPanel = () => { + if (![...backgroundHostedTabs.values()].some(Boolean)) return; + backgroundHostedTabs = new Map([...backgroundHostedTabs.keys()].map((key) => [key, false])); + for (const listener of hostingListeners) listener(); +}; + +const subscribeBackgroundHosting = (listener: () => void) => { + hostingListeners.add(listener); + if (hostingListeners.size === 1) window.addEventListener("focus", returnBackgroundTabsToPanel); + return () => { + hostingListeners.delete(listener); + if (hostingListeners.size === 0) { + window.removeEventListener("focus", returnBackgroundTabsToPanel); + } + }; +}; + +export type BackgroundHosting = ReadonlyMap; + +export const readBackgroundHosting = (): BackgroundHosting => backgroundHostedTabs; + +export function useBackgroundHosting(): BackgroundHosting { + return useSyncExternalStore(subscribeBackgroundHosting, readBackgroundHosting); +} /** * A panel `` created while the app window is hidden or minimized never paints, so an * agent tab that appears then runs in a never-shown desktop window and the panel streams it. + * `hosting` is the map from `useBackgroundHosting`, passed so memoized callers recompute. */ export function hostsServerTabInBackground( environmentId: EnvironmentId, primaryEnvironmentId: EnvironmentId | null, snapshot: RuntimeSnapshot | null | undefined, + hosting: BackgroundHosting, ): boolean { if ( !snapshot || @@ -81,11 +112,10 @@ export function hostsServerTabInBackground( return false; } const key = `${environmentId}\u0000${snapshot.threadId}\u0000${snapshot.tabId}`; - let background = backgroundHostedTabs.get(key); - if (background === undefined) { - background = window.desktopBridge?.getWindowShown?.() === false; - backgroundHostedTabs.set(key, background); - } + const decided = backgroundHostedTabs.get(key) ?? hosting.get(key); + if (decided !== undefined) return decided; + const background = window.desktopBridge?.getWindowShown?.() === false; + backgroundHostedTabs.set(key, background); return background; } @@ -93,10 +123,11 @@ export function rendersServerTabNatively( environmentId: EnvironmentId, primaryEnvironmentId: EnvironmentId | null, snapshot: RuntimeSnapshot | null | undefined, + hosting: BackgroundHosting, ): boolean { return ( desktopHostsServerTab(environmentId, primaryEnvironmentId, snapshot) && - !hostsServerTabInBackground(environmentId, primaryEnvironmentId, snapshot) + !hostsServerTabInBackground(environmentId, primaryEnvironmentId, snapshot, hosting) ); } @@ -104,5 +135,10 @@ export function useRendersServerTabNatively( environmentId: EnvironmentId, snapshot: RuntimeSnapshot | null | undefined, ): boolean { - return rendersServerTabNatively(environmentId, useAtomValue(primaryEnvironmentIdAtom), snapshot); + return rendersServerTabNatively( + environmentId, + useAtomValue(primaryEnvironmentIdAtom), + snapshot, + useBackgroundHosting(), + ); } diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 3e4dba8d879ca..505d579d4b02a 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -24,7 +24,7 @@ import { import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; -import { rendersServerTabNatively } from "~/browser/previewRuntime"; +import { rendersServerTabNatively, useBackgroundHosting } from "~/browser/previewRuntime"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface"; import { @@ -138,10 +138,16 @@ function useBrowserMiniPlayerSource(threadRef: ScopedThreadRef | null, tabId: st runtimeTabId === null ? null : (state.byTabId[runtimeTabId]?.fittedSourceContent ?? null), ); const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); + const backgroundHosting = useBackgroundHosting(); const serverTab = threadRef !== null && snapshot?.runtime === "server" && - !rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot); + !rendersServerTabNatively( + threadRef.environmentId, + primaryEnvironmentId, + snapshot, + backgroundHosting, + ); const streamViewport = useStreamedSourceSize((state) => threadRef && tabId !== null && From 601b0fe0fabccb57fd4f8f44b2726d9ae8627f02 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 00:06:54 +0000 Subject: [PATCH 7/8] fix(desktop): only a viewed background agent tab moves into the panel Focusing T3 no longer reloads every background agent tab. A tab moves into a panel webview only once the panel shows it with T3's window focused, so tabs nobody looks at keep their live page. Co-Authored-By: Claude Opus 5.5 --- .../ElectronBrowserHost.browser.test.tsx | 27 ++++++++---- apps/web/src/browser/previewRuntime.ts | 42 ++++++++++++------- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 37ed137b3cb8f..024f3390c3fe5 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -67,6 +67,7 @@ import { import { reconcilePreviewServerSessions, resetPreviewStateForTests } from "~/previewStateStore"; import { AppAtomRegistryProvider } from "~/rpc/atomRegistry"; import { ElectronBrowserHost } from "./ElectronBrowserHost"; +import { useRendersServerTabNatively } from "./previewRuntime"; const threadRef = scopeThreadRef( EnvironmentId.make("host-webview-order"), @@ -187,14 +188,13 @@ describe("ElectronBrowserHost", () => { async ({ shown, tabId }) => { using _state = setup(); using _bridge = stubDesktopWindowShown(shown); + const agentTab: PreviewSessionSnapshot = { + ...snapshot(tabId, "2026-01-01T00:00:01.000Z"), + runtime: "server", + automationOwner: "agent-session", + }; reconcilePreviewServerSessions(threadRef, { - sessions: [ - { - ...snapshot(tabId, "2026-01-01T00:00:01.000Z"), - runtime: "server", - automationOwner: "agent-session", - }, - ], + sessions: [agentTab], serverEpoch: "server-a", revision: 1, }); @@ -218,7 +218,18 @@ describe("ElectronBrowserHost", () => { url: `https://example.com/${tabId}`, }); expect(screen.container.querySelectorAll("webview")).toHaveLength(0); - // Back on screen, the tab moves into the panel, where every desktop tool works. + // Back on screen, a tab nobody views keeps its live page; the viewed one moves into the panel. + await act(() => window.dispatchEvent(new Event("focus"))); + expect(screen.container.querySelectorAll("webview")).toHaveLength(0); + function Viewer() { + useRendersServerTabNatively(threadRef.environmentId, agentTab); + return null; + } + await using _viewer = await render( + + + , + ); await act(() => window.dispatchEvent(new Event("focus"))); expect(screen.container.querySelectorAll("webview")).toHaveLength(1); } diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts index 7e8e4d766503e..ebe7714a8c274 100644 --- a/apps/web/src/browser/previewRuntime.ts +++ b/apps/web/src/browser/previewRuntime.ts @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useSyncExternalStore } from "react"; +import { useEffect, useSyncExternalStore } from "react"; import type { EnvironmentId, PreviewRuntime, PreviewSessionSnapshot } from "@t3tools/contracts"; import { isElectron } from "~/env"; @@ -62,26 +62,25 @@ function desktopHostsServerTab( ); } -// Fixed when a tab first appears, until T3 is on screen again: moving a page between hosts replaces it. +// Fixed when a tab first appears, until someone views it with T3 on screen: moving a page between +// hosts replaces it, so a tab nobody looks at keeps its live page in the background window. let backgroundHostedTabs = new Map(); const hostingListeners = new Set<() => void>(); -// Its window takes focus when T3 is shown again. Background tabs then move to panel webviews, where -// every desktop tool works, and a new map makes memoized callers look again. -const returnBackgroundTabsToPanel = () => { - if (![...backgroundHostedTabs.values()].some(Boolean)) return; - backgroundHostedTabs = new Map([...backgroundHostedTabs.keys()].map((key) => [key, false])); +const hostingKey = (environmentId: EnvironmentId, snapshot: RuntimeSnapshot) => + `${environmentId}\u0000${snapshot.threadId}\u0000${snapshot.tabId}`; + +// A new map makes memoized callers look again. +const returnTabToPanel = (key: string) => { + if (backgroundHostedTabs.get(key) !== true) return; + backgroundHostedTabs = new Map(backgroundHostedTabs).set(key, false); for (const listener of hostingListeners) listener(); }; const subscribeBackgroundHosting = (listener: () => void) => { hostingListeners.add(listener); - if (hostingListeners.size === 1) window.addEventListener("focus", returnBackgroundTabsToPanel); return () => { hostingListeners.delete(listener); - if (hostingListeners.size === 0) { - window.removeEventListener("focus", returnBackgroundTabsToPanel); - } }; }; @@ -111,7 +110,7 @@ export function hostsServerTabInBackground( ) { return false; } - const key = `${environmentId}\u0000${snapshot.threadId}\u0000${snapshot.tabId}`; + const key = hostingKey(environmentId, snapshot); const decided = backgroundHostedTabs.get(key) ?? hosting.get(key); if (decided !== undefined) return decided; const background = window.desktopBridge?.getWindowShown?.() === false; @@ -131,14 +130,27 @@ export function rendersServerTabNatively( ); } +/** For the tab the panel shows: a background tab moves into the panel once T3 has focus. */ export function useRendersServerTabNatively( environmentId: EnvironmentId, snapshot: RuntimeSnapshot | null | undefined, ): boolean { - return rendersServerTabNatively( + const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); + const hosting = useBackgroundHosting(); + const background = hostsServerTabInBackground( environmentId, - useAtomValue(primaryEnvironmentIdAtom), + primaryEnvironmentId, snapshot, - useBackgroundHosting(), + hosting, ); + const backgroundKey = background && snapshot ? hostingKey(environmentId, snapshot) : null; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- moves the viewed tab into the panel when the app window has focus, which React does not track + useEffect(() => { + if (backgroundKey === null) return; + const moveToPanel = () => returnTabToPanel(backgroundKey); + if (document.hasFocus()) moveToPanel(); + window.addEventListener("focus", moveToPanel); + return () => window.removeEventListener("focus", moveToPanel); + }, [backgroundKey]); + return desktopHostsServerTab(environmentId, primaryEnvironmentId, snapshot) && !background; } From 45ae8051d6afcc2ee57a6c6ab95d94f0707d279a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 05:52:45 +0000 Subject: [PATCH 8/8] fix(desktop): background agent tabs keep their host so the page is never reloaded Moving a tab from its hidden window into a panel webview replaces the page, losing whatever the agent had built up in it. A tab's host is now fixed when it first appears, and the panel streams a background tab like a server tab. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/preview/Manager.test.ts | 3 +- apps/desktop/src/preview/Manager.ts | 2 +- .../ElectronBrowserHost.browser.test.tsx | 23 +++---- apps/web/src/browser/ElectronBrowserHost.tsx | 31 ++-------- apps/web/src/browser/previewRuntime.ts | 62 +++---------------- .../preview/ThreadPreviewMiniPlayer.tsx | 10 +-- 6 files changed, 24 insertions(+), 107 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 311453693a9c0..b2439d92c2f6d 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -722,7 +722,7 @@ describe("PreviewManager", () => { ); effectIt.effect( - "hosts an agent tab in a never-shown window until a panel webview takes it over", + "hosts an agent tab in a never-shown window until a panel webview replaces it", () => withManager((manager) => Effect.gen(function* () { @@ -767,7 +767,6 @@ describe("PreviewManager", () => { }); expect(state.webContentsId).toBe(55); expect(loadURL).toHaveBeenCalledWith("https://example.com/agent"); - // Back on screen, the renderer moves the tab into a panel webview. fromId.mockReturnValue( makeTestPreviewWebContents(async () => ({ toJPEG: () => Buffer.from(""), diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 4a5f35c0157e9..f7e78295b4ec6 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2086,7 +2086,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* withTabLifecycleLock( tabId, registerWebviewUnlocked(tabId, webContentsId, expectedGeneration).pipe( - // The renderer moved a background tab into the panel once T3 was on screen again. + // A reloaded renderer may host a background tab in a panel webview instead. Effect.tap(() => Effect.sync(() => closeBackgroundWindow(tabId))), ), ); diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 024f3390c3fe5..dac2848f4499e 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -67,7 +67,7 @@ import { import { reconcilePreviewServerSessions, resetPreviewStateForTests } from "~/previewStateStore"; import { AppAtomRegistryProvider } from "~/rpc/atomRegistry"; import { ElectronBrowserHost } from "./ElectronBrowserHost"; -import { useRendersServerTabNatively } from "./previewRuntime"; +import { rendersServerTabNatively } from "./previewRuntime"; const threadRef = scopeThreadRef( EnvironmentId.make("host-webview-order"), @@ -218,20 +218,15 @@ describe("ElectronBrowserHost", () => { url: `https://example.com/${tabId}`, }); expect(screen.container.querySelectorAll("webview")).toHaveLength(0); - // Back on screen, a tab nobody views keeps its live page; the viewed one moves into the panel. - await act(() => window.dispatchEvent(new Event("focus"))); + // Moving hosts would reload the agent's page, so it stays streamed once T3 is back. + window.desktopBridge = { + getWindowShown: () => true, + getWindowZoomFactor: () => 1, + } as never; + expect( + rendersServerTabNatively(threadRef.environmentId, threadRef.environmentId, agentTab), + ).toBe(false); expect(screen.container.querySelectorAll("webview")).toHaveLength(0); - function Viewer() { - useRendersServerTabNatively(threadRef.environmentId, agentTab); - return null; - } - await using _viewer = await render( - - - , - ); - await act(() => window.dispatchEvent(new Event("focus"))); - expect(screen.container.querySelectorAll("webview")).toHaveLength(1); } }, ); diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index a1b00a3e21ff7..c2cb6045102ec 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -31,20 +31,13 @@ import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; import { openUrlInPreview } from "./openFileInPreview"; import { acquireDesktopTab } from "./desktopTabLifetime"; -import { - type BackgroundHosting, - hostsServerTabInBackground, - readBackgroundHosting, - rendersServerTabNatively, - useBackgroundHosting, -} from "./previewRuntime"; +import { hostsServerTabInBackground, rendersServerTabNatively } from "./previewRuntime"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; /** Every session this window renders as a hosted webview, in a fixed (non-display) order. */ function hostedBrowserSessions( previewByThreadKey: Record, primaryEnvironmentId: EnvironmentId | null, - hosting: BackgroundHosting, ) { const activeSessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { const threadRef = parseScopedThreadKey(threadKey); @@ -53,18 +46,8 @@ function hostedBrowserSessions( .filter( (snapshot) => snapshot.runtime !== "server" || - rendersServerTabNatively( - threadRef.environmentId, - primaryEnvironmentId, - snapshot, - hosting, - ) || - hostsServerTabInBackground( - threadRef.environmentId, - primaryEnvironmentId, - snapshot, - hosting, - ), + rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot) || + hostsServerTabInBackground(threadRef.environmentId, primaryEnvironmentId, snapshot), ) .map((snapshot) => ({ threadRef, @@ -73,7 +56,6 @@ function hostedBrowserSessions( threadRef.environmentId, primaryEnvironmentId, snapshot, - hosting, ), runtimeTabId: previewRuntimeTabId(threadRef, previewState.serverEpoch, snapshot.tabId), pictureInPicture: @@ -124,7 +106,6 @@ function openLinkInPreview({ tabId, url, background }: DesktopPreviewOpenLinkEve const source = hostedBrowserSessions( readActivePreviewSessions(), appAtomRegistry.get(primaryEnvironmentIdAtom), - readBackgroundHosting(), ).find((session) => session.runtimeTabId === tabId); if (!source) return; void openUrlInPreview({ @@ -156,11 +137,7 @@ export function ElectronBrowserHost() { const previewByThreadKey = useActivePreviewSessions(); const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); useDesktopHostedPreviewSessions(primaryEnvironmentId); - const sessions = hostedBrowserSessions( - previewByThreadKey, - primaryEnvironmentId, - useBackgroundHosting(), - ); + const sessions = hostedBrowserSessions(previewByThreadKey, primaryEnvironmentId); if (!isElectron) return null; return ( diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts index ebe7714a8c274..46401812a63df 100644 --- a/apps/web/src/browser/previewRuntime.ts +++ b/apps/web/src/browser/previewRuntime.ts @@ -1,5 +1,4 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useSyncExternalStore } from "react"; import type { EnvironmentId, PreviewRuntime, PreviewSessionSnapshot } from "@t3tools/contracts"; import { isElectron } from "~/env"; @@ -62,46 +61,18 @@ function desktopHostsServerTab( ); } -// Fixed when a tab first appears, until someone views it with T3 on screen: moving a page between -// hosts replaces it, so a tab nobody looks at keeps its live page in the background window. -let backgroundHostedTabs = new Map(); -const hostingListeners = new Set<() => void>(); - -const hostingKey = (environmentId: EnvironmentId, snapshot: RuntimeSnapshot) => - `${environmentId}\u0000${snapshot.threadId}\u0000${snapshot.tabId}`; - -// A new map makes memoized callers look again. -const returnTabToPanel = (key: string) => { - if (backgroundHostedTabs.get(key) !== true) return; - backgroundHostedTabs = new Map(backgroundHostedTabs).set(key, false); - for (const listener of hostingListeners) listener(); -}; - -const subscribeBackgroundHosting = (listener: () => void) => { - hostingListeners.add(listener); - return () => { - hostingListeners.delete(listener); - }; -}; - -export type BackgroundHosting = ReadonlyMap; - -export const readBackgroundHosting = (): BackgroundHosting => backgroundHostedTabs; - -export function useBackgroundHosting(): BackgroundHosting { - return useSyncExternalStore(subscribeBackgroundHosting, readBackgroundHosting); -} +// Fixed when a tab first appears: Electron cannot move a page between a window and a +// ``, so changing hosts later would reload it and lose the agent's live page. +const backgroundHostedTabs = new Map(); /** * A panel `` created while the app window is hidden or minimized never paints, so an * agent tab that appears then runs in a never-shown desktop window and the panel streams it. - * `hosting` is the map from `useBackgroundHosting`, passed so memoized callers recompute. */ export function hostsServerTabInBackground( environmentId: EnvironmentId, primaryEnvironmentId: EnvironmentId | null, snapshot: RuntimeSnapshot | null | undefined, - hosting: BackgroundHosting, ): boolean { if ( !snapshot || @@ -110,8 +81,8 @@ export function hostsServerTabInBackground( ) { return false; } - const key = hostingKey(environmentId, snapshot); - const decided = backgroundHostedTabs.get(key) ?? hosting.get(key); + const key = `${environmentId}\u0000${snapshot.threadId}\u0000${snapshot.tabId}`; + const decided = backgroundHostedTabs.get(key); if (decided !== undefined) return decided; const background = window.desktopBridge?.getWindowShown?.() === false; backgroundHostedTabs.set(key, background); @@ -122,35 +93,16 @@ export function rendersServerTabNatively( environmentId: EnvironmentId, primaryEnvironmentId: EnvironmentId | null, snapshot: RuntimeSnapshot | null | undefined, - hosting: BackgroundHosting, ): boolean { return ( desktopHostsServerTab(environmentId, primaryEnvironmentId, snapshot) && - !hostsServerTabInBackground(environmentId, primaryEnvironmentId, snapshot, hosting) + !hostsServerTabInBackground(environmentId, primaryEnvironmentId, snapshot) ); } -/** For the tab the panel shows: a background tab moves into the panel once T3 has focus. */ export function useRendersServerTabNatively( environmentId: EnvironmentId, snapshot: RuntimeSnapshot | null | undefined, ): boolean { - const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); - const hosting = useBackgroundHosting(); - const background = hostsServerTabInBackground( - environmentId, - primaryEnvironmentId, - snapshot, - hosting, - ); - const backgroundKey = background && snapshot ? hostingKey(environmentId, snapshot) : null; - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- moves the viewed tab into the panel when the app window has focus, which React does not track - useEffect(() => { - if (backgroundKey === null) return; - const moveToPanel = () => returnTabToPanel(backgroundKey); - if (document.hasFocus()) moveToPanel(); - window.addEventListener("focus", moveToPanel); - return () => window.removeEventListener("focus", moveToPanel); - }, [backgroundKey]); - return desktopHostsServerTab(environmentId, primaryEnvironmentId, snapshot) && !background; + return rendersServerTabNatively(environmentId, useAtomValue(primaryEnvironmentIdAtom), snapshot); } diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 505d579d4b02a..3e4dba8d879ca 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -24,7 +24,7 @@ import { import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; -import { rendersServerTabNatively, useBackgroundHosting } from "~/browser/previewRuntime"; +import { rendersServerTabNatively } from "~/browser/previewRuntime"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface"; import { @@ -138,16 +138,10 @@ function useBrowserMiniPlayerSource(threadRef: ScopedThreadRef | null, tabId: st runtimeTabId === null ? null : (state.byTabId[runtimeTabId]?.fittedSourceContent ?? null), ); const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); - const backgroundHosting = useBackgroundHosting(); const serverTab = threadRef !== null && snapshot?.runtime === "server" && - !rendersServerTabNatively( - threadRef.environmentId, - primaryEnvironmentId, - snapshot, - backgroundHosting, - ); + !rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot); const streamViewport = useStreamedSourceSize((state) => threadRef && tabId !== null &&