Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c698494
fix(preview): show failed floating previews instead of a blank panel
gb1095 Aug 16, 2026
d2f6102
fix(preview): keep failed preview_status on the requested URL
gb1095 Aug 16, 2026
00cb03e
test(desktop): type failed-navigation automation status listeners
gb1095 Aug 16, 2026
5347a69
fix(preview): let Retry and Close receive clicks on the failed mini-p…
gb1095 Aug 16, 2026
492edd2
fix(preview): do not let a stale LoadFailed snapshot hide a live guest
gb1095 Aug 16, 2026
2276e1b
fix(preview): keep PiP close available on a failed mini-player
gb1095 Aug 16, 2026
55c38dd
fix(preview): keep failed tabs automatable and clip the overlay host
gb1095 Aug 17, 2026
4c8af2f
style(preview): format the failed mini-player overlay
gb1095 Aug 17, 2026
a4a498a
fix(preview): stub mute APIs on the failed-nav webContents mock
gb1095 Aug 23, 2026
8a8d52b
fix(preview): settle failed navigations instead of waiting until timeout
gb1095 Aug 23, 2026
1bda225
fix(preview): do not treat a detached guest as a finished load
gb1095 Aug 23, 2026
ab03582
fix(preview): keep retrying guests live and reject destroyed ones
gb1095 Sep 2, 2026
ebb3b9e
fix(preview): report retry loading during failed navigations
gb1095 Sep 3, 2026
808742f
fix(preview): reject destroyed guests before automation
gb1095 Sep 3, 2026
cb1782b
fix(preview): handle failed mini-player retries
gb1095 Sep 3, 2026
7c268a7
fix(preview): keep failed PiP controls reachable
gb1095 Sep 4, 2026
9ef66d5
fix(preview): preserve failed preview recovery state
gb1095 Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,7 @@ describe("PreviewManager", () => {
url: null,
title: null,
loading: false,
attached: false,
});

yield* manager.createTab("tab_1");
Expand All @@ -495,12 +496,106 @@ describe("PreviewManager", () => {
url: null,
title: null,
loading: false,
attached: false,
});
expect(fromId).not.toHaveBeenCalled();
}),
),
);

effectIt.effect("reports a failed navigation as unavailable automation", () =>
withManager((manager) =>
Effect.gen(function* () {
const listeners = new Map<string, (...args: unknown[]) => void>();
let url = "http://localhost:5173/";
let loading = false;
fromId.mockReturnValue({
id: 42,
isDestroyed: () => false,
getType: () => "webview",
getURL: () => url,
getTitle: () => "localhost:5173",
isLoading: () => loading,
getZoomFactor: () => 1,
setZoomFactor: vi.fn(),
setAudioMuted: vi.fn(),
isCurrentlyAudible: () => false,
loadURL: vi.fn(async () => undefined),
on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
listeners.set(event, listener);
}),
off: vi.fn(),
ipc: { on: vi.fn(), off: vi.fn() },
send: webviewSend,
navigationHistory: { canGoBack: () => false, canGoForward: () => false },
setWindowOpenHandler: vi.fn(),
debugger: {
isAttached: () => false,
attach: vi.fn(),
sendCommand: vi.fn(async () => undefined),
on: vi.fn(),
off: vi.fn(),
},
} as never);

yield* manager.createTab("tab_failed");
yield* manager.registerWebview("tab_failed", 42);
listeners.get("did-fail-load")?.(
{},
-102,
"ERR_CONNECTION_REFUSED",
"http://localhost:5173/",
true,
);
yield* Effect.yieldNow;

expect(yield* manager.automationStatus("tab_failed")).toEqual({
available: false,
visible: true,
tabId: "tab_failed",
url: "http://localhost:5173/",
title: "ERR_CONNECTION_REFUSED",
loading: false,
attached: true,
});

url = "chrome-error://chromewebdata/";
loading = true;
expect(yield* manager.automationStatus("tab_failed")).toEqual({
available: false,
visible: true,
tabId: "tab_failed",
url: "http://localhost:5173/",
title: "ERR_CONNECTION_REFUSED",
loading: true,
attached: true,
});
}),
),
);

effectIt.effect("reports a destroyed guest as detached even when webContentsId remains", () =>
withManager((manager) =>
Effect.gen(function* () {
const preview = makeFaviconWebContents();
fromId.mockReturnValue(preview.webContents);
yield* manager.createTab("tab_destroyed_status");
yield* manager.registerWebview("tab_destroyed_status", 42);
preview.setDestroyed(true);

expect(yield* manager.automationStatus("tab_destroyed_status")).toEqual({
available: false,
visible: true,
tabId: "tab_destroyed_status",
url: "http://localhost:3200/",
title: null,
loading: false,
attached: false,
});
}),
),
);

effectIt.effect("rejects a destroyed webview during registration", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -636,6 +731,7 @@ describe("PreviewManager", () => {
url: "http://localhost:3200/",
title: "",
loading: true,
attached: false,
});

yield* manager.registerWebview("tab_pending", 42);
Expand Down
59 changes: 41 additions & 18 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3462,28 +3462,51 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
visible: true,
tabId,
url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url,
title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title,
title:
navStatus?.kind === "LoadFailed"
? navStatus.description || navStatus.title
: !navStatus || navStatus.kind === "Idle"
? null
: navStatus.title,
loading: navStatus?.kind === "Loading",
attached: false,
};
}
const wc = webContents.fromId(tab.webContentsId);
return !wc || wc.isDestroyed()
? {
available: false,
visible: true,
tabId,
url: null,
title: null,
loading: false,
}
: {
available: true,
visible: true,
tabId,
url: wc.getURL() || null,
title: wc.getTitle() || null,
loading: wc.isLoading(),
};
if (!wc || wc.isDestroyed()) {
return {
available: false,
visible: true,
tabId,
url: tab.navStatus.kind === "Idle" ? null : tab.navStatus.url,
title:
tab.navStatus.kind === "LoadFailed"
? tab.navStatus.description || tab.navStatus.title
: null,
loading: false,
attached: false,
};
}
if (tab.navStatus.kind === "LoadFailed") {
return {
available: false,
visible: true,
tabId,
url: tab.navStatus.url,
title: tab.navStatus.description || wc.getTitle() || tab.navStatus.title,
loading: wc.isLoading(),
attached: true,
};
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
gb1095 marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}
return {
available: true,
visible: true,
tabId,
url: wc.getURL() || null,
title: wc.getTitle() || null,
loading: wc.isLoading(),
attached: true,
};
});

const captureAutomationSnapshot = Effect.fn("PreviewManager.captureAutomationSnapshot")(
Expand Down
56 changes: 43 additions & 13 deletions apps/web/src/components/preview/PreviewAutomationHosts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
} from "./previewNavigationReadiness";
import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer";
import { createPreviewAutomationClientId } from "./previewAutomationClientId";
import { applyPreviewLoadFailureToAutomationStatus } from "./previewAutomationStatus";
import {
needsPreviewAutomationSessionSync,
resolvePreviewAutomationOpenTab,
Expand Down Expand Up @@ -103,9 +104,23 @@ const waitForDesktopOverlay = async (
operation,
requestId,
});
if (state.desktopByTabId[tabId] && previewBridge && isPreviewWebviewRendering(runtimeTabId)) {
const status = await previewBridge.automation.status(runtimeTabId);
if (status.available) return;
// Attachment only. LoadFailed still has a live guest and must stay
// reachable so navigate/retry can recover. Page health is reported by
// preview_status, not this wait. Re-read the main-process attachment bit
// because a stale overlay snapshot can retain hasWebContents after destroy.
if (state.desktopByTabId[tabId]?.hasWebContents && previewBridge) {
const status = await previewBridge.automation.status(runtimeTabId).catch(() => null);
if (status?.attached === false) {
throw new PreviewAutomationTargetUnavailableError({
requestId,
operation,
environmentId: threadRef.environmentId,
threadId: threadRef.threadId,
tabId,
bridgeAvailable: true,
});
}
if (status) return;
}
await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
}
Expand Down Expand Up @@ -234,18 +249,33 @@ const currentStatus = async (
};
if (runtimeTabId && tabId && previewBridge && state.desktopByTabId[tabId]) {
const status = await previewBridge.automation.status(runtimeTabId);
return { ...status, tabId, visible, ...viewportStatus };
return applyPreviewLoadFailureToAutomationStatus(
{
available: status.available,
visible,
tabId,
url: status.url,
title: status.title,
loading: status.loading,
...viewportStatus,
},
snapshot?.navStatus,
{ preferLiveStatus: true },
);
Comment thread
cursor[bot] marked this conversation as resolved.
}
const navStatus = snapshot?.navStatus;
return {
available: Boolean(previewBridge?.automation),
visible,
tabId,
url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null,
title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null,
loading: navStatus?._tag === "Loading",
...viewportStatus,
};
return applyPreviewLoadFailureToAutomationStatus(
{
available: Boolean(previewBridge?.automation),
visible,
tabId,
url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null,
title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null,
loading: navStatus?._tag === "Loading",
...viewportStatus,
},
navStatus,
);
};

const raiseAtomCommandFailure = (result: Parameters<typeof squashAtomCommandFailure>[0]): never => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";

import { PreviewMiniPlayerUnreachable } from "./PreviewMiniPlayerUnreachable";

describe("PreviewMiniPlayerUnreachable", () => {
it("shows the failed host with Retry and Close", () => {
const html = renderToStaticMarkup(
<PreviewMiniPlayerUnreachable
url="http://localhost:5173/app"
description="ERR_CONNECTION_REFUSED"
onRetry={() => undefined}
onClose={() => undefined}
/>,
);
expect(html).toContain("localhost:5173");
expect(html).toContain("Retry");
expect(html).toContain("Close");
expect(html).toContain("Connection refused");
expect(html).toContain("pointer-events-auto");
expect(html).toContain("truncate");
});

it("falls back to the raw URL when there is no host", () => {
const html = renderToStaticMarkup(
<PreviewMiniPlayerUnreachable
url="file:///missing.html"
description="ERR_FILE_NOT_FOUND"
onRetry={() => undefined}
onClose={() => undefined}
/>,
);
expect(html).toContain("file:///missing.html");
});
});
43 changes: 43 additions & 0 deletions apps/web/src/components/preview/PreviewMiniPlayerUnreachable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Button } from "~/components/ui/button";

import { describePreviewError } from "./errorCodeMessages";

interface Props {
readonly url: string;
readonly description: string;
readonly onRetry: () => void;
readonly onClose: () => void;
}

/** Compact failed-navigation overlay for the floating mini-player. */
export function PreviewMiniPlayerUnreachable({ url, description, onRetry, onClose }: Props) {
const host = safeHost(url) ?? url;
const friendly = describePreviewError(description);

return (
<div className="pointer-events-auto absolute inset-0 z-[49] flex min-w-0 flex-col items-center justify-center gap-3 overflow-hidden rounded-xl bg-background px-4 text-center">
<p className="min-w-0 max-w-full truncate text-xs font-medium text-foreground">
Can&apos;t reach {host}
</p>
<p className="min-w-0 max-w-full text-[11px] leading-snug text-muted-foreground">
{friendly}
</p>
<div className="flex items-center gap-2">
<Button type="button" size="sm" onClick={onRetry}>
Retry
</Button>
<Button type="button" size="sm" variant="outline" onClick={onClose}>
Close
</Button>
</div>
</div>
);
}

function safeHost(url: string): string | null {
try {
return new URL(url).host || null;
} catch {
return null;
}
}
Loading
Loading