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";...@@ -22,9 +22,17 @@ import * as BrowserSession from "./BrowserSession.ts";
22import * as PreviewManager from "./Manager.ts";22import * as PreviewManager from "./Manager.ts";
2323
24describe("fitPictureInPictureContentSize", () => {24describe("fitPictureInPictureContentSize", () => {
25 it("fits landscape and portrait viewports without letterboxing the window", () => {25 it("preserves the PiP content area across aspect-ratio changes", () => {
26 expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 16 / 9)).toEqual([480, 270]);26 expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 16 / 9)).toEqual([523, 294]);
27 expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 9 / 16)).toEqual([240, 427]);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]);
28 });36 });
29});37});
3038
...@@ -1065,7 +1073,7 @@ describe("PreviewManager", () => {...@@ -1065,7 +1073,7 @@ describe("PreviewManager", () => {
1065 skipTransformProcessType: true,1073 skipTransformProcessType: true,
1066 });1074 });
1067 expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]);1075 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);
1069 expect(pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[0]).toBeLessThan(1077 expect(pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[0]).toBeLessThan(
1070 pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0] ?? 0,1078 pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0] ?? 0,
1071 );1079 );
apps/desktop/src/preview/Manager.ts+7-3
...@@ -104,6 +104,7 @@ const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480;...@@ -104,6 +104,7 @@ const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480;
104const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;104const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;
105const PICTURE_IN_PICTURE_MIN_WIDTH = 240;105const PICTURE_IN_PICTURE_MIN_WIDTH = 240;
106const PICTURE_IN_PICTURE_MIN_HEIGHT = 160;106const PICTURE_IN_PICTURE_MIN_HEIGHT = 160;
107const PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON = 0.002;
107const DIAGNOSTIC_BUFFER_LIMIT = 200;108const DIAGNOSTIC_BUFFER_LIMIT = 200;
108const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;109const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;
109const AGENT_CURSOR_MOVE_MS = 160;110const AGENT_CURSOR_MOVE_MS = 160;
...@@ -164,8 +165,8 @@ export const fitPictureInPictureContentSize = (...@@ -164,8 +165,8 @@ export const fitPictureInPictureContentSize = (
164): readonly [width: number, height: number] => {165): readonly [width: number, height: number] => {
165 const currentWidth = Math.max(1, current[0] ?? PICTURE_IN_PICTURE_INITIAL_WIDTH);166 const currentWidth = Math.max(1, current[0] ?? PICTURE_IN_PICTURE_INITIAL_WIDTH);
166 const currentHeight = Math.max(1, current[1] ?? PICTURE_IN_PICTURE_INITIAL_HEIGHT);167 const currentHeight = Math.max(1, current[1] ?? PICTURE_IN_PICTURE_INITIAL_HEIGHT);
167 let width =168 const currentArea = currentWidth * currentHeight;
168 currentWidth / currentHeight > aspectRatio ? currentHeight * aspectRatio : currentWidth;169 let width = Math.sqrt(currentArea * aspectRatio);
169 let height = width / aspectRatio;170 let height = width / aspectRatio;
170 const minimumScale = Math.max(171 const minimumScale = Math.max(
171 1,172 1,
...@@ -2074,7 +2075,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2074,7 +2075,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2074 tabId,2075 tabId,
2075 );2076 );
2076 const aspectRatio = frame.width / frame.height;2077 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 ) {
2078 yield* attempt(2082 yield* attempt(
2079 {2083 {
2080 operation: "pictureInPicture.setAspectRatio",2084 operation: "pictureInPicture.setAspectRatio",
apps/web/src/browser/ElectronBrowserHost.tsx+9-2
...@@ -11,6 +11,7 @@ import { useActivePreviewSessions } from "~/previewStateStore";...@@ -11,6 +11,7 @@ import { useActivePreviewSessions } from "~/previewStateStore";
11import { readPreviewAnnotationTheme } from "./annotationTheme";11import { readPreviewAnnotationTheme } from "./annotationTheme";
12import { useBrowserPointerStore } from "./browserPointerStore";12import { useBrowserPointerStore } from "./browserPointerStore";
13import { HostedBrowserWebview } from "./HostedBrowserWebview";13import { HostedBrowserWebview } from "./HostedBrowserWebview";
14import { previewRuntimeTabId } from "./previewRuntimeTabId";
1415
15export function ElectronBrowserHost() {16export function ElectronBrowserHost() {
16 const { resolvedTheme } = useTheme();17 const { resolvedTheme } = useTheme();
...@@ -23,6 +24,11 @@ export function ElectronBrowserHost() {...@@ -23,6 +24,11 @@ export function ElectronBrowserHost() {
23 ? Object.values(previewState.sessions).map((snapshot) => ({24 ? Object.values(previewState.sessions).map((snapshot) => ({
24 threadRef,25 threadRef,
25 snapshot,26 snapshot,
27 runtimeTabId: previewRuntimeTabId(
28 threadRef,
29 previewState.serverEpoch,
30 snapshot.tabId,
31 ),
26 zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,32 zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,
27 }))33 }))
28 : [];34 : [];
...@@ -74,13 +80,14 @@ export function ElectronBrowserHost() {...@@ -74,13 +80,14 @@ export function ElectronBrowserHost() {
74 if (!isElectron) return null;80 if (!isElectron) return null;
75 return (81 return (
76 <div className="contents" data-electron-browser-host>82 <div className="contents" data-electron-browser-host>
77 {sessions.map(({ threadRef, snapshot, zoomFactor }) => {83 {sessions.map(({ threadRef, snapshot, runtimeTabId, zoomFactor }) => {
78 const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;84 const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
79 return (85 return (
80 <HostedBrowserWebview86 <HostedBrowserWebview
81 key={snapshot.tabId}87 key={runtimeTabId}
82 threadRef={threadRef}88 threadRef={threadRef}
83 tabId={snapshot.tabId}89 tabId={snapshot.tabId}
90 runtimeTabId={runtimeTabId}
84 initialUrl={url}91 initialUrl={url}
85 viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}92 viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}
86 zoomFactor={zoomFactor}93 zoomFactor={zoomFactor}
apps/web/src/browser/HostedBrowserWebview.tsx+37-41
...@@ -9,7 +9,11 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge";...@@ -9,7 +9,11 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
9import { cn } from "~/lib/utils";9import { cn } from "~/lib/utils";
1010
11import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";11import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
12import { browserViewportSettingKey, resolveBrowserViewportLayout } from "./browserViewportLayout";12import {
13 browserViewportSettingKey,
14 resolveBrowserViewportLayout,
15 resolveFittedBrowserViewport,
16} from "./browserViewportLayout";
13import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";17import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";
14import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";18import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
15import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";19import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";
...@@ -40,11 +44,12 @@ declare global {...@@ -40,11 +44,12 @@ declare global {
40export function HostedBrowserWebview(props: {44export function HostedBrowserWebview(props: {
41 readonly threadRef: ScopedThreadRef;45 readonly threadRef: ScopedThreadRef;
42 readonly tabId: string;46 readonly tabId: string;
47 readonly runtimeTabId: string;
43 readonly initialUrl: string | null;48 readonly initialUrl: string | null;
44 readonly viewport: PreviewViewportSetting;49 readonly viewport: PreviewViewportSetting;
45 readonly zoomFactor: number;50 readonly zoomFactor: number;
46}) {51}) {
47 const { threadRef, tabId, initialUrl, viewport, zoomFactor } = props;52 const { threadRef, tabId, runtimeTabId, initialUrl, viewport, zoomFactor } = props;
48 const config = usePreviewWebviewConfig(threadRef.environmentId);53 const config = usePreviewWebviewConfig(threadRef.environmentId);
49 const [initialSrc] = useState(() => initialUrl ?? "about:blank");54 const [initialSrc] = useState(() => initialUrl ?? "about:blank");
50 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);55 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
...@@ -54,27 +59,28 @@ export function HostedBrowserWebview(props: {...@@ -54,27 +59,28 @@ export function HostedBrowserWebview(props: {
54 const [aspectRatioLocked, setAspectRatioLocked] = useState(false);59 const [aspectRatioLocked, setAspectRatioLocked] = useState(false);
55 const presentation = useBrowserSurfaceStore(60 const presentation = useBrowserSurfaceStore(
56 useShallow((state) => {61 useShallow((state) => {
57 const current = state.byTabId[tabId];62 const current = state.byTabId[runtimeTabId];
58 return {63 return {
64 content: current?.content ?? null,
59 cornerRadius: current?.cornerRadius ?? 0,65 cornerRadius: current?.cornerRadius ?? 0,
60 fitSourceContent: current?.fitSourceContent ?? false,66 fitSourceContent: current?.fitSourceContent ?? false,
61 fittedSourceContent: current?.fittedSourceContent ?? null,67 fittedSourceContent: current?.fittedSourceContent ?? null,
62 rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId),68 rect: resolveBrowserSurfacePanelRect(state.byTabId, runtimeTabId),
63 visible: current?.visible ?? false,69 visible: current?.visible ?? false,
64 };70 };
65 }),71 }),
66 );72 );
67 usePreviewBridge({ threadRef, tabId });73 usePreviewBridge({ threadRef, tabId, runtimeTabId });
6874
69 useEffect(() => {75 useEffect(() => {
70 crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE;76 crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE;
71 const lease = acquireDesktopTab(tabId);77 const lease = acquireDesktopTab(runtimeTabId);
72 tabLeaseRef.current = lease;78 tabLeaseRef.current = lease;
73 return () => {79 return () => {
74 if (tabLeaseRef.current === lease) tabLeaseRef.current = null;80 if (tabLeaseRef.current === lease) tabLeaseRef.current = null;
75 lease.release();81 lease.release();
76 };82 };
77 }, [tabId]);83 }, [runtimeTabId]);
7884
79 const [webviewGeneration, setWebviewGeneration] = useState(0);85 const [webviewGeneration, setWebviewGeneration] = useState(0);
80 const [recoverySrc, setRecoverySrc] = useState(initialSrc);86 const [recoverySrc, setRecoverySrc] = useState(initialSrc);
...@@ -107,7 +113,7 @@ export function HostedBrowserWebview(props: {...@@ -107,7 +113,7 @@ export function HostedBrowserWebview(props: {
107 if (disposed || webviewRef.current !== webview) return;113 if (disposed || webviewRef.current !== webview) return;
108 const webContentsId = webview.getWebContentsId();114 const webContentsId = webview.getWebContentsId();
109 if (Number.isInteger(webContentsId) && webContentsId > 0) {115 if (Number.isInteger(webContentsId) && webContentsId > 0) {
110 await bridge.registerWebview(tabId, webContentsId);116 await bridge.registerWebview(runtimeTabId, webContentsId);
111 }117 }
112 } catch {118 } catch {
113 // did-attach/dom-ready will retry if the guest was not ready yet.119 // did-attach/dom-ready will retry if the guest was not ready yet.
...@@ -138,7 +144,7 @@ export function HostedBrowserWebview(props: {...@@ -138,7 +144,7 @@ export function HostedBrowserWebview(props: {
138 webview.removeEventListener("dom-ready", register);144 webview.removeEventListener("dom-ready", register);
139 webview.removeEventListener("render-process-gone", recoverGuest);145 webview.removeEventListener("render-process-gone", recoverGuest);
140 };146 };
141 }, [config, initialSrc, tabId, webviewGeneration]);147 }, [config, initialSrc, runtimeTabId, webviewGeneration]);
142148
143 const active = presentation.visible && presentation.rect !== null;149 const active = presentation.visible && presentation.rect !== null;
144 const lastRect = presentation.rect;150 const lastRect = presentation.rect;
...@@ -152,13 +158,22 @@ export function HostedBrowserWebview(props: {...@@ -152,13 +158,22 @@ export function HostedBrowserWebview(props: {
152 const handleAspectRatioChange = useCallback((aspectRatio: number | null) => {158 const handleAspectRatioChange = useCallback((aspectRatio: number | null) => {
153 setAspectRatioLocked(aspectRatio !== null);159 setAspectRatioLocked(aspectRatio !== null);
154 }, []);160 }, []);
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;
155 const hiddenSize =167 const hiddenSize =
156 viewport._tag !== "fill"168 viewport._tag !== "fill"
157 ? {169 ? {
158 width: viewport.width * normalizedZoomFactor,170 width: viewport.width * normalizedZoomFactor,
159 height: viewport.height * normalizedZoomFactor,171 height: viewport.height * normalizedZoomFactor,
160 }172 }
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 };
162 const containerSize = active && lastRect ? lastRect : hiddenSize;177 const containerSize = active && lastRect ? lastRect : hiddenSize;
163 const deviceToolbarVisible = active && viewport._tag !== "fill" && !presentation.fitSourceContent;178 const deviceToolbarVisible = active && viewport._tag !== "fill" && !presentation.fitSourceContent;
164 const {179 const {
...@@ -169,7 +184,7 @@ export function HostedBrowserWebview(props: {...@@ -169,7 +184,7 @@ export function HostedBrowserWebview(props: {
169 handleResizePointerDown,184 handleResizePointerDown,
170 layout: viewportLayout,185 layout: viewportLayout,
171 } = useBrowserViewportResize({186 } = useBrowserViewportResize({
172 tabId,187 tabId: runtimeTabId,
173 viewport,188 viewport,
174 zoomFactor,189 zoomFactor,
175 containerSize,190 containerSize,
...@@ -178,31 +193,11 @@ export function HostedBrowserWebview(props: {...@@ -178,31 +193,11 @@ export function HostedBrowserWebview(props: {
178 });193 });
179 const fittedSourceViewport =194 const fittedSourceViewport =
180 presentation.fitSourceContent && lastRect195 presentation.fitSourceContent && lastRect
181 ? presentation.fittedSourceContent196 ? resolveFittedBrowserViewport(
182 ? {197 viewport,
183 _tag: "freeform" as const,198 presentation.fittedSourceContent,
184 width: Math.max(199 normalizedZoomFactor,
185 1,200 )
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 }
206 : null;201 : null;
207 const layout =202 const layout =
208 fittedSourceViewport && lastRect203 fittedSourceViewport && lastRect
...@@ -212,7 +207,7 @@ export function HostedBrowserWebview(props: {...@@ -212,7 +207,7 @@ export function HostedBrowserWebview(props: {
212 const syncContentPresentation = useCallback(() => {207 const syncContentPresentation = useCallback(() => {
213 const wrapper = wrapperRef.current;208 const wrapper = wrapperRef.current;
214 if (!wrapper) return;209 if (!wrapper) return;
215 useBrowserSurfaceStore.getState().presentContent(tabId, {210 useBrowserSurfaceStore.getState().presentContent(runtimeTabId, {
216 x: layout.viewportX,211 x: layout.viewportX,
217 y: layout.viewportY,212 y: layout.viewportY,
218 width: layout.viewportWidth,213 width: layout.viewportWidth,
...@@ -221,7 +216,7 @@ export function HostedBrowserWebview(props: {...@@ -221,7 +216,7 @@ export function HostedBrowserWebview(props: {
221 scrollLeft: wrapper.scrollLeft,216 scrollLeft: wrapper.scrollLeft,
222 scrollTop: wrapper.scrollTop,217 scrollTop: wrapper.scrollTop,
223 });218 });
224 }, [layout, tabId]);219 }, [layout, runtimeTabId]);
225220
226 useEffect(() => {221 useEffect(() => {
227 const frameId = window.requestAnimationFrame(syncContentPresentation);222 const frameId = window.requestAnimationFrame(syncContentPresentation);
...@@ -232,7 +227,7 @@ export function HostedBrowserWebview(props: {...@@ -232,7 +227,7 @@ export function HostedBrowserWebview(props: {
232 const wrapper = wrapperRef.current;227 const wrapper = wrapperRef.current;
233 if (!wrapper) return;228 if (!wrapper) return;
234 wrapper.scrollTo({ left: 0, top: 0 });229 wrapper.scrollTo({ left: 0, top: 0 });
235 }, [tabId, viewport._tag, viewportHeight, viewportWidth]);230 }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]);
236231
237 if (!config) return null;232 if (!config) return null;
238233
...@@ -249,7 +244,7 @@ export function HostedBrowserWebview(props: {...@@ -249,7 +244,7 @@ export function HostedBrowserWebview(props: {
249 className="fixed overflow-hidden bg-muted/35"244 className="fixed overflow-hidden bg-muted/35"
250 style={{ ...wrapperStyle, overscrollBehavior: "contain" }}245 style={{ ...wrapperStyle, overscrollBehavior: "contain" }}
251 onScroll={syncContentPresentation}246 onScroll={syncContentPresentation}
252 data-preview-viewport={tabId}247 data-preview-viewport={runtimeTabId}
253 >248 >
254 <div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}>249 <div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}>
255 {deviceToolbarVisible && effectiveViewport._tag !== "fill" ? (250 {deviceToolbarVisible && effectiveViewport._tag !== "fill" ? (
...@@ -268,7 +263,8 @@ export function HostedBrowserWebview(props: {...@@ -268,7 +263,8 @@ export function HostedBrowserWebview(props: {
268 partition={config.partition}263 partition={config.partition}
269 webpreferences={config.webPreferences}264 webpreferences={config.webPreferences}
270 {...(config.preloadUrl ? { preload: config.preloadUrl } : {})}265 {...(config.preloadUrl ? { preload: config.preloadUrl } : {})}
271 data-preview-tab={tabId}266 data-preview-tab={runtimeTabId}
267 data-preview-server-tab={tabId}
272 data-preview-viewport-mode={effectiveViewport._tag}268 data-preview-viewport-mode={effectiveViewport._tag}
273 data-preview-viewport-key={browserViewportSettingKey(effectiveViewport)}269 data-preview-viewport-key={browserViewportSettingKey(effectiveViewport)}
274 data-preview-css-width={270 data-preview-css-width={
apps/web/src/browser/browserRecording.test.ts+43
...@@ -89,10 +89,13 @@ import {...@@ -89,10 +89,13 @@ import {
89 BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS,89 BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS,
90 BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,90 BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,
91 BrowserRecordingConflictError,91 BrowserRecordingConflictError,
92 findActiveBrowserRecordingRuntimeTabId,
92 readActiveBrowserRecordingTabIds,93 readActiveBrowserRecordingTabIds,
94 readActiveBrowserRecordingTargets,
93 startBrowserRecording,95 startBrowserRecording,
94 stopBrowserRecording,96 stopBrowserRecording,
95} from "./browserRecording";97} from "./browserRecording";
98import { previewRuntimeTabId } from "./previewRuntimeTabId";
9699
97class FakeMediaRecorder {100class FakeMediaRecorder {
98 static isTypeSupported(): boolean {101 static isTypeSupported(): boolean {
...@@ -363,6 +366,46 @@ describe("browser recording", () => {...@@ -363,6 +366,46 @@ describe("browser recording", () => {
363 expect(save).toHaveBeenCalledTimes(2);366 expect(save).toHaveBeenCalledTimes(2);
364 });367 });
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
366 it("does not report success for a second start while the first is still starting", async () => {409 it("does not report success for a second start while the first is still starting", async () => {
367 let finishStartingScreencast: (() => void) | undefined;410 let finishStartingScreencast: (() => void) | undefined;
368 startScreencast.mockImplementationOnce(async (tabId: string) => {411 startScreencast.mockImplementationOnce(async (tabId: string) => {
apps/web/src/browser/browserRecording.ts+40
...@@ -82,7 +82,10 @@ type BrowserRecordingLifecycle =...@@ -82,7 +82,10 @@ type BrowserRecordingLifecycle =
82 };82 };
8383
84interface ActiveRecording {84interface ActiveRecording {
85 /** Desktop-scoped identity used by capture and surface stores. */
85 readonly tabId: string;86 readonly tabId: string;
87 /** Server-local identity returned by preview automation tools. */
88 readonly serverTabId: string;
86 readonly threadRef: ScopedThreadRef | null;89 readonly threadRef: ScopedThreadRef | null;
87 readonly canvas: HTMLCanvasElement;90 readonly canvas: HTMLCanvasElement;
88 readonly context: CanvasRenderingContext2D;91 readonly context: CanvasRenderingContext2D;
...@@ -99,6 +102,11 @@ interface ActiveRecording {...@@ -99,6 +102,11 @@ interface ActiveRecording {
99 lifecycle: BrowserRecordingLifecycle;102 lifecycle: BrowserRecordingLifecycle;
100}103}
101104
105export interface ActiveBrowserRecordingTarget {
106 readonly runtimeTabId: string;
107 readonly serverTabId: string;
108}
109
102interface ActiveBrowserRecordingIndex {110interface ActiveBrowserRecordingIndex {
103 readonly tabIds: ReadonlySet<string>;111 readonly tabIds: ReadonlySet<string>;
104}112}
...@@ -131,6 +139,28 @@ export function readActiveBrowserRecordingTabIds(threadRef?: ScopedThreadRef): R...@@ -131,6 +139,28 @@ export function readActiveBrowserRecordingTabIds(threadRef?: ScopedThreadRef): R
131 return tabIds;139 return tabIds;
132}140}
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
134const preferredMimeType = (): string => {164const preferredMimeType = (): string => {
135 const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"];165 const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"];
136 return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm";166 return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm";
...@@ -283,6 +313,7 @@ const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperatio...@@ -283,6 +313,7 @@ const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperatio
283export async function startBrowserRecording(313export async function startBrowserRecording(
284 tabId: string,314 tabId: string,
285 threadRef: ScopedThreadRef | null = null,315 threadRef: ScopedThreadRef | null = null,
316 serverTabId = tabId,
286): Promise<string> {317): Promise<string> {
287 const bridge = previewBridge;318 const bridge = previewBridge;
288 if (!bridge) throw new BrowserRecordingUnavailableError({ tabId });319 if (!bridge) throw new BrowserRecordingUnavailableError({ tabId });
...@@ -296,6 +327,14 @@ export async function startBrowserRecording(...@@ -296,6 +327,14 @@ export async function startBrowserRecording(
296 activeTabId: activeRecording.tabId,327 activeTabId: activeRecording.tabId,
297 });328 });
298 }329 }
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 }
299 const surface = useBrowserSurfaceStore.getState().byTabId[tabId];338 const surface = useBrowserSurfaceStore.getState().byTabId[tabId];
300 const recordingSize = surface?.content ?? surface?.rect;339 const recordingSize = surface?.content ?? surface?.rect;
301 const canvas = document.createElement("canvas");340 const canvas = document.createElement("canvas");
...@@ -321,6 +360,7 @@ export async function startBrowserRecording(...@@ -321,6 +360,7 @@ export async function startBrowserRecording(
321 });360 });
322 const recording: ActiveRecording = {361 const recording: ActiveRecording = {
323 tabId,362 tabId,
363 serverTabId,
324 threadRef,364 threadRef,
325 canvas,365 canvas,
326 context,366 context,
apps/web/src/browser/browserSurfaceStore.test.ts+2-2
...@@ -86,7 +86,7 @@ describe("browserSurfaceStore", () => {...@@ -86,7 +86,7 @@ describe("browserSurfaceStore", () => {
86 });86 });
87 });87 });
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", () => {
90 const staleRect = { x: 0, y: 0, width: 500, height: 700 };90 const staleRect = { x: 0, y: 0, width: 500, height: 700 };
91 const liveRect = { x: 10, y: 20, width: 900, height: 640 };91 const liveRect = { x: 10, y: 20, width: 900, height: 640 };
92 expect(92 expect(
...@@ -115,7 +115,7 @@ describe("browserSurfaceStore", () => {...@@ -115,7 +115,7 @@ describe("browserSurfaceStore", () => {
115 },115 },
116 "hidden",116 "hidden",
117 ),117 ),
118 ).toEqual(liveRect);118 ).toEqual(staleRect);
119 });119 });
120120
121 it("ignores updates and releases from a stale surface lease", () => {121 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(...@@ -52,19 +52,7 @@ export function resolveBrowserSurfacePanelRect(
52 tabId: string,52 tabId: string,
53): BrowserSurfaceRect | null {53): BrowserSurfaceRect | null {
54 const current = byTabId[tabId];54 const current = byTabId[tabId];
55 if (current?.visible && current.rect) return current.rect;55 return current?.rect ?? null;
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;
68}56}
6957
70const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean =>58const 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";...@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vite-plus/test";
4import {4import {
5 BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS,5 BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS,
6 commitBrowserViewportChange,6 commitBrowserViewportChange,
7 runBrowserViewportMutation,
7 subscribeBrowserViewportChange,8 subscribeBrowserViewportChange,
8} from "./browserViewportActions";9} from "./browserViewportActions";
910
...@@ -71,6 +72,33 @@ describe("browserViewportActions", () => {...@@ -71,6 +72,33 @@ describe("browserViewportActions", () => {
71 unsubscribe();72 unsubscribe();
72 });73 });
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
74 it("does not let a timed-out handler overtake a newer viewport commit", async () => {102 it("does not let a timed-out handler overtake a newer viewport commit", async () => {
75 vi.useFakeTimers();103 vi.useFakeTimers();
76 try {104 try {
apps/web/src/browser/browserViewportActions.ts+43-19
...@@ -15,6 +15,41 @@ export class BrowserViewportCommitTimeoutError extends Error {...@@ -15,6 +15,41 @@ export class BrowserViewportCommitTimeoutError extends Error {
15const handlers = new Map<string, BrowserViewportHandler>();15const handlers = new Map<string, BrowserViewportHandler>();
16const commitTails = new Map<string, Promise<void>>();16const 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
18const runHandlerWithTimeout = (tabId: string, operation: Promise<void>): Promise<void> => {53const runHandlerWithTimeout = (tabId: string, operation: Promise<void>): Promise<void> => {
19 let timeoutId: ReturnType<typeof setTimeout> | undefined;54 let timeoutId: ReturnType<typeof setTimeout> | undefined;
20 const timeout = new Promise<never>((_resolve, reject) => {55 const timeout = new Promise<never>((_resolve, reject) => {
...@@ -42,25 +77,14 @@ export function commitBrowserViewportChange(...@@ -42,25 +77,14 @@ export function commitBrowserViewportChange(
42 tabId: string,77 tabId: string,
43 setting: PreviewViewportSetting,78 setting: PreviewViewportSetting,
44): Promise<void> {79): Promise<void> {
45 const previous = commitTails.get(tabId) ?? Promise.resolve();80 const { started } = queueBrowserViewportMutation(tabId, () => {
46 const started = previous81 const handler = handlers.get(tabId);
47 .catch(() => undefined)82 return handler
48 .then(() => {83 ? handler(setting)
49 const handler = handlers.get(tabId);84 : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`));
50 const operation = handler85 });
51 ? Promise.resolve().then(() => handler(setting))86 // The queue follows the real handler lifetime, while the caller-facing
52 : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`));87 // timeout starts only once this commit reaches the front of that queue.
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);
59 const result = started.then(({ operation }) => runHandlerWithTimeout(tabId, operation));88 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);
65 return result;89 return result;
66}90}
apps/web/src/browser/browserViewportLayout.test.ts+16
...@@ -4,11 +4,27 @@ import {...@@ -4,11 +4,27 @@ import {
4 resizeBrowserViewportFromRail,4 resizeBrowserViewportFromRail,
5 resizeFreeformViewport,5 resizeFreeformViewport,
6 resolveBrowserDeviceViewportLayout,6 resolveBrowserDeviceViewportLayout,
7 resolveFittedBrowserViewport,
7 resolveBrowserViewportLayout,8 resolveBrowserViewportLayout,
8 resolveResponsiveBrowserViewportSize,9 resolveResponsiveBrowserViewportSize,
9} from "./browserViewportLayout";10} from "./browserViewportLayout";
1011
11describe("resolveBrowserViewportLayout", () => {12describe("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
12 it("fills the available surface in fill mode", () => {28 it("fills the available surface in fill mode", () => {
13 expect(resolveBrowserViewportLayout({ width: 700, height: 500 }, { _tag: "fill" })).toEqual({29 expect(resolveBrowserViewportLayout({ width: 700, height: 500 }, { _tag: "fill" })).toEqual({
14 canvasWidth: 700,30 canvasWidth: 700,
apps/web/src/browser/browserViewportLayout.ts+27
...@@ -40,6 +40,33 @@ export const browserViewportSettingKey = (setting: PreviewViewportSetting): stri...@@ -40,6 +40,33 @@ export const browserViewportSettingKey = (setting: PreviewViewportSetting): stri
40const normalizeZoomFactor = (zoomFactor: number): number =>40const normalizeZoomFactor = (zoomFactor: number): number =>
41 Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;41 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
43export function resolveBrowserDeviceViewportArea(container: {70export function resolveBrowserDeviceViewportArea(container: {
44 readonly width: number;71 readonly width: number;
45 readonly height: number;72 readonly height: number;
apps/web/src/browser/desktopTabLifetime.test.ts+35
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
23
3const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({4const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({
...@@ -15,6 +16,7 @@ vi.mock("./browserRecording", () => ({...@@ -15,6 +16,7 @@ vi.mock("./browserRecording", () => ({
15}));16}));
1617
17import { acquireDesktopTab } from "./desktopTabLifetime";18import { acquireDesktopTab } from "./desktopTabLifetime";
19import { previewRuntimeTabId } from "./previewRuntimeTabId";
1820
19describe("desktopTabLifetime", () => {21describe("desktopTabLifetime", () => {
20 beforeEach(() => {22 beforeEach(() => {
...@@ -55,6 +57,39 @@ describe("desktopTabLifetime", () => {...@@ -55,6 +57,39 @@ describe("desktopTabLifetime", () => {
55 expect(ready).toBe(true);57 expect(ready).toBe(true);
56 });58 });
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
58 it("stops recording before closing the final desktop tab lease", async () => {93 it("stops recording before closing the final desktop tab lease", async () => {
59 vi.useFakeTimers();94 vi.useFakeTimers();
60 let resolveStop: (() => void) | undefined;95 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 {...@@ -32,12 +32,14 @@ import {
32import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";32import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
33import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";33import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";
34import {34import {
35 readActiveBrowserRecordingTabIds,35 readActiveBrowserRecordingTargets,
36 startBrowserRecording,36 startBrowserRecording,
37 stopBrowserRecording,37 stopBrowserRecording,
38} from "~/browser/browserRecording";38} from "~/browser/browserRecording";
39import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope";39import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope";
40import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";40import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
41import { runBrowserViewportMutation } from "~/browser/browserViewportActions";
42import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
41import { isElectron } from "~/env";43import { isElectron } from "~/env";
42import { useEnvironments } from "~/state/environments";44import { useEnvironments } from "~/state/environments";
43import { previewEnvironment } from "~/state/preview";45import { previewEnvironment } from "~/state/preview";
...@@ -46,7 +48,6 @@ import { useAtomCommand } from "~/state/use-atom-command";...@@ -46,7 +48,6 @@ import { useAtomCommand } from "~/state/use-atom-command";
4648
47import { previewBridge } from "./previewBridge";49import { previewBridge } from "./previewBridge";
48import {50import {
49 PreviewAutomationNavigationTimeoutError,
50 PreviewAutomationOperationError,51 PreviewAutomationOperationError,
51 PreviewAutomationOverlayTimeoutError,52 PreviewAutomationOverlayTimeoutError,
52 PreviewAutomationRecordingNotActiveError,53 PreviewAutomationRecordingNotActiveError,
...@@ -54,9 +55,14 @@ import {...@@ -54,9 +55,14 @@ import {
54 PreviewAutomationViewportTimeoutError,55 PreviewAutomationViewportTimeoutError,
55} from "./previewAutomationErrors";56} from "./previewAutomationErrors";
56import {57import {
58 previewAutomationDefaultViewport,
57 previewAutomationOpenNeedsOverlay,59 previewAutomationOpenNeedsOverlay,
58 shouldOpenPreviewMiniPlayer,60 shouldOpenPreviewMiniPlayer,
59} from "./previewAutomationOpenReadiness";61} from "./previewAutomationOpenReadiness";
62import {
63 assertPreviewRuntimeCurrent,
64 waitForNavigationReadiness,
65} from "./previewNavigationReadiness";
60import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer";66import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer";
61import { createPreviewAutomationClientId } from "./previewAutomationClientId";67import { createPreviewAutomationClientId } from "./previewAutomationClientId";
62import {68import {
...@@ -65,58 +71,42 @@ import {...@@ -65,58 +71,42 @@ import {
65 resolvePreviewAutomationTarget,71 resolvePreviewAutomationTarget,
66} from "./previewAutomationTarget";72} from "./previewAutomationTarget";
67import { isPreviewViewportReady } from "./previewViewportReadiness";73import { isPreviewViewportReady } from "./previewViewportReadiness";
74import { shouldRollbackPreviewViewport } from "./previewViewportRollback";
6875
69const waitForDesktopOverlay = async (76const PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS = 500;
70 threadRef: ScopedThreadRef,77
71 requestId: string,78const waitForPreviewPresentation = async (runtimeTabId: string): Promise<void> => {
72 tabId: string,79 const deadline = Date.now() + PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS;
73 timeoutMs: number,
74): Promise<void> => {
75 const deadline = Date.now() + timeoutMs;
76 while (Date.now() <= deadline) {80 while (Date.now() <= deadline) {
77 const state = readThreadPreviewState(threadRef);81 if (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible) return;
78 if (state.desktopByTabId[tabId] && previewBridge) {82 await new Promise<void>((resolve) => window.setTimeout(resolve, 16));
79 const status = await previewBridge.automation.status(tabId);
80 if (status.available) return;
81 }
82 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
83 }83 }
84 throw new PreviewAutomationOverlayTimeoutError({
85 requestId,
86 environmentId: threadRef.environmentId,
87 threadId: threadRef.threadId,
88 timeoutMs,
89 });
90};84};
9185
92const waitForNavigationReadiness = async (86const waitForDesktopOverlay = async (
93 threadRef: ScopedThreadRef,87 threadRef: ScopedThreadRef,
94 requestId: string,88 requestId: string,
95 tabId: string,89 tabId: string,
96 readiness: PreviewAutomationNavigateInput["readiness"],90 runtimeTabId: string,
91 operation: PreviewAutomationRequest["operation"],
97 timeoutMs: number,92 timeoutMs: number,
98): Promise<void> => {93): Promise<void> => {
99 const targetReadiness = readiness ?? "load";
100 if (!previewBridge || targetReadiness === "none") return;
101 const deadline = Date.now() + timeoutMs;94 const deadline = Date.now() + timeoutMs;
102 while (Date.now() <= deadline) {95 while (Date.now() <= deadline) {
103 if (targetReadiness === "domContentLoaded") {96 const state = assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, {
104 const readyState = await previewBridge.automation.evaluate(tabId, {97 operation,
105 expression: "document.readyState",98 requestId,
106 });99 });
107 if (readyState === "interactive" || readyState === "complete") return;100 if (state.desktopByTabId[tabId] && previewBridge) {
108 } else {101 const status = await previewBridge.automation.status(runtimeTabId);
109 const status = await previewBridge.automation.status(tabId);102 if (status.available) return;
110 if (!status.loading) return;
111 }103 }
112 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));104 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
113 }105 }
114 throw new PreviewAutomationNavigationTimeoutError({106 throw new PreviewAutomationOverlayTimeoutError({
115 requestId,107 requestId,
116 environmentId: threadRef.environmentId,108 environmentId: threadRef.environmentId,
117 threadId: threadRef.threadId,109 threadId: threadRef.threadId,
118 tabId,
119 readiness: targetReadiness,
120 timeoutMs,110 timeoutMs,
121 });111 });
122};112};
...@@ -148,8 +138,10 @@ const readWebviewViewport = async (...@@ -148,8 +138,10 @@ const readWebviewViewport = async (
148 : null;138 : null;
149};139};
150140
151const readRenderedViewport = async (tabId: string): Promise<PreviewRenderedViewportSize | null> => {141const readRenderedViewport = async (
152 const webview = findPreviewWebview(tabId);142 runtimeTabId: string,
143): Promise<PreviewRenderedViewportSize | null> => {
144 const webview = findPreviewWebview(runtimeTabId);
153 if (!webview) return null;145 if (!webview) return null;
154 return await readWebviewViewport(webview);146 return await readWebviewViewport(webview);
155};147};
...@@ -165,19 +157,23 @@ const readDeclaredViewport = (...@@ -165,19 +157,23 @@ const readDeclaredViewport = (
165};157};
166158
167const waitForRenderedViewport = async (159const waitForRenderedViewport = async (
160 threadRef: ScopedThreadRef,
168 tabId: string,161 tabId: string,
162 runtimeTabId: string,
169 setting: PreviewViewportSetting,163 setting: PreviewViewportSetting,
170 timeoutMs: number,164 timeoutMs: number,
171 context: {165 context: {
172 readonly requestId: PreviewAutomationRequest["requestId"];166 readonly requestId: PreviewAutomationRequest["requestId"];
167 readonly operation: PreviewAutomationRequest["operation"];
173 readonly environmentId: EnvironmentId;168 readonly environmentId: EnvironmentId;
174 readonly threadId: PreviewAutomationRequest["threadId"];169 readonly threadId: PreviewAutomationRequest["threadId"];
175 },170 },
176): Promise<PreviewRenderedViewportSize> => {171): Promise<PreviewRenderedViewportSize> => {
177 const deadline = Date.now() + timeoutMs;172 const deadline = Date.now() + timeoutMs;
178 while (Date.now() <= deadline) {173 while (Date.now() <= deadline) {
174 assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, context);
179 try {175 try {
180 const webview = findPreviewWebview(tabId);176 const webview = findPreviewWebview(runtimeTabId);
181 const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null;177 const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null;
182 const declaredViewport = readDeclaredViewport(webview);178 const declaredViewport = readDeclaredViewport(webview);
183 const renderedViewport = webview ? await readWebviewViewport(webview) : null;179 const renderedViewport = webview ? await readWebviewViewport(webview) : null;
...@@ -211,18 +207,19 @@ const currentStatus = async (...@@ -211,18 +207,19 @@ const currentStatus = async (
211): Promise<PreviewAutomationStatus> => {207): Promise<PreviewAutomationStatus> => {
212 const state = readThreadPreviewState(threadRef);208 const state = readThreadPreviewState(threadRef);
213 const { snapshot, tabId } = resolvePreviewAutomationTarget(state, requestedTabId);209 const { snapshot, tabId } = resolvePreviewAutomationTarget(state, requestedTabId);
214 const visible = tabId210 const runtimeTabId = tabId ? previewRuntimeTabId(threadRef, state.serverEpoch, tabId) : null;
215 ? (useBrowserSurfaceStore.getState().byTabId[tabId]?.visible ?? false)211 const visible = runtimeTabId
212 ? (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible ?? false)
216 : false;213 : false;
217 const viewportSetting = snapshot ? (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT) : undefined;214 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;
219 const viewportStatus = {216 const viewportStatus = {
220 ...(viewportSetting === undefined ? {} : { viewportSetting }),217 ...(viewportSetting === undefined ? {} : { viewportSetting }),
221 ...(viewport === null ? {} : { viewport }),218 ...(viewport === null ? {} : { viewport }),
222 };219 };
223 if (tabId && previewBridge && state.desktopByTabId[tabId]) {220 if (runtimeTabId && tabId && previewBridge && state.desktopByTabId[tabId]) {
224 const status = await previewBridge.automation.status(tabId);221 const status = await previewBridge.automation.status(runtimeTabId);
225 return { ...status, visible, ...viewportStatus };222 return { ...status, tabId, visible, ...viewportStatus };
226 }223 }
227 const navStatus = snapshot?.navStatus;224 const navStatus = snapshot?.navStatus;
228 return {225 return {
...@@ -340,8 +337,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -340,8 +337,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
340 if (!bridge || !readyTabId) {337 if (!bridge || !readyTabId) {
341 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);338 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
342 }339 }
343 await waitForDesktopOverlay(threadRef, request.requestId, readyTabId, request.timeoutMs);340 const readyState = readThreadPreviewState(threadRef);
344 return { bridge, tabId: readyTabId };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 };
345 };355 };
346 switch (request.operation) {356 switch (request.operation) {
347 case "status":357 case "status":
...@@ -381,7 +391,45 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -381,7 +391,45 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
381 activeSnapshot = snapshot;391 activeSnapshot = snapshot;
382 tabId = activeTabId;392 tabId = activeTabId;
383 }393 }
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) {
385 usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId);433 usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId);
386 }434 }
387 if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {435 if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {
...@@ -389,15 +437,27 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -389,15 +437,27 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
389 threadRef,437 threadRef,
390 request.requestId,438 request.requestId,
391 activeTabId,439 activeTabId,
440 activeRuntimeTabId,
441 request.operation,
392 request.timeoutMs,442 request.timeoutMs,
393 );443 );
394 }444 }
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 }
395 if (reusedExistingTab && resolvedInputUrl && previewBridge) {452 if (reusedExistingTab && resolvedInputUrl && previewBridge) {
396 await previewBridge.navigate(activeTabId, resolvedInputUrl);453 assertPreviewRuntimeCurrent(threadRef, activeTabId, activeRuntimeTabId, request);
454 await previewBridge.navigate(activeRuntimeTabId, resolvedInputUrl);
397 await waitForNavigationReadiness(455 await waitForNavigationReadiness(
398 threadRef,456 threadRef,
399 request.requestId,457 request.requestId,
400 activeTabId,458 activeTabId,
459 activeRuntimeTabId,
460 request.operation,
401 "load",461 "load",
402 request.timeoutMs,462 request.timeoutMs,
403 );463 );
...@@ -414,11 +474,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -414,11 +474,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
414 url: input.url!,474 url: input.url!,
415 },475 },
416 );476 );
417 await ready.bridge.navigate(ready.tabId, resolution.resolvedUrl);477 await ready.bridge.navigate(ready.runtimeTabId, resolution.resolvedUrl);
418 await waitForNavigationReadiness(478 await waitForNavigationReadiness(
419 threadRef,479 threadRef,
420 request.requestId,480 request.requestId,
421 ready.tabId,481 ready.tabId,
482 ready.runtimeTabId,
483 request.operation,
422 input.readiness ?? "load",484 input.readiness ?? "load",
423 input.timeoutMs ?? request.timeoutMs,485 input.timeoutMs ?? request.timeoutMs,
424 );486 );
...@@ -428,28 +490,76 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -428,28 +490,76 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
428 const ready = await requireReadyTab();490 const ready = await requireReadyTab();
429 const input = request.input as PreviewAutomationResizeInput;491 const input = request.input as PreviewAutomationResizeInput;
430 const setting = resolvePreviewViewport(input);492 const setting = resolvePreviewViewport(input);
431 const result = await resize({493 const applied = await runBrowserViewportMutation(ready.runtimeTabId, async () => {
432 environmentId,494 const operationState = assertPreviewRuntimeCurrent(
433 input: {495 threadRef,
434 threadId: request.threadId,496 ready.tabId,
435 tabId: ready.tabId,497 ready.runtimeTabId,
436 viewport: setting,498 request,
437 },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 };
438 });518 });
439 if (result._tag === "Failure") {519 let viewport: PreviewRenderedViewportSize;
440 return raiseAtomCommandFailure(result);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;
441 }562 }
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 );
453 return {563 return {
454 tabId: ready.tabId,564 tabId: ready.tabId,
455 setting,565 setting,
...@@ -459,7 +569,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -459,7 +569,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
459 case "setColorScheme": {569 case "setColorScheme": {
460 const ready = await requireReadyTab();570 const ready = await requireReadyTab();
461 const input = request.input as PreviewAutomationSetColorSchemeInput;571 const input = request.input as PreviewAutomationSetColorSchemeInput;
462 await ready.bridge.setColorScheme(ready.tabId, input.colorScheme);572 await ready.bridge.setColorScheme(ready.runtimeTabId, input.colorScheme);
463 return {573 return {
464 tabId: ready.tabId,574 tabId: ready.tabId,
465 colorScheme: input.colorScheme,575 colorScheme: input.colorScheme,
...@@ -467,53 +577,57 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -467,53 +577,57 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
467 }577 }
468 case "snapshot": {578 case "snapshot": {
469 const ready = await requireReadyTab();579 const ready = await requireReadyTab();
470 return await ready.bridge.automation.snapshot(ready.tabId);580 return await ready.bridge.automation.snapshot(ready.runtimeTabId);
471 }581 }
472 case "click": {582 case "click": {
473 const ready = await requireReadyTab();583 const ready = await requireReadyTab();
474 return await ready.bridge.automation.click(584 return await ready.bridge.automation.click(
475 ready.tabId,585 ready.runtimeTabId,
476 request.input as Parameters<typeof ready.bridge.automation.click>[1],586 request.input as Parameters<typeof ready.bridge.automation.click>[1],
477 );587 );
478 }588 }
479 case "type": {589 case "type": {
480 const ready = await requireReadyTab();590 const ready = await requireReadyTab();
481 return await ready.bridge.automation.type(591 return await ready.bridge.automation.type(
482 ready.tabId,592 ready.runtimeTabId,
483 request.input as Parameters<typeof ready.bridge.automation.type>[1],593 request.input as Parameters<typeof ready.bridge.automation.type>[1],
484 );594 );
485 }595 }
486 case "press": {596 case "press": {
487 const ready = await requireReadyTab();597 const ready = await requireReadyTab();
488 return await ready.bridge.automation.press(598 return await ready.bridge.automation.press(
489 ready.tabId,599 ready.runtimeTabId,
490 request.input as Parameters<typeof ready.bridge.automation.press>[1],600 request.input as Parameters<typeof ready.bridge.automation.press>[1],
491 );601 );
492 }602 }
493 case "scroll": {603 case "scroll": {
494 const ready = await requireReadyTab();604 const ready = await requireReadyTab();
495 return await ready.bridge.automation.scroll(605 return await ready.bridge.automation.scroll(
496 ready.tabId,606 ready.runtimeTabId,
497 request.input as Parameters<typeof ready.bridge.automation.scroll>[1],607 request.input as Parameters<typeof ready.bridge.automation.scroll>[1],
498 );608 );
499 }609 }
500 case "evaluate": {610 case "evaluate": {
501 const ready = await requireReadyTab();611 const ready = await requireReadyTab();
502 return await ready.bridge.automation.evaluate(612 return await ready.bridge.automation.evaluate(
503 ready.tabId,613 ready.runtimeTabId,
504 request.input as Parameters<typeof ready.bridge.automation.evaluate>[1],614 request.input as Parameters<typeof ready.bridge.automation.evaluate>[1],
505 );615 );
506 }616 }
507 case "waitFor": {617 case "waitFor": {
508 const ready = await requireReadyTab();618 const ready = await requireReadyTab();
509 return await ready.bridge.automation.waitFor(619 return await ready.bridge.automation.waitFor(
510 ready.tabId,620 ready.runtimeTabId,
511 request.input as Parameters<typeof ready.bridge.automation.waitFor>[1],621 request.input as Parameters<typeof ready.bridge.automation.waitFor>[1],
512 );622 );
513 }623 }
514 case "recordingStart": {624 case "recordingStart": {
515 const ready = await requireReadyTab();625 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 );
517 return {631 return {
518 tabId: ready.tabId,632 tabId: ready.tabId,
519 recording: true,633 recording: true,
...@@ -521,15 +635,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -521,15 +635,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
521 };635 };
522 }636 }
523 case "recordingStop": {637 case "recordingStop": {
524 const activeTabIds = readActiveBrowserRecordingTabIds(threadRef);638 const activeRecordings = readActiveBrowserRecordingTargets(threadRef);
639 const activeTabIds = new Set(
640 activeRecordings.map((recording) => recording.serverTabId),
641 );
525 const stopTabId = resolveBrowserRecordingStopTarget(642 const stopTabId = resolveBrowserRecordingStopTarget(
526 activeTabIds,643 activeTabIds,
527 tabId,644 tabId,
528 request.tabIdExplicit ? request.tabId : undefined,645 request.tabIdExplicit ? request.tabId : undefined,
529 );646 );
530 tabId = stopTabId ?? tabId;647 tabId = stopTabId ?? tabId;
531 const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null;648 const stopRuntimeTabId =
532 if (!artifact) {649 activeRecordings.find((recording) => recording.serverTabId === stopTabId)
650 ?.runtimeTabId ?? null;
651 const artifact = stopRuntimeTabId ? await stopBrowserRecording(stopRuntimeTabId) : null;
652 if (!artifact || !stopTabId) {
533 return raisePreviewAutomationHostError(653 return raisePreviewAutomationHostError(
534 new PreviewAutomationRecordingNotActiveError({654 new PreviewAutomationRecordingNotActiveError({
535 requestId: request.requestId,655 requestId: request.requestId,
...@@ -539,7 +659,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -539,7 +659,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
539 }),659 }),
540 );660 );
541 }661 }
542 return artifact;662 return { ...artifact, tabId: stopTabId };
543 }663 }
544 }664 }
545 } catch (cause) {665 } catch (cause) {
apps/web/src/components/preview/PreviewView.test.tsx+18-4
...@@ -90,6 +90,7 @@ vi.mock("~/state/use-atom-command", () => ({...@@ -90,6 +90,7 @@ vi.mock("~/state/use-atom-command", () => ({
90}));90}));
9191
92vi.mock("~/browser/browserRecording", () => ({92vi.mock("~/browser/browserRecording", () => ({
93 findActiveBrowserRecordingRuntimeTabId: vi.fn(() => null),
93 startBrowserRecording: vi.fn(),94 startBrowserRecording: vi.fn(),
94 stopBrowserRecording: vi.fn(),95 stopBrowserRecording: vi.fn(),
95 useActiveBrowserRecordingTabIds: () => new Set(),96 useActiveBrowserRecordingTabIds: () => new Set(),
...@@ -188,6 +189,13 @@ vi.mock("./useLoadingProgress", () => ({ useLoadingProgress: () => 0 }));...@@ -188,6 +189,13 @@ vi.mock("./useLoadingProgress", () => ({ useLoadingProgress: () => 0 }));
188vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() }));189vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() }));
189190
190import { PreviewView } from "./PreviewView";191import { 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
192describe("PreviewView navigation", () => {200describe("PreviewView navigation", () => {
193 beforeEach(() => {201 beforeEach(() => {
...@@ -230,7 +238,9 @@ describe("PreviewView navigation", () => {...@@ -230,7 +238,9 @@ describe("PreviewView navigation", () => {
230 expect(mocks.submittedUrl).not.toBeNull();238 expect(mocks.submittedUrl).not.toBeNull();
231 mocks.submittedUrl?.(submitted);239 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 );
234 expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith(244 expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith(
235 {245 {
236 environmentId: "environment-1",246 environmentId: "environment-1",
...@@ -258,7 +268,7 @@ describe("PreviewView navigation", () => {...@@ -258,7 +268,7 @@ describe("PreviewView navigation", () => {
258268
259 await vi.waitFor(() =>269 await vi.waitFor(() =>
260 expect(mocks.navigate).toHaveBeenCalledWith(270 expect(mocks.navigate).toHaveBeenCalledWith(
261 "tab-1",271 TEST_RUNTIME_TAB_ID,
262 "http://172.25.85.75:5173/app?mode=test#top",272 "http://172.25.85.75:5173/app?mode=test#top",
263 ),273 ),
264 );274 );
...@@ -306,11 +316,15 @@ describe("PreviewView navigation", () => {...@@ -306,11 +316,15 @@ describe("PreviewView navigation", () => {
306316
307 renderToStaticMarkup(<PreviewView {...props} />);317 renderToStaticMarkup(<PreviewView {...props} />);
308 mocks.toggleNativePictureInPicture?.();318 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
311 mocks.pictureInPicture = true;323 mocks.pictureInPicture = true;
312 renderToStaticMarkup(<PreviewView {...props} />);324 renderToStaticMarkup(<PreviewView {...props} />);
313 mocks.toggleNativePictureInPicture?.();325 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 );
315 });329 });
316});330});
apps/web/src/components/preview/PreviewView.tsx+56-46
...@@ -37,6 +37,7 @@ import {...@@ -37,6 +37,7 @@ import {
37 subscribeBrowserViewportChange,37 subscribeBrowserViewportChange,
38} from "~/browser/browserViewportActions";38} from "~/browser/browserViewportActions";
39import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout";39import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout";
40import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
40import { PreviewUnreachable } from "./PreviewUnreachable";41import { PreviewUnreachable } from "./PreviewUnreachable";
41import { revealInFileExplorerLabel } from "./fileExplorerLabel";42import { revealInFileExplorerLabel } from "./fileExplorerLabel";
42import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";43import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
...@@ -47,6 +48,7 @@ import { usePreviewSession } from "./usePreviewSession";...@@ -47,6 +48,7 @@ import { usePreviewSession } from "./usePreviewSession";
47import { ZoomIndicator } from "./ZoomIndicator";48import { ZoomIndicator } from "./ZoomIndicator";
48import { AgentBrowserCursor } from "./AgentBrowserCursor";49import { AgentBrowserCursor } from "./AgentBrowserCursor";
49import {50import {
51 findActiveBrowserRecordingRuntimeTabId,
50 startBrowserRecording,52 startBrowserRecording,
51 stopBrowserRecording,53 stopBrowserRecording,
52 useActiveBrowserRecordingTabIds,54 useActiveBrowserRecordingTabIds,
...@@ -93,6 +95,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -93,6 +95,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
93 }, []);95 }, []);
9496
95 const tabId = requestedTabId ?? previewState.activeTabId;97 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;
96 const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null;107 const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null;
97 const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null;108 const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null;
98 const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const };109 const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const };
...@@ -115,15 +126,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -115,15 +126,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
115 : undefined;126 : undefined;
116 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;127 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
117 const panelRect = useBrowserSurfaceStore((state) =>128 const panelRect = useBrowserSurfaceStore((state) =>
118 tabId ? (state.byTabId[tabId]?.rect ?? null) : null,129 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,
119 );130 );
120131
121 const navigateToResolvedUrl = useCallback(132 const navigateToResolvedUrl = useCallback(
122 async (resolvedUrl: string) => {133 async (resolvedUrl: string) => {
123 if (tabId && previewBridge) {134 if (runtimeTabId && previewBridge) {
124 // Drive the webview imperatively; `usePreviewBridge` mirrors the135 // Drive the webview imperatively; `usePreviewBridge` mirrors the
125 // resolved URL back to the server so other clients stay in sync.136 // resolved URL back to the server so other clients stay in sync.
126 await previewBridge.navigate(tabId, resolvedUrl);137 await previewBridge.navigate(runtimeTabId, resolvedUrl);
127 rememberPreviewUrl(threadRef, resolvedUrl);138 rememberPreviewUrl(threadRef, resolvedUrl);
128 } else {139 } else {
129 await openPreviewSession({140 await openPreviewSession({
...@@ -133,7 +144,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -133,7 +144,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
133 });144 });
134 }145 }
135 },146 },
136 [open, tabId, threadRef],147 [open, runtimeTabId, threadRef],
137 );148 );
138149
139 const handleSubmitUrl = useCallback(150 const handleSubmitUrl = useCallback(
...@@ -159,20 +170,20 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -159,20 +170,20 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
159 );170 );
160171
161 const handleRefresh = useCallback(() => {172 const handleRefresh = useCallback(() => {
162 if (previewBridge && tabId) void previewBridge.refresh(tabId);173 if (previewBridge && runtimeTabId) void previewBridge.refresh(runtimeTabId);
163 }, [tabId]);174 }, [runtimeTabId]);
164175
165 const handleZoomIn = useCallback(() => {176 const handleZoomIn = useCallback(() => {
166 if (previewBridge && tabId) void previewBridge.zoomIn(tabId);177 if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId);
167 }, [tabId]);178 }, [runtimeTabId]);
168179
169 const handleZoomOut = useCallback(() => {180 const handleZoomOut = useCallback(() => {
170 if (previewBridge && tabId) void previewBridge.zoomOut(tabId);181 if (previewBridge && runtimeTabId) void previewBridge.zoomOut(runtimeTabId);
171 }, [tabId]);182 }, [runtimeTabId]);
172183
173 const handleResetZoom = useCallback(() => {184 const handleResetZoom = useCallback(() => {
174 if (previewBridge && tabId) void previewBridge.resetZoom(tabId);185 if (previewBridge && runtimeTabId) void previewBridge.resetZoom(runtimeTabId);
175 }, [tabId]);186 }, [runtimeTabId]);
176187
177 const handleViewportChange = useCallback(188 const handleViewportChange = useCallback(
178 async (nextViewport: PreviewViewportSetting) => {189 async (nextViewport: PreviewViewportSetting) => {
...@@ -200,32 +211,32 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -200,32 +211,32 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
200 );211 );
201212
202 const handleToggleDeviceToolbar = () => {213 const handleToggleDeviceToolbar = () => {
203 if (!tabId) return;214 if (!runtimeTabId) return;
204 if (viewport._tag !== "fill") {215 if (viewport._tag !== "fill") {
205 void commitBrowserViewportChange(tabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined);216 void commitBrowserViewportChange(runtimeTabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined);
206 return;217 return;
207 }218 }
208219
209 const responsiveSize = panelRect220 const responsiveSize = panelRect
210 ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor)221 ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor)
211 : { width: 1024, height: 768 };222 : { width: 1024, height: 768 };
212 void commitBrowserViewportChange(tabId, { _tag: "freeform", ...responsiveSize }).catch(223 void commitBrowserViewportChange(runtimeTabId, { _tag: "freeform", ...responsiveSize }).catch(
213 () => undefined,224 () => undefined,
214 );225 );
215 };226 };
216227
217 useEffect(() => {228 useEffect(() => {
218 if (!tabId) return;229 if (!runtimeTabId) return;
219 return subscribeBrowserViewportChange(tabId, handleViewportChange);230 return subscribeBrowserViewportChange(runtimeTabId, handleViewportChange);
220 }, [handleViewportChange, tabId]);231 }, [handleViewportChange, runtimeTabId]);
221232
222 const handleBack = useCallback(() => {233 const handleBack = useCallback(() => {
223 if (previewBridge && tabId) void previewBridge.goBack(tabId);234 if (previewBridge && runtimeTabId) void previewBridge.goBack(runtimeTabId);
224 }, [tabId]);235 }, [runtimeTabId]);
225236
226 const handleForward = useCallback(() => {237 const handleForward = useCallback(() => {
227 if (previewBridge && tabId) void previewBridge.goForward(tabId);238 if (previewBridge && runtimeTabId) void previewBridge.goForward(runtimeTabId);
228 }, [tabId]);239 }, [runtimeTabId]);
229240
230 const handleOpenInBrowser = useCallback(() => {241 const handleOpenInBrowser = useCallback(() => {
231 if (!localApi || !url) return;242 if (!localApi || !url) return;
...@@ -243,26 +254,25 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -243,26 +254,25 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
243 }, [miniPlayer?.tabId, tabId, threadRef]);254 }, [miniPlayer?.tabId, tabId, threadRef]);
244255
245 const handleNativePictureInPicture = useCallback(() => {256 const handleNativePictureInPicture = useCallback(() => {
246 if (!previewBridge || !tabId) return;257 if (!previewBridge || !runtimeTabId) return;
247 const operation = desktopOverlay?.pictureInPicture258 const operation = desktopOverlay?.pictureInPicture
248 ? previewBridge.pictureInPicture.close259 ? previewBridge.pictureInPicture.close
249 : previewBridge.pictureInPicture.open;260 : previewBridge.pictureInPicture.open;
250 void operation(tabId).catch((error) => {261 void operation(runtimeTabId).catch((error) => {
251 toastManager.add({262 toastManager.add({
252 type: "error",263 type: "error",
253 title: "Unable to update popped-out preview",264 title: "Unable to update popped-out preview",
254 description: error instanceof Error ? error.message : "An error occurred.",265 description: error instanceof Error ? error.message : "An error occurred.",
255 });266 });
256 });267 });
257 }, [desktopOverlay?.pictureInPicture, tabId]);268 }, [desktopOverlay?.pictureInPicture, runtimeTabId]);
258269
259 const handleCapture = useCallback(270 const handleCapture = useCallback(
260 (record: boolean) => {271 (record: boolean) => {
261 if (!previewBridge || !tabId) return;272 if (!previewBridge || !runtimeTabId || !tabId) return;
262 const bridge = previewBridge;273 const bridge = previewBridge;
263 const recordingThisTab = activeRecordingTabIds.has(tabId);274 if (recordingRuntimeTabId) {
264 if (recordingThisTab) {275 void stopBrowserRecording(recordingRuntimeTabId).then(
265 void stopBrowserRecording(tabId).then(
266 (artifact) => {276 (artifact) => {
267 if (!artifact) return;277 if (!artifact) return;
268 let pathCopied = false;278 let pathCopied = false;
...@@ -354,7 +364,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -354,7 +364,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
354 return;364 return;
355 }365 }
356 if (record) {366 if (record) {
357 void startBrowserRecording(tabId, threadRef).catch((error) => {367 void startBrowserRecording(runtimeTabId, threadRef, tabId).catch((error) => {
358 toastManager.add({368 toastManager.add({
359 type: "error",369 type: "error",
360 title: "Unable to start recording",370 title: "Unable to start recording",
...@@ -363,7 +373,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -363,7 +373,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
363 });373 });
364 return;374 return;
365 }375 }
366 void bridge.captureScreenshot(tabId).then(376 void bridge.captureScreenshot(runtimeTabId).then(
367 (artifact) => {377 (artifact) => {
368 const revealAction = {378 const revealAction = {
369 children: revealInFileExplorerLabel(navigator.platform),379 children: revealInFileExplorerLabel(navigator.platform),
...@@ -493,13 +503,13 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -493,13 +503,13 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
493 },503 },
494 );504 );
495 },505 },
496 [activeRecordingTabIds, tabId, threadRef],506 [recordingRuntimeTabId, runtimeTabId, tabId, threadRef],
497 );507 );
498508
499 const handlePickElement = useCallback(() => {509 const handlePickElement = useCallback(() => {
500 if (!previewBridge || !tabId) return;510 if (!previewBridge || !runtimeTabId) return;
501 if (pickActiveRef.current) {511 if (pickActiveRef.current) {
502 void previewBridge.cancelPickElement(tabId).catch(() => undefined);512 void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined);
503 return;513 return;
504 }514 }
505 // Snapshot whatever the user was focused on (typically the chat515 // Snapshot whatever the user was focused on (typically the chat
...@@ -513,7 +523,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -513,7 +523,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
513 setPickActive(true);523 setPickActive(true);
514 void (async () => {524 void (async () => {
515 try {525 try {
516 const annotation = await previewBridge.pickElement(tabId);526 const annotation = await previewBridge.pickElement(runtimeTabId);
517 if (!annotation) return;527 if (!annotation) return;
518 addPreviewAnnotation(threadRef, annotation);528 addPreviewAnnotation(threadRef, annotation);
519 const screenshotFile = await previewAnnotationScreenshotFile(annotation);529 const screenshotFile = await previewAnnotationScreenshotFile(annotation);
...@@ -551,7 +561,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -551,7 +561,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
551 }561 }
552 }562 }
553 })();563 })();
554 }, [addImage, addPreviewAnnotation, tabId, threadRef]);564 }, [addImage, addPreviewAnnotation, runtimeTabId, threadRef]);
555565
556 // If the active tab changes mid-pick (close, thread switch, hot restart),566 // If the active tab changes mid-pick (close, thread switch, hot restart),
557 // tell main to tear down the in-flight session AND reset our local toggle567 // 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,...@@ -560,12 +570,12 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
560 return () => {570 return () => {
561 if (!pickActiveRef.current) return;571 if (!pickActiveRef.current) return;
562 pickActiveRef.current = false;572 pickActiveRef.current = false;
563 if (previewBridge && tabId) {573 if (previewBridge && runtimeTabId) {
564 void previewBridge.cancelPickElement(tabId).catch(() => undefined);574 void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined);
565 }575 }
566 if (isMountedRef.current) setPickActive(false);576 if (isMountedRef.current) setPickActive(false);
567 };577 };
568 }, [tabId]);578 }, [runtimeTabId]);
569579
570 // Subscribe only while visible; `toggle-panel` is owned by ChatView's580 // Subscribe only while visible; `toggle-panel` is owned by ChatView's
571 // URL-aware handler regardless of whether the panel is currently mounted.581 // URL-aware handler regardless of whether the panel is currently mounted.
...@@ -615,7 +625,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -615,7 +625,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
615 onOpenInBrowser={tabId ? handleOpenInBrowser : undefined}625 onOpenInBrowser={tabId ? handleOpenInBrowser : undefined}
616 onCapture={previewBridge && tabId ? handleCapture : undefined}626 onCapture={previewBridge && tabId ? handleCapture : undefined}
617 captureDisabled={!desktopOverlay || isUnreachable}627 captureDisabled={!desktopOverlay || isUnreachable}
618 recording={tabId !== null && activeRecordingTabIds.has(tabId)}628 recording={recordingRuntimeTabId !== null}
619 onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined}629 onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined}
620 pictureInPicture={miniPlayer?.tabId === tabId}630 pictureInPicture={miniPlayer?.tabId === tabId}
621 pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable}631 pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable}
...@@ -631,7 +641,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -631,7 +641,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
631 trailingActions={641 trailingActions={
632 previewBridge ? (642 previewBridge ? (
633 <PreviewMoreMenu643 <PreviewMoreMenu
634 tabId={tabId}644 tabId={runtimeTabId}
635 hasWebContents={desktopOverlay?.hasWebContents ?? false}645 hasWebContents={desktopOverlay?.hasWebContents ?? false}
636 zoomFactor={desktopOverlay?.zoomFactor ?? 1}646 zoomFactor={desktopOverlay?.zoomFactor ?? 1}
637 colorScheme={desktopOverlay?.colorScheme ?? "system"}647 colorScheme={desktopOverlay?.colorScheme ?? "system"}
...@@ -645,10 +655,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -645,10 +655,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
645 />655 />
646656
647 <div className="relative min-h-0 flex-1 overflow-hidden">657 <div className="relative min-h-0 flex-1 overflow-hidden">
648 {tabId && snapshot && !showEmptyState ? (658 {runtimeTabId && snapshot && !showEmptyState ? (
649 <BrowserSurfaceSlot659 <BrowserSurfaceSlot
650 key={tabId}660 key={runtimeTabId}
651 tabId={tabId}661 tabId={runtimeTabId}
652 visible={visible && !isUnreachable}662 visible={visible && !isUnreachable}
653 className="absolute inset-0 h-full w-full"663 className="absolute inset-0 h-full w-full"
654 />664 />
...@@ -664,9 +674,9 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -664,9 +674,9 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
664 {snapshot && desktopOverlay ? (674 {snapshot && desktopOverlay ? (
665 <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} />675 <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} />
666 ) : null}676 ) : null}
667 {tabId && desktopOverlay && !showEmptyState && !isUnreachable ? (677 {runtimeTabId && desktopOverlay && !showEmptyState && !isUnreachable ? (
668 <AgentBrowserCursor678 <AgentBrowserCursor
669 tabId={tabId}679 tabId={runtimeTabId}
670 zoomFactor={desktopOverlay.zoomFactor}680 zoomFactor={desktopOverlay.zoomFactor}
671 controller={controller}681 controller={controller}
672 />682 />
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+4-2
...@@ -5,6 +5,7 @@ import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react";...@@ -5,6 +5,7 @@ import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react";
5import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef } from "react";5import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef } from "react";
66
7import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";7import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
8import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
8import { Button } from "~/components/ui/button";9import { Button } from "~/components/ui/button";
9import { toastManager } from "~/components/ui/toast";10import { toastManager } from "~/components/ui/toast";
10import { useThreadPreviewState } from "~/previewStateStore";11import { useThreadPreviewState } from "~/previewStateStore";
...@@ -49,6 +50,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props...@@ -49,6 +50,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
49 );50 );
50 const previewState = useThreadPreviewState(threadRef);51 const previewState = useThreadPreviewState(threadRef);
51 const snapshot = previewState.sessions[tabId] ?? null;52 const snapshot = previewState.sessions[tabId] ?? null;
53 const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId);
52 const desktopOverlay = previewState.desktopByTabId[tabId] ?? null;54 const desktopOverlay = previewState.desktopByTabId[tabId] ?? null;
53 const position = miniPlayer?.tabId === tabId ? miniPlayer.position : null;55 const position = miniPlayer?.tabId === tabId ? miniPlayer.position : null;
54 const size =56 const size =
...@@ -69,7 +71,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props...@@ -69,7 +71,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
69 const operation = desktopOverlay?.pictureInPicture71 const operation = desktopOverlay?.pictureInPicture
70 ? previewBridge.pictureInPicture.close72 ? previewBridge.pictureInPicture.close
71 : previewBridge.pictureInPicture.open;73 : previewBridge.pictureInPicture.open;
72 void operation(tabId).catch((error) => {74 void operation(runtimeTabId).catch((error) => {
73 toastManager.add({75 toastManager.add({
74 type: "error",76 type: "error",
75 title: "Unable to update popped-out preview",77 title: "Unable to update popped-out preview",
...@@ -284,7 +286,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props...@@ -284,7 +286,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
284 <div className="relative h-full min-h-0">286 <div className="relative h-full min-h-0">
285 <div className="absolute inset-0 z-[29] rounded-xl bg-muted shadow-2xl/35" />287 <div className="absolute inset-0 z-[29] rounded-xl bg-muted shadow-2xl/35" />
286 <BrowserSurfaceSlot288 <BrowserSurfaceSlot
287 tabId={tabId}289 tabId={runtimeTabId}
288 visible={Boolean(desktopOverlay?.hasWebContents)}290 visible={Boolean(desktopOverlay?.hasWebContents)}
289 cornerRadius={12}291 cornerRadius={12}
290 fitSourceContent292 fitSourceContent
apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts+18
...@@ -2,6 +2,8 @@ import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tool...@@ -2,6 +2,8 @@ import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tool
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import {4import {
5 DEFAULT_PREVIEW_AUTOMATION_VIEWPORT,
6 previewAutomationDefaultViewport,
5 previewAutomationOpenNeedsOverlay,7 previewAutomationOpenNeedsOverlay,
6 shouldOpenPreviewMiniPlayer,8 shouldOpenPreviewMiniPlayer,
7} from "./previewAutomationOpenReadiness";9} from "./previewAutomationOpenReadiness";
...@@ -58,4 +60,20 @@ describe("preview automation open readiness", () => {...@@ -58,4 +60,20 @@ describe("preview automation open readiness", () => {
58 ),60 ),
59 ).toBe(true);61 ).toBe(true);
60 });62 });
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 });
61});79});
apps/web/src/components/preview/previewAutomationOpenReadiness.ts+22-1
...@@ -1,4 +1,15 @@...@@ -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
3export function shouldOpenPreviewMiniPlayer(input: PreviewAutomationOpenInput): boolean {14export function shouldOpenPreviewMiniPlayer(input: PreviewAutomationOpenInput): boolean {
4 return input.open ?? input.show ?? true;15 return input.open ?? input.show ?? true;
...@@ -10,3 +21,13 @@ export function previewAutomationOpenNeedsOverlay(...@@ -10,3 +21,13 @@ export function previewAutomationOpenNeedsOverlay(
10): boolean {21): boolean {
11 return input.url !== undefined || snapshot.navStatus._tag !== "Idle";22 return input.url !== undefined || snapshot.navStatus._tag !== "Idle";
12}23}
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";...@@ -19,8 +19,12 @@ import { previewBridge } from "./previewBridge";
19 * Mirrors low-latency desktop state into the store and reflects navigation19 * Mirrors low-latency desktop state into the store and reflects navigation
20 * events back to the server. Webview lifetime is owned by ElectronBrowserHost.20 * events back to the server. Webview lifetime is owned by ElectronBrowserHost.
21 */21 */
22export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void {22export function usePreviewBridge(input: {
23 const { threadRef, tabId } = input;23 threadRef: ScopedThreadRef;
24 tabId: string;
25 runtimeTabId: string;
26}): void {
27 const { threadRef, tabId, runtimeTabId } = input;
24 const clearBrowserPointer = useBrowserPointerStore((state) => state.clear);28 const clearBrowserPointer = useBrowserPointerStore((state) => state.clear);
25 const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report");29 const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report");
26 const bridge = previewBridge;30 const bridge = previewBridge;
...@@ -36,9 +40,9 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str...@@ -36,9 +40,9 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str
36 lastReportedKind.current = null;40 lastReportedKind.current = null;
37 lastDesktopNavStatus.current = null;41 lastDesktopNavStatus.current = null;
38 const unsubscribe = bridge.onStateChange((changedTabId, state) => {42 const unsubscribe = bridge.onStateChange((changedTabId, state) => {
39 if (changedTabId !== tabId) return;43 if (changedTabId !== runtimeTabId) return;
40 if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) {44 if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) {
41 clearBrowserPointer(tabId);45 clearBrowserPointer(runtimeTabId);
42 }46 }
43 lastDesktopNavStatus.current = state.navStatus;47 lastDesktopNavStatus.current = state.navStatus;
44 applyPreviewDesktopState(threadRef, tabId, projectDesktopState(state));48 applyPreviewDesktopState(threadRef, tabId, projectDesktopState(state));
...@@ -58,7 +62,7 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str...@@ -58,7 +62,7 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str
58 });62 });
59 });63 });
60 return unsubscribe;64 return unsubscribe;
61 }, [bridge, clearBrowserPointer, reportStatus, tabId, threadRef]);65 }, [bridge, clearBrowserPointer, reportStatus, runtimeTabId, tabId, threadRef]);
62}66}
6367
64function shouldClearBrowserPointer(68function shouldClearBrowserPointer(
apps/web/src/components/preview/usePreviewSession.ts+4-4
...@@ -39,7 +39,6 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {...@@ -39,7 +39,6 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
3939
40 return Atom.make((get) => {40 return Atom.make((get) => {
41 let disposed = false;41 let disposed = false;
42 let sessionsVersion = 0;
43 let eventsVersion = 0;42 let eventsVersion = 0;
4443
45 const reconcileSessions = (result: Atom.Type<typeof sessionsAtom>) => {44 const reconcileSessions = (result: Atom.Type<typeof sessionsAtom>) => {
...@@ -60,10 +59,8 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {...@@ -60,10 +59,8 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
60 get.addFinalizer(() => {59 get.addFinalizer(() => {
61 disposed = true;60 disposed = true;
62 });61 });
63 const initialSessions = get.once(sessionsAtom);
64 const initialEvent = get.once(eventsAtom);62 const initialEvent = get.once(eventsAtom);
65 get.subscribe(sessionsAtom, (result) => {63 get.subscribe(sessionsAtom, (result) => {
66 sessionsVersion += 1;
67 reconcileSessions(result);64 reconcileSessions(result);
68 });65 });
69 get.subscribe(eventsAtom, (result) => {66 get.subscribe(eventsAtom, (result) => {
...@@ -72,7 +69,10 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {...@@ -72,7 +69,10 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
72 });69 });
73 queueMicrotask(() => {70 queueMicrotask(() => {
74 if (disposed) return;71 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);
76 if (eventsVersion === 0) applyLatestEvent(initialEvent);76 if (eventsVersion === 0) applyLatestEvent(initialEvent);
77 });77 });
78 }).pipe(Atom.setIdleTTL(1_000), Atom.withLabel(`preview:session-sync:${threadKey}`));78 }).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)", () => {...@@ -480,6 +480,48 @@ describe("previewStateStore (single-tab)", () => {
480 expect(state.serverRevision).toBe(0);480 expect(state.serverRevision).toBe(0);
481 });481 });
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
483 it("applyServerSnapshot null clears snapshot for a thread that had one", () => {525 it("applyServerSnapshot null clears snapshot for a thread that had one", () => {
484 const snapshot = makeSnapshot();526 const snapshot = makeSnapshot();
485 applyPreviewServerSnapshot(ref, snapshot);527 applyPreviewServerSnapshot(ref, snapshot);
apps/web/src/previewStateStore.ts+9-6
...@@ -323,10 +323,11 @@ export function reconcilePreviewServerSessions(...@@ -323,10 +323,11 @@ export function reconcilePreviewServerSessions(
323 if (sameServer && result.revision < current.serverRevision) return current;323 if (sameServer && result.revision < current.serverRevision) return current;
324 const snapshots = result.sessions;324 const snapshots = result.sessions;
325 const sessions: Record<string, PreviewSessionSnapshot> = {};325 const sessions: Record<string, PreviewSessionSnapshot> = {};
326 const currentSuppressedTabIds = sameServer ? current.suppressedTabIds : new Set<string>();
326 let recentlySeenUrls = current.recentlySeenUrls;327 let recentlySeenUrls = current.recentlySeenUrls;
327 for (const snapshot of snapshots) {328 for (const snapshot of snapshots) {
328 if (current.suppressedTabIds.has(snapshot.tabId)) continue;329 if (currentSuppressedTabIds.has(snapshot.tabId)) continue;
329 const existing = current.sessions[snapshot.tabId];330 const existing = sameServer ? current.sessions[snapshot.tabId] : undefined;
330 const next = existing && existing.updatedAt > snapshot.updatedAt ? existing : snapshot;331 const next = existing && existing.updatedAt > snapshot.updatedAt ? existing : snapshot;
331 sessions[next.tabId] = next;332 sessions[next.tabId] = next;
332 recentlySeenUrls = rememberSnapshotUrl(recentlySeenUrls, next);333 recentlySeenUrls = rememberSnapshotUrl(recentlySeenUrls, next);
...@@ -338,11 +339,13 @@ export function reconcilePreviewServerSessions(...@@ -338,11 +339,13 @@ export function reconcilePreviewServerSessions(
338 ? current.activeTabId339 ? current.activeTabId
339 : (fallback?.tabId ?? null);340 : (fallback?.tabId ?? null);
340 const snapshot = activeTabId ? (sessions[activeTabId] ?? null) : null;341 const snapshot = activeTabId ? (sessions[activeTabId] ?? null) : null;
341 const desktopByTabId = Object.fromEntries(342 const desktopByTabId = sameServer
342 Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined),343 ? Object.fromEntries(
343 );344 Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined),
345 )
346 : {};
344 const suppressedTabIds = new Set(347 const suppressedTabIds = new Set(
345 [...current.suppressedTabIds].filter((tabId) =>348 [...currentSuppressedTabIds].filter((tabId) =>
346 snapshots.some((snapshot) => snapshot.tabId === tabId),349 snapshots.some((snapshot) => snapshot.tabId === tabId),
347 ),350 ),
348 );351 );