diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index 420f1d98259..9d83f5ac945 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -186,9 +186,8 @@ jobs: - name: Test feature module boundary check run: node "$CI_NODE_TEST" ./scripts/check-module-boundaries.test.mjs - # Report-only for now (always exits 0); --enforce turns findings into failures. - - name: Report raw query-error rendering - run: node scripts/check-query-error-rendering.mjs --summary + - name: Reject raw query-error rendering + run: node scripts/check-query-error-rendering.mjs --enforce --summary - name: Test query-error rendering check run: node --test ./scripts/check-query-error-rendering.test.mjs diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 071b75ae895..2e8540363a0 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -4548,3 +4548,10 @@ one-time config-file seed preserves existing choices, then later starts use the database without loading home `.env` files or repairing worktree config. See [Cloud runtime configuration](cloud-runtime-configuration.md) for field ownership, secret handling, initialization order and migration interfaces. + + +### Read resilience during deploys + +Read surfaces use `useQueryView` and `QueryErrorState`. Transient refetch failures keep loaded data visible. An initial outage keeps a quiet placeholder until recovery. Authorization, missing resources and validation failures still show readable errors. Exhausted first reads on a reachable server offer Retry, including task filters and hierarchy reads. Health probes and the socket own app-wide recovery; a successful component load may be an older in-flight response or a local no-op. Sensitive manual reads use `useResilientFetch`; browser viewer URLs and pairing previews remain in component memory and never enter a shared cache. + +Run `node scripts/check-query-error-rendering.mjs`. The guard now enforces across `ui/src`, including multiline expressions, and CI runs it as a policy check. Its small allowlist covers standalone preview/performance fixtures, transcript protocol result flags, and the existing account-resolution and Copilot discovery retry owners. These are not ordinary read-error renders. Additions require a specific reason; do not allowlist a page to hide an outage regression. diff --git a/scripts/check-query-error-rendering.mjs b/scripts/check-query-error-rendering.mjs index b8e9675c314..dbdde137d23 100644 --- a/scripts/check-query-error-rendering.mjs +++ b/scripts/check-query-error-rendering.mjs @@ -16,7 +16,7 @@ * before errors. Intentional exceptions go in ALLOWLIST below as * ":" with a reason. * - * Report-only by default: prints findings and exits 0, except for findings in + * Enforcing by default: prints findings and exits 0, except for findings in * ENFORCED_FILES (surfaces already migrated), which always exit 1. `--enforce` * exits 1 on any finding outside the allowlist (CI turns this on once the * long tail migrates). @@ -59,7 +59,16 @@ export const RULES = [ * a reason. The list shrinks as surfaces migrate to the shared error handling. */ export const ALLOWLIST = new Set([ - // Empty for now: report-only mode lists every finding as the baseline. + "ui/src/artifact-directions/main.tsx:retry-false", + "ui/src/wizard-preview-main.tsx:retry-false", + "ui/src/fixtures/TaskChatPerfHarness.tsx:retry-false", + "ui/src/pages/NewAgent.test-support.tsx:retry-false", + "ui/src/components/TaskChatThread.tsx:is-error-render", + "ui/src/components/transcript/RunTranscriptView.tsx:is-error-render", + "ui/src/components/task-chat/transcript-adapter.ts:is-error-render", + "ui/src/lib/issue-chat-messages.ts:is-error-render", + "ui/src/lib/account-scope.ts:retry-false", + "ui/src/lib/copilot-task-model-catalog.ts:retry-false", ]); /** @@ -133,15 +142,20 @@ function walk(dir, out) { export function scanSource(sourceText, path) { const findings = []; const lines = sourceText.split("\n"); - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]; - if (/\/\/\s*query-error-ok\b/.test(line)) continue; + const checkable = lines.map((line) => { const trimmed = line.trim(); - if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) continue; - for (const rule of RULES) { - if (rule.pattern.test(line)) findings.push({ path, line: index + 1, rule: rule.id, text: trimmed }); + return /\/\/\s*query-error-ok\b/.test(line) + || trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*") + ? " ".repeat(line.length) : line; + }).join("\n"); + for (const rule of RULES) { + const pattern = new RegExp(rule.pattern.source, "g"); + for (const match of checkable.matchAll(pattern)) { + const line = checkable.slice(0, match.index).split("\n").length; + findings.push({ path, line, rule: rule.id, text: lines[line - 1].trim() }); } } + findings.sort((a, b) => a.line - b.line || RULES.findIndex((rule) => rule.id === a.rule) - RULES.findIndex((rule) => rule.id === b.rule)); return findings; } @@ -163,7 +177,7 @@ export function scanQueryErrorRendering({ function main() { const args = new Set(process.argv.slice(2)); - const enforce = args.has("--enforce"); + const enforce = !args.has("--report-only"); const summaryOnly = args.has("--summary"); const findings = scanQueryErrorRendering(); const enforcedFindings = findings.filter((finding) => finding.enforced); @@ -191,7 +205,7 @@ function main() { process.exitCode = 1; } if (!enforce) { - console.log("Report-only mode: findings outside migrated files do not fail. Pass --enforce to fail on all findings."); + console.log("Explicit report-only mode: findings outside migrated files do not fail."); return; } if (findings.length > 0) process.exitCode = 1; diff --git a/scripts/check-query-error-rendering.test.mjs b/scripts/check-query-error-rendering.test.mjs index 55573ef0933..f9a7c20c5c8 100644 --- a/scripts/check-query-error-rendering.test.mjs +++ b/scripts/check-query-error-rendering.test.mjs @@ -71,3 +71,11 @@ test("scans a tree, skips tests, and honors the allowlist", () => { rmSync(root, { recursive: true, force: true }); } }); + + +test("flags multiline renders and retry overrides", () => { + assert.deepEqual(rulesFor("

{\n error.message\n}

"), ["raw-error-message"]); + assert.deepEqual(rulesFor("

{\n (error as Error).message\n}

"), ["cast-error-message"]); + assert.deepEqual(rulesFor("query.isError\n ? : "), ["is-error-render"]); + assert.deepEqual(rulesFor("useQuery({ retry:\n false })"), ["retry-false"]); +}); diff --git a/server/src/__tests__/idle-domain-deadlines-postgres.test.ts b/server/src/__tests__/idle-domain-deadlines-postgres.test.ts index 35141db2ea4..c8242b4cc83 100644 --- a/server/src/__tests__/idle-domain-deadlines-postgres.test.ts +++ b/server/src/__tests__/idle-domain-deadlines-postgres.test.ts @@ -29,6 +29,7 @@ import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database import { readIdleDeadlineProof } from "../services/idle-deadline-proof.js"; import type { VerifiedIdleCapabilities } from "../services/idle-incoming-wake-capability.js"; import { claimDueTimerHeartbeatForAgent } from "../services/heartbeat/queue.js"; +import { createHeartbeatTimerPolicy } from "../services/heartbeat/timer-policy.js"; import { pluginJobStore } from "../services/plugin-job-store.js"; const external = resolveExternalTestDatabaseUrl("PAPERCLIP_IDLE_DEADLINE_TEST_DATABASE_URL"); @@ -119,6 +120,29 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu } }, 30_000); + it.each([ + ["2099-01-01T12:00:00.000Z", "2099-01-02T00:00:00.000Z"], + ["2099-01-01T23:59:06.888Z", "2099-01-02T00:06:36.888Z"], + ["2099-01-02T00:00:00.500Z", "2099-01-03T00:00:00.000Z"], + ])("preserves both the timer interval and UTC daily cap at %s", async (instant, deadline) => { + const now = new Date(instant), companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "UTC timer fixture", issuePrefix: `T${companyId.slice(0, 6)}` }); + try { + const [agent] = await db.insert(agents).values({ id: agentId, companyId, name: "Capped timer", role: "engineer", + status: "idle", adapterType: "process", createdAt: now, lastHeartbeatAt: now, + runtimeConfig: { heartbeat: { enabled: true, intervalSec: 450, maxDailyRuns: 1 } } }).returning(); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "succeeded", invocationSource: "timer", + startedAt: now, finishedAt: now, costAccountingPending: false }); + const schedule = await createHeartbeatTimerPolicy(db).readTimerHeartbeatSchedule(agent, now); + expect(schedule.deadline).toBe(Date.parse(deadline)); + expect(schedule.cap).toMatchObject({ reason: "heartbeat.daily_run_limit", observed: 1, limit: 1 }); + } finally { + await db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + await db.delete(agents).where(eq(agents.id, agentId)); + await db.delete(companies).where(eq(companies.id, companyId)); + } + }); + it("projects durable timers across client reconnection and executes the original plugin and trace owners", async () => { const now = Date.now(), companyId = randomUUID(), agentId = randomUUID(), pluginId = randomUUID(), runId = randomUUID(); let hold: { ownerId: string; expiresAt: number } = { ownerId: randomUUID(), expiresAt: now + 300_000 }; @@ -162,10 +186,11 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu expect(claimed.filter(Boolean)).toHaveLength(1); expect((await read()).scheduledWake?.nextWakeAt).toBe(new Date(now + 450_000).toISOString()); await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "succeeded", invocationSource: "timer", - startedAt: new Date(now - 1000), finishedAt: new Date(now), costAccountingPending: false }); + startedAt: new Date(now), finishedAt: new Date(now), costAccountingPending: false }); await db.update(agents).set({ runtimeConfig: { heartbeat: { enabled: true, intervalSec: 450, maxDailyRuns: 1 } } }).where(eq(agents.id, agentId)); const date = new Date(now); - expect((await read()).scheduledWake?.nextWakeAt).toBe(new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 1)).toISOString()); + const nextDay = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 1); + expect((await read()).scheduledWake?.nextWakeAt).toBe(new Date(Math.max(now + 450_000, nextDay)).toISOString()); await db.update(agents).set({ runtimeConfig: { heartbeat: { enabled: true, intervalSec: 450, skipTimerWhenNoActionableWork: true } } }).where(eq(agents.id, agentId)); expect((await read()).scheduledWake).toBeUndefined(); await db.update(agents).set({ runtimeConfig: { heartbeat: { enabled: "true", intervalSec: 450 } } }).where(eq(agents.id, agentId)); diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts index 9f6fc62b34b..746eef745bd 100644 --- a/tests/e2e/chat-adapters-ui-messaging.spec.ts +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -257,6 +257,9 @@ test.describe("Board send delivery refresh", () => { ? `/issues/${issue.id.toUpperCase()}` : `/${selected.prefix}/issues/${issue.identifier}?external=chat#files`, ); + if (!classic) { + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); + } await expect( page.getByRole("heading", { name: "Organization-bound task link", diff --git a/tests/e2e/disconnect-write-recovery.spec.ts b/tests/e2e/disconnect-write-recovery.spec.ts index 1450aec855d..d398d8fb46a 100644 --- a/tests/e2e/disconnect-write-recovery.spec.ts +++ b/tests/e2e/disconnect-write-recovery.spec.ts @@ -44,6 +44,7 @@ test("a task title survives an outage and saves after server recovery", async ({ { timeout: 30_000 }).toBe("A title retained through the outage"); expect((await json(await request.get(`/api/issues/${other.id}`))).title).toBe(other.title); await page.reload(); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); await expect(header.getByRole("heading", { name: "A title retained through the outage", exact: true })).toBeVisible(); expect((await json(await request.get(`/api/issues/${issue.id}`))).status).toBe("backlog"); } finally { @@ -88,6 +89,7 @@ test("two cached tasks keep separate title saves while the browser is offline", await expect.poll(async () => (await json(await request.get(`/api/issues/${first.id}`))).title, { timeout: 30_000 }).toBe("First title retained offline"); await expect.poll(async () => (await json(await request.get(`/api/issues/${second.id}`))).title, { timeout: 30_000 }).toBe("Second title retained offline"); await page.reload(); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); await expect(header.getByRole("heading", { name: "Second title retained offline", exact: true })).toBeVisible(); } finally { await page.context().setOffline(false).catch(() => {}); @@ -144,6 +146,7 @@ test("a cold task waits for the first session and retains a title saved during a await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}`))).title, { timeout: 15_000 }).toBe(savedTitle); await page.reload(); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); await expect(header.getByRole("heading", { name: savedTitle, exact: true })).toBeVisible(); } finally { releaseSession(); @@ -218,7 +221,10 @@ for (const delayWorkspacePreference of [false, true]) test(delayWorkspacePrefere await header.locator("textarea").press("Enter"); await expect.poll(() => failedAfterSave).toBeGreaterThan(0); if (delayWorkspacePreference) { - await expect(page.getByText("Couldn't update the task", { exact: true })).toBeVisible(); + // Identity is still unknown: retain the unowned edit in memory until the + // first verified session, then prove it survives the presentation remount. + await expect(header.locator("textarea")).toHaveValue("Saved after session recovery"); + expect((await json(await request.get(`/api/issues/${issue.id}`))).title).toBe(issue.title); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => resolve(null)))); } healthy = true; @@ -232,6 +238,7 @@ for (const delayWorkspacePreference of [false, true]) test(delayWorkspacePrefere await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}`))).title, { timeout: 15_000 }).toBe("Saved after session recovery"); await page.reload(); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); await expect(header.getByRole("heading", { name: "Saved after session recovery", exact: true })).toBeVisible(); } finally { releasePreference(); diff --git a/ui/src/api/client.test.ts b/ui/src/api/client.test.ts index 00156e922a8..cdd371a0119 100644 --- a/ui/src/api/client.test.ts +++ b/ui/src/api/client.test.ts @@ -93,7 +93,8 @@ describe("tenant-session recovery", () => { await expect(api.get("/ordinary-401")).rejects.toMatchObject({ name: "ApiError", status: 401, - message: "unauthorized", + code: "unauthorized", + message: "Your session has ended. Sign in again to continue.", }); fetchMock.mockResolvedValue(jsonResponse({ recovered: true })); @@ -103,6 +104,22 @@ describe("tenant-session recovery", () => { }); describe("in-tab GET coalescing", () => { + it("keeps fresh validation reads separate from older and concurrent requests", async () => { + const old = deferred(); + fetchMock.mockReturnValueOnce(old.promise).mockResolvedValue(jsonResponse({ title: "Current C" })); + const stale = api.get("/fresh-validation"); + const first = api.get("/fresh-validation", { cache: "no-store", fresh: true }); + const second = api.get("/fresh-validation", { cache: "no-store", fresh: true }); + const joined = api.get("/fresh-validation"); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[1][1]).toMatchObject({ cache: "no-store" }); + await expect(first).resolves.toEqual({ title: "Current C" }); + await expect(second).resolves.toEqual({ title: "Current C" }); + old.resolve(jsonResponse({ title: "Old A" })); + await expect(stale).resolves.toEqual({ title: "Old A" }); + await expect(joined).resolves.toEqual({ title: "Old A" }); + }); + it("times out a caller without cancelling another and starts a fresh fetch on retry", async () => { vi.useFakeTimers(); const first = deferred(); diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 78ec560949b..b0128490f47 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -99,6 +99,8 @@ export async function apiFetch(input: string, init?: RequestInit): Promise; } -const inflightGets = new Map(); +const inflightGets = new Map(); function coalescedGet(path: string, options?: RequestOptions): Promise { const signal = options?.signal; if (signal?.aborted) return Promise.reject(abortError()); - let entry = inflightGets.get(path); + const key = options?.fresh ? Symbol(path) : path; + let entry = inflightGets.get(key); if (!entry) { const controller = new AbortController(); const promise = request(path, { @@ -186,13 +189,13 @@ function coalescedGet(path: string, options?: RequestOptions): Promise { // Clear the shared entry once settled so later calls issue a fresh request. promise.then( () => { - if (inflightGets.get(path) === created) inflightGets.delete(path); + if (inflightGets.get(key) === created) inflightGets.delete(key); }, () => { - if (inflightGets.get(path) === created) inflightGets.delete(path); + if (inflightGets.get(key) === created) inflightGets.delete(key); }, ); - inflightGets.set(path, created); + inflightGets.set(key, created); entry = created; } @@ -204,7 +207,7 @@ function coalescedGet(path: string, options?: RequestOptions): Promise { if (!activeEntry.refs.delete(ref)) return; // Last caller gone before the fetch settled → abort the shared request. if (activeEntry.refs.size === 0) { - if (inflightGets.get(path) === activeEntry) inflightGets.delete(path); + if (inflightGets.get(key) === activeEntry) inflightGets.delete(key); activeEntry.controller.abort(); } }; @@ -225,7 +228,7 @@ function coalescedGet(path: string, options?: RequestOptions): Promise { timer = setTimeout(() => { cleanup(); // Other callers keep their request, but retries must not rejoin it. - if (inflightGets.get(path) === activeEntry) inflightGets.delete(path); + if (inflightGets.get(key) === activeEntry) inflightGets.delete(key); releaseRef(); reject(new ApiError(errorCopy("TIMEOUT").body, 408, { error: "TIMEOUT" })); }, options.timeoutMs); diff --git a/ui/src/api/companies-query.ts b/ui/src/api/companies-query.ts index 4475703e769..5eff1cb0c21 100644 --- a/ui/src/api/companies-query.ts +++ b/ui/src/api/companies-query.ts @@ -37,7 +37,6 @@ export function companyListQueryOptions(userId: string | null) { throw err; } }, - retry: false, } as const; } @@ -49,14 +48,12 @@ export function companyDirectoryQueryOptions(userId: string | null) { companiesApi.detachInflightDirectory(); return companiesApi.directory(); }, - retry: false, } as const; } const sessionQueryOptions = { queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), - retry: false, } as const; /** diff --git a/ui/src/api/errors.test.ts b/ui/src/api/errors.test.ts index e2b706e4a5b..c3d6a0c196e 100644 --- a/ui/src/api/errors.test.ts +++ b/ui/src/api/errors.test.ts @@ -162,7 +162,7 @@ describe("ApiError", () => { }); describe("isRawErrorCode", () => { - it.each(["tenant_app_unavailable", "WORKER_UNAVAILABLE", "TIMEOUT", "issue.not_found"])("%s is a code", (value) => { + it.each(["tenant_app_unavailable", "WORKER_UNAVAILABLE", "TIMEOUT", "issue.not_found", "forbidden", "unauthorized", "conflict"])("%s is a code", (value) => { expect(isRawErrorCode(value)).toBe(true); }); diff --git a/ui/src/api/errors.ts b/ui/src/api/errors.ts index 1c94961d285..e4ce2f2029f 100644 --- a/ui/src/api/errors.ts +++ b/ui/src/api/errors.ts @@ -159,7 +159,8 @@ export function errorCodes(err: unknown): string[] { export function isRawErrorCode(value: string): boolean { const text = value.trim(); if (!text || /\s/.test(text)) return false; - return /^[A-Za-z0-9]+(?:[_.][A-Za-z0-9]+)+$/.test(text) || /^[A-Z][A-Z0-9_]{2,}$/.test(text); + return Object.hasOwn(KIND_COPY, text) || text === "unauthorized" + || /^[A-Za-z0-9]+(?:[_.][A-Za-z0-9]+)+$/.test(text) || /^[A-Z][A-Z0-9_]{2,}$/.test(text); } /** A server message that is safe to show: not a raw code, not a generic status line. */ diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 8ab5b4ed9e1..6e583e6200d 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -339,8 +339,9 @@ export const toolsApi = { api.get(`/tool-connections/${connectionId}/review-counts`), listAppsAttention: (companyId: string) => api.get(`/companies/${companyId}/tools/apps/attention`), - listApplications: (companyId: string) => - api.get(`/companies/${companyId}/tools/applications`), + listApplications: (companyId: string, options?: RequestOptions) => + options ? api.get(`/companies/${companyId}/tools/applications`, options) + : api.get(`/companies/${companyId}/tools/applications`), createApplication: (companyId: string, input: CreateToolApplicationInput) => api.post(`/companies/${companyId}/tools/applications`, input), updateApplication: (applicationId: string, input: UpdateToolApplicationInput) => @@ -349,8 +350,9 @@ export const toolsApi = { api.delete(`/tool-applications/${applicationId}`), // --- Connections --- - listConnections: (companyId: string) => - api.get(`/companies/${companyId}/tools/connections`), + listConnections: (companyId: string, options?: RequestOptions) => + options ? api.get(`/companies/${companyId}/tools/connections`, options) + : api.get(`/companies/${companyId}/tools/connections`), getConnection: (connectionId: string) => api.get(`/tool-connections/${connectionId}`), // --- Installs (Phase 3b, PAP-13618): which agents carry this connection's diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 8f09811cf69..091637d8c44 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -107,7 +107,6 @@ export function ActiveAgentsPanel({ queryKey: queryKeys.issues.detail(issueId), queryFn: () => issuesApi.get(issueId), staleTime: 30_000, - retry: false, })), }); diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index a474f827cac..d7aca4b8952 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -246,7 +246,7 @@ export function AgentActionButtons({ const resolvedCompanyId = companyId ?? agent.companyId; const canonicalAgentRef = agentRouteRef(agent); const isPaused = agent.lifecycleState ? agent.lifecycleState === "paused" : agent.status === "paused"; - const isError = agent.status === "error"; + const isAgentError = agent.status === "error"; const needsSetup = agent.pauseReason === "setup" && agent.status !== "terminated" && !["terminating", "cleaning_up", "terminated", "rejected"].includes(agent.lifecycleState ?? ""); @@ -450,7 +450,7 @@ export function AgentActionButtons({ {runLabel} ) : null} - {needsSetup ? null : isError ? ( + {needsSetup ? null : isAgentError ? ( diff --git a/ui/src/components/ChatConnectorsExperimentalGate.test.tsx b/ui/src/components/ChatConnectorsExperimentalGate.test.tsx index 440758e3a61..c76d326837d 100644 --- a/ui/src/components/ChatConnectorsExperimentalGate.test.tsx +++ b/ui/src/components/ChatConnectorsExperimentalGate.test.tsx @@ -4,6 +4,7 @@ import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "@/api/client"; import { ChatConnectorsExperimentalGate } from "./ChatConnectorsExperimentalGate"; import { AgentChannelsPanel } from "./chat/AgentChannelsPanel"; import { ExternallyConnectedTaskBanner } from "./chat/ExternallyConnectedTaskBanner"; @@ -169,6 +170,45 @@ describe("Chat connectors visibility gate", () => { expect(container.querySelector("[data-chat-setup]")).toBeNull(); expect(container.querySelector("[data-redirect]")).not.toBeNull(); }); + it("keeps a loaded endpoint gating the page when its refetch fails transiently", async () => { + api.endpointId = "endpoint-1"; + api.get.mockResolvedValue({ provider: "slack" }); + api.settings.mockResolvedValue({ enableChatConnectors: true }); + await render(); + await vi.waitFor(() => expect(container.querySelector("[data-chat-setup]")).not.toBeNull()); + + api.get.mockRejectedValue( + new ApiError("Paperclip is restarting.", 503, { error: "tenant_app_unavailable" }), + ); + await client.invalidateQueries({ queryKey: queryKeys.chatEndpoints.detail("endpoint-1") }); + await flushReact(); + + expect(api.get).toHaveBeenCalledTimes(2); + expect(container.querySelector("[data-chat-setup]")).not.toBeNull(); + expect(container.querySelector('[data-query-view="error"]')).toBeNull(); + expect(container.textContent).not.toContain("Paperclip is restarting."); + expect(container.textContent).not.toContain("tenant_app_unavailable"); + }); + it("shows readable copy with Retry when the endpoint fails to load", async () => { + api.endpointId = "endpoint-1"; + api.get + .mockRejectedValueOnce(new ApiError("Boom", 500, { error: "Boom" })) + .mockResolvedValue({ provider: "slack" }); + api.settings.mockResolvedValue({ enableChatConnectors: true }); + await render(); + await vi.waitFor(() => expect(container.querySelector('[data-query-view="error"]')).not.toBeNull()); + + expect(container.textContent).toContain("Couldn't load this endpoint"); + expect(container.querySelector("[data-chat-setup]")).toBeNull(); + expect(container.querySelector("[data-redirect]")).toBeNull(); + const retry = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "Retry"); + expect(retry, "Retry button").toBeTruthy(); + retry!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushReact(); + + await vi.waitFor(() => expect(container.querySelector("[data-chat-setup]")).not.toBeNull()); + expect(container.querySelector('[data-query-view="error"]')).toBeNull(); + }); it("defaults off without a query provider and performs no settings request", async () => { await render(false); expect(container.querySelector("[data-chat-setup]")).toBeNull(); diff --git a/ui/src/components/ChatConnectorsExperimentalGate.tsx b/ui/src/components/ChatConnectorsExperimentalGate.tsx index ee9e5d61438..50ff9d5d5cf 100644 --- a/ui/src/components/ChatConnectorsExperimentalGate.tsx +++ b/ui/src/components/ChatConnectorsExperimentalGate.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { chatEndpointsApi } from "@/api/chatEndpoints"; import { queryKeys } from "@/lib/queryKeys"; -import { Button } from "@/components/ui/button"; +import { QueryErrorState, useQueryView } from "@/components/QueryView"; import { useChatConnectorsEnabled, chatProviderVisible, @@ -20,17 +20,20 @@ function ProviderEndpointGate({ queryKey: queryKeys.chatEndpoints.detail(endpointId), queryFn: () => chatEndpointsApi.get(endpointId), }); + const endpointView = useQueryView(endpoint); const { enabled, githubEnabled } = useChatConnectorsEnabled(); - if (endpoint.isError) + // A cached endpoint keeps gating the page through an outage; only a real + // failure with nothing loaded shows the error. + if (endpointView.kind === "error") return ( -
-

- {endpoint.error.message} -

- -
+ ); if (!endpoint.data) return null; return chatProviderVisible(endpoint.data.provider, enabled, githubEnabled) ? ( diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 85dc925a18a..7f29445f678 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -102,7 +102,6 @@ export function CommandPalette() { const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), - retry: false, }); const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled(); const fileViewerEnabled = experimentalSettings?.enableExperimentalFileViewer === true; diff --git a/ui/src/components/CompanySettingsSidebar.production.tsx b/ui/src/components/CompanySettingsSidebar.production.tsx index f651922c15f..2dd61e2837d 100644 --- a/ui/src/components/CompanySettingsSidebar.production.tsx +++ b/ui/src/components/CompanySettingsSidebar.production.tsx @@ -72,7 +72,6 @@ export function CompanySettingsSidebar() { } }, enabled: !!selectedCompanyId, - retry: false, refetchInterval: 15_000, }); const { data: plugins } = useQuery({ diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index d7a999dd135..0545cdf3176 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -70,7 +70,6 @@ export function CompanySettingsSidebar() { } }, enabled: !!selectedCompanyId, - retry: false, refetchInterval: 15_000, }); const { data: plugins } = useQuery({ diff --git a/ui/src/components/ConfigureBuiltInAgentModal.tsx b/ui/src/components/ConfigureBuiltInAgentModal.tsx index 7121207404d..4c197de9980 100644 --- a/ui/src/components/ConfigureBuiltInAgentModal.tsx +++ b/ui/src/components/ConfigureBuiltInAgentModal.tsx @@ -1,3 +1,4 @@ +import { describeError } from "@/api/errors"; import { useMemo, useState } from "react"; import { agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared"; import { adaptersApi } from "@/api/adapters"; @@ -192,7 +193,7 @@ export function ConfigureBuiltInAgentModal({ {["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) &&
Advanced { void adapters.refetch(); }} + pending={adapters.isFetching} error={adapters.error ? describeError(adapters.error).body : undefined} onRetry={() => { void adapters.refetch(); }} defaultRunner={runnerAvailability?.defaultRunner} supportedRunners={runnerAvailability?.supportedRunners} onChange={setRunner} />
} diff --git a/ui/src/components/CoordinatorWorkspaceSetting.tsx b/ui/src/components/CoordinatorWorkspaceSetting.tsx index 0cc82f049ed..ad334c0bcf7 100644 --- a/ui/src/components/CoordinatorWorkspaceSetting.tsx +++ b/ui/src/components/CoordinatorWorkspaceSetting.tsx @@ -23,7 +23,6 @@ export function CoordinatorWorkspaceSetting() { queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess(), enabled: chatDisabled && !!workspace.userId, - retry: false, staleTime: 30_000, }); const chatManaged = experimental.data?.managedKeys?.enableAgentChat?.managed === true; diff --git a/ui/src/components/DotRunnerConnection.test.tsx b/ui/src/components/DotRunnerConnection.test.tsx index 80df6a64246..981081c099e 100644 --- a/ui/src/components/DotRunnerConnection.test.tsx +++ b/ui/src/components/DotRunnerConnection.test.tsx @@ -73,7 +73,7 @@ it("keeps a revoked binding cleared when the follow-up connection read fails", a await flush(); expect(container.querySelector("output")?.textContent).toBe(""); expect(onBinding).toHaveBeenLastCalledWith(""); - expect(container.querySelector("[role=alert]")?.textContent).toBe("Connection refresh failed"); + expect(container.querySelector("[role=alert]")?.textContent).toContain("Connection refresh failed"); expect(client.getQueryData(["dot-binding", "company", "agent"])).toMatchObject({ binding: null }); expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent === "Revoke connection")).toBe(false); }); diff --git a/ui/src/components/DotRunnerConnection.tsx b/ui/src/components/DotRunnerConnection.tsx index 124ee1bfde4..133f304db48 100644 --- a/ui/src/components/DotRunnerConnection.tsx +++ b/ui/src/components/DotRunnerConnection.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "../api/client"; +import { describeError } from "../api/errors"; import { Button } from "./ui/button"; import { buildDotSetupPrompt } from "../lib/dot-setup-prompt"; import { AgentSetupPrompt } from "./AgentSetupPrompt"; +import { QueryErrorState, useQueryView } from "./QueryView"; interface Connection { enabled: boolean; @@ -25,6 +27,10 @@ export function DotRunnerConnection({ companyId, agentId, bindingId, onBinding } refetchInterval: query => query.state.data?.binding?.status === "pairing" || (query.state.data?.binding?.connected && !query.state.data.binding.subscriptionVerified) || query.state.data?.binding?.hasPendingChallenge || query.state.data?.binding?.assignment ? 5000 : false }); + // Read the connection state through the shared view: cached data stays + // visible while a transient refetch fails,and a no-data failure shows + // readable copy with Retry. + const stateView = useQueryView(state); const pair = useMutation({ mutationFn: () => api.post<{ bindingId: string; pairingCode: string; expiresAt: string }>(path, {}), onSuccess: result => { setPairing(result); onBinding(result.bindingId); void client.invalidateQueries({ queryKey: key }); } }); const test = useMutation({ mutationFn: () => api.post(path + "/event-test", {}), @@ -46,7 +52,7 @@ export function DotRunnerConnection({ companyId, agentId, bindingId, onBinding } if (existingBindingId && existingBindingId !== bindingId && !revoke.isPending) onBinding(existingBindingId); }, [existingBindingId, bindingId, onBinding, revoke.isPending]); if (!agentId) return

Save the agent, then return here to pair your Dot.

; - const error = state.error ?? pair.error ?? test.error ?? revoke.error; + const mutationError = pair.error ?? test.error ?? revoke.error; const b = state.data?.binding; const setupPrompt = pairing && state.data?.resourceUrl && companyId && agentId ? buildDotSetupPrompt({ companyId, agentId, resourceUrl: state.data.resourceUrl, ...pairing }) @@ -77,6 +83,15 @@ export function DotRunnerConnection({ companyId, agentId, bindingId, onBinding } {b && } {b?.hasPendingChallenge &&

Waiting for Dot to read and confirm the harmless mailbox challenge.

} - {error &&

{error instanceof Error ? error.message : "Connection failed"}

} + {stateView.kind === "error" && ( + + )} + {mutationError &&

{describeError(mutationError).body}

} ; } diff --git a/ui/src/components/EmailConnectionAccess.test.tsx b/ui/src/components/EmailConnectionAccess.test.tsx new file mode 100644 index 00000000000..9175630f5d1 --- /dev/null +++ b/ui/src/components/EmailConnectionAccess.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { ApiError } from "@/api/client"; +import { EmailConnectionAccess } from "./EmailConnectionAccess"; + +const mocks = vi.hoisted(() => ({ grants: vi.fn(), installs: vi.fn(), save: vi.fn() })); +vi.mock("@/api/tools", () => ({ toolsApi: { listConnectionGrants: mocks.grants, getConnectionInstalls: mocks.installs, putConnectionInstalls: mocks.save } })); +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +let root: Root, host: HTMLDivElement, client: QueryClient; +let finishInstalls: () => void; +beforeEach(() => { + vi.clearAllMocks(); + host = document.createElement("div"); document.body.append(host); root = createRoot(host); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mocks.installs.mockReturnValue(new Promise(resolve => { finishInstalls = () => resolve({ installs: [] }); })); +}); +afterEach(async () => { + await act(async () => root.unmount()); finishInstalls(); client.clear(); host.remove(); +}); +it.each([403, 500])("shows a known %s before the other access read finishes", async status => { + mocks.grants.mockRejectedValue(new ApiError("Access lookup failed", status, {})); + await act(async () => root.render()); + await vi.waitFor(() => expect(host.querySelector('[data-query-view="error"]')).not.toBeNull()); + expect(host.textContent).not.toContain("Loading access"); + const retry = [...host.querySelectorAll("button")].find(button => button.textContent?.trim() === "Retry"); + if (status === 403) expect(retry).toBeUndefined(); + else { + expect(retry).toBeDefined(); + mocks.grants.mockResolvedValue({ grants: [], capabilities: { canConfigure: false } }); + await act(async () => retry!.click()); + await vi.waitFor(() => expect(mocks.grants).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(host.textContent).toContain("Loading access")); + } +}); diff --git a/ui/src/components/EmailConnectionAccess.tsx b/ui/src/components/EmailConnectionAccess.tsx index e3835fb6df2..b16ac514da3 100644 --- a/ui/src/components/EmailConnectionAccess.tsx +++ b/ui/src/components/EmailConnectionAccess.tsx @@ -1,6 +1,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Agent } from "@paperclipai/shared"; +import { describeError } from "@/api/errors"; import { toolsApi } from "@/api/tools"; +import { QueryErrorState, useQueryView } from "@/components/QueryView"; import { queryKeys } from "@/lib/queryKeys"; import { AgentMultiSelect } from "@/components/AgentMultiSelect"; import { RadioCardGroup } from "@/components/ui/radio-card"; @@ -33,14 +35,20 @@ export function EmailConnectionAccess({ }); }, }); - if (grants.isLoading || installs.isLoading) - return

Loading access…

; - if (grants.error || installs.error) + const grantsView = useQueryView(grants); + const installsView = useQueryView(installs); + const failedView = [grantsView, installsView].find((view) => view.kind === "error"); + if (failedView) return ( -

- Connection access could not be loaded. -

+ ); + if (grants.isLoading || installs.isLoading || grantsView.kind === "reconnecting" || installsView.kind === "reconnecting") + return

Loading access…

; const active = grants.data?.grants.filter((g) => g.status === "active") ?? []; const everyone = active.some((g) => g.kind === "organization"); const personal = active.find((g) => g.kind === "user"); @@ -111,7 +119,7 @@ export function EmailConnectionAccess({

{save.error && (

- {save.error.message} + {describeError(save.error).body}

)} diff --git a/ui/src/components/EmailTaskActivity.tsx b/ui/src/components/EmailTaskActivity.tsx index 6a384fb1d0e..9a5781d82c4 100644 --- a/ui/src/components/EmailTaskActivity.tsx +++ b/ui/src/components/EmailTaskActivity.tsx @@ -5,6 +5,8 @@ import { emailApi } from "@/api/email"; import { useEmailThread } from "@/hooks/useEmailThread"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { describeError } from "@/api/errors"; +import { QueryErrorState, useQueryView } from "./QueryView"; import type { EmailPublicationSummary } from "@paperclipai/shared"; // Email actions belong to the agent's task conversation. Only surface mail @@ -20,43 +22,55 @@ export function EmailTaskActivity({ const threadKey = ["email-thread", companyId, issueId]; const queryEnabled = Boolean(companyId && issueId) && !issueId.startsWith("chat:"); const thread = useEmailThread(companyId, issueId); + const threadView = useQueryView(thread); if (!queryEnabled) return null; - const data = thread.data; - const messages = data?.messages.filter((m) => !m.commentId) ?? []; - const publications = data?.publications.filter( - (p) => !p.providerMessageId || p.outcome === "uncertain", - ) ?? []; - if (!thread.error && !messages.length && !publications.length) return null; - return ( -
- {thread.error && ( -

- {thread.error.message} -

- )} - {messages.map((m) => ( - p.providerMessageId === m.providerMessageId, - )} - /> - ))} - {publications.map((p) => ( - { - void cache.invalidateQueries({ queryKey: threadKey }); - }} - /> - ))} -
- ); + if (threadView.kind === "stale" || threadView.kind === "ready") { + const data = threadView.data; + const messages = data?.messages.filter((m) => !m.commentId) ?? []; + const publications = data?.publications.filter( + (p) => !p.providerMessageId || p.outcome === "uncertain", + ) ?? []; + if (!messages.length && !publications.length) return null; + return ( +
+ {messages.map((m) => ( + p.providerMessageId === m.providerMessageId, + )} + /> + ))} + {publications.map((p) => ( + { + void cache.invalidateQueries({ queryKey: threadKey }); + }} + /> + ))} +
+ ); + } + // A quiet section while the initial read is still settling;a no-data read + // failure gets readable copy with Retry instead of a raw error line. + if (threadView.kind === "error") { + return ( + + ); + } + return null; } function EmailDelivery({ @@ -126,9 +140,9 @@ function EmailDelivery({ Confirm not sent - {resolve.error && ( + {resolve.isError && ( // query-error-ok: mutation result

- {resolve.error.message} + {describeError(resolve.error).body}

)} diff --git a/ui/src/components/ExecutionBlockerNotice.tsx b/ui/src/components/ExecutionBlockerNotice.tsx index 2c13d9e6975..31209cc4e97 100644 --- a/ui/src/components/ExecutionBlockerNotice.tsx +++ b/ui/src/components/ExecutionBlockerNotice.tsx @@ -1,3 +1,4 @@ +import { describeError } from "../api/errors"; import type { TaskInterruptionPresentation } from "@paperclipai/shared"; import { AlertCircle } from "lucide-react"; import { Button } from "./ui/button"; @@ -23,7 +24,7 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi if (presentation?.projectAccess && !optimistic && !takingLonger) return ; - if (error && !optimistic && !takingLonger) return
{error.message}{canRetry && }
; + if (error && !optimistic && !takingLonger) return
{describeError(error).body}{canRetry && }
; if (takingLonger) return
Taking longer than usual…{canRetry && }
; if (!optimistic && !presentation?.retryWait && presentation?.providerFailure && ["failed", "retry_scheduled", "waiting_for_user"].includes(presentation.state)) { return executionWorkspacesApi.getCloseReadiness(workspaceId), enabled: open, }); + // Read close readiness through the shared view: cached readiness stays + // usable during a transient refetch failure,and a no-data failure shows + // readable copy with Retry. + const readinessView = useQueryView(readinessQuery); const closeWorkspace = useMutation({ mutationFn: () => executionWorkspacesApi.update(workspaceId, { status: "archived" }), @@ -69,7 +75,7 @@ export function ExecutionWorkspaceCloseDialog({ onError: (error) => { pushToast({ title: "Failed to close workspace", - body: error instanceof Error ? error.message : "Unknown error", + body: describeError(error).body, tone: "error", }); }, @@ -98,15 +104,18 @@ export function ExecutionWorkspaceCloseDialog({ - {readinessQuery.isLoading ? ( + {(readinessView.kind === "loading" || readinessView.kind === "reconnecting") ? (
Checking whether this workspace is safe to close...
- ) : readinessQuery.error ? ( -
- {readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."} -
+ ) : readinessView.kind === "error" ? ( + ) : readiness ? (
diff --git a/ui/src/components/FileTree.test.tsx b/ui/src/components/FileTree.test.tsx index 9b8b26ebbf2..2bf88810200 100644 --- a/ui/src/components/FileTree.test.tsx +++ b/ui/src/components/FileTree.test.tsx @@ -29,6 +29,19 @@ describe("FileTree", () => { return container.querySelector(`[data-file-tree-path="${path}"]`) as HTMLDivElement | null; } + it("preserves readable plugin error details and retry without exposing raw codes", () => { + const retry = vi.fn(); + const renderError = (message: string) => act(() => root.render( + {}} onToggleDir={() => {}} error={{ message, retry }} />, + )); + renderError("The wiki source was removed. Choose another source."); + expect(container.textContent).toContain("The wiki source was removed. Choose another source."); + act(() => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Retry")!.click()); + expect(retry).toHaveBeenCalledOnce(); + renderError("source_unavailable"); + expect(container.textContent).not.toContain("source_unavailable"); + }); + it("selects file rows and expands directory rows", () => { const onSelectFile = vi.fn(); const onToggleDir = vi.fn(); diff --git a/ui/src/components/FileTree.tsx b/ui/src/components/FileTree.tsx index 8c585ada981..d58ecaee8c1 100644 --- a/ui/src/components/FileTree.tsx +++ b/ui/src/components/FileTree.tsx @@ -1,6 +1,7 @@ import type { CSSProperties, KeyboardEvent, ReactNode } from "react"; import { useMemo, useRef, useState } from "react"; import { cn } from "../lib/utils"; +import { describeError } from "../api/errors"; import { ChevronDown, ChevronRight, @@ -385,7 +386,7 @@ export function FileTree({ > error - {error.message} + {describeError(new Error(error.message)).body}
{error.retry && ( , publicBlockedReason, )} - {canManage && constraintsQuery.isError ? ( + {canManage && constraintsView.kind === "error" ? ( ) : null} diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index 1bee4217d05..76c94da2712 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -448,7 +448,6 @@ export function IssueRunLedger({ const { data: boardAccess } = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess(), - retry: false, }); const { data: runs } = useQuery({ queryKey: queryKeys.issues.runs(issueId), @@ -484,7 +483,6 @@ export function IssueRunLedger({ queryKey: queryKeys.providerTraceMetadata(companyId, traceRunIds), queryFn: () => heartbeatsApi.providerTraceMetadata(companyId, traceRunIds), enabled: canInspectProviderTrace && traceRunIds.length > 0, - retry: false, }); const providerTraceMetadata = useMemo( () => new Map((providerTraceRows ?? []).map((trace) => [trace.runId, trace])), diff --git a/ui/src/components/IssueScheduledRetryCard.tsx b/ui/src/components/IssueScheduledRetryCard.tsx index 83130ffaecb..383fce3be87 100644 --- a/ui/src/components/IssueScheduledRetryCard.tsx +++ b/ui/src/components/IssueScheduledRetryCard.tsx @@ -8,6 +8,7 @@ import type { IssueScheduledRetry } from "@paperclipai/shared"; import { useRetryNowMutation, type RetryNowError } from "../hooks/useRetryNowMutation"; import { Badge } from "@/components/ui/badge"; import { InlineBanner } from "@/components/InlineBanner"; +import { describeError } from "@/api/errors"; const MAX_TURN_CONTINUATION = "max_turns_continuation"; @@ -118,7 +119,8 @@ export function IssueScheduledRetryCard({ Last attempt failed: {scheduledRetry.error}{/[.!?]$/.test(scheduledRetry.error.trim()) ? "" : "."} Paperclip will retry automatically.
) : null} - {isError ? ( + {isError ? ( // query-error-ok: mutation result + { @@ -192,7 +194,7 @@ export function RetryErrorBand({ error, onRetry, className }: RetryErrorBandProp