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 }