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/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx
index c16af882a..ed777bb24 100644
--- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx
+++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx
@@ -72,20 +72,21 @@ export default function AgentChatPage() {
loadMoreHistory,
} = useThreadStream({
threadId: isNewThread ? undefined : threadId,
+ displayThreadId: threadId,
context: { ...settings.context, agent_name: agent_name },
isMock,
onSend: () => {
setIsWelcomeMode(false);
},
onStart: (createdThreadId) => {
- setThreadId(createdThreadId);
- setIsNewThread(false);
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
history.replaceState(
null,
"",
`/workspace/agents/${agent_name}/chats/${createdThreadId}`,
);
+ setThreadId(createdThreadId);
+ setIsNewThread(false);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx
index ce3912b91..f20c1c228 100644
--- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx
+++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx
@@ -75,6 +75,7 @@ export default function ChatPage() {
loadMoreHistory,
} = useThreadStream({
threadId: isNewThread ? undefined : threadId,
+ displayThreadId: threadId,
context: settings.context,
isMock,
// onSend only animates the UI; do NOT flip `isNewThread` here — the
@@ -84,10 +85,10 @@ export default function ChatPage() {
setIsWelcomeMode(false);
},
onStart: (createdThreadId) => {
- setThreadId(createdThreadId);
- setIsNewThread(false);
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
history.replaceState(null, "", `/workspace/chats/${createdThreadId}`);
+ setThreadId(createdThreadId);
+ setIsNewThread(false);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
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/chats/use-thread-chat.ts b/frontend/src/components/workspace/chats/use-thread-chat.ts
index 6913e3b76..06773d0e1 100644
--- a/frontend/src/components/workspace/chats/use-thread-chat.ts
+++ b/frontend/src/components/workspace/chats/use-thread-chat.ts
@@ -1,29 +1,44 @@
"use client";
import { useParams, usePathname, useSearchParams } from "next/navigation";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { uuid } from "@/core/utils/uuid";
export function useThreadChat() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const pathname = usePathname();
+ const actualPathname =
+ typeof window === "undefined" ? pathname : window.location.pathname;
+ const isNewPath = actualPathname.endsWith("/new");
+ const newThreadIdRef = useRef(
+ threadIdFromPath === "new" ? uuid() : null,
+ );
+
+ if (isNewPath && !newThreadIdRef.current) {
+ newThreadIdRef.current = uuid();
+ }
const searchParams = useSearchParams();
- const [threadId, setThreadId] = useState(() => {
- return threadIdFromPath === "new" ? uuid() : threadIdFromPath;
+ const [threadId, setThreadIdState] = useState(() => {
+ return threadIdFromPath === "new"
+ ? (newThreadIdRef.current ?? uuid())
+ : threadIdFromPath;
});
- const [isNewThread, setIsNewThread] = useState(
+ const [isNewThreadState, setIsNewThreadState] = useState(
() => threadIdFromPath === "new",
);
useEffect(() => {
- if (pathname.endsWith("/new")) {
- setIsNewThread(true);
- setThreadId(uuid());
+ if (isNewPath) {
+ const nextThreadId = newThreadIdRef.current ?? uuid();
+ newThreadIdRef.current = nextThreadId;
+ setIsNewThreadState(true);
+ setThreadIdState(nextThreadId);
return;
}
+ newThreadIdRef.current = null;
// Guard: after history.replaceState updates the URL from /chats/new to
// /chats/{UUID}, Next.js useParams may still return the stale "new" value
// because replaceState does not trigger router updates. Avoid propagating
@@ -32,9 +47,28 @@ export function useThreadChat() {
if (threadIdFromPath === "new") {
return;
}
- setIsNewThread(false);
- setThreadId(threadIdFromPath);
- }, [pathname, threadIdFromPath]);
+ setIsNewThreadState(false);
+ setThreadIdState(threadIdFromPath);
+ }, [isNewPath, threadIdFromPath]);
+
+ const setThreadId = useCallback((nextThreadId: string) => {
+ newThreadIdRef.current = null;
+ setThreadIdState(nextThreadId);
+ }, []);
+
+ const setIsNewThread = useCallback((nextIsNewThread: boolean) => {
+ if (!nextIsNewThread) {
+ newThreadIdRef.current = null;
+ }
+ setIsNewThreadState(nextIsNewThread);
+ }, []);
+
const isMock = searchParams.get("mock") === "true";
- return { threadId, setThreadId, isNewThread, setIsNewThread, isMock };
+ return {
+ threadId: isNewPath ? (newThreadIdRef.current ?? threadId) : threadId,
+ setThreadId,
+ isNewThread: isNewPath ? true : isNewThreadState,
+ setIsNewThread,
+ isMock,
+ };
}
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 (
+