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