From 98ba0ce755c0c16eec52b9d3e1bb6bfbaa431faa Mon Sep 17 00:00:00 2001 From: Shantanu Ojha Date: Sat, 14 Nov 2026 01:04:11 +0530 Subject: [PATCH 1/2] fix(desktop): capture the whole oversized preview viewport --- apps/web/src/browser/HostedBrowserWebview.tsx | 3 + .../browser/hostedBrowserWebviewStyle.test.ts | 93 +++++++++++++++++++ .../src/browser/hostedBrowserWebviewStyle.ts | 12 ++- 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/browser/hostedBrowserWebviewStyle.test.ts diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 28edc42d406a8..e95b557eb6c3a 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -13,6 +13,7 @@ import { } from "~/components/preview/thumbButtons"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { useClientSettingsHydrated } from "~/hooks/useSettings"; +import { useViewportSize } from "~/hooks/useViewportSize"; import { cn, isMacPlatform } from "~/lib/utils"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; @@ -79,6 +80,7 @@ export function HostedBrowserWebview(props: { serverRendering, } = props; const clientSettingsHydrated = useClientSettingsHydrated(); + const windowSize = useViewportSize(); const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); @@ -331,6 +333,7 @@ export function HostedBrowserWebview(props: { zIndex: presentation.zIndex, rect: lastRect, hiddenSize: { width: hiddenWidth, height: hiddenHeight }, + windowSize, }); return ( diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts new file mode 100644 index 0000000000000..829211a9a1620 --- /dev/null +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -0,0 +1,93 @@ +import { expect, it } from "vite-plus/test"; + +import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; + +it.each([1, 2])("refits every background capture edge at layout zoom %i", (zoom) => { + for (const viewport of [ + { width: 390, height: 600 }, + { width: 1440, height: 900 }, + { width: 1920, height: 1080 }, + { width: 2560, height: 1440 }, + { width: 3440, height: 1440 }, + { width: 390, height: 600 }, + ]) { + const hiddenSize = { width: viewport.width * zoom, height: viewport.height * zoom }; + for (const windowSize of [ + { width: 1840, height: 1016 }, + { width: 1000, height: 668 }, + { width: 1840, height: 1016 }, + ]) { + const input = { active: false, renderingActive: true, rect: null, hiddenSize, windowSize }; + const style = resolveHostedBrowserWebviewWrapperStyle(input); + const transform = style.transform; + const scale = + transform === undefined ? 1 : Number(String(transform).match(/^scale\((.+)\)$/)?.[1]); + + expect({ width: style.width, height: style.height }).toEqual(hiddenSize); + expect(scale).toBeGreaterThan(0); + expect(scale).toBeLessThanOrEqual(1); + for (const [x, y] of [ + [0, 0], + [style.width, 0], + [0, style.height], + [style.width, style.height], + ] as const) { + expect(style.left + x * scale).toBeGreaterThanOrEqual(0); + expect(style.top + y * scale).toBeGreaterThanOrEqual(0); + expect(style.left + x * scale).toBeLessThanOrEqual(windowSize.width + 1e-9); + expect(style.top + y * scale).toBeLessThanOrEqual(windowSize.height + 1e-9); + } + expect((style.width * scale) / (style.height * scale)).toBeCloseTo( + hiddenSize.width / hiddenSize.height, + ); + if (scale < 1) { + expect(style).toMatchObject({ transformOrigin: "top left" }); + expect( + Math.max( + (style.width * scale) / windowSize.width, + (style.height * scale) / windowSize.height, + ), + ).toBeCloseTo(1); + } + expect(style).toMatchObject({ zIndex: -1, pointerEvents: "none", visibility: "visible" }); + } + } +}); + +it("releases the capture transform when a tab is shown or suspended", () => { + const input = { + active: false, + renderingActive: true, + rect: { x: 120, y: 80, width: 600, height: 400 }, + hiddenSize: { width: 3440, height: 1440 }, + windowSize: { width: 1000, height: 668 }, + cornerRadius: 8, + zIndex: 40, + }; + expect(resolveHostedBrowserWebviewWrapperStyle(input)).toHaveProperty("transform"); + expect(resolveHostedBrowserWebviewWrapperStyle({ ...input, active: true })).toEqual({ + left: 120, + top: 80, + width: 600, + height: 400, + zIndex: 40, + pointerEvents: "auto", + borderRadius: 8, + }); + for (const keepPaintableWhenInactive of [false, true]) { + const suspended = resolveHostedBrowserWebviewWrapperStyle({ + ...input, + renderingActive: false, + keepPaintableWhenInactive, + }); + expect(suspended).not.toHaveProperty("transform"); + expect(suspended).toMatchObject({ + width: 3440, + height: 1440, + pointerEvents: "none", + visibility: keepPaintableWhenInactive ? "visible" : "hidden", + }); + expect(suspended.left + suspended.width).toBeLessThan(0); + expect(suspended.top + suspended.height).toBeLessThan(0); + } +}); diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index f37c222b08e9c..a12a052b280ed 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -14,6 +14,8 @@ export interface HostedBrowserWebviewWrapperStyle { readonly pointerEvents: "auto" | "none"; readonly borderRadius?: number; readonly visibility?: "hidden" | "visible"; + readonly transform?: string; + readonly transformOrigin?: "top left"; } const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; @@ -26,6 +28,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly zIndex?: number; readonly rect: BrowserSurfaceRect | null; readonly hiddenSize: HostedBrowserWebviewSize; + readonly windowSize: HostedBrowserWebviewSize; }): HostedBrowserWebviewWrapperStyle { const { active, @@ -35,6 +38,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { rect, renderingActive, zIndex = 30, + windowSize, } = input; if (active && rect) { return { @@ -49,7 +53,12 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { } if (renderingActive) { - // Electron stops compositing offscreen guests; keep capture-active guests inside the viewport behind the app. + // Electron clips oversized native surfaces; fit the whole guest without changing its DOM viewport. + const scale = Math.min( + 1, + windowSize.width / hiddenSize.width, + windowSize.height / hiddenSize.height, + ); return { left: 0, top: 0, @@ -58,6 +67,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { zIndex: -1, pointerEvents: "none", visibility: "visible", + ...(scale < 1 ? { transform: `scale(${scale})`, transformOrigin: "top left" as const } : {}), }; } From dfcfd1a26622643714e00119a3d3122dadd96e15 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:40:15 +0000 Subject: [PATCH 2/2] fix(desktop): keep a minimized window's capture guest unscaled A zero viewport would scale the guest to nothing. Trim the geometry matrix to a table of exact fits. Co-Authored-By: Claude Opus 5.5 --- .../browser/hostedBrowserWebviewStyle.test.ts | 113 ++++-------------- .../src/browser/hostedBrowserWebviewStyle.ts | 10 +- 2 files changed, 31 insertions(+), 92 deletions(-) diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts index 829211a9a1620..4f676dc6e6cf2 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -2,92 +2,31 @@ import { expect, it } from "vite-plus/test"; import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; -it.each([1, 2])("refits every background capture edge at layout zoom %i", (zoom) => { - for (const viewport of [ - { width: 390, height: 600 }, - { width: 1440, height: 900 }, - { width: 1920, height: 1080 }, - { width: 2560, height: 1440 }, - { width: 3440, height: 1440 }, - { width: 390, height: 600 }, - ]) { - const hiddenSize = { width: viewport.width * zoom, height: viewport.height * zoom }; - for (const windowSize of [ - { width: 1840, height: 1016 }, - { width: 1000, height: 668 }, - { width: 1840, height: 1016 }, - ]) { - const input = { active: false, renderingActive: true, rect: null, hiddenSize, windowSize }; - const style = resolveHostedBrowserWebviewWrapperStyle(input); - const transform = style.transform; - const scale = - transform === undefined ? 1 : Number(String(transform).match(/^scale\((.+)\)$/)?.[1]); - - expect({ width: style.width, height: style.height }).toEqual(hiddenSize); - expect(scale).toBeGreaterThan(0); - expect(scale).toBeLessThanOrEqual(1); - for (const [x, y] of [ - [0, 0], - [style.width, 0], - [0, style.height], - [style.width, style.height], - ] as const) { - expect(style.left + x * scale).toBeGreaterThanOrEqual(0); - expect(style.top + y * scale).toBeGreaterThanOrEqual(0); - expect(style.left + x * scale).toBeLessThanOrEqual(windowSize.width + 1e-9); - expect(style.top + y * scale).toBeLessThanOrEqual(windowSize.height + 1e-9); - } - expect((style.width * scale) / (style.height * scale)).toBeCloseTo( - hiddenSize.width / hiddenSize.height, - ); - if (scale < 1) { - expect(style).toMatchObject({ transformOrigin: "top left" }); - expect( - Math.max( - (style.width * scale) / windowSize.width, - (style.height * scale) / windowSize.height, - ), - ).toBeCloseTo(1); - } - expect(style).toMatchObject({ zIndex: -1, pointerEvents: "none", visibility: "visible" }); - } - } -}); - -it("releases the capture transform when a tab is shown or suspended", () => { - const input = { - active: false, - renderingActive: true, - rect: { x: 120, y: 80, width: 600, height: 400 }, +it.each([ + { hiddenSize: { width: 1440, height: 900 }, windowSize: { width: 1840, height: 1016 }, scale: 1 }, + { hiddenSize: { width: 3440, height: 1440 }, - windowSize: { width: 1000, height: 668 }, - cornerRadius: 8, - zIndex: 40, - }; - expect(resolveHostedBrowserWebviewWrapperStyle(input)).toHaveProperty("transform"); - expect(resolveHostedBrowserWebviewWrapperStyle({ ...input, active: true })).toEqual({ - left: 120, - top: 80, - width: 600, - height: 400, - zIndex: 40, - pointerEvents: "auto", - borderRadius: 8, - }); - for (const keepPaintableWhenInactive of [false, true]) { - const suspended = resolveHostedBrowserWebviewWrapperStyle({ - ...input, - renderingActive: false, - keepPaintableWhenInactive, - }); - expect(suspended).not.toHaveProperty("transform"); - expect(suspended).toMatchObject({ - width: 3440, - height: 1440, - pointerEvents: "none", - visibility: keepPaintableWhenInactive ? "visible" : "hidden", + windowSize: { width: 1720, height: 1016 }, + scale: 0.5, + }, + { + hiddenSize: { width: 1000, height: 2000 }, + windowSize: { width: 1840, height: 500 }, + scale: 0.25, + }, + { hiddenSize: { width: 3440, height: 1440 }, windowSize: { width: 0, height: 0 }, scale: 1 }, +])( + "fits a $hiddenSize.width x $hiddenSize.height capture guest in a $windowSize.width x $windowSize.height window", + ({ hiddenSize, windowSize, scale }) => { + const style = resolveHostedBrowserWebviewWrapperStyle({ + active: false, + renderingActive: true, + rect: null, + hiddenSize, + windowSize, }); - expect(suspended.left + suspended.width).toBeLessThan(0); - expect(suspended.top + suspended.height).toBeLessThan(0); - } -}); + expect(style).toMatchObject({ left: 0, top: 0, ...hiddenSize, visibility: "visible" }); + expect(style.transform).toBe(scale < 1 ? `scale(${scale})` : undefined); + expect(style.transformOrigin).toBe(scale < 1 ? "top left" : undefined); + }, +); diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index a12a052b280ed..c4a31c6c56900 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -54,11 +54,11 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { if (renderingActive) { // Electron clips oversized native surfaces; fit the whole guest without changing its DOM viewport. - const scale = Math.min( - 1, - windowSize.width / hiddenSize.width, - windowSize.height / hiddenSize.height, - ); + // A minimized window can report a zero viewport; scale(0) would stop the guest compositing. + const scale = + windowSize.width > 0 && windowSize.height > 0 + ? Math.min(1, windowSize.width / hiddenSize.width, windowSize.height / hiddenSize.height) + : 1; return { left: 0, top: 0,