diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py
index fa7c445d..c40571b4 100644
--- a/backend/apps/outputs/outputs.py
+++ b/backend/apps/outputs/outputs.py
@@ -497,6 +497,7 @@ def runtime_status_payload(workspace_id: str, instance: int = 1) -> dict:
"running": rt.running,
# 'spawned' vs 'serving': ready flips only once the primary port answered the bind poll (and un-flips when the process dies or is frozen).
"ready": rt.ready,
+ "boot_failed": bool(getattr(rt, "boot_failed", False)),
# True when the app is served as a built bundle with no dev-server process (ENG-209).
"serve_static": rt.serve_static,
"port": rt.port,
diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py
index fb84b9cd..5cd042f4 100644
--- a/backend/apps/outputs/runtime.py
+++ b/backend/apps/outputs/runtime.py
@@ -113,6 +113,8 @@ class AppRuntime:
self.serve_static: bool = False
# New-mode only: flips True once something is actually listening on frontend_port (we kick off a background poll task in p_start_new_mode). frontend_url returns null until this flips, so the preview pane doesn't try to navigate to an unbound port and show a "Site can't be reached" error mid-npm-install.
self.p_frontend_ready: bool = False
+ # Set when the bind poll gave up; the status payload carries it so the card can stop spinning honestly.
+ self.boot_failed = False
# True only while a live bind-poll task owns the global vite boot lock; start() releases it otherwise.
self.p_boot_lock_handed_off: bool = False
# True while the process tree is SIGSTOP'd in the idle pool. A frozen vite still holds its port but can't answer it, so frontend_url must stay null while suspended (else the webview loads a dead port = the ERR_FAILED on fast app-switching).
@@ -427,6 +429,8 @@ class AppRuntime:
pass
await asyncio.sleep(FRONTEND_BIND_POLL_INTERVAL)
# Timed out; keep the runtime up (Terminal might show useful errors) but surface why the preview never appeared.
+ # boot_failed reaches the CARD: the log line lands in a Terminal most users never open, so the spinner span "Starting preview" forever (Alex's report).
+ self.boot_failed = True
self.p_broadcast(LogLine(
"runtime",
f"[runtime] frontend did NOT bind on port {port} after "
diff --git a/backend/main.py b/backend/main.py
index bf613c2d..2dcfdc8b 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -315,6 +315,7 @@ async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str, instan
# The property is the one honest gate (crashed/suspended vite -> None, static -> serve URL); a duplicate running check here nulled every serve-static app's URL, whose card then waited forever on a process that never exists (the "Starting preview" wedge).
"frontend_url": rt.frontend_url,
"is_new_mode": rt.is_new_mode,
+ "boot_failed": bool(getattr(rt, "boot_failed", False)),
},
}
diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx
index 4d3193c2..3c3e2776 100644
--- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx
+++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx
@@ -87,6 +87,21 @@ interface Props {
}
// The app card's loading state while its runtime spins up. One soft pulse, calm copy, and an honest hint only after 9s, a freshly-imported app installs its deps on first open, which is the slow case worth explaining instead of leaving the user staring at a dead screen.
+const BootFailedBody: React.FC<{ onRetry: () => void }> = ({ onRetry }) => {
+ const c = useClaudeTokens();
+ return (
+
+ Preview didn't start
+
+ The app's dev server never came up. The Terminal tab has the exact error.
+
+
+ Try again
+
+
+ );
+};
+
const BootingBody: React.FC = () => {
const c = useClaudeTokens();
const [slow, setSlow] = useState(false);
@@ -1012,7 +1027,7 @@ const DashboardOutputPreview: React.FC<{
const tokens = useClaudeTokens();
const dispatch = useAppDispatch();
const workspaceId = output.workspace_id ?? null;
- const { frontendUrl, isNewMode, isHydrating } = useRuntimePreviewUrl({
+ const { frontendUrl, isNewMode, isHydrating, bootFailed } = useRuntimePreviewUrl({
workspaceId,
enabled: !!workspaceId,
onLog: onRuntimeLog,
@@ -1111,6 +1126,9 @@ const DashboardOutputPreview: React.FC<{
}
if (isBooting) {
+ if (bootFailed) {
+ return { const tok = getAuthToken(); void fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/restart?instance=${instance}`, { method: 'POST', headers: tok ? { Authorization: `Bearer ${tok}` } : {} }).catch(() => {}); }} />;
+ }
return ;
}
diff --git a/frontend/src/shared/hooks/useRuntimePreviewUrl.ts b/frontend/src/shared/hooks/useRuntimePreviewUrl.ts
index 4186ba7e..6b0993ac 100644
--- a/frontend/src/shared/hooks/useRuntimePreviewUrl.ts
+++ b/frontend/src/shared/hooks/useRuntimePreviewUrl.ts
@@ -15,6 +15,8 @@ export interface RuntimePreviewState {
isNewMode: boolean;
// True until the runtime:status frame lands; prevents placeholder flash on remount when Vite is up.
isHydrating: boolean;
+ // The bind poll gave up: the spinner must become an honest failure state, not spin forever.
+ bootFailed: boolean;
}
export interface RuntimePreviewOptions {
@@ -31,6 +33,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
const [frontendUrl, setFrontendUrl] = useState(null);
const [isNewMode, setIsNewMode] = useState(false);
const [isHydrating, setIsHydrating] = useState(true);
+ const [bootFailed, setBootFailed] = useState(false);
// Pin latest onLog so callback identity changes don't tear down/respawn the runtime.
const onLogRef = useRef(onLog);
onLogRef.current = onLog;
@@ -80,6 +83,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
const fu = msg.data?.frontend_url ?? null;
setFrontendUrl(fu || null);
setIsNewMode(!!msg.data?.is_new_mode);
+ setBootFailed(!!msg.data?.boot_failed && !fu);
setIsHydrating(false);
} else if (msg.event === 'runtime:log') {
const stream = msg.data?.stream || 'stdout';
@@ -120,7 +124,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
};
}, [workspaceId, enabled, instance]);
- return { frontendUrl, isNewMode, isHydrating };
+ return { frontendUrl, isNewMode, isHydrating, bootFailed };
}
export interface PickPreviewUrlOptions {