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