From 27319f9dc8a70f4f70775a3d067e88ec126a1aa8 Mon Sep 17 00:00:00 2001 From: ashx-j <281260375+ashx-j@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:19:32 +0200 Subject: [PATCH 1/3] fix(preview): failed floating previews show retry controls --- apps/server/src/preview/ServerBrowser.test.ts | 86 +++++ apps/server/src/preview/ServerBrowser.ts | 26 +- apps/web/src/browser/ServerBrowserSurface.tsx | 7 +- .../src/components/RightPanelTabs.test.tsx | 1 + .../preview/ThreadPreviewMiniPlayer.test.tsx | 355 ++++++++++++++++++ .../preview/ThreadPreviewMiniPlayer.tsx | 112 +++++- .../components/preview/usePreviewBridge.ts | 1 + apps/web/src/previewStateStore.test.ts | 5 + apps/web/src/previewStateStore.ts | 5 + packages/contracts/src/preview.test.ts | 18 + packages/contracts/src/previewAutomation.ts | 8 + 11 files changed, 613 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/preview/ThreadPreviewMiniPlayer.test.tsx diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index dce0c23b96cf..67dbf23ecdfe 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -261,6 +261,92 @@ beforeEach(() => { desktopConnections.length = 0; }); +it.live( + "reports main-frame navigation failures until a retry begins while keeping the host available", + () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const page = contexts[0]!.page; + const url = "http://localhost:5733/"; + yield* Effect.promise(() => page.goto(url)); + const request = { + isNavigationRequest: () => true, + frame: () => page, + url: () => url, + method: () => "GET", + failure: () => ({ errorText: "net::ERR_CONNECTION_REFUSED" }), + }; + page.emit("request", request); + page.emit("requestfailed", request); + page.emit("load"); + yield* Effect.promise(() => page.emitAsync("framenavigated", page)); + const failed = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(failed).toMatchObject({ + available: true, + loading: false, + navigationError: { url, code: -102, description: "ERR_CONNECTION_REFUSED" }, + }); + + page.emit("requestfailed", { + ...request, + frame: () => ({}), + failure: () => ({ errorText: "net::ERR_NAME_NOT_RESOLVED" }), + }); + expect( + yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }), + ).toMatchObject({ navigationError: failed.navigationError }); + + yield* Effect.promise(() => page.goto("https://previous.example/")); + yield* Effect.promise(() => page.emitAsync("framenavigated", page)); + const restored = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(restored).toMatchObject({ + available: true, + loading: false, + url: "https://previous.example/", + }); + expect(restored).not.toHaveProperty("navigationError"); + + page.emit("request", request); + page.emit("requestfailed", request); + + page.emit("request", request); + const retrying = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(retrying.loading).toBe(true); + expect(retrying).not.toHaveProperty("navigationError"); + yield* Effect.promise(() => page.goto(url)); + const loaded = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(loaded).toMatchObject({ available: true, loading: false, url }); + expect(loaded).not.toHaveProperty("navigationError"); + }), + ).pipe(Effect.provide(layer)), +); + it.live("readiness none responds immediately but takeover input waits for navigation commit", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 42dc916abb61..6a529fb18e84 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -308,6 +308,7 @@ interface ServerTab { colorScheme: PreviewAppearancePreference; zoomFactor: number; loading: boolean; + loadFailure: Extract | null; closing: boolean; recording: Recording | null; initialNavigation: Promise | null; @@ -515,6 +516,7 @@ const make = Effect.gen(function* () { }; const report = (tab: ServerTab, navStatus: PreviewNavStatus) => { + tab.loadFailure = navStatus._tag === "LoadFailed" ? navStatus : null; void tab.cdp .send("Page.getNavigationHistory") .catch(() => null) @@ -536,13 +538,19 @@ const make = Effect.gen(function* () { }); }; - const reportLoaded = async (tab: ServerTab) => { + const reportLoaded = async (tab: ServerTab, confirmedNavigation = false) => { const url = tab.page.url(); + const loadFailure = tab.loadFailure; // Chromium's error page loads after `requestfailed` and must not clear LoadFailed. - if (url === "about:blank" || url.startsWith("chrome-error://")) return; + if ( + (loadFailure && (!confirmedNavigation || url === loadFailure.url)) || + url === "about:blank" || + url.startsWith("chrome-error://") + ) + return; const title = (await tab.page.title().catch(() => "")).slice(0, 512); // A navigation that started while reading the title owns the status now. - if (tab.loading || tab.page.url() !== url) return; + if (tab.loadFailure !== loadFailure || tab.loading || tab.page.url() !== url) return; report(tab, { _tag: "Success", url: url.slice(0, 2048), title }); }; @@ -751,6 +759,7 @@ const make = Effect.gen(function* () { colorScheme: "system", zoomFactor: 1, loading: false, + loadFailure: snapshot.navStatus._tag === "LoadFailed" ? snapshot.navStatus : null, closing: false, recording: null, recordingStart: null, @@ -775,7 +784,7 @@ const make = Effect.gen(function* () { }); page.on("framenavigated", (frame) => { // Same-document navigations (SPA routes) fire no load event. - if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab); + if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab, true); }); page.on("requestfailed", (request) => { const errorText = request.failure()?.errorText ?? ""; @@ -1119,6 +1128,15 @@ const make = Effect.gen(function* () { url: url === "about:blank" ? null : url, title: null, loading: tab.loading, + ...(tab.loadFailure + ? { + navigationError: { + url: tab.loadFailure.url, + code: tab.loadFailure.code, + description: tab.loadFailure.description, + }, + } + : {}), control: { owner: tab.control.controller !== null diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 26013d720bc5..50cccc1da80e 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -26,6 +26,7 @@ import { type KeyboardEvent, type PointerEvent, type Ref, + type ReactNode, useCallback, useEffect, useEffectEvent, @@ -153,6 +154,8 @@ export function ServerBrowserSurface(props: { readonly onFirstFrame?: () => void; readonly onViewport?: (viewport: PreviewStreamViewport) => void; readonly onControl?: (control: PreviewStreamControl | null) => void; + /** Replaces the page while keeping the stream and ownership controls connected. */ + readonly pageOverlay?: ReactNode; readonly className?: string; readonly ref?: Ref; }) { @@ -166,6 +169,7 @@ export function ServerBrowserSurface(props: { onFirstFrame, onViewport, onControl, + pageOverlay, className, ref, } = props; @@ -747,7 +751,7 @@ export function ServerBrowserSurface(props: {
) : null} + {pageOverlay ?
{pageOverlay}
: null} {/* Focus target for page keyboard input. Pinned top-left so focusing it never scrolls the surface; 16px keeps iOS from zooming the app on focus. */}