From 6e01c3dc2f6ef67afdce00ca234f715b0cf2050f Mon Sep 17 00:00:00 2001 From: THEtheChad <880190+THEtheChad@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:54:11 -0600 Subject: [PATCH 1/5] fix(preview): typed URLs navigate browser tabs when WSL is the primary environment A server with no desktop browser channel (the WSL backend in WSL-only mode) never marked its tabs headless, so the desktop treated them as tabs it draws itself and sent address-bar navigation to an empty local page. Fixes #26147 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 19 +++++++++++++++++++ apps/server/src/preview/ServerBrowser.ts | 5 +++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index c558631fd2a51..91ed88e690e6e 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -623,6 +623,25 @@ it.effect("a tab the desktop does not draw is marked headless, so the desktop st ).pipe(Effect.provide(layer)), ); +it.effect("a server with no desktop app attached marks its tabs headless", () => + Effect.scoped( + Effect.gen(function* () { + const { tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + + expect(sessions.find((session) => session.tabId === tabId)?.headless).toBe(true); + }), + ).pipe( + Effect.provide( + ServerBrowser.layer.pipe( + Layer.provide(DesktopChannel.layer), + Layer.provideMerge(dependencies), + ), + ), + ), +); + it.effect("a desktop page that loaded before the server attached reports its loaded title", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index d811cff3014fe..4fa8b097643c2 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -806,8 +806,9 @@ const make = Effect.gen(function* () { ? await connectDesktop(snapshot) : null; if (desktop) opened.push(desktop.close); - // Otherwise a desktop that attaches late shows its own page, and the agent drives another. - if (!desktop && desktopChannel.available) { + // Otherwise a desktop that attaches late, or a desktop whose primary server has no + // desktop channel (WSL), shows its own page while the agent drives this one. + if (!desktop) { await runPromise( manager .markHeadless({ threadId: ThreadId.make(snapshot.threadId), tabId: snapshot.tabId }) From dd177a4076b372a2d1526cd0428c954c4ba320bc Mon Sep 17 00:00:00 2001 From: THEtheChad <880190+THEtheChad@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:02:57 -0600 Subject: [PATCH 2/5] test(server): no-desktop headless test sees only the real desktop channel Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 79 ++++++++++--------- 1 file changed, 40 insertions(+), 39 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 91ed88e690e6e..7e999801953bc 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -341,7 +341,44 @@ const asSession = (providerSessionId: string) => ({ ...scope, thread: { ...testThread, providerSessionId }, }); -const dependencies = Layer.mergeAll( +const mockDesktopChannel = Layer.succeed(DesktopChannel.DesktopBrowserChannel, { + available: true, + awaitAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), + detached: Stream.callback<{ threadId: string; tabId: string }>((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const onDetach = (key: { threadId: string; tabId: string }) => + Queue.offerUnsafe(queue, key); + desktopDetaches.on("detach", onDetach); + desktopDetachReady.resolve(); + return onDetach; + }), + (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)), + ), + ), + 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)), + ), + ), + isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), + endpoint: (key) => + Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => + Effect.sync(() => { + releasedDesktopTabs.push(key.tabId); + desktopReleased.resolve(); + }), + ), + pointer: () => Effect.void, + turn: (threadId, active) => Effect.sync(() => desktopTurns.emit("turn", threadId, active)), +}); +const sharedDependencies = Layer.mergeAll( Broker.layer, Manager.layer, Layer.succeed(ServerEnvironment.ServerEnvironment, { @@ -352,48 +389,12 @@ const dependencies = Layer.mergeAll( executable: Effect.die("mock Chromium does not need an executable"), installed: Effect.die("mock Chromium does not need an executable"), }), - Layer.succeed(DesktopChannel.DesktopBrowserChannel, { - available: true, - awaitAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), - detached: Stream.callback<{ threadId: string; tabId: string }>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const onDetach = (key: { threadId: string; tabId: string }) => - Queue.offerUnsafe(queue, key); - desktopDetaches.on("detach", onDetach); - desktopDetachReady.resolve(); - return onDetach; - }), - (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)), - ), - ), - 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)), - ), - ), - isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), - endpoint: (key) => - Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => - Effect.sync(() => { - releasedDesktopTabs.push(key.tabId); - desktopReleased.resolve(); - }), - ), - pointer: () => Effect.void, - turn: (threadId, active) => Effect.sync(() => desktopTurns.emit("turn", threadId, active)), - }), PreviewAgentActivity.layer, ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-server-browser-" })), Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); +const dependencies = Layer.merge(sharedDependencies, mockDesktopChannel); const layer = ServerBrowser.layer.pipe(Layer.provideMerge(dependencies)); const ready = Effect.gen(function* () { const browser = yield* ServerBrowser.ServerBrowser; @@ -636,7 +637,7 @@ it.effect("a server with no desktop app attached marks its tabs headless", () => Effect.provide( ServerBrowser.layer.pipe( Layer.provide(DesktopChannel.layer), - Layer.provideMerge(dependencies), + Layer.provideMerge(sharedDependencies), ), ), ), From 5a3af6b40a836f20726cdb3347abfc101c4ff424 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 04:52:47 +0000 Subject: [PATCH 3/5] fix(preview): server tabs with no desktop channel publish as headless from the first event --- apps/server/src/preview/Manager.ts | 2 ++ apps/server/src/preview/ServerBrowser.test.ts | 6 +++++- apps/server/src/preview/ServerBrowser.ts | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index f5db1d2fefc1e..e44b9e53c0b2d 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -35,6 +35,7 @@ export class PreviewManager extends Context.Service< readonly open: ( input: PreviewOpenInput & { readonly automationOwner?: string; + readonly headless?: boolean; /** Runs before the `opened` event publishes, so subscribers find state keyed by the tab. */ readonly beforePublish?: (snapshot: PreviewSessionSnapshot) => void; }, @@ -193,6 +194,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { ? { automationOwner: input.automationOwner } : {}), ...(input.reveal === undefined ? {} : { reveal: input.reveal }), + ...(runtime === "server" && input.headless ? { headless: true } : {}), updatedAt, }; yield* SynchronizedRef.modifyEffect(stateRef, (state) => diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 8343a62b319b8..cbbb8ab40d2cb 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -631,10 +631,14 @@ it.effect("a tab the desktop does not draw is marked headless, so the desktop st it.effect("a server with no desktop app attached marks its tabs headless", () => Effect.scoped( Effect.gen(function* () { - const { tabId } = yield* ready; const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; + const { tabId } = yield* ready; + let event = yield* PubSub.take(events); + while (event.type !== "opened") event = yield* PubSub.take(events); const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + expect(event.snapshot.headless).toBe(true); expect(sessions.find((session) => session.tabId === tabId)?.headless).toBe(true); }), ).pipe( diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 15b6bc0f966bb..b83b421f738ac 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1468,6 +1468,8 @@ const make = Effect.gen(function* () { manager .open({ ...input, + // No desktop can draw this tab, so it must not publish as natively renderable. + ...(desktopChannel.available ? {} : { headless: true }), beforePublish: (snapshot) => { pendingTabs.delete(key); key = tabKey(snapshot.threadId, snapshot.tabId); From ef9e23bfccf050d56807a7f44066dec31019ee84 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 05:07:36 +0000 Subject: [PATCH 4/5] fix(preview): client-opened server tabs also publish as headless without a desktop channel --- apps/server/src/preview/Manager.test.ts | 2 + apps/server/src/preview/Manager.ts | 9 +++- apps/server/src/preview/ServerBrowser.test.ts | 47 ++++++++++++------- apps/server/src/preview/ServerBrowser.ts | 2 - 4 files changed, 38 insertions(+), 22 deletions(-) diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 1c16992123206..82f62ff9061e6 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -85,6 +85,8 @@ it.layer(layer)("PreviewManager", (it) => { const manager = yield* PreviewManager.PreviewManager; const collector = yield* collectEvents; const opened = yield* manager.open({ threadId, runtime: "server", reveal: false }); + // The test server has no desktop browser channel. + expect(opened.headless).toBe(true); yield* manager.requestReveal({ threadId, tabId: opened.tabId, force: true }); const first = (yield* manager.list({ threadId })).sessions[0]; expect(first?.reveal).toBe(true); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index e44b9e53c0b2d..3c77dcbef7a23 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -27,6 +27,8 @@ import * as PubSub from "effect/PubSub"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; + +import * as ServerConfig from "../config.ts"; import { PreviewControlRequiredError } from "@t3tools/contracts"; export class PreviewManager extends Context.Service< @@ -35,7 +37,6 @@ export class PreviewManager extends Context.Service< readonly open: ( input: PreviewOpenInput & { readonly automationOwner?: string; - readonly headless?: boolean; /** Runs before the `opened` event publishes, so subscribers find state keyed by the tab. */ readonly beforePublish?: (snapshot: PreviewSessionSnapshot) => void; }, @@ -121,6 +122,10 @@ const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); /** @public */ export const make = Effect.gen(function* PreviewManagerMake() { const random = yield* SecureRandom.SecureRandom; + const config = yield* ServerConfig.ServerConfig; + // Without a desktop browser channel no desktop can draw server tabs, so publish them headless. + const serverTabsHeadless = + config.desktopBrowserFd === undefined || config.desktopBrowserControlFd === undefined; const serverEpoch = yield* random.uuidV4(); const stateRef = yield* SynchronizedRef.make(initialState); const eventsPubSub = yield* PubSub.unbounded(); @@ -194,7 +199,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { ? { automationOwner: input.automationOwner } : {}), ...(input.reveal === undefined ? {} : { reveal: input.reveal }), - ...(runtime === "server" && input.headless ? { headless: true } : {}), + ...(runtime === "server" && serverTabsHeadless ? { headless: true } : {}), updatedAt, }; yield* SynchronizedRef.modifyEffect(stateRef, (state) => diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index cbbb8ab40d2cb..03826718bdba1 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -381,23 +381,34 @@ const mockDesktopChannel = Layer.succeed(DesktopChannel.DesktopBrowserChannel, { pointer: () => Effect.void, turn: (threadId, active) => Effect.sync(() => desktopTurns.emit("turn", threadId, active)), }); -const sharedDependencies = Layer.mergeAll( - Broker.layer, - Manager.layer, - Layer.succeed(ServerEnvironment.ServerEnvironment, { - getEnvironmentId: Effect.succeed(scope.environmentId), - getDescriptor: Effect.die("unused descriptor"), - }), - Layer.succeed(PreviewBrowser.PreviewBrowser, { - executable: Effect.die("mock Chromium does not need an executable"), - installed: Effect.die("mock Chromium does not need an executable"), - }), - PreviewAgentActivity.layer, -).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-server-browser-" })), - Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), -); -const dependencies = Layer.merge(sharedDependencies, mockDesktopChannel); +const testConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-server-browser-" }); +const sharedDependencies = (config: typeof testConfig) => + Layer.mergeAll( + Broker.layer, + Manager.layer, + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(scope.environmentId), + getDescriptor: Effect.die("unused descriptor"), + }), + Layer.succeed(PreviewBrowser.PreviewBrowser, { + executable: Effect.die("mock Chromium does not need an executable"), + installed: Effect.die("mock Chromium does not need an executable"), + }), + PreviewAgentActivity.layer, + ).pipe( + Layer.provideMerge(config), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ); +// The mock channel stands in for a desktop that passed its browser fds. +const desktopConfig = Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ + ...config, + desktopBrowserFd: 3, + desktopBrowserControlFd: 4, + })), +).pipe(Layer.provide(testConfig)); +const dependencies = Layer.merge(sharedDependencies(desktopConfig), mockDesktopChannel); const layer = ServerBrowser.layer.pipe(Layer.provideMerge(dependencies)); const ready = Effect.gen(function* () { const browser = yield* ServerBrowser.ServerBrowser; @@ -645,7 +656,7 @@ it.effect("a server with no desktop app attached marks its tabs headless", () => Effect.provide( ServerBrowser.layer.pipe( Layer.provide(DesktopChannel.layer), - Layer.provideMerge(sharedDependencies), + Layer.provideMerge(sharedDependencies(testConfig)), ), ), ), diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index b83b421f738ac..15b6bc0f966bb 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1468,8 +1468,6 @@ const make = Effect.gen(function* () { manager .open({ ...input, - // No desktop can draw this tab, so it must not publish as natively renderable. - ...(desktopChannel.available ? {} : { headless: true }), beforePublish: (snapshot) => { pendingTabs.delete(key); key = tabKey(snapshot.threadId, snapshot.tabId); From 385f95792766edbd16b804747636e0beb3bbf185 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:43:38 +0000 Subject: [PATCH 5/5] refactor(preview): headless-at-open in the manager is the whole fix PreviewManager already publishes server tabs headless when the server has no desktop browser fds, so ServerBrowser's extra markHeadless never changed anything. Drop it and its duplicate test, and give the mock desktop channel's config the fds it stands in for instead of restructuring the test layers. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/preview/Manager.ts | 2 +- apps/server/src/preview/ServerBrowser.test.ts | 191 ++++++++---------- apps/server/src/preview/ServerBrowser.ts | 5 +- 3 files changed, 86 insertions(+), 112 deletions(-) diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 298c598f13e1c..f38d8d01538e8 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -27,9 +27,9 @@ import * as PubSub from "effect/PubSub"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; +import { PreviewControlRequiredError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; -import { PreviewControlRequiredError } from "@t3tools/contracts"; export class PreviewManager extends Context.Service< PreviewManager, diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 2c0001f87f5f3..a97f3583705f0 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -345,96 +345,94 @@ const asSession = (providerSessionId: string) => ({ ...scope, thread: { ...testThread, providerSessionId }, }); -const mockDesktopChannel = Layer.succeed(DesktopChannel.DesktopBrowserChannel, { - available: true, - awaitAttached: (key, timeout) => - !desktopAttachesLate || desktopRenders(key.tabId) - ? Effect.sync(() => desktopRenders(key.tabId)) - : Effect.raceFirst( - Stream.callback((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const onAttach = (attached: { tabId: string }) => - Queue.offerUnsafe(queue, attached.tabId); - desktopDetaches.on("attach", onAttach); - return onAttach; - }), - (onAttach) => Effect.sync(() => desktopDetaches.off("attach", onAttach)), +const dependencies = Layer.mergeAll( + Broker.layer, + Manager.layer, + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(scope.environmentId), + getDescriptor: Effect.die("unused descriptor"), + }), + Layer.succeed(PreviewBrowser.PreviewBrowser, { + executable: Effect.die("mock Chromium does not need an executable"), + installed: Effect.die("mock Chromium does not need an executable"), + }), + Layer.succeed(DesktopChannel.DesktopBrowserChannel, { + available: true, + awaitAttached: (key, timeout) => + !desktopAttachesLate || desktopRenders(key.tabId) + ? Effect.sync(() => desktopRenders(key.tabId)) + : Effect.raceFirst( + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const onAttach = (attached: { tabId: string }) => + Queue.offerUnsafe(queue, attached.tabId); + desktopDetaches.on("attach", onAttach); + return onAttach; + }), + (onAttach) => Effect.sync(() => desktopDetaches.off("attach", onAttach)), + ), + ).pipe( + Stream.filter((tabId) => tabId === key.tabId), + Stream.runHead, + Effect.as(true), + ), + Effect.sync(() => desktopAttachWaiting.resolve()).pipe( + Effect.andThen(Effect.sleep(timeout)), + Effect.as(false), ), - ).pipe( - Stream.filter((tabId) => tabId === key.tabId), - Stream.runHead, - Effect.as(true), - ), - Effect.sync(() => desktopAttachWaiting.resolve()).pipe( - Effect.andThen(Effect.sleep(timeout)), - Effect.as(false), ), - ), - detached: Stream.callback<{ threadId: string; tabId: string }>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const onDetach = (key: { threadId: string; tabId: string }) => - Queue.offerUnsafe(queue, key); - desktopDetaches.on("detach", onDetach); - desktopDetachReady.resolve(); - return onDetach; - }), - (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)), + detached: Stream.callback<{ threadId: string; tabId: string }>((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const onDetach = (key: { threadId: string; tabId: string }) => + Queue.offerUnsafe(queue, key); + desktopDetaches.on("detach", onDetach); + desktopDetachReady.resolve(); + return onDetach; + }), + (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)), + ), ), - ), - 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)), + 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)), + ), ), + isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), + isShown: (key) => shownDesktopTabs.has(key.tabId), + endpoint: (key) => + Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => + Effect.sync(() => { + releasedDesktopTabs.push(key.tabId); + desktopDetaches.emit("release", key.tabId); + desktopReleased.resolve(); + }), + ), + pointer: () => Effect.void, + turn: (threadId, active) => Effect.sync(() => desktopTurns.emit("turn", threadId, active)), + }), + PreviewAgentActivity.layer, +).pipe( + Layer.provideMerge( + // The mock channel stands in for a desktop that passed its browser fds. + Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ + ...config, + desktopBrowserFd: 3, + desktopBrowserControlFd: 4, + })), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-server-browser-" }))), ), - isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), - isShown: (key) => shownDesktopTabs.has(key.tabId), - endpoint: (key) => - Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => - Effect.sync(() => { - releasedDesktopTabs.push(key.tabId); - desktopDetaches.emit("release", key.tabId); - desktopReleased.resolve(); - }), - ), - pointer: () => Effect.void, - turn: (threadId, active) => Effect.sync(() => desktopTurns.emit("turn", threadId, active)), -}); -const testConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-server-browser-" }); -const sharedDependencies = (config: typeof testConfig) => - Layer.mergeAll( - Broker.layer, - Manager.layer, - Layer.succeed(ServerEnvironment.ServerEnvironment, { - getEnvironmentId: Effect.succeed(scope.environmentId), - getDescriptor: Effect.die("unused descriptor"), - }), - Layer.succeed(PreviewBrowser.PreviewBrowser, { - executable: Effect.die("mock Chromium does not need an executable"), - installed: Effect.die("mock Chromium does not need an executable"), - }), - PreviewAgentActivity.layer, - ).pipe( - Layer.provideMerge(config), - Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), - ); -// The mock channel stands in for a desktop that passed its browser fds. -const desktopConfig = Layer.effect( - ServerConfig.ServerConfig, - Effect.map(ServerConfig.ServerConfig, (config) => ({ - ...config, - desktopBrowserFd: 3, - desktopBrowserControlFd: 4, - })), -).pipe(Layer.provide(testConfig)); -const dependencies = Layer.merge(sharedDependencies(desktopConfig), mockDesktopChannel); + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), +); const layer = ServerBrowser.layer.pipe(Layer.provideMerge(dependencies)); const ready = Effect.gen(function* () { const browser = yield* ServerBrowser.ServerBrowser; @@ -667,29 +665,6 @@ it.effect("a tab the desktop does not draw is marked headless, so the desktop st ).pipe(Effect.provide(layer)), ); -it.effect("a server with no desktop app attached marks its tabs headless", () => - Effect.scoped( - Effect.gen(function* () { - const manager = yield* Manager.PreviewManager; - const events = yield* manager.subscribeEvents; - const { tabId } = yield* ready; - let event = yield* PubSub.take(events); - while (event.type !== "opened") event = yield* PubSub.take(events); - const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); - - expect(event.snapshot.headless).toBe(true); - expect(sessions.find((session) => session.tabId === tabId)?.headless).toBe(true); - }), - ).pipe( - Effect.provide( - ServerBrowser.layer.pipe( - Layer.provide(DesktopChannel.layer), - Layer.provideMerge(sharedDependencies(testConfig)), - ), - ), - ), -); - it.effect("an agent's tab the reloaded desktop dropped goes back to the desktop", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 99a96e350df34..ee31b17cc5889 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -824,9 +824,8 @@ const make = Effect.gen(function* () { ? await connectDesktop(snapshot) : null; if (desktop) opened.push(desktop.close); - // Otherwise a desktop that attaches late, or a desktop whose primary server has no - // desktop channel (WSL), shows its own page while the agent drives this one. - if (!desktop) { + // Otherwise a desktop that attaches late shows its own page, and the agent drives another. + if (!desktop && desktopChannel.available) { await runPromise( manager .markHeadless({ threadId: ThreadId.make(snapshot.threadId), tabId: snapshot.tabId })