diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 9b11a2283a273..cddb24f2f0d31 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_VISIBLE_CHANNEL = "desktop:preview-set-visible"; 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 00a58ecce4d5e..5771f70b8b41c 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -10,6 +10,7 @@ import { DesktopPreviewScreenshotArtifactSchema, DesktopPreviewSetAudioMutedInputSchema, DesktopPreviewSetColorSchemeInputSchema, + DesktopPreviewSetVisibleInputSchema, DesktopPreviewSetZoomFactorInputSchema, BrowserImportResult, BrowserImportSource, @@ -196,6 +197,15 @@ export const setAudioMuted = DesktopIpc.makeIpcMethod({ yield* manager.setAudioMuted(tabId, audioMuted); }), }); +export const setVisible = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_VISIBLE_CHANNEL, + payload: DesktopPreviewSetVisibleInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setVisible")(function* ({ tabId, visible }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setVisible(tabId, visible); + }), +}); export const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", @@ -452,6 +462,7 @@ export const methods = [ setColorScheme, setZoomFactor, setAudioMuted, + setVisible, openDevTools, clearCookies, clearCache, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9fe0e9efa1597..67e995eb9b25f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -355,6 +355,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 }), + setVisible: (tabId, visible) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_VISIBLE_CHANNEL, { tabId, visible }), 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 0d281c46f8605..96d4032635c7b 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -4,6 +4,7 @@ import { DesktopBrowserEvent } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; +import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as NodeEvents from "node:events"; @@ -62,6 +63,48 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect( + "replays a shown tab across backend restarts and reports its hide and detach lifecycle", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + host.setVisible(key, true); + host.attach(key, makeDebuggee().tab); + const announcement = [ + { type: "attached", ...key }, + { type: "visibility", ...key, visible: true }, + ]; + expect(yield* takeEvents(host, 2)).toEqual(announcement); + + const events = yield* Queue.unbounded(); + const reader = yield* host.events.pipe( + Stream.runForEach((line) => + Queue.offer(events, decodeEvent(new TextDecoder().decode(line))), + ), + Effect.forkScoped, + ); + expect(yield* Queue.take(events)).toEqual(announcement[0]); + expect(yield* Queue.take(events)).toEqual(announcement[1]); + + host.setVisible(key, false); + host.setVisible(key, false); + host.setVisible(key, true); + expect(yield* Queue.take(events)).toEqual({ type: "visibility", ...key, visible: false }); + expect(yield* Queue.take(events)).toEqual({ type: "visibility", ...key, visible: true }); + + host.detach(key); + expect(yield* Queue.take(events)).toEqual({ type: "detached", ...key }); + // Guest replacement retains the panel's presentation until the panel hides. + host.attach(key, makeDebuggee().tab); + expect(yield* Queue.take(events)).toEqual(announcement[0]); + expect(yield* Queue.take(events)).toEqual(announcement[1]); + host.setVisible(key, false); + expect(yield* Queue.take(events)).toEqual({ type: "visibility", ...key, visible: false }); + yield* Fiber.interrupt(reader); + expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]); + }).pipe(Effect.scoped), + ); + 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 cef42d4f35fc3..0cc65784e8415 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -61,6 +61,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 setVisible: (key: DesktopBrowserTabKey, visible: boolean) => void; readonly humanStartedDownload: (source: Electron.WebContents) => boolean; readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; readonly pointers: Stream.Stream<{ @@ -82,7 +83,16 @@ export const make = Effect.gen(function* () { }>(16); const runFork = Effect.runForkWith(yield* Effect.context()); const tabs = new Map(); + // Preserve panel visibility while its guest is detached or still attaching. + const visibleTabs = new Set(); const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); + const announcement = (key: DesktopBrowserTabKey): ReadonlyArray => + visibleTabs.has(keyOf(key)) + ? [ + { type: "attached", ...key }, + { type: "visibility", ...key, visible: true }, + ] + : [{ type: "attached", ...key }]; const relayFor = (tab: AttachedTab) => { if (tab.relay) return tab.relay; @@ -158,7 +168,15 @@ export const make = Effect.gen(function* () { }; tabs.set(id, tab); debuggee.debugger.on("message", tab.onMessage); - emit({ type: "attached", ...key }); + announcement(key).forEach(emit); + }; + + const setVisible = (key: DesktopBrowserTabKey, visible: boolean) => { + const id = keyOf(key); + if (visibleTabs.has(id) === visible) return; + if (visible) visibleTabs.add(id); + else visibleTabs.delete(id); + if (tabs.has(id)) emit({ type: "visibility", ...key, visible }); }; const handleCommandLine = (line: string) => @@ -190,7 +208,7 @@ export const make = Effect.gen(function* () { [...tabs.values()], (tab) => { tab.relay = null; - return PubSub.publish(outbox, { type: "attached", ...tab.key }); + return PubSub.publishAll(outbox, announcement(tab.key)); }, { discard: true }, ), @@ -209,6 +227,7 @@ export const make = Effect.gen(function* () { handleCommandLine, attach, detach, + setVisible, placeDownload, humanStartedDownload, }); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index af45365b237d3..92b47cd25737f 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,7 +1,7 @@ import * as NodeVM from "node:vm"; import { it as effectIt } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; +import { DesktopBrowserEvent, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { DesktopPreviewRecordingFrame, DesktopPreviewRecordingInputEvent, @@ -17,6 +17,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -3399,36 +3400,72 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("tells the server when a tab it renders natively closes", () => - Effect.gen(function* () { - const host = yield* DesktopBrowserHost.DesktopBrowserHost; - const serverTab = { threadId: "thread-1", tabId: "server-tab-1" }; - const lines = yield* Queue.unbounded(); - yield* host.events.pipe( - Stream.runForEach((line) => Queue.offer(lines, new TextDecoder().decode(line))), - Effect.forkScoped, - ); - const nextType = Queue.take(lines).pipe( - Effect.map((line) => /"type":"(\w+)"/.exec(line)?.[1]), - ); - const capturePage = vi.fn(async () => ({ - toPNG: () => Buffer.from("png"), - toJPEG: () => Buffer.from("jpeg"), - getSize: () => ({ width: 100, height: 80 }), - })); - fromId.mockReturnValue( - Object.assign(makeTestPreviewWebContents(capturePage, 42), { - isDevToolsOpened: () => false, - getUserAgent: () => "Electron", - }), - ); - const manager = yield* PreviewManager.PreviewManager; - yield* manager.createTab("tab_1", { serverTab }); - yield* manager.registerWebview("tab_1", 42); - expect(yield* nextType).toBe("attached"); - yield* manager.closeTab("tab_1"); - expect(yield* nextType).toBe("detached"); - }).pipe(Effect.provide(managerLayer()), Effect.scoped), + effectIt.effect( + "preserves a native server tab's visibility through navigation and clears it on close", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.DesktopBrowserHost; + const serverTab = { threadId: "thread-1", tabId: "server-tab-1" }; + const events = yield* Queue.unbounded(); + const decodeEvent = Schema.decodeUnknownEffect(Schema.fromJsonString(DesktopBrowserEvent)); + yield* host.events.pipe( + Stream.runForEach((line) => + decodeEvent(new TextDecoder().decode(line)).pipe( + Effect.flatMap((event) => Queue.offer(events, event)), + ), + ), + Effect.forkScoped, + ); + const capturePage = vi.fn(async () => ({ + toPNG: () => Buffer.from("png"), + toJPEG: () => Buffer.from("jpeg"), + getSize: () => ({ width: 100, height: 80 }), + })); + fromId.mockReturnValue( + Object.assign(makeTestPreviewWebContents(capturePage, 42), { + isDevToolsOpened: () => false, + getUserAgent: () => "Electron", + loadURL: vi.fn(async () => undefined), + }), + ); + const manager = yield* PreviewManager.PreviewManager; + yield* manager.createTab("tab_1", { serverTab }); + yield* manager.setVisible("tab_1", true); + yield* manager.registerWebview("tab_1", 42); + expect(yield* Queue.take(events)).toEqual({ type: "attached", ...serverTab }); + expect(yield* Queue.take(events)).toEqual({ + type: "visibility", + ...serverTab, + visible: true, + }); + + yield* manager.navigate("tab_1", "https://example.com"); + yield* manager.setVisible("tab_1", false); + expect(yield* Queue.take(events)).toEqual({ + type: "visibility", + ...serverTab, + visible: false, + }); + yield* manager.setVisible("tab_1", true); + expect(yield* Queue.take(events)).toEqual({ + type: "visibility", + ...serverTab, + visible: true, + }); + yield* manager.closeTab("tab_1"); + expect(yield* Queue.take(events)).toEqual({ + type: "visibility", + ...serverTab, + visible: false, + }); + expect(yield* Queue.take(events)).toEqual({ type: "detached", ...serverTab }); + + yield* manager.createTab("tab_1", { serverTab }); + yield* manager.registerWebview("tab_1", 42); + expect(yield* Queue.take(events)).toEqual({ type: "attached", ...serverTab }); + yield* manager.closeTab("tab_1"); + expect(yield* Queue.take(events)).toEqual({ type: "detached", ...serverTab }); + }).pipe(Effect.provide(managerLayer()), Effect.scoped), ); effectIt.effect("stops capture retries when the tab swaps during the retry delay", () => diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 17d8990fe170c..c3318ce6303d5 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1768,6 +1768,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); if (Option.isNone(tab)) return; const closedTab = tab.value; + if (closedTab.serverTab) browserHost.setVisible(closedTab.serverTab, false); if (closedTab.webContentsId != null) { yield* Effect.all( [ @@ -2025,6 +2026,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function audible: current?.audible ?? false, controller: current?.controller ?? "none", ...(current?.favicon ? { favicon: current.favicon } : {}), + ...(current?.serverTab ? { serverTab: current.serverTab } : {}), updatedAt, }; return [ @@ -2450,6 +2452,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* applyColorScheme(tabId, wc, colorScheme); }); + const setVisible = Effect.fn("PreviewManager.setVisible")(function* ( + tabId: string, + visible: boolean, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab) return yield* new PreviewTabNotFoundError({ tabId }); + if (tab.serverTab) browserHost.setVisible(tab.serverTab, visible); + }); + const setAudioMuted = Effect.fn("PreviewManager.setAudioMuted")(function* ( tabId: string, audioMuted: boolean, @@ -3516,6 +3527,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setAnnotationSendEnabled, setAudioMuted, setColorScheme, + setVisible, setMainWindow, setForwardedShortcuts: (shortcuts: ReadonlyArray) => Effect.sync(() => { @@ -3722,6 +3734,10 @@ export class PreviewManager extends Context.Service< tabId: string, audioMuted: boolean, ) => Effect.Effect; + readonly setVisible: ( + tabId: string, + visible: boolean, + ) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: ( partitions?: ReadonlyArray, @@ -3865,6 +3881,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { setColorScheme: operations.setColorScheme, setZoomFactor: operations.setZoomFactor, setAudioMuted: operations.setAudioMuted, + setVisible: operations.setVisible, 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 index 92157d210fe27..5dc636e99d9d4 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.test.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.test.ts @@ -34,6 +34,12 @@ it("receives desktop messages and exits while the parent keeps both input pipes write(3, { type: "attached", ...key }); write(5, { version: 1, type: "desktopTelemetryHello", electronPid: process.pid }); } else if (line === "attached") { + write(3, { type: "visibility", ...key, visible: true }); + } else if (line === "shown") { + write(3, { type: "visibility", ...key, visible: false }); + } else if (line === "hidden") { + write(3, { type: "visibility", ...key, visible: true }); + } else if (line === "shown-again") { write(3, { type: "detached", ...key }); } else if (line === "verified") { verified = true; diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 0d1db856c1e6b..f12ea1f0c1295 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; + readonly isVisible: (key: DesktopTabKey) => boolean; + readonly visibilityChanged: Stream.Stream; readonly endpoint: (key: DesktopTabKey) => Effect.Effect; readonly pointer: ( key: DesktopTabKey, @@ -66,6 +68,8 @@ const make = Effect.gen(function* () { const controlFd = config.desktopBrowserControlFd; const changes = yield* PubSub.unbounded<{ key: DesktopTabKey; attached: boolean }>(); const attachedTabs = new Set(); + const visibleTabs = new Set(); + const visibility = yield* PubSub.unbounded(); const inbound = new Map>(); const writeLock = yield* Semaphore.make(1); @@ -76,6 +80,8 @@ const make = Effect.gen(function* () { detached: Stream.empty, attached: Stream.empty, isAttached: () => Effect.succeed(false), + isVisible: () => false, + visibilityChanged: Stream.empty, endpoint: () => Effect.die("No desktop app is attached to this server."), pointer: () => Effect.void, }); @@ -117,8 +123,14 @@ const make = Effect.gen(function* () { case "attached": attachedTabs.add(id); return PubSub.publish(changes, { key, attached: true }); + case "visibility": + if (visibleTabs.has(id) === event.visible) return Effect.void; + if (event.visible) visibleTabs.add(id); + else visibleTabs.delete(id); + return PubSub.publish(visibility, key).pipe(Effect.asVoid); case "detached": { attachedTabs.delete(id); + visibleTabs.delete(id); const queue = inbound.get(id); return (queue ? Queue.shutdown(queue) : Effect.void).pipe( Effect.andThen(PubSub.publish(changes, { key, attached: false })), @@ -214,6 +226,8 @@ const make = Effect.gen(function* () { Stream.map((change) => change.key), ), isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))), + isVisible: (key) => visibleTabs.has(keyOf(key)), + visibilityChanged: Stream.fromPubSub(visibility), 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 c263772a85502..26a9d0d73a94e 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -233,6 +233,7 @@ const desktopRenders = (tabId: string) => { } return desktopTabs.has(tabId); }; +const desktopVisibleTabs = new Set(); const releasedDesktopTabs: Array = []; const desktopConnections: Array<{ endpoint: string; context: ReturnType }> = []; const testThread = { @@ -289,6 +290,8 @@ const dependencies = Layer.mergeAll( ), ), isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), + isVisible: (key) => desktopVisibleTabs.has(key.tabId), + visibilityChanged: Stream.empty, endpoint: (key) => Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => Effect.sync(() => releasedDesktopTabs.push(key.tabId)), @@ -338,6 +341,7 @@ beforeEach(() => { keyCopy = ""; focusedFrame = null; desktopTabs.clear(); + desktopVisibleTabs.clear(); desktopRendersNext = false; releasedDesktopTabs.length = 0; desktopConnections.length = 0; @@ -2065,6 +2069,67 @@ it.live("desktop file pickers stay native outside agent actions and remote contr ).pipe(Effect.provide(layer)), ); +it.live("prefers the shown native tab and keeps stream visibility independent of its panel", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const status = broker.invoke({ + scope, + operation: "status", + input: {}, + tabId: PreviewTabId.make(opened.tabId), + }); + expect(yield* status).toMatchObject({ visible: false }); + const later = (yield* Clock.currentTimeMillis) + 1; + const clock = vi.spyOn(Date, "now").mockReturnValue(later); + yield* Effect.addFinalizer(() => Effect.sync(() => clock.mockRestore())); + desktopRendersNext = true; + const background = yield* manager.open({ + threadId: scope.thread.threadId, + runtime: "server", + }); + yield* broker.invoke({ + scope, + operation: "status", + input: {}, + tabId: PreviewTabId.make(background.tabId), + }); + + desktopVisibleTabs.add(opened.tabId); + const shown = yield* broker.invoke({ + scope: asSession("new-agent"), + operation: "status", + input: {}, + }); + expect(shown).toMatchObject({ tabId: opened.tabId, visible: true }); + expect(shown.tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ tabId: opened.tabId, visible: true }), + expect.objectContaining({ tabId: background.tabId, visible: false }), + ]), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + yield* browser.attachViewer(viewerInput(opened.tabId, false)); + desktopVisibleTabs.delete(opened.tabId); + expect(yield* status).toMatchObject({ visible: true }); + }), + ); + const hidden = yield* status; + expect(hidden).toMatchObject({ visible: false }); + expect(hidden.tabs).toEqual( + expect.arrayContaining([expect.objectContaining({ tabId: opened.tabId, visible: false })]), + ); + }), + ).pipe(Effect.provide(layer)), +); + it.live("a desktop page the desktop takes back reconnects instead of closing", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 8dbd5310499fc..2ecad6756ad1c 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -496,6 +496,11 @@ const make = Effect.gen(function* () { }, }); + const seen = (tab: ServerTab) => + tab.viewers.size > 0 || + (tab.desktop !== null && + desktopChannel.isVisible({ threadId: tab.threadId, tabId: tab.tabId })); + const dialogStatus = (tab: ServerTab) => tab.dialog ? { @@ -589,7 +594,7 @@ const make = Effect.gen(function* () { liveTabs: [...tabs.values()].map((tab) => ({ threadId: tab.threadId, tabId: tab.tabId, - visible: tab.viewers.size > 0, + visible: seen(tab), })), }), ), @@ -1287,9 +1292,7 @@ const make = Effect.gen(function* () { .filter((tab) => tab.control.agentId === agentSessionId) .sort((left, right) => right.createdAt - left.createdAt)[0] ?? threadTabs.sort( - (left, right) => - Number(right.viewers.size > 0) - Number(left.viewers.size > 0) || - right.usedAt - left.usedAt, + (left, right) => Number(seen(right)) - Number(seen(left)) || right.usedAt - left.usedAt, )[0] ); }; @@ -1363,7 +1366,7 @@ const make = Effect.gen(function* () { const viewport = tab.page.viewportSize(); const status = { available: true, - visible: tab.viewers.size > 0, + visible: seen(tab), tabId: tab.tabId, url: url === "about:blank" ? null : url, title: null, @@ -1387,7 +1390,7 @@ const make = Effect.gen(function* () { ...(candidate.openerTabId === undefined ? {} : { openerTabId: candidate.openerTabId }), owner: tabOwner(candidate), ownedByCaller: candidate.control.agentId === agentSessionId, - visible: candidate.viewers.size > 0, + visible: seen(candidate), ...(candidate.profileId === undefined ? {} : { profileId: candidate.profileId }), })), ...profileStatus(), @@ -2478,6 +2481,10 @@ const make = Effect.gen(function* () { ), Effect.forkScoped, ); + yield* desktopChannel.visibilityChanged.pipe( + Stream.runForEach(() => Effect.sync(reportLiveTabs)), + Effect.forkScoped, + ); yield* Effect.sync(closeIdleAgentTabs).pipe( Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)), Effect.forkScoped, diff --git a/apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts b/apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts index b9fee70bf39fc..c282ee1d5b2fa 100644 --- a/apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts +++ b/apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as ServerSettings from "../../serverSettings.ts"; @@ -44,13 +45,34 @@ Effect.gen(function* () { Effect.forkScoped, ); const detached = yield* browser.detached.pipe(Stream.runHead, Effect.forkScoped); + const visibility = yield* Queue.unbounded(); + yield* browser.visibilityChanged.pipe( + Stream.runForEach((changed) => Queue.offer(visibility, changed)), + Effect.forkScoped, + ); yield* Console.log("ready"); if (!(yield* browser.awaitAttached(key, "5 seconds"))) { return yield* Effect.die("The browser attach message was not received."); } yield* Fiber.join(healthy); + if (browser.isVisible(key)) return yield* Effect.die("An attached tab started visible."); yield* Console.log("attached"); + for (const [visible, milestone] of [ + [true, "shown"], + [false, "hidden"], + [true, "shown-again"], + ] as const) { + const changed = yield* Queue.take(visibility); + if ( + changed.threadId !== key.threadId || + changed.tabId !== key.tabId || + browser.isVisible(key) !== visible + ) + return yield* Effect.die("The desktop visibility message was not applied."); + yield* Console.log(milestone); + } yield* Fiber.join(detached); + if (browser.isVisible(key)) return yield* Effect.die("A detached tab stayed visible."); const endpoint = yield* Effect.exit(Effect.scoped(browser.endpoint(key))); if (Exit.isSuccess(endpoint)) return yield* Effect.die("A detached tab received an endpoint."); yield* Console.log("verified"); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 7d6c86d77409e..c03304413b93f 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -209,6 +209,16 @@ export function HostedBrowserWebview(props: { ]); const active = presentation.visible && presentation.rect !== null; + useEffect(() => { + const bridge = window.desktopBridge?.preview; + if (!bridge || !serverDriven || !clientSettingsHydrated) return; + withDesktopTab(runtimeTabId, () => bridge.setVisible(runtimeTabId, active)); + }, [active, clientSettingsHydrated, runtimeTabId, serverDriven]); + useEffect(() => { + const bridge = window.desktopBridge?.preview; + if (!bridge || !serverDriven) return; + return () => withDesktopTab(runtimeTabId, () => bridge.setVisible(runtimeTabId, false)); + }, [runtimeTabId, serverDriven]); const lastRect = presentation.rect; const layoutZoomFactor = useBrowserLayoutZoomFactor(zoomFactor); const viewportWidth = viewport._tag === "fill" ? null : viewport.width; diff --git a/packages/contracts/src/desktopBrowser.ts b/packages/contracts/src/desktopBrowser.ts index c70b374910079..dabd213d5b66c 100644 --- a/packages/contracts/src/desktopBrowser.ts +++ b/packages/contracts/src/desktopBrowser.ts @@ -12,6 +12,7 @@ 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 }), + Schema.Struct({ type: Schema.Literal("visibility"), ...TabKey, visible: Schema.Boolean }), ]); export type DesktopBrowserEvent = typeof DesktopBrowserEvent.Type; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index cb48058b8cad3..711990063f96a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1004,6 +1004,11 @@ export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({ audioMuted: Schema.Boolean, }); +export const DesktopPreviewSetVisibleInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + visible: Schema.Boolean, +}); + export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ theme: DesktopPreviewAnnotationThemeSchema, }); @@ -1173,6 +1178,7 @@ export interface DesktopPreviewBridge { hardReload: (tabId: string) => Promise; setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise; setAudioMuted: (tabId: string, audioMuted: boolean) => Promise; + setVisible: (tabId: string, visible: boolean) => Promise; openDevTools: (tabId: string) => Promise; clearCookies: (environmentId: EnvironmentId, profileId?: string) => Promise; clearCache: (environmentId: EnvironmentId, profileId?: string) => Promise;