diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index c4ef82ec8cb7..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"; @@ -77,6 +78,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..d4075e2af575 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, @@ -163,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", @@ -299,6 +312,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, @@ -383,6 +409,7 @@ export const methods = [ hardReload, setColorScheme, setAudioMuted, + setViewport, openDevTools, clearCookies, clearCache, @@ -397,6 +424,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..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), @@ -236,6 +241,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.test.ts b/apps/desktop/src/preview/Manager.test.ts index 3bf6d63051af..997694d5f946 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1554,6 +1554,124 @@ 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", { width: 844, height: 390 }); + yield* manager.setViewport("tab_viewport", { clear: true }); + + expect(sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { + width: 390, + height: 844, + 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"); + }), + ), + ); + + 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 0d90e0175fe3..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* ( @@ -2446,6 +2459,64 @@ 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 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, + 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", + deviceMetricsOverride(input), + ), + ); + }); + + // 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* rememberViewportOverride(tabId, input); + yield* applyViewportOverride(tabId, wc, input); + }); + + 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* rememberViewportOverride(tabId, input); + yield* withControlSession(tabId, wc, "resize", (send) => + "clear" in input + ? send("Emulation.clearDeviceMetricsOverride") + : send("Emulation.setDeviceMetricsOverride", deviceMetricsOverride(input)), + ); + }); + const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( tabId: string, ) { @@ -3746,6 +3817,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setAnnotationTheme, setAudioMuted, setColorScheme, + setViewport, + automationSetViewport, setMainWindow, startRecording, closePictureInPicture, @@ -4062,6 +4135,14 @@ 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 }, + ) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: () => Effect.Effect; readonly clearCache: () => Effect.Effect; @@ -4161,6 +4242,8 @@ 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* () { yield* browserSession diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index ae0526abb15f..ab7f64d7b8cc 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -5,8 +5,10 @@ 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"; +import { useThreadPreviewState } from "~/previewStateStore"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; import { @@ -55,6 +57,8 @@ export function HostedBrowserWebview(props: { const tabLeaseRef = useRef(null); 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( @@ -71,6 +75,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; @@ -114,6 +120,16 @@ 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, + guestZoomRef.current, + ).catch(() => undefined); + } } } catch { // did-attach/dom-ready will retry if the guest was not ready yet. @@ -149,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 = @@ -191,6 +208,21 @@ export function HostedBrowserWebview(props: { deviceToolbarVisible, aspectRatio: lockedAspectRatio, }); + const guestViewportKey = browserViewportSettingKey(effectiveViewport); + guestViewportRef.current = effectiveViewport; + useEffect(() => { + const setViewport = previewBridge?.setViewport; + if (!setViewport || !hasWebContents) return; + const frame = window.requestAnimationFrame(() => { + void applyPreviewGuestViewport( + setViewport, + runtimeTabId, + guestViewportRef.current, + guestZoomRef.current, + ).catch(() => undefined); + }); + return () => window.cancelAnimationFrame(frame); + }, [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 acf7e52e3039..d9cc75146df9 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, @@ -497,7 +498,66 @@ 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 rollbackGuestIfCurrent = async ( + previousSetting: PreviewViewportSetting, + operationServerEpoch: string | null, + guestHasRequestedOverride: boolean, + ) => { + 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; + } + const zoomFactor = latestState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1; + let guestRolledBack = !guestHasRequestedOverride; + try { + await applyPreviewGuestViewport( + setViewport, + ready.runtimeTabId, + previousSetting, + zoomFactor, + ); + guestRolledBack = true; + } catch { + if (guestHasRequestedOverride) return; + } + const rollback = await resize({ + environmentId, + input: { + threadId: request.threadId, + tabId: ready.tabId, + viewport: previousSetting, + }, + }); + if (rollback._tag !== "Failure") { + updatePreviewServerSnapshot(threadRef, rollback.value); + return; + } + if (guestHasRequestedOverride && guestRolledBack) { + await applyPreviewGuestViewport( + setViewport, + ready.runtimeTabId, + setting, + zoomFactor, + ).catch(() => undefined); + } + }; + const persistViewport = async () => { const operationState = assertPreviewRuntimeCurrent( threadRef, ready.tabId, @@ -518,11 +578,23 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) return raiseAtomCommandFailure(result); } updatePreviewServerSnapshot(threadRef, result.value); + try { + await applyPreviewGuestViewport( + setViewport, + ready.runtimeTabId, + setting, + operationState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1, + ); + } catch (error) { + await rollbackGuestIfCurrent(previousSetting, operationState.serverEpoch, false); + throw error; + } return { previousSetting, serverEpoch: operationState.serverEpoch, }; - }); + }; + const applied = await runBrowserViewportMutation(ready.runtimeTabId, persistViewport); let viewport: PreviewRenderedViewportSize; try { viewport = await waitForRenderedViewport( @@ -540,30 +612,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 rollbackGuestIfCurrent(applied.previousSetting, applied.serverEpoch, true); }); throw cause; } 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..45f4ac9038ec --- /dev/null +++ b/apps/web/src/components/preview/previewGuestViewport.test.ts @@ -0,0 +1,58 @@ +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 }); + }); + + 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", () => { + 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: 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 new file mode 100644 index 000000000000..91c86e31976e --- /dev/null +++ b/apps/web/src/components/preview/previewGuestViewport.ts @@ -0,0 +1,48 @@ +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; + +/** 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 { + 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. */ +export async function applyPreviewGuestViewport( + setViewport: PreviewGuestViewportApplier | undefined, + tabId: string, + setting: PreviewViewportSetting, + zoomFactor = 1, +): Promise { + if (!setViewport) return; + await setViewport(tabId, previewGuestViewportOverride(setting, zoomFactor)); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e753596f3d33..b4c63daef596 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)), @@ -1176,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). */ @@ -1219,6 +1240,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({