diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 1c16992123206..6d6cdc911a379 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -229,6 +229,49 @@ 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("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 55156d605a152..46a56531ee4c3 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -178,25 +178,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), { @@ -217,7 +223,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 109776a091e94..1c65d550f1a6a 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -55,6 +55,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( @@ -78,6 +79,7 @@ export async function openUrlInPreview(input: { viewport: defaults.viewport, profileId: input.profileId ?? defaults.profileId, ...(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 cbf8ac60b2690..fe567cc39ebc1 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;