Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
},
getWindowFullscreenState: () =>
ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true,
getWindowZoomFactor: () => webFrame.getZoomFactor(),
onWindowFullscreenStateChange: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, fullscreen: unknown) => {
if (typeof fullscreen !== "boolean") return;
Expand Down
94 changes: 94 additions & 0 deletions apps/web/src/browser/HostedBrowserWebview.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import {
ThreadId,
type ClientSettings,
type DesktopPreviewBridge,
type PreviewViewportSetting,
} from "@t3tools/contracts";
import { act } from "react";

import "~/index.css";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

Expand Down Expand Up @@ -201,3 +204,94 @@ describe("HostedBrowserWebview settings hydration", () => {
expect(mocks.setClientSettings).not.toHaveBeenCalled();
});
});

describe("HostedBrowserWebview viewport layout", () => {
const runtimeTabId = "zoom-layout-tab";
const owner = Symbol("panel");
const panel = { x: 0, y: 0, width: 2000, height: 1600 };

async function renderViewport(viewport: PreviewViewportSetting) {
mocks.getClientSettings.mockResolvedValue({
...DEFAULT_CLIENT_SETTINGS,
browserDefaultZoomFactor: 1.5,
});
root ??= createRoot(container);
await act(async () => {
await ensureClientSettingsHydrated();
root!.render(
<HostedBrowserWebview
threadRef={{
environmentId: EnvironmentId.make("zoom-layout"),
threadId: ThreadId.make("zoom-layout"),
}}
tabId="zoom-layout"
runtimeTabId={runtimeTabId}
initialUrl="about:blank"
viewport={viewport}
pictureInPicture={false}
profileId={undefined}
zoomFactor={1.5}
/>,
);
});
const guest = container.querySelector("webview");
expect(guest).not.toBeNull();
return guest!;
}

it("keeps fixed viewports sized in guest pixels across host zoom and fitted surfaces", async () => {
let windowZoom = 1.25;
vi.stubGlobal("desktopBridge", { getWindowZoomFactor: () => windowZoom });
useBrowserSurfaceStore.getState().claim(runtimeTabId, owner, false);
useBrowserSurfaceStore.getState().present(runtimeTabId, owner, panel, true, 0, 30);
const guest = await renderViewport({ _tag: "freeform", width: 800, height: 600 });
expect(guest.getBoundingClientRect()).toMatchObject({ width: 960, height: 720 });

windowZoom = 0.75;
await act(() => window.dispatchEvent(new Event("resize")));
expect(guest.getBoundingClientRect()).toMatchObject({ width: 1600, height: 1200 });

windowZoom = 1;
await act(() => window.dispatchEvent(new Event("resize")));
expect(guest.getBoundingClientRect()).toMatchObject({ width: 1200, height: 900 });

windowZoom = 1.25;
await act(() => window.dispatchEvent(new Event("resize")));
await renderViewport({ _tag: "preset", presetId: "iphone-se", width: 375, height: 667 });
expect(guest.getBoundingClientRect().width).toBe(450);
expect(guest.getBoundingClientRect().height).toBeCloseTo(800.4, 1);

const miniPlayer = Symbol("mini-player");
await act(() => {
useBrowserSurfaceStore.getState().claim(runtimeTabId, miniPlayer, true);
useBrowserSurfaceStore
.getState()
.present(runtimeTabId, miniPlayer, { x: 0, y: 0, width: 225, height: 401 }, true, 0, 48);
});
expect(guest.offsetWidth).toBe(450);
expect(guest.offsetHeight).toBe(800);
expect(guest.getBoundingClientRect().width).toBe(225);
expect(guest.getBoundingClientRect().height).toBeCloseTo(400.2, 1);

await act(() => {
useBrowserSurfaceStore.getState().claim(runtimeTabId, owner, false);
useBrowserSurfaceStore.getState().present(runtimeTabId, owner, panel, true, 0, 30);
});
await renderViewport(FILL_PREVIEW_VIEWPORT);
expect(guest.getBoundingClientRect()).toMatchObject({ width: 2000, height: 1600 });
expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, {
zoomFactor: 1.5,
colorScheme: DEFAULT_CLIENT_SETTINGS.browserDefaultAppearance,
});
});

it("preserves preview scaling with an older bridge or no desktop bridge", async () => {
vi.stubGlobal("desktopBridge", {});
const guest = await renderViewport({ _tag: "freeform", width: 800, height: 600 });
expect(guest.getBoundingClientRect()).toMatchObject({ width: 1200, height: 900 });

vi.stubGlobal("desktopBridge", undefined);
await act(() => window.dispatchEvent(new Event("resize")));
expect(guest.getBoundingClientRect()).toMatchObject({ width: 1200, height: 900 });
});
});
21 changes: 9 additions & 12 deletions apps/web/src/browser/HostedBrowserWebview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
import { acquireDesktopTab, type AcquiredDesktopTab, withDesktopTab } from "./desktopTabLifetime";
import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle";
import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
import { useBrowserLayoutZoomFactor } from "./useBrowserLayoutZoomFactor";
import { useBrowserViewportResize } from "./useBrowserViewportResize";
import {
INITIAL_WEBVIEW_CRASH_RECOVERY_STATE,
Expand Down Expand Up @@ -209,7 +210,7 @@ export function HostedBrowserWebview(props: {

const active = presentation.visible && presentation.rect !== null;
const lastRect = presentation.rect;
const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
const layoutZoomFactor = useBrowserLayoutZoomFactor(zoomFactor);
const viewportWidth = viewport._tag === "fill" ? null : viewport.width;
const viewportHeight = viewport._tag === "fill" ? null : viewport.height;
const viewportAspectRatio =
Expand All @@ -222,13 +223,13 @@ export function HostedBrowserWebview(props: {
const content = presentation.content;
const hiddenWidth =
viewport._tag !== "fill"
? viewport.width * normalizedZoomFactor
? viewport.width * layoutZoomFactor
: content
? content.width / content.scale
: (lastRect?.width ?? 1280);
const hiddenHeight =
viewport._tag !== "fill"
? viewport.height * normalizedZoomFactor
? viewport.height * layoutZoomFactor
: content
? content.height / content.scale
: (lastRect?.height ?? 800);
Expand All @@ -247,22 +248,18 @@ export function HostedBrowserWebview(props: {
} = useBrowserViewportResize({
tabId: runtimeTabId,
viewport,
zoomFactor,
zoomFactor: layoutZoomFactor,
containerSize,
deviceToolbarVisible,
aspectRatio: lockedAspectRatio,
});
const fittedSourceViewport =
presentation.fitSourceContent && lastRect
? resolveFittedBrowserViewport(
viewport,
presentation.fittedSourceContent,
normalizedZoomFactor,
)
? resolveFittedBrowserViewport(viewport, presentation.fittedSourceContent, layoutZoomFactor)
: null;
const layout =
fittedSourceViewport && lastRect
? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, normalizedZoomFactor)
? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, layoutZoomFactor)
: viewportLayout;

const syncContentPresentation = () => {
Expand Down Expand Up @@ -350,14 +347,14 @@ export function HostedBrowserWebview(props: {
fittedSourceViewport
? fittedSourceViewport.width
: effectiveViewport._tag === "fill"
? Math.max(1, Math.round(layout.viewportWidth / normalizedZoomFactor))
? Math.max(1, Math.round(layout.viewportWidth / layoutZoomFactor))
: effectiveViewport.width
}
data-preview-css-height={
fittedSourceViewport
? fittedSourceViewport.height
: effectiveViewport._tag === "fill"
? Math.max(1, Math.round(layout.viewportHeight / normalizedZoomFactor))
? Math.max(1, Math.round(layout.viewportHeight / layoutZoomFactor))
: effectiveViewport.height
}
aria-hidden={active ? undefined : true}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/browser/browserViewportLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export const browserViewportSettingKey = (setting: PreviewViewportSetting): stri
const normalizeZoomFactor = (zoomFactor: number): number =>
Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;

// Convert guest CSS pixels to host CSS pixels without changing the guest's page zoom.
export const resolveBrowserLayoutZoomFactor = (
previewZoomFactor: number,
windowZoomFactor: number,
): number => normalizeZoomFactor(previewZoomFactor) / normalizeZoomFactor(windowZoomFactor);

export function resolveFittedBrowserViewport(
setting: PreviewViewportSetting,
sourceContent: {
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/browser/useBrowserLayoutZoomFactor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useSyncExternalStore } from "react";

import { resolveBrowserLayoutZoomFactor } from "./browserViewportLayout";

// App zoom changes the CSS viewport, so resize also signals zoom changes.
const subscribeToWindowZoom = (onChange: () => void) => {
window.addEventListener("resize", onChange);
return () => window.removeEventListener("resize", onChange);
};
const readWindowZoomFactor = () => window.desktopBridge?.getWindowZoomFactor?.() ?? 1;

export function useBrowserLayoutZoomFactor(previewZoomFactor: number) {
const windowZoomFactor = useSyncExternalStore(subscribeToWindowZoom, readWindowZoomFactor);
return resolveBrowserLayoutZoomFactor(previewZoomFactor, windowZoomFactor);
}
75 changes: 75 additions & 0 deletions apps/web/src/components/preview/PreviewView.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => ({
pictureInPicture: false,
showEmptyState: false,
loading: false,
nativeServerTab: false,
serverEpoch: null as string | null,
servers: [] as PreviewableServer[],
recordingTabIds: new Set<string>(),
Expand Down Expand Up @@ -115,6 +116,7 @@ vi.mock(import("~/previewStateStore"), async (original) => ({
"tab-1": {
threadId: "thread-1",
tabId: "tab-1",
runtime: mocks.nativeServerTab ? "server" : "desktop",
navStatus: { _tag: "Success", url: "http://example.com/", title: "Example" },
canGoBack: false,
canGoForward: false,
Expand Down Expand Up @@ -147,6 +149,15 @@ vi.mock(import("./usePreviewSession"), async (original) => ({
usePreviewSession: () => {},
}));

vi.mock(import("~/browser/previewRuntime"), async (original) => ({
...(await original()),
useRendersServerTabNatively: () => mocks.nativeServerTab,
}));

vi.mock(import("~/browser/browserViewportActions"), { spy: true });

import { commitBrowserViewportChange } from "~/browser/browserViewportActions";
import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
import { useComposerDraftStore } from "~/composerDraftStore";
import { useBrowserPointerStore } from "~/browser/browserPointerStore";
import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
Expand Down Expand Up @@ -205,13 +216,15 @@ function hasCursor() {

describe("PreviewView", () => {
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.clearAllMocks();
mocks.readEnvironmentScope.mockReset().mockReturnValue(true);
mocks.setAnnotationSendEnabled.mockReset().mockResolvedValue(undefined);
mocks.pickElement.mockReset();
mocks.pictureInPicture = false;
mocks.showEmptyState = false;
mocks.loading = false;
mocks.nativeServerTab = false;
mocks.serverEpoch = null;
mocks.servers = [];
mocks.recordingTabIds = new Set();
Expand All @@ -232,6 +245,7 @@ describe("PreviewView", () => {
for (const each of roots.splice(0)) await act(async () => each.unmount());
document.body.replaceChildren();
vi.useRealTimers();
vi.unstubAllGlobals();
});

it("shows the cursor in a replacement browser while the old instance still records", async () => {
Expand All @@ -254,6 +268,67 @@ describe("PreviewView", () => {
expect(mocks.recordingTabIds.has(TEST_RUNTIME_TAB_ID)).toBe(true);
});

it.each([false, true])(
"aligns the cursor and responsive viewport after host zoom changes (native server tab: %s)",
async (nativeServerTab) => {
mocks.nativeServerTab = nativeServerTab;
let windowZoom = 1;
vi.stubGlobal("desktopBridge", {
getWindowZoomFactor: () => windowZoom,
getLocalEnvironmentBootstraps: () => [],
});
const commit = vi.mocked(commitBrowserViewportChange).mockResolvedValueOnce(undefined);
try {
await render(view());
await act(() => {
const surfaces = useBrowserSurfaceStore.getState();
const slot = document.querySelector<HTMLElement>("[data-browser-surface-slot]")!;
slot.style.width = "1020px";
slot.style.height = "798px";
surfaces.presentContent(TEST_RUNTIME_TAB_ID, {
x: 20,
y: 40,
width: 500,
height: 375,
scale: 0.5,
scrollLeft: 7,
scrollTop: 11,
});
useBrowserPointerStore.setState({
byTabId: {
[TEST_RUNTIME_TAB_ID]: {
tabId: TEST_RUNTIME_TAB_ID,
phase: "move",
x: 100,
y: 200,
sequence: 1,
createdAt: "2026-07-27T00:00:00Z",
},
},
});
windowZoom = 1.25;
window.dispatchEvent(new Event("resize"));
});
const cursor = document.querySelector("[data-agent-browser-cursor]");
expect(cursor).not.toBeNull();
const position = new DOMMatrixReadOnly(getComputedStyle(cursor!).transform);
expect(position.e).toBe(53);
expect(position.f).toBe(109);

await userEvent.click(page.getByRole("button", { name: "Preview menu" }));
await userEvent.click(page.getByRole("menuitem", { name: "Show device toolbar" }));
expect(commit).toHaveBeenCalledExactlyOnceWith(TEST_RUNTIME_TAB_ID, {
_tag: "freeform",
width: 1250,
height: 938,
});
} finally {
commit.mockReset();
await act(() => useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }));
}
},
);

it("does not rerender while loading time passes", async () => {
vi.useFakeTimers();
mocks.loading = true;
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/preview/PreviewView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults";
import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar";
import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout";
import { useBrowserLayoutZoomFactor } from "~/browser/useBrowserLayoutZoomFactor";
import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
import { BrowserSettingsReadError } from "~/browser/openFileInPreview";
import { PreviewFileNotShown, PreviewUnreachable } from "./PreviewUnreachable";
Expand Down Expand Up @@ -219,6 +220,7 @@ export function PreviewView({
const serverInputDisabled = isServerTab && serverControlledTabId !== runtimeTabId;
const [serverFrameTabId, setServerFrameTabId] = useState<string | null>(null);
const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null;
const layoutZoomFactor = useBrowserLayoutZoomFactor(desktopOverlay?.zoomFactor ?? 1);
const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const };
const url = navStatus._tag === "Idle" ? "" : navStatus.url;
const loading = desktopOverlay?.loading ?? navStatus._tag === "Loading";
Expand Down Expand Up @@ -389,7 +391,7 @@ export function PreviewView({
browserResponsiveViewportForToggle({
defaults: browserDefaults,
panelRect,
zoomFactor: serverOwnsRendering ? serverZoomFactor : desktopOverlay?.zoomFactor,
zoomFactor: isServerTab ? serverZoomFactor : layoutZoomFactor,
}),
).catch(() => undefined);
};
Expand Down Expand Up @@ -1056,7 +1058,7 @@ export function PreviewView({
!activeRecordingTabIds.has(runtimeTabId) ? (
<AgentBrowserCursor
tabId={runtimeTabId}
zoomFactor={desktopOverlay.zoomFactor}
zoomFactor={layoutZoomFactor}
controller={controller}
/>
) : null}
Expand Down
Loading
Loading