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"}
+
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
}