Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
67 changes: 67 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,73 @@ describe("PreviewManager", () => {
),
);

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/";
fromId.mockReturnValue({
id: 42,
isDestroyed: () => false,
getType: () => "webview",
getURL: () => url,
getTitle: () => "localhost:5173",
isLoading: () => false,
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,
});

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

effectIt.effect("rejects a destroyed webview during registration", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down
55 changes: 37 additions & 18 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3039,28 +3039,47 @@ 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",
};
}
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,
};
}
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: false,
};
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
gbarros-dev marked this conversation as resolved.
}
return {
available: true,
visible: true,
tabId,
url: wc.getURL() || null,
title: wc.getTitle() || null,
loading: wc.isLoading(),
};
});

const captureAutomationSnapshot = Effect.fn("PreviewManager.captureAutomationSnapshot")(
Expand Down
36 changes: 23 additions & 13 deletions apps/web/src/components/preview/PreviewAutomationHosts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
} from "./previewNavigationReadiness";
import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer";
import { createPreviewAutomationClientId } from "./previewAutomationClientId";
import { applyPreviewLoadFailureToAutomationStatus } from "./previewAutomationStatus";
import {
needsPreviewAutomationSessionSync,
resolvePreviewAutomationOpenTab,
Expand Down Expand Up @@ -98,9 +99,11 @@ const waitForDesktopOverlay = async (
operation,
requestId,
});
if (state.desktopByTabId[tabId] && previewBridge) {
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.
if (state.desktopByTabId[tabId]?.hasWebContents && previewBridge) {
return;
}
await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
}
Expand Down Expand Up @@ -220,18 +223,25 @@ const currentStatus = async (
};
if (runtimeTabId && tabId && previewBridge && state.desktopByTabId[tabId]) {
const status = await previewBridge.automation.status(runtimeTabId);
return { ...status, tabId, visible, ...viewportStatus };
return applyPreviewLoadFailureToAutomationStatus(
{ ...status, tabId, visible, ...viewportStatus },
snapshot?.navStatus,
{ preferLiveAvailability: 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-[32] 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;
}
}
22 changes: 19 additions & 3 deletions apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/prev
import { useRightPanelStore } from "~/rightPanelStore";

import { previewBridge } from "./previewBridge";
import { PreviewMiniPlayerUnreachable } from "./PreviewMiniPlayerUnreachable";
import {
clampPreviewMiniPlayerPosition,
clampPreviewMiniPlayerSize,
Expand Down Expand Up @@ -62,9 +63,14 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
miniPlayer?.tabId === tabId && miniPlayer.size
? miniPlayer.size
: PREVIEW_MINI_PLAYER_DEFAULT_SIZE;
const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const };
const isUnreachable = navStatus._tag === "LoadFailed";
const close = () => {
usePreviewMiniPlayerStore.getState().close(threadRef);
};
const retry = () => {
if (previewBridge && runtimeTabId) void previewBridge.refresh(runtimeTabId);
};

const openInPanel = () => {
usePreviewMiniPlayerStore.getState().close(threadRef);
Expand Down Expand Up @@ -282,7 +288,10 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
? "Close popped-out preview"
: "Pop preview into separate window"
}
disabled={!desktopOverlay?.hasWebContents}
disabled={
!desktopOverlay?.hasWebContents ||
(isUnreachable && !desktopOverlay.pictureInPicture)
}
onPointerDown={(event) => event.stopPropagation()}
onClick={toggleNativePictureInPicture}
/>
Expand Down Expand Up @@ -319,7 +328,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
<div className="absolute inset-0 z-[29] rounded-xl bg-muted shadow-2xl/35" />
<BrowserSurfaceSlot
tabId={runtimeTabId}
visible={Boolean(desktopOverlay?.hasWebContents)}
Comment thread
cursor[bot] marked this conversation as resolved.
visible={Boolean(desktopOverlay?.hasWebContents) && !isUnreachable}
cornerRadius={12}
fitSourceContent
layoutVersion={
Expand All @@ -330,7 +339,14 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
className="absolute inset-0"
/>
<div className="pointer-events-none absolute inset-0 z-[31] rounded-xl ring-1 ring-inset ring-border/80" />
{!desktopOverlay?.hasWebContents ? (
{isUnreachable && navStatus._tag === "LoadFailed" ? (
<PreviewMiniPlayerUnreachable
url={navStatus.url}
description={navStatus.description}
onRetry={retry}
onClose={close}
/>
) : !desktopOverlay?.hasWebContents ? (
<div className="pointer-events-none absolute inset-0 z-[32] flex items-center justify-center rounded-xl bg-muted text-xs text-muted-foreground">
Reconnecting preview…
</div>
Expand Down
Loading
Loading