From f3e3adb7ac72c862e9ed463f16d4ff4e9c314224 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 22:53:42 +0200 Subject: [PATCH] feat(web): open the preview page in the system browser from the menu The integrated browser's more menu offered a hard reload, DevTools, a separate preview window and the device toolbar, but no way to hand the current page to the OS default browser. The only entry point was the hover-reveal button inside the URL input, which is easy to miss. PreviewMoreMenu gains an onOpenInBrowser prop and a menu item "Open in system browser", placed after "Open separate preview window" and disabled under the same condition as its neighbours. PreviewView passes the existing handleOpenInBrowser handler through, the same path the chat's right-click "Open in system browser" already takes. --- .../components/preview/PreviewMoreMenu.tsx | 6 +++ .../components/preview/PreviewView.test.tsx | 40 ++++++++++++++++++- .../src/components/preview/PreviewView.tsx | 1 + 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 7080fb5238f4..3972895941ae 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -52,6 +52,8 @@ interface Props { nativePictureInPicture: boolean; /** Toggles the optional native always-on-top preview window. */ onNativePictureInPicture: () => void; + /** Opens the current page in the OS default browser. */ + onOpenInBrowser: () => void; /** Environment the tab belongs to; scopes storage clearing to its partitions. */ environmentId: EnvironmentId; /** Profile the tab was opened under, if the server recorded one. */ @@ -79,6 +81,7 @@ export function PreviewMoreMenu({ onToggleDeviceToolbar, nativePictureInPicture, onNativePictureInPicture, + onOpenInBrowser, environmentId, profileId, profileName, @@ -120,6 +123,9 @@ export function PreviewMoreMenu({ ? "Close separate preview window" : "Open separate preview window"} + + Open in system browser + {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 2a146834012e..053d9a95457f 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -11,6 +11,16 @@ import { act, Profiler } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +// PreviewView resolves the local API once at module scope behind a `typeof +// window` guard. The guard has to see a window before the import further down +// runs, or every shell call in this suite is a silent no-op. +vi.hoisted(() => { + globalThis.window ??= { + addEventListener() {}, + removeEventListener() {}, + } as unknown as Window & typeof globalThis; +}); + const mocks = vi.hoisted(() => ({ navigate: vi.fn(async (_tabId: string, _url: string): Promise => undefined), rememberPreviewUrl: vi.fn(), @@ -19,6 +29,8 @@ const mocks = vi.hoisted(() => ({ emptyStateUrl: null as ((url: string) => void) | null, togglePictureInPicture: null as (() => void) | null, toggleNativePictureInPicture: null as (() => void) | null, + openInBrowser: null as (() => void) | null, + openExternal: vi.fn(async (_url: string): Promise => undefined), pictureInPicturePressed: false, miniPlayerTabId: null as string | null, openMiniPlayer: vi.fn(), @@ -95,7 +107,7 @@ vi.mock("~/lib/previewAnnotation", () => ({ })); vi.mock("~/localApi", () => ({ - ensureLocalApi: vi.fn(), + ensureLocalApi: vi.fn(() => ({ shell: { openExternal: mocks.openExternal } })), })); vi.mock("~/previewStateStore", () => ({ @@ -221,7 +233,7 @@ vi.mock("./PreviewChromeRow", () => ({ onPictureInPicture?: () => void; pictureInPicture?: boolean; trailingActions?: { - props: { onNativePictureInPicture?: () => void }; + props: { onNativePictureInPicture?: () => void; onOpenInBrowser?: () => void }; }; }) => { mocks.submittedUrl = props.onSubmit; @@ -229,6 +241,7 @@ vi.mock("./PreviewChromeRow", () => ({ mocks.togglePictureInPicture = props.onPictureInPicture ?? null; mocks.toggleNativePictureInPicture = props.trailingActions?.props.onNativePictureInPicture ?? null; + mocks.openInBrowser = props.trailingActions?.props.onOpenInBrowser ?? null; mocks.pictureInPicturePressed = props.pictureInPicture ?? false; return null; }, @@ -333,6 +346,8 @@ describe("PreviewView navigation", () => { mocks.emptyStateUrl = null; mocks.togglePictureInPicture = null; mocks.toggleNativePictureInPicture = null; + mocks.openInBrowser = null; + mocks.openExternal.mockClear(); mocks.pictureInPicturePressed = false; mocks.miniPlayerTabId = null; mocks.openMiniPlayer.mockClear(); @@ -519,6 +534,27 @@ describe("PreviewView navigation", () => { ); }); + it("hands the page the tab is showing to the system browser", async () => { + const props = { + threadRef: TEST_THREAD_REF, + tabId: "tab-1", + visible: true, + } as const; + + renderToStaticMarkup(); + mocks.openInBrowser?.(); + await vi.waitFor(() => expect(mocks.openExternal).toHaveBeenCalledWith("http://example.com/")); + + // An empty tab has no address to hand over; the menu item stays clickable, + // so the handler is the only thing keeping it from opening about:blank. + mocks.showEmptyState = true; + mocks.openExternal.mockClear(); + renderToStaticMarkup(); + expect(mocks.openInBrowser).not.toBeNull(); + mocks.openInBrowser?.(); + expect(mocks.openExternal).not.toHaveBeenCalled(); + }); + it("forwards Cmd/Ctrl+Enter annotations to the composer send path", async () => { const annotation = { id: "annotation-1", diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index e6ad2758bc48..e91838189b06 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -767,6 +767,7 @@ export function PreviewView({ onToggleDeviceToolbar={handleToggleDeviceToolbar} nativePictureInPicture={desktopOverlay?.pictureInPicture ?? false} onNativePictureInPicture={handleNativePictureInPicture} + onOpenInBrowser={handleOpenInBrowser} /> ) : null }