diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx
index 1a673f598..798ac8ad5 100644
--- a/web/frontend/src/components/app-header.tsx
+++ b/web/frontend/src/components/app-header.tsx
@@ -49,10 +49,12 @@ export function AppHeader() {
state: gwState,
loading: gwLoading,
canStart,
+ startReason,
restartRequired,
start,
restart,
stop,
+ error: gwError,
} = useGateway()
const isRunning = gwState === "running"
@@ -196,37 +198,50 @@ export function AppHeader() {
- {t("header.gateway.action.stop")}
+ {gwError ?? t("header.gateway.action.stop")}
) : (
-
+
+
+ {/* Wrap in span so the tooltip still fires when the button is disabled */}
+
+
+
+
+ {(gwError || (!canStart && startReason)) ? (
+ {gwError ?? startReason}
+ ) : null}
+
)}
(null)
useEffect(() => {
return subscribeGatewayPolling()
@@ -23,6 +24,7 @@ export function useGateway() {
const start = useCallback(async () => {
if (!canStart) return
+ setError(null)
setLoading(true)
try {
await startGateway()
@@ -32,6 +34,7 @@ export function useGateway() {
})
} catch (err) {
console.error("Failed to start gateway:", err)
+ setError(err instanceof Error ? err.message : String(err))
} finally {
await refreshGatewayState({ force: true })
setLoading(false)
@@ -39,12 +42,14 @@ export function useGateway() {
}, [canStart])
const stop = useCallback(async () => {
+ setError(null)
setLoading(true)
beginGatewayStoppingTransition()
try {
await stopGateway()
} catch (err) {
console.error("Failed to stop gateway:", err)
+ setError(err instanceof Error ? err.message : String(err))
cancelGatewayStoppingTransition()
} finally {
await refreshGatewayState({ force: true })
@@ -55,6 +60,7 @@ export function useGateway() {
const restart = useCallback(async () => {
if (state !== "running") return
+ setError(null)
setLoading(true)
try {
await restartGateway()
@@ -64,11 +70,12 @@ export function useGateway() {
})
} catch (err) {
console.error("Failed to restart gateway:", err)
+ setError(err instanceof Error ? err.message : String(err))
} finally {
await refreshGatewayState({ force: true })
setLoading(false)
}
}, [state])
- return { state, loading, canStart, restartRequired, start, stop, restart }
+ return { state, loading, canStart, startReason, restartRequired, start, stop, restart, error }
}
diff --git a/web/frontend/src/routes/__root.tsx b/web/frontend/src/routes/__root.tsx
index 33ad8c698..d287ce801 100644
--- a/web/frontend/src/routes/__root.tsx
+++ b/web/frontend/src/routes/__root.tsx
@@ -42,8 +42,14 @@ const RootLayout = () => {
globalThis.location.assign("/launcher-login")
}
})
- .catch(() => {
- // Network error or 401 — launcherFetch will handle redirect on real API calls.
+ .catch((err: unknown) => {
+ // For real HTTP errors (e.g. 503 when the auth store is unavailable),
+ // redirect to login so the user can re-authenticate rather than being
+ // silently stranded on the dashboard. Network failures (no response)
+ // are left alone — launcherFetch handles the 401 redirect on real calls.
+ if (err instanceof Error && /^status \d+$/.test(err.message)) {
+ globalThis.location.assign("/launcher-login")
+ }
})
}, [isAuthPage])
diff --git a/web/frontend/src/store/gateway.ts b/web/frontend/src/store/gateway.ts
index 1bdec6220..5bf6f3897 100644
--- a/web/frontend/src/store/gateway.ts
+++ b/web/frontend/src/store/gateway.ts
@@ -14,6 +14,7 @@ export type GatewayState =
export interface GatewayStoreState {
status: GatewayState
canStart: boolean
+ startReason?: string
restartRequired: boolean
}
@@ -57,6 +58,7 @@ function normalizeGatewayStoreState(
if (
next.status === prev.status &&
next.canStart === prev.canStart &&
+ next.startReason === prev.startReason &&
next.restartRequired === prev.restartRequired
) {
return prev
@@ -108,7 +110,10 @@ export function applyGatewayStatusToStore(
data: Partial<
Pick<
GatewayStatusResponse,
- "gateway_status" | "gateway_start_allowed" | "gateway_restart_required"
+ | "gateway_status"
+ | "gateway_start_allowed"
+ | "gateway_start_reason"
+ | "gateway_restart_required"
>
>,
) {
@@ -121,6 +126,10 @@ export function applyGatewayStatusToStore(
prev.status === "stopping" && data.gateway_status === "running"
? false
: (data.gateway_start_allowed ?? prev.canStart),
+ startReason:
+ prev.status === "stopping" && data.gateway_status === "running"
+ ? prev.startReason
+ : (data.gateway_start_reason ?? prev.startReason),
restartRequired:
prev.status === "stopping" && data.gateway_status === "running"
? false