From df26b168d13dd8a3c6b71057e87996ef929a5438 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 02:19:02 +0000 Subject: [PATCH 1/2] test(server): reproduce #26250 popup from agent tab loses its owner Opening a tab with openerTabId naming an agent-owned server tab should give the new tab that agent's automationOwner. Fails on main: the popup comes back unowned. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/Manager.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 511970b9d7665..4a1619c2658bc 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -250,6 +250,28 @@ it.layer(layer)("PreviewManager", (it) => { }), ); + it.effect("gives a popup opened from an agent's tab to that agent", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const opener = yield* manager.open({ + threadId, + url: "http://localhost:8765/popup.html", + runtime: "server", + automationOwner: "agent-1", + }); + + const popup = yield* manager.open({ + threadId, + url: "https://example.org/", + runtime: "server", + openerTabId: opener.tabId, + }); + + expect(popup.automationOwner).toBe("agent-1"); + }), + ); + it.effect("close with explicit tabId removes only that tab", () => Effect.gen(function* () { const threadId = freshThreadId(); From 62018f87c01d730de5750b6292b6cab39ea95c16 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 02:23:43 +0000 Subject: [PATCH 2/2] fix(preview): popups from an agent's desktop tab belong to that agent A target=_blank link in a desktop-rendered server tab opened its new tab through previewOpen with no owner, so the agent that owned the opener could not close it. The desktop open-link path now sends the source tab as openerTabId, and PreviewManager.open gives the new tab the opener's automationOwner from its own same-thread record, never from the client. Fixes pingdotgg/t3code#26250 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/Manager.test.ts | 21 ++++++++++ apps/server/src/preview/Manager.ts | 43 +++++++++++--------- apps/web/src/browser/ElectronBrowserHost.tsx | 1 + apps/web/src/browser/openFileInPreview.ts | 2 + packages/contracts/src/preview.ts | 1 + 5 files changed, 49 insertions(+), 19 deletions(-) diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 4a1619c2658bc..69547ced73ff8 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -272,6 +272,27 @@ it.layer(layer)("PreviewManager", (it) => { }), ); + it.effect("does not take an owner from an opener in another thread", () => + Effect.gen(function* () { + const manager = yield* PreviewManager.PreviewManager; + const opener = yield* manager.open({ + threadId: freshThreadId(), + url: "http://localhost:8765/popup.html", + runtime: "server", + automationOwner: "agent-1", + }); + + const popup = yield* manager.open({ + threadId: freshThreadId(), + url: "https://example.org/", + runtime: "server", + openerTabId: opener.tabId, + }); + + expect(popup.automationOwner).toBeUndefined(); + }), + ); + it.effect("close with explicit tabId removes only that tab", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index bc9ee5e54a8c9..9f7fe7106ea4e 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -184,25 +184,31 @@ export const make = Effect.gen(function* PreviewManagerMake() { // Persisted client surfaces must not bind to a different tab after a server restart. const tabId = `${newPreviewTabId()}${runtime === "server" ? `_${serverEpoch}` : ""}`; const updatedAt = yield* currentIsoTimestamp; - const snapshot: PreviewSessionSnapshot = { - threadId: input.threadId, - tabId, - navStatus: input.url - ? { _tag: "Loading", url: yield* normalizeUrl(input.url), title: "" } - : { _tag: "Idle" }, - canGoBack: false, - canGoForward: false, - viewport: input.viewport ?? FILL_PREVIEW_VIEWPORT, - ...(input.profileId === undefined ? {} : { profileId: input.profileId }), - ...(runtime === undefined ? {} : { runtime }), - ...(runtime === "server" && input.automationOwner !== undefined - ? { automationOwner: input.automationOwner } - : {}), - ...(input.reveal === undefined ? {} : { reveal: input.reveal }), - updatedAt, - }; - yield* SynchronizedRef.modifyEffect(stateRef, (state) => + const navStatus: PreviewSessionSnapshot["navStatus"] = input.url + ? { _tag: "Loading", url: yield* normalizeUrl(input.url), title: "" } + : { _tag: "Idle" }; + return yield* SynchronizedRef.modifyEffect(stateRef, (state) => Effect.gen(function* () { + // A popup inherits its opener's owner from this record, never from the client. + const automationOwner = + input.automationOwner ?? + (input.openerTabId === undefined + ? undefined + : state.sessions.get(compositeKey(input.threadId, input.openerTabId))?.snapshot + .automationOwner); + const snapshot: PreviewSessionSnapshot = { + threadId: input.threadId, + tabId, + navStatus, + canGoBack: false, + canGoForward: false, + viewport: input.viewport ?? FILL_PREVIEW_VIEWPORT, + ...(input.profileId === undefined ? {} : { profileId: input.profileId }), + ...(runtime === undefined ? {} : { runtime }), + ...(runtime === "server" && automationOwner !== undefined ? { automationOwner } : {}), + ...(input.reveal === undefined ? {} : { reveal: input.reveal }), + updatedAt, + }; const revision = state.revision + 1; const sessions = new Map(state.sessions); sessions.set(compositeKey(input.threadId, tabId), { @@ -223,7 +229,6 @@ export const make = Effect.gen(function* PreviewManagerMake() { return [snapshot, { sessions, revision }] as const; }), ); - return snapshot; }, ); diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index d71b7a2b6b2ba..1cc9785edbd24 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -105,6 +105,7 @@ function openLinkInPreview({ tabId, url, background }: DesktopPreviewOpenLinkEve url, openPreview: (input) => runAtomCommand(appAtomRegistry, previewEnvironment.open, input), profileId: source.snapshot.profileId, + openerTabId: source.snapshot.tabId, background, }); } diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 6403c1f189b53..b303acbad86eb 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -59,6 +59,7 @@ export async function openUrlInPreview(input: { readonly url: string; readonly openPreview: OpenPreviewMutation; readonly profileId?: PreviewOpenInput["profileId"]; + readonly openerTabId?: PreviewOpenInput["openerTabId"]; readonly background?: boolean; }): Promise> { const defaults = await resolveBrowserDefaults().catch( @@ -82,6 +83,7 @@ export async function openUrlInPreview(input: { viewport: browserDefaultOpenViewport(defaults), profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), ...(runtime === undefined ? {} : { runtime }), + ...(input.openerTabId === undefined ? {} : { openerTabId: input.openerTabId }), }, }); return mapAtomCommandResult(result, (snapshot) => { diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 84ca2650b98a5..1fccb529410b0 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -176,6 +176,7 @@ export const PreviewOpenInput = Schema.Struct({ profileId: Schema.optional(BrowserProfileId), runtime: Schema.optional(PreviewRuntime), reveal: Schema.optional(Schema.Boolean), + openerTabId: Schema.optional(PreviewTabId), }); export type PreviewOpenInput = typeof PreviewOpenInput.Type;