From a3313f69447e522a82558798ccbb19fdf6694fee Mon Sep 17 00:00:00 2001
From: Devin Foley {\n error.message\n} {\n (error as Error).message\n}
- {refreshModelsError
- ?? (modelError instanceof Error
- ? modelError.message
- : "Failed to load adapter models.")}
- {refreshModelsError} {describeError(connectionModelsError).body}
- {endpoint.error.message}
- Save the agent, then return here to pair your Dot. Waiting for Dot to read and confirm the harmless mailbox challenge. {error instanceof Error ? error.message : "Connection failed"} {describeError(mutationError).body} Loading access…
- Connection access could not be loaded.
- Loading access…"), ["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 35141db2ea..c8242b4cc8 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 9f6fc62b34..746eef745b 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 1450aec855..d398d8fb46 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 00156e922a..cdd371a011 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
- {error.message}
+ {describeError(error).body}{/* // query-error-ok: error boundary; crash diagnostics, not a query read */}
Advanced
- {save.error.message} + {describeError(save.error).body}
)} diff --git a/ui/src/components/EmailTaskActivity.tsx b/ui/src/components/EmailTaskActivity.tsx index 6a384fb1d0..9a5781d82c 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.message} -
- )} - {messages.map((m) => ( -- {resolve.error.message} + {describeError(resolve.error).body}
)} diff --git a/ui/src/components/ExecutionBlockerNotice.tsx b/ui/src/components/ExecutionBlockerNotice.tsx index 2c13d9e697..31209cc4e9 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) returnRuns in Grok Bot. The Bot retains its apps and persistent context. Paperclip supplies task coordination and first-party tools. Usage and spending are not reported.
{state.isPending &&Loading connection…
} - {error &&{error.message}
} + {error &&{describeError(error).body}
} {state.data && !state.data.enabled &&Enable Grok Bot, Assistant connections (MCP), and Paperclip Runner on an authenticated public HTTPS instance.
} {binding?.connected ? <>Agent access connected. {routine?.verified && !recovering ? "Routine round trip verified." : "Assignments require a verified routine round trip."}
diff --git a/ui/src/components/GrokBotWorkSummary.tsx b/ui/src/components/GrokBotWorkSummary.tsx index 72c388cf0b..61dce7e4f4 100644 --- a/ui/src/components/GrokBotWorkSummary.tsx +++ b/ui/src/components/GrokBotWorkSummary.tsx @@ -1,12 +1,13 @@ +import { describeError } from "@/api/errors"; import { useQuery } from "@tanstack/react-query"; import { grokBotApi } from "@/api/grokBot"; import { Link } from "@/lib/router"; /** Reports observed Paperclip activity; this is not a provider availability indicator. */ export function GrokBotWorkSummary({ companyId, agentId }: { companyId: string; agentId: string }) { - const state = useQuery({ queryKey: ["grok-binding", companyId, agentId], queryFn: ({ signal }) => grokBotApi.connection(companyId, agentId, signal), retry: false, refetchInterval: 10000 }); + const state = useQuery({ queryKey: ["grok-binding", companyId, agentId], queryFn: ({ signal }) => grokBotApi.connection(companyId, agentId, signal), refetchInterval: 10000 }); const data = state.data, binding = data?.binding, routine = binding?.routine; returnConnection status could not be refreshed. {state.error.message}
} + {state.error &&Connection status could not be refreshed. {describeError(state.error).body}
}{data?.recoveryNeeded ? "Recovery needed" : binding?.assignment?.status === "offered" ? "Awaiting acknowledgement" : binding?.assignment ? "Working in Grok Bot" : routine?.verified ? "No active Paperclip work session" : "Connection setup incomplete"}
{data?.currentTask && {data.currentTask.identifier}: {data.currentTask.title}}Queued tasks: {data?.queuedTaskCount ?? "Checking…"}
diff --git a/ui/src/components/InboxAgentPolicyControl.tsx b/ui/src/components/InboxAgentPolicyControl.tsx index 56aff2b941..ba4e0c817f 100644 --- a/ui/src/components/InboxAgentPolicyControl.tsx +++ b/ui/src/components/InboxAgentPolicyControl.tsx @@ -3,8 +3,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Inbox, LoaderCircle, Save } from "lucide-react"; import type { InboxAgentPolicy, InboxAgentPolicyMode } from "@paperclipai/shared"; import { agentsApi } from "@/api/agents"; +import { describeError } from "@/api/errors"; import { inboxAgentPolicyApi } from "@/api/inbox-agent-policy"; import { queryKeys } from "@/lib/queryKeys"; +import { QueryErrorState, useQueryView } from "@/components/QueryView"; import { isAgentTaskTarget } from "@/lib/company-members"; import { AgentMultiSelect } from "@/components/AgentMultiSelect"; import { Button } from "@/components/ui/button"; @@ -54,6 +56,7 @@ export function InboxAgentPolicyControl({ companyId }: { companyId: string | nul queryFn: () => inboxAgentPolicyApi.getMine(companyId!), enabled: !!companyId, }); + const policyView = useQueryView(policyQuery); const policy = policyQuery.data; const agentsQuery = useQuery({ @@ -109,15 +112,20 @@ export function InboxAgentPolicyControl({ companyId }: { companyId: string | nul draft && policy && policyKey(draft.mode, draft.allowedAgentIds) !== policyKey(policy.mode, policy.allowedAgentIds), ); - if (policyQuery.error) { + // A saved policy stays on screen through a failed refetch; only a real + // failure, or an outage before the first load, replaces the control. + if (policyView.kind === "error") { return ( -Loading revisions…
+ {historyView.kind === "error" && ( +- {error.message} -
+ {diffView.kind === "error" && ( +{describeError(restore.error).body}
+ )} + {(historyView.kind === "loading" || historyView.kind === "reconnecting") && ( +Loading revisions…
)} {history.data?.pages .flatMap((page) => page.revisions) diff --git a/ui/src/components/IssuePrivacyActions.tsx b/ui/src/components/IssuePrivacyActions.tsx index 98a6edf4be..77848bf172 100644 --- a/ui/src/components/IssuePrivacyActions.tsx +++ b/ui/src/components/IssuePrivacyActions.tsx @@ -14,9 +14,11 @@ import { } from "@/components/ui/alert-dialog"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { issuesApi } from "@/api/issues"; +import { describeError } from "@/api/errors"; import { useToastActions } from "@/context/ToastContext"; import { queryKeys } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; +import { useQueryView } from "./QueryView"; import { IssueShareSheet, type ShareSheetImplicitPrincipal } from "./IssueShareSheet"; const MENU_ITEM_CLASS = @@ -62,13 +64,14 @@ export function IssuePrivacyActions({ queryFn: () => issuesApi.privacyConstraints(issue.id), enabled: canManage && isPrivate, }); - const publicBlockedReason = constraintsQuery.isError + const constraintsView = useQueryView(constraintsQuery); + const publicBlockedReason = constraintsView.kind === "error" ? "Couldn't check task privacy. Retry before making this task public." - : !constraintsQuery.data || constraintsQuery.isFetching + : constraintsView.kind === "loading" || constraintsView.kind === "reconnecting" || constraintsView.isFetching ? "Checking task privacy…" - : constraintsQuery.data.publicBlockedBy === "parent" + : constraintsView.data?.publicBlockedBy === "parent" ? "Move this task out of its private parent before making it public." - : constraintsQuery.data.publicBlockedBy === "project" + : constraintsView.data?.publicBlockedBy === "project" ? "Move this task out of its private project before making it public." : null; @@ -89,7 +92,7 @@ export function IssuePrivacyActions({ }); }, onError: (error) => { - pushToast({ title: "Couldn't change visibility", body: (error as Error).message, tone: "error" }); + pushToast({ title: "Couldn't change visibility", body: describeError(error).body, tone: "error" }); }, }); @@ -136,9 +139,9 @@ export function IssuePrivacyActions({ , publicBlockedReason, )} - {canManage && constraintsQuery.isError ? ( + {canManage && constraintsView.kind === "error" ? ({error.message}
} + {error ?Unable to filter tasks by external status: {externalObjectFilterError.message}
+Unable to filter tasks by external status: {describeError(externalObjectFilterError).body}
Failed to load task hierarchy: {hierarchy.error.message}
: null} - {!collectionLoading && !externalObjectFilterError && visibleListRowCount === 0 && viewState.viewMode === "list" && ( + {hierarchy.error ?{describeError(hierarchy.error, { action: "load task hierarchy" }).body}
{error.message}
} + {error ?Showing up to {ISSUE_SEARCH_RESULT_LIMIT} matches. Refine the search to narrow further. @@ -1954,8 +1959,8 @@ export function IssuesList({ Some board columns are showing up to {ISSUE_BOARD_COLUMN_RESULT_LIMIT} tasks. Refine filters or search to reveal the rest.
)} - {hierarchy.error ?Failed to load task hierarchy: {hierarchy.error.message}
: null} - {!isLoading && !externalObjectFilterLoading && visibleListRowCount === 0 && viewState.viewMode === "list" && ( + {hierarchy.error ?Failed to load task hierarchy: {describeError(hierarchy.error).body}
: null} + {!isLoading && !error && !externalObjectFilterLoading && visibleListRowCount === 0 && viewState.viewMode === "list" && (Save the agent, then connect your personal Muse here.
; const error = muse.state.error ?? muse.pair.error ?? muse.verify.error ?? muse.revoke.error ?? muse.attest.error ?? muse.attestDetector.error ?? pause.error; if (muse.identity.failed) returnUnable to confirm your account. Refresh to inspect this Muse connection.
{muse.state.error ? "Unable to load the Muse connection." : "Loading Muse connection…"}
{error &&{error.message}
}{muse.state.error ? "Unable to load the Muse connection." : "Loading Muse connection…"}
{error &&{describeError(error).body}
}{create.error.message}
} + {create.status === "error" &&{describeError(create.error).body}
}Couldn't load the company access directory.
) : null}Loading access…
: null} - {membersQuery.isError ? ( + {membersWaiting ?Loading access…
: null} + {membersView.kind === "error" ? (Couldn't load project access members.
) : null}{error.message}
} + {error &&{describeError(error).body}
} {!ready &&{workspaceError}
)} - {createWorkspace.isError && ( + {createWorkspace.isError && ( // query-error-ok: mutation result +Failed to save workspace.
)} - {removeWorkspace.isError && ( + {removeWorkspace.isError && ( // query-error-ok: mutation result +Failed to delete workspace.
)} - {updateWorkspace.isError && ( + {updateWorkspace.isError && ( // query-error-ok: mutation result +Failed to update workspace.
)} } diff --git a/ui/src/components/ProjectRepositories.tsx b/ui/src/components/ProjectRepositories.tsx index a5f0b861da..74e709bbbf 100644 --- a/ui/src/components/ProjectRepositories.tsx +++ b/ui/src/components/ProjectRepositories.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Project, ProjectRepository } from "@paperclipai/shared"; import { projectsApi } from "@/api/projects"; +import { describeError } from "@/api/errors"; import { queryKeys } from "@/lib/queryKeys"; import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow"; import { ProjectRepositoryInput, repositoryOptionsKey } from "./ProjectRepositoryInput"; @@ -33,7 +34,7 @@ export function ProjectRepositories({ project }: { project: Project }) {{save.error.message}
} + {save.status === "error" &&{describeError(save.error).body}
} {save.isSuccess && Changes saved} {draft &&
- {error.message}
+ {describeError(error).body}{/* // query-error-ok: error boundary; crash diagnostics, not a query read */}
Could not load revisions
-- {revisionsQuery.error instanceof Error - ? revisionsQuery.error.message - : "Unknown error loading revisions."} -
-Raw provider traces require an instance administrator.
Canonical PRP events and presentation decisions remain inspectable below.
Raw provider capture was off for this run.
diff --git a/ui/src/components/SentryGate.tsx b/ui/src/components/SentryGate.tsx index 3d99695cc1..9812ebd602 100644 --- a/ui/src/components/SentryGate.tsx +++ b/ui/src/components/SentryGate.tsx @@ -22,7 +22,6 @@ export function SentryGate() { const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), - retry: false, }); const dsn = session?.sentryDsn; diff --git a/ui/src/components/SidebarAccountMenu.production.tsx b/ui/src/components/SidebarAccountMenu.production.tsx index c085b62117..2129f3fa90 100644 --- a/ui/src/components/SidebarAccountMenu.production.tsx +++ b/ui/src/components/SidebarAccountMenu.production.tsx @@ -149,7 +149,6 @@ export function SidebarAccountMenu({ const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), - retry: false, }); const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome }); diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index fbf7a29781..1195977a3f 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -153,7 +153,6 @@ export function SidebarAccountMenu({ const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), - retry: false, }); const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome }); diff --git a/ui/src/components/SidebarCompanyMenu.production.tsx b/ui/src/components/SidebarCompanyMenu.production.tsx index 81f3bbcca7..69b8fdfcf5 100644 --- a/ui/src/components/SidebarCompanyMenu.production.tsx +++ b/ui/src/components/SidebarCompanyMenu.production.tsx @@ -179,7 +179,6 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), - retry: false, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; const { orderedCompanies, persistOrder } = useCompanyOrder({ diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx index c1072143bc..3a2d969327 100644 --- a/ui/src/components/SidebarCompanyMenu.tsx +++ b/ui/src/components/SidebarCompanyMenu.tsx @@ -183,7 +183,6 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange, showChevron = const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), - retry: false, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; const { orderedCompanies, persistOrder } = useCompanyOrder({ diff --git a/ui/src/components/SummarySlotCard.tsx b/ui/src/components/SummarySlotCard.tsx index 9b94b0dfb3..8398f60663 100644 --- a/ui/src/components/SummarySlotCard.tsx +++ b/ui/src/components/SummarySlotCard.tsx @@ -15,8 +15,10 @@ import { builtInAgentsApi, type BuiltInAgentState } from "@/api/builtInAgents"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { summarySlotsApi, type SummarySlotSelector } from "@/api/summarySlots"; import { MarkdownBody } from "@/components/MarkdownBody"; +import { QueryErrorState, useQueryView } from "@/components/QueryView"; import { ConfigureBuiltInAgentModal } from "@/components/ConfigureBuiltInAgentModal"; import { InlineBanner } from "@/components/InlineBanner"; +import { describeError } from "@/api/errors"; import { useSummaryDraftStream } from "@/components/useSummaryDraftStream"; import { useCompanyLiveEvent } from "@/context/LiveUpdatesProvider"; import { Badge } from "@/components/ui/badge"; @@ -160,7 +162,6 @@ export function SummarySlotCard({ queryKey: queryKeys.builtInAgents.list(companyId ?? "__none__"), queryFn: () => builtInAgentsApi.list(companyId!), enabled: Boolean(companyId && summariesEnabled && builtInAgentsEnabled), - retry: false, }); const summarizerState = builtInAgentsQuery.data?.find( @@ -179,15 +180,15 @@ export function SummarySlotCard({ queryKey: slotQueryKey, queryFn: () => summarySlotsApi.get(selector!), enabled: Boolean(selector && summariesEnabled), - retry: false, refetchInterval: (query) => query.state.data?.slot?.status === "generating" ? 3_000 : false, }); + const slotView = useQueryView(slotQuery); + const slotReady = slotView.kind === "ready" || slotView.kind === "stale"; const revisionsQuery = useQuery({ queryKey: revisionsQueryKey, queryFn: () => summarySlotsApi.revisions(selector!), enabled: Boolean(selector && summariesEnabled && slotQuery.data?.document), - retry: false, }); const generateMutation = useMutation({ @@ -357,21 +358,17 @@ export function SummarySlotCard({ ) : null} - {slotQuery.isError ? ( -No summary yet
diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 5c8b468217..24a22b59a5 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -636,8 +636,8 @@ export function TaskChatThread(props: TaskChatThreadProps) { error: props.scrollingHistory.error, load: () => Promise.all([ props.scrollingHistory?.hasMore ? props.scrollingHistory.load() : undefined, - !decisionReceipts.isError && decisionReceipts.hasNextPage ? decisionReceipts.fetchNextPage() : undefined, - !taskHandoffs.isError && taskHandoffs.hasNextPage ? taskHandoffs.fetchNextPage() : undefined, + decisionReceipts.status !== "error" && decisionReceipts.hasNextPage ? decisionReceipts.fetchNextPage() : undefined, + taskHandoffs.status !== "error" && taskHandoffs.hasNextPage ? taskHandoffs.fetchNextPage() : undefined, ]), } : undefined; const conversationWindowStart = props.scrollingHistory?.hasMore && comments.length diff --git a/ui/src/components/TaskDecisionRecord.tsx b/ui/src/components/TaskDecisionRecord.tsx index 4a5487da1b..e63b8826fb 100644 --- a/ui/src/components/TaskDecisionRecord.tsx +++ b/ui/src/components/TaskDecisionRecord.tsx @@ -1,3 +1,4 @@ +import { useQueryView } from "@/components/QueryView"; import { useQuery } from "@tanstack/react-query"; import type { Agent } from "@paperclipai/shared"; import { decisionsApi } from "@/api/decisions"; @@ -16,15 +17,15 @@ export function TaskDecisionRecord({ companyId, issueId, viewerId, agentMap }: { const decisionId = anchor?.startsWith("decision-") ? anchor.slice("decision-".length) : null; const record = useQuery({ queryKey: queryKeys.decisions.taskRecord(companyId, issueId, viewerId, decisionId), - queryFn: () => decisionsApi.get(decisionId!), enabled: !!decisionId, retry: false, - }); + queryFn: () => decisionsApi.get(decisionId!), enabled: !!decisionId, }); + const recordView = useQueryView(record); if (!decisionId) return null; const decision = record.data; const matchesTask = decision?.companyId === companyId && decision.originIssueId === issueId; const choice = decision?.options.find((option) => option.id === decision.chosenOptionId); returnLoading decision…
: record.isError ? ( + {["loading", "reconnecting"].includes(recordView.kind) ?Loading decision…
: (recordView.kind === "error") ? (Could not load this decision.
This decision is not available in this task.
: <> {decision.status !== "open" ?Inspecting the recorded file source…
: null} - {source.isError || check.isError || notice ?- {notice ?? (check.error instanceof Error ? check.error.message : "File recovery could not be inspected. Check its status again.")} + {sourceView.kind === "loading" || sourceView.kind === "reconnecting" ?
Inspecting the recorded file source…
: null} + {sourceView.kind === "error" || checkError || notice ?+ {notice ?? (checkError instanceof Error ? checkError.message : "File recovery could not be inspected. Check its status again.")}
: null} {current && recovery.status === "blocked" && (serverAcknowledged || recovery.retryable !== true) ?File recovery has not finished. The saved source remains protected; service assistance is needed.
: null} - {source.data && !current ?The recorded source is unavailable or has changed. Inspect recovery before continuing.
: null} - > : access.isError ?Task access could not be checked. Refresh before requesting file recovery.
- : access.isPending || !owner ?Checking file recovery access…
+ {sourceReady && !current ?The recorded source is unavailable or has changed. Inspect recovery before continuing.
: null} + > : accessView.kind === "error" ?Task access could not be checked. Refresh before requesting file recovery.
+ : accessView.kind === "loading" || accessView.kind === "reconnecting" || !owner ?Checking file recovery access…
:A board member with task access can request file recovery.
}{files.error.message}
} - {submit.isError &&{submit.error.message}
} + {(filesView.kind === "error") &&{describeError(files.error).body}
} + {submit.error &&{describeError(submit.error).body}
}A board member with runtime access can continue from the surviving task attachments.
} ; } diff --git a/ui/src/components/access/InvitesSection.test.tsx b/ui/src/components/access/InvitesSection.test.tsx index 6c46f9725b..ee69dbbc25 100644 --- a/ui/src/components/access/InvitesSection.test.tsx +++ b/ui/src/components/access/InvitesSection.test.tsx @@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "@/api/client"; import { InvitesSection } from "./InvitesSection"; import { queryKeys } from "@/lib/queryKeys"; @@ -330,4 +331,78 @@ describe("InvitesSection", () => { root.unmount(); }); }); + + async function renderSection(queryClient: QueryClient) { + const root = createRoot(container); + await act(async () => { + root.render( +Checking connections and models…
} - {(catalogs.error || loadErrors.length > 0) &&{catalogs.error?.message}
{loadErrors.map(r =>{r.agent.name}: {"error" in r ? r.error : ""}
)}{catalogs.error ? describeError(catalogs.error).body : undefined}
{loadErrors.map(r =>{r.agent.name}: {"error" in r ? r.error : ""}
)}{selectionError}
} {problems.length > 0 &&{p.agent.name}: {p.message}
)}{!changeModel && !failures &&{agent.name}
{changeConnection && !sameBinding(binding, agentConnection(agent)) &&{bulkConnectionName(agentConnection(agent), rows.find(r => r.agent.id === agent.id)?.accounts)}
{agentDefaultModel(agent) ?? "Harness default"}
{targets.length - changed.length} already have these settings.
}{revokeError}
}{revocationDetails}{revokeError}
}{revocationDetails}} ); } diff --git a/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx b/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx index 121b759ae8..1d10aac49b 100644 --- a/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx +++ b/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx @@ -7,7 +7,9 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { aiConnectionsApi } from "@/api/ai-connections"; +import { describeError } from "@/api/errors"; import { environmentsApi } from "@/api/environments"; +import { QueryErrorState, useQueryView } from "@/components/QueryView"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { queryKeys } from "@/lib/queryKeys"; import { resolveAdapterTestEnvironmentId, resolveLocalDefaultEnvironmentId, resolveManagedSandboxEnvironmentId } from "@/lib/adapter-test-environment"; @@ -47,6 +49,12 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM const settings = useQuery({ queryKey: queryKeys.instance.settings, queryFn: instanceSettingsApi.get }); const experimental = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: instanceSettingsApi.getExperimental }); const general = useQuery({ queryKey: queryKeys.instance.generalSettings, queryFn: instanceSettingsApi.getGeneral }); + const envsView = useQueryView(envs); + const capsView = useQueryView(caps); + const settingsView = useQueryView(settings); + const experimentalView = useQueryView(experimental); + const generalView = useQueryView(general); + const views = [envsView, capsView, settingsView, experimentalView, generalView]; const forced = resolveForcedKubernetesEnvironment(general.data?.executionMode, envs.data ?? []); let environmentId: string | null = null; let environmentError: string | undefined; @@ -59,7 +67,7 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(envs.data), visibleEnvironmentIds: envs.data?.map((env) => env.id), }); - } catch (error) { environmentError = error instanceof Error ? error.message : "Could not resolve the sign-in environment."; } + } catch (error) { environmentError = describeError(error).body; } const loginEnvironments = (envs.data ?? []).filter((env) => env.status === "active" && (env.driver === "local" || (provider !== "google" && env.driver === "sandbox" && typeof env.config.provider === "string" && @@ -74,8 +82,8 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM const environment = envs.data?.find((env) => env.id === environmentId); const sandboxProvider = typeof environment?.config.provider === "string" ? environment.config.provider : ""; const canLogin = environment?.driver === "sandbox" && caps.data?.sandboxProviders?.[sandboxProvider]?.supportsLoginPty === true; - const loading = [envs, caps, settings, experimental, general].some((query) => query.isPending); - const error = environmentError ?? [envs, caps, settings, experimental, general].find((query) => query.error)?.error?.message; + const loading = [envs, caps, settings, experimental, general].some((query) => query.isPending) || views.some((view) => view.kind === "reconnecting"); + const loadError = views.find((view) => view.kind === "error"); const intent: AiConnectionLoginIntent = { provider: provider as AiConnectionLoginIntent["provider"], method: "subscription", name, ownership, agentIds, allAgents, connectionId }; return{error}
} + {loadError ?{environmentError}
: null} {defaults} {loading ?Preparing sign-in…
:{save.error.message}
} + {save.error &&{describeError(save.error).body}
} {copilot &&Use a personal fine-grained token with Copilot Requests permission. GitHub token instructions. Verification discovers models in the selected environment and sends no model prompt. Repository access uses a separate connection.
} {copilot && !suppliedEnvironmentId &&Usage not observed.
} {!supported &&Unavailable for this sign-in method.
} - {probe.error &&{probe.error.message}
} + {probe.error &&{describeError(probe.error).body}
} {usage && usage.status !== "ok" &&{usageError(usage)}
} {usage?.status === "ok" && (Loading connection permissions…
; - if (!reconnect && accounts.isError) returnCould not load connection permissions.
Loading connection permissions…
; + if (!reconnect && accountsView.kind === "error") returnCould not load agents.
{save.error instanceof Error && save.error.name === "ZodError" ? "Check the URL, API format, and authentication fields." - : save.error.message} + : describeError(save.error).body}
)} {accessDefaults} @@ -451,7 +455,7 @@ export function AiProviderSetup({- {accounts.isPending || grants.isPending + {accounts.isPending || grants.isPending || accountsView.kind === "reconnecting" || grantsView.kind === "reconnecting" ? "Loading AI account…" : "This account is not available to you."}
@@ -100,7 +104,7 @@ export function ManagedAiConnectionDetails({ return ({error.message}
} + {error &&{describeError(error).body}
}- Could not load active runs. Retry before revoking. -
+ {runsView.kind === "error" && ( +Decisions could not be loaded.
{filtersActive && !visibleRecentItems.length ? "No decisions match these filters." : pendingItems.length ? "No open decisions match these filters." : "You're caught up."}
Decisions could not be loaded.
{filtersActive && !visibleRecentItems.length ? "No decisions match these filters." : pendingItems.length ? "No open decisions match these filters." : "You're caught up."}
Recent decisions could not be loaded.
Recent decisions could not be loaded.
No tasks were created.
; returnLoading decision model…
; - if (query.error) returnCould not load decision settings.
Loading connections…
} - {query.error &&{query.error.message}
{describeError(query.error).body}
Connect GitHub to continue.
} diff --git a/ui/src/components/task-chat/QuestionForm.tsx b/ui/src/components/task-chat/QuestionForm.tsx index 23a354b08d..bd1b823892 100644 --- a/ui/src/components/task-chat/QuestionForm.tsx +++ b/ui/src/components/task-chat/QuestionForm.tsx @@ -453,6 +453,7 @@ function QuestionFormForRequest({ } const currentError = validationErrors[question.id]; + const errorMessage = error?.message ?? null; const isLastPage = page === questionSet.questions.length - 1; const busy = disabled || working != null || inputUploading; // Cancel resolves the request when the host owns that; otherwise it just @@ -689,9 +690,9 @@ function QuestionFormForRequest({{currentError}
) : null}