From bcee526427abdcfc9601b04820fd976f49fd2810 Mon Sep 17 00:00:00 2001 From: Jreevo Date: Thu, 8 Oct 2026 15:01:22 -0700 Subject: [PATCH] fix(server): agent preview tabs follow the desktop page when it mounts late A server tab decides once, at creation, whether to drive the desktop's page. If the desktop panel did not mount it within 10 seconds, it ran headless (isolated storage for agent tabs) and stayed that way after the desktop attached, so the panel showed the person's signed-in page while agents drove a signed-out one under the same tab id. On a late attach, drop the headless page and reconnect to the desktop's, keeping the session and telling stream viewers to reconnect. Fixes #17334 Co-Authored-By: Claude Opus 5.5 --- .../src/preview/DesktopBrowserChannel.ts | 7 +++ apps/server/src/preview/ServerBrowser.test.ts | 47 ++++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 24 ++++++++-- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 3c3094f3aa60..cea8223fed97 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -52,6 +52,8 @@ export class DesktopBrowserChannel extends Context.Service< * attach landing in between is never missed. False after `timeout`. */ readonly awaitAttached: (key: DesktopTabKey, timeout: Duration.Input) => Effect.Effect; + /** Desktop tabs as they attach. */ + readonly attached: Stream.Stream; /** Desktop tabs as they detach. */ readonly detached: Stream.Stream; readonly isAttached: (key: DesktopTabKey) => Effect.Effect; @@ -82,6 +84,7 @@ const make = Effect.gen(function* () { return DesktopBrowserChannel.of({ available: false, awaitAttached: () => Effect.succeed(false), + attached: Stream.empty, detached: Stream.empty, isAttached: () => Effect.succeed(false), endpoint: () => Effect.die("No desktop app is attached to this server."), @@ -212,6 +215,10 @@ const make = Effect.gen(function* () { ); }), ), + attached: Stream.fromPubSub(changes).pipe( + Stream.filter((change) => change.attached), + Stream.map((change) => change.key), + ), detached: Stream.fromPubSub(changes).pipe( Stream.filter((change) => !change.attached), Stream.map((change) => change.key), diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index dce0c23b96cf..a0efdf86cf24 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -153,7 +153,7 @@ 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; -/** Pages the fake desktop takes back; the channel's detached stream emits them. */ +/** Pages the fake desktop mounts late or takes back; the channel's streams emit them. */ const desktopDetaches = new NodeEvents.EventEmitter(); const desktopTabs = new Set(); const desktopRenders = (tabId: string) => { @@ -198,6 +198,17 @@ const dependencies = Layer.mergeAll( // Only tabs a test marks render on the desktop; the rest stay headless. available: true, awaitAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), + attached: Stream.callback<{ threadId: string; tabId: string }>((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const onAttach = (key: { threadId: string; tabId: string }) => + Queue.offerUnsafe(queue, key); + desktopDetaches.on("attach", onAttach); + return onAttach; + }), + (onAttach) => Effect.sync(() => desktopDetaches.off("attach", onAttach)), + ), + ), detached: Stream.callback<{ threadId: string; tabId: string }>((queue) => Effect.acquireRelease( Effect.sync(() => { @@ -1064,6 +1075,40 @@ it.live("drives the desktop's own page for a tab the desktop renders", () => ).pipe(Effect.provide(layer)), ); +it.live("an agent tab that started headless moves to the desktop's page once it mounts", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + // No panel showed the agent's tab in time, so it runs headless in its own storage. + expect(desktopConnections).toEqual([]); + const headless = contexts[0]!; + const viewer = yield* browser.attachViewer(viewerInput(tabId, false)); + // The person opens the panel, and the desktop mounts its own page for the tab. + while (desktopDetaches.listenerCount("attach") === 0) yield* Effect.yieldNow; + desktopTabs.add(tabId); + desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId }); + let end = yield* Queue.take(viewer.output); + while (end._tag !== "reconnect" && end._tag !== "gone") + end = yield* Queue.take(viewer.output); + expect(end._tag).toBe("reconnect"); + while (desktopConnections.length === 0) yield* Effect.yieldNow; + expect(desktopConnections.map((connection) => connection.endpoint)).toEqual([ + `ws://desktop/${tabId}`, + ]); + expect(headless.close).toHaveBeenCalled(); + // The agent keeps the same tab and now drives the page the person sees. + const status = yield* broker.invoke({ + scope, + operation: "status", + input: {}, + tabId, + }); + expect(status).toMatchObject({ tabId }); + expect(contexts).toHaveLength(1); + }), + ).pipe(Effect.provide(layer)), +); + it.live("a desktop page the desktop takes back reconnects instead of closing", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 42dc916abb61..f9bfcbba4f10 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -44,7 +44,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import { constVoid } from "effect/Function"; +import { constUndefined, constVoid } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -641,13 +641,15 @@ const make = Effect.gen(function* () { /** Whether the preview session for a tab still exists. */ const sessionOpen = (tab: ServerTab) => !closedSessions.has(tabKey(tab.threadId, tab.tabId)); - const dropTab = (tab: ServerTab, closeSession: boolean) => { + /** `handOff` keeps viewers for a session that moves to another page, such as the desktop's. */ + const dropTab = (tab: ServerTab, closeSession: boolean, handOff = false) => { const key = tabKey(tab.threadId, tab.tabId); if (tabs.get(key) !== tab) return; tabs.delete(key); tab.closing = true; // A desktop page outlives the connection unless its session closed with it. - const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; + const end = + (tab.desktop || handOff) && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; for (const viewer of tab.viewers) viewer.push({ _tag: end }); void tab.control.close().catch(constVoid); // The desktop owns its page; letting go only ends this connection. @@ -2142,6 +2144,22 @@ const make = Effect.gen(function* () { ), Effect.forkScoped, ); + // A tab opened while no desktop panel showed it in time started headless, with + // its own storage. Once the desktop mounts it, move the session onto the + // desktop's page so agents drive what the person sees, with their sign-ins. + yield* desktopChannel.attached.pipe( + Stream.mapEffect( + (key) => + Effect.promise(async () => { + const id = tabKey(key.threadId, key.tabId); + const tab = tabs.get(id) ?? (await pendingTabs.get(id)?.catch(constUndefined)); + if (tab && !tab.desktop) dropTab(tab, false, true); + }).pipe(Effect.andThen(findTab(key.threadId, key.tabId)), Effect.ignore), + { concurrency: "unbounded" }, + ), + Stream.runDrain, + Effect.forkScoped, + ); yield* Effect.sync(closeIdleAgentTabs).pipe( Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)), Effect.forkScoped,