mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 14:10:50 +02:00
Integrate current mainline for ACP production qualification
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/runner-cursor-acp: feat(runner): add rich ACP transport and durable interaction foundation (#14430) fix: preserve terminal task owners during release (#14561) fix(ui): recover gracefully during server restarts (#14560)
This commit is contained in:
commit
9f4476feb8
18 files changed
+586
-52
No files matched your search
@@ -1314,7 +1314,7 @@ export function registerIssueCommands(program: Command): void {
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("release")
|
||||
.description("Release issue back to todo and clear assignee")
|
||||
.description("Release issue execution locks; clear the assignee only for unfinished issues")
|
||||
.argument("<issueId>", "Issue ID")
|
||||
.action(async (issueId: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1097,6 +1097,12 @@ Server behavior:
|
||||
2. if updated row count is 0, return `409` with current owner/status
|
||||
3. successful checkout sets `assignee_agent_id`, `status = in_progress`, and `started_at`
|
||||
|
||||
`POST /issues/:issueId/release` clears checkout and execution locks. For terminal
|
||||
issues (`done` or `cancelled`), it preserves the assignee, status, and disposition
|
||||
timestamps, including on repeated release. For unfinished issues it clears the
|
||||
agent assignee; only `in_progress` changes to `todo`. Existing company access,
|
||||
assignee/run ownership checks, and the `issue.released` activity event still apply.
|
||||
|
||||
`POST /issues/:issueId/admin/force-release` is an operator recovery endpoint for stale harness locks. It requires board access to the issue company, clears checkout and execution run lock fields, and may clear the agent assignee when `clearAssignee=true` is passed. The route must write an `issue.admin_force_release` activity log entry containing the previous checkout and execution run IDs.
|
||||
|
||||
## 10.5 Projects
|
||||
|
||||
@@ -414,6 +414,11 @@ Tasks use **single assignment** (one agent per task) with **atomic checkout**:
|
||||
|
||||
No optimistic locking or CRDTs needed. The single-assignment model + atomic checkout prevents conflicts at the design level.
|
||||
|
||||
Releasing a terminal task clears execution locks while preserving its assigned
|
||||
owner and final status. Assignment remains part of the work history after Done
|
||||
or Cancelled. Releasing unfinished work still relinquishes the agent assignment;
|
||||
only an active `in_progress` task returns to `todo`.
|
||||
|
||||
### Human in the Loop
|
||||
|
||||
Agents can create tasks assigned to humans. The board member (or any human with access) can complete these tasks through the UI.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -175,13 +175,16 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: "done" as const, title: "Done release preserves status", completedAt: new Date() },
|
||||
{ status: "cancelled" as const, title: "Cancelled release preserves status", cancelledAt: new Date() },
|
||||
{ status: "done" as const, title: "Done release preserves owner", completedAt: new Date(), retainAssignee: true },
|
||||
{ status: "cancelled" as const, title: "Cancelled release preserves owner", cancelledAt: new Date(), retainAssignee: true },
|
||||
{ status: "in_review" as const, title: "In review release preserves status" },
|
||||
{ status: "blocked" as const, title: "Blocked release preserves status" },
|
||||
{ status: "backlog" as const, title: "Backlog release relinquishes ownership" },
|
||||
{ status: "todo" as const, title: "Todo release relinquishes ownership" },
|
||||
{ status: "in_progress" as const, title: "Active release requeues work", expectedStatus: "todo" },
|
||||
])(
|
||||
"preserves $status when releasing a non-in_progress issue",
|
||||
async ({ status, title, completedAt, cancelledAt }) => {
|
||||
"$title and clears execution locks",
|
||||
async ({ status, title, completedAt, cancelledAt, retainAssignee, expectedStatus = status }) => {
|
||||
const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns();
|
||||
const issueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
@@ -204,7 +207,8 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
|
||||
.send();
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.status).toBe(status);
|
||||
const expectedAssignee = retainAssignee ? agentId : null;
|
||||
expect(res.body).toMatchObject({ status: expectedStatus, assigneeAgentId: expectedAssignee });
|
||||
|
||||
const row = await db
|
||||
.select({
|
||||
@@ -212,18 +216,40 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
checkoutRunId: issues.checkoutRunId,
|
||||
executionRunId: issues.executionRunId,
|
||||
executionAgentNameKey: issues.executionAgentNameKey,
|
||||
executionLockedAt: issues.executionLockedAt,
|
||||
completedAt: issues.completedAt,
|
||||
cancelledAt: issues.cancelledAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row).toEqual({
|
||||
status,
|
||||
assigneeAgentId: null,
|
||||
status: expectedStatus,
|
||||
assigneeAgentId: expectedAssignee,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
completedAt: completedAt ?? null,
|
||||
cancelledAt: cancelledAt ?? null,
|
||||
});
|
||||
|
||||
const events = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId));
|
||||
expect(events).toContainEqual(expect.objectContaining({
|
||||
action: "issue.released", companyId, agentId, runId: currentRunId,
|
||||
}));
|
||||
|
||||
if (retainAssignee) {
|
||||
// Repeated cleanup, including by the board, must not erase attribution.
|
||||
const retried = await request(createApp(boardActor(companyId)))
|
||||
.post(`/api/issues/${issueId}/release`)
|
||||
.send();
|
||||
expect(retried.status, JSON.stringify(retried.body)).toBe(200);
|
||||
expect(retried.body).toMatchObject({ status, assigneeAgentId: agentId, checkoutRunId: null, executionRunId: null });
|
||||
const [saved] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(saved).toMatchObject(row);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -11787,14 +11787,16 @@ export function issueService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
// Release clears checkout/assignee locks; only in_progress work re-queues to todo.
|
||||
// Terminal assignment records who owned the work, not a live execution
|
||||
// claim. Cleanup must preserve it; unfinished release still relinquishes it.
|
||||
const isTerminal = existing.status === "done" || existing.status === "cancelled";
|
||||
const releaseStatus =
|
||||
existing.status === "in_progress" ? "todo" : existing.status;
|
||||
const updated = await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
status: releaseStatus,
|
||||
assigneeAgentId: null,
|
||||
assigneeAgentId: isTerminal ? existing.assigneeAgentId : null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
|
||||
@@ -1355,9 +1355,9 @@ Terminal states: `done`, `cancelled`
|
||||
- `in_progress` = actively owned work. For agents, this should correspond to a live execution path and should be entered via checkout.
|
||||
- `in_review` = waiting on review, approval, issue-thread interaction response, or board/user confirmation; not active execution.
|
||||
- `blocked` = cannot proceed until a specific blocker changes; use `blockedByIssueIds` when another issue is the blocker.
|
||||
- `done` = completed.
|
||||
- `cancelled` = intentionally abandoned.
|
||||
- `in_progress` requires an assignee (use checkout).
|
||||
- `done` = completed. Release clears execution locks but preserves the assignee and `completedAt`.
|
||||
- `cancelled` = intentionally abandoned. Release clears execution locks but preserves the assignee and `cancelledAt`.
|
||||
- `in_progress` requires an assignee (use checkout). Release returns it to `todo` and clears the agent assignee.
|
||||
- `started_at` is auto-set on `in_progress`.
|
||||
- `completed_at` is auto-set on `done`.
|
||||
- One assignee per task at a time.
|
||||
@@ -1418,7 +1418,7 @@ Terminal states: `done`, `cancelled`
|
||||
| POST | `/api/companies/:companyId/issues` | Create issue (supports `blockedByIssueIds: string[]` for dependencies) |
|
||||
| PATCH | `/api/issues/:issueId` | Update issue; response is authoritative and includes `changes` + `comment` (`Prefer: return=minimal` supported); `blockedByIssueIds` replaces blocker set |
|
||||
| POST | `/api/issues/:issueId/checkout` | Atomic checkout (claim + start). Idempotent if you already own it. |
|
||||
| POST | `/api/issues/:issueId/release` | Release task ownership |
|
||||
| POST | `/api/issues/:issueId/release` | Release execution locks; preserve terminal task ownership |
|
||||
| GET | `/api/issues/:issueId/comments` | List comments |
|
||||
| GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID |
|
||||
| POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) |
|
||||
|
||||
+5
-2
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 -----------------------
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 } };
|
||||
Reference in new issue
Block a user