From 5f9afdbe409c8004a7470c674bd369d9536e2319 Mon Sep 17 00:00:00 2001 From: davidesala Date: Sun, 11 Oct 2026 15:04:24 +0200 Subject: [PATCH 1/3] fix(server): desktop attach timeouts no longer split browser pages --- apps/server/src/preview/ServerBrowser.test.ts | 205 +++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 126 ++++++++--- 2 files changed, 299 insertions(+), 32 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 385223a42d397..5a02a7dd8a37b 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -51,8 +51,11 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ } async connectDesktopPage(endpoint: string) { const context = makeContext(); - desktopConnections.push({ endpoint, context }); - return { browser: { close: async () => {} }, page: context.page as unknown as Page }; + const close = vi.fn(async () => desktopConnectionClosed?.resolve()); + desktopConnections.push({ endpoint, context, close }); + desktopConnectionStarted?.resolve(); + await desktopConnectionGate?.promise; + return { browser: { close }, page: context.page as unknown as Page }; } async close() { for (const context of contexts) await context.close(); @@ -158,6 +161,10 @@ let clipboardBinding: ClipboardBinding | null = null; let contextFailure: Error | null = null; /** Server tabs the fake desktop renders, and the endpoints the server connected to. */ let desktopRendersNext = false; +let desktopAvailable = false; +let desktopConnectionGate: PromiseWithResolvers | null = null; +let desktopConnectionStarted: PromiseWithResolvers | null = null; +let desktopConnectionClosed: PromiseWithResolvers | null = null; /** Pages the fake desktop takes back or returns; the channel's streams emit them. */ const desktopDetaches = new NodeEvents.EventEmitter(); const desktopTabs = new Set(); @@ -169,7 +176,11 @@ const desktopRenders = (tabId: string) => { return desktopTabs.has(tabId); }; const releasedDesktopTabs: Array = []; -const desktopConnections: Array<{ endpoint: string; context: ReturnType }> = []; +const desktopConnections: Array<{ + endpoint: string; + context: ReturnType; + close: ReturnType; +}> = []; const testThread = { threadId: ThreadId.make("browser-test-thread"), providerSessionId: "agent-a", @@ -200,8 +211,10 @@ const dependencies = Layer.mergeAll( installed: Effect.die("mock Chromium does not need an executable"), }), Layer.succeed(DesktopChannel.DesktopBrowserChannel, { - // Only tabs a test marks render on the desktop; the rest stay headless. - available: true, + // Standalone tests have no desktop; native tests explicitly enable it. + get available() { + return desktopAvailable || desktopRendersNext || desktopTabs.size > 0; + }, awaitAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), detached: Stream.callback<{ threadId: string; tabId: string }>((queue) => Effect.acquireRelease( @@ -274,6 +287,10 @@ beforeEach(() => { contextFailure = null; desktopTabs.clear(); desktopRendersNext = false; + desktopAvailable = false; + desktopConnectionGate = null; + desktopConnectionStarted = null; + desktopConnectionClosed = null; releasedDesktopTabs.length = 0; desktopConnections.length = 0; }); @@ -1310,6 +1327,184 @@ it.live("viewers see the agent's pointer move to its target and click there", () ).pipe(Effect.provide(layer)), ); +it.live("a late native attachment retries the same tab without opening headless", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + desktopAvailable = true; + const failure = yield* broker + .invoke({ + scope, + operation: "open", + input: { reuseExistingTab: false, show: false }, + }) + .pipe(Effect.flip); + expect(failure).toMatchObject({ + _tag: "PreviewAutomationExecutionError", + message: expect.stringContaining("in-app browser tab has not attached"), + }); + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + expect(sessions).toHaveLength(1); + const pending = yield* broker.invoke({ + scope, + operation: "status", + input: {}, + }); + const tabId = PreviewTabId.make(sessions[0]!.tabId); + expect(pending).toMatchObject({ available: false, tabId }); + expect(pending.tabs).toEqual([ + expect.objectContaining({ tabId, owner: "agent", ownedByCaller: true }), + ]); + expect(contexts).toEqual([]); + expect(contextRequests).toEqual([]); + for (const operation of ["open", "evaluate"] as const) { + expect( + yield* broker + .invoke({ + scope, + tabId, + operation, + input: operation === "open" ? { reuseExistingTab: true } : { expression: "42" }, + }) + .pipe(Effect.flip), + ).toMatchObject({ + _tag: "PreviewAutomationExecutionError", + message: expect.stringContaining("in-app browser tab has not attached"), + }); + } + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toHaveLength(1); + expect(contexts).toEqual([]); + desktopTabs.add(tabId); + desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId }); + const retried = yield* broker.invoke({ + scope, + tabId, + operation: "open", + input: { reuseExistingTab: true, show: false }, + }); + expect(retried).toMatchObject({ available: true, tabId }); + expect(desktopConnections).toHaveLength(1); + expect(contexts).toEqual([]); + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toHaveLength(1); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("only the owning agent can close a native tab that has not attached", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + desktopAvailable = true; + yield* broker + .invoke({ + scope, + operation: "open", + input: { reuseExistingTab: false, show: false }, + }) + .pipe(Effect.flip); + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + const tabId = PreviewTabId.make(sessions[0]!.tabId); + expect( + yield* broker + .invoke({ + scope: asSession("another-agent"), + tabId, + operation: "close", + input: {}, + }) + .pipe(Effect.flip), + ).toMatchObject({ + _tag: "PreviewAutomationControlInterruptedError", + reason: "agentMismatch", + }); + // Status makes the pending tab current, so the owner can close without an explicit ID. + yield* broker.invoke({ scope, operation: "status", input: {} }); + yield* broker.invoke({ scope, operation: "close", input: {} }); + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toEqual([]); + expect(contexts).toEqual([]); + const queried = Promise.withResolvers(); + const list = manager.list; + vi.spyOn(manager, "list").mockImplementation((input) => + list(input).pipe(Effect.tap(() => Effect.sync(() => queried.resolve()))), + ); + desktopTabs.add(tabId); + desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId }); + yield* Effect.promise(() => queried.promise); + expect(desktopConnections).toEqual([]); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("closing a pending native session cancels its connection already in flight", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + desktopAvailable = true; + yield* broker + .invoke({ scope, operation: "open", input: { reuseExistingTab: false, show: false } }) + .pipe(Effect.flip); + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + const tabId = PreviewTabId.make(sessions[0]!.tabId); + const connecting = Promise.withResolvers(); + const connected = Promise.withResolvers(); + const disconnected = Promise.withResolvers(); + desktopConnectionStarted = connecting; + desktopConnectionGate = connected; + desktopConnectionClosed = disconnected; + desktopTabs.add(tabId); + desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId }); + yield* Effect.promise(() => connecting.promise); + const closingStarted = Promise.withResolvers(); + const closeCommitted = Promise.withResolvers(); + const close = manager.close; + vi.spyOn(manager, "close").mockImplementationOnce((input) => + Effect.promise(async () => { + closingStarted.resolve(); + await closeCommitted.promise; + }).pipe(Effect.andThen(close(input))), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + connected.resolve(); + closeCommitted.resolve(); + }), + ); + const closing = yield* broker + .invoke({ scope, tabId, operation: "close", input: {} }) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => closingStarted.promise); + connected.resolve(); + // The page connection tears down before the manager close can publish. + yield* Effect.promise(() => disconnected.promise); + expect(desktopConnections[0]!.close).toHaveBeenCalledOnce(); + closeCommitted.resolve(); + yield* Fiber.join(closing); + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toEqual([]); + expect(contexts).toEqual([]); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a standalone server still opens a headless page when no desktop channel exists", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + expect(desktopAvailable).toBe(false); + expect(contexts).toHaveLength(1); + expect(desktopConnections).toEqual([]); + expect( + yield* broker.invoke({ scope, tabId, operation: "evaluate", input: { expression: "42" } }), + ).toBe("evaluated"); + }), + ).pipe(Effect.provide(layer)), +); + it.live("drives the desktop's own page for a tab the desktop renders", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 939d112c49081..7fd47befc0a39 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -92,7 +92,7 @@ const SCREENCAST_MOTION_FRAMES = 4; const SCREENCAST_MOTION_WINDOW_MS = 300; const SCREENCAST_MOTION_QUALITY = 50; const HOST_RECONNECT_DELAY = "1 second"; -/** How long a new tab waits for the desktop app to mount it before running headless. */ +/** How long a new tab waits for the desktop app to mount it before returning a retryable error. */ const DESKTOP_ATTACH_TIMEOUT = "10 seconds"; const VIEWER_OUTPUT_LIMIT = 64; const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const; @@ -143,6 +143,16 @@ const isHostSetupError = Schema.is( ]), ); +class ServerBrowserDesktopAttachError extends ServerBrowserPage.ServerBrowserOperationError { + readonly _tag = "ServerBrowserDesktopAttachError"; + constructor() { + super( + "PreviewAutomationExecutionError", + "The in-app browser tab has not attached yet. Retry using its tabId from preview_status; no separate browser was opened.", + ); + } +} + export class ServerBrowserLaunchError extends Schema.TaggedError()( "ServerBrowserLaunchError", { cause: Schema.Defect() }, @@ -709,15 +719,17 @@ const make = Effect.gen(function* () { * With a desktop app attached, every tab of this server renders there, so a * new tab waits for its `` instead of launching headless. */ - const desktopRenders = (snapshot: PreviewSessionSnapshot) => - desktopChannel.available - ? Effect.runPromise( - desktopChannel.awaitAttached( - { threadId: snapshot.threadId, tabId: snapshot.tabId }, - DESKTOP_ATTACH_TIMEOUT, - ), - ) - : Promise.resolve(false); + const desktopRenders = async (snapshot: PreviewSessionSnapshot) => { + if (!desktopChannel.available) return false; + const attached = await Effect.runPromise( + desktopChannel.awaitAttached( + { threadId: snapshot.threadId, tabId: snapshot.tabId }, + DESKTOP_ATTACH_TIMEOUT, + ), + ); + if (!attached) throw new ServerBrowserDesktopAttachError(); + return true; + }; /** Connects to the desktop's page for a tab, which it renders and the server drives. */ const connectDesktop = async (snapshot: PreviewSessionSnapshot) => { @@ -1220,6 +1232,17 @@ const make = Effect.gen(function* () { }); }); + // Viewers use a launch-error wrapper; automation keeps the native retry guidance. + const findAutomationTab = (threadId: string, tabId: string) => + findTab(threadId, tabId).pipe( + Effect.mapError((error) => + error instanceof ServerBrowserLaunchError && + error.cause instanceof ServerBrowserDesktopAttachError + ? error.cause + : error, + ), + ); + const atTabLimit = (agentSessionId: string) => tabs.size + pendingTabs.size >= SERVER_TAB_LIMIT || [...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length >= @@ -1315,15 +1338,45 @@ const make = Effect.gen(function* () { const statusWithTitle = async ( tab: ServerTab | undefined, agentSessionId?: string, + threadId: string | undefined = tab?.threadId, + requestedTabId?: string, ): Promise => { + const { sessions } = threadId + ? await Effect.runPromise(manager.list({ threadId: ThreadId.make(threadId) })) + : { sessions: [] }; + const sessionTabs = sessions.filter((session) => session.runtime === "server"); + const listedTabs = sessionTabs.map((session) => { + const live = tabs.get(tabKey(session.threadId, session.tabId)); + const url = live?.page.url() ?? ("url" in session.navStatus ? session.navStatus.url : null); + return { + tabId: session.tabId, + url: url === "about:blank" ? null : url, + ...(live?.openerTabId === undefined ? {} : { openerTabId: live.openerTabId }), + owner: live + ? tabOwner(live) + : session.automationOwner + ? ("agent" as const) + : ("unclaimed" as const), + ownedByCaller: live + ? live.control.agentId === agentSessionId + : session.automationOwner !== undefined && session.automationOwner === agentSessionId, + visible: (live?.viewers.size ?? 0) > 0, + ...(session.profileId === undefined ? {} : { profileId: session.profileId }), + }; + }); if (!tab) { + const pending = requestedTabId + ? sessionTabs.find((session) => session.tabId === requestedTabId) + : (sessionTabs.findLast((session) => session.automationOwner === agentSessionId) ?? + sessionTabs.at(-1)); return { available: false, visible: false, - tabId: null, - url: null, - title: null, - loading: false, + tabId: pending?.tabId ?? null, + url: pending && "url" in pending.navStatus ? pending.navStatus.url : null, + title: pending && "title" in pending.navStatus ? pending.navStatus.title || null : null, + loading: pending?.navStatus._tag === "Loading", + tabs: listedTabs, ...profileStatus(), }; } @@ -1347,17 +1400,7 @@ const make = Effect.gen(function* () { : null, viewportSetting: tab.setting, ...(viewport ? { viewport } : {}), - tabs: [...tabs.values()] - .filter((candidate) => candidate.threadId === tab.threadId) - .map((candidate) => ({ - tabId: candidate.tabId, - url: candidate.page.url() === "about:blank" ? null : candidate.page.url(), - ...(candidate.openerTabId === undefined ? {} : { openerTabId: candidate.openerTabId }), - owner: tabOwner(candidate), - ownedByCaller: candidate.control.agentId === agentSessionId, - visible: candidate.viewers.size > 0, - ...(candidate.profileId === undefined ? {} : { profileId: candidate.profileId }), - })), + tabs: listedTabs, ...profileStatus(), downloads: tab.downloads.map(({ fileName, path, sizeBytes, url, completedAt }) => ({ fileName, @@ -1652,7 +1695,13 @@ const make = Effect.gen(function* () { request.tabId === undefined ? latestThreadTab(request.threadId, request.agentSessionId) : await Effect.runPromise( - findTab(request.threadId, request.tabId).pipe(Effect.orElseSucceed(constVoid)), + findAutomationTab(request.threadId, request.tabId).pipe( + Effect.catch((error) => + error instanceof ServerBrowserDesktopAttachError + ? Effect.fail(error) + : Effect.succeed(undefined), + ), + ), ); if (!tab) { throw new ServerBrowserPage.ServerBrowserOperationError( @@ -1693,6 +1742,8 @@ const make = Effect.gen(function* () { ? latestThreadTab(request.threadId, request.agentSessionId) : tabs.get(tabKey(request.threadId, request.tabId)), request.agentSessionId, + request.threadId, + request.tabId, ); case "open": { if (!request.agentSessionId) @@ -1718,7 +1769,7 @@ const make = Effect.gen(function* () { const found = reuse && request.tabId !== undefined ? await Effect.runPromise( - findTab(request.threadId, request.tabId).pipe( + findAutomationTab(request.threadId, request.tabId).pipe( Effect.catchTags({ ServerBrowserTabNotFoundError: () => Effect.succeed(undefined), }), @@ -1819,6 +1870,27 @@ const make = Effect.gen(function* () { return tab.control.agent(request.agentSessionId ?? "", () => stopRecording(tab)); } } + // A timed-out native open still has a session its owner can close without + // first attaching to its page. An attachment racing this read uses normal control checks. + if (request.operation === "close" && request.tabId !== undefined && request.agentSessionId) { + const key = tabKey(request.threadId, request.tabId); + if (!tabs.has(key)) { + const { sessions } = await Effect.runPromise(manager.list({ threadId: request.threadId })); + const unopened = sessions.find((session) => session.tabId === request.tabId); + if (unopened && !tabs.has(key)) { + if (unopened.automationOwner !== request.agentSessionId) + throw new BrowserControlInterrupted( + "Only the agent session that opened this tab can close it.", + "agentMismatch", + ); + if (pendingTabs.has(key)) closedPendingTabs.add(key); + await Effect.runPromise( + manager.close({ threadId: request.threadId, tabId: unopened.tabId }), + ); + return {}; + } + } + } const tab = await requireTab(request); // Closing must unblock an action waiting on a dialog, without queueing behind it. if (request.operation === "close") { From 19306f05caa82edc8afe4a93826c85bf6a4f0b2e Mon Sep 17 00:00:00 2001 From: davidesala Date: Sun, 11 Oct 2026 20:45:22 +0200 Subject: [PATCH 2/3] fix(browser): stream headless fallbacks after native attach timeouts --- apps/server/src/preview/Manager.test.ts | 83 ++++ apps/server/src/preview/Manager.ts | 50 ++- apps/server/src/preview/ServerBrowser.test.ts | 425 +++++++++++------- apps/server/src/preview/ServerBrowser.ts | 179 +++----- .../src/browser/HostedBrowserWebview.test.tsx | 45 ++ apps/web/src/browser/previewRuntime.test.ts | 53 ++- apps/web/src/browser/previewRuntime.ts | 8 +- .../components/preview/PreviewView.test.tsx | 131 +++++- .../src/components/preview/PreviewView.tsx | 3 +- .../preview/ThreadPreviewMiniPlayer.tsx | 4 +- .../preview/previewFallback.test.tsx | 227 ++++++++++ .../preview/usePreviewBridge.test.ts | 115 ++++- .../components/preview/usePreviewBridge.ts | 19 +- packages/contracts/src/preview.test.ts | 75 +++- packages/contracts/src/preview.ts | 10 +- 15 files changed, 1132 insertions(+), 295 deletions(-) create mode 100644 apps/web/src/components/preview/previewFallback.test.tsx diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 48657d85d9023..96587a128e566 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -100,6 +100,89 @@ it.layer(layer)("PreviewManager", (it) => { }), ); + it.effect("publishes the server's page mode once and preserves a headless choice", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + const opened = yield* manager.open({ + threadId, + runtime: "server", + url: "http://localhost:5173/", + profileId: "work", + automationOwner: "agent", + }); + const native = yield* manager.setServerPageMode({ + threadId, + tabId: opened.tabId, + mode: "native", + }); + const headless = yield* manager.setServerPageMode({ + threadId, + tabId: opened.tabId, + mode: "headless", + }); + expect(native.serverPageMode).toBe("native"); + expect(headless).toMatchObject({ + ...opened, + serverPageMode: "headless", + updatedAt: expect.any(String), + }); + const before = yield* manager.list({ threadId }); + for (const mode of ["headless", "native"] as const) { + expect(yield* manager.setServerPageMode({ threadId, tabId: opened.tabId, mode })).toEqual( + headless, + ); + } + const after = yield* manager.list({ threadId }); + expect(after).toEqual(before); + const events = yield* collector.drain; + expect(events.map((event) => event.type)).toEqual(["opened", "resized", "resized"]); + expect(events.at(-1)).toMatchObject({ snapshot: headless, revision: after.revision }); + yield* manager.reportStatus({ + threadId, + tabId: opened.tabId, + serverControlled: true, + navStatus: { _tag: "Success", url: "http://localhost:5173/next", title: "Next" }, + canGoBack: true, + canGoForward: false, + }); + yield* manager.resize({ threadId, tabId: opened.tabId, viewport: { _tag: "fill" } }); + expect((yield* manager.list({ threadId })).sessions[0]?.serverPageMode).toBe("headless"); + }), + ); + + it.effect( + "allows only server sessions to select a page mode, and never recreates a closed tab", + () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const desktop = yield* manager.open({ threadId, serverPageMode: "headless" }); + expect(desktop).not.toHaveProperty("serverPageMode"); + expect( + (yield* manager + .setServerPageMode({ threadId, tabId: desktop.tabId, mode: "headless" }) + .pipe(Effect.flip))._tag, + ).toBe("PreviewControlRequiredError"); + const server = yield* manager.open({ + threadId, + runtime: "server", + serverPageMode: "headless", + }); + expect(server.serverPageMode).toBe("headless"); + yield* manager.close({ threadId, tabId: server.tabId }); + expect( + (yield* manager + .setServerPageMode({ threadId, tabId: server.tabId, mode: "headless" }) + .pipe(Effect.flip))._tag, + ).toBe("PreviewSessionLookupError"); + expect( + (yield* manager.list({ threadId })).sessions.map((session) => session.tabId), + ).toEqual([desktop.tabId]); + }), + ); + it.effect("lets any client size, theme, and zoom a server tab", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 9420e07a5a3a7..a3d79f5502020 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -48,6 +48,8 @@ export class PreviewManager extends Context.Service< readonly open: ( input: PreviewOpenInput & { readonly automationOwner?: string; + /** Only the server browser selects how its page is presented. */ + readonly serverPageMode?: PreviewSessionSnapshot["serverPageMode"]; /** Runs before the `opened` event publishes, so subscribers find state keyed by the tab. */ readonly beforePublish?: (snapshot: PreviewSessionSnapshot) => void; }, @@ -58,6 +60,13 @@ export class PreviewManager extends Context.Service< readonly reportStatus: ( input: PreviewReportStatusInput & { readonly serverControlled?: boolean }, ) => Effect.Effect; + /** Publishes the backing page selected by the server; headless cannot be promoted later. */ + readonly setServerPageMode: ( + input: PreviewCloseInput & { + readonly tabId: string; + readonly mode: "native" | "headless"; + }, + ) => Effect.Effect; readonly requestReveal: ( input: PreviewCloseInput & { readonly tabId: string; readonly force: boolean }, ) => Effect.Effect; @@ -221,6 +230,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { viewport: input.viewport ?? FILL_PREVIEW_VIEWPORT, ...(input.profileId === undefined ? {} : { profileId: input.profileId }), ...(runtime === undefined ? {} : { runtime }), + ...(runtime === "server" && input.serverPageMode !== undefined + ? { serverPageMode: input.serverPageMode } + : {}), ...(runtime === "server" && input.automationOwner !== undefined ? { automationOwner: input.automationOwner } : {}), @@ -337,6 +349,40 @@ export const make = Effect.gen(function* PreviewManagerMake() { ); }); + const setServerPageMode: PreviewManager["Service"]["setServerPageMode"] = Effect.fn( + "PreviewManager.setServerPageMode", + )(function* (input) { + return yield* mutateExistingSession( + input.threadId, + input.tabId, + Effect.fn("PreviewManager.setSessionPageMode")(function* (session) { + if (session.snapshot.runtime !== "server") + return yield* new PreviewControlRequiredError({ tabId: input.tabId }); + if ( + session.snapshot.serverPageMode === input.mode || + session.snapshot.serverPageMode === "headless" + ) + return { next: session, emit: null, result: session.snapshot }; + const snapshot: PreviewSessionSnapshot = { + ...session.snapshot, + serverPageMode: input.mode, + updatedAt: yield* currentIsoTimestamp, + }; + return { + next: { ...session, snapshot }, + emit: { + type: "resized", + threadId: session.threadId, + tabId: session.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }, + result: snapshot, + }; + }), + ); + }); + const resize: PreviewManager["Service"]["resize"] = Effect.fn("PreviewManager.resize")( function* (input) { return yield* mutateExistingSession( @@ -456,7 +502,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { const list: PreviewManager["Service"]["list"] = Effect.fn("PreviewManager.list")( function* (input) { - return yield* SynchronizedRef.get(stateRef).pipe( + // Events publish inside a mutation; their consumers must wait for its state commit. + return yield* SynchronizedRef.modify(stateRef, (state) => [state, state] as const).pipe( Effect.map((state): PreviewListResult => ({ sessions: sessionsForThread(state, input.threadId) .map((s) => s.snapshot) @@ -504,6 +551,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { requestReveal, navigate, reportStatus, + setServerPageMode, resize, adjust, refresh, diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 5a02a7dd8a37b..b791050ef8bf8 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -40,6 +40,7 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ } async contextFor(profileId: string, isolationKey?: string) { contextRequests.push({ profileId, isolated: isolationKey !== undefined }); + contextRequested?.resolve(); if (contextFailure) throw contextFailure; await contextGate?.promise; const context = makeContext(this.onClose); @@ -51,11 +52,8 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ } async connectDesktopPage(endpoint: string) { const context = makeContext(); - const close = vi.fn(async () => desktopConnectionClosed?.resolve()); - desktopConnections.push({ endpoint, context, close }); - desktopConnectionStarted?.resolve(); - await desktopConnectionGate?.promise; - return { browser: { close }, page: context.page as unknown as Page }; + desktopConnections.push({ endpoint, context }); + return { browser: { close: async () => {} }, page: context.page as unknown as Page }; } async close() { for (const context of contexts) await context.close(); @@ -156,15 +154,17 @@ const contexts: ReturnType[] = []; /** The profile and isolation each headless tab asked its context for. */ const contextRequests: Array<{ profileId: string; isolated: boolean }> = []; let contextGate: PromiseWithResolvers | null = null; +let contextRequested: PromiseWithResolvers | null = null; type ClipboardBinding = (source: { page: unknown }, text: unknown) => void; let clipboardBinding: ClipboardBinding | null = null; let contextFailure: Error | null = null; /** Server tabs the fake desktop renders, and the endpoints the server connected to. */ let desktopRendersNext = false; let desktopAvailable = false; -let desktopConnectionGate: PromiseWithResolvers | null = null; -let desktopConnectionStarted: PromiseWithResolvers | null = null; -let desktopConnectionClosed: PromiseWithResolvers | null = null; +let desktopAttachGate: PromiseWithResolvers | null = null; +let desktopAttachStarted: PromiseWithResolvers | null = null; +let desktopDetachConsumed: PromiseWithResolvers | null = null; +const desktopAttachRequests: string[] = []; /** Pages the fake desktop takes back or returns; the channel's streams emit them. */ const desktopDetaches = new NodeEvents.EventEmitter(); const desktopTabs = new Set(); @@ -179,7 +179,6 @@ const releasedDesktopTabs: Array = []; const desktopConnections: Array<{ endpoint: string; context: ReturnType; - close: ReturnType; }> = []; const testThread = { threadId: ThreadId.make("browser-test-thread"), @@ -215,7 +214,12 @@ const dependencies = Layer.mergeAll( get available() { return desktopAvailable || desktopRendersNext || desktopTabs.size > 0; }, - awaitAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), + awaitAttached: (key) => + Effect.promise(async () => { + desktopAttachRequests.push(key.tabId); + desktopAttachStarted?.resolve(); + return desktopAttachGate ? desktopAttachGate.promise : desktopRenders(key.tabId); + }), detached: Stream.callback<{ threadId: string; tabId: string }>((queue) => Effect.acquireRelease( Effect.sync(() => { @@ -226,7 +230,7 @@ const dependencies = Layer.mergeAll( }), (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)), ), - ), + ).pipe(Stream.tap(() => Effect.sync(() => desktopDetachConsumed?.resolve()))), attached: Stream.callback<{ threadId: string; tabId: string }>((queue) => Effect.acquireRelease( Effect.sync(() => { @@ -284,13 +288,15 @@ beforeEach(() => { contexts.length = 0; contextRequests.length = 0; contextGate = null; + contextRequested = null; contextFailure = null; desktopTabs.clear(); desktopRendersNext = false; desktopAvailable = false; - desktopConnectionGate = null; - desktopConnectionStarted = null; - desktopConnectionClosed = null; + desktopAttachGate = null; + desktopAttachStarted = null; + desktopDetachConsumed = null; + desktopAttachRequests.length = 0; releasedDesktopTabs.length = 0; desktopConnections.length = 0; }); @@ -977,12 +983,14 @@ it.live("a popup from a person's click is shown to that person, with its opener const browser = yield* ServerBrowser.ServerBrowser; const manager = yield* Manager.PreviewManager; yield* Effect.yieldNow; + desktopAvailable = true; const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); const watcher = yield* browser.attachViewer(viewerInput(opened.tabId, false)); const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); yield* viewer.input({ type: "takeControl" }); const opener = contexts[0]!.page; const popup = makeContext(); + const events = yield* manager.subscribeEvents; opener.emit("popup", popup.page); let shown = yield* Queue.take(viewer.output); while (shown._tag !== "popup") shown = yield* Queue.take(viewer.output); @@ -990,6 +998,16 @@ it.live("a popup from a person's click is shown to that person, with its opener expect(sessions.map((session) => session.tabId)).toEqual( expect.arrayContaining([opened.tabId, shown.tabId]), ); + expect(sessions.find((session) => session.tabId === shown.tabId)?.serverPageMode).toBe( + "headless", + ); + expect( + (yield* PubSub.takeUpTo(events, 10)).find((event) => event.type === "opened"), + ).toMatchObject({ snapshot: { tabId: shown.tabId, serverPageMode: "headless" } }); + yield* browser.attachViewer(viewerInput(shown.tabId, false)); + expect(desktopAttachRequests).toEqual([opened.tabId]); + expect(desktopConnections).toEqual([]); + expect(contexts).toHaveLength(1); expect((yield* Queue.clear(watcher.output)).some((item) => item._tag === "popup")).toBe( false, ); @@ -998,6 +1016,48 @@ it.live("a popup from a person's click is shown to that person, with its opener ).pipe(Effect.provide(layer)), ); +it.live("a popup closed before adoption does not leave its page behind", () => + Effect.scoped( + Effect.gen(function* () { + const { tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; + const reading = Promise.withResolvers(); + const read = Promise.withResolvers(); + const closed = Promise.withResolvers(); + const list = manager.list; + const spy = vi.spyOn(manager, "list").mockImplementationOnce((input) => + Effect.promise(async () => { + reading.resolve(); + await read.promise; + }).pipe(Effect.andThen(list(input))), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + read.resolve(); + spy.mockRestore(); + }), + ); + const popup = makeContext(); + popup.page.once("close", () => closed.resolve()); + contexts[0]!.page.emit("popup", popup.page); + yield* Effect.promise(() => reading.promise); + const opened = yield* PubSub.take(events); + expect(opened.type).toBe("opened"); + yield* manager.close({ threadId: scope.thread.threadId, tabId: opened.tabId }); + read.resolve(); + yield* Effect.promise(() => closed.promise); + expect(popup.page.isClosed()).toBe(true); + expect(contexts[0]!.page.isClosed()).toBe(false); + expect( + (yield* manager.list({ threadId: scope.thread.threadId })).sessions.map( + (session) => session.tabId, + ), + ).toEqual([tabId]); + }), + ).pipe(Effect.provide(layer)), +); + it.live("closing a tab while a viewer is still opening it does not leave its page behind", () => Effect.scoped( Effect.gen(function* () { @@ -1327,166 +1387,226 @@ it.live("viewers see the agent's pointer move to its target and click there", () ).pipe(Effect.provide(layer)), ); -it.live("a late native attachment retries the same tab without opening headless", () => +it.live( + "publishes a headless fallback before launch and keeps the same page after late native events", + () => + 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; + desktopAvailable = true; + const launching = Promise.withResolvers(); + const launch = Promise.withResolvers(); + contextRequested = launching; + contextGate = launch; + yield* Effect.addFinalizer(() => Effect.sync(() => launch.resolve())); + const opening = yield* broker + .invoke({ + scope, + operation: "open", + input: { url: "http://localhost:5173/fallback", reuseExistingTab: false, show: false }, + }) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => launching.promise); + const listed = yield* manager.list({ threadId: scope.thread.threadId }); + const snapshot = listed.sessions[0]!; + expect(snapshot.serverPageMode).toBe("headless"); + expect(contexts).toEqual([]); + expect( + (yield* PubSub.takeUpTo(events, 10)).map((event) => ({ + type: event.type, + mode: "snapshot" in event ? event.snapshot.serverPageMode : undefined, + })), + ).toEqual([ + { type: "opened", mode: undefined }, + { type: "resized", mode: "headless" }, + ]); + // A native guest arriving while headless is still launching must not become another page. + const queried = Promise.withResolvers(); + const list = manager.list; + const spy = vi + .spyOn(manager, "list") + .mockImplementation((input) => + list(input).pipe(Effect.tap(() => Effect.sync(() => queried.resolve()))), + ); + yield* Effect.addFinalizer(() => Effect.sync(() => spy.mockRestore())); + desktopTabs.add(snapshot.tabId); + desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId: snapshot.tabId }); + yield* Effect.promise(() => queried.promise); + launch.resolve(); + const opened = yield* Fiber.join(opening); + expect(opened).toMatchObject({ + available: true, + tabId: snapshot.tabId, + url: "http://localhost:5173/fallback", + }); + expect(contexts).toHaveLength(1); + expect(contexts[0]!.page.goto).toHaveBeenCalledOnce(); + expect(desktopConnections).toEqual([]); + const viewer = yield* browser.attachViewer(viewerInput(snapshot.tabId, false)); + expect((yield* Queue.clear(viewer.output)).some((item) => item._tag === "frame")).toBe( + true, + ); + const consumed = Promise.withResolvers(); + desktopDetachConsumed = consumed; + desktopTabs.delete(snapshot.tabId); + desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: snapshot.tabId }); + yield* Effect.promise(() => consumed.promise); + expect( + yield* broker.invoke({ + scope, + tabId: PreviewTabId.make(snapshot.tabId), + operation: "evaluate", + input: { expression: "42" }, + }), + ).toBe("evaluated"); + expect(contexts[0]!.page.close).not.toHaveBeenCalled(); + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toHaveLength(1); + }), + ).pipe(Effect.provide(layer)), +); + +it.live( + "a chosen headless page stays headless when its failed launch is retried after native attachment", + () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + desktopAvailable = true; + contextFailure = new Error("launch failed"); + yield* broker + .invoke({ scope, operation: "open", input: { reuseExistingTab: false, show: false } }) + .pipe(Effect.flip); + const snapshot = (yield* manager.list({ threadId: scope.thread.threadId })).sessions[0]!; + expect(snapshot.serverPageMode).toBe("headless"); + contextFailure = null; + desktopTabs.add(snapshot.tabId); + const viewer = yield* browser.attachViewer(viewerInput(snapshot.tabId, false)); + expect((yield* Queue.clear(viewer.output)).some((item) => item._tag === "frame")).toBe( + true, + ); + expect(desktopConnections).toEqual([]); + expect(contexts).toHaveLength(1); + expect(desktopAttachRequests).toEqual([snapshot.tabId]); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("closing a tab during its native wait prevents a headless fallback", () => Effect.scoped( Effect.gen(function* () { const broker = yield* Broker.PreviewAutomationBroker; const manager = yield* Manager.PreviewManager; yield* Effect.yieldNow; desktopAvailable = true; - const failure = yield* broker - .invoke({ - scope, - operation: "open", - input: { reuseExistingTab: false, show: false }, - }) - .pipe(Effect.flip); - expect(failure).toMatchObject({ - _tag: "PreviewAutomationExecutionError", - message: expect.stringContaining("in-app browser tab has not attached"), - }); - const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); - expect(sessions).toHaveLength(1); - const pending = yield* broker.invoke({ - scope, - operation: "status", - input: {}, - }); - const tabId = PreviewTabId.make(sessions[0]!.tabId); - expect(pending).toMatchObject({ available: false, tabId }); - expect(pending.tabs).toEqual([ - expect.objectContaining({ tabId, owner: "agent", ownedByCaller: true }), - ]); - expect(contexts).toEqual([]); + const waiting = Promise.withResolvers(); + const attached = Promise.withResolvers(); + desktopAttachStarted = waiting; + desktopAttachGate = attached; + yield* Effect.addFinalizer(() => Effect.sync(() => attached.resolve(false))); + const opening = yield* broker + .invoke({ scope, operation: "open", input: { reuseExistingTab: false, show: false } }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Effect.promise(() => waiting.promise); + const snapshot = (yield* manager.list({ threadId: scope.thread.threadId })).sessions[0]!; + yield* manager.close({ threadId: scope.thread.threadId, tabId: snapshot.tabId }); + attached.resolve(false); + yield* Fiber.join(opening); expect(contextRequests).toEqual([]); - for (const operation of ["open", "evaluate"] as const) { - expect( - yield* broker - .invoke({ - scope, - tabId, - operation, - input: operation === "open" ? { reuseExistingTab: true } : { expression: "42" }, - }) - .pipe(Effect.flip), - ).toMatchObject({ - _tag: "PreviewAutomationExecutionError", - message: expect.stringContaining("in-app browser tab has not attached"), - }); - } - expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toHaveLength(1); - expect(contexts).toEqual([]); - desktopTabs.add(tabId); - desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId }); - const retried = yield* broker.invoke({ - scope, - tabId, - operation: "open", - input: { reuseExistingTab: true, show: false }, - }); - expect(retried).toMatchObject({ available: true, tabId }); - expect(desktopConnections).toHaveLength(1); expect(contexts).toEqual([]); - expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toHaveLength(1); + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toEqual([]); }), ).pipe(Effect.provide(layer)), ); -it.live("only the owning agent can close a native tab that has not attached", () => +it.live( + "closing a tab while headless launches discards its page and prevents late native resurrection", + () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + desktopAvailable = true; + const launching = Promise.withResolvers(); + const launch = Promise.withResolvers(); + contextRequested = launching; + contextGate = launch; + yield* Effect.addFinalizer(() => Effect.sync(() => launch.resolve())); + const opening = yield* broker + .invoke({ scope, operation: "open", input: { reuseExistingTab: false, show: false } }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Effect.promise(() => launching.promise); + const snapshot = (yield* manager.list({ threadId: scope.thread.threadId })).sessions[0]!; + yield* manager.close({ threadId: scope.thread.threadId, tabId: snapshot.tabId }); + launch.resolve(); + yield* Fiber.join(opening); + expect(contexts[0]!.page.isClosed()).toBe(true); + expect(contexts[0]!.close).toHaveBeenCalledOnce(); + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toEqual([]); + const queried = Promise.withResolvers(); + const list = manager.list; + const spy = vi + .spyOn(manager, "list") + .mockImplementation((input) => + list(input).pipe(Effect.tap(() => Effect.sync(() => queried.resolve()))), + ); + yield* Effect.addFinalizer(() => Effect.sync(() => spy.mockRestore())); + desktopTabs.add(snapshot.tabId); + desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId: snapshot.tabId }); + yield* Effect.promise(() => queried.promise); + expect(desktopConnections).toEqual([]); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("headless fallbacks count toward both tab limits and free capacity when closed", () => Effect.scoped( Effect.gen(function* () { const broker = yield* Broker.PreviewAutomationBroker; const manager = yield* Manager.PreviewManager; yield* Effect.yieldNow; desktopAvailable = true; - yield* broker - .invoke({ - scope, + const open = (agent: string) => + broker.invoke({ + scope: asSession(agent), operation: "open", input: { reuseExistingTab: false, show: false }, - }) - .pipe(Effect.flip); - const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); - const tabId = PreviewTabId.make(sessions[0]!.tabId); + }); + let first: PreviewAutomationStatus | undefined; + for (let agent = 0; agent < 4; agent++) { + for (let count = 0; count < 8; count++) { + const opened = yield* open(`agent-${agent}`); + first ??= opened; + } + expect(yield* open(`agent-${agent}`).pipe(Effect.flip)).toMatchObject({ + reason: "tabLimit", + }); + } + expect(yield* open("another-agent").pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); + expect(contexts).toHaveLength(32); expect( - yield* broker - .invoke({ - scope: asSession("another-agent"), - tabId, - operation: "close", - input: {}, - }) - .pipe(Effect.flip), - ).toMatchObject({ - _tag: "PreviewAutomationControlInterruptedError", - reason: "agentMismatch", + (yield* manager.list({ threadId: scope.thread.threadId })).sessions.every( + (session) => session.serverPageMode === "headless", + ), + ).toBe(true); + desktopTabs.add(first!.tabId!); + desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId: first!.tabId! }); + yield* broker.invoke({ + scope: asSession("agent-0"), + tabId: PreviewTabId.make(first!.tabId!), + operation: "close", + input: {}, }); - // Status makes the pending tab current, so the owner can close without an explicit ID. - yield* broker.invoke({ scope, operation: "status", input: {} }); - yield* broker.invoke({ scope, operation: "close", input: {} }); - expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toEqual([]); - expect(contexts).toEqual([]); - const queried = Promise.withResolvers(); - const list = manager.list; - vi.spyOn(manager, "list").mockImplementation((input) => - list(input).pipe(Effect.tap(() => Effect.sync(() => queried.resolve()))), - ); - desktopTabs.add(tabId); - desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId }); - yield* Effect.promise(() => queried.promise); + expect((yield* open("another-agent")).available).toBe(true); expect(desktopConnections).toEqual([]); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("closing a pending native session cancels its connection already in flight", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* Broker.PreviewAutomationBroker; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - desktopAvailable = true; - yield* broker - .invoke({ scope, operation: "open", input: { reuseExistingTab: false, show: false } }) - .pipe(Effect.flip); - const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); - const tabId = PreviewTabId.make(sessions[0]!.tabId); - const connecting = Promise.withResolvers(); - const connected = Promise.withResolvers(); - const disconnected = Promise.withResolvers(); - desktopConnectionStarted = connecting; - desktopConnectionGate = connected; - desktopConnectionClosed = disconnected; - desktopTabs.add(tabId); - desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId }); - yield* Effect.promise(() => connecting.promise); - const closingStarted = Promise.withResolvers(); - const closeCommitted = Promise.withResolvers(); - const close = manager.close; - vi.spyOn(manager, "close").mockImplementationOnce((input) => - Effect.promise(async () => { - closingStarted.resolve(); - await closeCommitted.promise; - }).pipe(Effect.andThen(close(input))), - ); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - connected.resolve(); - closeCommitted.resolve(); - }), - ); - const closing = yield* broker - .invoke({ scope, tabId, operation: "close", input: {} }) - .pipe(Effect.forkScoped); - yield* Effect.promise(() => closingStarted.promise); - connected.resolve(); - // The page connection tears down before the manager close can publish. - yield* Effect.promise(() => disconnected.promise); - expect(desktopConnections[0]!.close).toHaveBeenCalledOnce(); - closeCommitted.resolve(); - yield* Fiber.join(closing); - expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toEqual([]); - expect(contexts).toEqual([]); + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toHaveLength(32); }), ).pipe(Effect.provide(layer)), ); @@ -1515,6 +1635,9 @@ it.live("drives the desktop's own page for a tab the desktop renders", () => desktopRendersNext = true; const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + expect( + (yield* manager.list({ threadId: scope.thread.threadId })).sessions[0]?.serverPageMode, + ).toBe("native"); expect(desktopConnections.map((connection) => connection.endpoint)).toEqual([ `ws://desktop/${opened.tabId}`, ]); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 7fd47befc0a39..9b85079ac03fc 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -92,7 +92,7 @@ const SCREENCAST_MOTION_FRAMES = 4; const SCREENCAST_MOTION_WINDOW_MS = 300; const SCREENCAST_MOTION_QUALITY = 50; const HOST_RECONNECT_DELAY = "1 second"; -/** How long a new tab waits for the desktop app to mount it before returning a retryable error. */ +/** How long a new tab waits for the desktop app to mount it before running headless. */ const DESKTOP_ATTACH_TIMEOUT = "10 seconds"; const VIEWER_OUTPUT_LIMIT = 64; const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const; @@ -143,16 +143,6 @@ const isHostSetupError = Schema.is( ]), ); -class ServerBrowserDesktopAttachError extends ServerBrowserPage.ServerBrowserOperationError { - readonly _tag = "ServerBrowserDesktopAttachError"; - constructor() { - super( - "PreviewAutomationExecutionError", - "The in-app browser tab has not attached yet. Retry using its tabId from preview_status; no separate browser was opened.", - ); - } -} - export class ServerBrowserLaunchError extends Schema.TaggedError()( "ServerBrowserLaunchError", { cause: Schema.Defect() }, @@ -716,20 +706,17 @@ const make = Effect.gen(function* () { }; /** - * With a desktop app attached, every tab of this server renders there, so a - * new tab waits for its `` instead of launching headless. + * Prefer the desktop's page, but keep a chosen headless page across reattachments. */ - const desktopRenders = async (snapshot: PreviewSessionSnapshot) => { - if (!desktopChannel.available) return false; - const attached = await Effect.runPromise( - desktopChannel.awaitAttached( - { threadId: snapshot.threadId, tabId: snapshot.tabId }, - DESKTOP_ATTACH_TIMEOUT, - ), - ); - if (!attached) throw new ServerBrowserDesktopAttachError(); - return true; - }; + const desktopRenders = (snapshot: PreviewSessionSnapshot) => + desktopChannel.available && snapshot.serverPageMode !== "headless" + ? Effect.runPromise( + desktopChannel.awaitAttached( + { threadId: snapshot.threadId, tabId: snapshot.tabId }, + DESKTOP_ATTACH_TIMEOUT, + ), + ) + : Promise.resolve(false); /** Connects to the desktop's page for a tab, which it renders and the server drives. */ const connectDesktop = async (snapshot: PreviewSessionSnapshot) => { @@ -754,13 +741,41 @@ const make = Effect.gen(function* () { } }; - const createTab = async (snapshot: PreviewSessionSnapshot): Promise => { - const adopted = adoptedPages.get(tabKey(snapshot.threadId, snapshot.tabId)); - adoptedPages.delete(tabKey(snapshot.threadId, snapshot.tabId)); - const desktop = - adopted === undefined && (await desktopRenders(snapshot)) - ? await connectDesktop(snapshot) - : null; + const currentSnapshot = async (threadId: string, tabId: string) => { + const { sessions } = await Effect.runPromise( + manager.list({ threadId: ThreadId.make(threadId) }), + ); + const snapshot = sessions.find( + (session) => session.tabId === tabId && session.runtime === "server", + ); + if (!snapshot) throw new ServerBrowserTabNotFoundError({ threadId, tabId }); + return snapshot; + }; + + const createTab = async (requestedSnapshot: PreviewSessionSnapshot): Promise => { + const key = tabKey(requestedSnapshot.threadId, requestedSnapshot.tabId); + const adopted = adoptedPages.get(key); + adoptedPages.delete(key); + // An older opened event may be processed after a failed headless launch or a close. + const fresh = await currentSnapshot(requestedSnapshot.threadId, requestedSnapshot.tabId).catch( + async (cause: unknown) => { + await adopted?.page.close().catch(constVoid); + throw cause; + }, + ); + let snapshot = fresh; + const native = adopted === undefined && (await desktopRenders(snapshot)); + if (adopted === undefined) { + // Publish before launching headless so every client releases the independent native guest. + snapshot = await Effect.runPromise( + manager.setServerPageMode({ + threadId: ThreadId.make(snapshot.threadId), + tabId: snapshot.tabId, + mode: native ? "native" : "headless", + }), + ); + } + const desktop = native ? await connectDesktop(snapshot) : null; // An agent tab without a profile (no client reported one) keeps throwaway storage. const isolatedContext = adopted === undefined && @@ -908,8 +923,8 @@ const make = Effect.gen(function* () { // Popups become tabs and keep `window.opener`, so sign-in popups can report back. page.on("popup", (popup) => void adoptPopup(tab, popup)); // Playwright cannot reload a crashed page, so it must leave the tab list. - const key = tabKey(tab.threadId, tab.tabId); - if (closedPendingTabs.delete(key)) { + const { sessions } = await Effect.runPromise(manager.list({ threadId: tab.threadId })); + if (closedPendingTabs.delete(key) || !sessions.some((session) => session.tabId === tab.tabId)) { await control.close().catch(constVoid); if (desktop) await desktop.close().catch(constVoid); else await page.close().catch(constVoid); @@ -1170,6 +1185,7 @@ const make = Effect.gen(function* () { threadId: opener.threadId, ...(/^https?:/i.test(url) ? { url } : {}), runtime: "server", + ...(!opener.desktop ? { serverPageMode: "headless" as const } : {}), ...(opener.profileId === undefined ? {} : { profileId: opener.profileId }), // Agent popups stay with the agent and only float when it asks, like its own opens. ...(opener.control.agentId === null @@ -1232,17 +1248,6 @@ const make = Effect.gen(function* () { }); }); - // Viewers use a launch-error wrapper; automation keeps the native retry guidance. - const findAutomationTab = (threadId: string, tabId: string) => - findTab(threadId, tabId).pipe( - Effect.mapError((error) => - error instanceof ServerBrowserLaunchError && - error.cause instanceof ServerBrowserDesktopAttachError - ? error.cause - : error, - ), - ); - const atTabLimit = (agentSessionId: string) => tabs.size + pendingTabs.size >= SERVER_TAB_LIMIT || [...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length >= @@ -1338,45 +1343,15 @@ const make = Effect.gen(function* () { const statusWithTitle = async ( tab: ServerTab | undefined, agentSessionId?: string, - threadId: string | undefined = tab?.threadId, - requestedTabId?: string, ): Promise => { - const { sessions } = threadId - ? await Effect.runPromise(manager.list({ threadId: ThreadId.make(threadId) })) - : { sessions: [] }; - const sessionTabs = sessions.filter((session) => session.runtime === "server"); - const listedTabs = sessionTabs.map((session) => { - const live = tabs.get(tabKey(session.threadId, session.tabId)); - const url = live?.page.url() ?? ("url" in session.navStatus ? session.navStatus.url : null); - return { - tabId: session.tabId, - url: url === "about:blank" ? null : url, - ...(live?.openerTabId === undefined ? {} : { openerTabId: live.openerTabId }), - owner: live - ? tabOwner(live) - : session.automationOwner - ? ("agent" as const) - : ("unclaimed" as const), - ownedByCaller: live - ? live.control.agentId === agentSessionId - : session.automationOwner !== undefined && session.automationOwner === agentSessionId, - visible: (live?.viewers.size ?? 0) > 0, - ...(session.profileId === undefined ? {} : { profileId: session.profileId }), - }; - }); if (!tab) { - const pending = requestedTabId - ? sessionTabs.find((session) => session.tabId === requestedTabId) - : (sessionTabs.findLast((session) => session.automationOwner === agentSessionId) ?? - sessionTabs.at(-1)); return { available: false, visible: false, - tabId: pending?.tabId ?? null, - url: pending && "url" in pending.navStatus ? pending.navStatus.url : null, - title: pending && "title" in pending.navStatus ? pending.navStatus.title || null : null, - loading: pending?.navStatus._tag === "Loading", - tabs: listedTabs, + tabId: null, + url: null, + title: null, + loading: false, ...profileStatus(), }; } @@ -1400,7 +1375,17 @@ const make = Effect.gen(function* () { : null, viewportSetting: tab.setting, ...(viewport ? { viewport } : {}), - tabs: listedTabs, + tabs: [...tabs.values()] + .filter((candidate) => candidate.threadId === tab.threadId) + .map((candidate) => ({ + tabId: candidate.tabId, + url: candidate.page.url() === "about:blank" ? null : candidate.page.url(), + ...(candidate.openerTabId === undefined ? {} : { openerTabId: candidate.openerTabId }), + owner: tabOwner(candidate), + ownedByCaller: candidate.control.agentId === agentSessionId, + visible: candidate.viewers.size > 0, + ...(candidate.profileId === undefined ? {} : { profileId: candidate.profileId }), + })), ...profileStatus(), downloads: tab.downloads.map(({ fileName, path, sizeBytes, url, completedAt }) => ({ fileName, @@ -1695,13 +1680,7 @@ const make = Effect.gen(function* () { request.tabId === undefined ? latestThreadTab(request.threadId, request.agentSessionId) : await Effect.runPromise( - findAutomationTab(request.threadId, request.tabId).pipe( - Effect.catch((error) => - error instanceof ServerBrowserDesktopAttachError - ? Effect.fail(error) - : Effect.succeed(undefined), - ), - ), + findTab(request.threadId, request.tabId).pipe(Effect.orElseSucceed(constVoid)), ); if (!tab) { throw new ServerBrowserPage.ServerBrowserOperationError( @@ -1742,8 +1721,6 @@ const make = Effect.gen(function* () { ? latestThreadTab(request.threadId, request.agentSessionId) : tabs.get(tabKey(request.threadId, request.tabId)), request.agentSessionId, - request.threadId, - request.tabId, ); case "open": { if (!request.agentSessionId) @@ -1769,7 +1746,7 @@ const make = Effect.gen(function* () { const found = reuse && request.tabId !== undefined ? await Effect.runPromise( - findAutomationTab(request.threadId, request.tabId).pipe( + findTab(request.threadId, request.tabId).pipe( Effect.catchTags({ ServerBrowserTabNotFoundError: () => Effect.succeed(undefined), }), @@ -1870,27 +1847,6 @@ const make = Effect.gen(function* () { return tab.control.agent(request.agentSessionId ?? "", () => stopRecording(tab)); } } - // A timed-out native open still has a session its owner can close without - // first attaching to its page. An attachment racing this read uses normal control checks. - if (request.operation === "close" && request.tabId !== undefined && request.agentSessionId) { - const key = tabKey(request.threadId, request.tabId); - if (!tabs.has(key)) { - const { sessions } = await Effect.runPromise(manager.list({ threadId: request.threadId })); - const unopened = sessions.find((session) => session.tabId === request.tabId); - if (unopened && !tabs.has(key)) { - if (unopened.automationOwner !== request.agentSessionId) - throw new BrowserControlInterrupted( - "Only the agent session that opened this tab can close it.", - "agentMismatch", - ); - if (pendingTabs.has(key)) closedPendingTabs.add(key); - await Effect.runPromise( - manager.close({ threadId: request.threadId, tabId: unopened.tabId }), - ); - return {}; - } - } - } const tab = await requireTab(request); // Closing must unblock an action waiting on a dialog, without queueing behind it. if (request.operation === "close") { @@ -2488,7 +2444,8 @@ const make = Effect.gen(function* () { const snapshot = sessions.find( (session) => session.tabId === key.tabId && session.runtime === "server", ); - if (snapshot) yield* Effect.promise(() => ensureTab(snapshot).catch(constVoid)); + if (snapshot && snapshot.serverPageMode !== "headless") + yield* Effect.promise(() => ensureTab(snapshot).catch(constVoid)); }).pipe(Effect.ignore), ), Effect.forkScoped, diff --git a/apps/web/src/browser/HostedBrowserWebview.test.tsx b/apps/web/src/browser/HostedBrowserWebview.test.tsx index aa582251cb079..6ba86087e7539 100644 --- a/apps/web/src/browser/HostedBrowserWebview.test.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.test.tsx @@ -99,6 +99,51 @@ afterEach(async () => { }); describe("HostedBrowserWebview settings hydration", () => { + it("releases a removed native guest without registering it when tab creation finishes late", async () => { + mocks.getClientSettings.mockResolvedValue(DEFAULT_CLIENT_SETTINGS); + await act(async () => ensureClientSettingsHydrated()); + const tabCreation = deferred(); + mocks.createTab.mockReturnValueOnce(tabCreation.promise); + const threadRef = { + environmentId: EnvironmentId.make("headless-fallback"), + threadId: ThreadId.make("thread-fallback"), + }; + const runtimeTabId = "late-native-fallback"; + await act(() => { + renderer = create( + , + { + createNodeMock: (element) => + element.type === "webview" + ? Object.assign(new EventTarget(), { getWebContentsId: () => 41 }) + : { scrollLeft: 0, scrollTop: 0, scrollTo: () => undefined }, + }, + ); + }); + expect(mocks.createTab).toHaveBeenCalledOnce(); + expect(mocks.registerWebview).not.toHaveBeenCalled(); + vi.useFakeTimers(); + await act(() => renderer?.unmount()); + renderer = undefined; + await act(async () => { + tabCreation.resolve(); + await tabCreation.promise; + await vi.advanceTimersByTimeAsync(0); + }); + expect(mocks.registerWebview).not.toHaveBeenCalled(); + expect(mocks.closeTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId); + }); + it("starts a retained background tab only after a settings read succeeds on retry", async () => { const firstRead = deferred(); const retryRead = deferred(); diff --git a/apps/web/src/browser/previewRuntime.test.ts b/apps/web/src/browser/previewRuntime.test.ts index 951b52e904687..9169e8216f3a5 100644 --- a/apps/web/src/browser/previewRuntime.test.ts +++ b/apps/web/src/browser/previewRuntime.test.ts @@ -7,7 +7,11 @@ const state = vi.hoisted(() => ({ serverBrowser: new Set(), })); -vi.mock("~/env", () => ({ isElectron: false })); +vi.mock("~/env", () => ({ + get isElectron() { + return state.desktop; + }, +})); vi.mock("~/previewStateStore", () => ({ isPreviewSupportedInRuntime: () => state.desktop })); vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: { get: () => state.primary } })); vi.mock("~/state/primaryEnvironment", () => ({ primaryEnvironmentIdAtom: {} })); @@ -16,7 +20,11 @@ vi.mock("~/state/entities", () => ({ useEnvironmentSupportsServerBrowser: () => false, })); -import { alternatePreviewRuntime, previewRuntimeFor } from "./previewRuntime"; +import { + alternatePreviewRuntime, + previewRuntimeFor, + rendersServerTabNatively, +} from "./previewRuntime"; const local = "local" as EnvironmentId; const remote = "remote" as EnvironmentId; @@ -58,3 +66,44 @@ describe("alternatePreviewRuntime", () => { expect(alternatePreviewRuntime(remote, null, true, { runtime: "server" })).toBeNull(); }); }); + +describe("rendersServerTabNatively", () => { + it("streams a local tab after headless fallback while retaining native and legacy tabs", () => { + state.desktop = true; + expect(rendersServerTabNatively(local, local, { runtime: "server" })).toBe(true); + expect( + rendersServerTabNatively(local, local, { + runtime: "server", + serverPageMode: "native", + }), + ).toBe(true); + expect( + rendersServerTabNatively(local, local, { + runtime: "server", + serverPageMode: "headless", + }), + ).toBe(false); + }); + + it("never hosts remote or non-server tabs as local server webviews", () => { + state.desktop = true; + expect( + rendersServerTabNatively(remote, local, { + runtime: "server", + serverPageMode: "native", + }), + ).toBe(false); + expect(rendersServerTabNatively(local, null, { runtime: "server" })).toBe(false); + expect(rendersServerTabNatively(local, local, { runtime: "desktop" })).toBe(false); + expect(rendersServerTabNatively(local, local, null)).toBe(false); + }); + + it("streams native server pages in a web client", () => { + expect( + rendersServerTabNatively(local, local, { + runtime: "server", + serverPageMode: "native", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts index 435eb07bbc3c5..1818e4720757f 100644 --- a/apps/web/src/browser/previewRuntime.ts +++ b/apps/web/src/browser/previewRuntime.ts @@ -56,16 +56,18 @@ export function usePreviewAvailable(environmentId: EnvironmentId | null): boolea /** * Whether this client draws a server tab with its own ``. The desktop * app renders tabs of the server it launched, which drives them over the - * desktop browser channel; every other client and environment streams them. + * desktop browser channel; headless tabs and every other client and environment + * stream them. */ export function rendersServerTabNatively( environmentId: EnvironmentId, primaryEnvironmentId: EnvironmentId | null, - snapshot: Pick | null | undefined, + snapshot: Pick | null | undefined, ): boolean { return ( isElectron && snapshot?.runtime === "server" && + snapshot.serverPageMode !== "headless" && primaryEnvironmentId !== null && environmentId === primaryEnvironmentId ); @@ -73,7 +75,7 @@ export function rendersServerTabNatively( export function useRendersServerTabNatively( environmentId: EnvironmentId, - snapshot: Pick | null | undefined, + snapshot: Pick | null | undefined, ): boolean { return rendersServerTabNatively(environmentId, useAtomValue(primaryEnvironmentIdAtom), snapshot); } diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 09eb7ee8e1d50..7dbb3a8c3c18f 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -6,10 +6,19 @@ import { EnvironmentId, FILL_PREVIEW_VIEWPORT, ThreadId, + type PreviewSessionSnapshot, } from "@t3tools/contracts"; -import { act, createElement, Profiler } from "react"; +import { + act, + createElement, + Profiler, + useEffect, + useImperativeHandle, + type ComponentProps, +} from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ServerBrowserSurface } from "~/browser/ServerBrowserSurface"; const mocks = vi.hoisted(() => ({ navigate: vi.fn(async (_tabId: string, _url: string): Promise => undefined), @@ -40,6 +49,62 @@ const mocks = vi.hoisted(() => ({ recordingTabIds: new Set(), recordingRuntimeTabId: null as string | null, recordVisitForThread: vi.fn(), + electron: false, + runtime: undefined as PreviewSessionSnapshot["runtime"], + serverPageMode: undefined as PreviewSessionSnapshot["serverPageMode"], + chromeLoading: false, + chromeInputDisabled: false, + nativeSurfaceMounted: vi.fn(), + nativeSurfaceUnmounted: vi.fn(), + streamMounted: vi.fn(), + streamUnmounted: vi.fn(), + streamNavigate: vi.fn(), +})); + +vi.mock("~/env", async (importOriginal) => ({ + ...(await importOriginal()), + get isElectron() { + return mocks.electron; + }, +})); +vi.mock("~/browser/previewRuntime", async (importOriginal) => { + const runtime = await importOriginal(); + return { + ...runtime, + useRendersServerTabNatively: ( + environmentId: EnvironmentId, + snapshot: Pick | null | undefined, + ) => + runtime.rendersServerTabNatively( + environmentId, + EnvironmentId.make("environment-1"), + snapshot, + ), + }; +}); +vi.mock("~/browser/ServerBrowserSurface", () => ({ + ServerBrowserSurface: (props: ComponentProps) => { + useImperativeHandle( + props.ref, + () => ({ + navigate: mocks.streamNavigate, + history: vi.fn(), + reload: vi.fn(), + viewport: vi.fn(), + canvas: () => null, + }), + [], + ); + useEffect(() => { + mocks.streamMounted(props.tabId); + props.onControl?.({ controller: "you", canOperate: true, generation: 1, dialog: null }); + props.onFirstFrame?.(); + return () => { + mocks.streamUnmounted(props.tabId); + }; + }, []); + return createElement("server-surface"); + }, })); const EMPTY_HISTORY: never[] = []; @@ -135,6 +200,8 @@ vi.mock("~/previewStateStore", () => ({ "tab-1": { threadId: "thread-1", tabId: "tab-1", + runtime: mocks.runtime, + serverPageMode: mocks.serverPageMode, navStatus: { _tag: "Success", url: "http://example.com/", @@ -232,6 +299,8 @@ vi.mock("./previewBridge", () => ({ vi.mock("./PreviewChromeRow", () => ({ PreviewChromeRow: (props: { onSubmit: (url: string) => void; + loading: boolean; + inputDisabled?: boolean; onPickElement?: () => void; onPictureInPicture?: () => void; pictureInPicture?: boolean; @@ -240,6 +309,8 @@ vi.mock("./PreviewChromeRow", () => ({ }; }) => { mocks.submittedUrl = props.onSubmit; + mocks.chromeLoading = props.loading; + mocks.chromeInputDisabled = props.inputDisabled ?? false; mocks.toggleAnnotation = props.onPickElement ?? null; mocks.togglePictureInPicture = props.onPictureInPicture ?? null; mocks.toggleNativePictureInPicture = @@ -266,7 +337,17 @@ vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null })); vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => createElement("agent-cursor"), })); -vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); +vi.mock("~/browser/BrowserSurfaceSlot", () => ({ + BrowserSurfaceSlot: (props: { tabId: string }) => { + useEffect(() => { + mocks.nativeSurfaceMounted(props.tabId); + return () => { + mocks.nativeSurfaceUnmounted(props.tabId); + }; + }, [props.tabId]); + return null; + }, +})); vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); import { PreviewView } from "./PreviewView"; @@ -372,6 +453,52 @@ describe("PreviewView navigation", () => { mocks.recordingTabIds = new Set(); mocks.recordingRuntimeTabId = null; mocks.recordVisitForThread.mockClear(); + mocks.electron = false; + mocks.runtime = undefined; + mocks.serverPageMode = undefined; + mocks.nativeSurfaceMounted.mockClear(); + mocks.nativeSurfaceUnmounted.mockClear(); + mocks.streamMounted.mockClear(); + mocks.streamUnmounted.mockClear(); + mocks.streamNavigate.mockClear(); + }); + + it("switches a native server tab to its stream and drops stale desktop state", async () => { + mocks.electron = true; + mocks.runtime = "server"; + mocks.serverPageMode = "native"; + mocks.loading = true; + const document = installTestDom(); + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + const root = createRoot(container as unknown as Element); + const hasCursor = (node: TestNode): boolean => + node.nodeName === "AGENT-CURSOR" || node.childNodes.some(hasCursor); + const view = () => ; + try { + await act(() => root.render(view())); + expect(mocks.nativeSurfaceMounted).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID); + expect(mocks.streamMounted).not.toHaveBeenCalled(); + expect(mocks.chromeLoading).toBe(true); + expect(hasCursor(container)).toBe(true); + + mocks.serverPageMode = "headless"; + await act(() => root.render(view())); + expect(mocks.nativeSurfaceUnmounted).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID); + expect(mocks.streamMounted).toHaveBeenCalledTimes(1); + expect(mocks.streamMounted).toHaveBeenCalledWith("tab-1"); + expect(mocks.chromeLoading).toBe(false); + expect(mocks.chromeInputDisabled).toBe(false); + expect(mocks.toggleAnnotation).toBeNull(); + expect(hasCursor(container)).toBe(false); + + await act(async () => mocks.submittedUrl?.("localhost:3000/stream")); + expect(mocks.streamNavigate).toHaveBeenCalledWith("http://localhost:3000/stream"); + expect(mocks.navigate).not.toHaveBeenCalled(); + } finally { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + } }); it("shows the cursor in a replacement browser while the old instance still records", async () => { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 161fc4c888c87..992391596f987 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -226,7 +226,8 @@ export function PreviewView({ const [serverControlledTabId, setServerControlledTabId] = useState(null); const serverInputDisabled = isServerTab && serverControlledTabId !== runtimeTabId; const [serverFrameTabId, setServerFrameTabId] = useState(null); - const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null; + const desktopOverlay = + !isServerTab && tabId ? (previewState.desktopByTabId[tabId] ?? null) : null; const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; const url = navStatus._tag === "Idle" ? "" : navStatus.url; const loading = desktopOverlay?.loading ?? navStatus._tag === "Loading"; diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index b249c44afc108..d69f49e0a8fdd 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -125,12 +125,12 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly const recording = recordingTabIds.has(runtimeTabId) || findActiveBrowserRecordingRuntimeTabId(threadRef, tabId) !== null; - const desktopOverlay = previewState.desktopByTabId[tabId] ?? null; const fittedSourceContent = useBrowserSurfaceStore( (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null, ); const nativeServerTab = useRendersServerTabNatively(threadRef.environmentId, snapshot); const serverTab = snapshot?.runtime === "server" && !nativeServerTab; + const desktopOverlay = serverTab ? null : (previewState.desktopByTabId[tabId] ?? null); const [streamViewport, setStreamViewport] = useState(null); const serverSurfaceRef = useRef(null); const serverPictureInPicture = @@ -140,7 +140,7 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly ? streamViewport : resolvePreviewMiniPlayerSourceSize( snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, - fittedSourceContent, + serverTab ? null : fittedSourceContent, desktopOverlay?.zoomFactor ?? 1, ); diff --git a/apps/web/src/components/preview/previewFallback.test.tsx b/apps/web/src/components/preview/previewFallback.test.tsx new file mode 100644 index 0000000000000..177dc64975a94 --- /dev/null +++ b/apps/web/src/components/preview/previewFallback.test.tsx @@ -0,0 +1,227 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, type PreviewSessionSnapshot } from "@t3tools/contracts"; +import { + act, + createElement, + useEffect, + useImperativeHandle, + type ComponentProps, + type ReactNode, +} from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { HostedBrowserWebview } from "~/browser/HostedBrowserWebview"; +import type { ServerBrowserSurface } from "~/browser/ServerBrowserSurface"; + +const mocks = vi.hoisted(() => ({ + mode: "native" as "native" | "headless", + nativeMount: vi.fn(), + nativeUnmount: vi.fn(), + streamMount: vi.fn(), + streamUnmount: vi.fn(), + reportPreview: vi.fn(), + clearPreview: vi.fn(), + nativePopOut: vi.fn(), + nativeClosePopOut: vi.fn(), + serverPopOut: vi.fn(), +})); + +const threadRef = { + environmentId: EnvironmentId.make("local"), + threadId: ThreadId.make("thread-1"), +}; +function previewState() { + return { + serverEpoch: "server-1", + sessions: { + "tab-1": { + threadId: threadRef.threadId, + tabId: "tab-1", + runtime: "server", + serverPageMode: mocks.mode, + navStatus: { _tag: "Success", url: "https://example.com/", title: "Example" }, + viewport: { _tag: "freeform", width: 800, height: 600 }, + profileId: "default", + canGoBack: false, + canGoForward: false, + updatedAt: "2026-10-11T00:00:00.000Z", + } satisfies PreviewSessionSnapshot, + }, + desktopByTabId: { + "tab-1": { hasWebContents: true, zoomFactor: 2, pictureInPicture: true }, + }, + }; +} + +vi.mock("~/env", () => ({ isElectron: true })); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => threadRef.environmentId })); +vi.mock("~/state/primaryEnvironment", () => ({ primaryEnvironmentIdAtom: {} })); +vi.mock("~/state/entities", () => ({ + readEnvironmentSupportsServerBrowser: () => true, + useEnvironmentSupportsServerBrowser: () => true, +})); +vi.mock("~/previewStateStore", () => ({ + useThreadPreviewState: previewState, + useActivePreviewSessions: () => ({ [scopedThreadKey(threadRef)]: previewState() }), + isPreviewSupportedInRuntime: () => true, +})); +vi.mock("~/hooks/useSettings", () => ({ useClientSettingsHydrated: () => true })); +vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) })); +vi.mock("~/browser/browserDefaults", () => ({ + useBrowserDefaults: () => ({ profileId: "default" }), +})); +vi.mock("~/browser/browserPointerStore", () => ({ useBrowserPointerStore: {} })); +vi.mock("~/state/session", () => ({ useEnvironmentScope: () => true })); +vi.mock("~/state/preview", () => ({ previewEnvironment: { open: {} } })); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("~/browser/openFileInPreview", () => ({ openUrlInPreview: vi.fn() })); +vi.mock("~/browser/HostedBrowserWebview", () => ({ + HostedBrowserWebview: (props: ComponentProps) => { + useEffect(() => { + mocks.nativeMount(props.runtimeTabId); + return () => { + mocks.nativeUnmount(props.runtimeTabId); + }; + }, [props.runtimeTabId]); + return createElement("webview"); + }, +})); +vi.mock("~/browser/browserRecording", () => ({ + findActiveBrowserRecordingRuntimeTabId: () => null, + useActiveBrowserRecordingTabIds: () => new Set(), +})); +vi.mock("~/browser/browserSurfaceStore", () => ({ + useBrowserSurfaceStore: (select: (state: { byTabId: Record }) => unknown) => + select({ byTabId: {} }), +})); +vi.mock("~/browser/BrowserSurfaceSlot", () => ({ + BrowserSurfaceSlot: () => createElement("span", { "aria-label": "Native surface" }), +})); +vi.mock("~/browser/ServerBrowserSurface", () => ({ + ServerBrowserSurface: (props: ComponentProps) => { + useImperativeHandle( + props.ref, + () => ({ + navigate: vi.fn(), + history: vi.fn(), + reload: vi.fn(), + viewport: vi.fn(), + canvas: () => null, + }), + [], + ); + useEffect(() => { + mocks.streamMount(props.tabId); + return () => { + mocks.streamUnmount(props.tabId); + }; + }, [props.tabId]); + return createElement("stream-surface"); + }, +})); +vi.mock("~/browser/serverPictureInPicture", () => ({ + closeServerPictureInPicture: vi.fn(), + openServerPictureInPicture: mocks.serverPopOut, + serverPictureInPictureKey: (threadId: string, tabId: string) => `${threadId}:${tabId}`, + supportsServerPictureInPicture: () => true, + useServerPictureInPictureKey: () => null, +})); +vi.mock("./previewBridge", () => ({ + previewBridge: { pictureInPicture: { open: mocks.nativePopOut, close: mocks.nativeClosePopOut } }, +})); +vi.mock("../chat/ChatCanvasContext", () => ({ + useChatCanvas: () => ({ + previewKey: "browser:tab-1", + layout: { frame: { x: 0, y: 0, width: 240, height: 180 } }, + reportPreview: mocks.reportPreview, + clearPreview: mocks.clearPreview, + }), +})); +vi.mock("~/state/device", () => ({ + useDeviceState: () => ({ state: { devices: [], hosts: [] } }), +})); +vi.mock("../device/DeviceStreamView", () => ({ DeviceStreamView: () => null })); +vi.mock("~/components/ui/button", () => ({ + Button: (props: ComponentProps<"button">) => createElement("button", props), +})); +vi.mock("~/components/ui/tooltip", () => ({ + Tooltip: (props: { children: ReactNode }) => props.children, + TooltipTrigger: (props: { render: ReactNode }) => props.render, + TooltipPopup: () => null, +})); + +import { ElectronBrowserHost } from "~/browser/ElectronBrowserHost"; +import { ThreadPreviewMiniPlayer } from "./ThreadPreviewMiniPlayer"; +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; + +let renderer: ReactTestRenderer | undefined; +beforeEach(() => { + vi.clearAllMocks(); + mocks.mode = "native"; + vi.stubGlobal("window", {}); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +}); +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("headless fallback surfaces", () => { + it("unmounts the hosted native guest and never remounts it for a headless snapshot", async () => { + const runtimeTabId = previewRuntimeTabId(threadRef, "server-1", "tab-1"); + await act(() => { + renderer = create(); + }); + expect(mocks.nativeMount).toHaveBeenCalledExactlyOnceWith(runtimeTabId); + expect(renderer!.root.findAllByType("webview")).toHaveLength(1); + mocks.mode = "headless"; + await act(() => renderer!.update()); + expect(mocks.nativeUnmount).toHaveBeenCalledExactlyOnceWith(runtimeTabId); + expect(renderer!.root.findAllByType("webview")).toHaveLength(0); + await act(() => renderer!.update()); + expect(mocks.nativeMount).toHaveBeenCalledTimes(1); + }); + + it("moves a floating tab to its stream and uses server pop-out instead of stale native PiP", async () => { + const view = () => ( + + ); + await act(() => { + renderer = create(view()); + }); + expect(renderer!.root.findAllByProps({ "aria-label": "Native surface" })).toHaveLength(1); + const nativeCloseButton = renderer!.root.findByProps({ + "aria-label": "Close popped-out preview", + }); + await act(async () => nativeCloseButton.props.onClick()); + expect(mocks.nativeClosePopOut).toHaveBeenCalledOnce(); + + mocks.mode = "headless"; + await act(() => renderer!.update(view())); + expect(renderer!.root.findAllByProps({ "aria-label": "Native surface" })).toHaveLength(0); + expect(mocks.streamMount).toHaveBeenCalledExactlyOnceWith("tab-1"); + expect(mocks.reportPreview).toHaveBeenLastCalledWith( + expect.objectContaining({ source: { width: 800, height: 600 } }), + ); + const serverOpenButton = renderer!.root.findByProps({ + "aria-label": "Pop preview into separate window", + }); + await act(async () => serverOpenButton.props.onClick()); + expect(mocks.serverPopOut).toHaveBeenCalledExactlyOnceWith({ + ...threadRef, + tabId: "tab-1", + seed: null, + }); + expect(mocks.nativeClosePopOut).toHaveBeenCalledOnce(); + expect(mocks.nativePopOut).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/preview/usePreviewBridge.test.ts b/apps/web/src/components/preview/usePreviewBridge.test.ts index 14acf0d69609e..31fe7863c5bca 100644 --- a/apps/web/src/components/preview/usePreviewBridge.test.ts +++ b/apps/web/src/components/preview/usePreviewBridge.test.ts @@ -1,7 +1,69 @@ -import type { DesktopPreviewTabState } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ThreadId, type DesktopPreviewTabState } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { act, createElement } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { projectDesktopState } from "./usePreviewBridge"; +const mocks = vi.hoisted(() => ({ + mode: undefined as "native" | "headless" | undefined, + listener: null as ((tabId: string, state: DesktopPreviewTabState) => void) | null, + applyDesktopState: vi.fn(), + unsubscribe: vi.fn(), + reportStatus: vi.fn(), + clearPointer: vi.fn(), +})); + +vi.mock("~/previewStateStore", () => ({ + applyPreviewDesktopState: mocks.applyDesktopState, + readThreadPreviewState: () => ({ sessions: { "tab-1": { serverPageMode: mocks.mode } } }), +})); +vi.mock("~/browserFaviconStore", () => ({ + flushPendingFaviconsForThread: vi.fn(), + recordFaviconForThread: vi.fn(), + useFaviconProjectRefForThread: () => null, +})); +vi.mock("~/browser/browserPointerStore", () => ({ + useBrowserPointerStore: (select: (state: { clear: typeof mocks.clearPointer }) => unknown) => + select({ clear: mocks.clearPointer }), +})); +vi.mock("~/state/preview", () => ({ previewEnvironment: { reportStatus: {} } })); +vi.mock("~/state/session", () => ({ usePreparedConnection: () => Option.none() })); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => mocks.reportStatus })); +vi.mock("./previewBridge", () => ({ + previewBridge: { + onStateChange: (listener: typeof mocks.listener) => { + mocks.listener = listener; + return mocks.unsubscribe; + }, + }, +})); + +import { projectDesktopState, usePreviewBridge } from "./usePreviewBridge"; + +const threadRef = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), +}; +let renderer: ReactTestRenderer | undefined; + +function BridgeHarness() { + usePreviewBridge({ threadRef, tabId: "tab-1", runtimeTabId: "native-tab-1", serverDriven: true }); + return null; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.mode = undefined; + mocks.listener = null; + vi.stubGlobal("window", {}); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); const favicon = { dataUrl: "data:image/png;base64,AAAA", @@ -48,3 +110,50 @@ describe("projectDesktopState", () => { expect(projectDesktopState(state({ kind: "Idle" })).favicon).toBeNull(); }); }); + +describe("native bridge lifetime", () => { + it("drops late native state when the server switches this tab to headless", async () => { + mocks.mode = "native"; + await act(() => { + renderer = create(createElement(BridgeHarness)); + }); + mocks.listener?.( + "native-tab-1", + state({ kind: "Loading", url: "https://example.com/", title: "" }), + ); + expect(mocks.applyDesktopState).toHaveBeenLastCalledWith( + threadRef, + "tab-1", + expect.objectContaining({ loading: true }), + ); + expect(mocks.reportStatus).not.toHaveBeenCalled(); + + mocks.mode = "headless"; + mocks.listener?.( + "native-tab-1", + state({ kind: "Success", url: "https://other.example/", title: "Late" }), + ); + expect(mocks.applyDesktopState).toHaveBeenLastCalledWith(threadRef, "tab-1", null); + expect(mocks.reportStatus).not.toHaveBeenCalled(); + + await act(() => renderer?.unmount()); + renderer = undefined; + expect(mocks.unsubscribe).toHaveBeenCalledTimes(1); + expect(mocks.applyDesktopState).toHaveBeenLastCalledWith(threadRef, "tab-1", null); + }); + + it("clears the native overlay on headless unmount even without another native event", async () => { + await act(() => { + renderer = create(createElement(BridgeHarness)); + }); + mocks.listener?.( + "native-tab-1", + state({ kind: "Loading", url: "https://example.com/", title: "" }), + ); + mocks.mode = "headless"; + await act(() => renderer?.unmount()); + renderer = undefined; + expect(mocks.unsubscribe).toHaveBeenCalledTimes(1); + expect(mocks.applyDesktopState).toHaveBeenLastCalledWith(threadRef, "tab-1", null); + }); +}); diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index ee737892a58d0..83772b35b3b97 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -16,7 +16,11 @@ import { useFaviconProjectRefForThread, } from "~/browserFaviconStore"; import { useBrowserPointerStore } from "~/browser/browserPointerStore"; -import { applyPreviewDesktopState, type DesktopPreviewOverlay } from "~/previewStateStore"; +import { + applyPreviewDesktopState, + readThreadPreviewState, + type DesktopPreviewOverlay, +} from "~/previewStateStore"; import { previewEnvironment } from "~/state/preview"; import { usePreparedConnection } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -66,6 +70,11 @@ export function usePreviewBridge(input: { const handleStateChange = useEffectEvent( (changedTabId: string, state: DesktopPreviewTabState): void => { if (changedTabId !== runtimeTabId) return; + // A late native guest must not override the headless page's stream state. + if (readThreadPreviewState(stableThreadRef).sessions[tabId]?.serverPageMode === "headless") { + applyPreviewDesktopState(stableThreadRef, tabId, null); + return; + } if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) { clearBrowserPointer(runtimeTabId); } @@ -95,7 +104,13 @@ export function usePreviewBridge(input: { lastReportedUrl.current = null; lastReportedKind.current = null; lastDesktopNavStatus.current = null; - return bridge.onStateChange(handleStateChange); + const unsubscribe = bridge.onStateChange(handleStateChange); + return () => { + unsubscribe(); + if (readThreadPreviewState(stableThreadRef).sessions[tabId]?.serverPageMode === "headless") { + applyPreviewDesktopState(stableThreadRef, tabId, null); + } + }; }, [bridge, runtimeTabId, stableThreadRef, tabId]); useEffect(() => { if (!projectRef) return; diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index 24f429745ef87..707ef3d5f777a 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -80,21 +80,45 @@ describe("PreviewNavStatus", () => { }); describe("PreviewSessionSnapshot", () => { - it("round-trips a Success snapshot", () => { + const legacySnapshot = { + threadId: "thread-1", + tabId: "preview-thread-1", + navStatus: { + _tag: "Success" as const, + url: "http://localhost:5173/", + title: "Vite App", + }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const jsonCodec = Schema.toCodecJson(PreviewSessionSnapshot); + const encodeJson = Schema.encodeSync(jsonCodec); + const decodeJson = Schema.decodeUnknownSync(jsonCodec); + + it("round-trips a legacy snapshot without choosing a page backing", () => { + const snapshot = decodeJson(encodeJson(decodeSnapshot(legacySnapshot))); + expect(snapshot).toEqual(legacySnapshot); + expect(snapshot).not.toHaveProperty("serverPageMode"); + }); + + it.each(["native", "headless"] as const)("preserves %s server page backing over JSON", (mode) => { const snapshot = decodeSnapshot({ - threadId: "thread-1", - tabId: "preview-thread-1", - navStatus: { - _tag: "Success", - url: "http://localhost:5173/", - title: "Vite App", - }, - canGoBack: false, - canGoForward: false, - updatedAt: "2026-01-01T00:00:00.000Z", + ...legacySnapshot, + runtime: "server", + serverPageMode: mode, + }); + expect(decodeJson(encodeJson(snapshot))).toEqual({ + ...legacySnapshot, + runtime: "server", + serverPageMode: mode, }); - expect(snapshot.tabId).toBe("preview-thread-1"); - expect(snapshot.navStatus._tag).toBe("Success"); + }); + + it("rejects a runtime value used as a page backing", () => { + expect(() => + decodeSnapshot({ ...legacySnapshot, runtime: "server", serverPageMode: "server" }), + ).toThrow(); }); }); @@ -244,6 +268,31 @@ describe("PreviewEvent", () => { expect(event.type).toBe("opened"); }); + it("preserves the headless fallback in a snapshot update sent to clients", () => { + const event = decodePreviewEvent({ + type: "navigated", + threadId: "t", + tabId: "preview-t", + createdAt: "2026-01-01T00:00:00.000Z", + serverEpoch: "server-a", + revision: 2, + snapshot: { + threadId: "t", + tabId: "preview-t", + runtime: "server", + serverPageMode: "headless", + navStatus: { _tag: "Loading", url: "https://example.com/", title: "" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + const jsonCodec = Schema.toCodecJson(PreviewEvent); + const received = Schema.decodeUnknownSync(jsonCodec)(Schema.encodeSync(jsonCodec)(event)); + expect(received).toEqual(event); + expect(received.type === "navigated" && received.snapshot.serverPageMode).toBe("headless"); + }); + it("decodes failed with code/description", () => { const event = decodePreviewEvent({ type: "failed", diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 802bbd4765b7c..2d61108b0fb9a 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -172,10 +172,10 @@ export const PreviewNavStatus = Schema.Union([ export type PreviewNavStatus = typeof PreviewNavStatus.Type; /** - * Where a tab's page runs. `desktop` is an Electron owned by one - * desktop client; `server` is headless Chromium owned by the environment - * server, viewed by any client through `/api/preview-stream` and driven by - * agents with no client attached. Absent means `desktop`. + * Who manages a tab. `desktop` is an Electron owned by one desktop + * client; `server` is owned by the environment server and driven by agents. + * A server tab can use its desktop host's native page or headless Chromium; + * other clients view either through `/api/preview-stream`. Absent means `desktop`. */ export const PreviewRuntime = Schema.Literals(["desktop", "server"]); export type PreviewRuntime = typeof PreviewRuntime.Type; @@ -213,6 +213,8 @@ export const PreviewSessionSnapshot = Schema.Struct({ */ profileId: Schema.optional(BrowserProfileId), runtime: Schema.optional(PreviewRuntime), + /** Server-selected page backing. Absent preserves clients' existing host-based rendering choice. */ + serverPageMode: Schema.optional(Schema.Literals(["native", "headless"])), /** Authenticated provider session owning an isolated server tab. */ automationOwner: Schema.optional(Schema.String), /** An agent opened this tab and asked to show it, so viewers float it. */ From 154780d6d8fccbe2fc78f7b7ee7cd987eb2d0abb Mon Sep 17 00:00:00 2001 From: davidesala Date: Mon, 12 Oct 2026 20:00:51 +0200 Subject: [PATCH 3/3] fix(browser): recover native profiles after headless fallback --- apps/server/src/mcp/toolkits/preview/tools.ts | 4 +- apps/server/src/preview/ServerBrowser.test.ts | 377 +++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 70 +++- packages/contracts/src/preview.test.ts | 19 + packages/contracts/src/previewAutomation.ts | 3 + 5 files changed, 452 insertions(+), 21 deletions(-) diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 6e34a330025e0..e47af97f40e24 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -64,7 +64,7 @@ const readonlyBrowserTool = (tool: T): T => const PreviewStatusTool = Tool.make("preview_status", { description: - "Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab, or the tab the user is viewing when you have none. Server hosts also list every tab in the thread (tabs) with its owner, including tabs the user opened, and the browser profiles preview_open accepts. You can read any listed tab; act only on your own tabs, or on an unclaimed tab while no human controls it.", + "Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Server hosts report serverPageMode (native or headless) for the current tab and tabs list; absence means the host did not report the backing, not that it is native. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab, or the tab the user is viewing when you have none. Server hosts also list every tab in the thread (tabs) with its owner, including tabs the user opened, and the browser profiles preview_open accepts. You can read any listed tab; act only on your own tabs, or on an unclaimed tab while no human controls it.", parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationStatus, failure: PreviewToolFailure, @@ -78,7 +78,7 @@ const PreviewStatusTool = Tool.make("preview_status", { const PreviewOpenTool = browserTool( Tool.make("preview_open", { description: - "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Pass profileId (an id or name from preview_status profiles) to open under a browser profile and its saved logins; omit it for the user's default profile. Another agent session's tabs can be read but not operated.", + "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Pass profileId (an id or name from preview_status profiles) to open under a browser profile; omit it for the user's default profile. To recover a desktop profile's existing sign-ins after a headless fallback, pass its profileId explicitly with open=true and reuseExistingTab=false. On a desktop server, this new foreground tab visits the requested URL only after connecting to the native page; attachment failure closes the new blank tab and preserves previous tabs. Incognito, background, and standalone server opens retain headless support. Another agent session's tabs can be read but not operated.", parameters: PreviewAutomationOpenInput, success: PreviewAutomationStatus, failure: PreviewToolFailure, diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index b791050ef8bf8..c44a34ced9beb 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -51,9 +51,27 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ return makeContext().page as unknown as Page; } async connectDesktopPage(endpoint: string) { + if (desktopConnectionFailure) throw desktopConnectionFailure; const context = makeContext(); - desktopConnections.push({ endpoint, context }); - return { browser: { close: async () => {} }, page: context.page as unknown as Page }; + if (desktopSetupGate) { + const newCDPSession = context.newCDPSession; + context.newCDPSession = async () => { + desktopSetupStarted?.resolve(); + await desktopSetupGate?.promise; + return newCDPSession(); + }; + } + if (desktopSetupFailure === "cdp") + context.newCDPSession = async () => { + throw new Error("Desktop CDP session setup failed"); + }; + if (desktopSetupFailure === "rendering") + context.page.emulateMedia.mockRejectedValueOnce( + new Error("Desktop rendering setup failed"), + ); + const close = vi.fn(async () => {}); + desktopConnections.push({ endpoint, context, close }); + return { browser: { close }, page: context.page as unknown as Page }; } async close() { for (const context of contexts) await context.close(); @@ -161,6 +179,10 @@ let contextFailure: Error | null = null; /** Server tabs the fake desktop renders, and the endpoints the server connected to. */ let desktopRendersNext = false; let desktopAvailable = false; +let desktopConnectionFailure: Error | null = null; +let desktopSetupFailure: "cdp" | "rendering" | null = null; +let desktopSetupGate: PromiseWithResolvers | null = null; +let desktopSetupStarted: PromiseWithResolvers | null = null; let desktopAttachGate: PromiseWithResolvers | null = null; let desktopAttachStarted: PromiseWithResolvers | null = null; let desktopDetachConsumed: PromiseWithResolvers | null = null; @@ -179,6 +201,7 @@ const releasedDesktopTabs: Array = []; const desktopConnections: Array<{ endpoint: string; context: ReturnType; + close: ReturnType Promise>>; }> = []; const testThread = { threadId: ThreadId.make("browser-test-thread"), @@ -293,6 +316,10 @@ beforeEach(() => { desktopTabs.clear(); desktopRendersNext = false; desktopAvailable = false; + desktopConnectionFailure = null; + desktopSetupFailure = null; + desktopSetupGate = null; + desktopSetupStarted = null; desktopAttachGate = null; desktopAttachStarted = null; desktopDetachConsumed = null; @@ -1625,6 +1652,352 @@ it.live("a standalone server still opens a headless page when no desktop channel ).pipe(Effect.provide(layer)), ); +it.live("an explicit visible profile open refuses to visit the URL through headless fallback", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + yield* browser.reportProfiles({ + profiles: [{ id: "profile-work", name: "Work", kind: "persistent" }], + defaultProfileId: "profile-work", + }); + desktopAvailable = true; + const outcome = yield* broker + .invoke({ + scope, + operation: "open", + input: { + url: "http://localhost:5173/authenticated", + profileId: "Work", + open: true, + reuseExistingTab: false, + }, + }) + .pipe( + Effect.match({ + onSuccess: (status) => ({ ok: true as const, status }), + onFailure: (error) => ({ ok: false as const, error }), + }), + ); + expect(outcome).toMatchObject({ + ok: false, + error: { + _tag: "PreviewAutomationExecutionError", + reason: expect.stringContaining("native"), + }, + }); + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toMatchObject([ + { tabId }, + ]); + expect(contexts).toHaveLength(2); + expect(contexts[1]!.page.goto).not.toHaveBeenCalled(); + expect(contexts[1]!.page.close).toHaveBeenCalledOnce(); + expect(contexts[1]!.close).not.toHaveBeenCalled(); + expect(contexts[0]!.page.close).not.toHaveBeenCalled(); + expect( + yield* broker.invoke({ scope, tabId, operation: "evaluate", input: { expression: "42" } }), + ).toBe("evaluated"); + }), + ).pipe(Effect.provide(layer)), +); + +it.live.each([ + { name: "open", profileId: "Work", resolvedProfileId: "profile-work", reveal: { open: true } }, + { + name: "show alias", + profileId: "profile-work", + resolvedProfileId: "profile-work", + reveal: { show: true }, + }, + { + name: "built-in Default", + profileId: "default", + resolvedProfileId: "default", + reveal: { open: true }, + }, +])("an explicit visible profile open uses the native page with $name", (input) => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; + yield* browser.reportProfiles({ + profiles: [{ id: "profile-work", name: "Work", kind: "persistent" }], + defaultProfileId: "profile-work", + }); + // Recovery must keep even an old unwatched fallback available. + const clock = vi.spyOn(Date, "now").mockReturnValue(Date.now() + 31 * 60 * 1000); + yield* Effect.addFinalizer(() => Effect.sync(() => clock.mockRestore())); + desktopRendersNext = true; + const opened = yield* broker.invoke({ + scope, + operation: "open", + input: { + url: "http://localhost:5173/authenticated", + profileId: input.profileId, + ...input.reveal, + reuseExistingTab: false, + }, + }); + const published = yield* PubSub.takeUpTo(events, 10); + expect(published.find((event) => event.type === "opened")).toMatchObject({ + snapshot: { + tabId: opened.tabId, + profileId: input.resolvedProfileId, + navStatus: { _tag: "Idle" }, + }, + }); + expect(desktopAttachRequests).toEqual([opened.tabId]); + expect(desktopConnections).toHaveLength(1); + expect(contexts).toHaveLength(1); + expect(desktopConnections[0]!.context.page.goto).toHaveBeenCalledExactlyOnceWith( + "http://localhost:5173/authenticated", + expect.objectContaining({ waitUntil: "load" }), + ); + expect(opened).toMatchObject({ + serverPageMode: "native", + url: "http://localhost:5173/authenticated", + control: { owner: "agent", ownedByCaller: true }, + tabs: expect.arrayContaining([ + expect.objectContaining({ tabId, serverPageMode: "headless" }), + expect.objectContaining({ tabId: opened.tabId, serverPageMode: "native" }), + ]), + }); + expect(contexts[0]!.page.close).not.toHaveBeenCalled(); + expect( + yield* broker.invoke({ scope, tabId, operation: "evaluate", input: { expression: "42" } }), + ).toBe("evaluated"); + expect( + yield* broker + .invoke({ + scope: asSession("another-agent"), + tabId: PreviewTabId.make(opened.tabId!), + operation: "evaluate", + input: { expression: "42" }, + }) + .pipe(Effect.flip), + ).toMatchObject({ reason: "agentMismatch" }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a failed native profile connection removes only its newly created blank session", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; + yield* browser.reportProfiles({ + profiles: [{ id: "profile-work", name: "Work", kind: "persistent" }], + defaultProfileId: "profile-work", + }); + desktopRendersNext = true; + desktopConnectionFailure = new Error("CDP connection failed"); + expect( + yield* broker + .invoke({ + scope, + operation: "open", + input: { + url: "http://localhost:5173/authenticated", + profileId: "profile-work", + open: true, + reuseExistingTab: false, + }, + }) + .pipe(Effect.flip), + ).toMatchObject({ reason: expect.stringContaining("native") }); + const published = yield* PubSub.takeUpTo(events, 10); + expect(published.find((event) => event.type === "opened")).toMatchObject({ + snapshot: { navStatus: { _tag: "Idle" } }, + }); + expect(published.find((event) => event.type === "closed")).toMatchObject({ + tabId: desktopAttachRequests[0], + }); + expect(releasedDesktopTabs).toEqual(desktopAttachRequests); + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toMatchObject([ + { tabId }, + ]); + expect(contexts).toHaveLength(1); + expect(contexts[0]!.page.goto).not.toHaveBeenCalled(); + expect(contexts[0]!.page.close).not.toHaveBeenCalled(); + expect( + yield* broker.invoke({ scope, tabId, operation: "evaluate", input: { expression: "42" } }), + ).toBe("evaluated"); + }), + ).pipe(Effect.provide(layer)), +); + +it.live.each(["cdp", "rendering"] as const)( + "a failed native profile $0 setup releases its acquired connection before responding", + (failure) => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + yield* browser.reportProfiles({ + profiles: [{ id: "profile-work", name: "Work", kind: "persistent" }], + defaultProfileId: "profile-work", + }); + desktopAvailable = true; + desktopSetupFailure = failure; + const waiting = Promise.withResolvers(); + const attached = Promise.withResolvers(); + desktopAttachStarted = waiting; + desktopAttachGate = attached; + yield* Effect.addFinalizer(() => Effect.sync(() => attached.resolve(false))); + const opening = yield* broker + .invoke({ + scope, + operation: "open", + input: { + url: "http://localhost:5173/authenticated", + profileId: "profile-work", + open: true, + reuseExistingTab: false, + }, + }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Effect.promise(() => waiting.promise); + const pending = (yield* manager.list({ threadId: scope.thread.threadId })).sessions.find( + (session) => session.tabId !== tabId, + )!; + yield* manager.adjust({ + threadId: scope.thread.threadId, + tabId: pending.tabId, + colorScheme: "dark", + }); + attached.resolve(true); + expect(yield* Fiber.join(opening)).toMatchObject({ + reason: expect.stringContaining("native"), + }); + expect(desktopConnections).toHaveLength(1); + expect(desktopConnections[0]!.close).toHaveBeenCalledOnce(); + expect(releasedDesktopTabs).toEqual(desktopAttachRequests); + expect(desktopConnections[0]!.context.page.goto).not.toHaveBeenCalled(); + expect(desktopConnections[0]!.context.page.close).not.toHaveBeenCalled(); + expect(desktopConnections[0]!.context.close).not.toHaveBeenCalled(); + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toMatchObject([ + { tabId }, + ]); + expect(contexts[0]!.page.close).not.toHaveBeenCalled(); + expect( + yield* broker.invoke({ + scope, + tabId, + operation: "evaluate", + input: { expression: "42" }, + }), + ).toBe("evaluated"); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("closing a native profile session during setup releases its connection once", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + desktopRendersNext = true; + const settingUp = Promise.withResolvers(); + const setup = Promise.withResolvers(); + desktopSetupStarted = settingUp; + desktopSetupGate = setup; + yield* Effect.addFinalizer(() => Effect.sync(() => setup.resolve())); + const opening = yield* broker + .invoke({ + scope, + operation: "open", + input: { profileId: "default", open: true, reuseExistingTab: false }, + }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Effect.promise(() => settingUp.promise); + const pending = (yield* manager.list({ threadId: scope.thread.threadId })).sessions.find( + (session) => session.tabId !== tabId, + )!; + yield* manager.close({ threadId: scope.thread.threadId, tabId: pending.tabId }); + setup.resolve(); + yield* Fiber.join(opening); + expect(desktopConnections[0]!.close).toHaveBeenCalledOnce(); + expect(releasedDesktopTabs).toEqual(desktopAttachRequests); + expect(desktopConnections[0]!.context.page.close).not.toHaveBeenCalled(); + expect(contexts[0]!.page.close).not.toHaveBeenCalled(); + expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toMatchObject([ + { tabId }, + ]); + }), + ).pipe(Effect.provide(layer)), +); + +it.live.each([ + { name: "omitted profile", input: { open: true, reuseExistingTab: false } }, + { name: "omitted reveal", input: { profileId: "Work", reuseExistingTab: false } }, + { name: "background", input: { profileId: "Work", open: false, reuseExistingTab: false } }, + { + name: "open overrides show", + input: { profileId: "Work", open: false, show: true, reuseExistingTab: false }, + }, + { name: "incognito", input: { profileId: "incognito", open: true, reuseExistingTab: false } }, + { name: "standalone", input: { profileId: "Work", open: true, reuseExistingTab: false } }, + { name: "reuse", input: { profileId: "Work", open: true, reuseExistingTab: true } }, + { name: "default reuse", input: { profileId: "Work", open: true } }, +])("ordinary profile opening still allows headless for $name", ({ name, input }) => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + yield* browser.reportProfiles({ + profiles: [{ id: "profile-work", name: "Work", kind: "persistent" }], + defaultProfileId: "profile-work", + }); + desktopAvailable = name !== "standalone"; + const reuses = name === "reuse" || name === "default reuse"; + const opened = yield* broker.invoke({ + scope, + ...(reuses ? { tabId } : {}), + operation: "open", + input: { url: "http://localhost:5173/headless", ...input }, + }); + expect(opened).toMatchObject({ available: true, serverPageMode: "headless" }); + expect(contexts.at(-1)!.page.goto).toHaveBeenCalledExactlyOnceWith( + "http://localhost:5173/headless", + expect.objectContaining({ waitUntil: reuses ? "load" : "commit" }), + ); + expect(desktopConnections).toEqual([]); + expect(contexts).toHaveLength(reuses ? 1 : 2); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("native profile recovery respects the existing agent tab capacity", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker } = yield* ready; + for (let count = 1; count < 8; count++) + yield* broker.invoke({ + scope, + operation: "open", + input: { reuseExistingTab: false, show: false }, + }); + yield* browser.reportProfiles({ + profiles: [{ id: "profile-work", name: "Work", kind: "persistent" }], + defaultProfileId: "profile-work", + }); + desktopRendersNext = true; + expect( + yield* broker + .invoke({ + scope, + operation: "open", + input: { profileId: "Work", open: true, reuseExistingTab: false }, + }) + .pipe(Effect.flip), + ).toMatchObject({ reason: "tabLimit" }); + expect(desktopAttachRequests).toEqual([]); + expect(contexts).toHaveLength(8); + }), + ).pipe(Effect.provide(layer)), +); + it.live("drives the desktop's own page for a tab the desktop renders", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 9b85079ac03fc..a6f0e3f4c48c7 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -728,12 +728,14 @@ const make = Effect.gen(function* () { .pipe(Scope.provide(scope)), ); const connected = await contexts.connectDesktopPage(endpoint); + let closing: Promise | undefined; return { page: connected.page, - close: async () => { - await connected.browser.close().catch(constVoid); - await Effect.runPromise(Scope.close(scope, Exit.void)); - }, + close: () => + (closing ??= (async () => { + await connected.browser.close().catch(constVoid); + await Effect.runPromise(Scope.close(scope, Exit.void)); + })()), }; } catch (cause) { await Effect.runPromise(Scope.close(scope, Exit.void)); @@ -752,7 +754,10 @@ const make = Effect.gen(function* () { return snapshot; }; - const createTab = async (requestedSnapshot: PreviewSessionSnapshot): Promise => { + const createTab = async ( + requestedSnapshot: PreviewSessionSnapshot, + onDesktopAcquired: (close: () => Promise) => void, + ): Promise => { const key = tabKey(requestedSnapshot.threadId, requestedSnapshot.tabId); const adopted = adoptedPages.get(key); adoptedPages.delete(key); @@ -776,6 +781,7 @@ const make = Effect.gen(function* () { ); } const desktop = native ? await connectDesktop(snapshot) : null; + if (desktop) onDesktopAcquired(desktop.close); // An agent tab without a profile (no client reported one) keeps throwaway storage. const isolatedContext = adopted === undefined && @@ -1215,8 +1221,13 @@ const make = Effect.gen(function* () { if (pending) return pending; const existing = tabs.get(key); if (existing) return Promise.resolve(existing); - const opening = createTab(snapshot) - .catch((cause: unknown) => { + let releaseDesktop: (() => Promise) | undefined; + const opening = createTab(snapshot, (close) => { + releaseDesktop = close; + }) + .catch(async (cause: unknown) => { + // The tab has not been published, so dropTab cannot release its connection. + await releaseDesktop?.().catch(constVoid); runFork( Effect.logWarning( isHostSetupError(cause) ? cause.message : "server preview tab failed to start", @@ -1364,6 +1375,7 @@ const make = Effect.gen(function* () { url: url === "about:blank" ? null : url, title: null, loading: tab.loading, + serverPageMode: tab.desktop ? ("native" as const) : ("headless" as const), control: { owner: tabOwner(tab), ownedByCaller: tab.control.agentId === agentSessionId, @@ -1384,6 +1396,7 @@ const make = Effect.gen(function* () { owner: tabOwner(candidate), ownedByCaller: candidate.control.agentId === agentSessionId, visible: candidate.viewers.size > 0, + serverPageMode: candidate.desktop ? ("native" as const) : ("headless" as const), ...(candidate.profileId === undefined ? {} : { profileId: candidate.profileId }), })), ...profileStatus(), @@ -1761,24 +1774,48 @@ const make = Effect.gen(function* () { : undefined; const navigationTimeout = Math.min(request.timeoutMs, NAVIGATION_TIMEOUT_MS); const profileId = existing ? undefined : resolveOpenProfile(open.profileId); + const reveal = open.open ?? open.show; + // An explicit new, visible profile tab must use that profile's desktop + // session. Open it blank so a fallback cannot visit the authenticated site. + const nativeProfileOpen = + !existing && + open.profileId !== undefined && + profileId !== INCOGNITO_BROWSER_PROFILE_ID && + reveal === true && + open.reuseExistingTab === false && + desktopChannel.available; if (!existing) { - closeIdleAgentTabs(); + if (!nativeProfileOpen) closeIdleAgentTabs(); assertTabCapacity(request.agentSessionId); } - const tab = - existing ?? - (await ensureTab( - await Effect.runPromise( + const created = existing + ? undefined + : await Effect.runPromise( manager.open({ threadId: request.threadId, - ...(url ? { url } : {}), + ...(url && !nativeProfileOpen ? { url } : {}), ...(profileId === undefined ? {} : { profileId }), runtime: "server", reveal: false, automationOwner: request.agentSessionId, }), - ), - )); + ); + let tab: ServerTab; + try { + tab = existing ?? (await ensureTab(created!)); + if (nativeProfileOpen && !tab.desktop) throw new Error("Native profile did not attach."); + } catch (cause) { + if (!nativeProfileOpen || !created) throw cause; + await Effect.runPromise( + manager.close({ threadId: request.threadId, tabId: created.tabId }), + ); + const failed = tabs.get(tabKey(request.threadId, created.tabId)); + if (failed) dropTab(failed, false); + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationNativeProfileUnavailableError", + "The desktop's native browser did not attach. Open the Browser panel and retry preview_open with the same profileId, open=true and reuseExistingTab=false. The URL was not visited; existing tabs were kept.", + ); + } if (existing?.dialog) throw new BrowserControlInterrupted( "A browser dialog is pending. Read preview_status and use preview_dialog first.", @@ -1790,13 +1827,12 @@ const make = Effect.gen(function* () { "A browser dialog is pending. Read preview_status and use preview_dialog first.", "dialogPending", ); - if (existing) { + if (existing || nativeProfileOpen) { if (url) await navigate(tab, url, "load", navigationTimeout); } else { // Await the original navigation failure even though background creation keeps the tab. await tab.initialNavigation; } - const reveal = open.open ?? open.show; if (reveal !== false) { await Effect.runPromise( manager.requestReveal({ diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index 707ef3d5f777a..4ef269e5e3919 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -227,6 +227,25 @@ describe("PreviewAutomationError", () => { }); describe("PreviewAutomationStatus", () => { + it("preserves mixed page backings in the automation response over JSON", () => { + const status = { + available: true, + visible: true, + tabId: "native-tab", + url: "https://example.com/", + title: "Example", + loading: false, + serverPageMode: "native" as const, + tabs: [ + { tabId: "native-tab", url: "https://example.com/", serverPageMode: "native" as const }, + { tabId: "fallback-tab", url: "https://example.com/", serverPageMode: "headless" as const }, + ], + }; + const codec = Schema.toCodecJson(PreviewAutomationStatus); + const decoded = decodeAutomationStatus(status); + expect(Schema.decodeUnknownSync(codec)(Schema.encodeSync(codec)(decoded))).toEqual(status); + }); + it("accepts old hosts without viewport data and exposes it from current hosts", () => { const base = { available: true, diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 504d28148b296..154c413792927 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -92,6 +92,8 @@ export const PreviewAutomationStatus = Schema.Struct({ url: Schema.NullOr(Schema.String), title: Schema.NullOr(Schema.String), loading: Schema.Boolean, + /** Actual server page backing. Older hosts may omit it; absence does not imply native. */ + serverPageMode: Schema.optional(Schema.Literals(["native", "headless"])), control: Schema.optional( Schema.Struct({ owner: PreviewAutomationTabOwner, @@ -143,6 +145,7 @@ export const PreviewAutomationStatus = Schema.Struct({ ownedByCaller: Schema.optional(Schema.Boolean), visible: Schema.optional(Schema.Boolean), profileId: Schema.optional(BrowserProfileId), + serverPageMode: Schema.optional(Schema.Literals(["native", "headless"])), }), ), ),