diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 9b6b2d6dd7bdf..44788cb05ee38 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -2255,6 +2255,33 @@ it.live("agents read a human's tab with no arguments and act on it only while no ).pipe(Effect.provide(layer)), ); +it.live("preview_open reports a tab it shows as visible once the client attaches", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; + yield* Effect.yieldNow; + // Like a real client, the stream attaches asynchronously after the reveal request arrives. + yield* Effect.gen(function* () { + let event; + do event = yield* PubSub.take(events); + while (event.type !== "navigated" || !event.snapshot.revealRequest); + yield* browser.attachViewer(viewerInput(event.tabId, false)).pipe(Effect.forkScoped); + }).pipe(Effect.forkScoped); + + const opened = yield* broker.invoke({ + scope, + operation: "open", + input: { reuseExistingTab: false, open: true }, + }); + + expect(opened.visible).toBe(true); + }), + ).pipe(Effect.provide(layer)), +); + it.live("a session's own tab stays its default while other sessions' tabs are listed", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 4b7d9c2805bb1..a1e534aa57731 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -106,10 +106,24 @@ const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; /** A dialog blocks page script, so a desktop size read gives up after this. */ const DESKTOP_VIEWPORT_READ_TIMEOUT = "1 second"; +// A revealed tab's client opens the panel and attaches its stream a few round trips later, remote ones included. +const REVEAL_VIEWER_WAIT_MS = 1000; const sleepUntil = (deadline: number) => new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); +const awaitViewer = async (tab: ServerTab, timeoutMs: number) => { + const attached = (tab.viewerAttached ??= Promise.withResolvers()); + let timer: ReturnType | undefined; + await Promise.race([ + attached.promise, + new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, timeoutMs)); + }), + ]); + clearTimeout(timer); +}; + const EDITABLE_AT_POINT_SCRIPT = `(x, y) => { let element = document.elementFromPoint(x, y); while (element && element.shadowRoot) { @@ -303,6 +317,7 @@ interface ServerTab { readonly createdAt: number; usedAt: number; readonly viewers: Set; + viewerAttached: PromiseWithResolvers | null; readonly consoleEntries: Array; readonly networkEntries: Array; readonly actionTimeline: Array; @@ -755,6 +770,7 @@ const make = Effect.gen(function* () { if (tabs.get(key) !== tab) return; tabs.delete(key); tab.closing = true; + tab.viewerAttached?.resolve(); clearAbortedNavigation(tab); // A desktop page outlives the connection unless its session closed with it. const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; @@ -880,6 +896,7 @@ const make = Effect.gen(function* () { createdAt: Date.now(), usedAt: Date.now(), viewers: new Set(), + viewerAttached: null, consoleEntries: [], networkEntries: [], actionTimeline: [], @@ -2135,6 +2152,7 @@ const make = Effect.gen(function* () { }); } const reveal = open.open ?? open.show; + let shown: Promise | undefined; if (reveal !== false) { await runPromise( manager.requestReveal({ @@ -2143,7 +2161,14 @@ const make = Effect.gen(function* () { force: reveal === true, }), ); + // Only an explicit show is sure to reach a client; desktop tabs are visible without a viewer. + if (reveal === true && tab.viewers.size === 0 && tab.desktop === null) + shown = awaitViewer( + tab, + Math.min(REVEAL_VIEWER_WAIT_MS, deadlineMs - performance.now()), + ); } + await shown; return statusWithTitle(tab, request.agentSessionId); }, deadlineMs, @@ -2668,6 +2693,8 @@ const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.sync(() => { tab.viewers.add(viewer); + tab.viewerAttached?.resolve(); + tab.viewerAttached = null; reportLiveTabs(); }), () => diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index fa2c46717318a..597ae26ca0341 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -71,6 +71,9 @@ export const PreviewAutomationProfile = Schema.Struct({ }); export type PreviewAutomationProfile = typeof PreviewAutomationProfile.Type; +const VISIBLE_DESCRIPTION = + "True while a client shows this tab: a streamed live view, or the desktop app's own browser panel."; + /** Why the tab shows the browser's error page instead of the page it tried to load. */ export const PreviewAutomationLoadError = Schema.Struct({ url: Schema.String, @@ -81,7 +84,7 @@ export type PreviewAutomationLoadError = typeof PreviewAutomationLoadError.Type; export const PreviewAutomationStatus = Schema.Struct({ available: Schema.Boolean, - visible: Schema.Boolean, + visible: Schema.Boolean.annotate({ description: VISIBLE_DESCRIPTION }), tabId: Schema.NullOr(PreviewTabId), url: Schema.NullOr(Schema.String), title: Schema.NullOr(Schema.String), @@ -128,7 +131,7 @@ export const PreviewAutomationStatus = Schema.Struct({ openerTabId: Schema.optional(PreviewTabId), owner: Schema.optional(PreviewAutomationTabOwner), ownedByCaller: Schema.optional(Schema.Boolean), - visible: Schema.optional(Schema.Boolean), + visible: Schema.optional(Schema.Boolean.annotate({ description: VISIBLE_DESCRIPTION })), profileId: Schema.optional(BrowserProfileId), }), ),