From 2caf9b86750c35faea163fe7c9b5437e6375ed20 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Mon, 17 Aug 2026 09:45:27 +0200 Subject: [PATCH 01/10] fix(preview): apply viewport changes when the panel is hidden preview_resize only updated the CSS/React chrome. If the browser panel was hidden, the guest never changed size and wait timed out. Resize now persists the setting, then applies a CDP device-metrics override so the guest viewport changes even when the tab is not visible. --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/preview.ts | 15 ++++++++ apps/desktop/src/preload.ts | 5 +++ apps/desktop/src/preview/Manager.ts | 23 ++++++++++++ .../preview/PreviewAutomationHosts.tsx | 30 ++++++++++++++-- .../src/components/preview/PreviewView.tsx | 10 +++++- .../preview/previewGuestViewport.test.ts | 35 +++++++++++++++++++ .../preview/previewGuestViewport.ts | 29 +++++++++++++++ packages/contracts/src/ipc.ts | 16 +++++++++ packages/contracts/src/preview.test.ts | 20 +++++++++++ 10 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/preview/previewGuestViewport.test.ts create mode 100644 apps/web/src/components/preview/previewGuestViewport.ts diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index c4ef82ec8cb7..3ed1d9a4546b 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -77,6 +77,7 @@ export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-pres export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll"; export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate"; export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for"; +export const PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL = "desktop:preview-automation-set-viewport"; export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 9850230a03a9..c29b1af7e38f 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -2,6 +2,7 @@ import { DesktopPreviewAnnotationThemeInputSchema, DesktopPreviewArtifactInputSchema, DesktopPreviewAutomationClickInputSchema, + DesktopPreviewAutomationSetViewportInputSchema, DesktopPreviewAutomationEvaluateInputSchema, DesktopPreviewAutomationPressInputSchema, DesktopPreviewAutomationScrollInputSchema, @@ -299,6 +300,19 @@ export const automationSnapshot = DesktopIpc.makeIpcMethod({ }), }); +export const automationSetViewport = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL, + payload: DesktopPreviewAutomationSetViewportInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationSetViewport")(function* (input) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationSetViewport( + input.tabId, + "clear" in input ? { clear: true } : { width: input.width, height: input.height }, + ); + }), +}); + export const automationClick = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, payload: DesktopPreviewAutomationClickInputSchema, @@ -397,6 +411,7 @@ export const methods = [ closePictureInPicture, automationStatus, automationSnapshot, + automationSetViewport, automationClick, automationType, automationPress, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 407c7c3ef498..97418a92cca3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -236,6 +236,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), snapshot: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }), + setViewport: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL, { + tabId, + ...input, + }), click: (tabId, input) => ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }), type: (tabId, input) => diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 0d90e0175fe3..902366e36467 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2446,6 +2446,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const automationSetViewport = Effect.fn("PreviewManager.automationSetViewport")(function* ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "resize", (send) => + "clear" in input + ? send("Emulation.clearDeviceMetricsOverride") + : send("Emulation.setDeviceMetricsOverride", { + width: input.width, + height: input.height, + deviceScaleFactor: 1, + mobile: input.width < 768, + }), + ); + }); + const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( tabId: string, ) { @@ -3746,6 +3763,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setAnnotationTheme, setAudioMuted, setColorScheme, + automationSetViewport, setMainWindow, startRecording, closePictureInPicture, @@ -4062,6 +4080,10 @@ export class PreviewManager extends Context.Service< tabId: string, audioMuted: boolean, ) => Effect.Effect; + readonly automationSetViewport: ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: () => Effect.Effect; readonly clearCache: () => Effect.Effect; @@ -4161,6 +4183,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { hardReload: operations.hardReload, setColorScheme: operations.setColorScheme, setAudioMuted: operations.setAudioMuted, + automationSetViewport: operations.automationSetViewport, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { yield* browserSession diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index acf7e52e3039..ccd919ee64c6 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -48,6 +48,7 @@ import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; +import { applyPreviewGuestViewport } from "./previewGuestViewport"; import { PreviewAutomationOperationError, PreviewAutomationOverlayTimeoutError, @@ -178,6 +179,14 @@ const waitForRenderedViewport = async ( const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null; const declaredViewport = readDeclaredViewport(webview); const renderedViewport = webview ? await readWebviewViewport(webview) : null; + if ( + setting._tag !== "fill" && + renderedViewport && + Math.abs(renderedViewport.width - setting.width) <= 1 && + Math.abs(renderedViewport.height - setting.height) <= 1 + ) { + return renderedViewport; + } if ( renderedViewport && isPreviewViewportReady({ @@ -497,7 +506,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const ready = await requireReadyTab(); const input = request.input as PreviewAutomationResizeInput; const setting = resolvePreviewViewport(input); - const applied = await runBrowserViewportMutation(ready.runtimeTabId, async () => { + const setViewport = ready.bridge.automation.setViewport; + const persistViewport = async () => { const operationState = assertPreviewRuntimeCurrent( threadRef, ready.tabId, @@ -518,11 +528,22 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) return raiseAtomCommandFailure(result); } updatePreviewServerSnapshot(threadRef, result.value); + try { + await applyPreviewGuestViewport(setViewport, ready.runtimeTabId, setting); + } catch (error) { + await applyPreviewGuestViewport( + setViewport, + ready.runtimeTabId, + previousSetting, + ).catch(() => undefined); + throw error; + } return { previousSetting, serverEpoch: operationState.serverEpoch, }; - }); + }; + const applied = await runBrowserViewportMutation(ready.runtimeTabId, persistViewport); let viewport: PreviewRenderedViewportSize; try { viewport = await waitForRenderedViewport( @@ -562,6 +583,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }); if (rollback._tag !== "Failure") { updatePreviewServerSnapshot(threadRef, rollback.value); + await applyPreviewGuestViewport( + setViewport, + ready.runtimeTabId, + applied.previousSetting, + ).catch(() => undefined); } } }); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 9a812923717a..9aef34885715 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -34,6 +34,7 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/prev import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; +import { applyPreviewGuestViewport } from "./previewGuestViewport"; import { subscribePreviewAction } from "./previewActionBus"; import { openPreviewSession } from "./openPreviewSession"; import { PreviewChromeRow } from "./PreviewChromeRow"; @@ -237,8 +238,15 @@ export function PreviewView({ throw error; } updatePreviewServerSnapshot(threadRef, result.value); + if (runtimeTabId) { + await applyPreviewGuestViewport( + previewBridge?.automation.setViewport, + runtimeTabId, + nextViewport, + ); + } }, - [resize, tabId, threadRef], + [resize, runtimeTabId, tabId, threadRef], ); const handleToggleDeviceToolbar = () => { diff --git a/apps/web/src/components/preview/previewGuestViewport.test.ts b/apps/web/src/components/preview/previewGuestViewport.test.ts new file mode 100644 index 000000000000..0b2fd30a7fe8 --- /dev/null +++ b/apps/web/src/components/preview/previewGuestViewport.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { applyPreviewGuestViewport, previewGuestViewportOverride } from "./previewGuestViewport"; + +describe("previewGuestViewportOverride", () => { + it("clears fill mode and uses explicit dimensions for fixed viewports", () => { + expect(previewGuestViewportOverride({ _tag: "fill" })).toEqual({ clear: true }); + expect(previewGuestViewportOverride({ _tag: "freeform", width: 1024, height: 768 })).toEqual({ + width: 1024, + height: 768, + }); + expect( + previewGuestViewportOverride({ + _tag: "preset", + presetId: "iphone-12-pro", + width: 390, + height: 844, + }), + ).toEqual({ width: 390, height: 844 }); + }); +}); + +describe("applyPreviewGuestViewport", () => { + it("skips older desktops and applies the mapped override otherwise", async () => { + await applyPreviewGuestViewport(undefined, "tab-1", { _tag: "fill" }); + + const setViewport = vi.fn(async () => undefined); + await applyPreviewGuestViewport(setViewport, "tab-1", { + _tag: "freeform", + width: 800, + height: 600, + }); + expect(setViewport).toHaveBeenCalledWith("tab-1", { width: 800, height: 600 }); + }); +}); diff --git a/apps/web/src/components/preview/previewGuestViewport.ts b/apps/web/src/components/preview/previewGuestViewport.ts new file mode 100644 index 000000000000..26c69912c717 --- /dev/null +++ b/apps/web/src/components/preview/previewGuestViewport.ts @@ -0,0 +1,29 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; + +export type PreviewGuestViewportOverride = + | { readonly clear: true } + | { readonly width: number; readonly height: number }; + +export type PreviewGuestViewportApplier = ( + tabId: string, + input: PreviewGuestViewportOverride, +) => Promise; + +/** Maps a stored viewport setting onto the desktop CDP override. */ +export function previewGuestViewportOverride( + setting: PreviewViewportSetting, +): PreviewGuestViewportOverride { + return setting._tag === "fill" + ? { clear: true } + : { width: setting.width, height: setting.height }; +} + +/** Applies or clears the guest CDP metrics override. No-op on older desktops. */ +export async function applyPreviewGuestViewport( + setViewport: PreviewGuestViewportApplier | undefined, + tabId: string, + setting: PreviewViewportSetting, +): Promise { + if (!setViewport) return; + await setViewport(tabId, previewGuestViewportOverride(setting)); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e753596f3d33..ea29a9b4df4e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -993,6 +993,18 @@ export interface DesktopPreviewTabDefaults { readonly colorScheme?: DesktopPreviewColorScheme | undefined; } +export const DesktopPreviewAutomationSetViewportInputSchema = Schema.Union([ + Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + width: Schema.Int.check(Schema.isGreaterThan(0)), + height: Schema.Int.check(Schema.isGreaterThan(0)), + }), + Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + clear: Schema.Literal(true), + }), +]); + export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({ tabId: DesktopPreviewTabIdSchema, webContentsId: Schema.Int.check(Schema.isGreaterThan(0)), @@ -1219,6 +1231,10 @@ export interface DesktopPreviewBridge { automation: { status: (tabId: string) => Promise; snapshot: (tabId: string) => Promise; + setViewport: ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => Promise; click: (tabId: string, input: PreviewAutomationClickInput) => Promise; type: (tabId: string, input: PreviewAutomationTypeInput) => Promise; press: (tabId: string, input: PreviewAutomationPressInput) => Promise; diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index 24f429745ef8..b9306b4e4996 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -11,6 +11,7 @@ import { PreviewSessionSnapshot, PreviewViewportSetting, } from "./preview.ts"; +import { DesktopPreviewAutomationSetViewportInputSchema } from "./ipc.ts"; import { PreviewAutomationHost, PreviewAutomationError, @@ -32,6 +33,9 @@ const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResul const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus); +const decodeSetViewportInput = Schema.decodeUnknownSync( + DesktopPreviewAutomationSetViewportInputSchema, +); describe("PreviewAutomationOpenInput", () => { it("accepts the inline preview visibility flag", () => { @@ -223,6 +227,22 @@ describe("PreviewAutomationStatus", () => { }); }); +describe("DesktopPreviewAutomationSetViewportInputSchema", () => { + it("accepts a complete size or an explicit clear, and rejects a partial size", () => { + expect(decodeSetViewportInput({ tabId: "tab-1", width: 800, height: 600 })).toEqual({ + tabId: "tab-1", + width: 800, + height: 600, + }); + expect(decodeSetViewportInput({ tabId: "tab-1", clear: true })).toEqual({ + tabId: "tab-1", + clear: true, + }); + expect(() => decodeSetViewportInput({ tabId: "tab-1", width: 800 })).toThrow(); + expect(() => decodeSetViewportInput({ tabId: "tab-1" })).toThrow(); + }); +}); + describe("PreviewEvent", () => { it("decodes opened", () => { const event = decodePreviewEvent({ From f3d394a0d2f905ba5a6d3c18cb4bcdb210e1d8cf Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Mon, 17 Aug 2026 10:37:31 +0200 Subject: [PATCH 02/10] fix(preview): keep guest viewport and server snapshot in lockstep A size-only ready check could resolve resize before React committed the webview geometry. A failed CDP apply also left the persisted snapshot on the new size. Resize now waits for the existing ready gates, rolls the snapshot back when the override fails, and re-applies the committed setting when the guest attaches. --- .../preview/PreviewAutomationHosts.tsx | 19 ++++++++++-------- .../src/components/preview/PreviewView.tsx | 20 +++++++++++-------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index ccd919ee64c6..a839b3ac2d2a 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -179,14 +179,6 @@ const waitForRenderedViewport = async ( const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null; const declaredViewport = readDeclaredViewport(webview); const renderedViewport = webview ? await readWebviewViewport(webview) : null; - if ( - setting._tag !== "fill" && - renderedViewport && - Math.abs(renderedViewport.width - setting.width) <= 1 && - Math.abs(renderedViewport.height - setting.height) <= 1 - ) { - return renderedViewport; - } if ( renderedViewport && isPreviewViewportReady({ @@ -531,6 +523,17 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) try { await applyPreviewGuestViewport(setViewport, ready.runtimeTabId, setting); } catch (error) { + const rollback = await resize({ + environmentId, + input: { + threadId: request.threadId, + tabId: ready.tabId, + viewport: previousSetting, + }, + }); + if (rollback._tag !== "Failure") { + updatePreviewServerSnapshot(threadRef, rollback.value); + } await applyPreviewGuestViewport( setViewport, ready.runtimeTabId, diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 9aef34885715..e94b51704060 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -238,17 +238,21 @@ export function PreviewView({ throw error; } updatePreviewServerSnapshot(threadRef, result.value); - if (runtimeTabId) { - await applyPreviewGuestViewport( - previewBridge?.automation.setViewport, - runtimeTabId, - nextViewport, - ); - } }, - [resize, runtimeTabId, tabId, threadRef], + [resize, tabId, threadRef], ); + const viewportOverrideKey = + viewport._tag === "fill" ? "fill" : `${viewport._tag}:${viewport.width}x${viewport.height}`; + useEffect(() => { + if (!runtimeTabId || !desktopOverlay?.hasWebContents) return; + void applyPreviewGuestViewport( + previewBridge?.automation.setViewport, + runtimeTabId, + viewport, + ).catch(() => undefined); + }, [desktopOverlay?.hasWebContents, runtimeTabId, viewport, viewportOverrideKey]); + const handleToggleDeviceToolbar = () => { if (!runtimeTabId) return; if (viewport._tag !== "fill") { From 633be650f59fdf232967f5aefb0c7814cbe9879d Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Mon, 17 Aug 2026 10:55:12 +0200 Subject: [PATCH 03/10] fix(preview): apply human viewport overrides without agent control The restore effect depended on the viewport object, so every preview event re-sent CDP and flashed the agent-controlling badge. Toolbar resizes now use a setViewport path that does not take agent control, and the effect keys only on the viewport size string. --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/preview.ts | 13 +++++ apps/desktop/src/preload.ts | 5 ++ apps/desktop/src/preview/Manager.test.ts | 55 +++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 33 +++++++++++ .../src/components/preview/PreviewView.tsx | 8 ++- packages/contracts/src/ipc.ts | 9 +++ 7 files changed, 121 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 3ed1d9a4546b..f18e63f0d11d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -56,6 +56,7 @@ export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; +export const PREVIEW_SET_VIEWPORT_CHANNEL = "desktop:preview-set-viewport"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index c29b1af7e38f..d4075e2af575 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -164,6 +164,18 @@ export const setAudioMuted = DesktopIpc.makeIpcMethod({ yield* manager.setAudioMuted(tabId, audioMuted); }), }); +export const setViewport = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL, + payload: DesktopPreviewAutomationSetViewportInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setViewport")(function* (input) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setViewport( + input.tabId, + "clear" in input ? { clear: true } : { width: input.width, height: input.height }, + ); + }), +}); export const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", @@ -397,6 +409,7 @@ export const methods = [ hardReload, setColorScheme, setAudioMuted, + setViewport, openDevTools, clearCookies, clearCache, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 97418a92cca3..f7b713b237ed 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -187,6 +187,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), setAudioMuted: (tabId, audioMuted) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), + setViewport: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL, { + tabId, + ...input, + }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 3bf6d63051af..63e04099f868 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1554,6 +1554,61 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("applies a guest viewport override without taking agent control", () => + withManager((manager) => + Effect.gen(function* () { + const sendCommand = vi.fn(async () => undefined); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const controllers: Array = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + controllers.push(state.controller); + }), + ); + yield* manager.createTab("tab_viewport"); + yield* manager.registerWebview("tab_viewport", 42); + yield* manager.setViewport("tab_viewport", { width: 390, height: 844 }); + yield* manager.setViewport("tab_viewport", { clear: true }); + + expect(sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { + width: 390, + height: 844, + deviceScaleFactor: 1, + mobile: true, + }); + expect(sendCommand).toHaveBeenCalledWith("Emulation.clearDeviceMetricsOverride"); + expect(controllers).not.toContain("agent"); + + }), + ), + ); + effectIt.effect("blocks late webview and capture starts during tab close", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 902366e36467..f2da7ffd850c 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2446,6 +2446,33 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const applyViewportOverride = Effect.fn("PreviewManager.applyViewportOverride")(function* ( + tabId: string, + wc: Electron.WebContents, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) { + yield* ensureControlSession(wc); + yield* attemptPromise({ operation: "applyViewportOverride", tabId, webContentsId: wc.id }, () => + "clear" in input + ? wc.debugger.sendCommand("Emulation.clearDeviceMetricsOverride") + : wc.debugger.sendCommand("Emulation.setDeviceMetricsOverride", { + width: input.width, + height: input.height, + deviceScaleFactor: 1, + mobile: input.width < 768, + }), + ); + }); + + // Human/toolbar path. Must not take agent control or write a resize action. + const setViewport = Effect.fn("PreviewManager.setViewport")(function* ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) { + const wc = yield* requireWebContents(tabId); + yield* applyViewportOverride(tabId, wc, input); + }); + const automationSetViewport = Effect.fn("PreviewManager.automationSetViewport")(function* ( tabId: string, input: { readonly width: number; readonly height: number } | { readonly clear: true }, @@ -3763,6 +3790,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setAnnotationTheme, setAudioMuted, setColorScheme, + setViewport, automationSetViewport, setMainWindow, startRecording, @@ -4080,6 +4108,10 @@ export class PreviewManager extends Context.Service< tabId: string, audioMuted: boolean, ) => Effect.Effect; + readonly setViewport: ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => Effect.Effect; readonly automationSetViewport: ( tabId: string, input: { readonly width: number; readonly height: number } | { readonly clear: true }, @@ -4183,6 +4215,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { hardReload: operations.hardReload, setColorScheme: operations.setColorScheme, setAudioMuted: operations.setAudioMuted, + setViewport: operations.setViewport, automationSetViewport: operations.automationSetViewport, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index e94b51704060..dd5a554f0f6d 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -244,14 +244,16 @@ export function PreviewView({ const viewportOverrideKey = viewport._tag === "fill" ? "fill" : `${viewport._tag}:${viewport.width}x${viewport.height}`; + const viewportRef = useRef(viewport); + viewportRef.current = viewport; useEffect(() => { if (!runtimeTabId || !desktopOverlay?.hasWebContents) return; void applyPreviewGuestViewport( - previewBridge?.automation.setViewport, + previewBridge?.setViewport, runtimeTabId, - viewport, + viewportRef.current, ).catch(() => undefined); - }, [desktopOverlay?.hasWebContents, runtimeTabId, viewport, viewportOverrideKey]); + }, [desktopOverlay?.hasWebContents, runtimeTabId, viewportOverrideKey]); const handleToggleDeviceToolbar = () => { if (!runtimeTabId) return; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ea29a9b4df4e..b4c63daef596 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1188,6 +1188,15 @@ export interface DesktopPreviewBridge { * allowed; it simply takes effect once the page plays something. */ setAudioMuted: (tabId: string, audioMuted: boolean) => Promise; + /** + * Apply or clear a guest device-metrics override without taking agent + * control. Used by the toolbar and restore path so a human resize does + * not flash the agent-controlling badge. + */ + setViewport: ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => Promise; /** Open the guest webview's DevTools (detached). */ openDevTools: (tabId: string) => Promise; /** Drop cookies + storage data for the preview partition (all tabs). */ From da4b4bd021eaa60d09e7e5da9f2f204899173bd3 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Mon, 17 Aug 2026 11:17:53 +0200 Subject: [PATCH 04/10] fix(preview): keep landscape phones mobile and sync drag to the guest Width-only mobile detection treated 844x390 phones as desktop. The guest also stayed pinned to the last committed size while the frame was dragged. mobile now uses the shortest side. HostedBrowserWebview applies a frame-throttled CDP override from the effective (including drag) viewport. --- apps/desktop/src/preview/Manager.test.ts | 7 ++++++ apps/desktop/src/preview/Manager.ts | 25 ++++++++++--------- apps/web/src/browser/HostedBrowserWebview.tsx | 14 +++++++++++ 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 63e04099f868..fea967668227 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1594,6 +1594,7 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_viewport"); yield* manager.registerWebview("tab_viewport", 42); yield* manager.setViewport("tab_viewport", { width: 390, height: 844 }); + yield* manager.setViewport("tab_viewport", { width: 844, height: 390 }); yield* manager.setViewport("tab_viewport", { clear: true }); expect(sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { @@ -1602,6 +1603,12 @@ describe("PreviewManager", () => { deviceScaleFactor: 1, mobile: true, }); + expect(sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { + width: 844, + height: 390, + deviceScaleFactor: 1, + mobile: true, + }); expect(sendCommand).toHaveBeenCalledWith("Emulation.clearDeviceMetricsOverride"); expect(controllers).not.toContain("agent"); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index f2da7ffd850c..723a2fa23392 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2446,6 +2446,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const deviceMetricsOverride = (input: { readonly width: number; readonly height: number }) => ({ + width: input.width, + height: input.height, + deviceScaleFactor: 1, + // Shortest side, so landscape phones stay mobile (844x390, not width-only). + mobile: Math.min(input.width, input.height) < 768, + }); + const applyViewportOverride = Effect.fn("PreviewManager.applyViewportOverride")(function* ( tabId: string, wc: Electron.WebContents, @@ -2455,12 +2463,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* attemptPromise({ operation: "applyViewportOverride", tabId, webContentsId: wc.id }, () => "clear" in input ? wc.debugger.sendCommand("Emulation.clearDeviceMetricsOverride") - : wc.debugger.sendCommand("Emulation.setDeviceMetricsOverride", { - width: input.width, - height: input.height, - deviceScaleFactor: 1, - mobile: input.width < 768, - }), + : wc.debugger.sendCommand( + "Emulation.setDeviceMetricsOverride", + deviceMetricsOverride(input), + ), ); }); @@ -2481,12 +2487,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* withControlSession(tabId, wc, "resize", (send) => "clear" in input ? send("Emulation.clearDeviceMetricsOverride") - : send("Emulation.setDeviceMetricsOverride", { - width: input.width, - height: input.height, - deviceScaleFactor: 1, - mobile: input.width < 768, - }), + : send("Emulation.setDeviceMetricsOverride", deviceMetricsOverride(input)), ); }); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index ae0526abb15f..7f86b6f5da67 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -5,6 +5,7 @@ import { useShallow } from "zustand/react/shallow"; import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; +import { applyPreviewGuestViewport } from "~/components/preview/previewGuestViewport"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { cn } from "~/lib/utils"; @@ -191,6 +192,19 @@ export function HostedBrowserWebview(props: { deviceToolbarVisible, aspectRatio: lockedAspectRatio, }); + const guestViewportKey = browserViewportSettingKey(effectiveViewport); + const guestViewportRef = useRef(effectiveViewport); + guestViewportRef.current = effectiveViewport; + useEffect(() => { + const setViewport = previewBridge?.setViewport; + if (!setViewport) return; + const frame = window.requestAnimationFrame(() => { + void applyPreviewGuestViewport(setViewport, runtimeTabId, guestViewportRef.current).catch( + () => undefined, + ); + }); + return () => window.cancelAnimationFrame(frame); + }, [guestViewportKey, runtimeTabId, webviewGeneration]); const fittedSourceViewport = presentation.fitSourceContent && lastRect ? resolveFittedBrowserViewport( From 30b347ac9bc84791fc554936d9c4e28c465411bf Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Mon, 17 Aug 2026 12:20:56 +0200 Subject: [PATCH 05/10] fix(preview): apply guest viewport only after the webview is registered The HostedBrowserWebview override ran one frame after mount, before registerWebview finished, then swallowed the failure. Crash recovery and mini-player never retried. The override now runs after a successful register, and drag updates wait for hasWebContents. --- apps/web/src/browser/HostedBrowserWebview.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 7f86b6f5da67..513d0da042cd 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -8,6 +8,7 @@ import { previewBridge } from "~/components/preview/previewBridge"; import { applyPreviewGuestViewport } from "~/components/preview/previewGuestViewport"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { cn } from "~/lib/utils"; +import { useThreadPreviewState } from "~/previewStateStore"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; import { @@ -56,6 +57,7 @@ export function HostedBrowserWebview(props: { const tabLeaseRef = useRef(null); const wrapperRef = useRef(null); const webviewRef = useRef(null); + const guestViewportRef = useRef(viewport); const crashRecoveryRef = useRef(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE); const [aspectRatioLocked, setAspectRatioLocked] = useState(false); const presentation = useBrowserSurfaceStore( @@ -72,6 +74,8 @@ export function HostedBrowserWebview(props: { }), ); usePreviewBridge({ threadRef, tabId, runtimeTabId }); + const hasWebContents = + useThreadPreviewState(threadRef).desktopByTabId[tabId]?.hasWebContents === true; useEffect(() => { crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; @@ -115,6 +119,15 @@ export function HostedBrowserWebview(props: { const webContentsId = webview.getWebContentsId(); if (Number.isInteger(webContentsId) && webContentsId > 0) { await bridge.registerWebview(runtimeTabId, webContentsId); + if (disposed || webviewRef.current !== webview) return; + const setViewport = previewBridge?.setViewport; + if (setViewport) { + await applyPreviewGuestViewport( + setViewport, + runtimeTabId, + guestViewportRef.current, + ).catch(() => undefined); + } } } catch { // did-attach/dom-ready will retry if the guest was not ready yet. @@ -193,18 +206,17 @@ export function HostedBrowserWebview(props: { aspectRatio: lockedAspectRatio, }); const guestViewportKey = browserViewportSettingKey(effectiveViewport); - const guestViewportRef = useRef(effectiveViewport); guestViewportRef.current = effectiveViewport; useEffect(() => { const setViewport = previewBridge?.setViewport; - if (!setViewport) return; + if (!setViewport || !hasWebContents) return; const frame = window.requestAnimationFrame(() => { void applyPreviewGuestViewport(setViewport, runtimeTabId, guestViewportRef.current).catch( () => undefined, ); }); return () => window.cancelAnimationFrame(frame); - }, [guestViewportKey, runtimeTabId, webviewGeneration]); + }, [guestViewportKey, hasWebContents, runtimeTabId]); const fittedSourceViewport = presentation.fitSourceContent && lastRect ? resolveFittedBrowserViewport( From af0ec5333e4addb3a0b289ef4057c6f66b2ab401 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Mon, 17 Aug 2026 20:42:17 +0200 Subject: [PATCH 06/10] fix(preview): leave guest viewport overrides to HostedBrowserWebview PreviewView reapplied the committed size after the webview owner already set the live override. That raced drags and keyboard resizes. The active-tab effect is gone; HostedBrowserWebview is the single owner. --- apps/web/src/components/preview/PreviewView.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index dd5a554f0f6d..9a812923717a 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -34,7 +34,6 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/prev import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; -import { applyPreviewGuestViewport } from "./previewGuestViewport"; import { subscribePreviewAction } from "./previewActionBus"; import { openPreviewSession } from "./openPreviewSession"; import { PreviewChromeRow } from "./PreviewChromeRow"; @@ -242,19 +241,6 @@ export function PreviewView({ [resize, tabId, threadRef], ); - const viewportOverrideKey = - viewport._tag === "fill" ? "fill" : `${viewport._tag}:${viewport.width}x${viewport.height}`; - const viewportRef = useRef(viewport); - viewportRef.current = viewport; - useEffect(() => { - if (!runtimeTabId || !desktopOverlay?.hasWebContents) return; - void applyPreviewGuestViewport( - previewBridge?.setViewport, - runtimeTabId, - viewportRef.current, - ).catch(() => undefined); - }, [desktopOverlay?.hasWebContents, runtimeTabId, viewportOverrideKey]); - const handleToggleDeviceToolbar = () => { if (!runtimeTabId) return; if (viewport._tag !== "fill") { From 1e8ff2981b6ceade839bfa549aea25557347fd35 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sun, 23 Aug 2026 09:31:31 +0200 Subject: [PATCH 07/10] style(preview): drop extra blank line in viewport override test --- apps/desktop/src/preview/Manager.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index fea967668227..6b200eaf8003 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1611,7 +1611,6 @@ describe("PreviewManager", () => { }); expect(sendCommand).toHaveBeenCalledWith("Emulation.clearDeviceMetricsOverride"); expect(controllers).not.toContain("agent"); - }), ), ); From 0ea4e3bd8e7e3fb75bb90658ada763c5382fe603 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sun, 23 Aug 2026 09:46:15 +0200 Subject: [PATCH 08/10] fix(preview): restore viewport after DevTools and gate guest rollback Device-metrics overrides live on the CDP session, so DevTools and webview swaps dropped a fixed viewport while the toolbar still showed it. Remember the last override and re-apply it from restoreControlSession, like color scheme. Guest rollback now rechecks runtime/epoch and only persists previous if the guest actually rolled back, so a replaced tab or a failed CDP clear cannot desync store and guest. --- apps/desktop/src/preview/Manager.test.ts | 57 ++++++++++++ apps/desktop/src/preview/Manager.ts | 34 ++++++- .../preview/PreviewAutomationHosts.tsx | 88 +++++++++---------- 3 files changed, 130 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6b200eaf8003..997694d5f946 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1615,6 +1615,63 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("re-applies a guest viewport override after a webview swap", () => + withManager((manager) => + Effect.gen(function* () { + const makeWebContents = (id: number) => { + const sendCommand = vi.fn(async () => undefined); + return { + sendCommand, + wc: { + id, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never, + }; + }; + const first = makeWebContents(42); + fromId.mockReturnValue(first.wc); + + yield* manager.createTab("tab_viewport_restore"); + yield* manager.registerWebview("tab_viewport_restore", 42); + yield* manager.setViewport("tab_viewport_restore", { width: 390, height: 844 }); + + const replacement = makeWebContents(43); + fromId.mockReturnValue(replacement.wc); + yield* manager.registerWebview("tab_viewport_restore", 43); + yield* Effect.yieldNow; + + expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { + width: 390, + height: 844, + deviceScaleFactor: 1, + mobile: true, + }); + }), + ), + ); + effectIt.effect("blocks late webview and capture starts during tab close", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 723a2fa23392..4adfc31fda2e 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -497,6 +497,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); const mainWindowRef = yield* Ref.make>(Option.none()); const tabsRef = yield* SynchronizedRef.make>(new Map()); + const viewportOverridesRef = yield* SynchronizedRef.make< + ReadonlyMap + >(new Map()); const attachedRef = yield* Ref.make>(new Map()); const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); @@ -1829,6 +1832,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; }); if (Option.isNone(tab)) return; + yield* SynchronizedRef.update(viewportOverridesRef, (overrides) => + replaceMap(overrides, (copy) => { + copy.delete(tabId); + }), + ); const closedTab = tab.value; if (closedTab.webContentsId != null) { yield* Effect.all( @@ -2362,10 +2370,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Re-establish the control session after a detach, restoring any - // color-scheme override the tab carries. The scheme is read after the - // session attaches so a concurrent setColorScheme is not overwritten with - // a stale snapshot. + // Re-establish the control session after a detach, restoring color-scheme + // and viewport overrides the tab carries. Both live on the CDP debugger + // session, so they are lost on webview swap and DevTools open/close. + // Values are read after attach so a concurrent setColorScheme/setViewport + // is not overwritten with a stale snapshot. const restoreControlSession = (tabId: string, wc: Electron.WebContents) => Effect.gen(function* () { const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); @@ -2388,6 +2397,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); } + const viewportOverride = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId); + if (viewportOverride) { + yield* applyViewportOverride(tabId, wc, viewportOverride); + } }).pipe(Effect.ignore); const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( @@ -2454,6 +2467,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function mobile: Math.min(input.width, input.height) < 768, }); + const rememberViewportOverride = ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => + SynchronizedRef.update(viewportOverridesRef, (overrides) => + replaceMap(overrides, (copy) => { + if ("clear" in input) copy.delete(tabId); + else copy.set(tabId, { width: input.width, height: input.height }); + }), + ); + const applyViewportOverride = Effect.fn("PreviewManager.applyViewportOverride")(function* ( tabId: string, wc: Electron.WebContents, @@ -2476,6 +2500,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: { readonly width: number; readonly height: number } | { readonly clear: true }, ) { const wc = yield* requireWebContents(tabId); + yield* rememberViewportOverride(tabId, input); yield* applyViewportOverride(tabId, wc, input); }); @@ -2484,6 +2509,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: { readonly width: number; readonly height: number } | { readonly clear: true }, ) { const wc = yield* requireWebContents(tabId); + yield* rememberViewportOverride(tabId, input); yield* withControlSession(tabId, wc, "resize", (send) => "clear" in input ? send("Emulation.clearDeviceMetricsOverride") diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index a839b3ac2d2a..5fe8840aae91 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -499,6 +499,47 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const input = request.input as PreviewAutomationResizeInput; const setting = resolvePreviewViewport(input); const setViewport = ready.bridge.automation.setViewport; + const rollbackGuestIfCurrent = async ( + previousSetting: PreviewViewportSetting, + operationServerEpoch: string | null, + ) => { + const latestState = readThreadPreviewState(threadRef); + const latestSetting = + latestState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; + if ( + !shouldRollbackPreviewViewport( + previousSetting, + setting, + latestSetting, + operationServerEpoch, + latestState.serverEpoch, + ) + ) { + return; + } + try { + assertPreviewRuntimeCurrent(threadRef, ready.tabId, ready.runtimeTabId, request); + } catch { + return; + } + try { + await applyPreviewGuestViewport(setViewport, ready.runtimeTabId, previousSetting); + } catch { + // Guest still has the requested override; leave the store matching it. + return; + } + const rollback = await resize({ + environmentId, + input: { + threadId: request.threadId, + tabId: ready.tabId, + viewport: previousSetting, + }, + }); + if (rollback._tag !== "Failure") { + updatePreviewServerSnapshot(threadRef, rollback.value); + } + }; const persistViewport = async () => { const operationState = assertPreviewRuntimeCurrent( threadRef, @@ -523,22 +564,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) try { await applyPreviewGuestViewport(setViewport, ready.runtimeTabId, setting); } catch (error) { - const rollback = await resize({ - environmentId, - input: { - threadId: request.threadId, - tabId: ready.tabId, - viewport: previousSetting, - }, - }); - if (rollback._tag !== "Failure") { - updatePreviewServerSnapshot(threadRef, rollback.value); - } - await applyPreviewGuestViewport( - setViewport, - ready.runtimeTabId, - previousSetting, - ).catch(() => undefined); + await rollbackGuestIfCurrent(previousSetting, operationState.serverEpoch); throw error; } return { @@ -564,35 +590,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ); } catch (cause) { await runBrowserViewportMutation(ready.runtimeTabId, async () => { - const latestState = readThreadPreviewState(threadRef); - const latestSetting = - latestState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; - if ( - shouldRollbackPreviewViewport( - applied.previousSetting, - setting, - latestSetting, - applied.serverEpoch, - latestState.serverEpoch, - ) - ) { - const rollback = await resize({ - environmentId, - input: { - threadId: request.threadId, - tabId: ready.tabId, - viewport: applied.previousSetting, - }, - }); - if (rollback._tag !== "Failure") { - updatePreviewServerSnapshot(threadRef, rollback.value); - await applyPreviewGuestViewport( - setViewport, - ready.runtimeTabId, - applied.previousSetting, - ).catch(() => undefined); - } - } + await rollbackGuestIfCurrent(applied.previousSetting, applied.serverEpoch); }); throw cause; } From fd6b40af7858ea4062f53e0cda0d59351b988c90 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sun, 23 Aug 2026 09:55:18 +0200 Subject: [PATCH 09/10] fix(preview): scale desktop CDP viewport overrides by page zoom setDeviceMetricsOverride is a widget DIP size, so page zoom still divides it. Desktop presets now send setting * zoom so innerWidth matches the toolbar. Phone sizes stay unscaled because mobile emulation pins zoom to 1. --- apps/web/src/browser/HostedBrowserWebview.tsx | 14 +++++--- .../preview/PreviewAutomationHosts.tsx | 14 ++++++-- .../preview/previewGuestViewport.test.ts | 35 +++++++++++++++---- .../preview/previewGuestViewport.ts | 27 +++++++++++--- 4 files changed, 74 insertions(+), 16 deletions(-) diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 513d0da042cd..ab7f64d7b8cc 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -58,6 +58,7 @@ export function HostedBrowserWebview(props: { const wrapperRef = useRef(null); const webviewRef = useRef(null); const guestViewportRef = useRef(viewport); + const guestZoomRef = useRef(1); const crashRecoveryRef = useRef(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE); const [aspectRatioLocked, setAspectRatioLocked] = useState(false); const presentation = useBrowserSurfaceStore( @@ -126,6 +127,7 @@ export function HostedBrowserWebview(props: { setViewport, runtimeTabId, guestViewportRef.current, + guestZoomRef.current, ).catch(() => undefined); } } @@ -163,6 +165,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; + guestZoomRef.current = normalizedZoomFactor; const viewportWidth = viewport._tag === "fill" ? null : viewport.width; const viewportHeight = viewport._tag === "fill" ? null : viewport.height; const viewportAspectRatio = @@ -211,12 +214,15 @@ export function HostedBrowserWebview(props: { const setViewport = previewBridge?.setViewport; if (!setViewport || !hasWebContents) return; const frame = window.requestAnimationFrame(() => { - void applyPreviewGuestViewport(setViewport, runtimeTabId, guestViewportRef.current).catch( - () => undefined, - ); + void applyPreviewGuestViewport( + setViewport, + runtimeTabId, + guestViewportRef.current, + guestZoomRef.current, + ).catch(() => undefined); }); return () => window.cancelAnimationFrame(frame); - }, [guestViewportKey, hasWebContents, runtimeTabId]); + }, [guestViewportKey, hasWebContents, runtimeTabId, normalizedZoomFactor]); const fittedSourceViewport = presentation.fitSourceContent && lastRect ? resolveFittedBrowserViewport( diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 5fe8840aae91..0507e23816ba 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -523,7 +523,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) return; } try { - await applyPreviewGuestViewport(setViewport, ready.runtimeTabId, previousSetting); + await applyPreviewGuestViewport( + setViewport, + ready.runtimeTabId, + previousSetting, + latestState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1, + ); } catch { // Guest still has the requested override; leave the store matching it. return; @@ -562,7 +567,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } updatePreviewServerSnapshot(threadRef, result.value); try { - await applyPreviewGuestViewport(setViewport, ready.runtimeTabId, setting); + await applyPreviewGuestViewport( + setViewport, + ready.runtimeTabId, + setting, + operationState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1, + ); } catch (error) { await rollbackGuestIfCurrent(previousSetting, operationState.serverEpoch); throw error; diff --git a/apps/web/src/components/preview/previewGuestViewport.test.ts b/apps/web/src/components/preview/previewGuestViewport.test.ts index 0b2fd30a7fe8..45f4ac9038ec 100644 --- a/apps/web/src/components/preview/previewGuestViewport.test.ts +++ b/apps/web/src/components/preview/previewGuestViewport.test.ts @@ -18,6 +18,24 @@ describe("previewGuestViewportOverride", () => { }), ).toEqual({ width: 390, height: 844 }); }); + + it("scales desktop overrides by page zoom so innerWidth matches the toolbar", () => { + expect( + previewGuestViewportOverride({ _tag: "freeform", width: 1024, height: 768 }, 1.25), + ).toEqual({ width: 1280, height: 960 }); + }); + + it("does not scale phone sizes; mobile emulation pins page zoom to 1", () => { + expect( + previewGuestViewportOverride( + { _tag: "preset", presetId: "iphone-12-pro", width: 390, height: 844 }, + 1.25, + ), + ).toEqual({ width: 390, height: 844 }); + expect( + previewGuestViewportOverride({ _tag: "freeform", width: 844, height: 390 }, 1.25), + ).toEqual({ width: 844, height: 390 }); + }); }); describe("applyPreviewGuestViewport", () => { @@ -25,11 +43,16 @@ describe("applyPreviewGuestViewport", () => { await applyPreviewGuestViewport(undefined, "tab-1", { _tag: "fill" }); const setViewport = vi.fn(async () => undefined); - await applyPreviewGuestViewport(setViewport, "tab-1", { - _tag: "freeform", - width: 800, - height: 600, - }); - expect(setViewport).toHaveBeenCalledWith("tab-1", { width: 800, height: 600 }); + await applyPreviewGuestViewport( + setViewport, + "tab-1", + { + _tag: "freeform", + width: 1024, + height: 768, + }, + 1.25, + ); + expect(setViewport).toHaveBeenCalledWith("tab-1", { width: 1280, height: 960 }); }); }); diff --git a/apps/web/src/components/preview/previewGuestViewport.ts b/apps/web/src/components/preview/previewGuestViewport.ts index 26c69912c717..91c86e31976e 100644 --- a/apps/web/src/components/preview/previewGuestViewport.ts +++ b/apps/web/src/components/preview/previewGuestViewport.ts @@ -9,13 +9,31 @@ export type PreviewGuestViewportApplier = ( input: PreviewGuestViewportOverride, ) => Promise; +/** Keep in sync with PreviewManager.deviceMetricsOverride. */ +const PREVIEW_GUEST_MOBILE_MAX_SHORTEST_SIDE = 768; + +const normalizeZoomFactor = (zoomFactor: number): number => + Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + +/** Shortest side, so landscape phones stay mobile (844x390, not width-only). */ +export function previewGuestViewportIsMobile(width: number, height: number): boolean { + return Math.min(width, height) < PREVIEW_GUEST_MOBILE_MAX_SHORTEST_SIDE; +} + /** Maps a stored viewport setting onto the desktop CDP override. */ export function previewGuestViewportOverride( setting: PreviewViewportSetting, + zoomFactor = 1, ): PreviewGuestViewportOverride { - return setting._tag === "fill" - ? { clear: true } - : { width: setting.width, height: setting.height }; + if (setting._tag === "fill") return { clear: true }; + const zoom = normalizeZoomFactor(zoomFactor); + // The override is a widget DIP size; page zoom still divides it. Mobile + // emulation pins page zoom to 1, so those sizes stay in CSS pixels. + const scale = previewGuestViewportIsMobile(setting.width, setting.height) ? 1 : zoom; + return { + width: Math.max(1, Math.round(setting.width * scale)), + height: Math.max(1, Math.round(setting.height * scale)), + }; } /** Applies or clears the guest CDP metrics override. No-op on older desktops. */ @@ -23,7 +41,8 @@ export async function applyPreviewGuestViewport( setViewport: PreviewGuestViewportApplier | undefined, tabId: string, setting: PreviewViewportSetting, + zoomFactor = 1, ): Promise { if (!setViewport) return; - await setViewport(tabId, previewGuestViewportOverride(setting)); + await setViewport(tabId, previewGuestViewportOverride(setting, zoomFactor)); } From 79fa0bd6ecc6a83e8dd4fa3355e7f80d1140f06e Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sun, 23 Aug 2026 09:56:39 +0200 Subject: [PATCH 10/10] fix(preview): keep store and guest matched on resize rollback Apply-failure never put the new override on the guest, so the store still rolls back even if the guest CDP clear throws. Timeout still rolls the guest first and only persists previous if that apply succeeds; a failed store resize puts the guest back on the requested size. --- .../preview/PreviewAutomationHosts.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 0507e23816ba..d9cc75146df9 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -502,6 +502,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const rollbackGuestIfCurrent = async ( previousSetting: PreviewViewportSetting, operationServerEpoch: string | null, + guestHasRequestedOverride: boolean, ) => { const latestState = readThreadPreviewState(threadRef); const latestSetting = @@ -522,16 +523,18 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } catch { return; } + const zoomFactor = latestState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1; + let guestRolledBack = !guestHasRequestedOverride; try { await applyPreviewGuestViewport( setViewport, ready.runtimeTabId, previousSetting, - latestState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1, + zoomFactor, ); + guestRolledBack = true; } catch { - // Guest still has the requested override; leave the store matching it. - return; + if (guestHasRequestedOverride) return; } const rollback = await resize({ environmentId, @@ -543,6 +546,15 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }); if (rollback._tag !== "Failure") { updatePreviewServerSnapshot(threadRef, rollback.value); + return; + } + if (guestHasRequestedOverride && guestRolledBack) { + await applyPreviewGuestViewport( + setViewport, + ready.runtimeTabId, + setting, + zoomFactor, + ).catch(() => undefined); } }; const persistViewport = async () => { @@ -574,7 +586,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) operationState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1, ); } catch (error) { - await rollbackGuestIfCurrent(previousSetting, operationState.serverEpoch); + await rollbackGuestIfCurrent(previousSetting, operationState.serverEpoch, false); throw error; } return { @@ -600,7 +612,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ); } catch (cause) { await runBrowserViewportMutation(ready.runtimeTabId, async () => { - await rollbackGuestIfCurrent(applied.previousSetting, applied.serverEpoch); + await rollbackGuestIfCurrent(applied.previousSetting, applied.serverEpoch, true); }); throw cause; }