From 608da77485da9548af056c90ccee7f4efaca89da Mon Sep 17 00:00:00 2001 From: James Villarrubia Date: Sun, 27 Sep 2026 10:00:07 -0400 Subject: [PATCH 1/2] fix(desktop): target=_blank links in a preview tab open a new preview tab A page that sets target="_blank" is asking to keep the reader's place. previewWindowOpenAction now returns "new-tab" for a foreground/background tab disposition with an http(s) URL: it denies the window and sends the URL to the web app over a new onOpenLink bridge event instead of calling loadURL on the originating webContents. ElectronBrowserHost opens the link as a new preview tab in the owning thread. OAuth popups (new-window + http(s)) still get a real window, and non-http(s) targets (about:blank, etc.) still load in place. Co-Authored-By: Claude Sonnet 5 --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/preview.ts | 3 + apps/desktop/src/preload.ts | 10 ++++ apps/desktop/src/preview/Manager.test.ts | 60 +++++++++++++++++++- apps/desktop/src/preview/Manager.ts | 49 ++++++++++++++-- apps/web/src/browser/ElectronBrowserHost.tsx | 21 ++++++- packages/contracts/src/ipc.ts | 7 +++ 7 files changed, 142 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index e8a688c189a33..cbb043e77f115 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -110,6 +110,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; +export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 36cf7c9abe153..ed41b6330c143 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -58,6 +58,9 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribePointerEvents((event) => electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event), ); + yield* manager.subscribeOpenLinks((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event), + ); }); export const createTab = DesktopIpc.makeIpcMethod({ diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 53d8cca7b0880..21798bedd2569 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,6 @@ import type { DesktopBridge, + DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, DesktopPreviewRecordingFrame, @@ -402,5 +403,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); }, + onOpenLink: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, linkEvent: unknown) => { + if (typeof linkEvent !== "object" || linkEvent === null) return; + listener(linkEvent as DesktopPreviewOpenLinkEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener); + }, }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 304efeafefe92..47987ffe532c3 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -154,11 +154,17 @@ describe("previewWindowOpenAction", () => { ).toBe("popup"); }); - it("keeps target=_blank links in the preview tab", () => { + it("opens target=_blank links as a new tab", () => { expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe( - "navigate", + "new-tab", ); expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe( + "new-tab", + ); + }); + + it("keeps other dispositions loading in the preview tab", () => { + expect(PreviewManager.previewWindowOpenAction(details({ disposition: "default" }))).toBe( "navigate", ); }); @@ -4119,6 +4125,56 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("opens a target=_blank link as a new tab without navigating the opener", () => + withManager((manager) => + Effect.gen(function* () { + let openHandler: + | ((details: { url: string; disposition: string }) => { action: string }) + | undefined; + const loadURL = vi.fn(async () => undefined); + fromId.mockReturnValue({ + id: 42, + hostWebContents: makeTestHostWebContents(), + setBackgroundThrottling: vi.fn(), + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "http://localhost:8976/", + getTitle: () => "Picker", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn((handler: typeof openHandler) => { + openHandler = handler; + }), + send: vi.fn(), + loadURL, + } as never); + const opened: Array<{ tabId: string; url: string }> = []; + yield* manager.subscribeOpenLinks((event) => Effect.sync(() => void opened.push(event))); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + const result = openHandler?.({ + url: "https://github.com/issues/1", + disposition: "foreground-tab", + }); + yield* Effect.yieldNow; + + expect(result).toEqual({ action: "deny" }); + expect(loadURL).not.toHaveBeenCalled(); + expect(opened).toEqual([{ tabId: "tab_1", url: "https://github.com/issues/1" }]); + }), + ), + ); + effectIt.effect("types in background webviews and enables native key input", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index b97af6f052046..5a3e042f19703 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -15,6 +15,7 @@ import type { DesktopPreviewAutomationStatus, DesktopPreviewColorScheme, DesktopPreviewFavicon, + DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, @@ -502,6 +503,8 @@ type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effe type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect; +type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; + interface ExpectedAgentInput { readonly signal: PreviewInputSignal; readonly expiresAt: number; @@ -553,14 +556,20 @@ const POPUP_WINDOW_OPTIONS = { * navigating the preview tab instead destroys the opener the popup has to * `postMessage` its result back to. * - * `target="_blank"` links arrive as a tab disposition and keep loading in the - * preview tab, which is what people expect from a link inside a preview. + * `"new-tab"` hands a `target="_blank"` link (a tab disposition) to the web + * app, which opens it as another preview tab so the page that held the link + * stays put. Schemes a popup cannot be hardened for keep loading in place. */ export const previewWindowOpenAction = (details: { readonly url: string; readonly disposition: Electron.HandlerDetails["disposition"]; -}): "popup" | "navigate" => - details.disposition === "new-window" && isPopupUrl(details.url) ? "popup" : "navigate"; +}): "popup" | "new-tab" | "navigate" => { + if (!isPopupUrl(details.url)) return "navigate"; + if (details.disposition === "new-window") return "popup"; + return details.disposition === "foreground-tab" || details.disposition === "background-tab" + ? "new-tab" + : "navigate"; +}; export const isPreviewRefreshShortcut = (input: Electron.Input): boolean => input.type === "keyDown" && @@ -651,6 +660,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const attachedRef = yield* Ref.make>(new Map()); const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); + const openLinkListenersRef = yield* Ref.make>(new Set()); const recordingInputListenersRef = yield* Ref.make>( new Set(), ); @@ -937,7 +947,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: "state-change" | "recording-frame" | "recording-input" | "pointer-event", + eventKind: + | "state-change" + | "recording-frame" + | "recording-input" + | "pointer-event" + | "open-link", tabId: string, delivery: () => Effect.Effect, ) => @@ -2048,9 +2063,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.setWindowOpenHandler((details) => { - if (previewWindowOpenAction(details) === "popup") { + const action = previewWindowOpenAction(details); + if (action === "popup") { return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS }; } + if (action === "new-tab") { + runFork(emitOpenLink({ tabId, url: details.url })); + return { action: "deny" }; + } runFork( attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => wc.loadURL(details.url), @@ -3825,6 +3845,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return point; }); + const emitOpenLink = Effect.fn("PreviewManager.emitOpenLink")(function* ( + event: DesktopPreviewOpenLinkEvent, + ) { + const listeners = yield* Ref.get(openLinkListenersRef); + yield* Effect.forEach( + listeners, + (listener) => deliverEvent("open-link", event.tabId, () => listener(event)), + { discard: true }, + ); + }); + const emitPointerEvent = Effect.fn("PreviewManager.emitPointerEvent")(function* ( event: DesktopPreviewPointerEvent, ) { @@ -4598,6 +4629,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Ref.set(listenersRef, new Set()), Ref.set(expectedAgentInputsRef, new Map()), Ref.set(pointerEventListenersRef, new Set()), + Ref.set(openLinkListenersRef, new Set()), Ref.set(recordingFrameListenersRef, new Set()), Ref.set(recordingInputListenersRef, new Set()), ], @@ -4643,6 +4675,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function stopRecording, subscribePointerEvents: (listener: PointerEventListener) => subscribe(pointerEventListenersRef, listener), + subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener), subscribeRecordingInputs: (listener: RecordingInputListener) => subscribe(recordingInputListenersRef, listener), subscribeRecordingFrames: (listener: RecordingFrameListener) => @@ -5053,6 +5086,9 @@ export class PreviewManager extends Context.Service< readonly subscribePointerEvents: ( listener: PointerEventListener, ) => Effect.Effect; + readonly subscribeOpenLinks: ( + listener: OpenLinkListener, + ) => Effect.Effect; readonly subscribeRecordingInputs: ( listener: RecordingInputListener, ) => Effect.Effect; @@ -5148,6 +5184,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { automationWaitFor: operations.automationWaitFor, subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, + subscribeOpenLinks: operations.subscribeOpenLinks, subscribeRecordingFrames: operations.subscribeRecordingFrames, subscribeRecordingInputs: operations.subscribeRecordingInputs, }); diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index de7e236032989..b403644676ca6 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -2,15 +2,18 @@ import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { isElectron } from "~/env"; import { useTheme } from "~/hooks/useTheme"; import { useActivePreviewSessions } from "~/previewStateStore"; +import { previewEnvironment } from "~/state/preview"; +import { useAtomCommand } from "~/state/use-atom-command"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; +import { openUrlInPreview } from "./openFileInPreview"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; export function ElectronBrowserHost() { @@ -79,6 +82,22 @@ export function ElectronBrowserHost() { }); }, []); + // A `target="_blank"` link inside a hosted page opens as another tab of the + // same thread, so the page that held the link stays where it is. + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: true }); + const threadRefByRuntimeTabId = useRef(new Map()); + threadRefByRuntimeTabId.current = new Map( + sessions.map(({ runtimeTabId, threadRef }) => [runtimeTabId, threadRef]), + ); + useEffect(() => { + const preview = window.desktopBridge?.preview; + if (!preview) return; + return preview.onOpenLink(({ tabId, url }) => { + const threadRef = threadRefByRuntimeTabId.current.get(tabId); + if (threadRef) void openUrlInPreview({ threadRef, url, openPreview }); + }); + }, [openPreview]); + if (!isElectron) return null; return (
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 7caf6d1cac75f..234c3b9842d93 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -662,6 +662,12 @@ export interface DesktopPreviewPointerEvent { createdAt: string; } +/** A `target="_blank"` link the previewed page asked to open beside itself. */ +export interface DesktopPreviewOpenLinkEvent { + tabId: string; + url: string; +} + /** Recording decorations are forwarded separately from the captured page pixels. */ export const DesktopPreviewRecordingInputSchema = Schema.Union([ Schema.Struct({ @@ -1346,6 +1352,7 @@ export interface DesktopPreviewBridge { }; onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void; + onOpenLink: (listener: (event: DesktopPreviewOpenLinkEvent) => void) => () => void; } export type ConfirmDialogVariant = "default" | "destructive"; From 2a0f19663a8c04c7685b82412e55f266747f9d22 Mon Sep 17 00:00:00 2001 From: James Villarrubia Date: Tue, 29 Sep 2026 13:39:59 -0400 Subject: [PATCH 2/2] fix(desktop): new preview tabs keep the source profile and honor background clicks A target=_blank link now opens under the source tab's browser profile. Middle-click and Cmd-click open the tab without switching to it. A form POST with a body keeps loading in place instead of reopening as a GET. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/src/preview/Manager.test.ts | 23 +++++++++- apps/desktop/src/preview/Manager.ts | 15 ++++-- apps/web/src/browser/ElectronBrowserHost.tsx | 21 ++++++--- apps/web/src/browser/openFileInPreview.ts | 18 +++++++- .../preview/openPreviewSession.test.ts | 46 ++++++++++++++++++- packages/contracts/src/ipc.ts | 2 + 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 47987ffe532c3..9422be3d0a86f 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -139,6 +139,7 @@ describe("previewWindowOpenAction", () => { const details = (overrides: { readonly url?: string; readonly disposition?: Electron.HandlerDetails["disposition"]; + readonly postBody?: Electron.PostBody; }) => ({ url: "https://accounts.google.com/o/oauth2/auth", disposition: "new-window" as Electron.HandlerDetails["disposition"], @@ -163,6 +164,14 @@ describe("previewWindowOpenAction", () => { ); }); + it("keeps a form POST with a body on the in-place path", () => { + // A new tab can only reopen the URL as a GET, which would drop the body. + const postBody = { data: [], contentType: "application/x-www-form-urlencoded" }; + expect( + PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab", postBody })), + ).toBe("navigate"); + }); + it("keeps other dispositions loading in the preview tab", () => { expect(PreviewManager.previewWindowOpenAction(details({ disposition: "default" }))).toBe( "navigate", @@ -4157,7 +4166,7 @@ describe("PreviewManager", () => { send: vi.fn(), loadURL, } as never); - const opened: Array<{ tabId: string; url: string }> = []; + const opened: Array<{ tabId: string; url: string; background: boolean }> = []; yield* manager.subscribeOpenLinks((event) => Effect.sync(() => void opened.push(event))); yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); @@ -4170,7 +4179,17 @@ describe("PreviewManager", () => { expect(result).toEqual({ action: "deny" }); expect(loadURL).not.toHaveBeenCalled(); - expect(opened).toEqual([{ tabId: "tab_1", url: "https://github.com/issues/1" }]); + expect(opened).toEqual([ + { tabId: "tab_1", url: "https://github.com/issues/1", background: false }, + ]); + + openHandler?.({ url: "https://github.com/issues/2", disposition: "background-tab" }); + yield* Effect.yieldNow; + expect(opened[1]).toEqual({ + tabId: "tab_1", + url: "https://github.com/issues/2", + background: true, + }); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 5a3e042f19703..a9ac4be018e0f 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -558,15 +558,18 @@ const POPUP_WINDOW_OPTIONS = { * * `"new-tab"` hands a `target="_blank"` link (a tab disposition) to the web * app, which opens it as another preview tab so the page that held the link - * stays put. Schemes a popup cannot be hardened for keep loading in place. + * stays put. Schemes a popup cannot be hardened for keep loading in place, and + * so does a form POST with a body, which a new tab could only reopen as a GET. */ export const previewWindowOpenAction = (details: { readonly url: string; readonly disposition: Electron.HandlerDetails["disposition"]; + readonly postBody?: Electron.PostBody; }): "popup" | "new-tab" | "navigate" => { if (!isPopupUrl(details.url)) return "navigate"; if (details.disposition === "new-window") return "popup"; - return details.disposition === "foreground-tab" || details.disposition === "background-tab" + return !details.postBody && + (details.disposition === "foreground-tab" || details.disposition === "background-tab") ? "new-tab" : "navigate"; }; @@ -2068,7 +2071,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS }; } if (action === "new-tab") { - runFork(emitOpenLink({ tabId, url: details.url })); + runFork( + emitOpenLink({ + tabId, + url: details.url, + background: details.disposition === "background-tab", + }), + ); return { action: "deny" }; } runFork( diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index b403644676ca6..cc389e7afe087 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -85,16 +85,25 @@ export function ElectronBrowserHost() { // A `target="_blank"` link inside a hosted page opens as another tab of the // same thread, so the page that held the link stays where it is. const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: true }); - const threadRefByRuntimeTabId = useRef(new Map()); - threadRefByRuntimeTabId.current = new Map( - sessions.map(({ runtimeTabId, threadRef }) => [runtimeTabId, threadRef]), + const sessionByRuntimeTabId = useRef(new Map()); + sessionByRuntimeTabId.current = new Map( + sessions.map((session) => [session.runtimeTabId, session]), ); useEffect(() => { const preview = window.desktopBridge?.preview; if (!preview) return; - return preview.onOpenLink(({ tabId, url }) => { - const threadRef = threadRefByRuntimeTabId.current.get(tabId); - if (threadRef) void openUrlInPreview({ threadRef, url, openPreview }); + return preview.onOpenLink(({ tabId, url, background }) => { + const source = sessionByRuntimeTabId.current.get(tabId); + // The new tab keeps the source tab's profile so its cookies carry over. + if (source) { + void openUrlInPreview({ + threadRef: source.threadRef, + url, + openPreview, + profileId: source.snapshot.profileId, + background, + }); + } }); }, [openPreview]); diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index a320e3ba34dae..ddb8486ee1f34 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -19,7 +19,10 @@ import { resolveAssetUrl } from "~/assets/assetUrls"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime, + readThreadPreviewState, rememberPreviewUrl, + setActivePreviewTab, + updatePreviewServerSnapshot, } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; @@ -55,6 +58,10 @@ export async function openUrlInPreview(input: { readonly threadRef: ScopedThreadRef; readonly url: string; readonly openPreview: OpenPreviewMutation; + /** Profile to open under; omit for the configured default. */ + readonly profileId?: PreviewOpenInput["profileId"]; + /** Open the tab without switching the thread to it. */ + readonly background?: boolean; }): Promise> { const defaults = await resolveBrowserDefaults().catch( (cause: unknown) => new BrowserSettingsReadError({ cause }), @@ -62,6 +69,7 @@ export async function openUrlInPreview(input: { if (defaults instanceof BrowserSettingsReadError) { return AsyncResult.failure(Cause.fail(defaults)); } + const previousActiveTabId = readThreadPreviewState(input.threadRef).activeTabId; const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { @@ -71,12 +79,18 @@ export async function openUrlInPreview(input: { // maps the result differently, so the configured defaults have to be // applied explicitly or file/link opens would ignore them. viewport: browserDefaultOpenViewport(defaults), - profileId: browserDefaultOpenProfileId(defaults), + profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), }, }); return mapAtomCommandResult(result, (snapshot) => { - applyPreviewServerSnapshot(input.threadRef, snapshot); rememberPreviewUrl(input.threadRef, input.url); + if (input.background) { + updatePreviewServerSnapshot(input.threadRef, snapshot); + // The server's "opened" event activates the new tab; hand focus back. + if (previousActiveTabId) setActivePreviewTab(input.threadRef, previousActiveTabId); + return; + } + applyPreviewServerSnapshot(input.threadRef, snapshot); useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); }); } diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index fe14211280c2a..e601ef8408b7e 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -13,7 +13,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import * as browserDefaults from "~/browser/browserDefaults"; import { BrowserSettingsReadError, openUrlInPreview } from "~/browser/openFileInPreview"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; -import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; +import { + applyPreviewServerSnapshot, + readThreadPreviewState, + resetPreviewStateForTests, +} from "~/previewStateStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -140,3 +144,43 @@ describe("openPreviewSession", () => { }, ); }); + +describe("openUrlInPreview from a link", () => { + it("opens under the source tab's profile instead of the default", async () => { + const openPreview = vi.fn(async (_arg: { input: PreviewOpenInput }) => + AsyncResult.success(snapshot), + ); + + await openUrlInPreview({ openPreview, threadRef, url: "https://t3.chat/", profileId: "work" }); + + expect(openPreview.mock.calls[0]?.[0].input.profileId).toBe("work"); + }); + + it("keeps the current tab active for a background open", async () => { + const current: PreviewSessionSnapshot = { ...snapshot, tabId: "tab-current" }; + applyPreviewServerSnapshot(threadRef, current); + + await openUrlInPreview({ + openPreview: async () => AsyncResult.success(snapshot), + threadRef, + url: "https://t3.chat/", + background: true, + }); + + const state = readThreadPreviewState(threadRef); + expect(state.activeTabId).toBe("tab-current"); + expect(Object.keys(state.sessions).toSorted()).toEqual(["tab-1", "tab-current"]); + }); + + it("activates the new tab for a foreground open", async () => { + applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-current" }); + + await openUrlInPreview({ + openPreview: async () => AsyncResult.success(snapshot), + threadRef, + url: "https://t3.chat/", + }); + + expect(readThreadPreviewState(threadRef).activeTabId).toBe("tab-1"); + }); +}); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 234c3b9842d93..ca7c35172249b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -666,6 +666,8 @@ export interface DesktopPreviewPointerEvent { export interface DesktopPreviewOpenLinkEvent { tabId: string; url: string; + /** True for middle-click / Cmd-click, which should not take focus. */ + background: boolean; } /** Recording decorations are forwarded separately from the captured page pixels. */