diff --git a/__tests__/components/backends/manage-backends-modal.test.tsx b/__tests__/components/backends/manage-backends-modal.test.tsx index 186cc4bcb5..56d90315a6 100644 --- a/__tests__/components/backends/manage-backends-modal.test.tsx +++ b/__tests__/components/backends/manage-backends-modal.test.tsx @@ -19,6 +19,12 @@ import { ManageBackendsModal } from "#/components/features/backends/manage-backe import { BackendVersion } from "#/components/features/backends/backend-version"; import { BackendRow } from "#/components/features/backends/backend-row"; import { type Backend } from "#/api/backend-registry/types"; +import { CLOUD_BACKEND_LOGGED_OUT_ERROR } from "#/hooks/query/use-backends-health"; + +const deviceFlowMocks = vi.hoisted(() => ({ + startDeviceFlow: vi.fn(), + pollForToken: vi.fn(), +})); const getServerInfoMock = vi.fn().mockResolvedValue({ version: "1.28.0" }); const getSettingsMock = vi.fn().mockResolvedValue({}); @@ -39,6 +45,19 @@ vi.mock("#/api/cloud/organization-service.api", () => ({ }), })); +vi.mock("#/api/device-flow-client", () => ({ + startDeviceFlow: deviceFlowMocks.startDeviceFlow, + pollForToken: deviceFlowMocks.pollForToken, + DeviceFlowError: class DeviceFlowError extends Error { + code: string; + + constructor(message: string, code: string) { + super(message); + this.code = code; + } + }, +})); + function renderWithProviders(ui: React.ReactElement) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -71,12 +90,25 @@ beforeEach(() => { getServerInfoMock.mockResolvedValue({ version: "1.28.0" }); getSettingsMock.mockReset(); getSettingsMock.mockResolvedValue({}); + deviceFlowMocks.startDeviceFlow.mockReset(); + deviceFlowMocks.startDeviceFlow.mockResolvedValue({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://app.all-hands.dev/device", + verification_uri_complete: + "https://app.all-hands.dev/device?user_code=ABCD-EFGH", + expires_in: 600, + interval: 5, + }); + deviceFlowMocks.pollForToken.mockReset(); + deviceFlowMocks.pollForToken.mockImplementation(() => new Promise(() => {})); __resetActiveStoreForTests(); __resetHealthStoreForTests(); }); afterEach(() => { window.localStorage.clear(); + vi.restoreAllMocks(); __resetActiveStoreForTests(); __resetHealthStoreForTests(); }); @@ -140,9 +172,7 @@ describe("ManageBackendsModal", () => { const onClose = vi.fn(); renderWithProviders(); - await user.click( - await screen.findByTestId("close-manage-backends-modal"), - ); + await user.click(await screen.findByTestId("close-manage-backends-modal")); expect(onClose).toHaveBeenCalledTimes(1); }); @@ -271,9 +301,7 @@ describe("ManageBackendsModal", () => { const stored = JSON.parse( window.localStorage.getItem("openhands-backends") ?? "[]", ); - const updated = stored.find( - (b: { id: string }) => b.id === backendId, - ); + const updated = stored.find((b: { id: string }) => b.id === backendId); expect(updated).toMatchObject({ name: "OHE Prod Renamed", kind: "cloud", @@ -368,4 +396,96 @@ describe("BackendRow", () => { .closest("button"); expect(selectButton).toBeDisabled(); }); + + it("shows a logged-out cloud status with a log back in button", () => { + const onLogin = vi.fn(); + + renderInQueryClient( + , + ); + + expect( + screen.getByTestId(`manage-backends-status-${cloudBackend.name}`), + ).toHaveTextContent("BACKEND$LOGGED_OUT"); + expect( + screen.queryByTestId( + `manage-backends-status-detail-${cloudBackend.name}`, + ), + ).not.toBeInTheDocument(); + const loginButton = screen.getByTestId( + `manage-backends-login-${cloudBackend.id}-login-button`, + ); + expect(loginButton).toHaveAccessibleName("BACKEND$LOG_BACK_IN"); + expect(loginButton).not.toHaveTextContent("BACKEND$LOG_BACK_IN"); + expect(loginButton.querySelector("svg")).toBeInTheDocument(); + expect(loginButton).toHaveClass("hover:bg-interactive-hover"); + expect(loginButton).not.toHaveClass("border"); + expect(loginButton).not.toHaveClass("bg-primary"); + }); + + it("shows device authorization in a modal instead of expanding the row", async () => { + const user = userEvent.setup(); + const popup = { + closed: false, + close: vi.fn(), + location: { href: "" }, + } as unknown as Window; + vi.spyOn(window, "open").mockReturnValue(popup); + + renderInQueryClient( + , + ); + + const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`); + await user.click( + screen.getByTestId( + `manage-backends-login-${cloudBackend.id}-login-button`, + ), + ); + + const modal = await screen.findByTestId( + `manage-backends-login-${cloudBackend.id}-auth-modal`, + ); + expect( + await within(modal).findByTestId( + `manage-backends-login-${cloudBackend.id}-auth-awaiting`, + ), + ).toBeInTheDocument(); + expect( + within(row).queryByTestId( + `manage-backends-login-${cloudBackend.id}-auth-awaiting`, + ), + ).not.toBeInTheDocument(); + expect(deviceFlowMocks.startDeviceFlow).toHaveBeenCalledWith( + cloudBackend.host, + ); + }); }); diff --git a/__tests__/hooks/query/use-backends-health.test.tsx b/__tests__/hooks/query/use-backends-health.test.tsx index 4ccdbf624c..7bcc29491d 100644 --- a/__tests__/hooks/query/use-backends-health.test.tsx +++ b/__tests__/hooks/query/use-backends-health.test.tsx @@ -16,7 +16,10 @@ import { resetBackendHealth, } from "#/api/backend-registry/health-store"; import type { Backend } from "#/api/backend-registry/types"; -import { useBackendsHealth } from "#/hooks/query/use-backends-health"; +import { + CLOUD_BACKEND_LOGGED_OUT_ERROR, + useBackendsHealth, +} from "#/hooks/query/use-backends-health"; const getSettingsMock = vi.fn(); const getServerInfoMock = vi.fn(); @@ -160,7 +163,7 @@ describe("useBackendsHealth", () => { }); it("reports disconnected when the cloud probe throws", async () => { - getCurrentCloudApiKeyMock.mockRejectedValue(new Error("401")); + getCurrentCloudApiKeyMock.mockRejectedValue(new Error("Network Error")); const { result } = renderHook(() => useBackendsHealth([cloudBackend]), { wrapper, @@ -171,6 +174,26 @@ describe("useBackendsHealth", () => { ); }); + it("reports logged out when the cloud probe returns 401", async () => { + getCurrentCloudApiKeyMock.mockRejectedValue( + Object.assign(new Error("Unauthorized"), { + isAxiosError: true, + response: { status: 401 }, + }), + ); + + const { result } = renderHook(() => useBackendsHealth([cloudBackend]), { + wrapper, + }); + + await waitFor(() => + expect(result.current[cloudBackend.id]).toMatchObject({ + isConnected: false, + lastError: CLOUD_BACKEND_LOGGED_OUT_ERROR, + }), + ); + }); + it("reports null while the first probe is still in flight", async () => { let resolveProbe!: () => void; getSettingsMock.mockImplementation( diff --git a/__tests__/query-client-config.test.ts b/__tests__/query-client-config.test.ts index baa9dae1e1..743adce6ec 100644 --- a/__tests__/query-client-config.test.ts +++ b/__tests__/query-client-config.test.ts @@ -1,9 +1,13 @@ import { AxiosError } from "axios"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createAgentServerQueryClient } from "#/query-client-config"; +import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; import * as ToastHandlers from "#/utils/custom-toast-handlers"; afterEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + __resetActiveStoreForTests(); vi.restoreAllMocks(); }); @@ -42,4 +46,47 @@ describe("createAgentServerQueryClient", () => { expect(toastSpy).toHaveBeenCalledWith("query error with toast"); }); + + it("does not show raw 401 toasts while the active cloud backend is logged out", async () => { + const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); + const backend = { + id: "cloud-expired", + name: "OpenHands Cloud", + host: "https://app.all-hands.dev", + apiKey: "expired-token", + kind: "cloud", + }; + window.localStorage.setItem( + "openhands-backends", + JSON.stringify([backend]), + ); + window.localStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: backend.id, orgId: null }), + ); + window.sessionStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: backend.id, orgId: null }), + ); + __resetActiveStoreForTests(); + const client = createAgentServerQueryClient(); + + await expect( + client.fetchQuery({ + queryKey: ["cloud", "logged-out"], + queryFn: async () => { + throw new AxiosError( + "Request failed with status code 401", + "ERR_BAD_REQUEST", + undefined, + undefined, + { status: 401 } as never, + ); + }, + retry: false, + }), + ).rejects.toThrow("Request failed with status code 401"); + + expect(toastSpy).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/root.test.tsx b/__tests__/root.test.tsx index e041920e2f..e5f75f23b5 100644 --- a/__tests__/root.test.tsx +++ b/__tests__/root.test.tsx @@ -9,14 +9,16 @@ import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store" import { ActiveBackendProvider } from "#/contexts/active-backend-context"; const TRANSLATIONS: Record = { - "BACKEND$MANAGE_TITLE": "Manage backends", - "BACKEND$MANAGE_EMPTY": "No backends yet.", - "BACKEND$ADD": "+ Add Backend", - "BACKEND$KIND_LOCAL": "Local", - "BACKEND$KIND_CLOUD": "Cloud", - "BACKEND$EDIT": "Edit", - "BACKEND$REMOVE": "Remove", - "HOME$DONE": "Done", + BACKEND$MANAGE_TITLE: "Manage backends", + BACKEND$MANAGE_EMPTY: "No backends yet.", + BACKEND$ADD: "+ Add Backend", + BACKEND$LOG_BACK_IN: "Log back in", + BACKEND$LOGGED_OUT: "Logged out", + BACKEND$KIND_LOCAL: "Local", + BACKEND$KIND_CLOUD: "Cloud", + BACKEND$EDIT: "Edit", + BACKEND$REMOVE: "Remove", + HOME$DONE: "Done", }; vi.mock("react-i18next", () => ({ @@ -65,7 +67,6 @@ describe("App root agent-server availability guard", () => { __resetActiveStoreForTests(); }); - it("shows the manage-backends modal when the connected server reports an old version", async () => { server.use( http.get("/server_info", () => @@ -136,6 +137,48 @@ describe("App root agent-server availability guard", () => { expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); }); + it("shows the manage-backends recovery modal when the active cloud backend is logged out", async () => { + const cloudBackend = { + id: "cloud-expired", + name: "OpenHands Cloud", + host: "https://app.all-hands.dev", + apiKey: "expired-token", + kind: "cloud", + }; + window.localStorage.setItem( + "openhands-backends", + JSON.stringify([cloudBackend]), + ); + window.localStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: cloudBackend.id, orgId: null }), + ); + window.sessionStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: cloudBackend.id, orgId: null }), + ); + __resetActiveStoreForTests(); + server.use( + http.get("https://app.all-hands.dev/api/keys/current", () => + HttpResponse.json({ detail: "NoCredentialsError" }, { status: 401 }), + ), + ); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("agent-server-onboarding-screen"), + ).toBeInTheDocument(); + }); + expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument(); + expect(screen.getByText("Logged out")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Log back in" }), + ).toBeInTheDocument(); + expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); + }); + it("renders the routed page when the agent server is reachable", async () => { renderApp(["/"]); diff --git a/src/components/features/backends/backend-row.tsx b/src/components/features/backends/backend-row.tsx index 768f320af5..3fea356daf 100644 --- a/src/components/features/backends/backend-row.tsx +++ b/src/components/features/backends/backend-row.tsx @@ -1,8 +1,9 @@ import { useTranslation } from "react-i18next"; -import { Pencil, Trash2 } from "lucide-react"; +import { Pencil, RefreshCw, Trash2 } from "lucide-react"; import { type Backend } from "#/api/backend-registry/types"; import { + isCloudBackendLoggedOutHealthError, isInvalidBackendApiKeyHealthError, type BackendHealth, } from "#/hooks/query/use-backends-health"; @@ -10,6 +11,7 @@ import { I18nKey } from "#/i18n/declaration"; import { cn } from "#/utils/utils"; import { BackendStatusDot } from "./backend-status-dot"; import { BackendVersion } from "./backend-version"; +import { DeviceFlowAuth } from "./device-flow-auth"; const ROW_ACTION_BUTTON_CLASS = "inline-flex cursor-pointer items-center justify-center rounded-md p-1 text-muted transition-colors hover:bg-interactive-hover hover:text-white"; @@ -20,6 +22,7 @@ interface BackendRowProps { onSelect: () => void; onEdit: () => void; onRemove: () => void; + onLogin?: (apiKey: string) => void; } export function BackendRow({ @@ -28,17 +31,27 @@ export function BackendRow({ onSelect, onEdit, onRemove, + onLogin, }: BackendRowProps) { const { t } = useTranslation("openhands"); const isInvalidApiKey = isInvalidBackendApiKeyHealthError(health?.lastError); + const isCloudLoggedOut = + backend.kind === "cloud" && + isCloudBackendLoggedOutHealthError(health?.lastError); const statusDetail = - !isInvalidApiKey && health?.isConnected === false && health.lastError + !isInvalidApiKey && + !isCloudLoggedOut && + health?.isConnected === false && + health.lastError ? health.lastError : null; let statusLabel: string; let statusClassName = "text-[var(--oh-muted)]"; - if (isInvalidApiKey) { + if (isCloudLoggedOut) { + statusLabel = t(I18nKey.BACKEND$LOGGED_OUT); + statusClassName = "text-red-300"; + } else if (isInvalidApiKey) { statusLabel = t(I18nKey.AUTH$INVALID_KEY); statusClassName = "text-red-300"; } else if (health?.isConnected === true) { @@ -100,7 +113,22 @@ export function BackendRow({ : t(I18nKey.BACKEND$KIND_LOCAL)} -
+
+ {isCloudLoggedOut && onLogin ? ( + + } + className="w-auto" + buttonVariant="unstyled" + buttonClassName={ROW_ACTION_BUTTON_CLASS} + statusDisplay="modal" + /> + ) : null} + )} + + {deviceFlow.status === "idle" && buttonVariant !== "unstyled" && ( - {t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS)} + {idleButtonContent ?? idleLabel} )} - {deviceFlow.status === "starting" && ( -
+ + {statusContent} + + + ) : null} +
+ ); +} + +interface DeviceFlowStatusContentProps { + status: DeviceFlowStatus; + error: string | null; + verificationUrl: string | null; + testIdRoot: string; + onCancel: () => void; + onRetry: () => void; +} + +function DeviceFlowStatusContent({ + status, + error, + verificationUrl, + testIdRoot, + onCancel, + onRetry, +}: DeviceFlowStatusContentProps) { + const { t } = useTranslation("openhands"); + + if (status === "idle" || status === "success") return null; + + if (status === "starting") { + return ( +
+ + + {t(I18nKey.BACKEND$AUTH_STARTING)} + +
+ ); + } + + if (status === "awaiting_authorization") { + const validVerificationUrl = + verificationUrl && isValidVerificationUrl(verificationUrl) + ? verificationUrl + : null; + + return ( +
+
- - {t(I18nKey.BACKEND$AUTH_STARTING)} + + {t(I18nKey.BACKEND$AUTH_AWAITING)}
- )} - - {deviceFlow.status === "awaiting_authorization" && ( -
-
- - - {t(I18nKey.BACKEND$AUTH_AWAITING)} - +

+ {t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)} +

+ {validVerificationUrl ? ( +
+

{t(I18nKey.BACKEND$AUTH_OPEN_MANUALLY)}

+ + {validVerificationUrl} +
-

- {t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)} -

- {deviceFlow.verificationUrl && - isValidVerificationUrl(deviceFlow.verificationUrl) && ( -
-

{t(I18nKey.BACKEND$AUTH_OPEN_MANUALLY)}

- - {deviceFlow.verificationUrl} - -
- )} - - {t(I18nKey.BACKEND$AUTH_CANCEL)} - -
- )} - - {deviceFlow.status === "error" && ( -
-

{deviceFlow.error}

- - {t(I18nKey.BACKEND$AUTH_RETRY)} - -
- )} + {t(I18nKey.BACKEND$AUTH_CANCEL)} + +
+ ); + } + + return ( +
+

{error}

+ + {t(I18nKey.BACKEND$AUTH_RETRY)} +
); } diff --git a/src/components/features/backends/manage-backends-modal.tsx b/src/components/features/backends/manage-backends-modal.tsx index 52eeabfe4d..34965a418b 100644 --- a/src/components/features/backends/manage-backends-modal.tsx +++ b/src/components/features/backends/manage-backends-modal.tsx @@ -38,7 +38,7 @@ export function ManageBackendsModal({ recoveryMode = false, }: ManageBackendsModalProps) { const { t } = useTranslation("openhands"); - const { backends, active, removeBackend, setActive } = + const { backends, active, removeBackend, setActive, updateBackend } = useActiveBackendContext(); const healthByBackendId = useBackendsHealth(backends, { probeDisabledOnce: true, @@ -66,6 +66,13 @@ export function ManageBackendsModal({ [active.backend.id, active.orgId, onClose, setActive], ); + const handleCloudLogin = React.useCallback( + (backend: Backend, apiKey: string) => { + updateBackend(backend.id, { apiKey }); + }, + [updateBackend], + ); + return ( <> handleCloudLogin(backend, apiKey)} /> ))} diff --git a/src/hooks/query/use-backends-health.ts b/src/hooks/query/use-backends-health.ts index 4164340754..69cfb32e23 100644 --- a/src/hooks/query/use-backends-health.ts +++ b/src/hooks/query/use-backends-health.ts @@ -1,4 +1,5 @@ import React from "react"; +import axios from "axios"; import { useQueries } from "@tanstack/react-query"; import { ServerClient, @@ -22,6 +23,7 @@ import { MAX_CONSECUTIVE_FAILURES } from "#/api/backend-registry/health-storage" const REFRESH_INTERVAL_MS = 10000; const PROBE_TIMEOUT_MS = 4000; export const INVALID_BACKEND_API_KEY_ERROR = "Invalid API key"; +export const CLOUD_BACKEND_LOGGED_OUT_ERROR = "Logged out"; export function isInvalidBackendApiKeyHealthError( error: string | null | undefined, @@ -29,6 +31,12 @@ export function isInvalidBackendApiKeyHealthError( return error === INVALID_BACKEND_API_KEY_ERROR; } +export function isCloudBackendLoggedOutHealthError( + error: string | null | undefined, +): boolean { + return error === CLOUD_BACKEND_LOGGED_OUT_ERROR; +} + /** * Probe a single backend for connectivity. The probe path differs by * backend kind: @@ -48,7 +56,14 @@ export function isInvalidBackendApiKeyHealthError( */ async function probeBackend(backend: Backend): Promise { if (backend.kind === "cloud") { - await getCurrentCloudApiKey(backend); + try { + await getCurrentCloudApiKey(backend); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + throw new Error(CLOUD_BACKEND_LOGGED_OUT_ERROR); + } + throw error; + } return true; } diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 2c2a553542..4984760497 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -27335,6 +27335,40 @@ "uk": "Увійти через OpenHands Cloud", "ca": "Inicia sessió amb OpenHands Cloud" }, + "BACKEND$LOG_BACK_IN": { + "en": "Log back in", + "ar": "تسجيل الدخول مجددًا", + "ca": "Torna a iniciar sessió", + "de": "Erneut anmelden", + "es": "Volver a iniciar sesión", + "fr": "Se reconnecter", + "it": "Accedi di nuovo", + "ja": "再ログイン", + "ko-KR": "다시 로그인", + "no": "Logg inn igjen", + "pt": "Entrar novamente", + "tr": "Tekrar giriş yap", + "uk": "Увійти знову", + "zh-CN": "重新登录", + "zh-TW": "重新登入" + }, + "BACKEND$LOGGED_OUT": { + "en": "Logged out", + "ar": "تم تسجيل الخروج", + "ca": "Sessió tancada", + "de": "Abgemeldet", + "es": "Sesión cerrada", + "fr": "Déconnecté", + "it": "Disconnesso", + "ja": "ログアウト済み", + "ko-KR": "로그아웃됨", + "no": "Logget ut", + "pt": "Desconectado", + "tr": "Oturum kapatıldı", + "uk": "Вийшли з системи", + "zh-CN": "已退出登录", + "zh-TW": "已登出" + }, "BACKEND$LOGIN_CLOUD_HINT": { "en": "Works with OpenHands Cloud or your self-hosted OpenHands Cloud deployment.", "ja": "OpenHands Cloud またはセルフホスト型 OpenHands Cloud デプロイメントで使用できます。", diff --git a/src/query-client-config.ts b/src/query-client-config.ts index a4d832479e..c4d28ea881 100644 --- a/src/query-client-config.ts +++ b/src/query-client-config.ts @@ -4,6 +4,7 @@ import i18n from "#/i18n"; import { I18nKey } from "./i18n/declaration"; import { retrieveAxiosErrorMessage } from "./utils/retrieve-axios-error-message"; import { displayErrorToast } from "./utils/custom-toast-handlers"; +import { getActiveBackend } from "#/api/backend-registry/active-store"; import { recordBackendSuccess } from "#/api/backend-registry/health-store"; const handle401Error = (error: AxiosError, client: QueryClient) => { @@ -12,6 +13,19 @@ const handle401Error = (error: AxiosError, client: QueryClient) => { } }; +const isActiveCloudBackendAuthError = (error: unknown) => { + if (!(error instanceof AxiosError)) return false; + if (error.response?.status !== 401 && error.status !== 401) return false; + + const activeBackend = getActiveBackend().backend; + if (activeBackend.kind !== "cloud") return false; + + const requestUrl = error.config?.url; + return ( + !requestUrl || requestUrl.startsWith(activeBackend.host.replace(/\/+$/, "")) + ); +}; + const shownErrors = new Set(); export const createAgentServerQueryClient = () => { @@ -34,7 +48,7 @@ export const createAgentServerQueryClient = () => { const disableToast = query.meta?.disableToast ?? query.options.meta?.disableToast; - if (!disableToast) { + if (!disableToast && !isActiveCloudBackendAuthError(error)) { const errorMessage = retrieveAxiosErrorMessage(error); if (!shownErrors.has(errorMessage || "")) { @@ -55,7 +69,7 @@ export const createAgentServerQueryClient = () => { const disableToast = mutation?.meta?.disableToast ?? mutation?.options.meta?.disableToast; - if (!disableToast) { + if (!disableToast && !isActiveCloudBackendAuthError(error)) { const message = retrieveAxiosErrorMessage(error); displayErrorToast(message || i18n.t(I18nKey.ERROR$GENERIC)); } diff --git a/src/root.tsx b/src/root.tsx index f785454797..88c55ec2b0 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -19,6 +19,11 @@ import { } from "#/api/agent-server-compatibility"; import { isAuthRequiredAndMissing } from "#/api/agent-server-config"; import { getEffectiveLocalBackend } from "#/api/backend-registry/active-store"; +import { useActiveBackendContext } from "#/contexts/active-backend-context"; +import { + isCloudBackendLoggedOutHealthError, + useBackendsHealth, +} from "#/hooks/query/use-backends-health"; import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers"; import { TelemetryConsentBanner } from "#/components/features/analytics/telemetry-consent-banner"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; @@ -149,6 +154,14 @@ export default function App() { // Skip the /server_info probe entirely when we already know auth is // required and missing — it would just 401 and waste time. const config = useConfig({ enabled: !authMissing }); + const { active } = useActiveBackendContext(); + const activeCloudHealth = useBackendsHealth( + active.backend.kind === "cloud" ? [active.backend] : [], + )[active.backend.id]; + const activeCloudLoggedOut = + active.backend.kind === "cloud" && + activeCloudHealth?.isConnected === false && + isCloudBackendLoggedOutHealthError(activeCloudHealth.lastError); // No key at all → instant auth screen (no network). // Stale key → /server_info 401 → auth screen (public mode only). @@ -164,7 +177,7 @@ export default function App() { return ; } - if (isAgentServerUnavailableError(config.error)) { + if (activeCloudLoggedOut || isAgentServerUnavailableError(config.error)) { return ; }