fix(ui): recover gracefully during server restarts (#14560)

Show a clear reconnecting state during server restarts and retry safe access
checks every five seconds. Preserve open drafts, wait for initial startup
readiness, and keep authentication failures separate from temporary outages.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-29 08:31:05 -05:00
1 parent 3ca196b0a6
commit 61b3fd57a6
12 files changed
+533 -38

No files matched your search

+11
View File
@@ -238,6 +238,17 @@ owns committed updates.
## Hot-Restart Deploys
During a restart, the board's health, session, and access checks retry temporary
network/gateway failures and non-JSON API responses every five seconds. A new
page shows **Reconnecting to Paperclip** with a **Try again** action and waits
for startup health to become ready. Valid startup metadata remains available to
sign-in and invitation pages. An already
open page stays mounted during temporary background failures so unsaved edits
survive. Successful checks resume the same route and refresh other failed reads;
this recovery does not reload the browser or replay mutations. Authorization
failures still require sign-in or an explicit retry. Storybook **App / Connection
recovery** shows the startup recovery states.
Primary-instance rebuilds that restart `paperclip.service` can request one-shot live-run adoption instead of using the normal graceful shutdown drain. Before restarting the service, write the marker from the newly staged app with the current service PID:
```sh
+7
View File
@@ -6,6 +6,13 @@ Run scope: `ui/src/components/` and `ui/src/pages/` on branch `design/token-extr
## Counts
### Connection recovery — 2026-09-29
`CloudAccessGate.tsx` contains the startup `CloudAccessError` composition, using
the existing Button and typography tokens. It covers automatic reconnection,
an in-flight check, and manual retry after access-check failures. See
`/design-guide` and Storybook **App / Connection recovery**.
### Independent MCP connection setup — 2026-09-21
`ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx` is the controlled
+5 -2
View File
@@ -121,7 +121,9 @@ describe("CloudAccessGate", () => {
it("does not mistake a session service failure for a signed-out user", async () => {
mockAuthApi.getSession.mockRejectedValue(new Error("Session service unavailable"));
const root = renderGate(container);
await waitForText(container, "Session service unavailable");
await waitForText(container, "Unable to load Paperclip");
expect(container.querySelector("button")?.textContent).toBe("Try again");
expect(container.textContent).not.toContain("Outlet content");
expect(container.textContent).not.toContain("Navigate:/auth");
expect(beginCloudSignInMock).not.toHaveBeenCalled();
unmountRoot(root);
@@ -193,7 +195,8 @@ describe("CloudAccessGate", () => {
mockAuthApi.getSession.mockResolvedValue({ user: { id: "invitee" } });
mockAccessApi.getCurrentBoardAccess.mockRejectedValueOnce(new Error("Access check unavailable"));
const root = renderGate(container, true);
await waitForText(container, "Access check unavailable");
await waitForText(container, "Unable to load Paperclip");
expect(container.querySelector("button")?.textContent).toBe("Try again");
expect(container.textContent).not.toContain("Outlet content");
unmountRoot(root);
});
+4 -2
View File
@@ -10,6 +10,7 @@ import {
} from "@paperclipai/shared";
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
import { tenantSessionRecovery } from "@/lib/tenant-session-recovery";
import { readApiJson } from "./response";
type AuthErrorBody =
| {
@@ -158,13 +159,14 @@ export const authApi = {
const res = await fetch("/api/auth/get-session", {
credentials: "include",
headers: { Accept: "application/json" },
cache: "no-store",
});
const payload = await res.json().catch(() => null);
const payload = await readApiJson(res);
if (!res.ok) {
const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload);
if (recovery) return recovery;
if (res.status === 401) return null;
throw new Error(`Failed to load session (${res.status})`);
throw extractAuthError(payload as AuthErrorBody, res.status);
}
const direct = toSession(payload);
if (direct) return direct;
+3 -2
View File
@@ -1,5 +1,6 @@
import { getPageVisibility, getVisibilityHeaderValue } from "@/lib/page-visibility";
import { tenantSessionRecovery } from "@/lib/tenant-session-recovery";
import { readApiJson } from "./response";
const BASE = "/api";
@@ -56,7 +57,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
...init,
});
if (!res.ok) {
const errorBody = await res.json().catch(() => null);
const errorBody = await readApiJson(res);
const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, errorBody);
if (recovery) return recovery;
throw new ApiError(
@@ -66,7 +67,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
);
}
if (res.status === 204) return undefined as T;
return res.json();
return readApiJson<T>(res);
}
// --- In-tab request coalescing for identical safe GETs -----------------------
+9 -4
View File
@@ -1,5 +1,7 @@
import type { ServerInfoSnapshot } from "@paperclipai/shared";
import { tenantSessionRecovery } from "@/lib/tenant-session-recovery";
import { ApiError } from "./client";
import { ApiUnavailableError, readApiJson } from "./response";
export type DevServerHealthStatus = {
enabled: true;
@@ -24,7 +26,7 @@ export type CloudInstanceHealthStatus = {
};
export type HealthStatus = {
status: "ok";
status: "ok" | "starting";
version?: string;
/** Commit of the running server; null when build metadata is unavailable. */
commit?: string | null;
@@ -52,14 +54,17 @@ export const healthApi = {
const res = await fetch("/api/health", {
credentials: "include",
headers: { Accept: "application/json" },
cache: "no-store",
});
const payload = await readApiJson<HealthStatus & { error?: string } | null>(res);
if (!res.ok) {
const payload = await res.json().catch(() => null) as { error?: string } | null;
const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload);
if (recovery) return recovery;
throw new Error(payload?.error ?? `Failed to load health (${res.status})`);
throw new ApiError(payload?.error ?? `Failed to load health (${res.status})`, res.status, payload);
}
return res.json();
// Startup recovery can still serve sign-in and deployment metadata.
if (payload?.status !== "ok" && payload?.status !== "starting") throw new ApiUnavailableError(res.status);
return payload;
},
requestDevServerRestart: async (): Promise<void> => {
const res = await fetch("/api/health/dev-server/restart", {
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { api, ApiError } from "./client";
import { authApi } from "./auth";
import { healthApi } from "./health";
import { ApiUnavailableError, isTemporaryApiError, readApiJson } from "./response";
afterEach(() => vi.unstubAllGlobals());
describe("API responses during a restart", () => {
it.each([200, 502, 503, 504])("handles HTML with status %i on every startup API", async (status) => {
const fetchMock = vi.fn().mockImplementation(async () => new Response("<!doctype html><h1>Restarting</h1>", {
status,
headers: { "Content-Type": "text/html" },
}));
vi.stubGlobal("fetch", fetchMock);
for (const read of [healthApi.get, authApi.getSession, () => api.get("/cli-auth/me")]) {
await expect(read()).rejects.toMatchObject({
name: "ApiUnavailableError",
message: "Paperclip is temporarily unavailable. Please try again in a moment.",
status,
});
}
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("does not replay a mutation after an HTML response", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("<!doctype html>", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await expect(api.post("/issues", { title: "Save once" })).rejects.toBeInstanceOf(ApiUnavailableError);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("preserves empty mutation successes", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
await expect(api.delete("/issues/example")).resolves.toBeUndefined();
});
it("preserves valid startup health metadata for sign-in and invitations", async () => {
const payload = {
status: "starting",
deploymentMode: "authenticated",
cloud: { managed: true, managedBy: "paperclip-cloud", stackSlug: "example", cloudBaseUrl: "https://example.com" },
};
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(payload)));
await expect(healthApi.get()).resolves.toEqual(payload);
});
it.each([null, {}, { status: "unhealthy" }])("rejects an unusable health payload: %j", async (payload) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(payload)));
await expect(healthApi.get()).rejects.toBeInstanceOf(ApiUnavailableError);
});
it.each([healthApi.get, authApi.getSession, () => api.get("/cli-auth/me")])(
"retains the status of JSON gateway errors for retry classification",
async (read) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ error: "Service unavailable" }, { status: 503 })));
const error = await read().catch((error: unknown) => error);
expect(isTemporaryApiError(error)).toBe(true);
},
);
it("preserves HTTP denials with or without JSON", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response("Forbidden", { status: 403 }))
.mockResolvedValueOnce(Response.json({ error: "Access denied" }, { status: 403 })));
await expect(api.get("/denied")).rejects.toMatchObject({ name: "ApiError", status: 403 });
await expect(api.get("/denied")).rejects.toMatchObject({ message: "Access denied", status: 403 });
});
it("does not swallow an aborted response body", async () => {
const error = new DOMException("Aborted", "AbortError");
const response = new Response();
vi.spyOn(response, "json").mockRejectedValue(error);
await expect(readApiJson(response)).rejects.toBe(error);
});
it.each(["Failed to fetch", "NetworkError when attempting to fetch resource.", "Load failed"])(
"recognizes the browser network failure: %s", (message) => {
expect(isTemporaryApiError(new TypeError(message))).toBe(true);
},
);
it.each([
new ApiError("Unauthorized", 401, null),
new ApiError("Forbidden", 403, null),
new ApiError("Not found", 404, null),
new ApiError("Internal error", 500, null),
new TypeError("Cannot read properties of undefined"),
new DOMException("Aborted", "AbortError"),
])("does not automatically retry non-transport errors: %s", (error) => {
expect(isTemporaryApiError(error)).toBe(false);
});
});
+29
View File
@@ -0,0 +1,29 @@
/** A proxy or restarting server answered an API request without usable JSON. */
export class ApiUnavailableError extends Error {
constructor(public readonly status: number) {
super("Paperclip is temporarily unavailable. Please try again in a moment.");
this.name = "ApiUnavailableError";
}
}
export function isTemporaryApiError(error: unknown): boolean {
if (error instanceof ApiUnavailableError) return true;
if (!(error instanceof Error)) return false;
if ("status" in error && [502, 503, 504].includes(error.status as number)) return true;
// Fetch uses different network-failure messages across browsers. Do not
// classify arbitrary TypeErrors (programming bugs) or aborts as outages.
return error instanceof TypeError && /fetch|network|load failed/i.test(error.message);
}
export async function readApiJson<T = unknown>(response: Response): Promise<T> {
try {
return await response.json() as T;
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
// An HTML fallback can have status 200 during a deployment. Never expose
// its parser error or treat it as a successful, empty API response.
if (response.ok || response.status >= 500) throw new ApiUnavailableError(response.status);
// Preserve HTTP/auth error handling even when a 4xx response has no JSON.
return null as T;
}
}
+237
View File
@@ -0,0 +1,237 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { focusManager, QueryClient, QueryClientProvider, QueryObserver } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CloudAccessGate } from "./CloudAccessGate";
import { queryKeys } from "@/lib/queryKeys";
import { ApiUnavailableError } from "@/api/response";
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: "/CON/agents/conan/instructions", search: "?tab=edit", hash: "#draft" }),
Navigate: ({ to }: { to: string }) => <div data-redirect={to} />,
Outlet: () => <textarea aria-label="Instructions" defaultValue="Unsaved instructions" />,
}));
vi.mock("@/components/AnimatedPaperclipIcon", () => ({ PaperclipLoading: () => <div>Loading…</div> }));
vi.mock("@/components/CloudSignIn", () => ({ CloudSignIn: () => <div>Sign in to Cloud</div> }));
vi.mock("@/components/BootstrapPendingPage", () => ({ BootstrapPendingPage: () => <div>Set up instance</div> }));
const responses: Record<string, unknown> = {
"/api/health": { status: "ok", deploymentMode: "authenticated", bootstrapStatus: "ready" },
"/api/auth/get-session": {
session: { id: "session", userId: "user" },
user: { id: "user", name: "Operator", email: "operator@example.com", image: null },
sentryDsn: null,
},
"/api/cli-auth/me": { isInstanceAdmin: false, companyIds: ["company"] },
};
const checks = [
["/api/health", queryKeys.health],
["/api/auth/get-session", queryKeys.auth.session],
["/api/cli-auth/me", queryKeys.access.currentBoardAccess],
] as const;
describe("CloudAccessGate restart recovery", () => {
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
let failingPath: string | null;
let failure: () => Response;
const fetchMock = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
failingPath = null;
failure = () => new Response("<!doctype html><h1>Restarting</h1>", { status: 200 });
fetchMock.mockImplementation(async (path: string) => {
if (path === failingPath) return failure();
if (!(path in responses)) throw new Error(`Unexpected request: ${path}`);
return Response.json(responses[path]);
});
vi.stubGlobal("fetch", fetchMock);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
});
afterEach(() => {
flushSync(() => root.unmount());
client.clear();
focusManager.setFocused(undefined);
container.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
async function flushReact() {
await vi.advanceTimersByTimeAsync(20);
flushSync(() => {});
}
async function render() {
flushSync(() => root.render(<QueryClientProvider client={client}><CloudAccessGate /></QueryClientProvider>));
await flushReact();
}
it.each(checks)("recovers from an HTML response on %s without navigating", async (path) => {
failingPath = path;
await render();
expect(container.textContent).toContain("Reconnecting to Paperclip");
expect(container.textContent).not.toContain("Unexpected token");
expect(container.querySelector("[data-redirect]")).toBeNull();
expect(container.querySelector("textarea")).toBeNull();
const callsBeforeRetry = fetchMock.mock.calls.filter(([url]) => url === path).length;
await vi.advanceTimersByTimeAsync(4_000);
expect(fetchMock.mock.calls.filter(([url]) => url === path)).toHaveLength(callsBeforeRetry);
failingPath = null;
await vi.advanceTimersByTimeAsync(1_100);
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
expect(container.textContent).not.toContain("Reconnecting");
const callsAfterRecovery = fetchMock.mock.calls.length;
await vi.advanceTimersByTimeAsync(15_000);
expect(fetchMock).toHaveBeenCalledTimes(callsAfterRecovery);
expect(fetchMock.mock.calls.every(([, init]) => !init.method || init.method === "GET")).toBe(true);
});
it.each(checks)("preserves a mounted editor through an outage on %s", async (path, queryKey) => {
await render();
const editor = container.querySelector("textarea")!;
editor.value = "Keep my unsaved changes";
failingPath = path;
failure = () => new Response("Bad gateway", { status: 502 });
await client.refetchQueries({ queryKey });
await flushReact();
expect(container.querySelector("textarea")).toBe(editor);
expect(editor.value).toBe("Keep my unsaved changes");
expect(container.textContent).toContain("Reconnecting automatically");
failingPath = null;
await vi.advanceTimersByTimeAsync(5_100);
await flushReact();
expect(container.querySelector("textarea")).toBe(editor);
expect(editor.value).toBe("Keep my unsaved changes");
expect(container.textContent).not.toContain("Reconnecting");
});
it.each(checks)("keeps retrying %s while the tab is hidden", async (path) => {
focusManager.setFocused(false);
failingPath = path;
await render();
expect(container.textContent).toContain("Reconnecting to Paperclip");
failingPath = null;
await vi.advanceTimersByTimeAsync(5_100);
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
});
it("waits for ready health before opening the board even when access checks succeed", async () => {
failingPath = "/api/health";
failure = () => Response.json({ status: "starting", deploymentMode: "authenticated", bootstrapStatus: "ready" });
await render();
expect(container.querySelector("textarea")).toBeNull();
expect(container.textContent).toContain("Reconnecting");
expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(checks.map(([path]) => path));
await vi.advanceTimersByTimeAsync(5_100);
expect(container.querySelector("textarea")).toBeNull();
failingPath = null;
await vi.advanceTimersByTimeAsync(5_100);
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
expect(container.textContent).not.toContain("Reconnecting");
});
it("supports manual retry while startup health is pending", async () => {
fetchMock.mockResolvedValueOnce(Response.json({ status: "starting", deploymentMode: "local_trusted" }));
await render();
expect(container.querySelector("textarea")).toBeNull();
container.querySelector("button")!.click();
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
});
it("preserves an open editor while health reports startup recovery", async () => {
await render();
const editor = container.querySelector("textarea")!;
editor.value = "Keep my unsaved changes";
fetchMock.mockResolvedValueOnce(Response.json({
status: "starting", deploymentMode: "authenticated", bootstrapStatus: "ready",
}));
await client.refetchQueries({ queryKey: queryKeys.health });
await flushReact();
expect(container.querySelector("textarea")).toBe(editor);
expect(container.textContent).toContain("Reconnecting automatically");
await vi.advanceTimersByTimeAsync(5_100);
await flushReact();
expect(container.querySelector("textarea")).toBe(editor);
expect(editor.value).toBe("Keep my unsaved changes");
expect(container.textContent).not.toContain("Reconnecting");
});
it("offers immediate retry while waiting for the next automatic check", async () => {
failingPath = "/api/health";
await render();
failingPath = null;
container.querySelector("button")!.click();
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
});
it("retries a dropped connection and opens the requested page when it returns", async () => {
fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"));
await render();
expect(container.textContent).toContain("Reconnecting to Paperclip");
await vi.advanceTimersByTimeAsync(5_100);
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
expect(container.querySelector("[data-redirect]")).toBeNull();
});
it("refreshes other failed reads after access checks recover", async () => {
const read = vi.fn().mockRejectedValueOnce(new ApiUnavailableError(503)).mockResolvedValue([]);
const observer = new QueryObserver(client, { queryKey: ["companies", "test"], queryFn: read, retry: false });
const unsubscribe = observer.subscribe(() => {});
failingPath = "/api/health";
await render();
expect(read).toHaveBeenCalledTimes(1);
failingPath = null;
await vi.advanceTimersByTimeAsync(5_100);
await flushReact();
expect(read).toHaveBeenCalledTimes(2);
expect(observer.getCurrentResult().data).toEqual([]);
unsubscribe();
});
it("fails closed for access denials even with cached access, and does not poll", async () => {
await render();
failingPath = "/api/cli-auth/me";
failure = () => Response.json({ error: "Forbidden" }, { status: 403 });
await client.refetchQueries({ queryKey: queryKeys.access.currentBoardAccess });
await flushReact();
expect(container.textContent).toContain("Unable to load Paperclip");
expect(container.textContent).not.toContain("reconnect automatically");
expect(container.querySelector("textarea")).toBeNull();
const calls = fetchMock.mock.calls.length;
await vi.advanceTimersByTimeAsync(15_000);
expect(fetchMock).toHaveBeenCalledTimes(calls);
failingPath = null;
container.querySelector("button")!.click();
await flushReact();
expect(container.querySelector("textarea")).not.toBeNull();
});
it("still redirects to sign-in when the session expires", async () => {
await render();
failingPath = "/api/auth/get-session";
failure = () => Response.json({ error: "unauthorized" }, { status: 401 });
await client.refetchQueries({ queryKey: queryKeys.auth.session });
await flushReact();
expect(container.querySelector("textarea")).toBeNull();
expect(container.querySelector("[data-redirect]")?.getAttribute("data-redirect"))
.toBe("/auth?next=%2FCON%2Fagents%2Fconan%2Finstructions%3Ftab%3Dedit");
});
});
+105 -28
View File
@@ -1,17 +1,53 @@
import { useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import { RefreshCw } from "lucide-react";
import { Navigate, Outlet, useLocation } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { accessApi } from "@/api/access";
import { ApiError } from "@/api/client";
import { authApi } from "@/api/auth";
import { healthApi } from "@/api/health";
import { isTemporaryApiError } from "@/api/response";
import { queryKeys } from "@/lib/queryKeys";
import { BootstrapPendingPage } from "@/components/BootstrapPendingPage";
import { PaperclipLoading } from "@/components/AnimatedPaperclipIcon";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CloudSignIn } from "@/components/CloudSignIn";
import { clearCloudSignInAttempt } from "@/lib/cloud-sign-in";
const RECONNECT_INTERVAL_MS = 5_000;
export function CloudAccessError({
temporary,
retrying,
onRetry,
}: {
temporary: boolean;
retrying: boolean;
onRetry: () => void;
}) {
return (
<div className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-16">
<RefreshCw className="size-6 text-muted-foreground" aria-hidden="true" />
<div className="flex flex-col gap-2" role="status">
<h1 className="text-xl font-semibold">
{temporary ? "Reconnecting to Paperclip" : "Unable to load Paperclip"}
</h1>
<p className="text-sm text-muted-foreground">
{temporary
? "The server may be restarting or your connection was interrupted. We’ll try again every few seconds and reconnect automatically."
: "We couldn’t check your access to this instance. Try again, or contact your instance administrator if this continues."}
</p>
</div>
<div>
<Button variant="outline" onClick={onRetry} disabled={retrying}>
{retrying ? "Connecting…" : "Try again"}
</Button>
</div>
</div>
);
}
function NoBoardAccessPage() {
return (
<div className="mx-auto max-w-xl py-10">
@@ -32,14 +68,15 @@ function NoBoardAccessPage() {
export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembershipRequest?: boolean } = {}) {
const location = useLocation();
const queryClient = useQueryClient();
const [hasOpenedBoard, setHasOpenedBoard] = useState(false);
const healthQuery = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
refetchInterval: (query) => {
const data = query.state.data as
| { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" }
| undefined;
if (query.state.error) return isTemporaryApiError(query.state.error) ? RECONNECT_INTERVAL_MS : false;
const data = query.state.data;
if (data?.status === "starting") return RECONNECT_INTERVAL_MS;
return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending"
? 2000
: false;
@@ -54,6 +91,8 @@ export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembe
queryFn: () => authApi.getSession(),
enabled: isAuthenticatedMode,
retry: false,
refetchInterval: (query) => isTemporaryApiError(query.state.error) ? RECONNECT_INTERVAL_MS : false,
refetchIntervalInBackground: true,
});
useEffect(() => {
@@ -65,6 +104,8 @@ export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembe
queryFn: () => accessApi.getCurrentBoardAccess(),
enabled: isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data,
retry: false,
refetchInterval: (query) => isTemporaryApiError(query.state.error) ? RECONNECT_INTERVAL_MS : false,
refetchIntervalInBackground: true,
});
const claimMutation = useMutation({
mutationFn: () => accessApi.claimBootstrapAdmin(),
@@ -77,26 +118,59 @@ export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembe
},
});
if (
const activeQueries = [
healthQuery,
...(isAuthenticatedMode ? [sessionQuery] : []),
...(isAuthenticatedMode && !isBootstrapPending && sessionQuery.data ? [boardAccessQuery] : []),
];
const isServerStarting = healthQuery.data?.status === "starting";
const isReconnecting = isServerStarting || activeQueries.some((query) => isTemporaryApiError(query.error));
// A background outage must not unmount editors and discard drafts. Cached
// access is only retained for transport failures; 401/403 still fail closed.
const blockingError = activeQueries.find((query) => query.error
&& !(query.data !== undefined && isTemporaryApiError(query.error)))?.error;
const isLoading =
healthQuery.isLoading ||
(isAuthenticatedMode && sessionQuery.isLoading) ||
(isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data && boardAccessQuery.isLoading)
) {
return <PaperclipLoading />;
(isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data && boardAccessQuery.isLoading);
const hasBoardAccess = allowMembershipRequest || !isAuthenticatedMode
|| !!boardAccessQuery.data?.isInstanceAdmin || (boardAccessQuery.data?.companyIds.length ?? 0) > 0;
const canAccessBoard = !isLoading && !blockingError && !isBootstrapPending
&& (!isAuthenticatedMode || !!sessionQuery.data) && hasBoardAccess;
useEffect(() => {
if (!canAccessBoard) setHasOpenedBoard(false);
else if (healthQuery.data?.status === "ok") setHasOpenedBoard(true);
}, [canAccessBoard, healthQuery.data?.status]);
const wasReconnecting = useRef(false);
useEffect(() => {
if (isReconnecting) {
wasReconnecting.current = true;
} else if (wasReconnecting.current && !isLoading && !blockingError) {
wasReconnecting.current = false;
// Other reads (including the company list) may have failed during boot.
// Refresh those too so recovery does not strand the user on a second error.
void queryClient.invalidateQueries({ predicate: (query) => isTemporaryApiError(query.state.error) });
}
}, [isReconnecting, isLoading, blockingError, queryClient]);
if (blockingError || (isServerStarting && !hasOpenedBoard)) {
return (
<CloudAccessError
temporary={blockingError ? isTemporaryApiError(blockingError) : true}
retrying={activeQueries.some((query) => query.isFetching)}
onRetry={() => {
for (const query of activeQueries) {
if (query.error || (query === healthQuery && isServerStarting)) {
void query.refetch({ cancelRefetch: false });
}
}
}}
/>
);
}
if (healthQuery.error || (isAuthenticatedMode && sessionQuery.error) || boardAccessQuery.error) {
return (
<div className="mx-auto max-w-xl py-10 text-sm text-destructive">
{healthQuery.error instanceof Error
? healthQuery.error.message
: sessionQuery.error instanceof Error
? sessionQuery.error.message
: boardAccessQuery.error instanceof Error
? boardAccessQuery.error.message
: "Failed to load app state"}
</div>
);
if (isLoading) {
return <PaperclipLoading />;
}
if (isAuthenticatedMode && healthQuery.data?.cloud && !sessionQuery.data) {
@@ -132,15 +206,18 @@ export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembe
// Private invitation pages may let signed-in nonmembers request access.
// Their token APIs still enforce membership before granting any authority.
if (
!allowMembershipRequest &&
isAuthenticatedMode &&
sessionQuery.data &&
!boardAccessQuery.data?.isInstanceAdmin &&
(boardAccessQuery.data?.companyIds.length ?? 0) === 0
) {
if (!hasBoardAccess) {
return <NoBoardAccessPage />;
}
return <Outlet />;
return (
<>
{isReconnecting && (
<div role="status" className="bg-muted px-4 py-2 text-center text-sm text-muted-foreground">
Connection interrupted. Reconnecting automatically…
</div>
)}
<Outlet />
</>
);
}
+13
View File
@@ -1,5 +1,6 @@
import { DispositionRecoveryNotice } from "../components/DispositionRecoveryNotice";
import { CloudSignIn } from "../components/CloudSignIn";
import { CloudAccessError } from "../components/CloudAccessGate";
import { SetupPrompt } from "./apps/chat/SetupPrompt";
import { MediaArtifactCard } from "@/components/artifacts/MediaArtifactCard";
import { WebhookUrlWarning } from "@/components/routine-triggers/WebhookUrlWarning";
@@ -2374,6 +2375,18 @@ export function DesignGuide() {
</div>
</Section>
<Section title="Connection recovery">
<SubSection title="Waiting for server">
<CloudAccessError temporary retrying={false} onRetry={() => undefined} />
</SubSection>
<SubSection title="Checking connection">
<CloudAccessError temporary retrying onRetry={() => undefined} />
</SubSection>
<SubSection title="Access check failed">
<CloudAccessError temporary={false} retrying={false} onRetry={() => undefined} />
</SubSection>
</Section>
<Section title="Media artifacts">
<p className="text-sm text-muted-foreground">Images and videos use gallery tiles. The whole tile opens the task gallery; files and links keep compact, fully clickable rows. Task/Artifact Gallery in Storybook covers playable videos, mixed files, narrow panels, and unavailable previews.</p>
<div className="grid max-w-2xl grid-cols-1 gap-3 sm:grid-cols-2">
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CloudAccessError } from "@/components/CloudAccessGate";
const meta: Meta<typeof CloudAccessError> = {
title: "App/Connection recovery",
component: CloudAccessError,
parameters: { layout: "fullscreen" },
args: { temporary: true, retrying: false, onRetry: () => undefined },
};
export default meta;
type Story = StoryObj<typeof CloudAccessError>;
export const WaitingForServer: Story = {};
export const CheckingConnection: Story = { args: { retrying: true } };
export const AccessCheckFailed: Story = { args: { temporary: false } };