diff --git a/frontend/src/app/(auth)/layout.tsx b/frontend/src/app/(auth)/layout.tsx
index 0b35d4ac1..a7a5f413e 100644
--- a/frontend/src/app/(auth)/layout.tsx
+++ b/frontend/src/app/(auth)/layout.tsx
@@ -1,7 +1,7 @@
-import Link from "next/link";
import { redirect } from "next/navigation";
import { type ReactNode } from "react";
+import { GatewayOfflineFallback } from "@/components/workspace/gateway-offline-fallback";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { getServerSideUser } from "@/core/auth/server";
import { assertNever } from "@/core/auth/types";
@@ -25,18 +25,17 @@ export default async function AuthLayout({
case "unauthenticated":
return {children};
case "gateway_unavailable":
+ // Auth pages have no banner of their own, so render one here. The
+ // fallback's AuthProvider replaces the bare-HTML branch that
+ // previously locked users out without any logout/retry capability.
return (
-
-
- Service temporarily unavailable.
-
-
- Retry
-
-
+
+
+
+ Service temporarily unavailable.
+
+
+
);
case "config_error":
throw new Error(result.message);
diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx
index 0d214f0d3..0f576930a 100644
--- a/frontend/src/app/workspace/layout.tsx
+++ b/frontend/src/app/workspace/layout.tsx
@@ -1,6 +1,6 @@
-import Link from "next/link";
import { redirect } from "next/navigation";
+import { GatewayOfflineFallback } from "@/components/workspace/gateway-offline-fallback";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { getServerSideUser } from "@/core/auth/server";
import { assertNever } from "@/core/auth/types";
@@ -28,31 +28,13 @@ export default async function WorkspaceLayout({
case "unauthenticated":
redirect("/login");
case "gateway_unavailable":
+ // GatewayOfflineFallback supplies the AuthProvider; WorkspaceContent
+ // already mounts the banner inside its sidebar layout, so renderBanner
+ // stays false here to avoid double-mounting.
return (
-
-
- Service temporarily unavailable.
-
-
- The backend may be restarting. Please wait a moment and try again.
-
-
-
- Retry
-
-
-
-
+
+ {children}
+
);
case "config_error":
throw new Error(result.message);
diff --git a/frontend/src/app/workspace/workspace-content.tsx b/frontend/src/app/workspace/workspace-content.tsx
index 85c20b2ca..ded8c81ee 100644
--- a/frontend/src/app/workspace/workspace-content.tsx
+++ b/frontend/src/app/workspace/workspace-content.tsx
@@ -4,6 +4,7 @@ import { Toaster } from "sonner";
import { QueryClientProvider } from "@/components/query-client-provider";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { CommandPalette } from "@/components/workspace/command-palette";
+import { GatewayOfflineBanner } from "@/components/workspace/gateway-offline-banner";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
function parseSidebarOpenCookie(
@@ -16,7 +17,11 @@ function parseSidebarOpenCookie(
export async function WorkspaceContent({
children,
-}: Readonly<{ children: React.ReactNode }>) {
+ gatewayUnavailable = false,
+}: Readonly<{
+ children: React.ReactNode;
+ gatewayUnavailable?: boolean;
+}>) {
const cookieStore = await cookies();
const initialSidebarOpen = parseSidebarOpenCookie(
cookieStore.get("sidebar_state")?.value,
@@ -26,7 +31,10 @@ export async function WorkspaceContent({
- {children}
+
+
+ {children}
+
diff --git a/frontend/src/components/workspace/gateway-offline-banner-helpers.ts b/frontend/src/components/workspace/gateway-offline-banner-helpers.ts
new file mode 100644
index 000000000..f76756e14
--- /dev/null
+++ b/frontend/src/components/workspace/gateway-offline-banner-helpers.ts
@@ -0,0 +1,87 @@
+export const OFFLINE_BANNER_RETRY_INTERVAL_MS = 10_000;
+
+/**
+ * Number of consecutive 401 responses before treating the session as
+ * expired and delegating to AuthProvider.refreshUser() for /login redirect.
+ *
+ * Threshold > 1 absorbs transient 401s that may occur in the first few
+ * milliseconds after a gateway becomes ready again, without indefinitely
+ * masking a genuinely expired cookie.
+ */
+export const OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD = 3;
+
+import type { User } from "@/core/auth/types";
+
+export function shouldShowOfflineBanner(
+ user: User | null,
+ gatewayUnavailable: boolean,
+): boolean {
+ return gatewayUnavailable && user === null;
+}
+
+/** Categorised outcome of a single /auth/me probe. */
+export type ProbeOutcome =
+ | { kind: "ok"; user: User } // 2xx with parsed body
+ | { kind: "unauthorized" } // 401
+ | { kind: "transient" }; // 5xx, network, abort, malformed body, etc.
+
+/** Next action the banner effect should take after a probe. */
+export type ProbeAction =
+ | { type: "apply-user"; user: User }
+ | { type: "delegate-refresh"; reason: "session-expired" }
+ | { type: "noop"; nextFailureCount: number };
+
+/**
+ * Pure: classify an HTTP probe outcome into ProbeOutcome.
+ *
+ * Extracted from the banner effect so it can be unit-tested independently.
+ * `parsedUser` is the JSON body of a 2xx response (or null if absent/malformed);
+ * surfacing it through ProbeOutcome lets the caller apply it directly instead
+ * of paying for a second /auth/me round-trip via refreshUser().
+ */
+export function classifyProbe(
+ res: Response | null,
+ errored: boolean,
+ parsedUser: User | null = null,
+): ProbeOutcome {
+ if (errored || res === null) return { kind: "transient" };
+ if (res.ok && parsedUser !== null) return { kind: "ok", user: parsedUser };
+ if (res.ok) return { kind: "transient" }; // 2xx but body unusable
+ if (res.status === 401) return { kind: "unauthorized" };
+ return { kind: "transient" };
+}
+
+/**
+ * Pure state machine for what to do after a probe lands.
+ *
+ * Inputs: how many consecutive 401s we've seen so far + the new outcome.
+ * Outputs: either "apply the user body we just fetched", "delegate to
+ * refreshUser() for /login redirect", or "do nothing, update counter".
+ *
+ * Transient outcomes (5xx / network / abort) decrement the auth-failure
+ * streak by 1 (floored at 0) rather than resetting it. This prevents a
+ * flapping gateway that alternates 401 ↔ 5xx from indefinitely masking a
+ * genuinely expired session: the streak still converges on the threshold.
+ */
+export function decideProbeAction(
+ consecutiveAuthFailures: number,
+ outcome: ProbeOutcome,
+ threshold: number = OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD,
+): ProbeAction {
+ if (outcome.kind === "ok") {
+ return { type: "apply-user", user: outcome.user };
+ }
+ if (outcome.kind === "unauthorized") {
+ const next = consecutiveAuthFailures + 1;
+ if (next >= threshold) {
+ return { type: "delegate-refresh", reason: "session-expired" };
+ }
+ return { type: "noop", nextFailureCount: next };
+ }
+ // transient: decrement rather than reset so a flapping gateway
+ // (alternating 401 ↔ 5xx) still converges on session-expired.
+ return {
+ type: "noop",
+ nextFailureCount: Math.max(0, consecutiveAuthFailures - 1),
+ };
+}
diff --git a/frontend/src/components/workspace/gateway-offline-banner.tsx b/frontend/src/components/workspace/gateway-offline-banner.tsx
new file mode 100644
index 000000000..31e3a7426
--- /dev/null
+++ b/frontend/src/components/workspace/gateway-offline-banner.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+import { useAuth } from "@/core/auth/AuthProvider";
+import { userSchema, type User } from "@/core/auth/types";
+import { useI18n } from "@/core/i18n/hooks";
+
+import {
+ OFFLINE_BANNER_RETRY_INTERVAL_MS,
+ classifyProbe,
+ decideProbeAction,
+ shouldShowOfflineBanner,
+} from "./gateway-offline-banner-helpers";
+
+interface GatewayOfflineBannerProps {
+ /**
+ * True when the server-side auth probe at `/api/v1/auth/me` could not
+ * reach the gateway. The banner stays mounted until a client-side probe
+ * confirms the gateway is healthy and `user` becomes populated.
+ */
+ gatewayUnavailable: boolean;
+}
+
+export function GatewayOfflineBanner({
+ gatewayUnavailable,
+}: GatewayOfflineBannerProps) {
+ const { t } = useI18n();
+ const { user, applyUser, refreshUser, logout } = useAuth();
+ // Guard against piling up probe calls while the gateway is still slow.
+ const inFlightRef = useRef(false);
+ // Count consecutive 401s so we can distinguish "transient warm-up 401"
+ // from "session actually expired" and avoid lying with the banner.
+ const authFailuresRef = useRef(0);
+
+ useEffect(() => {
+ if (!gatewayUnavailable) return;
+ // Once AuthProvider has a user again the banner has served its
+ // purpose; tear down the polling so we don't keep probing every 10s
+ // for the entire lifetime of the page (gatewayUnavailable is a
+ // server-rendered prop and stays true until a full reload).
+ if (user !== null) return;
+
+ const probe = async () => {
+ if (inFlightRef.current) return;
+ inFlightRef.current = true;
+ let res: Response | null = null;
+ let errored = false;
+ let parsedUser: User | null = null;
+ try {
+ res = await fetch("/api/v1/auth/me", {
+ credentials: "include",
+ cache: "no-store",
+ });
+ // Reuse the probe's own response body instead of triggering a
+ // second /auth/me request via refreshUser() — halves the recovery
+ // burst against an already-struggling gateway.
+ if (res.ok) {
+ try {
+ const data = await res.json();
+ const parsed = userSchema.safeParse(data);
+ if (parsed.success) parsedUser = parsed.data;
+ } catch (err) {
+ console.warn(
+ "[gateway-offline-banner] probe body parse failed:",
+ err,
+ );
+ }
+ }
+ } catch (err) {
+ console.warn("[gateway-offline-banner] probe failed:", err);
+ errored = true;
+ } finally {
+ inFlightRef.current = false;
+ }
+
+ const action = decideProbeAction(
+ authFailuresRef.current,
+ classifyProbe(res, errored, parsedUser),
+ );
+
+ if (action.type === "apply-user") {
+ authFailuresRef.current = 0;
+ applyUser(action.user);
+ return;
+ }
+ if (action.type === "delegate-refresh") {
+ // Hand off to AuthProvider, which on 401 will /login-redirect.
+ authFailuresRef.current = 0;
+ await refreshUser();
+ return;
+ }
+ authFailuresRef.current = action.nextFailureCount;
+ };
+
+ void probe();
+ const handle = window.setInterval(() => {
+ void probe();
+ }, OFFLINE_BANNER_RETRY_INTERVAL_MS);
+ return () => {
+ window.clearInterval(handle);
+ };
+ }, [gatewayUnavailable, user, applyUser, refreshUser]);
+
+ if (!shouldShowOfflineBanner(user, gatewayUnavailable)) {
+ return null;
+ }
+
+ return (
+