authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-27 20:08:28+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 20:08:28+02:00
log32af2f002468c320d40ab730b575dea872f1d86d
tree4273c6a6ee6a16d9b7c3a777f175c335a3ce2410
parentf0121f31d0e7c069f4007480fc13907c28efd4d5
signature Signed by PGP key B5690EEEBB952194

fix(preview): stabilize PiP viewport identity (#4661)

Co-authored-by: codex <codex@users.noreply.github.com>

29 files changed, 938 insertions(+), 235 deletions(-)

apps/desktop/src/preview/Manager.test.ts+12-4
......@@ -22,9 +22,17 @@ import * as BrowserSession from "./BrowserSession.ts";
2222import * as PreviewManager from "./Manager.ts";
2323
2424describe("fitPictureInPictureContentSize", () => {
25 it("fits landscape and portrait viewports without letterboxing the window", () => {
26 expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 16 / 9)).toEqual([480, 270]);
27 expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 9 / 16)).toEqual([240, 427]);
25 it("preserves the PiP content area across aspect-ratio changes", () => {
26 expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 16 / 9)).toEqual([523, 294]);
27 expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 9 / 16)).toEqual([294, 523]);
28 });
29
30 it("does not collapse toward the minimum size when orientation changes repeatedly", () => {
31 const portrait = PreviewManager.fitPictureInPictureContentSize([523, 294], 9 / 16);
32 const landscape = PreviewManager.fitPictureInPictureContentSize(portrait, 16 / 9);
33
34 expect(portrait).toEqual([294, 523]);
35 expect(landscape).toEqual([523, 294]);
2836 });
2937});
3038
......@@ -1065,7 +1073,7 @@ describe("PreviewManager", () => {
10651073 skipTransformProcessType: true,
10661074 });
10671075 expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]);
1068 expect(pictureInPictureWindow.setContentSize).toHaveBeenCalledWith(480, 270, false);
1076 expect(pictureInPictureWindow.setContentSize).toHaveBeenCalledWith(523, 294, false);
10691077 expect(pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[0]).toBeLessThan(
10701078 pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0] ?? 0,
10711079 );
apps/desktop/src/preview/Manager.ts+7-3
......@@ -104,6 +104,7 @@ const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480;
104104const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;
105105const PICTURE_IN_PICTURE_MIN_WIDTH = 240;
106106const PICTURE_IN_PICTURE_MIN_HEIGHT = 160;
107const PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON = 0.002;
107108const DIAGNOSTIC_BUFFER_LIMIT = 200;
108109const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;
109110const AGENT_CURSOR_MOVE_MS = 160;
......@@ -164,8 +165,8 @@ export const fitPictureInPictureContentSize = (
164165): readonly [width: number, height: number] => {
165166 const currentWidth = Math.max(1, current[0] ?? PICTURE_IN_PICTURE_INITIAL_WIDTH);
166167 const currentHeight = Math.max(1, current[1] ?? PICTURE_IN_PICTURE_INITIAL_HEIGHT);
167 let width =
168 currentWidth / currentHeight > aspectRatio ? currentHeight * aspectRatio : currentWidth;
168 const currentArea = currentWidth * currentHeight;
169 let width = Math.sqrt(currentArea * aspectRatio);
169170 let height = width / aspectRatio;
170171 const minimumScale = Math.max(
171172 1,
......@@ -2074,7 +2075,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20742075 tabId,
20752076 );
20762077 const aspectRatio = frame.width / frame.height;
2077 if (previousAspectRatio !== aspectRatio) {
2078 if (
2079 previousAspectRatio === undefined ||
2080 Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON
2081 ) {
20782082 yield* attempt(
20792083 {
20802084 operation: "pictureInPicture.setAspectRatio",
apps/web/src/browser/ElectronBrowserHost.tsx+9-2
......@@ -11,6 +11,7 @@ import { useActivePreviewSessions } from "~/previewStateStore";
1111import { readPreviewAnnotationTheme } from "./annotationTheme";
1212import { useBrowserPointerStore } from "./browserPointerStore";
1313import { HostedBrowserWebview } from "./HostedBrowserWebview";
14import { previewRuntimeTabId } from "./previewRuntimeTabId";
1415
1516export function ElectronBrowserHost() {
1617 const { resolvedTheme } = useTheme();
......@@ -23,6 +24,11 @@ export function ElectronBrowserHost() {
2324 ? Object.values(previewState.sessions).map((snapshot) => ({
2425 threadRef,
2526 snapshot,
27 runtimeTabId: previewRuntimeTabId(
28 threadRef,
29 previewState.serverEpoch,
30 snapshot.tabId,
31 ),
2632 zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,
2733 }))
2834 : [];
......@@ -74,13 +80,14 @@ export function ElectronBrowserHost() {
7480 if (!isElectron) return null;
7581 return (
7682 <div className="contents" data-electron-browser-host>
77 {sessions.map(({ threadRef, snapshot, zoomFactor }) => {
83 {sessions.map(({ threadRef, snapshot, runtimeTabId, zoomFactor }) => {
7884 const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
7985 return (
8086 <HostedBrowserWebview
81 key={snapshot.tabId}
87 key={runtimeTabId}
8288 threadRef={threadRef}
8389 tabId={snapshot.tabId}
90 runtimeTabId={runtimeTabId}
8491 initialUrl={url}
8592 viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}
8693 zoomFactor={zoomFactor}
apps/web/src/browser/HostedBrowserWebview.tsx+37-41
......@@ -9,7 +9,11 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
99import { cn } from "~/lib/utils";
1010
1111import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
12import { browserViewportSettingKey, resolveBrowserViewportLayout } from "./browserViewportLayout";
12import {
13 browserViewportSettingKey,
14 resolveBrowserViewportLayout,
15 resolveFittedBrowserViewport,
16} from "./browserViewportLayout";
1317import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";
1418import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
1519import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";
......@@ -40,11 +44,12 @@ declare global {
4044export function HostedBrowserWebview(props: {
4145 readonly threadRef: ScopedThreadRef;
4246 readonly tabId: string;
47 readonly runtimeTabId: string;
4348 readonly initialUrl: string | null;
4449 readonly viewport: PreviewViewportSetting;
4550 readonly zoomFactor: number;
4651}) {
47 const { threadRef, tabId, initialUrl, viewport, zoomFactor } = props;
52 const { threadRef, tabId, runtimeTabId, initialUrl, viewport, zoomFactor } = props;
4853 const config = usePreviewWebviewConfig(threadRef.environmentId);
4954 const [initialSrc] = useState(() => initialUrl ?? "about:blank");
5055 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
......@@ -54,27 +59,28 @@ export function HostedBrowserWebview(props: {
5459 const [aspectRatioLocked, setAspectRatioLocked] = useState(false);
5560 const presentation = useBrowserSurfaceStore(
5661 useShallow((state) => {
57 const current = state.byTabId[tabId];
62 const current = state.byTabId[runtimeTabId];
5863 return {
64 content: current?.content ?? null,
5965 cornerRadius: current?.cornerRadius ?? 0,
6066 fitSourceContent: current?.fitSourceContent ?? false,
6167 fittedSourceContent: current?.fittedSourceContent ?? null,
62 rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId),
68 rect: resolveBrowserSurfacePanelRect(state.byTabId, runtimeTabId),
6369 visible: current?.visible ?? false,
6470 };
6571 }),
6672 );
67 usePreviewBridge({ threadRef, tabId });
73 usePreviewBridge({ threadRef, tabId, runtimeTabId });
6874
6975 useEffect(() => {
7076 crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE;
71 const lease = acquireDesktopTab(tabId);
77 const lease = acquireDesktopTab(runtimeTabId);
7278 tabLeaseRef.current = lease;
7379 return () => {
7480 if (tabLeaseRef.current === lease) tabLeaseRef.current = null;
7581 lease.release();
7682 };
77 }, [tabId]);
83 }, [runtimeTabId]);
7884
7985 const [webviewGeneration, setWebviewGeneration] = useState(0);
8086 const [recoverySrc, setRecoverySrc] = useState(initialSrc);
......@@ -107,7 +113,7 @@ export function HostedBrowserWebview(props: {
107113 if (disposed || webviewRef.current !== webview) return;
108114 const webContentsId = webview.getWebContentsId();
109115 if (Number.isInteger(webContentsId) && webContentsId > 0) {
110 await bridge.registerWebview(tabId, webContentsId);
116 await bridge.registerWebview(runtimeTabId, webContentsId);
111117 }
112118 } catch {
113119 // did-attach/dom-ready will retry if the guest was not ready yet.
......@@ -138,7 +144,7 @@ export function HostedBrowserWebview(props: {
138144 webview.removeEventListener("dom-ready", register);
139145 webview.removeEventListener("render-process-gone", recoverGuest);
140146 };
141 }, [config, initialSrc, tabId, webviewGeneration]);
147 }, [config, initialSrc, runtimeTabId, webviewGeneration]);
142148
143149 const active = presentation.visible && presentation.rect !== null;
144150 const lastRect = presentation.rect;
......@@ -152,13 +158,22 @@ export function HostedBrowserWebview(props: {
152158 const handleAspectRatioChange = useCallback((aspectRatio: number | null) => {
153159 setAspectRatioLocked(aspectRatio !== null);
154160 }, []);
161 const hiddenContentSize = presentation.content
162 ? {
163 width: presentation.content.width / presentation.content.scale,
164 height: presentation.content.height / presentation.content.scale,
165 }
166 : null;
155167 const hiddenSize =
156168 viewport._tag !== "fill"
157169 ? {
158170 width: viewport.width * normalizedZoomFactor,
159171 height: viewport.height * normalizedZoomFactor,
160172 }
161 : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 };
173 : {
174 width: hiddenContentSize?.width ?? lastRect?.width ?? 1280,
175 height: hiddenContentSize?.height ?? lastRect?.height ?? 800,
176 };
162177 const containerSize = active && lastRect ? lastRect : hiddenSize;
163178 const deviceToolbarVisible = active && viewport._tag !== "fill" && !presentation.fitSourceContent;
164179 const {
......@@ -169,7 +184,7 @@ export function HostedBrowserWebview(props: {
169184 handleResizePointerDown,
170185 layout: viewportLayout,
171186 } = useBrowserViewportResize({
172 tabId,
187 tabId: runtimeTabId,
173188 viewport,
174189 zoomFactor,
175190 containerSize,
......@@ -178,31 +193,11 @@ export function HostedBrowserWebview(props: {
178193 });
179194 const fittedSourceViewport =
180195 presentation.fitSourceContent && lastRect
181 ? presentation.fittedSourceContent
182 ? {
183 _tag: "freeform" as const,
184 width: Math.max(
185 1,
186 Math.round(
187 presentation.fittedSourceContent.width /
188 presentation.fittedSourceContent.scale /
189 normalizedZoomFactor,
190 ),
191 ),
192 height: Math.max(
193 1,
194 Math.round(
195 presentation.fittedSourceContent.height /
196 presentation.fittedSourceContent.scale /
197 normalizedZoomFactor,
198 ),
199 ),
200 }
201 : {
202 _tag: "freeform" as const,
203 width: viewport._tag === "fill" ? 1280 : viewport.width,
204 height: viewport._tag === "fill" ? 800 : viewport.height,
205 }
196 ? resolveFittedBrowserViewport(
197 viewport,
198 presentation.fittedSourceContent,
199 normalizedZoomFactor,
200 )
206201 : null;
207202 const layout =
208203 fittedSourceViewport && lastRect
......@@ -212,7 +207,7 @@ export function HostedBrowserWebview(props: {
212207 const syncContentPresentation = useCallback(() => {
213208 const wrapper = wrapperRef.current;
214209 if (!wrapper) return;
215 useBrowserSurfaceStore.getState().presentContent(tabId, {
210 useBrowserSurfaceStore.getState().presentContent(runtimeTabId, {
216211 x: layout.viewportX,
217212 y: layout.viewportY,
218213 width: layout.viewportWidth,
......@@ -221,7 +216,7 @@ export function HostedBrowserWebview(props: {
221216 scrollLeft: wrapper.scrollLeft,
222217 scrollTop: wrapper.scrollTop,
223218 });
224 }, [layout, tabId]);
219 }, [layout, runtimeTabId]);
225220
226221 useEffect(() => {
227222 const frameId = window.requestAnimationFrame(syncContentPresentation);
......@@ -232,7 +227,7 @@ export function HostedBrowserWebview(props: {
232227 const wrapper = wrapperRef.current;
233228 if (!wrapper) return;
234229 wrapper.scrollTo({ left: 0, top: 0 });
235 }, [tabId, viewport._tag, viewportHeight, viewportWidth]);
230 }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]);
236231
237232 if (!config) return null;
238233
......@@ -249,7 +244,7 @@ export function HostedBrowserWebview(props: {
249244 className="fixed overflow-hidden bg-muted/35"
250245 style={{ ...wrapperStyle, overscrollBehavior: "contain" }}
251246 onScroll={syncContentPresentation}
252 data-preview-viewport={tabId}
247 data-preview-viewport={runtimeTabId}
253248 >
254249 <div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}>
255250 {deviceToolbarVisible && effectiveViewport._tag !== "fill" ? (
......@@ -268,7 +263,8 @@ export function HostedBrowserWebview(props: {
268263 partition={config.partition}
269264 webpreferences={config.webPreferences}
270265 {...(config.preloadUrl ? { preload: config.preloadUrl } : {})}
271 data-preview-tab={tabId}
266 data-preview-tab={runtimeTabId}
267 data-preview-server-tab={tabId}
272268 data-preview-viewport-mode={effectiveViewport._tag}
273269 data-preview-viewport-key={browserViewportSettingKey(effectiveViewport)}
274270 data-preview-css-width={
apps/web/src/browser/browserRecording.test.ts+43
......@@ -89,10 +89,13 @@ import {
8989 BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS,
9090 BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,
9191 BrowserRecordingConflictError,
92 findActiveBrowserRecordingRuntimeTabId,
9293 readActiveBrowserRecordingTabIds,
94 readActiveBrowserRecordingTargets,
9395 startBrowserRecording,
9496 stopBrowserRecording,
9597} from "./browserRecording";
98import { previewRuntimeTabId } from "./previewRuntimeTabId";
9699
97100class FakeMediaRecorder {
98101 static isTypeSupported(): boolean {
......@@ -363,6 +366,46 @@ describe("browser recording", () => {
363366 expect(save).toHaveBeenCalledTimes(2);
364367 });
365368
369 it("keeps a recording reachable through its runtime id after a server epoch changes", async () => {
370 const threadRef = {
371 environmentId: EnvironmentId.make("environment-recording"),
372 threadId: ThreadId.make("thread-recording-scoped"),
373 };
374 const runtimeTabId = previewRuntimeTabId(threadRef, "epoch-a", "tab_1");
375 surfaceState.byTabId = {
376 [runtimeTabId]: {
377 visible: false,
378 rect: { x: 0, y: 0, width: 1280, height: 800 },
379 content: {
380 x: 0,
381 y: 0,
382 width: 1280,
383 height: 800,
384 scale: 1,
385 scrollLeft: 0,
386 scrollTop: 0,
387 },
388 },
389 };
390
391 await startBrowserRecording(runtimeTabId, threadRef, "tab_1");
392
393 expect(startScreencast).toHaveBeenCalledWith(runtimeTabId);
394 expect(readActiveBrowserRecordingTabIds(threadRef)).toEqual(new Set([runtimeTabId]));
395 expect(readActiveBrowserRecordingTargets(threadRef)).toEqual([
396 { runtimeTabId, serverTabId: "tab_1" },
397 ]);
398 expect(findActiveBrowserRecordingRuntimeTabId(threadRef, "tab_1")).toBe(runtimeTabId);
399
400 const replacementRuntimeTabId = previewRuntimeTabId(threadRef, "epoch-b", "tab_1");
401 await expect(
402 startBrowserRecording(replacementRuntimeTabId, threadRef, "tab_1"),
403 ).rejects.toBeInstanceOf(BrowserRecordingConflictError);
404 expect(startScreencast).toHaveBeenCalledTimes(1);
405
406 await stopBrowserRecording(runtimeTabId);
407 });
408
366409 it("does not report success for a second start while the first is still starting", async () => {
367410 let finishStartingScreencast: (() => void) | undefined;
368411 startScreencast.mockImplementationOnce(async (tabId: string) => {
apps/web/src/browser/browserRecording.ts+40
......@@ -82,7 +82,10 @@ type BrowserRecordingLifecycle =
8282 };
8383
8484interface ActiveRecording {
85 /** Desktop-scoped identity used by capture and surface stores. */
8586 readonly tabId: string;
87 /** Server-local identity returned by preview automation tools. */
88 readonly serverTabId: string;
8689 readonly threadRef: ScopedThreadRef | null;
8790 readonly canvas: HTMLCanvasElement;
8891 readonly context: CanvasRenderingContext2D;
......@@ -99,6 +102,11 @@ interface ActiveRecording {
99102 lifecycle: BrowserRecordingLifecycle;
100103}
101104
105export interface ActiveBrowserRecordingTarget {
106 readonly runtimeTabId: string;
107 readonly serverTabId: string;
108}
109
102110interface ActiveBrowserRecordingIndex {
103111 readonly tabIds: ReadonlySet<string>;
104112}
......@@ -131,6 +139,28 @@ export function readActiveBrowserRecordingTabIds(threadRef?: ScopedThreadRef): R
131139 return tabIds;
132140}
133141
142export function readActiveBrowserRecordingTargets(
143 threadRef: ScopedThreadRef,
144): ReadonlyArray<ActiveBrowserRecordingTarget> {
145 return Array.from(activeRecordings.values()).flatMap((recording) =>
146 recording.threadRef?.environmentId === threadRef.environmentId &&
147 recording.threadRef.threadId === threadRef.threadId
148 ? [{ runtimeTabId: recording.tabId, serverTabId: recording.serverTabId }]
149 : [],
150 );
151}
152
153export function findActiveBrowserRecordingRuntimeTabId(
154 threadRef: ScopedThreadRef,
155 serverTabId: string,
156): string | null {
157 return (
158 readActiveBrowserRecordingTargets(threadRef).find(
159 (recording) => recording.serverTabId === serverTabId,
160 )?.runtimeTabId ?? null
161 );
162}
163
134164const preferredMimeType = (): string => {
135165 const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"];
136166 return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm";
......@@ -283,6 +313,7 @@ const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperatio
283313export async function startBrowserRecording(
284314 tabId: string,
285315 threadRef: ScopedThreadRef | null = null,
316 serverTabId = tabId,
286317): Promise<string> {
287318 const bridge = previewBridge;
288319 if (!bridge) throw new BrowserRecordingUnavailableError({ tabId });
......@@ -296,6 +327,14 @@ export async function startBrowserRecording(
296327 activeTabId: activeRecording.tabId,
297328 });
298329 }
330 const activeLogicalRecording =
331 threadRef === null ? null : findActiveBrowserRecordingRuntimeTabId(threadRef, serverTabId);
332 if (activeLogicalRecording !== null) {
333 throw new BrowserRecordingConflictError({
334 requestedTabId: tabId,
335 activeTabId: activeLogicalRecording,
336 });
337 }
299338 const surface = useBrowserSurfaceStore.getState().byTabId[tabId];
300339 const recordingSize = surface?.content ?? surface?.rect;
301340 const canvas = document.createElement("canvas");
......@@ -321,6 +360,7 @@ export async function startBrowserRecording(
321360 });
322361 const recording: ActiveRecording = {
323362 tabId,
363 serverTabId,
324364 threadRef,
325365 canvas,
326366 context,
apps/web/src/browser/browserSurfaceStore.test.ts+2-2
......@@ -86,7 +86,7 @@ describe("browserSurfaceStore", () => {
8686 });
8787 });
8888
89 it("uses the live panel rect for a hidden background tab", () => {
89 it("keeps a hidden background tab on its own last rect", () => {
9090 const staleRect = { x: 0, y: 0, width: 500, height: 700 };
9191 const liveRect = { x: 10, y: 20, width: 900, height: 640 };
9292 expect(
......@@ -115,7 +115,7 @@ describe("browserSurfaceStore", () => {
115115 },
116116 "hidden",
117117 ),
118 ).toEqual(liveRect);
118 ).toEqual(staleRect);
119119 });
120120
121121 it("ignores updates and releases from a stale surface lease", () => {
apps/web/src/browser/browserSurfaceStore.ts+1-13
......@@ -52,19 +52,7 @@ export function resolveBrowserSurfacePanelRect(
5252 tabId: string,
5353): BrowserSurfaceRect | null {
5454 const current = byTabId[tabId];
55 if (current?.visible && current.rect) return current.rect;
56
57 let latestVisible: BrowserSurfacePresentation | undefined;
58 for (const presentation of Object.values(byTabId)) {
59 if (
60 presentation.visible &&
61 presentation.rect &&
62 (!latestVisible || presentation.updatedAt > latestVisible.updatedAt)
63 ) {
64 latestVisible = presentation;
65 }
66 }
67 return latestVisible?.rect ?? current?.rect ?? null;
55 return current?.rect ?? null;
6856}
6957
7058const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean =>
apps/web/src/browser/browserViewportActions.test.ts+28
......@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vite-plus/test";
44import {
55 BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS,
66 commitBrowserViewportChange,
7 runBrowserViewportMutation,
78 subscribeBrowserViewportChange,
89} from "./browserViewportActions";
910
......@@ -71,6 +72,33 @@ describe("browserViewportActions", () => {
7172 unsubscribe();
7273 });
7374
75 it("serializes background mutations with visible viewport commits", async () => {
76 let releaseBackground: (() => void) | undefined;
77 const backgroundPending = new Promise<void>((resolve) => {
78 releaseBackground = resolve;
79 });
80 const calls: string[] = [];
81 const background = runBrowserViewportMutation("tab-shared", async () => {
82 calls.push("background");
83 await backgroundPending;
84 });
85 const unsubscribe = subscribeBrowserViewportChange("tab-shared", async () => {
86 calls.push("visible");
87 });
88 const visible = commitBrowserViewportChange("tab-shared", {
89 _tag: "freeform",
90 width: 900,
91 height: 700,
92 });
93
94 await vi.waitFor(() => expect(calls).toEqual(["background"]));
95 releaseBackground?.();
96 await Promise.all([background, visible]);
97
98 expect(calls).toEqual(["background", "visible"]);
99 unsubscribe();
100 });
101
74102 it("does not let a timed-out handler overtake a newer viewport commit", async () => {
75103 vi.useFakeTimers();
76104 try {
apps/web/src/browser/browserViewportActions.ts+43-19
......@@ -15,6 +15,41 @@ export class BrowserViewportCommitTimeoutError extends Error {
1515const handlers = new Map<string, BrowserViewportHandler>();
1616const commitTails = new Map<string, Promise<void>>();
1717
18const queueBrowserViewportMutation = <A>(
19 tabId: string,
20 start: () => Promise<A>,
21): {
22 readonly started: Promise<{ readonly operation: Promise<A> }>;
23 readonly execution: Promise<A>;
24} => {
25 const previous = commitTails.get(tabId) ?? Promise.resolve();
26 const started = previous
27 .catch(() => undefined)
28 .then(() => ({
29 operation: Promise.resolve().then(start),
30 }));
31 const execution = started.then(({ operation }) => operation);
32 const tail = execution.then(() => undefined);
33 commitTails.set(tabId, tail);
34 const clear = () => {
35 if (commitTails.get(tabId) === tail) commitTails.delete(tabId);
36 };
37 void tail.then(clear, clear);
38 return { started, execution };
39};
40
41/**
42 * Serializes every server-side viewport mutation for one desktop runtime tab.
43 * Both visible UI commits and background automation use this queue so a
44 * compensating rollback cannot overtake a newer resize.
45 */
46export function runBrowserViewportMutation<A>(
47 tabId: string,
48 mutation: () => Promise<A>,
49): Promise<A> {
50 return queueBrowserViewportMutation(tabId, mutation).execution;
51}
52
1853const runHandlerWithTimeout = (tabId: string, operation: Promise<void>): Promise<void> => {
1954 let timeoutId: ReturnType<typeof setTimeout> | undefined;
2055 const timeout = new Promise<never>((_resolve, reject) => {
......@@ -42,25 +77,14 @@ export function commitBrowserViewportChange(
4277 tabId: string,
4378 setting: PreviewViewportSetting,
4479): Promise<void> {
45 const previous = commitTails.get(tabId) ?? Promise.resolve();
46 const started = previous
47 .catch(() => undefined)
48 .then(() => {
49 const handler = handlers.get(tabId);
50 const operation = handler
51 ? Promise.resolve().then(() => handler(setting))
52 : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`));
53 return { operation };
54 });
55 // The queue follows the real handler lifetime, not the caller-facing timeout.
56 // A slow commit therefore cannot time out, release the queue, and overwrite a
57 // newer viewport after that newer request has already completed.
58 const execution = started.then(({ operation }) => operation);
80 const { started } = queueBrowserViewportMutation(tabId, () => {
81 const handler = handlers.get(tabId);
82 return handler
83 ? handler(setting)
84 : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`));
85 });
86 // The queue follows the real handler lifetime, while the caller-facing
87 // timeout starts only once this commit reaches the front of that queue.
5988 const result = started.then(({ operation }) => runHandlerWithTimeout(tabId, operation));
60 commitTails.set(tabId, execution);
61 const clear = () => {
62 if (commitTails.get(tabId) === execution) commitTails.delete(tabId);
63 };
64 void execution.then(clear, clear);
6589 return result;
6690}
apps/web/src/browser/browserViewportLayout.test.ts+16
......@@ -4,11 +4,27 @@ import {
44 resizeBrowserViewportFromRail,
55 resizeFreeformViewport,
66 resolveBrowserDeviceViewportLayout,
7 resolveFittedBrowserViewport,
78 resolveBrowserViewportLayout,
89 resolveResponsiveBrowserViewportSize,
910} from "./browserViewportLayout";
1011
1112describe("resolveBrowserViewportLayout", () => {
13 it("uses the current fixed viewport instead of stale fitted source dimensions", () => {
14 expect(
15 resolveFittedBrowserViewport(
16 { _tag: "freeform", width: 900, height: 600 },
17 { width: 1280, height: 800, scale: 1 },
18 ),
19 ).toEqual({ _tag: "freeform", width: 900, height: 600 });
20 });
21
22 it("preserves the last logical viewport when fitting a fill-mode surface", () => {
23 expect(
24 resolveFittedBrowserViewport({ _tag: "fill" }, { width: 320, height: 200, scale: 0.25 }),
25 ).toEqual({ _tag: "freeform", width: 1280, height: 800 });
26 });
27
1228 it("fills the available surface in fill mode", () => {
1329 expect(resolveBrowserViewportLayout({ width: 700, height: 500 }, { _tag: "fill" })).toEqual({
1430 canvasWidth: 700,
apps/web/src/browser/browserViewportLayout.ts+27
......@@ -40,6 +40,33 @@ export const browserViewportSettingKey = (setting: PreviewViewportSetting): stri
4040const normalizeZoomFactor = (zoomFactor: number): number =>
4141 Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
4242
43export function resolveFittedBrowserViewport(
44 setting: PreviewViewportSetting,
45 sourceContent: {
46 readonly width: number;
47 readonly height: number;
48 readonly scale: number;
49 } | null,
50 zoomFactor = 1,
51): Exclude<PreviewViewportSetting, { readonly _tag: "fill" }> {
52 if (setting._tag !== "fill") return setting;
53 const normalizedZoomFactor = normalizeZoomFactor(zoomFactor);
54 if (sourceContent) {
55 return {
56 _tag: "freeform",
57 width: Math.max(
58 1,
59 Math.round(sourceContent.width / sourceContent.scale / normalizedZoomFactor),
60 ),
61 height: Math.max(
62 1,
63 Math.round(sourceContent.height / sourceContent.scale / normalizedZoomFactor),
64 ),
65 };
66 }
67 return { _tag: "freeform", width: 1280, height: 800 };
68}
69
4370export function resolveBrowserDeviceViewportArea(container: {
4471 readonly width: number;
4572 readonly height: number;
apps/web/src/browser/desktopTabLifetime.test.ts+35
......@@ -1,3 +1,4 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
12import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
23
34const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({
......@@ -15,6 +16,7 @@ vi.mock("./browserRecording", () => ({
1516}));
1617
1718import { acquireDesktopTab } from "./desktopTabLifetime";
19import { previewRuntimeTabId } from "./previewRuntimeTabId";
1820
1921describe("desktopTabLifetime", () => {
2022 beforeEach(() => {
......@@ -55,6 +57,39 @@ describe("desktopTabLifetime", () => {
5557 expect(ready).toBe(true);
5658 });
5759
60 it("keeps identical server tab ids from two environments in separate desktop slots", async () => {
61 vi.useFakeTimers();
62 createTab.mockResolvedValue(undefined);
63 const tabA = previewRuntimeTabId(
64 {
65 environmentId: EnvironmentId.make("environment-a"),
66 threadId: ThreadId.make("thread-a"),
67 },
68 "epoch-a",
69 "tab_1",
70 );
71 const tabB = previewRuntimeTabId(
72 {
73 environmentId: EnvironmentId.make("environment-b"),
74 threadId: ThreadId.make("thread-b"),
75 },
76 "epoch-b",
77 "tab_1",
78 );
79
80 const first = acquireDesktopTab(tabA);
81 const second = acquireDesktopTab(tabB);
82 await Promise.all([first.ready, second.ready]);
83
84 expect(createTab).toHaveBeenCalledWith(tabA);
85 expect(createTab).toHaveBeenCalledWith(tabB);
86 expect(createTab).toHaveBeenCalledTimes(2);
87
88 first.release();
89 second.release();
90 await vi.advanceTimersByTimeAsync(0);
91 });
92
5893 it("stops recording before closing the final desktop tab lease", async () => {
5994 vi.useFakeTimers();
6095 let resolveStop: (() => void) | undefined;
apps/web/src/browser/previewRuntimeTabId.test.ts created+46
......@@ -0,0 +1,46 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { isCurrentPreviewRuntimeTab, previewRuntimeTabId } from "./previewRuntimeTabId";
5
6describe("previewRuntimeTabId", () => {
7 it("scopes process-local tab ids to their environment, thread, and server epoch", () => {
8 const base = {
9 environmentId: EnvironmentId.make("environment-a"),
10 threadId: ThreadId.make("thread-a"),
11 };
12
13 expect(previewRuntimeTabId(base, "epoch-a", "tab_1")).not.toBe(
14 previewRuntimeTabId(
15 { ...base, environmentId: EnvironmentId.make("environment-b") },
16 "epoch-a",
17 "tab_1",
18 ),
19 );
20 expect(previewRuntimeTabId(base, "epoch-a", "tab_1")).not.toBe(
21 previewRuntimeTabId({ ...base, threadId: ThreadId.make("thread-b") }, "epoch-a", "tab_1"),
22 );
23 expect(previewRuntimeTabId(base, "epoch-a", "tab_1")).not.toBe(
24 previewRuntimeTabId(base, "epoch-b", "tab_1"),
25 );
26 });
27
28 it("is stable for the same runtime tab", () => {
29 const ref = {
30 environmentId: EnvironmentId.make("environment-a"),
31 threadId: ThreadId.make("thread-a"),
32 };
33 expect(previewRuntimeTabId(ref, null, "tab_1")).toBe(previewRuntimeTabId(ref, null, "tab_1"));
34 });
35
36 it("rejects a pinned operation target after the server epoch changes", () => {
37 const ref = {
38 environmentId: EnvironmentId.make("environment-a"),
39 threadId: ThreadId.make("thread-a"),
40 };
41 const runtimeTabId = previewRuntimeTabId(ref, "epoch-a", "tab_1");
42
43 expect(isCurrentPreviewRuntimeTab(ref, "epoch-a", "tab_1", runtimeTabId)).toBe(true);
44 expect(isCurrentPreviewRuntimeTab(ref, "epoch-b", "tab_1", runtimeTabId)).toBe(false);
45 });
46});
apps/web/src/browser/previewRuntimeTabId.ts created+23
......@@ -0,0 +1,23 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2
3/**
4 * The server only guarantees preview tab ids are unique within one process.
5 * Desktop resources live across every connected environment, so they need a
6 * stronger identity that also changes when a server process restarts.
7 */
8export function previewRuntimeTabId(
9 threadRef: ScopedThreadRef,
10 serverEpoch: string | null,
11 tabId: string,
12): string {
13 return JSON.stringify([threadRef.environmentId, threadRef.threadId, serverEpoch, tabId]);
14}
15
16export function isCurrentPreviewRuntimeTab(
17 threadRef: ScopedThreadRef,
18 serverEpoch: string | null,
19 tabId: string,
20 runtimeTabId: string,
21): boolean {
22 return previewRuntimeTabId(threadRef, serverEpoch, tabId) === runtimeTabId;
23}
apps/web/src/components/preview/PreviewAutomationHosts.tsx+203-83
......@@ -32,12 +32,14 @@ import {
3232import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
3333import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";
3434import {
35 readActiveBrowserRecordingTabIds,
35 readActiveBrowserRecordingTargets,
3636 startBrowserRecording,
3737 stopBrowserRecording,
3838} from "~/browser/browserRecording";
3939import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope";
4040import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
41import { runBrowserViewportMutation } from "~/browser/browserViewportActions";
42import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
4143import { isElectron } from "~/env";
4244import { useEnvironments } from "~/state/environments";
4345import { previewEnvironment } from "~/state/preview";
......@@ -46,7 +48,6 @@ import { useAtomCommand } from "~/state/use-atom-command";
4648
4749import { previewBridge } from "./previewBridge";
4850import {
49 PreviewAutomationNavigationTimeoutError,
5051 PreviewAutomationOperationError,
5152 PreviewAutomationOverlayTimeoutError,
5253 PreviewAutomationRecordingNotActiveError,
......@@ -54,9 +55,14 @@ import {
5455 PreviewAutomationViewportTimeoutError,
5556} from "./previewAutomationErrors";
5657import {
58 previewAutomationDefaultViewport,
5759 previewAutomationOpenNeedsOverlay,
5860 shouldOpenPreviewMiniPlayer,
5961} from "./previewAutomationOpenReadiness";
62import {
63 assertPreviewRuntimeCurrent,
64 waitForNavigationReadiness,
65} from "./previewNavigationReadiness";
6066import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer";
6167import { createPreviewAutomationClientId } from "./previewAutomationClientId";
6268import {
......@@ -65,58 +71,42 @@ import {
6571 resolvePreviewAutomationTarget,
6672} from "./previewAutomationTarget";
6773import { isPreviewViewportReady } from "./previewViewportReadiness";
74import { shouldRollbackPreviewViewport } from "./previewViewportRollback";
6875
69const waitForDesktopOverlay = async (
70 threadRef: ScopedThreadRef,
71 requestId: string,
72 tabId: string,
73 timeoutMs: number,
74): Promise<void> => {
75 const deadline = Date.now() + timeoutMs;
76const PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS = 500;
77
78const waitForPreviewPresentation = async (runtimeTabId: string): Promise<void> => {
79 const deadline = Date.now() + PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS;
7680 while (Date.now() <= deadline) {
77 const state = readThreadPreviewState(threadRef);
78 if (state.desktopByTabId[tabId] && previewBridge) {
79 const status = await previewBridge.automation.status(tabId);
80 if (status.available) return;
81 }
82 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
81 if (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible) return;
82 await new Promise<void>((resolve) => window.setTimeout(resolve, 16));
8383 }
84 throw new PreviewAutomationOverlayTimeoutError({
85 requestId,
86 environmentId: threadRef.environmentId,
87 threadId: threadRef.threadId,
88 timeoutMs,
89 });
9084};
9185
92const waitForNavigationReadiness = async (
86const waitForDesktopOverlay = async (
9387 threadRef: ScopedThreadRef,
9488 requestId: string,
9589 tabId: string,
96 readiness: PreviewAutomationNavigateInput["readiness"],
90 runtimeTabId: string,
91 operation: PreviewAutomationRequest["operation"],
9792 timeoutMs: number,
9893): Promise<void> => {
99 const targetReadiness = readiness ?? "load";
100 if (!previewBridge || targetReadiness === "none") return;
10194 const deadline = Date.now() + timeoutMs;
10295 while (Date.now() <= deadline) {
103 if (targetReadiness === "domContentLoaded") {
104 const readyState = await previewBridge.automation.evaluate(tabId, {
105 expression: "document.readyState",
106 });
107 if (readyState === "interactive" || readyState === "complete") return;
108 } else {
109 const status = await previewBridge.automation.status(tabId);
110 if (!status.loading) return;
96 const state = assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, {
97 operation,
98 requestId,
99 });
100 if (state.desktopByTabId[tabId] && previewBridge) {
101 const status = await previewBridge.automation.status(runtimeTabId);
102 if (status.available) return;
111103 }
112104 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
113105 }
114 throw new PreviewAutomationNavigationTimeoutError({
106 throw new PreviewAutomationOverlayTimeoutError({
115107 requestId,
116108 environmentId: threadRef.environmentId,
117109 threadId: threadRef.threadId,
118 tabId,
119 readiness: targetReadiness,
120110 timeoutMs,
121111 });
122112};
......@@ -148,8 +138,10 @@ const readWebviewViewport = async (
148138 : null;
149139};
150140
151const readRenderedViewport = async (tabId: string): Promise<PreviewRenderedViewportSize | null> => {
152 const webview = findPreviewWebview(tabId);
141const readRenderedViewport = async (
142 runtimeTabId: string,
143): Promise<PreviewRenderedViewportSize | null> => {
144 const webview = findPreviewWebview(runtimeTabId);
153145 if (!webview) return null;
154146 return await readWebviewViewport(webview);
155147};
......@@ -165,19 +157,23 @@ const readDeclaredViewport = (
165157};
166158
167159const waitForRenderedViewport = async (
160 threadRef: ScopedThreadRef,
168161 tabId: string,
162 runtimeTabId: string,
169163 setting: PreviewViewportSetting,
170164 timeoutMs: number,
171165 context: {
172166 readonly requestId: PreviewAutomationRequest["requestId"];
167 readonly operation: PreviewAutomationRequest["operation"];
173168 readonly environmentId: EnvironmentId;
174169 readonly threadId: PreviewAutomationRequest["threadId"];
175170 },
176171): Promise<PreviewRenderedViewportSize> => {
177172 const deadline = Date.now() + timeoutMs;
178173 while (Date.now() <= deadline) {
174 assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, context);
179175 try {
180 const webview = findPreviewWebview(tabId);
176 const webview = findPreviewWebview(runtimeTabId);
181177 const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null;
182178 const declaredViewport = readDeclaredViewport(webview);
183179 const renderedViewport = webview ? await readWebviewViewport(webview) : null;
......@@ -211,18 +207,19 @@ const currentStatus = async (
211207): Promise<PreviewAutomationStatus> => {
212208 const state = readThreadPreviewState(threadRef);
213209 const { snapshot, tabId } = resolvePreviewAutomationTarget(state, requestedTabId);
214 const visible = tabId
215 ? (useBrowserSurfaceStore.getState().byTabId[tabId]?.visible ?? false)
210 const runtimeTabId = tabId ? previewRuntimeTabId(threadRef, state.serverEpoch, tabId) : null;
211 const visible = runtimeTabId
212 ? (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible ?? false)
216213 : false;
217214 const viewportSetting = snapshot ? (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT) : undefined;
218 const viewport = tabId ? await readRenderedViewport(tabId).catch(() => null) : null;
215 const viewport = runtimeTabId ? await readRenderedViewport(runtimeTabId).catch(() => null) : null;
219216 const viewportStatus = {
220217 ...(viewportSetting === undefined ? {} : { viewportSetting }),
221218 ...(viewport === null ? {} : { viewport }),
222219 };
223 if (tabId && previewBridge && state.desktopByTabId[tabId]) {
224 const status = await previewBridge.automation.status(tabId);
225 return { ...status, visible, ...viewportStatus };
220 if (runtimeTabId && tabId && previewBridge && state.desktopByTabId[tabId]) {
221 const status = await previewBridge.automation.status(runtimeTabId);
222 return { ...status, tabId, visible, ...viewportStatus };
226223 }
227224 const navStatus = snapshot?.navStatus;
228225 return {
......@@ -340,8 +337,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
340337 if (!bridge || !readyTabId) {
341338 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
342339 }
343 await waitForDesktopOverlay(threadRef, request.requestId, readyTabId, request.timeoutMs);
344 return { bridge, tabId: readyTabId };
340 const readyState = readThreadPreviewState(threadRef);
341 const runtimeTabId = previewRuntimeTabId(threadRef, readyState.serverEpoch, readyTabId);
342 await waitForDesktopOverlay(
343 threadRef,
344 request.requestId,
345 readyTabId,
346 runtimeTabId,
347 request.operation,
348 request.timeoutMs,
349 );
350 return {
351 bridge,
352 tabId: readyTabId,
353 runtimeTabId,
354 };
345355 };
346356 switch (request.operation) {
347357 case "status":
......@@ -381,7 +391,45 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
381391 activeSnapshot = snapshot;
382392 tabId = activeTabId;
383393 }
384 if (shouldOpenPreviewMiniPlayer(input)) {
394 const activeRuntimeTabId = previewRuntimeTabId(
395 threadRef,
396 readThreadPreviewState(threadRef).serverEpoch,
397 activeTabId,
398 );
399 if (activeSnapshot) {
400 const defaultViewport = previewAutomationDefaultViewport(
401 reusedExistingTab,
402 activeSnapshot,
403 );
404 if (defaultViewport) {
405 const resizeResult = await runBrowserViewportMutation(
406 activeRuntimeTabId,
407 async () => {
408 assertPreviewRuntimeCurrent(
409 threadRef,
410 activeTabId,
411 activeRuntimeTabId,
412 request,
413 );
414 return await resize({
415 environmentId,
416 input: {
417 threadId: request.threadId,
418 tabId: activeTabId,
419 viewport: defaultViewport,
420 },
421 });
422 },
423 );
424 if (resizeResult._tag === "Failure") {
425 return raiseAtomCommandFailure(resizeResult);
426 }
427 activeSnapshot = resizeResult.value;
428 updatePreviewServerSnapshot(threadRef, resizeResult.value);
429 }
430 }
431 const shouldPresentPreview = shouldOpenPreviewMiniPlayer(input);
432 if (shouldPresentPreview) {
385433 usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId);
386434 }
387435 if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {
......@@ -389,15 +437,27 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
389437 threadRef,
390438 request.requestId,
391439 activeTabId,
440 activeRuntimeTabId,
441 request.operation,
392442 request.timeoutMs,
393443 );
394444 }
445 if (shouldPresentPreview) {
446 // React commits the thread-bound surface asynchronously. Settle
447 // briefly so active-thread opens report visible=true, without
448 // turning a background thread's offscreen mini player into an
449 // operation failure.
450 await waitForPreviewPresentation(activeRuntimeTabId);
451 }
395452 if (reusedExistingTab && resolvedInputUrl && previewBridge) {
396 await previewBridge.navigate(activeTabId, resolvedInputUrl);
453 assertPreviewRuntimeCurrent(threadRef, activeTabId, activeRuntimeTabId, request);
454 await previewBridge.navigate(activeRuntimeTabId, resolvedInputUrl);
397455 await waitForNavigationReadiness(
398456 threadRef,
399457 request.requestId,
400458 activeTabId,
459 activeRuntimeTabId,
460 request.operation,
401461 "load",
402462 request.timeoutMs,
403463 );
......@@ -414,11 +474,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
414474 url: input.url!,
415475 },
416476 );
417 await ready.bridge.navigate(ready.tabId, resolution.resolvedUrl);
477 await ready.bridge.navigate(ready.runtimeTabId, resolution.resolvedUrl);
418478 await waitForNavigationReadiness(
419479 threadRef,
420480 request.requestId,
421481 ready.tabId,
482 ready.runtimeTabId,
483 request.operation,
422484 input.readiness ?? "load",
423485 input.timeoutMs ?? request.timeoutMs,
424486 );
......@@ -428,28 +490,76 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
428490 const ready = await requireReadyTab();
429491 const input = request.input as PreviewAutomationResizeInput;
430492 const setting = resolvePreviewViewport(input);
431 const result = await resize({
432 environmentId,
433 input: {
434 threadId: request.threadId,
435 tabId: ready.tabId,
436 viewport: setting,
437 },
493 const applied = await runBrowserViewportMutation(ready.runtimeTabId, async () => {
494 const operationState = assertPreviewRuntimeCurrent(
495 threadRef,
496 ready.tabId,
497 ready.runtimeTabId,
498 request,
499 );
500 const previousSetting =
501 operationState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT;
502 const result = await resize({
503 environmentId,
504 input: {
505 threadId: request.threadId,
506 tabId: ready.tabId,
507 viewport: setting,
508 },
509 });
510 if (result._tag === "Failure") {
511 return raiseAtomCommandFailure(result);
512 }
513 updatePreviewServerSnapshot(threadRef, result.value);
514 return {
515 previousSetting,
516 serverEpoch: operationState.serverEpoch,
517 };
438518 });
439 if (result._tag === "Failure") {
440 return raiseAtomCommandFailure(result);
519 let viewport: PreviewRenderedViewportSize;
520 try {
521 viewport = await waitForRenderedViewport(
522 threadRef,
523 ready.tabId,
524 ready.runtimeTabId,
525 setting,
526 input.timeoutMs ?? request.timeoutMs,
527 {
528 requestId: request.requestId,
529 operation: request.operation,
530 environmentId,
531 threadId: request.threadId,
532 },
533 );
534 } catch (cause) {
535 await runBrowserViewportMutation(ready.runtimeTabId, async () => {
536 const latestState = readThreadPreviewState(threadRef);
537 const latestSetting =
538 latestState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT;
539 if (
540 shouldRollbackPreviewViewport(
541 applied.previousSetting,
542 setting,
543 latestSetting,
544 applied.serverEpoch,
545 latestState.serverEpoch,
546 )
547 ) {
548 const rollback = await resize({
549 environmentId,
550 input: {
551 threadId: request.threadId,
552 tabId: ready.tabId,
553 viewport: applied.previousSetting,
554 },
555 });
556 if (rollback._tag !== "Failure") {
557 updatePreviewServerSnapshot(threadRef, rollback.value);
558 }
559 }
560 });
561 throw cause;
441562 }
442 updatePreviewServerSnapshot(threadRef, result.value);
443 const viewport = await waitForRenderedViewport(
444 ready.tabId,
445 setting,
446 input.timeoutMs ?? request.timeoutMs,
447 {
448 requestId: request.requestId,
449 environmentId,
450 threadId: request.threadId,
451 },
452 );
453563 return {
454564 tabId: ready.tabId,
455565 setting,
......@@ -459,7 +569,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
459569 case "setColorScheme": {
460570 const ready = await requireReadyTab();
461571 const input = request.input as PreviewAutomationSetColorSchemeInput;
462 await ready.bridge.setColorScheme(ready.tabId, input.colorScheme);
572 await ready.bridge.setColorScheme(ready.runtimeTabId, input.colorScheme);
463573 return {
464574 tabId: ready.tabId,
465575 colorScheme: input.colorScheme,
......@@ -467,53 +577,57 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
467577 }
468578 case "snapshot": {
469579 const ready = await requireReadyTab();
470 return await ready.bridge.automation.snapshot(ready.tabId);
580 return await ready.bridge.automation.snapshot(ready.runtimeTabId);
471581 }
472582 case "click": {
473583 const ready = await requireReadyTab();
474584 return await ready.bridge.automation.click(
475 ready.tabId,
585 ready.runtimeTabId,
476586 request.input as Parameters<typeof ready.bridge.automation.click>[1],
477587 );
478588 }
479589 case "type": {
480590 const ready = await requireReadyTab();
481591 return await ready.bridge.automation.type(
482 ready.tabId,
592 ready.runtimeTabId,
483593 request.input as Parameters<typeof ready.bridge.automation.type>[1],
484594 );
485595 }
486596 case "press": {
487597 const ready = await requireReadyTab();
488598 return await ready.bridge.automation.press(
489 ready.tabId,
599 ready.runtimeTabId,
490600 request.input as Parameters<typeof ready.bridge.automation.press>[1],
491601 );
492602 }
493603 case "scroll": {
494604 const ready = await requireReadyTab();
495605 return await ready.bridge.automation.scroll(
496 ready.tabId,
606 ready.runtimeTabId,
497607 request.input as Parameters<typeof ready.bridge.automation.scroll>[1],
498608 );
499609 }
500610 case "evaluate": {
501611 const ready = await requireReadyTab();
502612 return await ready.bridge.automation.evaluate(
503 ready.tabId,
613 ready.runtimeTabId,
504614 request.input as Parameters<typeof ready.bridge.automation.evaluate>[1],
505615 );
506616 }
507617 case "waitFor": {
508618 const ready = await requireReadyTab();
509619 return await ready.bridge.automation.waitFor(
510 ready.tabId,
620 ready.runtimeTabId,
511621 request.input as Parameters<typeof ready.bridge.automation.waitFor>[1],
512622 );
513623 }
514624 case "recordingStart": {
515625 const ready = await requireReadyTab();
516 const startedAt = await startBrowserRecording(ready.tabId, threadRef);
626 const startedAt = await startBrowserRecording(
627 ready.runtimeTabId,
628 threadRef,
629 ready.tabId,
630 );
517631 return {
518632 tabId: ready.tabId,
519633 recording: true,
......@@ -521,15 +635,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
521635 };
522636 }
523637 case "recordingStop": {
524 const activeTabIds = readActiveBrowserRecordingTabIds(threadRef);
638 const activeRecordings = readActiveBrowserRecordingTargets(threadRef);
639 const activeTabIds = new Set(
640 activeRecordings.map((recording) => recording.serverTabId),
641 );
525642 const stopTabId = resolveBrowserRecordingStopTarget(
526643 activeTabIds,
527644 tabId,
528645 request.tabIdExplicit ? request.tabId : undefined,
529646 );
530647 tabId = stopTabId ?? tabId;
531 const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null;
532 if (!artifact) {
648 const stopRuntimeTabId =
649 activeRecordings.find((recording) => recording.serverTabId === stopTabId)
650 ?.runtimeTabId ?? null;
651 const artifact = stopRuntimeTabId ? await stopBrowserRecording(stopRuntimeTabId) : null;
652 if (!artifact || !stopTabId) {
533653 return raisePreviewAutomationHostError(
534654 new PreviewAutomationRecordingNotActiveError({
535655 requestId: request.requestId,
......@@ -539,7 +659,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
539659 }),
540660 );
541661 }
542 return artifact;
662 return { ...artifact, tabId: stopTabId };
543663 }
544664 }
545665 } catch (cause) {
apps/web/src/components/preview/PreviewView.test.tsx+18-4
......@@ -90,6 +90,7 @@ vi.mock("~/state/use-atom-command", () => ({
9090}));
9191
9292vi.mock("~/browser/browserRecording", () => ({
93 findActiveBrowserRecordingRuntimeTabId: vi.fn(() => null),
9394 startBrowserRecording: vi.fn(),
9495 stopBrowserRecording: vi.fn(),
9596 useActiveBrowserRecordingTabIds: () => new Set(),
......@@ -188,6 +189,13 @@ vi.mock("./useLoadingProgress", () => ({ useLoadingProgress: () => 0 }));
188189vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() }));
189190
190191import { PreviewView } from "./PreviewView";
192import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
193
194const TEST_THREAD_REF = {
195 environmentId: EnvironmentId.make("environment-1"),
196 threadId: ThreadId.make("thread-1"),
197} as const;
198const TEST_RUNTIME_TAB_ID = previewRuntimeTabId(TEST_THREAD_REF, null, "tab-1");
191199
192200describe("PreviewView navigation", () => {
193201 beforeEach(() => {
......@@ -230,7 +238,9 @@ describe("PreviewView navigation", () => {
230238 expect(mocks.submittedUrl).not.toBeNull();
231239 mocks.submittedUrl?.(submitted);
232240
233 await vi.waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith("tab-1", expected));
241 await vi.waitFor(() =>
242 expect(mocks.navigate).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID, expected),
243 );
234244 expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith(
235245 {
236246 environmentId: "environment-1",
......@@ -258,7 +268,7 @@ describe("PreviewView navigation", () => {
258268
259269 await vi.waitFor(() =>
260270 expect(mocks.navigate).toHaveBeenCalledWith(
261 "tab-1",
271 TEST_RUNTIME_TAB_ID,
262272 "http://172.25.85.75:5173/app?mode=test#top",
263273 ),
264274 );
......@@ -306,11 +316,15 @@ describe("PreviewView navigation", () => {
306316
307317 renderToStaticMarkup(<PreviewView {...props} />);
308318 mocks.toggleNativePictureInPicture?.();
309 await vi.waitFor(() => expect(mocks.openPictureInPicture).toHaveBeenCalledWith("tab-1"));
319 await vi.waitFor(() =>
320 expect(mocks.openPictureInPicture).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID),
321 );
310322
311323 mocks.pictureInPicture = true;
312324 renderToStaticMarkup(<PreviewView {...props} />);
313325 mocks.toggleNativePictureInPicture?.();
314 await vi.waitFor(() => expect(mocks.closePictureInPicture).toHaveBeenCalledWith("tab-1"));
326 await vi.waitFor(() =>
327 expect(mocks.closePictureInPicture).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID),
328 );
315329 });
316330});
apps/web/src/components/preview/PreviewView.tsx+56-46
......@@ -37,6 +37,7 @@ import {
3737 subscribeBrowserViewportChange,
3838} from "~/browser/browserViewportActions";
3939import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout";
40import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
4041import { PreviewUnreachable } from "./PreviewUnreachable";
4142import { revealInFileExplorerLabel } from "./fileExplorerLabel";
4243import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
......@@ -47,6 +48,7 @@ import { usePreviewSession } from "./usePreviewSession";
4748import { ZoomIndicator } from "./ZoomIndicator";
4849import { AgentBrowserCursor } from "./AgentBrowserCursor";
4950import {
51 findActiveBrowserRecordingRuntimeTabId,
5052 startBrowserRecording,
5153 stopBrowserRecording,
5254 useActiveBrowserRecordingTabIds,
......@@ -93,6 +95,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
9395 }, []);
9496
9597 const tabId = requestedTabId ?? previewState.activeTabId;
98 const runtimeTabId = tabId
99 ? previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId)
100 : null;
101 const recordingRuntimeTabId =
102 tabId && runtimeTabId
103 ? activeRecordingTabIds.has(runtimeTabId)
104 ? runtimeTabId
105 : findActiveBrowserRecordingRuntimeTabId(threadRef, tabId)
106 : null;
96107 const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null;
97108 const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null;
98109 const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const };
......@@ -115,15 +126,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
115126 : undefined;
116127 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
117128 const panelRect = useBrowserSurfaceStore((state) =>
118 tabId ? (state.byTabId[tabId]?.rect ?? null) : null,
129 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,
119130 );
120131
121132 const navigateToResolvedUrl = useCallback(
122133 async (resolvedUrl: string) => {
123 if (tabId && previewBridge) {
134 if (runtimeTabId && previewBridge) {
124135 // Drive the webview imperatively; `usePreviewBridge` mirrors the
125136 // resolved URL back to the server so other clients stay in sync.
126 await previewBridge.navigate(tabId, resolvedUrl);
137 await previewBridge.navigate(runtimeTabId, resolvedUrl);
127138 rememberPreviewUrl(threadRef, resolvedUrl);
128139 } else {
129140 await openPreviewSession({
......@@ -133,7 +144,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
133144 });
134145 }
135146 },
136 [open, tabId, threadRef],
147 [open, runtimeTabId, threadRef],
137148 );
138149
139150 const handleSubmitUrl = useCallback(
......@@ -159,20 +170,20 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
159170 );
160171
161172 const handleRefresh = useCallback(() => {
162 if (previewBridge && tabId) void previewBridge.refresh(tabId);
163 }, [tabId]);
173 if (previewBridge && runtimeTabId) void previewBridge.refresh(runtimeTabId);
174 }, [runtimeTabId]);
164175
165176 const handleZoomIn = useCallback(() => {
166 if (previewBridge && tabId) void previewBridge.zoomIn(tabId);
167 }, [tabId]);
177 if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId);
178 }, [runtimeTabId]);
168179
169180 const handleZoomOut = useCallback(() => {
170 if (previewBridge && tabId) void previewBridge.zoomOut(tabId);
171 }, [tabId]);
181 if (previewBridge && runtimeTabId) void previewBridge.zoomOut(runtimeTabId);
182 }, [runtimeTabId]);
172183
173184 const handleResetZoom = useCallback(() => {
174 if (previewBridge && tabId) void previewBridge.resetZoom(tabId);
175 }, [tabId]);
185 if (previewBridge && runtimeTabId) void previewBridge.resetZoom(runtimeTabId);
186 }, [runtimeTabId]);
176187
177188 const handleViewportChange = useCallback(
178189 async (nextViewport: PreviewViewportSetting) => {
......@@ -200,32 +211,32 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
200211 );
201212
202213 const handleToggleDeviceToolbar = () => {
203 if (!tabId) return;
214 if (!runtimeTabId) return;
204215 if (viewport._tag !== "fill") {
205 void commitBrowserViewportChange(tabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined);
216 void commitBrowserViewportChange(runtimeTabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined);
206217 return;
207218 }
208219
209220 const responsiveSize = panelRect
210221 ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor)
211222 : { width: 1024, height: 768 };
212 void commitBrowserViewportChange(tabId, { _tag: "freeform", ...responsiveSize }).catch(
223 void commitBrowserViewportChange(runtimeTabId, { _tag: "freeform", ...responsiveSize }).catch(
213224 () => undefined,
214225 );
215226 };
216227
217228 useEffect(() => {
218 if (!tabId) return;
219 return subscribeBrowserViewportChange(tabId, handleViewportChange);
220 }, [handleViewportChange, tabId]);
229 if (!runtimeTabId) return;
230 return subscribeBrowserViewportChange(runtimeTabId, handleViewportChange);
231 }, [handleViewportChange, runtimeTabId]);
221232
222233 const handleBack = useCallback(() => {
223 if (previewBridge && tabId) void previewBridge.goBack(tabId);
224 }, [tabId]);
234 if (previewBridge && runtimeTabId) void previewBridge.goBack(runtimeTabId);
235 }, [runtimeTabId]);
225236
226237 const handleForward = useCallback(() => {
227 if (previewBridge && tabId) void previewBridge.goForward(tabId);
228 }, [tabId]);
238 if (previewBridge && runtimeTabId) void previewBridge.goForward(runtimeTabId);
239 }, [runtimeTabId]);
229240
230241 const handleOpenInBrowser = useCallback(() => {
231242 if (!localApi || !url) return;
......@@ -243,26 +254,25 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
243254 }, [miniPlayer?.tabId, tabId, threadRef]);
244255
245256 const handleNativePictureInPicture = useCallback(() => {
246 if (!previewBridge || !tabId) return;
257 if (!previewBridge || !runtimeTabId) return;
247258 const operation = desktopOverlay?.pictureInPicture
248259 ? previewBridge.pictureInPicture.close
249260 : previewBridge.pictureInPicture.open;
250 void operation(tabId).catch((error) => {
261 void operation(runtimeTabId).catch((error) => {
251262 toastManager.add({
252263 type: "error",
253264 title: "Unable to update popped-out preview",
254265 description: error instanceof Error ? error.message : "An error occurred.",
255266 });
256267 });
257 }, [desktopOverlay?.pictureInPicture, tabId]);
268 }, [desktopOverlay?.pictureInPicture, runtimeTabId]);
258269
259270 const handleCapture = useCallback(
260271 (record: boolean) => {
261 if (!previewBridge || !tabId) return;
272 if (!previewBridge || !runtimeTabId || !tabId) return;
262273 const bridge = previewBridge;
263 const recordingThisTab = activeRecordingTabIds.has(tabId);
264 if (recordingThisTab) {
265 void stopBrowserRecording(tabId).then(
274 if (recordingRuntimeTabId) {
275 void stopBrowserRecording(recordingRuntimeTabId).then(
266276 (artifact) => {
267277 if (!artifact) return;
268278 let pathCopied = false;
......@@ -354,7 +364,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
354364 return;
355365 }
356366 if (record) {
357 void startBrowserRecording(tabId, threadRef).catch((error) => {
367 void startBrowserRecording(runtimeTabId, threadRef, tabId).catch((error) => {
358368 toastManager.add({
359369 type: "error",
360370 title: "Unable to start recording",
......@@ -363,7 +373,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
363373 });
364374 return;
365375 }
366 void bridge.captureScreenshot(tabId).then(
376 void bridge.captureScreenshot(runtimeTabId).then(
367377 (artifact) => {
368378 const revealAction = {
369379 children: revealInFileExplorerLabel(navigator.platform),
......@@ -493,13 +503,13 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
493503 },
494504 );
495505 },
496 [activeRecordingTabIds, tabId, threadRef],
506 [recordingRuntimeTabId, runtimeTabId, tabId, threadRef],
497507 );
498508
499509 const handlePickElement = useCallback(() => {
500 if (!previewBridge || !tabId) return;
510 if (!previewBridge || !runtimeTabId) return;
501511 if (pickActiveRef.current) {
502 void previewBridge.cancelPickElement(tabId).catch(() => undefined);
512 void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined);
503513 return;
504514 }
505515 // Snapshot whatever the user was focused on (typically the chat
......@@ -513,7 +523,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
513523 setPickActive(true);
514524 void (async () => {
515525 try {
516 const annotation = await previewBridge.pickElement(tabId);
526 const annotation = await previewBridge.pickElement(runtimeTabId);
517527 if (!annotation) return;
518528 addPreviewAnnotation(threadRef, annotation);
519529 const screenshotFile = await previewAnnotationScreenshotFile(annotation);
......@@ -551,7 +561,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
551561 }
552562 }
553563 })();
554 }, [addImage, addPreviewAnnotation, tabId, threadRef]);
564 }, [addImage, addPreviewAnnotation, runtimeTabId, threadRef]);
555565
556566 // If the active tab changes mid-pick (close, thread switch, hot restart),
557567 // tell main to tear down the in-flight session AND reset our local toggle
......@@ -560,12 +570,12 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
560570 return () => {
561571 if (!pickActiveRef.current) return;
562572 pickActiveRef.current = false;
563 if (previewBridge && tabId) {
564 void previewBridge.cancelPickElement(tabId).catch(() => undefined);
573 if (previewBridge && runtimeTabId) {
574 void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined);
565575 }
566576 if (isMountedRef.current) setPickActive(false);
567577 };
568 }, [tabId]);
578 }, [runtimeTabId]);
569579
570580 // Subscribe only while visible; `toggle-panel` is owned by ChatView's
571581 // URL-aware handler regardless of whether the panel is currently mounted.
......@@ -615,7 +625,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
615625 onOpenInBrowser={tabId ? handleOpenInBrowser : undefined}
616626 onCapture={previewBridge && tabId ? handleCapture : undefined}
617627 captureDisabled={!desktopOverlay || isUnreachable}
618 recording={tabId !== null && activeRecordingTabIds.has(tabId)}
628 recording={recordingRuntimeTabId !== null}
619629 onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined}
620630 pictureInPicture={miniPlayer?.tabId === tabId}
621631 pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable}
......@@ -631,7 +641,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
631641 trailingActions={
632642 previewBridge ? (
633643 <PreviewMoreMenu
634 tabId={tabId}
644 tabId={runtimeTabId}
635645 hasWebContents={desktopOverlay?.hasWebContents ?? false}
636646 zoomFactor={desktopOverlay?.zoomFactor ?? 1}
637647 colorScheme={desktopOverlay?.colorScheme ?? "system"}
......@@ -645,10 +655,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
645655 />
646656
647657 <div className="relative min-h-0 flex-1 overflow-hidden">
648 {tabId && snapshot && !showEmptyState ? (
658 {runtimeTabId && snapshot && !showEmptyState ? (
649659 <BrowserSurfaceSlot
650 key={tabId}
651 tabId={tabId}
660 key={runtimeTabId}
661 tabId={runtimeTabId}
652662 visible={visible && !isUnreachable}
653663 className="absolute inset-0 h-full w-full"
654664 />
......@@ -664,9 +674,9 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
664674 {snapshot && desktopOverlay ? (
665675 <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} />
666676 ) : null}
667 {tabId && desktopOverlay && !showEmptyState && !isUnreachable ? (
677 {runtimeTabId && desktopOverlay && !showEmptyState && !isUnreachable ? (
668678 <AgentBrowserCursor
669 tabId={tabId}
679 tabId={runtimeTabId}
670680 zoomFactor={desktopOverlay.zoomFactor}
671681 controller={controller}
672682 />
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+4-2
......@@ -5,6 +5,7 @@ import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react";
55import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef } from "react";
66
77import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
8import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
89import { Button } from "~/components/ui/button";
910import { toastManager } from "~/components/ui/toast";
1011import { useThreadPreviewState } from "~/previewStateStore";
......@@ -49,6 +50,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
4950 );
5051 const previewState = useThreadPreviewState(threadRef);
5152 const snapshot = previewState.sessions[tabId] ?? null;
53 const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId);
5254 const desktopOverlay = previewState.desktopByTabId[tabId] ?? null;
5355 const position = miniPlayer?.tabId === tabId ? miniPlayer.position : null;
5456 const size =
......@@ -69,7 +71,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
6971 const operation = desktopOverlay?.pictureInPicture
7072 ? previewBridge.pictureInPicture.close
7173 : previewBridge.pictureInPicture.open;
72 void operation(tabId).catch((error) => {
74 void operation(runtimeTabId).catch((error) => {
7375 toastManager.add({
7476 type: "error",
7577 title: "Unable to update popped-out preview",
......@@ -284,7 +286,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
284286 <div className="relative h-full min-h-0">
285287 <div className="absolute inset-0 z-[29] rounded-xl bg-muted shadow-2xl/35" />
286288 <BrowserSurfaceSlot
287 tabId={tabId}
289 tabId={runtimeTabId}
288290 visible={Boolean(desktopOverlay?.hasWebContents)}
289291 cornerRadius={12}
290292 fitSourceContent
apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts+18
......@@ -2,6 +2,8 @@ import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tool
22import { describe, expect, it } from "vite-plus/test";
33
44import {
5 DEFAULT_PREVIEW_AUTOMATION_VIEWPORT,
6 previewAutomationDefaultViewport,
57 previewAutomationOpenNeedsOverlay,
68 shouldOpenPreviewMiniPlayer,
79} from "./previewAutomationOpenReadiness";
......@@ -58,4 +60,20 @@ describe("preview automation open readiness", () => {
5860 ),
5961 ).toBe(true);
6062 });
63
64 it("gives newly-created automation tabs a stable desktop viewport", () => {
65 expect(previewAutomationDefaultViewport(false, snapshot({ _tag: "Idle" }))).toEqual(
66 DEFAULT_PREVIEW_AUTOMATION_VIEWPORT,
67 );
68 });
69
70 it("preserves reused and already-fixed browser viewports", () => {
71 expect(previewAutomationDefaultViewport(true, snapshot({ _tag: "Idle" }))).toBeNull();
72 expect(
73 previewAutomationDefaultViewport(false, {
74 ...snapshot({ _tag: "Idle" }),
75 viewport: { _tag: "freeform", width: 900, height: 600 },
76 }),
77 ).toBeNull();
78 });
6179});
apps/web/src/components/preview/previewAutomationOpenReadiness.ts+22-1
......@@ -1,4 +1,15 @@
1import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts";
1import {
2 FILL_PREVIEW_VIEWPORT,
3 type PreviewAutomationOpenInput,
4 type PreviewSessionSnapshot,
5 type PreviewViewportSetting,
6} from "@t3tools/contracts";
7
8export const DEFAULT_PREVIEW_AUTOMATION_VIEWPORT = {
9 _tag: "freeform",
10 width: 1280,
11 height: 800,
12} as const satisfies PreviewViewportSetting;
213
314export function shouldOpenPreviewMiniPlayer(input: PreviewAutomationOpenInput): boolean {
415 return input.open ?? input.show ?? true;
......@@ -10,3 +21,13 @@ export function previewAutomationOpenNeedsOverlay(
1021): boolean {
1122 return input.url !== undefined || snapshot.navStatus._tag !== "Idle";
1223}
24
25export function previewAutomationDefaultViewport(
26 reusedExistingTab: boolean,
27 snapshot: PreviewSessionSnapshot,
28): PreviewViewportSetting | null {
29 const viewport = snapshot.viewport ?? FILL_PREVIEW_VIEWPORT;
30 return !reusedExistingTab && viewport._tag === "fill"
31 ? DEFAULT_PREVIEW_AUTOMATION_VIEWPORT
32 : null;
33}
apps/web/src/components/preview/previewNavigationReadiness.test.ts created+56
......@@ -0,0 +1,56 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4const mocks = vi.hoisted(() => ({
5 readThreadPreviewState: vi.fn(),
6}));
7
8vi.mock("~/previewStateStore", () => ({
9 applyPreviewServerSnapshot: vi.fn(),
10 readThreadPreviewState: mocks.readThreadPreviewState,
11 reconcilePreviewServerSessions: vi.fn(),
12 updatePreviewServerSnapshot: vi.fn(),
13}));
14
15vi.mock("./previewBridge", () => ({
16 previewBridge: {
17 automation: {
18 evaluate: vi.fn(),
19 status: vi.fn(),
20 },
21 },
22}));
23
24import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
25
26import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors";
27import { waitForNavigationReadiness } from "./previewNavigationReadiness";
28
29describe("waitForNavigationReadiness", () => {
30 it("rejects a replaced runtime target even when readiness polling is disabled", async () => {
31 const threadRef = {
32 environmentId: EnvironmentId.make("environment-2"),
33 threadId: ThreadId.make("thread-1"),
34 };
35 const tabId = "tab_1";
36 const staleRuntimeTabId = previewRuntimeTabId(threadRef, "epoch-1", tabId);
37 mocks.readThreadPreviewState.mockReturnValue({
38 serverEpoch: "epoch-2",
39 sessions: {
40 [tabId]: { tabId },
41 },
42 });
43
44 await expect(
45 waitForNavigationReadiness(
46 threadRef,
47 "request-1",
48 tabId,
49 staleRuntimeTabId,
50 "navigate",
51 "none",
52 100,
53 ),
54 ).rejects.toBeInstanceOf(PreviewAutomationTargetUnavailableError);
55 });
56});
apps/web/src/components/preview/previewNavigationReadiness.ts created+74
......@@ -0,0 +1,74 @@
1import {
2 type PreviewAutomationNavigateInput,
3 type PreviewAutomationRequest,
4 type ScopedThreadRef,
5} from "@t3tools/contracts";
6
7import { isCurrentPreviewRuntimeTab } from "~/browser/previewRuntimeTabId";
8import { readThreadPreviewState } from "~/previewStateStore";
9
10import { previewBridge } from "./previewBridge";
11import {
12 PreviewAutomationNavigationTimeoutError,
13 PreviewAutomationTargetUnavailableError,
14} from "./previewAutomationErrors";
15
16export function assertPreviewRuntimeCurrent(
17 threadRef: ScopedThreadRef,
18 tabId: string,
19 runtimeTabId: string,
20 request: Pick<PreviewAutomationRequest, "operation" | "requestId">,
21) {
22 const state = readThreadPreviewState(threadRef);
23 if (
24 state.sessions[tabId] &&
25 isCurrentPreviewRuntimeTab(threadRef, state.serverEpoch, tabId, runtimeTabId)
26 ) {
27 return state;
28 }
29 throw new PreviewAutomationTargetUnavailableError({
30 requestId: request.requestId,
31 operation: request.operation,
32 environmentId: threadRef.environmentId,
33 threadId: threadRef.threadId,
34 tabId,
35 bridgeAvailable: Boolean(previewBridge),
36 });
37}
38
39export async function waitForNavigationReadiness(
40 threadRef: ScopedThreadRef,
41 requestId: string,
42 tabId: string,
43 runtimeTabId: string,
44 operation: PreviewAutomationRequest["operation"],
45 readiness: PreviewAutomationNavigateInput["readiness"],
46 timeoutMs: number,
47): Promise<void> {
48 const targetReadiness = readiness ?? "load";
49 if (!previewBridge) return;
50 assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, { operation, requestId });
51 if (targetReadiness === "none") return;
52 const deadline = Date.now() + timeoutMs;
53 while (Date.now() <= deadline) {
54 assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, { operation, requestId });
55 if (targetReadiness === "domContentLoaded") {
56 const readyState = await previewBridge.automation.evaluate(runtimeTabId, {
57 expression: "document.readyState",
58 });
59 if (readyState === "interactive" || readyState === "complete") return;
60 } else {
61 const status = await previewBridge.automation.status(runtimeTabId);
62 if (status.available && !status.loading) return;
63 }
64 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
65 }
66 throw new PreviewAutomationNavigationTimeoutError({
67 requestId,
68 environmentId: threadRef.environmentId,
69 threadId: threadRef.threadId,
70 tabId,
71 readiness: targetReadiness,
72 timeoutMs,
73 });
74}
apps/web/src/components/preview/previewViewportRollback.test.ts created+36
......@@ -0,0 +1,36 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { shouldRollbackPreviewViewport } from "./previewViewportRollback";
4
5describe("shouldRollbackPreviewViewport", () => {
6 const fill = { _tag: "fill" } as const;
7 const requested = { _tag: "freeform", width: 900, height: 600 } as const;
8
9 it("rolls back a timed-out request that still owns the latest setting", () => {
10 expect(shouldRollbackPreviewViewport(fill, requested, requested, "server-a", "server-a")).toBe(
11 true,
12 );
13 });
14
15 it("does not overwrite a newer resize, replacement server, or repeated setting", () => {
16 expect(
17 shouldRollbackPreviewViewport(
18 fill,
19 requested,
20 {
21 _tag: "freeform",
22 width: 1024,
23 height: 768,
24 },
25 "server-a",
26 "server-a",
27 ),
28 ).toBe(false);
29 expect(shouldRollbackPreviewViewport(fill, requested, requested, "server-a", "server-b")).toBe(
30 false,
31 );
32 expect(
33 shouldRollbackPreviewViewport(requested, requested, requested, "server-a", "server-a"),
34 ).toBe(false);
35 });
36});
apps/web/src/components/preview/previewViewportRollback.ts created+18
......@@ -0,0 +1,18 @@
1import type { PreviewViewportSetting } from "@t3tools/contracts";
2
3import { browserViewportSettingKey } from "~/browser/browserViewportLayout";
4
5export function shouldRollbackPreviewViewport(
6 previous: PreviewViewportSetting,
7 requested: PreviewViewportSetting,
8 latest: PreviewViewportSetting,
9 operationServerEpoch: string | null,
10 currentServerEpoch: string | null,
11): boolean {
12 const requestedKey = browserViewportSettingKey(requested);
13 return (
14 currentServerEpoch === operationServerEpoch &&
15 browserViewportSettingKey(latest) === requestedKey &&
16 browserViewportSettingKey(previous) !== requestedKey
17 );
18}
apps/web/src/components/preview/usePreviewBridge.ts+9-5
......@@ -19,8 +19,12 @@ import { previewBridge } from "./previewBridge";
1919 * Mirrors low-latency desktop state into the store and reflects navigation
2020 * events back to the server. Webview lifetime is owned by ElectronBrowserHost.
2121 */
22export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void {
23 const { threadRef, tabId } = input;
22export function usePreviewBridge(input: {
23 threadRef: ScopedThreadRef;
24 tabId: string;
25 runtimeTabId: string;
26}): void {
27 const { threadRef, tabId, runtimeTabId } = input;
2428 const clearBrowserPointer = useBrowserPointerStore((state) => state.clear);
2529 const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report");
2630 const bridge = previewBridge;
......@@ -36,9 +40,9 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str
3640 lastReportedKind.current = null;
3741 lastDesktopNavStatus.current = null;
3842 const unsubscribe = bridge.onStateChange((changedTabId, state) => {
39 if (changedTabId !== tabId) return;
43 if (changedTabId !== runtimeTabId) return;
4044 if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) {
41 clearBrowserPointer(tabId);
45 clearBrowserPointer(runtimeTabId);
4246 }
4347 lastDesktopNavStatus.current = state.navStatus;
4448 applyPreviewDesktopState(threadRef, tabId, projectDesktopState(state));
......@@ -58,7 +62,7 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str
5862 });
5963 });
6064 return unsubscribe;
61 }, [bridge, clearBrowserPointer, reportStatus, tabId, threadRef]);
65 }, [bridge, clearBrowserPointer, reportStatus, runtimeTabId, tabId, threadRef]);
6266}
6367
6468function shouldClearBrowserPointer(
apps/web/src/components/preview/usePreviewSession.ts+4-4
......@@ -39,7 +39,6 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
3939
4040 return Atom.make((get) => {
4141 let disposed = false;
42 let sessionsVersion = 0;
4342 let eventsVersion = 0;
4443
4544 const reconcileSessions = (result: Atom.Type<typeof sessionsAtom>) => {
......@@ -60,10 +59,8 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
6059 get.addFinalizer(() => {
6160 disposed = true;
6261 });
63 const initialSessions = get.once(sessionsAtom);
6462 const initialEvent = get.once(eventsAtom);
6563 get.subscribe(sessionsAtom, (result) => {
66 sessionsVersion += 1;
6764 reconcileSessions(result);
6865 });
6966 get.subscribe(eventsAtom, (result) => {
......@@ -72,7 +69,10 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
7269 });
7370 queueMicrotask(() => {
7471 if (disposed) return;
75 if (sessionsVersion === 0) reconcileSessions(initialSessions);
72 // The cached list can predate an automation-created tab. Keep the local
73 // snapshot visible until an authoritative refresh arrives instead of
74 // reconciling against a stale empty result when the panel first mounts.
75 get.refresh(sessionsAtom);
7676 if (eventsVersion === 0) applyLatestEvent(initialEvent);
7777 });
7878 }).pipe(Atom.setIdleTTL(1_000), Atom.withLabel(`preview:session-sync:${threadKey}`));
apps/web/src/previewStateStore.test.ts+42
......@@ -480,6 +480,48 @@ describe("previewStateStore (single-tab)", () => {
480480 expect(state.serverRevision).toBe(0);
481481 });
482482
483 it("does not carry raw-tab state across a server restart", () => {
484 const previous = makeSnapshot({
485 navStatus: { _tag: "Success", url: "https://old.example", title: "Old" },
486 updatedAt: "2026-01-01T00:00:02.000Z",
487 });
488 applyPreviewServerEventImpl(ref, {
489 type: "opened",
490 threadId: "thread-1",
491 tabId: previous.tabId,
492 createdAt: previous.updatedAt,
493 serverEpoch,
494 revision: 12,
495 snapshot: previous,
496 });
497 beginPreviewSessionClose(ref, previous.tabId);
498 applyPreviewDesktopState(ref, previous.tabId, {
499 hasWebContents: true,
500 canGoBack: false,
501 canGoForward: false,
502 loading: false,
503 zoomFactor: 1,
504 pictureInPicture: false,
505 colorScheme: "system",
506 controller: "none",
507 });
508 const restarted = makeSnapshot({
509 navStatus: { _tag: "Success", url: "https://new.example", title: "New" },
510 updatedAt: "2026-01-01T00:00:01.000Z",
511 });
512 reconcilePreviewServerSessions(ref, {
513 sessions: [restarted],
514 serverEpoch: "server-b",
515 revision: 0,
516 });
517
518 const state = readThreadPreviewState(ref);
519 expect(state.sessions[restarted.tabId]).toEqual(restarted);
520 expect(state.suppressedTabIds).toEqual(new Set());
521 expect(state.desktopByTabId).toEqual({});
522 expect(state.desktopOverlay).toBeNull();
523 });
524
483525 it("applyServerSnapshot null clears snapshot for a thread that had one", () => {
484526 const snapshot = makeSnapshot();
485527 applyPreviewServerSnapshot(ref, snapshot);
apps/web/src/previewStateStore.ts+9-6
......@@ -323,10 +323,11 @@ export function reconcilePreviewServerSessions(
323323 if (sameServer && result.revision < current.serverRevision) return current;
324324 const snapshots = result.sessions;
325325 const sessions: Record<string, PreviewSessionSnapshot> = {};
326 const currentSuppressedTabIds = sameServer ? current.suppressedTabIds : new Set<string>();
326327 let recentlySeenUrls = current.recentlySeenUrls;
327328 for (const snapshot of snapshots) {
328 if (current.suppressedTabIds.has(snapshot.tabId)) continue;
329 const existing = current.sessions[snapshot.tabId];
329 if (currentSuppressedTabIds.has(snapshot.tabId)) continue;
330 const existing = sameServer ? current.sessions[snapshot.tabId] : undefined;
330331 const next = existing && existing.updatedAt > snapshot.updatedAt ? existing : snapshot;
331332 sessions[next.tabId] = next;
332333 recentlySeenUrls = rememberSnapshotUrl(recentlySeenUrls, next);
......@@ -338,11 +339,13 @@ export function reconcilePreviewServerSessions(
338339 ? current.activeTabId
339340 : (fallback?.tabId ?? null);
340341 const snapshot = activeTabId ? (sessions[activeTabId] ?? null) : null;
341 const desktopByTabId = Object.fromEntries(
342 Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined),
343 );
342 const desktopByTabId = sameServer
343 ? Object.fromEntries(
344 Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined),
345 )
346 : {};
344347 const suppressedTabIds = new Set(
345 [...current.suppressedTabIds].filter((tabId) =>
348 [...currentSuppressedTabIds].filter((tabId) =>
346349 snapshots.some((snapshot) => snapshot.tabId === tabId),
347350 ),
348351 );