diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0f125c05de307..05c72eb707830 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -90,6 +90,7 @@ export const PREVIEW_SET_ZOOM_FACTOR_CHANNEL = "desktop:preview-set-zoom-factor" export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; +export const PREVIEW_SET_SHOWN_CHANNEL = "desktop:preview-set-shown"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 20b7bb4877d59..c4c33f6b8dca8 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -9,6 +9,7 @@ import { DesktopPreviewRegisterWebviewInputSchema, DesktopPreviewScreenshotArtifactSchema, DesktopPreviewSetAudioMutedInputSchema, + DesktopPreviewSetShownInputSchema, DesktopPreviewSetColorSchemeInputSchema, DesktopPreviewSetZoomFactorInputSchema, BrowserImportResult, @@ -193,6 +194,15 @@ const setAudioMuted = DesktopIpc.makeIpcMethod({ yield* manager.setAudioMuted(tabId, audioMuted); }), }); +const setShown = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_SHOWN_CHANNEL, + payload: DesktopPreviewSetShownInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setShown")(function* ({ tabId, shown }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setShown(tabId, shown); + }), +}); const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", @@ -432,6 +442,7 @@ export const methods = [ setColorScheme, setZoomFactor, setAudioMuted, + setShown, openDevTools, clearCookies, clearCache, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index da56807186ac5..72245779b9c7e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -348,6 +348,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), setAudioMuted: (tabId, audioMuted) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), + setShown: (tabId, shown) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_SHOWN_CHANNEL, { tabId, shown }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), listBrowserImportSources: () => ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL), diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 2e6e54a5293a7..b2a3886f87668 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -144,6 +144,44 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("re-sends shown to a backend that starts later and when a tab attaches again", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + host.attach(key, makeDebuggee().tab); + host.setShown(key, true); + const reader = yield* takeEvents(host, 5).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + host.detach(key); + host.attach(key, makeDebuggee().tab); + + expect(yield* Fiber.join(reader)).toEqual([ + { type: "attached", ...key }, + { type: "shown", ...key, shown: true }, + { type: "detached", ...key }, + { type: "attached", ...key }, + { type: "shown", ...key, shown: true }, + ]); + }), + ); + + it.effect("replays an explicit hidden report before announcing another tab", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const other = { ...key, tabId: "tab-2" }; + host.setShown(key, false); + host.attach(key, makeDebuggee().tab); + host.setShown(other, true); + host.attach(other, makeDebuggee().tab); + + expect(yield* takeEvents(host, 3)).toEqual([ + { type: "attached", ...key }, + { type: "shown", ...key, shown: false }, + { type: "attached", ...other }, + ]); + }), + ); + it.effect("drops replies from a relay the server released", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 3ac6c6d73caec..80a23417b7163 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -87,6 +87,7 @@ export class DesktopBrowserHost extends Context.Service< readonly handleCommandLine: (line: string) => Effect.Effect; readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; readonly detach: (key: DesktopBrowserTabKey) => void; + readonly setShown: (key: DesktopBrowserTabKey, shown: boolean | undefined) => void; readonly humanStartedDownload: (source: Electron.WebContents) => boolean; readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; readonly pointers: Stream.Stream<{ @@ -119,6 +120,9 @@ export const make = Effect.gen(function* () { const tabs = new Map(); /** Threads whose agent turn runs, by the server's account. */ const agentTurns = new Set(); + // Kept apart from attachment: the renderer can show a webview before its debugger attaches, + // and the server forgets `shown` on detach, for example while DevTools holds the debugger. + const shownTabs = new Map(); const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); const nextScreenshot = (tab: AttachedTab) => { @@ -456,6 +460,19 @@ export const make = Effect.gen(function* () { debuggee.debugger.on("message", tab.onMessage); debuggee.webContents.on("did-navigate", tab.onNewDocument); emit({ type: "attached", ...key }); + const shown = shownTabs.get(id); + if (shown !== undefined) emit({ type: "shown", ...key, shown }); + }; + + const setShown = (key: DesktopBrowserTabKey, shown: boolean | undefined) => { + const id = keyOf(key); + if (shown === undefined) { + shownTabs.delete(id); + return; + } + if (shownTabs.get(id) === shown) return; + shownTabs.set(id, shown); + if (tabs.has(id)) emit({ type: "shown", ...key, shown }); }; const handleCommandLine = (line: string) => @@ -494,7 +511,14 @@ export const make = Effect.gen(function* () { [...tabs.values()], (tab) => { dropRelay(tab); - return PubSub.publish(outbox, { type: "attached", ...tab.key }); + const shown = shownTabs.get(keyOf(tab.key)); + return PubSub.publish(outbox, { type: "attached", ...tab.key }).pipe( + Effect.andThen( + shown !== undefined + ? PubSub.publish(outbox, { type: "shown", ...tab.key, shown }) + : Effect.void, + ), + ); }, { discard: true }, ); @@ -514,6 +538,7 @@ export const make = Effect.gen(function* () { handleCommandLine, attach, detach, + setShown, placeDownload, humanStartedDownload, }); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 0087444a8412c..2d9bec73fdaa8 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1685,6 +1685,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); if (Option.isNone(tab)) return; const closedTab = tab.value; + if (closedTab.serverTab) browserHost.setShown(closedTab.serverTab, undefined); if (closedTab.webContentsId != null) { yield* Effect.all( [ @@ -1893,6 +1894,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* rendererHistory.register(wc, { surface: "preview" }); const webContentsId = wc.id; wc.once("destroyed", () => { + runFork(hideDestroyedGuest(webContentsId)); runFork(detachControlSession(webContentsId)); }); yield* ensureControlSession(wc).pipe( @@ -2346,6 +2348,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* applyColorScheme(tabId, wc, colorScheme); }); + const setShown = Effect.fn("PreviewManager.setShown")(function* (tabId: string, shown: boolean) { + const serverTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.serverTab; + if (serverTab) browserHost.setShown(serverTab, shown); + }); + + // A window reload or guest crash destroys the guest without the renderer reporting it hidden. + // The renderer reports again once a new guest registers. + const hideDestroyedGuest = Effect.fnUntraced(function* (webContentsId: number) { + for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) { + if (tab.webContentsId === webContentsId && tab.serverTab) { + browserHost.setShown(tab.serverTab, false); + } + } + }); + const setAudioMuted = Effect.fn("PreviewManager.setAudioMuted")(function* ( tabId: string, audioMuted: boolean, @@ -3307,6 +3324,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setAnnotationTheme, setAnnotationSendEnabled, setAudioMuted, + setShown, setColorScheme, setMainWindow, setForwardedShortcuts: (shortcuts: ReadonlyArray) => @@ -3512,6 +3530,7 @@ export class PreviewManager extends Context.Service< tabId: string, audioMuted: boolean, ) => Effect.Effect; + readonly setShown: (tabId: string, shown: boolean) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: ( partitions?: ReadonlyArray, @@ -3652,6 +3671,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { setColorScheme: operations.setColorScheme, setZoomFactor: operations.setZoomFactor, setAudioMuted: operations.setAudioMuted, + setShown: operations.setShown, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) { yield* browserSession diff --git a/apps/server/src/preview/DesktopBrowserChannel.test.ts b/apps/server/src/preview/DesktopBrowserChannel.test.ts new file mode 100644 index 0000000000000..ab5464cc4f6f7 --- /dev/null +++ b/apps/server/src/preview/DesktopBrowserChannel.test.ts @@ -0,0 +1,67 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeStream from "@effect/platform-node/NodeStream"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { vi } from "vite-plus/test"; + +import * as ServerConfig from "../config.ts"; +import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; + +vi.mock("@effect/platform-node/NodeStream", () => ({ fromReadable: vi.fn() })); +vi.mock("node:net", () => ({ + Socket: class { + destroy() {} + }, +})); + +it.effect("protects unknown desktop visibility until an explicit hidden report arrives", () => + Effect.gen(function* () { + const input = yield* Queue.unbounded(); + vi.mocked(NodeStream.fromReadable).mockReturnValue(Stream.fromQueue(input)); + const config = yield* ServerConfig.ServerConfig; + yield* Effect.gen(function* () { + const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel; + const key = { threadId: "thread-1", tabId: "tab-1" }; + const send = (event: object) => + Queue.offer(input, new TextEncoder().encode(`${JSON.stringify(event)}\n`)); + + expect(channel.isShown(key)).toBe(true); + yield* send({ type: "attached", ...key }); + yield* channel.awaitAttached(key, "5 seconds"); + expect(channel.isShown(key)).toBe(true); + + yield* send({ type: "shown", ...key, shown: false }); + yield* send({ type: "attached", threadId: key.threadId, tabId: "sentinel-1" }); + yield* channel.awaitAttached({ threadId: key.threadId, tabId: "sentinel-1" }, "5 seconds"); + expect(channel.isShown(key)).toBe(false); + + yield* send({ type: "shown", ...key, shown: true }); + yield* send({ type: "attached", threadId: key.threadId, tabId: "sentinel-2" }); + yield* channel.awaitAttached({ threadId: key.threadId, tabId: "sentinel-2" }, "5 seconds"); + expect(channel.isShown(key)).toBe(true); + + yield* send({ type: "shown", ...key, shown: false }); + yield* send({ type: "detached", ...key }); + yield* send({ type: "attached", threadId: key.threadId, tabId: "sentinel-3" }); + yield* channel.awaitAttached({ threadId: key.threadId, tabId: "sentinel-3" }, "5 seconds"); + expect(channel.isShown(key)).toBe(true); + }).pipe( + Effect.provide( + DesktopBrowserChannel.layer.pipe( + Layer.provide( + ServerConfig.layer({ ...config, desktopBrowserFd: 41, desktopBrowserControlFd: 42 }), + ), + ), + ), + ); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "desktop-browser-channel-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + ), +); diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index d407fc78faf57..65129274206da 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -44,6 +44,8 @@ export class DesktopBrowserChannel extends Context.Service< readonly detached: Stream.Stream; readonly attached: Stream.Stream; readonly isAttached: (key: DesktopTabKey) => Effect.Effect; + /** Whether a desktop window draws the tab on screen, which no streamed viewer counts. */ + readonly isShown: (key: DesktopTabKey) => boolean; readonly endpoint: (key: DesktopTabKey) => Effect.Effect; readonly pointer: ( key: DesktopTabKey, @@ -60,6 +62,8 @@ const make = Effect.gen(function* () { const controlFd = config.desktopBrowserControlFd; const changes = yield* PubSub.unbounded<{ key: DesktopTabKey; attached: boolean }>(); const attachedTabs = new Set(); + // Missing reports protect desktop tabs until the renderer explicitly marks them hidden. + const shownTabs = new Map(); const inbound = new Map>(); const writeLock = yield* Semaphore.make(1); @@ -83,6 +87,7 @@ const make = Effect.gen(function* () { pointer: Effect.fn("DesktopBrowserChannel.pointer")(function () { return Effect.void; }), + isShown: () => false, }); } @@ -123,14 +128,19 @@ const make = Effect.gen(function* () { return PubSub.publish(changes, { key, attached: true }); case "detached": { attachedTabs.delete(id); + shownTabs.delete(id); const queue = inbound.get(id); return (queue ? Queue.shutdown(queue) : Effect.void).pipe( Effect.andThen(PubSub.publish(changes, { key, attached: false })), ); } + case "shown": + shownTabs.set(id, event.shown); + return Effect.void; } }), Effect.catchCause((cause) => Effect.logWarning("desktop browser channel stopped", { cause })), + Effect.ensuring(Effect.sync(() => shownTabs.clear())), Effect.forkScoped, ); @@ -221,6 +231,7 @@ const make = Effect.gen(function* () { isAttached: Effect.fn("DesktopBrowserChannel.isAttached")(function (key) { return Effect.sync(() => attachedTabs.has(keyOf(key))); }), + isShown: (key) => shownTabs.get(keyOf(key)) ?? true, endpoint, turn: Effect.fn("DesktopBrowserChannel.turn")(function (threadId, active) { return command({ type: "turn", threadId, active }); diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 15b2abdf1aa6f..7d35ef0b3beb6 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -313,6 +313,7 @@ const desktopRenders = (tabId: string) => { } return desktopTabs.has(tabId); }; +const shownDesktopTabs = new Set(); const releasedDesktopTabs: Array = []; const desktopTurns = new NodeEvents.EventEmitter(); const desktopConnections: Array<{ endpoint: string; context: ReturnType }> = []; @@ -371,10 +372,12 @@ const dependencies = Layer.mergeAll( ), ), isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), + isShown: (key) => shownDesktopTabs.has(key.tabId), endpoint: (key) => Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => Effect.sync(() => { releasedDesktopTabs.push(key.tabId); + desktopDetaches.emit("release", key.tabId); desktopReleased.resolve(); }), ), @@ -446,6 +449,7 @@ beforeEach(() => { keyCopy = ""; focusedFrame = null; desktopTabs.clear(); + shownDesktopTabs.clear(); desktopRendersNext = false; desktopPageUrl = "about:blank"; desktopSizeAnswer = null; @@ -2224,3 +2228,72 @@ it.live("failed session and browser launches release capacity, as does closing a }), ).pipe(Effect.provide(layer)), ); + +it.live("a full server takes back the slot of the longest idle agent tab nobody views", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + yield* ServerBrowser.ServerBrowser; + yield* Effect.yieldNow; + const now = vi.spyOn(Date, "now").mockReturnValue(0); + yield* Effect.addFinalizer(() => Effect.sync(() => now.mockRestore())); + const open = (providerSessionId: string) => { + desktopRendersNext = true; + return broker.invoke({ + scope: asSession(providerSessionId), + operation: "open", + input: { reuseExistingTab: false, open: false }, + }); + }; + const opened: Array = []; + for (let index = 0; index < 32; index++) { + now.mockReturnValue(index); + opened.push((yield* open(`agent-${Math.floor(index / 8)}`)).tabId!); + } + + now.mockReturnValue(100); + yield* broker.invoke({ + scope: asSession("agent-observer"), + operation: "status", + tabId: PreviewTabId.make(opened[0]!), + input: {}, + }); + + // Inside the idle window every desktop tab keeps its slot. + now.mockReturnValue(29 * 60 * 1000); + expect(yield* open("agent-new").pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); + expect(releasedDesktopTabs).toEqual([]); + + now.mockReturnValue(31 * 60 * 1000); + // A session at its own limit couldn't use a freed slot, so nothing closes for it. + expect(yield* open("agent-3").pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); + expect(releasedDesktopTabs).toEqual([]); + // Tabs a desktop window shows keep their slots however long they sit idle. + for (const tabId of opened) shownDesktopTabs.add(tabId); + expect(yield* open("agent-new").pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); + expect(releasedDesktopTabs).toEqual([]); + shownDesktopTabs.clear(); + shownDesktopTabs.add(opened[1]!); + const events = yield* manager.subscribeEvents; + const releases = yield* Queue.unbounded(); + const recordRelease = (tabId: string) => Queue.offerUnsafe(releases, tabId); + desktopDetaches.on("release", recordRelease); + yield* Effect.addFinalizer(() => + Effect.sync(() => desktopDetaches.off("release", recordRelease)), + ); + const fresh = yield* open("agent-new"); + expect(fresh.tabId).toBeDefined(); + expect(yield* Queue.take(releases)).toBe(opened[2]); + yield* Stream.fromSubscription(events).pipe( + Stream.filter((event) => event.type === "closed" && event.tabId === opened[2]), + Stream.runHead, + ); + expect(releasedDesktopTabs).toEqual([opened[2]]); + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + expect(sessions.map((session) => session.tabId).toSorted()).toEqual( + [...opened.filter((tabId) => tabId !== opened[2]), fresh.tabId!].toSorted(), + ); + }), + ).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 6257a686764f1..77a0b30d8fe7e 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1375,15 +1375,15 @@ const make = Effect.gen(function* () { }); }); - const atTabLimit = (agentSessionId: string) => { - const opening = [...pendingTabs.entries()].filter(([key]) => !tabs.has(key)); - return ( - tabs.size + opening.length >= SERVER_TAB_LIMIT || - [...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length + - opening.filter(([, pending]) => pending.agentId === agentSessionId).length >= - AGENT_TAB_LIMIT - ); - }; + const openingReservations = () => + [...pendingTabs.entries()].filter(([key]) => !tabs.has(key)).map(([, pending]) => pending); + const serverAtTabLimit = () => tabs.size + openingReservations().length >= SERVER_TAB_LIMIT; + const agentAtTabLimit = (agentSessionId: string) => + [...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length + + openingReservations().filter((pending) => pending.agentId === agentSessionId).length >= + AGENT_TAB_LIMIT; + const atTabLimit = (agentSessionId: string) => + serverAtTabLimit() || agentAtTabLimit(agentSessionId); const assertTabCapacity = (agentSessionId: string) => { if (atTabLimit(agentSessionId)) @@ -1393,16 +1393,40 @@ const make = Effect.gen(function* () { ); }; + // The desktop draws its tabs in its own webview, so they never get a streamed viewer. + const isVisible = (tab: ServerTab) => + tab.viewers.size > 0 || (tab.desktop !== null && desktopChannel.isShown(tab)); + + const idleAgentTabs = () => { + const cutoff = Date.now() - AGENT_TAB_IDLE_MS; + return [...tabs.values()].filter( + (tab) => tab.control.agentId !== null && !isVisible(tab) && tab.usedAt < cutoff, + ); + }; + + // The desktop app renders every server tab, so idle desktop agent tabs would otherwise + // hold the server's budget forever. Hidden ones give up their slots, longest idle first, + // only when an agent needs one and has room for it. + const freeIdleAgentSlot = (agentSessionId: string) => { + if (!serverAtTabLimit() || agentAtTabLimit(agentSessionId)) return; + const longestIdle = idleAgentTabs().sort((left, right) => left.usedAt - right.usedAt)[0]; + if (longestIdle) dropTab(longestIdle, true); + }; + // A Playwright launch must settle before its capacity can be released. const openTab = Effect.fn("ServerBrowser.openTab")(function* ( input: Parameters[0], + options: { readonly reclaimIdleSlot?: boolean } = {}, ) { let key: string = NodeCrypto.randomUUID(); const reservation = { agentId: input.automationOwner ?? null }; return yield* Effect.acquireUseRelease( Effect.try({ try: () => { - if (input.automationOwner !== undefined) assertTabCapacity(input.automationOwner); + if (input.automationOwner !== undefined) { + if (options.reclaimIdleSlot) freeIdleAgentSlot(input.automationOwner); + assertTabCapacity(input.automationOwner); + } pendingTabs.set(key, reservation); }, catch: ServerBrowserPage.toOperationError, @@ -1442,16 +1466,7 @@ const make = Effect.gen(function* () { // Desktop browsing bypasses streamed viewers and server input tracking, // so these idle signals only apply to headless tabs. const closeIdleAgentTabs = () => { - const cutoff = Date.now() - AGENT_TAB_IDLE_MS; - for (const tab of tabs.values()) { - if ( - tab.control.agentId !== null && - tab.desktop === null && - tab.viewers.size === 0 && - tab.usedAt < cutoff - ) - dropTab(tab, true); - } + for (const tab of idleAgentTabs()) if (tab.desktop === null) dropTab(tab, true); }; const latestThreadTab = (threadId: string, agentSessionId?: string) => { @@ -1984,14 +1999,17 @@ const make = Effect.gen(function* () { const tab = existing ?? (await runPromise( - openTab({ - threadId: request.threadId, - ...(url ? { url } : {}), - ...(profileId === undefined ? {} : { profileId }), - runtime: "server", - reveal: false, - automationOwner: request.agentSessionId, - }), + openTab( + { + threadId: request.threadId, + ...(url ? { url } : {}), + ...(profileId === undefined ? {} : { profileId }), + runtime: "server", + reveal: false, + automationOwner: request.agentSessionId, + }, + { reclaimIdleSlot: true }, + ), )); if (existing?.dialog) throw new BrowserControlInterrupted( diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 70eef380dbff9..34c7978b7b40b 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -135,6 +135,9 @@ export function HostedBrowserWebview(props: { const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); + const active = presentation.visible && presentation.rect !== null; + const shown = active || pictureInPicture; + const reportedShown = useEffectEvent(() => serverDriven && shown); const remountCrashedWebview = useEffectEvent(() => { setRecoverySrc(initialUrl ?? initialSrc); setWebviewGeneration((generation) => generation + 1); @@ -162,6 +165,7 @@ export function HostedBrowserWebview(props: { const webContentsId = webview.getWebContentsId(); if (Number.isInteger(webContentsId) && webContentsId > 0) { await bridge.registerWebview(runtimeTabId, webContentsId); + withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, reportedShown())); } } catch {} })(); @@ -203,7 +207,6 @@ export function HostedBrowserWebview(props: { webviewGeneration, ]); - const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; const layoutZoomFactor = useBrowserLayoutZoomFactor(zoomFactor); const viewportWidth = viewport._tag === "fill" ? null : viewport.width; @@ -289,6 +292,14 @@ export function HostedBrowserWebview(props: { viewportWidth, ]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Report the mounted webview's visibility to the desktop host. + useEffect(() => { + const bridge = window.desktopBridge?.preview; + if (!clientSettingsHydrated || !bridge || !serverDriven) return; + withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, shown)); + return () => withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, false)); + }, [clientSettingsHydrated, runtimeTabId, serverDriven, shown]); + if (!clientSettingsHydrated || !config) return null; const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; diff --git a/packages/contracts/src/desktopBrowser.ts b/packages/contracts/src/desktopBrowser.ts index 17d256f0d8d3e..52fb66885adfe 100644 --- a/packages/contracts/src/desktopBrowser.ts +++ b/packages/contracts/src/desktopBrowser.ts @@ -12,6 +12,8 @@ export const DesktopBrowserEvent = Schema.Union([ /** 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 }), + /** Its `` started or stopped being on screen in a desktop window. */ + Schema.Struct({ type: Schema.Literal("shown"), ...TabKey, shown: Schema.Boolean }), ]); export type DesktopBrowserEvent = typeof DesktopBrowserEvent.Type; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5aa9acf2773a7..b2f12cb83d6a9 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -993,6 +993,11 @@ export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({ audioMuted: Schema.Boolean, }); +export const DesktopPreviewSetShownInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + shown: Schema.Boolean, +}); + export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ theme: DesktopPreviewAnnotationThemeSchema, }); @@ -1165,6 +1170,8 @@ export interface DesktopPreviewBridge { hardReload: (tabId: string) => Promise; setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise; setAudioMuted: (tabId: string, audioMuted: boolean) => Promise; + /** Tells the tab's server whether its webview is on screen. */ + setShown: (tabId: string, shown: boolean) => Promise; openDevTools: (tabId: string) => Promise; clearCookies: (environmentId: EnvironmentId, profileId?: string) => Promise; clearCache: (environmentId: EnvironmentId, profileId?: string) => Promise;