mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
fix(ui): complete read recovery and enforce query error rendering (#18822)
## Thinking Path > - Paperclip lets people manage agent work through the board. > - The board reads task, agent, settings, plugin, and run data from the server. > - A deploy can interrupt those reads after content is already visible. > - Local error branches can replace that content with an error or show raw codes. > - This pull request moves the remaining reads to the shared recovery behavior. > - The query-error guard prevents new local error patterns from returning. ## Linked Issues or Issue Description **What happened?** Loaded lists, settings, task panels, and manual reads could clear their content or show errors during a server restart. Some reads also disabled the shared retry policy. **Expected behavior** Keep loaded content during a temporary failure. Show the shared connection banner. Retry reads after recovery. Replace content after a real access denial or missing resource, with readable copy. **Steps to reproduce** Open a task, settings page, or run log. Return 503 with `tenant_app_unavailable` from the API. Restore the API and check that content refreshes. Repeat with 403 and 404 to check the failure state. Related merged work: Refs #15754, Refs #15772, Refs #17960. This change covers remaining reads and enforcement. Browser persistence and the outage browser suite follow separately. ## What Changed - Repair an existing PostgreSQL deadline fixture at UTC boundaries. A daily cap defers a heartbeat until both its original interval and the next UTC day allow it. Keep the counted run in its seeded day and cover noon, just before midnight and the first second after midnight with explicit clocks and real PostgreSQL. Production scheduling is unchanged. - Integrate the saved list, settings, shell, task, and manual-read recovery changes against current master. Preserve the current task, Inbox, and run-log structure. - Add `useResilientFetch` for component-local reads. Preserve loaded data, cancel stale requests, and retry through the shared connectivity policy. Keep browser and pairing capabilities in memory. - Retain run log chunks across stream changes and catch up after recovery without duplicate chunks or stalled requests. - Keep genuine authorization and missing-resource failures visible. Translate known single-word codes such as `forbidden`. Show known skills and disconnected-app failures before sibling loading states. Keep Retry bound to its failed request. - Make the query-error guard enforce by default and run it in the CI policy job. Document the narrow exceptions and the read recovery contract. - Invalidate pending OAuth discovery when the operator leaves setup or its context changes. Keep reserved-popup cleanup bound to the current attempt. ## Verification - Current read head `09d1db29dea` is merge-ready: all 59 check runs are completed (54 successful, three skipped, two neutral), the legacy Snyk status is successful, and both required `ci / verify` and `ci / e2e` gates pass. Greptile Apex reviewed this exact head at 5/5 with no unresolved threads; the branch has no merge conflicts. The current CI run is `41748339602`. - Current read head `09d1db29dea` includes master `683fa4725e9`. The latest change touches only the existing server deadline test fixture. Application source remains the previously verified `86376fa751e` revision: all 60 affected skills/disconnected-app tests and both application UI typechecks passed, along with token gates and the enforcing query-error guard. Those UI suites were not repeated for this test-only repair. - The fixture repair passes all seven deadline integration cases and the complete server typecheck, including the server test layer. The old assertion deterministically fails at 23:59:06.888 UTC; its one-second-old run also fails the first-second-of-day probe. The corrected fixture passes noon and both midnight probes. The permanent three-case coverage uses seeded 2099 clocks. - Twelve new regressions fail against the previous implementation. Both skills variants and disconnected-app pages show either request's 403 or 429 while the sibling pauses or remains fetching. Permission failures stay visible. Retry stays usable for its failed request and recovers independently. An initial test-fixture query-key typo was corrected before the passing run. The finding is replied to and resolved. Greptile reviewed prior application revision `86376fa751e` at 5/5 with no new actionable findings or unresolved threads. The current test-only head has now received its own Apex 5/5 review with no unresolved threads, and its CI is successful as recorded above. - The preceding OAuth fix passed all 237 setup tests on both application branches. Its ten regressions prove that Back, Cancel, unmount, or context changes retire initial discovery without creating a connection, caching old identities, navigating an abandoned popup, or closing a newer attempt. - Earlier master integration `e992f770339` passed all 309 tests across eight affected Slack, recovery, retry, submission and task files, plus UI typecheck, token gates, enforcing query-error scan and seven guard tests. The integration preserves Slack workspace reconnect and shared recovery, readable execution errors, source-inspection company scoping, and current submission behavior. Newly inherited retained-file recovery controls keep cached data through transient failures and hide it after definitive denial. - The preceding cache head `97c0bc3966f` passed 1,038 focused cache/write/editor/account/task/API tests. All 22 built-UI outage/write/loading cases passed in 4.7 minutes on exact browser head `b7deaaee5da`, with strict checking of all three specs. The latest skills/app rendering fix is covered by its affected component suites; those broader cases were not repeated for this separate read path. - Historical broader UI, CLI and build results and unresolved aggregate server/DB/adapter failures remain in the PR discussion. Interrupted CI runs were audited and recorded separately; they are not counted as passes. Controlled local outages do not prove hosted authentication or a real Cloud restart/deploy. Real hosted acceptance remains required after merge and deployment. Commands for the latest fix: ```sh pnpm --filter @paperclipai/ui exec vitest run src/pages/CompanySkills.test.tsx src/pages/apps/AppNotConnected.test.tsx pnpm --filter @paperclipai/ui typecheck pnpm check:token-gates node scripts/check-query-error-rendering.mjs --enforce --summary ``` ## Risks This migration touches many read surfaces. The main risks are initial loading, request cancellation, retained authorization state, and run-log paging. The tests cover those states. There are no server, schema, dependency, or API contract changes. The follow-up browser suite and hosted deploy acceptance are required before release acceptance. Recovered commits retain their original authors. If squash-merging, preserve `Co-Authored-By: Bender (Fable) <bender@agents.paperclip.ing>` and the Paperclip trailer. ## Model Used OpenAI `gpt-6.1-sol` through Codex, with xhigh reasoning effort, tool use, and code execution. The context window size is not exposed. Saved source commits keep their original authorship. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in-PR following the bug report template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] The affected local tests pass; broader limitations are recorded above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Bender (Fable) <bender@agents.paperclip.ing> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
330 files changed
+7809
-2252
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -16,7 +16,7 @@
|
||||
* before errors. Intentional exceptions go in ALLOWLIST below as
|
||||
* "<path>:<rule>" 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;
|
||||
|
||||
@@ -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("<p>{\n error.message\n}</p>"), ["raw-error-message"]);
|
||||
assert.deepEqual(rulesFor("<p>{\n (error as Error).message\n}</p>"), ["cast-error-message"]);
|
||||
assert.deepEqual(rulesFor("query.isError\n ? <Error /> : <List />"), ["is-error-render"]);
|
||||
assert.deepEqual(rulesFor("useQuery({ retry:\n false })"), ["retry-false"]);
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Response>();
|
||||
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<Response>();
|
||||
|
||||
+10
-7
@@ -99,6 +99,8 @@ export async function apiFetch(input: string, init?: RequestInit): Promise<Respo
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
/** Do not join an older in-flight read when validating a write. */
|
||||
fresh?: boolean;
|
||||
/** Per-caller GET deadline, including the response body. A retry starts a fresh fetch. */
|
||||
timeoutMs?: number;
|
||||
/** Abort signal wired through to `fetch` and coalescing (per-caller). */
|
||||
@@ -168,13 +170,14 @@ interface InflightGet {
|
||||
refs: Set<symbol>;
|
||||
}
|
||||
|
||||
const inflightGets = new Map<string, InflightGet>();
|
||||
const inflightGets = new Map<string | symbol, InflightGet>();
|
||||
|
||||
function coalescedGet<T>(path: string, options?: RequestOptions): Promise<T> {
|
||||
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<T>(path, {
|
||||
@@ -186,13 +189,13 @@ function coalescedGet<T>(path: string, options?: RequestOptions): Promise<T> {
|
||||
// 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<T>(path: string, options?: RequestOptions): Promise<T> {
|
||||
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<T>(path: string, options?: RequestOptions): Promise<T> {
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
+6
-4
@@ -339,8 +339,9 @@ export const toolsApi = {
|
||||
api.get<ToolConnectionReviewCounts>(`/tool-connections/${connectionId}/review-counts`),
|
||||
listAppsAttention: (companyId: string) =>
|
||||
api.get<ToolAppsAttentionResponse>(`/companies/${companyId}/tools/apps/attention`),
|
||||
listApplications: (companyId: string) =>
|
||||
api.get<ToolApplicationsResponse>(`/companies/${companyId}/tools/applications`),
|
||||
listApplications: (companyId: string, options?: RequestOptions) =>
|
||||
options ? api.get<ToolApplicationsResponse>(`/companies/${companyId}/tools/applications`, options)
|
||||
: api.get<ToolApplicationsResponse>(`/companies/${companyId}/tools/applications`),
|
||||
createApplication: (companyId: string, input: CreateToolApplicationInput) =>
|
||||
api.post<ToolApplication>(`/companies/${companyId}/tools/applications`, input),
|
||||
updateApplication: (applicationId: string, input: UpdateToolApplicationInput) =>
|
||||
@@ -349,8 +350,9 @@ export const toolsApi = {
|
||||
api.delete<ToolApplication>(`/tool-applications/${applicationId}`),
|
||||
|
||||
// --- Connections ---
|
||||
listConnections: (companyId: string) =>
|
||||
api.get<ToolConnectionsResponse>(`/companies/${companyId}/tools/connections`),
|
||||
listConnections: (companyId: string, options?: RequestOptions) =>
|
||||
options ? api.get<ToolConnectionsResponse>(`/companies/${companyId}/tools/connections`, options)
|
||||
: api.get<ToolConnectionsResponse>(`/companies/${companyId}/tools/connections`),
|
||||
getConnection: (connectionId: string) =>
|
||||
api.get<ToolConnection>(`/tool-connections/${connectionId}`),
|
||||
// --- Installs (Phase 3b, PAP-13618): which agents carry this connection's
|
||||
|
||||
@@ -107,7 +107,6 @@ export function ActiveAgentsPanel({
|
||||
queryKey: queryKeys.issues.detail(issueId),
|
||||
queryFn: () => issuesApi.get(issueId),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
</button>
|
||||
) : null}
|
||||
{needsSetup ? null : isError ? (
|
||||
{needsSetup ? null : isAgentError ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(item, "text-destructive")}
|
||||
@@ -583,7 +583,7 @@ export function AgentActionButtons({
|
||||
label={runLabel}
|
||||
size={size}
|
||||
/>}
|
||||
{!needsSetup && (isError ? (
|
||||
{!needsSetup && (agent.status === "error" ? (
|
||||
<ClearErrorButton
|
||||
onClick={() => agentAction.mutate("clear_error")}
|
||||
disabled={clearErrorDisabled}
|
||||
|
||||
@@ -4020,6 +4020,87 @@ describe("AgentConfigForm environment selector", () => {
|
||||
expect(result.container.textContent).toContain("Authenticated");
|
||||
expect(result.container.textContent).not.toContain("sk-ant-SECRET-TOKEN");
|
||||
});
|
||||
|
||||
it("keeps the loaded adapter models when a refetch fails transiently", async () => {
|
||||
mockAgentsApi.adapterModels.mockResolvedValue([
|
||||
{ id: "gpt-5.6-sol", label: "gpt-5.6-sol" },
|
||||
{ id: "gpt-6-astra", label: "gpt-6-astra" },
|
||||
]);
|
||||
mockEnvironmentsApi.list.mockResolvedValue([
|
||||
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
||||
]);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider>
|
||||
<AgentConfigForm
|
||||
mode="edit"
|
||||
agent={makeAgent({ adapterConfig: { model: "gpt-6-astra" } })}
|
||||
onSave={vi.fn()}
|
||||
hidePromptTemplate
|
||||
showAdapterTypeField={false}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("gpt-6-astra");
|
||||
expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockAgentsApi.adapterModels.mockRejectedValue(
|
||||
new ApiError("Paperclip is restarting.", 503, { error: "tenant_app_unavailable" }),
|
||||
);
|
||||
await act(async () => {
|
||||
await queryClient.invalidateQueries();
|
||||
});
|
||||
await flushUntil(() => mockAgentsApi.adapterModels.mock.calls.length > 1);
|
||||
|
||||
expect(container.querySelector('[data-query-view="error"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("Paperclip is restarting.");
|
||||
expect(container.textContent).not.toContain("tenant_app_unavailable");
|
||||
expect(container.textContent).not.toContain("Loading models…");
|
||||
|
||||
// The cached list is still the dropdown's content.
|
||||
const trigger = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "gpt-6-astra");
|
||||
expect(trigger).toBeTruthy();
|
||||
await clickElement(trigger);
|
||||
expect(document.body.textContent).toContain("gpt-5.6-sol");
|
||||
});
|
||||
|
||||
it("shows readable copy and a Retry button when the adapter models fail to load", async () => {
|
||||
mockAgentsApi.adapterModels
|
||||
.mockRejectedValueOnce(new ApiError("Boom", 500, { error: "Boom" }))
|
||||
.mockResolvedValue([{ id: "gpt-6-astra", label: "gpt-6-astra" }]);
|
||||
const result = await renderForm([
|
||||
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
||||
]);
|
||||
roots.push(result.root);
|
||||
await flushUntil(() => result.container.querySelector('[data-query-view="error"]') !== null);
|
||||
|
||||
expect(result.container.querySelector('[data-query-view="error"]')).not.toBeNull();
|
||||
expect(result.container.textContent).toContain("Boom");
|
||||
expect(result.container.textContent).not.toContain("Failed to load adapter models.");
|
||||
const retry = findButton(result.container, "Retry");
|
||||
expect(retry).toBeTruthy();
|
||||
|
||||
await clickElement(retry);
|
||||
await flushUntil(() => result.container.querySelector('[data-query-view="error"]') === null);
|
||||
|
||||
expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(2);
|
||||
expect(result.container.querySelector('[data-query-view="error"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const FIXED_CLAUDE_OAUTH_BINDING = {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapte
|
||||
import type { AdapterModel } from "../api/agents";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { ApiError } from "../api/client";
|
||||
import { describeError } from "@/api/errors";
|
||||
import { environmentsApi } from "../api/environments";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { secretsApi } from "../api/secrets";
|
||||
@@ -43,6 +44,7 @@ import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
import { QueryErrorState, useQueryView } from "@/components/QueryView";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, SelectPopover } from "@/components/ui/select";
|
||||
@@ -387,7 +389,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
: ["user-secret-definitions", "none"],
|
||||
queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
retry: false,
|
||||
});
|
||||
// Pending binding proposals targeting this agent (PAP-14731). Board-only route;
|
||||
// non-permitted viewers simply get an empty list.
|
||||
@@ -398,7 +399,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
: ["secret-proposals", "none"],
|
||||
queryFn: () => secretsApi.listProposals(selectedCompanyId!, "pending"),
|
||||
enabled: Boolean(selectedCompanyId) && !isCreate,
|
||||
retry: false,
|
||||
});
|
||||
const agentBindingProposals = useMemo(
|
||||
() =>
|
||||
@@ -411,7 +411,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
const adapterPickerDisabledTypes = useMemo(() => {
|
||||
const next = new Set(disabledTypes);
|
||||
@@ -445,12 +444,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const { data: generalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.generalSettings,
|
||||
queryFn: () => instanceSettingsApi.getGeneral(),
|
||||
retry: false,
|
||||
});
|
||||
const { data: instanceSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.settings,
|
||||
queryFn: () => instanceSettingsApi.get(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: environments = [] } = useQuery<Environment[]>({
|
||||
@@ -653,6 +650,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
queryFn: () => adaptersApi.list({ companyId: selectedCompanyId!, environmentId: rawCurrentDefaultEnvironmentId || null }),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const runnerDiscoveryView = useQueryView(runnerDiscovery);
|
||||
const runnerAvailability = runnerDiscovery.data?.find(item => item.type === harnessType);
|
||||
const runner: AgentRunnerChoice = isCreate ? props.values.runner ?? "auto" : overlay.runner ?? agentRunner(adapterType);
|
||||
const fieldsAdapterType = isRunnerHarness
|
||||
@@ -943,7 +941,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const showRunnerUpgrade = !isCreate
|
||||
&& ["codex_local", "claude_local", "grok_local"].includes(props.agent.adapterType)
|
||||
&& adapterType === props.agent.adapterType
|
||||
&& !runnerDiscovery.isError
|
||||
&& !(runnerDiscoveryView.kind === "error")
|
||||
&& (runnerAvailability?.defaultRunner === "paperclip" || canQualifyRunnerUpgrade);
|
||||
const upgradeRunner = async () => {
|
||||
if (isCreate || runnerUpgradePending || upgradeEnvironmentChanged) return;
|
||||
@@ -1047,12 +1045,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const modelQueryKey = selectedCompanyId
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, modelAdapterType, currentDefaultEnvironmentId || null, modelCacheIdentity)
|
||||
: ["agents", "none", "adapter-models", adapterType];
|
||||
const {
|
||||
data: fetchedModels,
|
||||
error: fetchedModelsError,
|
||||
isLoading: fetchingModels,
|
||||
refetch: refreshConnectionModels,
|
||||
} = useQuery({
|
||||
const fetchedModelsQuery = useQuery({
|
||||
queryKey: modelQueryKey,
|
||||
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, modelAdapterType, {
|
||||
environmentId: currentDefaultEnvironmentId || null,
|
||||
@@ -1064,6 +1057,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
...(accountBoundCatalog ? ACCOUNT_BOUND_MODEL_DISCOVERY_QUERY_OPTIONS : {}),
|
||||
enabled: Boolean(selectedCompanyId) && !isExternalRunner && !connectionModels,
|
||||
});
|
||||
const { data: fetchedModels, error: fetchedModelsError, isLoading: fetchingModels, refetch: refreshConnectionModels } = fetchedModelsQuery;
|
||||
const fetchedModelsView = useQueryView(fetchedModelsQuery);
|
||||
const accountModelScope = accountBoundCatalog ? JSON.stringify(modelQueryKey) : null;
|
||||
useEffect(() => {
|
||||
if (accountModelScope && selectedCompanyId && !isExternalRunner && !connectionModels) void refreshConnectionModels();
|
||||
@@ -1071,7 +1066,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
|
||||
const [refreshingModels, setRefreshingModels] = useState(false);
|
||||
const models = connectionModels?.models ?? fetchedModels ?? externalModels ?? [];
|
||||
const modelError = connectionModels ? connectionModels.error : fetchedModelsError;
|
||||
// `useConnectionModels` owns its own query and exposes only `error`; the
|
||||
// adapter-models read is gated on its view below, so a transient refetch
|
||||
// failure keeps the cached list without an error line.
|
||||
const connectionModelsError = connectionModels?.error ?? null;
|
||||
const adapterCommandField = "command";
|
||||
const {
|
||||
data: detectedModelData,
|
||||
@@ -1168,7 +1166,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
// server resolves the driver and probes the host in that case.
|
||||
//
|
||||
// Test can be clicked before the settings query settles (or after it
|
||||
// failed with retry:false), so when the agent relies on the instance
|
||||
// failed), so when the agent relies on the instance
|
||||
// default, resolve the settings here rather than trusting the
|
||||
// render-time cache. A fetch that still fails FAILS the test with an
|
||||
// honest diagnostic — silently probing the host instead would report
|
||||
@@ -1349,7 +1347,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
try {
|
||||
return await testEnvironment.mutateAsync();
|
||||
} catch (error) {
|
||||
setTestActionError(error instanceof Error ? error.message : "Environment test failed");
|
||||
setTestActionError(describeError(error).body);
|
||||
throw error;
|
||||
} finally {
|
||||
setTestActionPending(false);
|
||||
@@ -1399,11 +1397,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
if (!props.onTestFeedbackChange) return;
|
||||
props.onTestFeedbackChange({
|
||||
errorMessage: testActionError
|
||||
?? (testEnvironment.error instanceof Error
|
||||
? testEnvironment.error.message
|
||||
: testEnvironment.error
|
||||
? "Environment test failed"
|
||||
: null),
|
||||
?? (testEnvironment.error ? describeError(testEnvironment.error).body : null),
|
||||
result: testEnvironment.data ?? null,
|
||||
// `showAdapterLogin` already requires a selected company and a non-empty
|
||||
// environment id, so both are present here.
|
||||
@@ -1440,7 +1434,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const refreshed = await agentsApi.adapterModels(selectedCompanyId, modelAdapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider, ...(runnerAcpxAgent ? { acpxAgent: runnerAcpxAgent } : {}), ...(accountBoundCatalog ? { aiConnection: modelAiBinding, agentId: !isCreate ? props.agent.id : undefined } : {}) });
|
||||
queryClient.setQueryData(modelQueryKey, refreshed);
|
||||
} catch (error) {
|
||||
setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models.");
|
||||
setRefreshModelsError(describeError(error).body);
|
||||
} finally {
|
||||
setRefreshingModels(false);
|
||||
}
|
||||
@@ -1903,10 +1897,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
|
||||
{showInlineAdapterTestEnvironmentFeedback && !props.compactTestFeedback && (testActionError || testEnvironment.error) && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{testActionError
|
||||
?? (testEnvironment.error instanceof Error
|
||||
? testEnvironment.error.message
|
||||
: "Environment test failed")}
|
||||
{testActionError ?? describeError(testEnvironment.error).body}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1957,7 +1948,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
{isLocal && (<>
|
||||
<ModelDropdown
|
||||
models={models}
|
||||
loadingModels={connectionModels?.isLoading ?? fetchingModels}
|
||||
loadingModels={connectionModels?.isLoading ?? (fetchingModels || fetchedModelsView.kind === "reconnecting")}
|
||||
value={currentModelId}
|
||||
onChange={(v) => {
|
||||
const supportedEfforts = setupEfforts(isRunnerHarness ? harnessType : adapterType, v);
|
||||
@@ -2001,14 +1992,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
detectModelLabel="Detect model"
|
||||
emptyDetectHint="No model detected. Select or enter one manually."
|
||||
/>
|
||||
{(refreshModelsError || modelError) && (
|
||||
<p className="text-xs text-destructive">
|
||||
{refreshModelsError
|
||||
?? (modelError instanceof Error
|
||||
? modelError.message
|
||||
: "Failed to load adapter models.")}
|
||||
</p>
|
||||
)}
|
||||
{refreshModelsError ? (
|
||||
<p className="text-xs text-destructive">{refreshModelsError}</p>
|
||||
) : connectionModelsError ? (
|
||||
<p className="text-xs text-destructive">{describeError(connectionModelsError).body}</p>
|
||||
) : !connectionModels && fetchedModelsView.kind === "error" ? (
|
||||
<QueryErrorState
|
||||
size="inline"
|
||||
error={fetchedModelsError}
|
||||
action="load adapter models"
|
||||
onRetry={fetchedModelsView.retry}
|
||||
retrying={fetchedModelsView.isFetching}
|
||||
/>
|
||||
) : null}
|
||||
{adapterType === "opencode_local"
|
||||
&& !connectionModels
|
||||
&& currentDefaultEnvironment
|
||||
@@ -2093,7 +2089,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{isRunnerHarness && <AgentRunnerSelect value={runner}
|
||||
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error?.message} onRetry={() => { void runnerDiscovery.refetch(); }}
|
||||
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error ? describeError(runnerDiscovery.error).body : undefined} onRetry={() => { void runnerDiscovery.refetch(); }}
|
||||
defaultRunner={runnerAvailability?.defaultRunner}
|
||||
supportedRunners={runnerAvailability?.supportedRunners}
|
||||
onChange={next => isCreate ? set!({ runner: next }) : setOverlay(prev => ({ ...prev, runner: next }))} />}
|
||||
@@ -2393,7 +2389,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
metadataOnly={accountBoundCatalog}
|
||||
state={testActionPending ? "running" : testActionError || testEnvironment.error ? "fail" : testResult?.status ?? "idle"}
|
||||
result={testResult ?? null}
|
||||
error={testActionError ?? (testEnvironment.error instanceof Error ? testEnvironment.error.message : null)}
|
||||
error={testActionError ?? (testEnvironment.error ? describeError(testEnvironment.error).body : null)}
|
||||
onTest={triggerTestEnvironment}
|
||||
disabled={testEnvironmentDisabled}
|
||||
/>
|
||||
@@ -2645,7 +2641,7 @@ function DisplayedCodeLoginPanel({
|
||||
setSessionId(session.sessionId);
|
||||
},
|
||||
onError: (error) => {
|
||||
setStartError(error instanceof Error ? error.message : "Could not start the login.");
|
||||
setStartError(describeError(error).body);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2662,7 +2658,7 @@ function DisplayedCodeLoginPanel({
|
||||
mutationFn: () => agentsApi.cancelAdapterAuthLogin(companyId, adapterType, sessionId!),
|
||||
onSuccess: clearActiveSession,
|
||||
onError: (error) => {
|
||||
setStartError(error instanceof Error ? error.message : "Could not cancel the login.");
|
||||
setStartError(describeError(error).body);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2682,7 +2678,6 @@ function DisplayedCodeLoginPanel({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
// Never answered from cache. This read decides whether to adopt a running
|
||||
// session or start a new one, and a cached "none" from an earlier mount is
|
||||
// exactly wrong after Back: the panel would read `isFetched` immediately,
|
||||
@@ -2691,6 +2686,7 @@ function DisplayedCodeLoginPanel({
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
});
|
||||
const activeSessionView = useQueryView(activeSessionQuery);
|
||||
|
||||
// While the panel releases a resumed session it cannot recover (see below),
|
||||
// it keeps showing the login as active rather than dropping back to idle, so
|
||||
@@ -2702,11 +2698,8 @@ function DisplayedCodeLoginPanel({
|
||||
queryFn: () => agentsApi.getAdapterAuthLoginStatus(companyId, adapterType, sessionId!),
|
||||
enabled: Boolean(sessionId) && !releasingResumedSession,
|
||||
// A status 404 is unrecoverable: the server removed the row, so a retry
|
||||
// cannot bring it back. Stop at once and fail loudly.
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status === 404) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
// cannot bring it back. The shared retry policy never retries a 4xx, so
|
||||
// it stops at once and fails loudly; only transient errors are retried.
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
return status && ADAPTER_LOGIN_TERMINAL_STATUSES.has(status)
|
||||
@@ -2802,13 +2795,9 @@ function DisplayedCodeLoginPanel({
|
||||
// A failed lookup is not proof that no session exists: only a successful
|
||||
// lookup is. Show the failure to the user instead of starting a second
|
||||
// login the server would reject against the per-owner cap.
|
||||
if (activeSessionQuery.isError) {
|
||||
if (activeSessionView.kind === "error") {
|
||||
autoStartedRef.current = true;
|
||||
setStartError(
|
||||
activeSessionQuery.error instanceof Error
|
||||
? activeSessionQuery.error.message
|
||||
: "Could not check for an active login.",
|
||||
);
|
||||
setStartError(describeError(activeSessionQuery.error).body);
|
||||
return;
|
||||
}
|
||||
if (!activeSessionQuery.isSuccess) return;
|
||||
@@ -2818,7 +2807,7 @@ function DisplayedCodeLoginPanel({
|
||||
}, [
|
||||
autoStart,
|
||||
activeSessionQuery.isSuccess,
|
||||
activeSessionQuery.isError,
|
||||
activeSessionView.kind,
|
||||
activeSessionQuery.data,
|
||||
activeSessionQuery.error,
|
||||
]);
|
||||
@@ -3188,10 +3177,6 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status === 404) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
});
|
||||
const storedToken = storedTokenQuery.data ?? null;
|
||||
|
||||
@@ -3218,7 +3203,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
setSessionId(session.sessionId);
|
||||
},
|
||||
onError: (error) => {
|
||||
setStartError(error instanceof Error ? error.message : "Could not start the login.");
|
||||
setStartError(describeError(error).body);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3245,7 +3230,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
clearActiveSession();
|
||||
return;
|
||||
}
|
||||
setStartError(error instanceof Error ? error.message : "Could not cancel the login.");
|
||||
setStartError(describeError(error).body);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3286,7 +3271,6 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
// Never answered from cache. This read decides whether to adopt a running
|
||||
// session or start a new one, and a cached "none" from an earlier mount is
|
||||
// exactly wrong after Back: the panel would read `isFetched` immediately,
|
||||
@@ -3295,6 +3279,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
});
|
||||
const activeSessionView = useQueryView(activeSessionQuery);
|
||||
|
||||
// While the panel releases a resumed session it cannot recover (see below),
|
||||
// it keeps showing the login as active rather than dropping back to idle, so
|
||||
@@ -3313,11 +3298,8 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
queryFn: () => agentsApi.getClaudeSetupTokenLoginStatus(companyId, sessionId!),
|
||||
enabled: pollingEnabled,
|
||||
// A status 404 is terminal. The server removes a cleaned-up session at once,
|
||||
// so a retry cannot recover it. Stop at once and fail loudly.
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status === 404) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
// so a retry cannot recover it. The shared retry policy never retries a
|
||||
// 4xx, so it stops at once and fails loudly; only transient errors are retried.
|
||||
refetchInterval: (query) => {
|
||||
if (timedOut || statusGone) return false;
|
||||
const status = query.state.data?.status;
|
||||
@@ -3409,7 +3391,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
mutationFn: (code: string) =>
|
||||
agentsApi.submitClaudeSetupTokenBrowserCode(companyId, sessionId!, code),
|
||||
onError: (error) => {
|
||||
setStartError(error instanceof Error ? error.message : "Could not submit the browser code.");
|
||||
setStartError(describeError(error).body);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3537,13 +3519,9 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
// A failed lookup is not proof that no session exists: only a successful
|
||||
// lookup is. Show the failure to the user instead of starting a second
|
||||
// login the server would reject against the per-owner cap.
|
||||
if (activeSessionQuery.isError) {
|
||||
if (activeSessionView.kind === "error") {
|
||||
autoStartedRef.current = true;
|
||||
setStartError(
|
||||
activeSessionQuery.error instanceof Error
|
||||
? activeSessionQuery.error.message
|
||||
: "Could not check for an active login.",
|
||||
);
|
||||
setStartError(describeError(activeSessionQuery.error).body);
|
||||
return;
|
||||
}
|
||||
if (!activeSessionQuery.isSuccess) return;
|
||||
@@ -3553,7 +3531,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
}, [
|
||||
autoStart,
|
||||
activeSessionQuery.isSuccess,
|
||||
activeSessionQuery.isError,
|
||||
activeSessionView.kind,
|
||||
activeSessionQuery.data,
|
||||
activeSessionQuery.error,
|
||||
]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeError } from "@/api/errors";
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { onChunkLoadReloadAbandoned, reloadForChunkLoadError } from "@/lib/chunk-load-recovery";
|
||||
import { captureBrowserException } from "@/lib/sentry";
|
||||
@@ -71,7 +72,7 @@ export class AppErrorBoundary extends Component<{ children: ReactNode }, AppErro
|
||||
</p>
|
||||
</div>
|
||||
<pre className="overflow-auto rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive whitespace-pre-wrap">
|
||||
{error.message}
|
||||
{describeError(error).body}{/* // query-error-ok: error boundary; crash diagnostics, not a query read */}
|
||||
</pre>
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query";
|
||||
import type { Issue, IssueBlockedInboxAttention } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/api/client";
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -400,4 +401,61 @@ describe("BlockedInboxView", () => {
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps loaded rows visible when a refetch fails transiently", async () => {
|
||||
mockIssuesApi.list.mockResolvedValue([
|
||||
makeIssue("issue-stalled-1", "TST-9", "Stalled cached row", attention()),
|
||||
]);
|
||||
|
||||
const { root, queryClient } = renderWithClient(
|
||||
<BlockedInboxView
|
||||
{...blockedViewProps}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
await waitFor(() => container.textContent?.includes("Stalled cached row") ?? false);
|
||||
|
||||
// A refetch during a deploy fails: the loaded rows stay, no error banner.
|
||||
mockIssuesApi.list.mockRejectedValueOnce(
|
||||
new ApiError("Paperclip is restarting.", 503, { error: "tenant_app_unavailable" }),
|
||||
);
|
||||
await act(async () => {
|
||||
await queryClient.invalidateQueries({ refetchType: "active" });
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Stalled cached row");
|
||||
expect(container.querySelector('[data-testid="blocked-inbox-error"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("tenant_app_unavailable");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows the loading skeleton, not the empty state, when the first load is paused on an outage", async () => {
|
||||
// Paused queries report isLoading=false, so gating on it alone would
|
||||
// render the empty state during an outage; the view state must win.
|
||||
mockIssuesApi.list.mockReturnValue(new Promise(() => {}));
|
||||
onlineManager.setOnline(false);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
||||
});
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BlockedInboxView {...blockedViewProps} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await waitFor(() => container.querySelector('[data-testid="blocked-inbox-loading"]') !== null);
|
||||
|
||||
expect(container.querySelector('[data-testid="blocked-inbox-loading"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="blocked-inbox-empty"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="blocked-inbox-error"]')).toBeNull();
|
||||
} finally {
|
||||
onlineManager.setOnline(true);
|
||||
act(() => root.unmount());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { describeError } from "../api/errors";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
type BlockedInboxSort,
|
||||
} from "../lib/blockedInbox";
|
||||
import { BlockedReasonChip } from "./BlockedReasonChip";
|
||||
import { useQueryView } from "./QueryView";
|
||||
import { IssueGroupHeader } from "./IssueGroupHeader";
|
||||
import { IssueRow, type IssueRowPresentation } from "./IssueRow";
|
||||
import { Identity } from "./Identity";
|
||||
@@ -69,13 +71,7 @@ export function BlockedInboxView({
|
||||
}: BlockedInboxViewProps) {
|
||||
const [collapsedVariants, setCollapsedVariants] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const {
|
||||
data: issues = [] as Issue[],
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
const blockedQuery = useQuery({
|
||||
queryKey: [...queryKeys.issues.listBlockedAttention(companyId), "live-descendant-summary", issueFilters.showAgentCreatedTasks],
|
||||
queryFn: () =>
|
||||
issuesApi.list(companyId, {
|
||||
@@ -89,6 +85,11 @@ export function BlockedInboxView({
|
||||
limit: BLOCKED_LIST_LIMIT,
|
||||
}),
|
||||
});
|
||||
const {
|
||||
data: issues = [] as Issue[],
|
||||
isLoading,
|
||||
} = blockedQuery;
|
||||
const blockedView = useQueryView(blockedQuery);
|
||||
|
||||
const allRows = useMemo(() => buildBlockedInboxRows(issues), [issues]);
|
||||
const filteredRows = useMemo(
|
||||
@@ -123,7 +124,9 @@ export function BlockedInboxView({
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
// A loaded list stays on screen through a failed refetch; an outage before
|
||||
// the first load shows the skeleton, and only a real failure shows copy.
|
||||
if (isLoading || blockedView.kind === "reconnecting") {
|
||||
return (
|
||||
<div data-testid="blocked-inbox-loading" className="space-y-3" aria-busy="true">
|
||||
{Array.from({ length: 3 }).map((_, groupIdx) => (
|
||||
@@ -150,9 +153,7 @@ export function BlockedInboxView({
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Couldn't load the Blocked tab.";
|
||||
if (blockedView.kind === "error") {
|
||||
return (
|
||||
<div
|
||||
data-testid="blocked-inbox-error"
|
||||
@@ -164,7 +165,7 @@ export function BlockedInboxView({
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium">Couldn't load the Blocked tab.</p>
|
||||
<p className="text-xs opacity-80">
|
||||
Other Inbox tabs still work. {message}
|
||||
Other Inbox tabs still work. {describeError(blockedView.error, { action: "load the Blocked tab" }).body}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -172,10 +173,10 @@ export function BlockedInboxView({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 border-amber-400/70 bg-white/40 text-amber-900 hover:bg-white/70 dark:bg-amber-500/20 dark:text-amber-100"
|
||||
onClick={() => void refetch()}
|
||||
disabled={isFetching}
|
||||
onClick={blockedView.retry}
|
||||
disabled={blockedView.isFetching}
|
||||
>
|
||||
{isFetching ? "Trying…" : "Try again"}
|
||||
{blockedView.isFetching ? "Trying…" : "Try again"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-3 p-6">
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{endpoint.error.message}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void endpoint.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
<QueryErrorState
|
||||
size="page"
|
||||
className="p-6"
|
||||
error={endpoint.error}
|
||||
action="load this endpoint"
|
||||
onRetry={endpointView.retry}
|
||||
retrying={endpointView.isFetching}
|
||||
/>
|
||||
);
|
||||
if (!endpoint.data) return null;
|
||||
return chatProviderVisible(endpoint.data.provider, enabled, githubEnabled) ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -72,7 +72,6 @@ export function CompanySettingsSidebar() {
|
||||
}
|
||||
},
|
||||
enabled: !!selectedCompanyId,
|
||||
retry: false,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const { data: plugins } = useQuery({
|
||||
|
||||
@@ -70,7 +70,6 @@ export function CompanySettingsSidebar() {
|
||||
}
|
||||
},
|
||||
enabled: !!selectedCompanyId,
|
||||
retry: false,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const { data: plugins } = useQuery({
|
||||
|
||||
@@ -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) && <details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<AgentRunnerSelect value={runner ?? (state.agent ? agentRunner(state.agent.adapterType) : "auto")}
|
||||
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { 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} />
|
||||
</details>}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 <p className="text-sm text-muted-foreground">Save the agent, then return here to pair your Dot.</p>;
|
||||
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 && <Button type="button" variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate()}>Revoke connection</Button>}
|
||||
</div>
|
||||
{b?.hasPendingChallenge && <p className="text-sm text-muted-foreground">Waiting for Dot to read and confirm the harmless mailbox challenge.</p>}
|
||||
{error && <p className="text-sm text-destructive" role="alert">{error instanceof Error ? error.message : "Connection failed"}</p>}
|
||||
{stateView.kind === "error" && (
|
||||
<QueryErrorState
|
||||
size="inline"
|
||||
error={stateView.error}
|
||||
action="load the Dot connection"
|
||||
onRetry={stateView.retry}
|
||||
retrying={stateView.isFetching}
|
||||
/>
|
||||
)}
|
||||
{mutationError && <p role="alert" className="text-sm text-destructive">{describeError(mutationError).body}</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -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(<QueryClientProvider client={client}><EmailConnectionAccess companyId="company" connectionId="connection" agents={[]} /></QueryClientProvider>));
|
||||
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"));
|
||||
}
|
||||
});
|
||||
@@ -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 <p className="text-sm text-muted-foreground">Loading access…</p>;
|
||||
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 (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Connection access could not be loaded.
|
||||
</p>
|
||||
<QueryErrorState
|
||||
error={failedView.error}
|
||||
action="load connection access"
|
||||
onRetry={failedView.retry}
|
||||
retrying={failedView.isFetching}
|
||||
/>
|
||||
);
|
||||
if (grants.isLoading || installs.isLoading || grantsView.kind === "reconnecting" || installsView.kind === "reconnecting")
|
||||
return <p className="text-sm text-muted-foreground">Loading access…</p>;
|
||||
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({
|
||||
</p>
|
||||
{save.error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{save.error.message}
|
||||
{describeError(save.error).body}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
{thread.error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{thread.error.message}
|
||||
</p>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<EmailMessageCard
|
||||
contextNotice
|
||||
key={m.id}
|
||||
issueId={issueId}
|
||||
message={m}
|
||||
publication={data?.publications.find(
|
||||
(p) => p.providerMessageId === m.providerMessageId,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{publications.map((p) => (
|
||||
<EmailDelivery
|
||||
key={p.id}
|
||||
companyId={companyId}
|
||||
publication={p}
|
||||
onResolved={() => {
|
||||
void cache.invalidateQueries({ queryKey: threadKey });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
{messages.map((m) => (
|
||||
<EmailMessageCard
|
||||
contextNotice
|
||||
key={m.id}
|
||||
issueId={issueId}
|
||||
message={m}
|
||||
publication={data?.publications.find(
|
||||
(p) => p.providerMessageId === m.providerMessageId,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{publications.map((p) => (
|
||||
<EmailDelivery
|
||||
key={p.id}
|
||||
companyId={companyId}
|
||||
publication={p}
|
||||
onResolved={() => {
|
||||
void cache.invalidateQueries({ queryKey: threadKey });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 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 (
|
||||
<QueryErrorState
|
||||
size="inline"
|
||||
error={threadView.error}
|
||||
action="load email activity"
|
||||
onRetry={threadView.retry}
|
||||
retrying={threadView.isFetching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function EmailDelivery({
|
||||
@@ -126,9 +140,9 @@ function EmailDelivery({
|
||||
Confirm not sent
|
||||
</Button>
|
||||
</div>
|
||||
{resolve.error && (
|
||||
{resolve.isError && ( // query-error-ok: mutation result
|
||||
<p role="alert" className="text-destructive">
|
||||
{resolve.error.message}
|
||||
{describeError(resolve.error).body}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 <TaskProjectAccessNotice
|
||||
access={presentation.projectAccess} issueId={issueId} agentName={agentName} projectName={projectName}
|
||||
canRetry={canRetry && presentation.canRetry} onRetry={onRetry} error={error} className={className} />;
|
||||
if (error && !optimistic && !takingLonger) return <div role="alert" className={cn("flex items-center justify-between gap-2 text-sm text-destructive", className)}><span>{error.message}</span>{canRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}</div>;
|
||||
if (error && !optimistic && !takingLonger) return <div role="alert" className={cn("flex items-center justify-between gap-2 text-sm text-destructive", className)}><span>{describeError(error).body}</span>{canRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}</div>;
|
||||
if (takingLonger) return <div role="status" className={cn("flex items-center justify-between gap-2 text-sm text-muted-foreground", className)}><span>Taking longer than usual…</span>{canRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}</div>;
|
||||
if (!optimistic && !presentation?.retryWait && presentation?.providerFailure && ["failed", "retry_scheduled", "waiting_for_user"].includes(presentation.state)) {
|
||||
return <ProviderFailureNotice failure={presentation.providerFailure} className={className}
|
||||
|
||||
@@ -3,10 +3,12 @@ import type { ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { describeError } from "../api/errors";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { formatDateTime, issueUrl } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { QueryErrorState, useQueryView } from "./QueryView";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -52,6 +54,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||
queryFn: () => 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({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{readinessQuery.isLoading ? (
|
||||
{(readinessView.kind === "loading" || readinessView.kind === "reconnecting") ? (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Checking whether this workspace is safe to close...
|
||||
</div>
|
||||
) : readinessQuery.error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
|
||||
</div>
|
||||
) : readinessView.kind === "error" ? (
|
||||
<QueryErrorState
|
||||
error={readinessView.error}
|
||||
action="inspect workspace close readiness"
|
||||
onRetry={readinessView.retry}
|
||||
retrying={readinessView.isFetching}
|
||||
/>
|
||||
) : readiness ? (
|
||||
<div className="space-y-4">
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${readinessTone(readiness.state)}`}>
|
||||
|
||||
@@ -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(
|
||||
<FileTree nodes={[]} selectedFile={null} expandedDirs={new Set()} onSelectFile={() => {}} 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();
|
||||
|
||||
@@ -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
|
||||
</Badge>
|
||||
<span className="min-w-0 text-destructive">{error.message}</span>
|
||||
<span className="min-w-0 text-destructive">{describeError(new Error(error.message)).body}</span>
|
||||
</div>
|
||||
{error.retry && (
|
||||
<Button type="button" size="xs" variant="outline" onClick={error.retry}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeError } from "@/api/errors";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { DotPairing } from "@paperclipai/shared";
|
||||
@@ -15,7 +16,7 @@ export function GrokBotConnection({ companyId, agentId }: { companyId?: string;
|
||||
const cache = useQueryClient();
|
||||
const key = ["grok-binding", companyId, agentId];
|
||||
const state = useQuery({ queryKey: key, enabled: !!companyId && !!agentId,
|
||||
queryFn: ({ signal }) => grokBotApi.connection(companyId!, agentId!, signal), retry: false, refetchInterval: 5000 });
|
||||
queryFn: ({ signal }) => grokBotApi.connection(companyId!, agentId!, signal), refetchInterval: 5000 });
|
||||
const [pairing, setPairing] = useState<DotPairing | null>(null);
|
||||
const [replace, setReplace] = useState(false);
|
||||
const [botName, setBotName] = useState("");
|
||||
@@ -56,7 +57,7 @@ export function GrokBotConnection({ companyId, agentId }: { companyId?: string;
|
||||
return <section aria-label="Grok Bot connection" className="space-y-4 text-sm">
|
||||
<p className="text-muted-foreground">Runs 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.</p>
|
||||
{state.isPending && <p role="status">Loading connection…</p>}
|
||||
{error && <p role="alert" className="text-destructive">{error.message}</p>}
|
||||
{error && <p role="alert" className="text-destructive">{describeError(error).body}</p>}
|
||||
{state.data && !state.data.enabled && <p className="text-muted-foreground">Enable Grok Bot, Assistant connections (MCP), and Paperclip Runner on an authenticated public HTTPS instance.</p>}
|
||||
{binding?.connected ? <>
|
||||
<p>Agent access connected. {routine?.verified && !recovering ? "Routine round trip verified." : "Assignments require a verified routine round trip."}</p>
|
||||
|
||||
@@ -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;
|
||||
return <div className="space-y-2 text-sm">
|
||||
{state.error && <p role="alert" className="text-destructive">Connection status could not be refreshed. {state.error.message}</p>}
|
||||
{state.error && <p role="alert" className="text-destructive">Connection status could not be refreshed. {describeError(state.error).body}</p>}
|
||||
<p>{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"}</p>
|
||||
{data?.currentTask && <Link className="underline" to={`/issues/${data.currentTask.id}`}>{data.currentTask.identifier}: {data.currentTask.title}</Link>}
|
||||
<p className="text-muted-foreground">Queued tasks: {data?.queuedTaskCount ?? "Checking…"}</p>
|
||||
|
||||
@@ -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 (
|
||||
<div className="text-sm text-destructive">
|
||||
{policyQuery.error instanceof Error ? policyQuery.error.message : "Failed to load inbox agent policy."}
|
||||
</div>
|
||||
<QueryErrorState
|
||||
error={policyView.error}
|
||||
action="load the inbox agent policy"
|
||||
onRetry={policyView.retry}
|
||||
retrying={policyView.isFetching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (policyQuery.isLoading || !draft) {
|
||||
if (policyQuery.isLoading || policyView.kind === "reconnecting" || !draft) {
|
||||
return <div className="text-sm text-muted-foreground">Loading inbox agent policy…</div>;
|
||||
}
|
||||
|
||||
@@ -166,8 +174,8 @@ export function InboxAgentPolicyControl({ companyId }: { companyId: string | nul
|
||||
) : null}
|
||||
|
||||
{updateMutation.error ? (
|
||||
<div className="max-w-2xl rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{updateMutation.error instanceof Error ? updateMutation.error.message : "Failed to save inbox agent policy."}
|
||||
<div role="alert" className="max-w-2xl rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{describeError(updateMutation.error, { action: "save the inbox agent policy" }).body}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useState } from "react";
|
||||
import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AgentInstructionsFileDetail } from "@paperclipai/shared";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { describeError } from "../api/errors";
|
||||
import { Button } from "./ui/button";
|
||||
import { QueryErrorState, useQueryView } from "./QueryView";
|
||||
|
||||
export function InstructionHistory({
|
||||
agentId,
|
||||
@@ -53,7 +55,11 @@ export function InstructionHistory({
|
||||
onRestored(file);
|
||||
},
|
||||
});
|
||||
const error = history.error ?? diff.error ?? restore.error;
|
||||
// Read the two reads through the shared view so cached history stays visible
|
||||
// while a transient refetch fails;a no-data failure shows copy with Retry.
|
||||
|
||||
const historyView = useQueryView(history);
|
||||
const diffView = useQueryView(diff);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
@@ -67,13 +73,29 @@ export function InstructionHistory({
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="space-y-3">
|
||||
{history.isLoading && (
|
||||
<p className="text-sm text-muted-foreground">Loading revisions…</p>
|
||||
{historyView.kind === "error" && (
|
||||
<QueryErrorState
|
||||
size="panel"
|
||||
error={historyView.error}
|
||||
action="load revision history"
|
||||
onRetry={historyView.retry}
|
||||
retrying={historyView.isFetching}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error.message}
|
||||
</p>
|
||||
{diffView.kind === "error" && (
|
||||
<QueryErrorState
|
||||
size="panel"
|
||||
error={diffView.error}
|
||||
action="load the revision diff"
|
||||
onRetry={diffView.retry}
|
||||
retrying={diffView.isFetching}
|
||||
/>
|
||||
)}
|
||||
{restore.isError && ( // query-error-ok: mutation result
|
||||
<p role="alert" className="text-sm text-destructive">{describeError(restore.error).body}</p>
|
||||
)}
|
||||
{(historyView.kind === "loading" || historyView.kind === "reconnecting") && (
|
||||
<p role="status" className="text-sm text-muted-foreground">Loading revisions…</p>
|
||||
)}
|
||||
{history.data?.pages
|
||||
.flatMap((page) => page.revisions)
|
||||
|
||||
@@ -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({
|
||||
</button>,
|
||||
publicBlockedReason,
|
||||
)}
|
||||
{canManage && constraintsQuery.isError ? (
|
||||
{canManage && constraintsView.kind === "error" ? (
|
||||
<button type="button" className={MENU_ITEM_CLASS} onClick={() => {
|
||||
void constraintsQuery.refetch();
|
||||
constraintsView.retry();
|
||||
}}>Retry access check</button>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -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])),
|
||||
|
||||
@@ -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.
|
||||
</div>
|
||||
) : null}
|
||||
{isError ? (
|
||||
{isError ? ( // query-error-ok: mutation result
|
||||
|
||||
<RetryErrorBand
|
||||
error={retryNow.lastError}
|
||||
onRetry={() => {
|
||||
@@ -192,7 +194,7 @@ export function RetryErrorBand({ error, onRetry, className }: RetryErrorBandProp
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">Couldn't retry now</div>
|
||||
<div className="mt-0.5 text-muted-foreground">{error.message}</div>
|
||||
<div className="mt-0.5 text-muted-foreground">{describeError({ status: error.status, body: { message: error.message } }).body}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from "./IssuesList";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks";
|
||||
import { ApiError } from "../api/client";
|
||||
import { ConnectivityProvider, createConnectivityStore } from "../lib/connectivity";
|
||||
import { taskCollectionPreferencesStorageKey } from "../lib/task-collection-preferences";
|
||||
import { readIssueSortParams } from "../lib/issue-sort";
|
||||
|
||||
@@ -731,10 +733,25 @@ describe("IssuesList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a retryable external-status filter error instead of a false empty task list", async () => {
|
||||
it.each([false, true])("shows an owner-selected initial 429 with retry instead of an empty list (streamlined=%s)", async (streamlined) => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: streamlined });
|
||||
const container = document.createElement("div"); document.body.append(container);
|
||||
const retry = vi.fn();
|
||||
const { root, queryClient } = renderWithQueryClient(<IssuesList issues={[]} error={new ApiError("rate_limited", 429, null)} onRetry={retry} onUpdateIssue={() => {}} viewStateKey="chosen-read-error" />, container);
|
||||
try {
|
||||
await waitForAssertion(() => expect(container.querySelector('[data-query-view="error"]')).not.toBeNull());
|
||||
expect(container.textContent).not.toContain("No tasks match");
|
||||
const button = [...container.querySelectorAll("button")].find(button => button.textContent?.trim() === "Retry");
|
||||
expect(button).toBeDefined();
|
||||
await act(() => button!.click());
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
} finally { await act(() => root.unmount()); queryClient.clear(); container.remove(); }
|
||||
});
|
||||
|
||||
it.each([null, 429, 503])("offers retry after a settled external-status failure while reachable (%s)", async status => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: true, enableExternalObjects: true });
|
||||
localStorage.setItem("paperclip:test-issues:company-1", JSON.stringify({ externalObjectStatuses: ["failed"] }));
|
||||
mockExternalObjectsApi.getIssueSummaries.mockRejectedValue(new Error("Status lookup unavailable"));
|
||||
mockExternalObjectsApi.getIssueSummaries.mockRejectedValue(status === null ? new Error("Status lookup unavailable") : new ApiError("temporarily_unavailable", status, null));
|
||||
const { root, queryClient } = renderWithQueryClient(<IssuesList
|
||||
issues={[createIssue({ id: "issue-failed", title: "Matching task after retry" })]}
|
||||
agents={[]} projects={[]} viewStateKey="paperclip:test-issues" rowPresentation="task"
|
||||
@@ -742,6 +759,7 @@ describe("IssuesList", () => {
|
||||
/>, container);
|
||||
await waitForAssertion(() => expect(container.textContent).toContain("Unable to filter tasks by external status"));
|
||||
expect(container.textContent).not.toContain("No tasks match");
|
||||
expect(container.textContent).not.toContain("temporarily_unavailable");
|
||||
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
|
||||
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: {
|
||||
"issue-failed": { total: 1, byStatusCategory: { failed: 1 }, objects: [] },
|
||||
@@ -754,6 +772,87 @@ describe("IssuesList", () => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("retries feature discovery before applying a saved external-status filter", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockRejectedValue(new ApiError("rate_limited", 429, null));
|
||||
localStorage.setItem("paperclip:test-issues:company-1", JSON.stringify({ externalObjectStatuses: ["failed"] }));
|
||||
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: { "issue-failed": { total: 1, byStatusCategory: { failed: 1 }, objects: [] } } });
|
||||
const { root, queryClient } = renderWithQueryClient(<IssuesList
|
||||
issues={[createIssue({ id: "issue-failed", title: "Recovered filtered task" })]} agents={[]} projects={[]} viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>, container);
|
||||
try {
|
||||
const retry = () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Retry external statuses");
|
||||
await waitForAssertion(() => expect(retry()).toBeDefined());
|
||||
expect(container.textContent).not.toContain("No tasks match");
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: true, enableExternalObjects: true });
|
||||
await act(() => retry()!.click());
|
||||
await waitForAssertion(() => expect(container.textContent).toContain("Recovered filtered task"));
|
||||
} finally { act(() => root.unmount()); queryClient.clear(); }
|
||||
});
|
||||
|
||||
it("keeps an initial external-status outage in a quiet loading state", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: true, enableExternalObjects: true });
|
||||
localStorage.setItem("paperclip:test-issues:company-1", JSON.stringify({ externalObjectStatuses: ["failed"] }));
|
||||
mockExternalObjectsApi.getIssueSummaries.mockRejectedValue(new ApiError("tenant_app_unavailable", 503, null));
|
||||
const store = createConnectivityStore({ browserOnline: false });
|
||||
const { root, queryClient } = renderWithQueryClient(<ConnectivityProvider store={store}><IssuesList
|
||||
issues={[createIssue({ id: "issue-failed" })]} agents={[]} projects={[]} viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/></ConnectivityProvider>, container);
|
||||
try {
|
||||
await waitForAssertion(() => expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledOnce());
|
||||
await waitForAssertion(() => expect(container.querySelector('[aria-label="Loading tasks"]')).not.toBeNull());
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("No tasks match");
|
||||
expect(container.textContent).not.toContain("tenant_app_unavailable");
|
||||
} finally { act(() => root.unmount()); queryClient.clear(); store.dispose(); }
|
||||
});
|
||||
|
||||
it.each([429, 503])("retains filtered rows after a cached external-status %s", async status => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: true, enableExternalObjects: true });
|
||||
localStorage.setItem("paperclip:test-issues:company-1", JSON.stringify({ externalObjectStatuses: ["failed"] }));
|
||||
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: { "issue-failed": { total: 1, byStatusCategory: { failed: 1 }, objects: [] } } });
|
||||
const { root, queryClient } = renderWithQueryClient(<IssuesList
|
||||
issues={[createIssue({ id: "issue-failed", title: "Retained filtered task" })]} agents={[]} projects={[]} viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>, container);
|
||||
try {
|
||||
await waitForAssertion(() => expect(container.textContent).toContain("Retained filtered task"));
|
||||
const row = container.querySelector('[data-issue-row-id="issue-failed"]');
|
||||
expect(row).not.toBeNull();
|
||||
mockExternalObjectsApi.getIssueSummaries.mockRejectedValue(new ApiError("temporarily_unavailable", status, null));
|
||||
await queryClient.invalidateQueries({ queryKey: ["external-objects"] });
|
||||
await waitForAssertion(() => expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledTimes(2));
|
||||
expect(container.textContent).toContain("Retained filtered task");
|
||||
expect(container.querySelector('[data-issue-row-id="issue-failed"]')).toBe(row);
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
} finally { act(() => root.unmount()); queryClient.clear(); }
|
||||
});
|
||||
|
||||
it("offers hierarchy retry after a reachable first-read 429 while keeping matching tasks", async () => {
|
||||
const parent = createIssue({ id: "human-root", title: "Readable root task", createdByUserId: "person" });
|
||||
let failed = true;
|
||||
mockIssuesApi.list.mockImplementation(async (_company, filters) => {
|
||||
if (filters.parentIds && failed) throw new ApiError("rate_limited", 429, null);
|
||||
return [];
|
||||
});
|
||||
const { root, queryClient } = renderWithQueryClient(<IssuesList
|
||||
issues={[parent]} agents={[]} projects={[]} viewStateKey="paperclip:test-tree"
|
||||
enableAgentCreatedVisibilityFilter onUpdateIssue={() => undefined}
|
||||
/>, container);
|
||||
try {
|
||||
const retry = () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Retry hierarchy");
|
||||
await waitForAssertion(() => expect(retry()).toBeDefined());
|
||||
expect(container.textContent).toContain(parent.title);
|
||||
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("rate_limited");
|
||||
failed = false;
|
||||
await act(() => retry()!.click());
|
||||
await waitForAssertion(() => expect(retry()).toBeUndefined());
|
||||
expect(container.textContent).toContain(parent.title);
|
||||
} finally { act(() => root.unmount()); queryClient.clear(); }
|
||||
});
|
||||
|
||||
it("renders server search results instead of filtering the full issue list locally", async () => {
|
||||
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
|
||||
const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueryErrorState } from "./QueryView";
|
||||
import { ISSUE_SORT_FIELDS, sortIssues, type IssueSort, type IssueSortField } from "../lib/issue-sort";
|
||||
import { useIssueRowDetails } from "../hooks/useIssueRowDetails";
|
||||
import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode";
|
||||
@@ -11,6 +12,7 @@ import type { ReactNode } from "react";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useVisibilityRefetchInterval } from "@/lib/polling";
|
||||
import { accessApi } from "../api/access";
|
||||
import { describeError } from "../api/errors";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { Link, useNavigate } from "@/lib/router";
|
||||
@@ -452,6 +454,8 @@ interface IssuesListProps {
|
||||
issues: Issue[];
|
||||
isLoading?: boolean;
|
||||
error?: Error | null;
|
||||
onRetry?: () => void;
|
||||
retrying?: boolean;
|
||||
agents?: Agent[];
|
||||
projects?: ProjectOption[];
|
||||
liveIssueIds?: Set<string>;
|
||||
@@ -708,6 +712,8 @@ function StreamlinedIssuesList({
|
||||
issues,
|
||||
isLoading,
|
||||
error,
|
||||
onRetry,
|
||||
retrying,
|
||||
agents,
|
||||
projects,
|
||||
liveIssueIds,
|
||||
@@ -784,7 +790,6 @@ function StreamlinedIssuesList({
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const experimentalSettingsLoaded = experimentalSettings !== undefined;
|
||||
@@ -2082,10 +2087,10 @@ function StreamlinedIssuesList({
|
||||
|
||||
{collectionLoading && !externalObjectFilterError && <PageSkeleton variant="issues-list" collectionHeading={viewState.groupBy !== "none" ? "group"
|
||||
: viewState.showDateGroupSeparators && issueDateSeparatorField(viewState) && rowPresentation === "task" ? "date" : null} />}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
{error ? <QueryErrorState error={error} action="load tasks" onRetry={onRetry} retrying={retrying} /> : null}
|
||||
{externalObjectFilterError && (
|
||||
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
|
||||
<p>Unable to filter tasks by external status: {externalObjectFilterError.message}</p>
|
||||
<p>Unable to filter tasks by external status: {describeError(externalObjectFilterError).body}</p>
|
||||
<Button variant="outline" size="sm" onClick={retryExternalObjectSummaries}>Retry external statuses</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -2099,8 +2104,8 @@ function StreamlinedIssuesList({
|
||||
Some board columns are showing up to {ISSUE_BOARD_COLUMN_RESULT_LIMIT} tasks. Refine filters or search to reveal the rest.
|
||||
</p>
|
||||
)}
|
||||
{hierarchy.error ? <p className="text-sm text-destructive">Failed to load task hierarchy: {hierarchy.error.message}</p> : null}
|
||||
{!collectionLoading && !externalObjectFilterError && visibleListRowCount === 0 && viewState.viewMode === "list" && (
|
||||
{hierarchy.error ? <div role="alert" className="flex items-center gap-2 text-sm text-destructive"><p>{describeError(hierarchy.error, { action: "load task hierarchy" }).body}</p><Button variant="outline" size="sm" onClick={hierarchy.refetch}>Retry hierarchy</Button></div> : null}
|
||||
{!collectionLoading && !error && !externalObjectFilterError && visibleListRowCount === 0 && viewState.viewMode === "list" && (
|
||||
<EmptyState
|
||||
icon={CircleDot}
|
||||
message="No tasks match the current filters or search."
|
||||
@@ -2109,7 +2114,7 @@ function StreamlinedIssuesList({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!collectionLoading && !externalObjectFilterError && (viewState.viewMode === "board" ? (
|
||||
{!collectionLoading && !error && !externalObjectFilterError && (viewState.viewMode === "board" ? (
|
||||
<KanbanBoard
|
||||
issues={filtered}
|
||||
agents={agents}
|
||||
|
||||
@@ -246,7 +246,6 @@ export function Layout() {
|
||||
const { data: health } = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
retry: false,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as
|
||||
{ devServer?: { enabled?: boolean } } | undefined;
|
||||
|
||||
@@ -246,7 +246,6 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
const { data: health } = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
retry: false,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined;
|
||||
return data?.devServer?.enabled ? 2000 : false;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueryErrorState } from "./QueryView";
|
||||
import { ISSUE_SORT_FIELDS, sortIssues, type IssueSort, type IssueSortField } from "../lib/issue-sort";
|
||||
import { saveTaskCollectionPreferences } from "../lib/task-collection-preferences";
|
||||
import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode";
|
||||
@@ -10,6 +11,7 @@ import type { ReactNode } from "react";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useVisibilityRefetchInterval } from "@/lib/polling";
|
||||
import { accessApi } from "../api/access";
|
||||
import { describeError } from "../api/errors";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { Link, useNavigate } from "@/lib/router";
|
||||
@@ -438,6 +440,8 @@ interface IssuesListProps {
|
||||
issues: Issue[];
|
||||
isLoading?: boolean;
|
||||
error?: Error | null;
|
||||
onRetry?: () => void;
|
||||
retrying?: boolean;
|
||||
agents?: Agent[];
|
||||
projects?: ProjectOption[];
|
||||
liveIssueIds?: Set<string>;
|
||||
@@ -660,6 +664,8 @@ export function IssuesList({
|
||||
issues,
|
||||
isLoading,
|
||||
error,
|
||||
onRetry,
|
||||
retrying,
|
||||
agents,
|
||||
projects,
|
||||
liveIssueIds,
|
||||
@@ -732,7 +738,6 @@ export function IssuesList({
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const experimentalSettingsLoaded = experimentalSettings !== undefined;
|
||||
@@ -1943,7 +1948,7 @@ export function IssuesList({
|
||||
</div>
|
||||
|
||||
{(isLoading || externalObjectFilterLoading) && <PageSkeleton variant="issues-list" />}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
{error ? <QueryErrorState error={error} action="load tasks" onRetry={onRetry} retrying={retrying} /> : null}
|
||||
{!searchWithinLoadedIssues && normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
{hierarchy.error ? <p className="text-sm text-destructive">Failed to load task hierarchy: {hierarchy.error.message}</p> : null}
|
||||
{!isLoading && !externalObjectFilterLoading && visibleListRowCount === 0 && viewState.viewMode === "list" && (
|
||||
{hierarchy.error ? <p className="text-sm text-destructive">Failed to load task hierarchy: {describeError(hierarchy.error).body}</p> : null}
|
||||
{!isLoading && !error && !externalObjectFilterLoading && visibleListRowCount === 0 && viewState.viewMode === "list" && (
|
||||
<EmptyState
|
||||
icon={CircleDot}
|
||||
message="No tasks match the current filters or search."
|
||||
@@ -1964,7 +1969,7 @@ export function IssuesList({
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewState.viewMode === "board" ? (
|
||||
{!error && (viewState.viewMode === "board" ? (
|
||||
<KanbanBoard
|
||||
issues={filtered}
|
||||
agents={agents}
|
||||
@@ -2410,7 +2415,7 @@ export function IssuesList({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ProjectWorkspace } from "@paperclipai/shared";
|
||||
import { Link } from "lucide-react";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { describeError } from "@/api/errors";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
@@ -32,7 +33,7 @@ export function LegacyProjectRepository({ workspace, projectRef }: { workspace:
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setDraft(workspace.repoUrl ?? "")}>Edit</Button>
|
||||
</div> : <form className="flex flex-col gap-2" onSubmit={(event) => { event.preventDefault(); if (!save.isPending) save.mutate(); }}>
|
||||
<Input aria-label="Existing repo URL" type="url" value={draft} disabled={save.isPending} onChange={(event) => setDraft(event.target.value)} />
|
||||
{save.isError && <p role="alert" className="text-sm text-destructive">{save.error.message}</p>}
|
||||
{save.status === "error" && <p role="alert" className="text-sm text-destructive">{describeError(save.error).body}</p>}
|
||||
<div className="flex justify-end gap-2"><Button type="button" variant="ghost" disabled={save.isPending} onClick={() => setDraft(null)}>Cancel</Button><Button type="submit" disabled={save.isPending}>Save URL</Button></div>
|
||||
</form>}
|
||||
</div>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeError } from "@/api/errors";
|
||||
import { Bot } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -143,10 +144,10 @@ export function MuseRunnerConnection({ companyId, agentId }: { companyId?: strin
|
||||
if (!agentId) return <p className="text-sm text-muted-foreground">Save the agent, then connect your personal Muse here.</p>;
|
||||
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) return <div className="space-y-3"><p role="alert" className="text-sm text-destructive">Unable to confirm your account. Refresh to inspect this Muse connection.</p><Button type="button" variant="outline" onClick={() => { void cache.invalidateQueries({ queryKey: queryKeys.auth.session }); }}>Refresh</Button></div>;
|
||||
if (!muse.state.data) return <div className="space-y-3"><p role="status" className="text-sm text-muted-foreground">{muse.state.error ? "Unable to load the Muse connection." : "Loading Muse connection…"}</p>{error && <p role="alert" className="text-sm text-destructive">{error.message}</p>}<Button type="button" variant="outline" onClick={() => { void muse.state.refetch(); }}>Refresh</Button></div>;
|
||||
if (!muse.state.data) return <div className="space-y-3"><p role="status" className="text-sm text-muted-foreground">{muse.state.error ? "Unable to load the Muse connection." : "Loading Muse connection…"}</p>{error && <p role="alert" className="text-sm text-destructive">{describeError(error).body}</p>}<Button type="button" variant="outline" onClick={() => { void muse.state.refetch(); }}>Refresh</Button></div>;
|
||||
return <MuseConnectionDetails key={muse.identity.userId} connection={muse.state.data} pairing={muse.pairing} offline={muse.state.isError}
|
||||
busy={muse.pair.isPending || muse.verify.isPending || muse.revoke.isPending || muse.attest.isPending || muse.attestDetector.isPending || pause.isPending}
|
||||
error={error?.message} onTest={() => { if (muse.binding) muse.verify.mutate(muse.binding); }} onRepair={muse.repair}
|
||||
error={error ? describeError(error).body : undefined} onTest={() => { if (muse.binding) muse.verify.mutate(muse.binding); }} onRepair={muse.repair}
|
||||
onPause={() => pause.mutate()} onDisconnect={() => { if (muse.binding) muse.revoke.mutate(muse.binding); }}
|
||||
onRefresh={() => { void muse.state.refetch(); }} onAttest={input => muse.attest.mutate(input)} onAttestDetectorCleanup={input => muse.attestDetector.mutate(input)} />;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { X } from "lucide-react";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { describeError } from "../api/errors";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { trackRecentProject } from "../lib/recent-projects";
|
||||
import { ProjectTile } from "./ProjectTile";
|
||||
@@ -60,7 +61,7 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl
|
||||
<div role="region" aria-label="Source repositories" tabIndex={0} className="min-h-0 overflow-y-auto overscroll-contain px-5 pb-1 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring">
|
||||
<ProjectRepositoryInput companyId={companyId} selected={repos} onChange={setRepos} onConnect={() => setConnecting(true)} disabled={create.isPending} />
|
||||
</div>
|
||||
{create.isError && <p role="alert" className="px-5 pt-3 text-sm text-destructive">{create.error.message}</p>}
|
||||
{create.status === "error" && <p role="alert" className="px-5 pt-3 text-sm text-destructive">{describeError(create.error).body}</p>}
|
||||
<div className="flex shrink-0 justify-end gap-2 px-5 py-5">
|
||||
<Button type="button" variant="ghost" disabled={create.isPending} onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={!name.trim() || create.isPending}>{create.isPending ? "Creating…" : "Create project"}</Button>
|
||||
|
||||
@@ -3451,7 +3451,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
await pickSource(/OpenAI/);
|
||||
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Service unavailable");
|
||||
expect(document.body.textContent).toContain("Paperclip is temporarily unavailable");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
@@ -3467,7 +3467,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
await pickSource(/Claude/);
|
||||
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Service unavailable");
|
||||
expect(document.body.textContent).toContain("Paperclip is temporarily unavailable");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useQueryView } from "@/components/QueryView";
|
||||
import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared";
|
||||
import { needsClaudeSetupQualification, qualifyClaudeSetup } from "../lib/qualify-claude-setup";
|
||||
import { agentHarnessType } from "@paperclipai/shared";
|
||||
import { adaptersApi, type AdapterInfo } from "../api/adapters";
|
||||
import { AgentRunnerSelect } from "./AgentRunnerSelect";
|
||||
import { healthApi } from "@/api/health";
|
||||
import { describeError } from "@/api/errors";
|
||||
import { LocalProviderLoginInstructions } from "./AdapterLoginChrome";
|
||||
import { useLocalAiLogin } from "./ai-connections/useLocalAiLogin";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
@@ -1023,6 +1025,7 @@ function OnboardingWizardInner({
|
||||
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4
|
||||
&& loginEnvironmentSettingsReady,
|
||||
});
|
||||
const runnerDiscoveryView = useQueryView(runnerDiscovery);
|
||||
const {
|
||||
data: adapterModels,
|
||||
error: adapterModelsError,
|
||||
@@ -1032,8 +1035,7 @@ function OnboardingWizardInner({
|
||||
queryKey: createdCompanyId ? queryKeys.agents.adapterModels(createdCompanyId, adapterType, null) : ["agents", "none", "adapter-models", adapterType, null],
|
||||
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType, { environmentId: null }),
|
||||
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && ONBOARDING_ADAPTER_TYPES.has(adapterType),
|
||||
retry: false,
|
||||
});
|
||||
});
|
||||
// Automatic selection uses native Claude/Codex on qualified execution targets and
|
||||
// silently falls back to legacy on Windows, keeping the same onboarding UI.
|
||||
// When Windows native execution is qualified, enable it in
|
||||
@@ -1047,7 +1049,7 @@ function OnboardingWizardInner({
|
||||
&& runnerDiscovery.isSuccess && !runnerDiscovery.isFetching && resolvedRunner !== undefined);
|
||||
const runnerDiscoveryError = needsRunnerDiscovery && (loginEnvironmentsFailed || loginSettingsFailed || loginPolicyFailed)
|
||||
? "Could not load execution environment settings. Retry setup."
|
||||
: needsRunnerDiscovery && runnerDiscovery.isError
|
||||
: needsRunnerDiscovery && (runnerDiscoveryView.kind === "error")
|
||||
? "Could not load runner availability. Retry setup."
|
||||
: needsRunnerDiscovery && runnerDiscovery.isSuccess && resolvedRunner === undefined
|
||||
? "Runner availability is missing for this harness. Retry setup."
|
||||
@@ -2004,7 +2006,7 @@ function OnboardingWizardInner({
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? `Could not store the API key: ${err.message}`
|
||||
? `Could not store the API key: ${describeError(err).body}`
|
||||
: "Could not store the API key.",
|
||||
);
|
||||
return false;
|
||||
@@ -3248,7 +3250,7 @@ function OnboardingWizardInner({
|
||||
{sourceSelected && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && <details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<AgentRunnerSelect value={runner} disabled={loading || adapterEnvLoading}
|
||||
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error?.message} onRetry={() => { void runnerDiscovery.refetch(); }}
|
||||
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error ? describeError(runnerDiscovery.error).body : undefined} onRetry={() => { void runnerDiscovery.refetch(); }}
|
||||
defaultRunner={runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner}
|
||||
supportedRunners={runnerDiscovery.data?.find(item => item.type === adapterType)?.supportedRunners}
|
||||
onChange={setRunner} />
|
||||
|
||||
@@ -35,7 +35,7 @@ export function PluginOrganizationSwitcher({ children, open: controlledOpen, onO
|
||||
const selectedCompany = companyList.data?.companies.find(company => company.id === selectedCompanyId) ?? null;
|
||||
const contextSettled = settled && companyList.isSuccess && !companyList.data?.unauthorized
|
||||
&& (selectedCompanyId === null || selectedCompany !== null);
|
||||
const contextLoading = !contextSettled && !failed && !companyList.isError && !companyList.data?.unauthorized;
|
||||
const contextLoading = !contextSettled && !failed && !companyList.isError && !companyList.data?.unauthorized; // query-error-ok: loading gate;on error the built-in fallback renders
|
||||
return (
|
||||
<OrganizationSwitcher key={JSON.stringify([userId, selectedCompanyId])}
|
||||
settled={contextSettled} loading={contextLoading} companyId={selectedCompanyId}
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { Project } from "@paperclipai/shared";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { describeError } from "@/api/errors";
|
||||
import { useQueryView } from "@/components/QueryView";
|
||||
import { useToastActions } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -36,6 +38,13 @@ export function ProjectAccessMembers({ project, canManage }: { project: Project;
|
||||
queryFn: () => agentsApi.list(project.companyId),
|
||||
enabled: open,
|
||||
});
|
||||
// Read all three through the shared view: cached data stays usable during a
|
||||
// transient refetch failure,and a no-data failure drops into readable copy.
|
||||
|
||||
const membersView = useQueryView(membersQuery);
|
||||
const directoryView = useQueryView(directoryQuery);
|
||||
const agentsView = useQueryView(agentsQuery);
|
||||
const membersWaiting = ["loading", "reconnecting"].includes(membersView.kind);
|
||||
const activeKeys = useMemo(
|
||||
() => new Set((membersQuery.data ?? []).map((member) => `${member.subjectType}:${member.subjectId}`)),
|
||||
[membersQuery.data],
|
||||
@@ -74,7 +83,7 @@ export function ProjectAccessMembers({ project, canManage }: { project: Project;
|
||||
queryClient.invalidateQueries({ queryKey: ["issues"] });
|
||||
pushToast({ title: "Project access added", tone: "success" });
|
||||
},
|
||||
onError: (error) => pushToast({ title: "Couldn't add project access", body: (error as Error).message, tone: "error" }),
|
||||
onError: (error) => pushToast({ title: "Couldn't add project access", body: describeError(error).body, tone: "error" }),
|
||||
});
|
||||
const removeMember = useMutation({
|
||||
mutationFn: (memberId: string) => projectsApi.removeAccessMember(project.id, memberId, project.companyId),
|
||||
@@ -84,7 +93,7 @@ export function ProjectAccessMembers({ project, canManage }: { project: Project;
|
||||
queryClient.invalidateQueries({ queryKey: ["issues"] });
|
||||
pushToast({ title: "Project access removed", tone: "success" });
|
||||
},
|
||||
onError: (error) => pushToast({ title: "Couldn't remove project access", body: (error as Error).message, tone: "error" }),
|
||||
onError: (error) => pushToast({ title: "Couldn't remove project access", body: describeError(error).body, tone: "error" }),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -101,7 +110,7 @@ export function ProjectAccessMembers({ project, canManage }: { project: Project;
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
{directoryQuery.isError || agentsQuery.isError ? (
|
||||
{directoryView.kind === "error" || agentsView.kind === "error" ? (
|
||||
<p className="text-sm text-destructive">Couldn't load the company access directory.</p>
|
||||
) : null}
|
||||
<div className="flex items-end gap-2">
|
||||
@@ -114,7 +123,7 @@ export function ProjectAccessMembers({ project, canManage }: { project: Project;
|
||||
placeholder="Add a person or agent"
|
||||
searchPlaceholder="Search people and agents"
|
||||
emptyMessage="No more company members to add"
|
||||
loading={directoryQuery.isLoading || agentsQuery.isLoading}
|
||||
loading={[directoryView.kind, agentsView.kind].some(kind => ["loading", "reconnecting"].includes(kind))}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button size="sm" disabled={!canManage || !selection || addMember.isPending} onClick={() => addMember.mutate()}>
|
||||
@@ -152,8 +161,8 @@ export function ProjectAccessMembers({ project, canManage }: { project: Project;
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{membersQuery.isLoading ? <p className="py-3 text-sm text-muted-foreground">Loading access…</p> : null}
|
||||
{membersQuery.isError ? (
|
||||
{membersWaiting ? <p className="py-3 text-sm text-muted-foreground">Loading access…</p> : null}
|
||||
{membersView.kind === "error" ? (
|
||||
<p className="py-3 text-sm text-destructive">Couldn't load project access members.</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeError } from "@/api/errors";
|
||||
import { ShieldCheck, ShieldX, ArrowRight } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -30,7 +31,7 @@ export function ProjectAccessNotice({ agentName = "This agent", projectName, rea
|
||||
{ready ? canRetry && <Button size="sm" onClick={onRetry}>Retry task<ArrowRight className="size-4" aria-hidden /></Button>
|
||||
: <Button size="sm" variant="outline" onClick={onManage}>{canManage ? "Manage project access" : "View agent access"}<ArrowRight className="size-4" aria-hidden /></Button>}
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error.message}</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{describeError(error).body}</p>}
|
||||
{!ready && <details className="pl-8 text-xs text-muted-foreground"><summary className="cursor-pointer">Run details</summary>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2"><dt>Stopped during</dt><dd>Workspace setup</dd><dt>Reason</dt><dd>Project outside allowed scope</dd><dt>Provider started</dt><dd>No</dd></dl>
|
||||
</details>}
|
||||
|
||||
@@ -239,7 +239,6 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
const environmentsEnabled = experimentalSettings?.enableEnvironments === true;
|
||||
const { data: availableSecrets = [] } = useQuery({
|
||||
@@ -253,7 +252,6 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
|
||||
: ["user-secret-definitions", "none"],
|
||||
queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
retry: false,
|
||||
});
|
||||
const createSecret = useMutation({
|
||||
mutationFn: (input: { name: string; value: string }) => {
|
||||
@@ -721,13 +719,16 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
|
||||
{workspaceError && (
|
||||
<p className="text-xs text-destructive">{workspaceError}</p>
|
||||
)}
|
||||
{createWorkspace.isError && (
|
||||
{createWorkspace.isError && ( // query-error-ok: mutation result
|
||||
|
||||
<p className="text-xs text-destructive">Failed to save workspace.</p>
|
||||
)}
|
||||
{removeWorkspace.isError && (
|
||||
{removeWorkspace.isError && ( // query-error-ok: mutation result
|
||||
|
||||
<p className="text-xs text-destructive">Failed to delete workspace.</p>
|
||||
)}
|
||||
{updateWorkspace.isError && (
|
||||
{updateWorkspace.isError && ( // query-error-ok: mutation result
|
||||
|
||||
<p className="text-xs text-destructive">Failed to update workspace.</p>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
@@ -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 }) {
|
||||
<ProjectRepositoryInput companyId={project.companyId} selected={draft ?? saved} onChange={(repos) => { setDraft(repos); save.reset(); }} onConnect={() => setConnecting(true)} disabled={save.isPending} />
|
||||
{project.workspaces.filter((workspace) => workspace.repoUrl && !workspace.metadata?.githubRepositoryId).map((workspace) => <LegacyProjectRepository key={workspace.id} workspace={workspace} projectRef={project.urlKey} />)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-4">
|
||||
{save.isError && <p role="alert" className="mr-auto text-sm text-destructive">{save.error.message}</p>}
|
||||
{save.status === "error" && <p role="alert" className="mr-auto text-sm text-destructive">{describeError(save.error).body}</p>}
|
||||
{save.isSuccess && <span role="status" className="mr-auto text-sm text-muted-foreground">Changes saved</span>}
|
||||
{draft && <Button type="button" variant="ghost" disabled={save.isPending} onClick={() => { setDraft(null); save.reset(); }}>Discard changes</Button>}
|
||||
<Button type="button" disabled={!draft || save.isPending} onClick={() => save.mutate()}>{save.isPending ? "Saving…" : "Save changes"}</Button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ProjectRepository } from "@paperclipai/shared";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { RepositoryEditor } from "./RepositoryEditor";
|
||||
import { Button } from "./ui/button";
|
||||
import { useQueryView } from "./QueryView";
|
||||
|
||||
export const repositoryOptionsKey = (companyId: string) => ["project-repositories", companyId] as const;
|
||||
|
||||
@@ -14,16 +15,20 @@ export function ProjectRepositoryInput({ companyId, selected, onChange, onConnec
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const query = useQuery({ queryKey: repositoryOptionsKey(companyId), queryFn: () => projectsApi.repositoryOptions(companyId), staleTime: 30_000 });
|
||||
const state = query.isPending ? "loading" : query.isError ? "error"
|
||||
: !query.data.connectionCount ? "disconnected"
|
||||
: query.data.failedConnectionCount && !query.data.repositories.length ? "error"
|
||||
: !query.data.repositories.length ? "empty" : "ready";
|
||||
// Read through the shared view so cached options stay visible while a transient
|
||||
// refetch fails; only a no-data failure lands in the error state with Retry.
|
||||
const view = useQueryView(query);
|
||||
const state = view.kind === "loading" || view.kind === "reconnecting" ? "loading"
|
||||
: view.kind === "error" ? "error"
|
||||
: !view.data?.connectionCount ? "disconnected"
|
||||
: view.data.failedConnectionCount && !view.data.repositories.length ? "error"
|
||||
: !view.data.repositories.length ? "empty" : "ready";
|
||||
return <div className="flex min-w-0 flex-col gap-3">
|
||||
<RepositoryEditor selected={selected.map((repo) => query.data?.repositories.find((available) => available.id === repo.id) ?? repo)} onChange={onChange} available={query.data?.repositories} state={state}
|
||||
onRetry={() => void query.refetch()} onConnect={onConnect} disabled={disabled} />
|
||||
onRetry={() => view.retry()} onConnect={onConnect} disabled={disabled} />
|
||||
{!!query.data?.failedConnectionCount && query.data.repositories.length > 0 && <div role="alert" className="flex flex-wrap items-center gap-2 text-xs text-destructive">
|
||||
Some GitHub connections could not load. Reconnect them in Apps or try again.
|
||||
<Button type="button" variant="ghost" size="sm" disabled={query.isFetching} onClick={() => void query.refetch()}>Try again</Button>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={view.isFetching} onClick={() => view.retry()}>Try again</Button>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeError } from "@/api/errors";
|
||||
import { Component, Suspense, type ErrorInfo, type ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -76,7 +77,7 @@ class RouteErrorBoundaryInner extends Component<RouteErrorBoundaryInnerProps, Ro
|
||||
</p>
|
||||
</div>
|
||||
<pre className="overflow-auto rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive whitespace-pre-wrap">
|
||||
{error.message}
|
||||
{describeError(error).body}{/* // query-error-ok: error boundary; crash diagnostics, not a query read */}
|
||||
</pre>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={this.props.onReset}>
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
type RestoreRoutineRevisionResponse,
|
||||
} from "../api/routines";
|
||||
import { ApiError } from "../api/client";
|
||||
import { describeError } from "../api/errors";
|
||||
import { QueryErrorState, useQueryView } from "./QueryView";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { buildLineDiff, type DiffRow } from "../lib/line-diff";
|
||||
import { relativeTime } from "../lib/utils";
|
||||
@@ -88,6 +90,7 @@ export function RoutineHistoryTab({
|
||||
queryKey: queryKeys.routines.revisions(routine.id),
|
||||
queryFn: () => routinesApi.listRevisions(routine.id),
|
||||
});
|
||||
const revisionsView = useQueryView(revisionsQuery);
|
||||
|
||||
const revisions = useMemo(() => revisionsQuery.data ?? [], [revisionsQuery.data]);
|
||||
const sortedRevisions = useMemo(
|
||||
@@ -154,7 +157,7 @@ export function RoutineHistoryTab({
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to restore revision",
|
||||
body: error instanceof Error ? error.message : "Paperclip could not restore the revision.",
|
||||
body: describeError(error, { action: "restore the revision" }).body,
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
@@ -183,7 +186,9 @@ export function RoutineHistoryTab({
|
||||
});
|
||||
};
|
||||
|
||||
if (revisionsQuery.isLoading) {
|
||||
// A loaded revision list stays on screen through a failed refetch; an outage
|
||||
// before the first load shows the skeleton, a real failure shows copy.
|
||||
if (revisionsView.kind === "loading" || revisionsView.kind === "reconnecting") {
|
||||
return (
|
||||
<div className="grid gap-5 md:grid-cols-(--gtc-9)">
|
||||
<div className="space-y-2">
|
||||
@@ -196,21 +201,14 @@ export function RoutineHistoryTab({
|
||||
);
|
||||
}
|
||||
|
||||
if (revisionsQuery.error) {
|
||||
if (revisionsView.kind === "error") {
|
||||
return (
|
||||
<div className="rounded-md border border-l-2 border-l-destructive border-border p-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Could not load revisions</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{revisionsQuery.error instanceof Error
|
||||
? revisionsQuery.error.message
|
||||
: "Unknown error loading revisions."}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => revisionsQuery.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
<QueryErrorState
|
||||
error={revisionsView.error}
|
||||
action="load revisions"
|
||||
onRetry={revisionsView.retry}
|
||||
retrying={revisionsView.isFetching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -252,7 +252,6 @@ export function RoutineRunVariablesDialog({
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls();
|
||||
|
||||
@@ -80,7 +80,6 @@ function WorkspaceValidationRecoverySurface({ run }: { run: HeartbeatRun }) {
|
||||
queryKey: queryKeys.access.currentBoardAccess,
|
||||
queryFn: () => accessApi.getCurrentBoardAccess(),
|
||||
enabled: Boolean(isWorkspaceValidationFailure && issueId),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const recoveryAction = issue?.activeRecoveryAction ?? null;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { createElement, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { ConnectivityProvider, createConnectivityStore, type ConnectivityStore } from "@/lib/connectivity";
|
||||
import { RunnerInspector } from "./RunnerInspector";
|
||||
|
||||
const accessMock = vi.hoisted(() => vi.fn());
|
||||
@@ -664,3 +666,182 @@ describe("RunnerInspector", () => {
|
||||
expect(container.textContent).toContain("Production surface preview");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunnerInspector disconnect handling", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let store: ConnectivityStore;
|
||||
let probe: ReturnType<typeof vi.fn<() => Promise<{ reachable: boolean; retryAfterMs: number | null }>>>;
|
||||
const unavailable = () => new ApiError("Paperclip is restarting.", 503, { error: "tenant_app_unavailable" });
|
||||
const event = (runId: string, seq: number) => ({
|
||||
id: seq,
|
||||
companyId: "company-1",
|
||||
runId,
|
||||
agentId: "agent-1",
|
||||
seq,
|
||||
eventType: "run.result.proposed",
|
||||
stream: "stdout",
|
||||
level: "info",
|
||||
color: null,
|
||||
message: null,
|
||||
payload: { prpEvent: { sourceEventId: `runner:${runId}:${seq}`, payload: {} } },
|
||||
createdAt: "2026-08-22T12:00:03.000Z",
|
||||
});
|
||||
|
||||
function render(runId = "run-1") {
|
||||
flushSync(() =>
|
||||
root.render(
|
||||
<ConnectivityProvider store={store}>
|
||||
<RunnerInspector
|
||||
runId={runId}
|
||||
run={{ status: "succeeded", resultJson: null }}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
/>
|
||||
</ConnectivityProvider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "", "/");
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
probe = vi.fn(async () => ({ reachable: true, retryAfterMs: null }));
|
||||
store = createConnectivityStore({ probe: probe as never, browserOnline: true });
|
||||
accessMock.mockResolvedValue({ source: "local_implicit", isInstanceAdmin: false });
|
||||
eventsMock.mockResolvedValue([]);
|
||||
traceMock.mockResolvedValue({ trace: null, entries: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
store.dispose();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not grant trace controls when a recovery read starts during deletion", async () => {
|
||||
traceMock.mockResolvedValue(traceInspection("run-1", "retained-redacted-frame"));
|
||||
const pendingDelete = deferred<void>();
|
||||
deleteMock.mockReturnValue(pendingDelete.promise);
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(); await flush();
|
||||
expect(container.textContent).toContain("Reveal exact frame");
|
||||
const traceReads = traceMock.mock.calls.length;
|
||||
const eventReads = eventsMock.mock.calls.length;
|
||||
flushSync(() => [...container.querySelectorAll("button")].find(button => button.textContent?.includes("Delete trace"))!.click());
|
||||
expect(deleteMock).toHaveBeenCalledWith("run-1");
|
||||
probe.mockResolvedValue({ reachable: false, retryAfterMs: null });
|
||||
store.reportError(unavailable()); await flush();
|
||||
probe.mockResolvedValue({ reachable: true, retryAfterMs: null });
|
||||
store.probeNow(); await flush();
|
||||
expect(store.getSnapshot().status).toBe("online");
|
||||
expect(eventsMock.mock.calls.length).toBeGreaterThan(eventReads);
|
||||
expect(traceMock).toHaveBeenCalledTimes(traceReads);
|
||||
expect(container.textContent).not.toContain("Reveal exact frame");
|
||||
expect(container.textContent).not.toContain("Download exact trace");
|
||||
expect(container.textContent).not.toContain("Delete trace");
|
||||
pendingDelete.resolve(); await flush();
|
||||
expect(container.textContent).not.toContain("retained-redacted-frame");
|
||||
});
|
||||
|
||||
it("keeps the loaded inspection on screen when the refetch after an outage fails transiently", async () => {
|
||||
eventsMock.mockResolvedValue([event("run-1", 9)]);
|
||||
traceMock.mockResolvedValue(traceInspection("run-1", "run-1-redacted"));
|
||||
render();
|
||||
await flush();
|
||||
expect(container.textContent).toContain("run-1-redacted");
|
||||
|
||||
probe.mockResolvedValue({ reachable: false, retryAfterMs: null });
|
||||
store.reportError(unavailable());
|
||||
await flush();
|
||||
expect(store.getSnapshot().status).toBe("reconnecting");
|
||||
expect(container.textContent).toContain("run-1-redacted");
|
||||
expect(container.querySelector('[data-query-view="error"]')).toBeNull();
|
||||
|
||||
// Recovery refetches; a transient failure of that refetch retries quietly.
|
||||
eventsMock.mockRejectedValue(new ApiError("Too many requests", 429, { error: "rate_limited" }));
|
||||
probe.mockResolvedValue({ reachable: true, retryAfterMs: null });
|
||||
store.probeNow();
|
||||
await flush();
|
||||
expect(store.getSnapshot().status).toBe("online");
|
||||
expect(container.textContent).toContain("run-1-redacted");
|
||||
expect(container.textContent).toContain("Reveal exact frame");
|
||||
expect(container.querySelector('[data-query-view="error"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("rate_limited");
|
||||
});
|
||||
|
||||
it("waits quietly for a first load during an outage and fills in on recovery", async () => {
|
||||
probe.mockResolvedValue({ reachable: false, retryAfterMs: null });
|
||||
store.reportError(unavailable());
|
||||
await flush();
|
||||
expect(store.getSnapshot().status).toBe("reconnecting");
|
||||
eventsMock.mockResolvedValue([event("run-1", 9)]);
|
||||
render();
|
||||
await flush();
|
||||
expect(accessMock).not.toHaveBeenCalledWith(); // the initial load is paused, not failing
|
||||
expect(eventsMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Loading run pipeline…");
|
||||
expect(container.querySelector('[data-query-view="error"]')).toBeNull();
|
||||
|
||||
probe.mockResolvedValue({ reachable: true, retryAfterMs: null });
|
||||
store.probeNow();
|
||||
await flush();
|
||||
expect(store.getSnapshot().status).toBe("online");
|
||||
expect(eventsMock).toHaveBeenCalledWith("run-1", 0, 1_000);
|
||||
expect(container.textContent).not.toContain("Loading run pipeline…");
|
||||
expect(container.textContent).toContain("1 PRP event");
|
||||
});
|
||||
|
||||
it("shows readable copy for a real failure, never raw access, and retries on request", async () => {
|
||||
traceMock.mockRejectedValueOnce(new ApiError("stale_revision", 409, { error: "stale_revision" }));
|
||||
render();
|
||||
await flush();
|
||||
const alert = container.querySelector('[data-query-view="error"]');
|
||||
expect(alert).not.toBeNull();
|
||||
expect(alert?.textContent).toContain("Couldn't load the runner inspection");
|
||||
expect(alert?.textContent).not.toContain("stale_revision");
|
||||
expect(container.textContent).not.toContain("Reveal exact frame");
|
||||
expect(container.textContent).not.toContain("Download exact trace");
|
||||
|
||||
traceMock.mockResolvedValue(traceInspection("run-1", "run-1-redacted"));
|
||||
flushSync(() => alert?.querySelector("button")?.click());
|
||||
await flush();
|
||||
expect(container.querySelector('[data-query-view="error"]')).toBeNull();
|
||||
expect(container.textContent).toContain("run-1-redacted");
|
||||
});
|
||||
|
||||
it("leaves out Retry and keeps raw controls hidden when access is forbidden", async () => {
|
||||
accessMock.mockRejectedValue(new ApiError("Forbidden", 403, { error: "Forbidden" }));
|
||||
render();
|
||||
await flush();
|
||||
const alert = container.querySelector('[data-query-view="error"]');
|
||||
expect(alert?.textContent).toContain("Forbidden");
|
||||
expect(alert?.querySelector("button")).toBeNull();
|
||||
expect(traceMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).not.toContain("Reveal exact frame");
|
||||
});
|
||||
|
||||
it("ignores a load that finishes after the run changed", async () => {
|
||||
const pendingFirst = deferred<ReturnType<typeof event>[]>();
|
||||
eventsMock.mockImplementation(async (runId: string) =>
|
||||
runId === "run-1" ? pendingFirst.promise : [event(runId, 2)],
|
||||
);
|
||||
traceMock.mockImplementation(async (runId: string) => traceInspection(runId, `${runId}-redacted`));
|
||||
render("run-1");
|
||||
await flush();
|
||||
expect(container.textContent).toContain("Loading run pipeline…");
|
||||
render("run-2");
|
||||
await flush();
|
||||
expect(container.textContent).toContain("run-2-redacted");
|
||||
pendingFirst.resolve([event("run-1", 1), event("run-1", 2), event("run-1", 3)]);
|
||||
await flush();
|
||||
expect(container.textContent).toContain("run-2-redacted");
|
||||
expect(container.textContent).not.toContain("run-1-redacted");
|
||||
expect(container.textContent).toContain("runner:run-2:2");
|
||||
expect(container.textContent).not.toContain("runner:run-1:");
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { heartbeatsApi, type ProviderTraceInspection } from "@/api/heartbeats";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { QueryErrorState } from "@/components/QueryView";
|
||||
import { useResilientFetch } from "@/hooks/useResilientFetch";
|
||||
import { parsePaperclipRunnerStdoutLine } from "@/adapters/paperclip-runner";
|
||||
import { TaskChatProtocolCard } from "@/components/task-chat/TaskChatProtocolCard";
|
||||
import type { TaskChatProtocolItem } from "@/components/task-chat/task-chat-model";
|
||||
@@ -656,8 +658,7 @@ export function RunnerInspector({
|
||||
}) {
|
||||
const [inspection, setInspection] = useState<ProviderTraceInspection | null>(null);
|
||||
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<unknown>(null);
|
||||
const [view, setView] = useState<InspectorView>("pipeline");
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [selectedFrameId, setSelectedFrameId] = useState<number | null>(null);
|
||||
@@ -680,8 +681,7 @@ export function RunnerInspector({
|
||||
setRawTraceAccess(null);
|
||||
setInspection(null);
|
||||
setEvents([]);
|
||||
setLoading(open);
|
||||
setError(null);
|
||||
setActionError(null);
|
||||
setSelectedKey(null);
|
||||
setSelectedFrameId(null);
|
||||
setRevealed({});
|
||||
@@ -705,8 +705,7 @@ export function RunnerInspector({
|
||||
rawTraceAccessRef.current = deniedAccess;
|
||||
setRawTraceAccess(deniedAccess);
|
||||
setInspection({ trace: null, entries: [] });
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setActionError(null);
|
||||
setSelectedKey(null);
|
||||
setSelectedFrameId(null);
|
||||
setRevealed({});
|
||||
@@ -721,53 +720,51 @@ export function RunnerInspector({
|
||||
if (requestedOperation) setSelectedKey(requestedOperation);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
const loadEpoch = rawTraceAccessEpochRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([accessApi.getCurrentBoardAccess(), loadAllRunEvents(runId)])
|
||||
.then(async ([boardAccess, nextEvents]) => {
|
||||
if (!active || rawTraceAccessEpochRef.current !== loadEpoch) return;
|
||||
const canRaw = boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin;
|
||||
const access: RawTraceAccess = {
|
||||
runId,
|
||||
allowed: canRaw,
|
||||
epoch: rawTraceAccessEpochRef.current,
|
||||
phase: canRaw ? "granted" : "denied",
|
||||
};
|
||||
rawTraceAccessRef.current = access;
|
||||
setRawTraceAccess(access);
|
||||
if (!canRaw) setRevealed({});
|
||||
const trace = canRaw
|
||||
? await heartbeatsApi.providerTrace(runId)
|
||||
: ({ trace: null, entries: [] } satisfies ProviderTraceInspection);
|
||||
const currentAccess = rawTraceAccessRef.current;
|
||||
if (
|
||||
!active ||
|
||||
currentAccess?.runId !== runId ||
|
||||
currentAccess.epoch !== loadEpoch ||
|
||||
currentAccess.allowed !== canRaw
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setInspection(trace);
|
||||
setEvents(nextEvents);
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (!active || rawTraceAccessEpochRef.current !== loadEpoch) return;
|
||||
setError(cause instanceof Error ? cause.message : "Runner inspection failed");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active && rawTraceAccessEpochRef.current === loadEpoch) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, runId]);
|
||||
// The initial load: the board's current access, every canonical event, and
|
||||
// (only for an administrator) the redacted provider trace. The permission
|
||||
// epoch fences it: a denial or deletion while it is in flight makes its
|
||||
// result stale, and a stale result never reveals or restores raw access.
|
||||
// Transient failures keep what is shown and retry; a real failure is
|
||||
// rendered as readable copy with a Retry button.
|
||||
const load = useResilientFetch({
|
||||
key: ["runner-inspection", runId, open],
|
||||
enabled: open,
|
||||
fetcher: async () => {
|
||||
const loadEpoch = rawTraceAccessEpochRef.current;
|
||||
const [boardAccess, nextEvents] = await Promise.all([
|
||||
accessApi.getCurrentBoardAccess(),
|
||||
loadAllRunEvents(runId),
|
||||
]);
|
||||
// Superseded by a newer denial or deletion: do not read the raw trace.
|
||||
if (rawTraceAccessEpochRef.current !== loadEpoch || rawTraceAccessRef.current?.phase === "deleting") return null;
|
||||
const canRaw = boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin;
|
||||
const trace = canRaw
|
||||
? await heartbeatsApi.providerTrace(runId)
|
||||
: ({ trace: null, entries: [] } satisfies ProviderTraceInspection);
|
||||
return { loadEpoch, canRaw, trace, events: nextEvents };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (!result || rawTraceAccessEpochRef.current !== result.loadEpoch || rawTraceAccessRef.current?.phase === "deleting") return;
|
||||
const access: RawTraceAccess = {
|
||||
runId,
|
||||
allowed: result.canRaw,
|
||||
epoch: result.loadEpoch,
|
||||
phase: result.canRaw ? "granted" : "denied",
|
||||
};
|
||||
rawTraceAccessRef.current = access;
|
||||
setRawTraceAccess(access);
|
||||
if (!result.canRaw) setRevealed({});
|
||||
setInspection(result.trace);
|
||||
setEvents(result.events);
|
||||
},
|
||||
});
|
||||
// Once access for this run is decided (a newer denial while the load is
|
||||
// still in flight counts) the inspector is no longer loading: the pending
|
||||
// result is stale and will be discarded.
|
||||
const loading =
|
||||
rawTraceAccess?.runId !== runId &&
|
||||
(load.view.kind === "loading" || load.view.kind === "reconnecting");
|
||||
const loadError = load.view.kind === "error" ? load.view.error : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -956,9 +953,7 @@ export function RunnerInspector({
|
||||
await heartbeatsApi.deleteProviderTrace(runId);
|
||||
} catch (cause) {
|
||||
if (rawTraceAccessRef.current?.epoch === deletionAccess.epoch) {
|
||||
setError(
|
||||
cause instanceof Error ? cause.message : "Raw trace deletion failed",
|
||||
);
|
||||
setActionError(cause);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1063,14 +1058,25 @@ export function RunnerInspector({
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{error ? <div className="m-4 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">{error}</div> : null}
|
||||
{!loading && !error && canInspectRaw === false ? (
|
||||
{loadError ? (
|
||||
<QueryErrorState
|
||||
className="m-4"
|
||||
error={loadError}
|
||||
action="load the runner inspection"
|
||||
onRetry={load.view.retry}
|
||||
retrying={load.view.isFetching}
|
||||
/>
|
||||
) : null}
|
||||
{actionError !== null && actionError !== undefined ? (
|
||||
<QueryErrorState className="m-4" error={actionError} action="delete the raw trace" />
|
||||
) : null}
|
||||
{!loading && !loadError && canInspectRaw === false ? (
|
||||
<div className="m-4 rounded-md border border-border bg-muted/20 p-4">
|
||||
<p className="font-medium">Raw provider traces require an instance administrator.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Canonical PRP events and presentation decisions remain inspectable below.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !error && canInspectRaw === true && !inspection?.trace ? (
|
||||
{!loading && !loadError && canInspectRaw === true && !inspection?.trace ? (
|
||||
<div className="m-4 flex items-start justify-between gap-4 rounded-md border border-border bg-muted/20 p-4">
|
||||
<div>
|
||||
<p className="font-medium">Raw provider capture was off for this run.</p>
|
||||
|
||||
@@ -22,7 +22,6 @@ export function SentryGate() {
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const dsn = session?.sentryDsn;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
</InlineBanner>
|
||||
) : null}
|
||||
|
||||
{slotQuery.isError ? (
|
||||
<InlineBanner
|
||||
tone="warning"
|
||||
title="Summary could not be loaded"
|
||||
actions={
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => void slotQuery.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{slotQuery.error instanceof Error ? slotQuery.error.message : "Try loading the summary again."}
|
||||
</InlineBanner>
|
||||
{slotView.kind === "error" ? (
|
||||
<QueryErrorState
|
||||
size="panel"
|
||||
error={slotView.error}
|
||||
action="load the summary"
|
||||
onRetry={slotView.retry}
|
||||
retrying={slotView.isFetching}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!slotQuery.isError && generationFailed ? (
|
||||
{slotReady && generationFailed ? (
|
||||
<InlineBanner
|
||||
tone="danger"
|
||||
title="Summary generation failed"
|
||||
@@ -390,7 +387,7 @@ export function SummarySlotCard({
|
||||
</InlineBanner>
|
||||
) : null}
|
||||
|
||||
{!slotQuery.isError && isGenerating && generatingIssue ? (
|
||||
{slotReady && isGenerating && generatingIssue ? (
|
||||
<div className="flex items-start gap-3 text-sm">
|
||||
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
@@ -434,7 +431,7 @@ export function SummarySlotCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!slotQuery.isError && !latestDocument && !isGenerating && !generationFailed && canGenerateFirstSummary ? (
|
||||
{slotReady && !latestDocument && !isGenerating && !generationFailed && canGenerateFirstSummary ? (
|
||||
<div className="flex flex-col items-start gap-3 rounded-lg border border-border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium text-foreground">No summary yet</p>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
return <section id={anchor!} aria-label="Decision record" className="space-y-3 rounded-xl border border-border bg-card p-4">
|
||||
<h2 className="text-sm font-medium">Decision record</h2>
|
||||
{record.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading decision…</p> : record.isError ? (
|
||||
{["loading", "reconnecting"].includes(recordView.kind) ? <p role="status" className="text-sm text-muted-foreground">Loading decision…</p> : (recordView.kind === "error") ? (
|
||||
<div role="alert" className="text-sm"><p>Could not load this decision.</p><Button size="sm" variant="ghost" onClick={() => void record.refetch()}>Retry</Button></div>
|
||||
) : !matchesTask ? <p role="alert" className="text-sm text-muted-foreground">This decision is not available in this task.</p> : <>
|
||||
{decision.status !== "open" ? <dl className="space-y-2 text-sm">
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Agent, Issue, IssueCommentMetadata } from "@paperclipai/shared";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import type { WorkspaceBaseRefRecoveryNoticeProps } from "./WorkspaceBaseRefRecoveryNotice";
|
||||
import { retryTransientOnly } from "../lib/query-client";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
@@ -45,7 +46,7 @@ export function WorkspaceBaseRefRecoveryProvider({ issue, agentMap, unavailableR
|
||||
const runId = typeof recordedRunId === "string" ? recordedRunId : null;
|
||||
const { data: run } = useQuery({
|
||||
queryKey: ["workspace-base-ref-recovery", issue.companyId, issue.id, runId],
|
||||
queryFn: () => heartbeatsApi.get(runId!), enabled: Boolean(runId), staleTime: Infinity, retry: false,
|
||||
queryFn: () => heartbeatsApi.get(runId!), enabled: Boolean(runId), staleTime: Infinity, retry: retryTransientOnly(0),
|
||||
});
|
||||
const failure = record(record(run?.resultJson).configurationIncomplete);
|
||||
const execution = record(record(run?.resultJson).executionRecovery);
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 type { IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { ApiError } from "../api/client";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { WorkspaceExportRecovery } from "./WorkspaceExportRecovery";
|
||||
|
||||
@@ -74,6 +75,33 @@ describe("governed retained-file recovery", () => {
|
||||
expect(container.textContent).not.toContain("Saved files recovered");
|
||||
});
|
||||
|
||||
it.each(["access", "source"])("retains loaded recovery controls through a transient %s refresh failure", async kind => {
|
||||
await mount();
|
||||
const key = kind === "access" ? queryKeys.access.currentBoardAccess
|
||||
: queryKeys.issues.recoveryActions("issue-1", "action-1", "run-1");
|
||||
await act(async () => {
|
||||
await client.fetchQuery({ queryKey: key, staleTime: 0, queryFn: async () => { throw new ApiError("Restarting", 503, { error: "tenant_app_unavailable" }); }, retry: false }).catch(() => {});
|
||||
});
|
||||
await settle();
|
||||
expect(button("Recover saved files")).toBeDefined();
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("tenant_app_unavailable");
|
||||
expect(api.recover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["access", "source"])("hides cached recovery controls after definitive %s denial", async kind => {
|
||||
await mount();
|
||||
const key = kind === "access" ? queryKeys.access.currentBoardAccess
|
||||
: queryKeys.issues.recoveryActions("issue-1", "action-1", "run-1");
|
||||
await act(async () => {
|
||||
await client.fetchQuery({ queryKey: key, staleTime: 0, queryFn: async () => { throw new ApiError("Forbidden", 403, null); }, retry: false }).catch(() => {});
|
||||
});
|
||||
await settle();
|
||||
expect(button("Recover saved files")).toBeUndefined();
|
||||
expect(container.querySelector('[role="alert"]')).not.toBeNull();
|
||||
expect(api.recover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requests file preservation on plain HTTP without crypto.randomUUID", async () => {
|
||||
const originalCrypto = globalThis.crypto;
|
||||
vi.stubGlobal("crypto", { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) });
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
|
||||
import { isTransientError } from "../api/errors";
|
||||
import { useQueryView } from "./QueryView";
|
||||
import { accessApi } from "../api/access";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useDraftOwner, useDraftOwnerReader } from "../hooks/useDraftOwner";
|
||||
@@ -53,12 +55,16 @@ function RetainedFilesRecovery({ action, issueId, companyId, className }: {
|
||||
const [checkedUnconfirmed, setCheckedUnconfirmed] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const access = useQuery({ queryKey: queryKeys.access.currentBoardAccess,
|
||||
queryFn: () => accessApi.getCurrentBoardAccess(), retry: false });
|
||||
const canMutate = Boolean(owner && (access.data?.source === "local_implicit"
|
||||
queryFn: () => accessApi.getCurrentBoardAccess() });
|
||||
const accessView = useQueryView(access);
|
||||
const accessReady = accessView.kind === "ready" || accessView.kind === "stale";
|
||||
const canMutate = Boolean(accessReady && owner && (access.data?.source === "local_implicit"
|
||||
|| owner !== "signed-out" && access.data?.userId === owner)
|
||||
&& canBoardResolveRecoveryAction(companyId, access.data));
|
||||
const source = useQuery({ queryKey: queryKeys.issues.recoveryActions(issueId, action.id, runId),
|
||||
queryFn: () => issuesApi.recoveryActions(issueId, { runId, actionId: action.id }), enabled: canMutate, retry: false });
|
||||
queryFn: () => issuesApi.recoveryActions(issueId, { runId, actionId: action.id }), enabled: canMutate });
|
||||
const sourceView = useQueryView(source);
|
||||
const sourceReady = sourceView.kind === "ready" || sourceView.kind === "stale";
|
||||
const storageKey = owner ? pendingSendKeyFor(`workspace-file-recovery:${companyId}:${issueId}:${action.id}:${runId}`, owner) : null;
|
||||
const matchesInput = (value: unknown): value is RecoveryInput => {
|
||||
const input = asObject(value);
|
||||
@@ -73,7 +79,7 @@ function RetainedFilesRecovery({ action, issueId, companyId, className }: {
|
||||
&& state.runId === runId && state.schema === "paperclip.workspace-recovery.v1"
|
||||
&& inspected?.actionId === action.id && inspected.runId === runId && inspected.status === "verified" ? current : null;
|
||||
}
|
||||
const current = boundAction(source.data);
|
||||
const current = sourceReady ? boundAction(source.data) : null;
|
||||
const recovery = asObject(current?.evidence.workspaceRecovery);
|
||||
const system = asObject(current?.evidence.systemRecovery);
|
||||
const serverAcknowledged = Boolean(pending && Array.isArray(recovery.requestIds) && recovery.requestIds.includes(pending.idempotencyKey));
|
||||
@@ -87,7 +93,7 @@ function RetainedFilesRecovery({ action, issueId, companyId, className }: {
|
||||
void client.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId) });
|
||||
void client.invalidateQueries({ queryKey: queryKeys.runDetail(runId) });
|
||||
};
|
||||
const request = useMutation({ retry: false, mutationFn: async (restartFailedAttempt: boolean) => {
|
||||
const request = useMutation({ retry: false, mutationFn: async (restartFailedAttempt: boolean) => { // query-error-ok: recovery requests require an explicit action and receipt reconciliation.
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
setNotice(null);
|
||||
@@ -130,7 +136,7 @@ function RetainedFilesRecovery({ action, issueId, companyId, className }: {
|
||||
setNotice(error instanceof Error ? error.message : "Unable to inspect file recovery.");
|
||||
} finally { busy.current = false; }
|
||||
} });
|
||||
const check = useMutation({ retry: false, mutationFn: async () => {
|
||||
const check = useMutation({ retry: false, mutationFn: async () => { // query-error-ok: operator checks run once; the source read uses the shared query retry policy.
|
||||
const observed = await source.refetch();
|
||||
if (observed.error) throw observed.error;
|
||||
const fresh = boundAction(observed.data);
|
||||
@@ -142,6 +148,7 @@ function RetainedFilesRecovery({ action, issueId, companyId, className }: {
|
||||
setNotice(unconfirmed ? "No recovery request is confirmed yet. Trying again will reuse the saved request." : null);
|
||||
invalidate();
|
||||
} });
|
||||
const checkError = isTransientError(check.error) ? null : check.error;
|
||||
const failedReceipt = serverAcknowledged && recovery.status === "blocked";
|
||||
const confirmedFailedAttempt = failedReceipt && retryable && system.state === "exhausted";
|
||||
const waiting = !failedReceipt && !saved && (restoring || acknowledged || serverAcknowledged);
|
||||
@@ -157,19 +164,19 @@ function RetainedFilesRecovery({ action, issueId, companyId, className }: {
|
||||
className="self-start" disabled={request.isPending || check.isPending}
|
||||
onClick={() => request.mutate(confirmedFailedAttempt)}>{request.isPending ? "Requesting file recovery…"
|
||||
: confirmedFailedAttempt ? "Try file recovery again" : "Recover saved files"}</Button> : null}
|
||||
{waiting || unconfirmed || failedReceipt || source.isError || source.data && !current ? <Button variant="ghost" size="sm" className="self-start"
|
||||
{waiting || unconfirmed || failedReceipt || source.error || sourceReady && !current ? <Button variant="ghost" size="sm" className="self-start"
|
||||
disabled={request.isPending || check.isPending} onClick={() => check.mutate()}>
|
||||
{check.isPending ? "Checking file recovery…" : "Check file recovery status"}
|
||||
</Button> : null}
|
||||
{source.isPending ? <p className="text-xs text-muted-foreground">Inspecting the recorded file source…</p> : null}
|
||||
{source.isError || check.isError || notice ? <p role="alert" className="text-sm text-destructive">
|
||||
{notice ?? (check.error instanceof Error ? check.error.message : "File recovery could not be inspected. Check its status again.")}
|
||||
{sourceView.kind === "loading" || sourceView.kind === "reconnecting" ? <p className="text-xs text-muted-foreground">Inspecting the recorded file source…</p> : null}
|
||||
{sourceView.kind === "error" || checkError || notice ? <p role="alert" className="text-sm text-destructive">
|
||||
{notice ?? (checkError instanceof Error ? checkError.message : "File recovery could not be inspected. Check its status again.")}
|
||||
</p> : null}
|
||||
{current && recovery.status === "blocked" && (serverAcknowledged || recovery.retryable !== true)
|
||||
? <p role="alert">File recovery has not finished. The saved source remains protected; service assistance is needed.</p> : null}
|
||||
{source.data && !current ? <p role="alert">The recorded source is unavailable or has changed. Inspect recovery before continuing.</p> : null}
|
||||
</> : access.isError ? <p role="alert" className="text-sm text-destructive">Task access could not be checked. Refresh before requesting file recovery.</p>
|
||||
: access.isPending || !owner ? <p role="status" className="text-xs text-muted-foreground">Checking file recovery access…</p>
|
||||
{sourceReady && !current ? <p role="alert">The recorded source is unavailable or has changed. Inspect recovery before continuing.</p> : null}
|
||||
</> : accessView.kind === "error" ? <p role="alert" className="text-sm text-destructive">Task access could not be checked. Refresh before requesting file recovery.</p>
|
||||
: accessView.kind === "loading" || accessView.kind === "reconnecting" || !owner ? <p role="status" className="text-xs text-muted-foreground">Checking file recovery access…</p>
|
||||
: <p className="text-xs text-muted-foreground">A board member with task access can request file recovery.</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useQueryView } from "@/components/QueryView";
|
||||
import { describeError } from "@/api/errors";
|
||||
import { useId, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { ExecutionBlocker } from "@paperclipai/shared";
|
||||
@@ -20,6 +22,7 @@ export function WorkspaceReconstructionRecovery({ issueId, blocker, canManage, o
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const files = useQuery({ queryKey: queryKeys.issues.attachments(issueId),
|
||||
queryFn: () => issuesApi.listAttachments(issueId), enabled: canManage });
|
||||
const filesView = useQueryView(files);
|
||||
const currentSelection = selected.filter(id => files.data?.some(file => file.id === id));
|
||||
const submit = useMutation({
|
||||
mutationFn: () => issuesApi.reconstructWorkspace(issueId, {
|
||||
@@ -60,8 +63,8 @@ export function WorkspaceReconstructionRecovery({ issueId, blocker, canManage, o
|
||||
<Button disabled={submit.isPending || !acknowledged || currentSelection.length === 0 || missingWork.trim().length < 20 || outcomeEvidence.trim().length < 20}
|
||||
onClick={() => submit.mutate()}>{submit.isPending ? "Queuing…" : "Rebuild and continue"}</Button>
|
||||
</div>
|
||||
{files.isError && <p role="alert" className="text-destructive">{files.error.message}</p>}
|
||||
{submit.isError && <p role="alert" className="text-destructive">{submit.error.message}</p>}
|
||||
{(filesView.kind === "error") && <p role="alert" className="text-destructive">{describeError(files.error).body}</p>}
|
||||
{submit.error && <p role="alert" className="text-destructive">{describeError(submit.error).body}</p>}
|
||||
</div> : <p>A board member with runtime access can continue from the surviving task attachments.</p>}
|
||||
</details>;
|
||||
}
|
||||
@@ -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(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<InvitesSection />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
return root;
|
||||
}
|
||||
|
||||
it("keeps loaded invite history on screen when a refetch fails transiently", async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const root = await renderSection(queryClient);
|
||||
expect(container.textContent).toContain("Board User 25");
|
||||
|
||||
listInvitesMock.mockRejectedValue(
|
||||
new ApiError("Paperclip is restarting.", 503, { error: "tenant_app_unavailable" }),
|
||||
);
|
||||
await act(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.invites("company-1", "all", 5) });
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(listInvitesMock).toHaveBeenCalledTimes(2);
|
||||
expect(container.textContent).toContain("Board User 25");
|
||||
expect(container.textContent).toContain("Invite a person");
|
||||
expect(container.querySelector('[data-query-view="error"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("Paperclip is restarting.");
|
||||
expect(container.textContent).not.toContain("tenant_app_unavailable");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("shows readable copy and a Retry button when invites fail to load", async () => {
|
||||
listInvitesMock.mockRejectedValueOnce(new ApiError("Boom", 500, { error: "Boom" }));
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const root = await renderSection(queryClient);
|
||||
|
||||
expect(container.textContent).toContain("Couldn't load invites");
|
||||
expect(container.querySelector('[data-query-view="error"]')).not.toBeNull();
|
||||
const retryButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Retry",
|
||||
);
|
||||
expect(retryButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
retryButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Board User 25");
|
||||
expect(container.querySelector('[data-query-view="error"]')).toBeNull();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the permission copy for a forbidden invite list", async () => {
|
||||
listInvitesMock.mockRejectedValue(new ApiError("Forbidden", 403, { error: "Forbidden" }));
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const root = await renderSection(queryClient);
|
||||
|
||||
expect(container.textContent).toContain("You do not have permission to manage organization invites.");
|
||||
expect(container.querySelector('[data-query-view="error"]')).toBeNull();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { QueryErrorState, useQueryView } from "@/components/QueryView";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
@@ -10,6 +10,7 @@ import { Link } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { describeError } from "@/api/errors";
|
||||
|
||||
const inviteRoleOptions = [
|
||||
{
|
||||
@@ -99,6 +100,7 @@ export function InvitesSection() {
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => lastPage.nextOffset ?? undefined,
|
||||
});
|
||||
const invitesView = useQueryView(invitesQuery);
|
||||
const inviteHistory = useMemo(
|
||||
() =>
|
||||
invitesQuery.data?.pages.flatMap((page) =>
|
||||
@@ -129,7 +131,7 @@ export function InvitesSection() {
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to create invite",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
body: describeError(error).body,
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
@@ -144,7 +146,7 @@ export function InvitesSection() {
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to revoke invite",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
body: describeError(error).body,
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
@@ -154,18 +156,23 @@ export function InvitesSection() {
|
||||
return <div className="text-sm text-muted-foreground">Select an organization to manage invites.</div>;
|
||||
}
|
||||
|
||||
if (invitesQuery.isLoading) {
|
||||
if (invitesQuery.isLoading || invitesView.kind === "reconnecting") {
|
||||
return <div className="text-sm text-muted-foreground">Loading invites…</div>;
|
||||
}
|
||||
|
||||
if (invitesQuery.error) {
|
||||
const message =
|
||||
invitesQuery.error instanceof ApiError && invitesQuery.error.status === 403
|
||||
? "You do not have permission to manage organization invites."
|
||||
: invitesQuery.error instanceof Error
|
||||
? invitesQuery.error.message
|
||||
: "Failed to load invites.";
|
||||
return <div className="text-sm text-destructive">{message}</div>;
|
||||
if (invitesView.kind === "error") {
|
||||
if (invitesView.errorKind === "forbidden") {
|
||||
return <div className="text-sm text-destructive">You do not have permission to manage organization invites.</div>;
|
||||
}
|
||||
return (
|
||||
<QueryErrorState
|
||||
size="page"
|
||||
error={invitesQuery.error}
|
||||
action="load invites"
|
||||
onRetry={invitesView.retry}
|
||||
retrying={invitesView.isFetching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,7 +21,7 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [editing, setEditing] = useState<Agent[] | null>(null);
|
||||
const [notice, setNotice] = useState("");
|
||||
const accounts = useQuery({ queryKey: ["ai-connections", companyId], queryFn: () => aiConnectionsApi.list(companyId), retry: false });
|
||||
const accounts = useQuery({ queryKey: ["ai-connections", companyId], queryFn: () => aiConnectionsApi.list(companyId) });
|
||||
const visible = visibleAgents.filter(a => filter === "all" || bulkAgentHarness(a) === filter);
|
||||
const selectable = visible.filter(a => !bulkAgentDisabledReason(a));
|
||||
const selectedAgents = agents.filter(a => selected.includes(a.id) && !bulkAgentDisabledReason(a));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeError } from "@/api/errors";
|
||||
import { useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowRight, Loader2 } from "lucide-react";
|
||||
@@ -38,7 +39,6 @@ export function AgentBulkSettingsSheet({ companyId, agents, totalSelected, onClo
|
||||
const targets = failures ? failures.map(f => f.agent) : agents;
|
||||
const catalogs = useQuery({
|
||||
queryKey: ["bulk-agent-models", companyId, targets.map(a => [a.id, a.defaultEnvironmentId ?? null, nativeTaskModelCatalogOptions(a) ?? null]), changeConnection ? binding : null],
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const modelRequests = new Map<string, Promise<AdapterModel[]>>();
|
||||
const results = await mapBulkAgents(targets, async agent => {
|
||||
@@ -148,7 +148,7 @@ export function AgentBulkSettingsSheet({ companyId, agents, totalSelected, onClo
|
||||
</section>
|
||||
</fieldset>
|
||||
{catalogs.isFetching && <p role="status" className="text-xs text-muted-foreground">Checking connections and models…</p>}
|
||||
{(catalogs.error || loadErrors.length > 0) && <div role="alert" className="space-y-2 text-sm text-destructive"><p>{catalogs.error?.message}</p>{loadErrors.map(r => <p key={r.agent.id}>{r.agent.name}: {"error" in r ? r.error : ""}</p>)}<Button variant="outline" size="sm" onClick={() => void catalogs.refetch()}>Retry loading options</Button></div>}
|
||||
{(catalogs.error || loadErrors.length > 0) && <div role="alert" className="space-y-2 text-sm text-destructive"><p>{catalogs.error ? describeError(catalogs.error).body : undefined}</p>{loadErrors.map(r => <p key={r.agent.id}>{r.agent.name}: {"error" in r ? r.error : ""}</p>)}<Button variant="outline" size="sm" onClick={() => void catalogs.refetch()}>Retry loading options</Button></div>}
|
||||
{selectionError && <p role="alert" className="text-sm text-destructive">{selectionError}</p>}
|
||||
{problems.length > 0 && <div role="alert" className="space-y-2 rounded-md border border-destructive/30 p-3 text-sm">{problems.map(p => <p key={p.agent.id}><span className="font-medium">{p.agent.name}: </span>{p.message}</p>)}{!changeModel && !failures && <Button size="sm" variant="outline" onClick={() => setChangeModel(true)}>Change default model</Button>}</div>}
|
||||
{!missing && changed.length > 0 && <section className="space-y-3" aria-label="Changes to apply"><h3 className="text-xs font-medium text-muted-foreground">CHANGES TO APPLY</h3><div className="divide-y divide-border">{changed.map(agent => <div key={agent.id} className="space-y-1.5 py-3 text-xs"><p className="text-sm font-medium">{agent.name}</p>{changeConnection && !sameBinding(binding, agentConnection(agent)) && <p className="flex flex-wrap items-center gap-1.5 text-muted-foreground"><span>{bulkConnectionName(agentConnection(agent), rows.find(r => r.agent.id === agent.id)?.accounts)}</span><ArrowRight className="size-3" aria-label="to" /><span className="text-foreground">{bulkConnectionName(binding, rows.find(r => r.agent.id === agent.id)?.accounts)}</span></p>}{changeModel && model !== agentDefaultModel(agent) && <p className="flex flex-wrap items-center gap-1.5 font-mono text-muted-foreground"><span>{agentDefaultModel(agent) ?? "Harness default"}</span><ArrowRight className="size-3" aria-label="to" /><span className="text-foreground">{model}</span></p>}</div>)}</div>{targets.length > changed.length && <p className="text-xs text-muted-foreground">{targets.length - changed.length} already have these settings.</p>}</section>}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RevokeGrantDialog } from "@/pages/apps/app-detail/IdentitiesSection";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AiConnectionSummary } from "./model";
|
||||
import { describeError } from "@/api/errors";
|
||||
|
||||
/** AI-only account controls; identity, access and navigation belong to AppDetail. */
|
||||
export function AiConnectionAccountControls({
|
||||
@@ -54,7 +55,7 @@ export function AiConnectionAccountControls({
|
||||
</div>
|
||||
{revoking && <RevokeGrantDialog grant={grant} providerName={account.name} pending={revokePending} credentialPolicy={account.ownership === "shared" ? "shared" : "per_user"} isOwnIdentity={account.ownerUserId === currentUserId}
|
||||
description="New runs using this account will be blocked. Existing runs may retain credentials already issued to them. No other account will be selected automatically."
|
||||
onCancel={() => setRevoking(false)} onConfirm={async () => { setRevokePending(true); setRevokeError(undefined); try { await onRevoke(); setRevoking(false); } catch (error) { setRevokeError(error instanceof Error ? error.message : "Could not revoke this account. Retry."); } finally { setRevokePending(false); } }}>{revokeError && <p role="alert" className="text-sm text-destructive">{revokeError}</p>}{revocationDetails}</RevokeGrantDialog>}
|
||||
onCancel={() => setRevoking(false)} onConfirm={async () => { setRevokePending(true); setRevokeError(undefined); try { await onRevoke(); setRevoking(false); } catch (error) { setRevokeError(describeError(error).body); } finally { setRevokePending(false); } }}>{revokeError && <p role="alert" className="text-sm text-destructive">{revokeError}</p>}{revocationDetails}</RevokeGrantDialog>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 <div className="mx-auto w-full min-w-0 max-w-xl space-y-6">
|
||||
{!hideName && <label className="block space-y-2 text-sm">Connection name<Input value={name} onChange={(event) => setName(event.target.value)} disabled={Boolean(connectionId)} /></label>}
|
||||
@@ -83,7 +91,8 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM
|
||||
<SelectTrigger aria-label="Sign-in environment"><SelectValue placeholder="Sign-in environment" /></SelectTrigger>
|
||||
<SelectContent>{loginEnvironments.map((env) => <SelectItem key={env.id} value={env.id}>{env.name}</SelectItem>)}</SelectContent>
|
||||
</Select>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
{loadError ? <QueryErrorState size="inline" error={loadError.error} action="prepare sign-in" onRetry={loadError.retry} retrying={loadError.isFetching} />
|
||||
: environmentError ? <p role="alert" className="text-sm text-destructive">{environmentError}</p> : null}
|
||||
{defaults}
|
||||
{loading ? <p role="status" className="text-sm text-muted-foreground">Preparing sign-in…</p> : <AgentProviderConnection
|
||||
key={environmentId ?? "local"}
|
||||
@@ -95,7 +104,7 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM
|
||||
onBack={onCancel}
|
||||
onConnected={() => {}}
|
||||
testConnection={async () => false}
|
||||
managedAccount={{ intent, nameForMethod, initialMethod, fixedMethod: fixedMethod ?? Boolean(connectionId), disabled: disabled || loading || Boolean(error) || !name.trim(), onComplete: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); void client.invalidateQueries({ queryKey: ["tools"] }); onComplete(result); } }}
|
||||
managedAccount={{ intent, nameForMethod, initialMethod, fixedMethod: fixedMethod ?? Boolean(connectionId), disabled: disabled || loading || Boolean(environmentError) || Boolean(loadError) || !name.trim(), onComplete: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); void client.invalidateQueries({ queryKey: ["tools"] }); onComplete(result); } }}
|
||||
/>}
|
||||
</div>;
|
||||
}
|
||||
@@ -118,7 +127,7 @@ function ApiKeyConnectionStep({ companyId, provider, connectionId, name: initial
|
||||
});
|
||||
return <div className="mx-auto w-full min-w-0 max-w-xl space-y-4">
|
||||
{!hideName && <label className="block space-y-2 text-sm">Connection name<Input value={name} onChange={(event) => setName(event.target.value)} disabled={Boolean(connectionId)} /></label>}
|
||||
{save.error && <p role="alert" className="text-sm text-destructive">{save.error.message}</p>}
|
||||
{save.error && <p role="alert" className="text-sm text-destructive">{describeError(save.error).body}</p>}
|
||||
{copilot && <p className="text-sm text-muted-foreground">Use a personal fine-grained token with Copilot Requests permission. <a className="underline" href="https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/authenticate-copilot-cli" target="_blank" rel="noreferrer">GitHub token instructions</a>. Verification discovers models in the selected environment and sends no model prompt. Repository access uses a separate connection.</p>}
|
||||
{copilot && !suppliedEnvironmentId && <Select value={environmentId ?? ""} onValueChange={setChosenEnvironment} disabled={preparing || save.isPending}>
|
||||
<SelectTrigger aria-label="Copilot execution environment"><SelectValue placeholder="Execution environment" /></SelectTrigger>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { AiManagedConnectionSummary } from "@paperclipai/shared";
|
||||
import { AiConnectionField, aiProviderForAdapter } from "./AiConnectionField";
|
||||
import { ApiError } from "@/api/client";
|
||||
import type { AiConnectionCredentialStep } from "./AiConnectionCredentialStep";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ list: vi.fn(), setDefault: vi.fn() }));
|
||||
@@ -207,3 +208,30 @@ it.each(["openai", "anthropic", "google", "openrouter"] as const)("keeps a match
|
||||
expect(choice).toBeDefined();
|
||||
expect(choice?.getAttribute("aria-disabled")).not.toBe("true");
|
||||
});
|
||||
|
||||
it("keeps loaded connections usable when a refetch fails during an outage", async () => {
|
||||
await mount([account()]);
|
||||
expect(document.body.textContent).toContain("Reconnect account");
|
||||
mocks.list.mockRejectedValue(new ApiError("Paperclip is restarting.", 503, { error: "tenant_app_unavailable" }));
|
||||
await client.invalidateQueries();
|
||||
await settle();
|
||||
expect(document.body.textContent).toContain("Reconnect account");
|
||||
expect(document.body.textContent).not.toContain("Retry connections");
|
||||
expect(document.body.textContent).not.toContain("Paperclip is restarting.");
|
||||
expect(document.querySelector('[data-query-view="error"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows readable copy with a retry when connections cannot be loaded", async () => {
|
||||
mocks.list.mockRejectedValueOnce(new ApiError("Request failed: 500", 500, { error: "internal_error" }));
|
||||
await mount([account()]);
|
||||
const alerts = Array.from(document.querySelectorAll('[role="alert"]')).map(node => node.textContent).join(" ");
|
||||
expect(alerts).toContain("An unexpected error occurred");
|
||||
expect(document.body.textContent).not.toContain("internal_error");
|
||||
expect(document.body.textContent).not.toContain("Request failed: 500");
|
||||
expect(document.body.textContent).not.toContain("Reconnect account");
|
||||
await click("Retry connections");
|
||||
await settle();
|
||||
expect(mocks.list).toHaveBeenCalledTimes(2);
|
||||
expect(document.body.textContent).toContain("Reconnect account");
|
||||
expect(document.body.textContent).not.toContain("Retry connections");
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
type AiManagedConnectionSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { describeError } from "@/api/errors";
|
||||
import { useQueryView } from "@/components/QueryView";
|
||||
import { AiConnectionSelect } from "./AiConnectionSelect";
|
||||
import { AiProviderSetup } from "./AiProviderSetup";
|
||||
import { AiConnectionCredentialStep } from "./AiConnectionCredentialStep";
|
||||
@@ -90,6 +92,7 @@ export function AiConnectionField({
|
||||
queryFn: () => aiConnectionsApi.list(companyId, agentId),
|
||||
enabled: Boolean(provider),
|
||||
});
|
||||
const accountsView = useQueryView(accounts);
|
||||
const personalDefault = accounts.data?.connections.find((account) => account.provider === provider && account.isDefault && account.ownership === "personal" && account.ownerUserId === accounts.data.currentUserId);
|
||||
const pendingAccount = pendingAdoption?.mode === "responsible_user"
|
||||
? personalDefault
|
||||
@@ -160,14 +163,14 @@ export function AiConnectionField({
|
||||
agentId={agentId ?? ""}
|
||||
agentName={agentName}
|
||||
readOnly={readOnly}
|
||||
loading={accounts.isPending}
|
||||
error={accounts.error?.message}
|
||||
loading={accounts.isPending || accountsView.kind === "reconnecting"}
|
||||
error={accountsView.kind === "error" ? describeError(accounts.error).body : undefined}
|
||||
onChange={(binding) =>
|
||||
changeBinding(aiConnectionBindingSchema.parse(binding))
|
||||
}
|
||||
onConnect={() => openConnection()}
|
||||
onReconnect={(!value || value.mode === "responsible_user") && personalDefault && personalDefault.status !== "connected" ? () => openConnection(personalDefault) : undefined}
|
||||
onRetry={() => void accounts.refetch()}
|
||||
onRetry={accountsView.retry}
|
||||
/>}
|
||||
<Dialog
|
||||
open={Boolean(pendingAdoption)}
|
||||
@@ -234,7 +237,7 @@ export function AiConnectionField({
|
||||
</label>}
|
||||
{savedAccount ? <div className="space-y-4">
|
||||
{selectDefault.error ? <>
|
||||
<p role="alert" className="text-sm text-destructive">{selectDefault.error.message}</p>
|
||||
<p role="alert" className="text-sm text-destructive">{describeError(selectDefault.error).body}</p>
|
||||
<Button onClick={() => selectDefault.mutate(savedAccount)}>Retry default selection</Button>
|
||||
</> : <p role="status" className="text-sm text-muted-foreground">Selecting your default account…</p>}
|
||||
</div> :
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, ExternalLink, Loader2, MoreHorizontal, Plus, Search
|
||||
import { aiConnectionRouterAppDefinition, type AiConnectionPool, type AiConnectionPoolConfig, type AiConnectionPoolMember, type AiManagedConnectionSummary, type ToolConnection } from "@paperclipai/shared";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { aiConnectionPoolsApi, type PoolInspection } from "@/api/ai-connection-pools";
|
||||
import { describeError } from "@/api/errors";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
@@ -15,6 +16,7 @@ import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import { AppDetailHeader } from "@/pages/apps/AppDetail";
|
||||
import { StepHeader } from "@/features/connections/ConnectionSetupHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorState, useQueryView } from "@/components/QueryView";
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -47,6 +49,9 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string
|
||||
const accountsQuery = useQuery({ queryKey: ["pool-accounts", companyId], queryFn: () => aiConnectionsApi.list(companyId) });
|
||||
const poolsQuery = useQuery({ queryKey: ["ai-connection-pools", companyId], queryFn: () => aiConnectionPoolsApi.list(companyId), enabled: accountsQuery.data?.canManageConnections === true });
|
||||
const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(companyId), queryFn: () => toolsApi.listGallery(companyId) });
|
||||
const accountsView = useQueryView(accountsQuery);
|
||||
const poolsView = useQueryView(poolsQuery);
|
||||
const galleryView = useQueryView(galleryQuery);
|
||||
const [editing, setEditing] = useState<AiConnectionPool>();
|
||||
const [draft, setDraft] = useState(emptyConfig);
|
||||
const [step, setStep] = useState(0);
|
||||
@@ -65,6 +70,7 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string
|
||||
const canManage = Boolean(accountsQuery.data?.canManageConnections);
|
||||
const disabled = busy || !canManage || unavailable;
|
||||
const agentsQuery = useQuery({ queryKey: queryKeys.agents.list(companyId), queryFn: () => agentsApi.list(companyId), enabled: Boolean(connection) && canManage });
|
||||
const agentsView = useQueryView(agentsQuery);
|
||||
const usingAgents = (agentsQuery.data ?? []).filter(agent => agent.status !== "terminated" && agent.runtimeConfig?.aiConnection?.mode === "router" && agent.runtimeConfig.aiConnection.connectionId === connection?.id).sort((a, b) => a.name.localeCompare(b.name));
|
||||
useEffect(() => {
|
||||
if (connection && !editing && canManage) {
|
||||
@@ -98,23 +104,25 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string
|
||||
await invalidate();
|
||||
pushToast({ title: connection ? "Connection pool saved" : "Connection pool created", tone: "success" });
|
||||
if (!connection) navigate(`/apps/${saved.id}/permissions`);
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
} catch (cause) { setError(describeError(cause).body); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function remove() {
|
||||
if (!editing) return;
|
||||
setBusy(true); setError("");
|
||||
try { await aiConnectionPoolsApi.remove(companyId, editing.id, editing.revision); await invalidate(); navigate("/apps"); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
catch (cause) { setError(describeError(cause).body); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
function move(index: number, offset: number) {
|
||||
setDraft(value => { const members = [...value.members]; [members[index], members[index + offset]] = [members[index + offset]!, members[index]!]; return { ...value, members }; });
|
||||
}
|
||||
const loadError = accountsQuery.error ?? poolsQuery.error ?? galleryQuery.error;
|
||||
if (accountsQuery.isSuccess && !canManage) return <p role="alert" className="text-sm text-muted-foreground">A connection manager can view and edit connection pools.</p>;
|
||||
if (loadError) return <div className="space-y-4"><p role="alert" className="text-sm text-destructive">{loadError.message}</p><Button variant="outline" onClick={() => { void accountsQuery.refetch(); void poolsQuery.refetch(); void galleryQuery.refetch(); }}>Try again</Button></div>;
|
||||
if (accountsQuery.isPending || poolsQuery.isPending || galleryQuery.isPending || (connection && !editing && poolsQuery.data?.some(pool => pool.id === connection.id))) return <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Loading connections…</p>;
|
||||
// Loaded data stays on screen through a transient refetch failure; only a real failure replaces the pool.
|
||||
const failedViews = [accountsView, poolsView, galleryView].filter(view => view.kind === "error");
|
||||
const reconnecting = [accountsView, poolsView, galleryView].some(view => view.kind === "reconnecting");
|
||||
if (accountsQuery.data && !canManage) return <p role="alert" className="text-sm text-muted-foreground">A connection manager can view and edit connection pools.</p>;
|
||||
if (failedViews.length > 0) return <QueryErrorState error={failedViews[0]!.error} action="load connections" onRetry={() => failedViews.forEach(view => view.retry())} retrying={failedViews.some(view => view.isFetching)} />;
|
||||
if (accountsQuery.isPending || poolsQuery.isPending || galleryQuery.isPending || reconnecting || (connection && !editing && poolsQuery.data?.some(pool => pool.id === connection.id))) return <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Loading connections…</p>;
|
||||
if (connection && !editing) return <p role="alert">This connection pool is no longer available.</p>;
|
||||
const name = entry?.name ?? "AI connection pool";
|
||||
const logoEntry = entry ?? aiConnectionRouterAppDefinition(pluginKey, { name, description: "Use existing AI connections." });
|
||||
@@ -156,8 +164,8 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string
|
||||
</fieldset></details>
|
||||
<section aria-labelledby="pool-used-by" className="space-y-3">
|
||||
<h2 id="pool-used-by" className="text-sm font-semibold">Used by</h2>
|
||||
{agentsQuery.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading agents…</p>
|
||||
: agentsQuery.isError ? <div className="flex items-center gap-2"><p role="alert" className="text-sm text-muted-foreground">Couldn’t load agents.</p><Button variant="ghost" size="sm" onClick={() => void agentsQuery.refetch()}>Retry</Button></div>
|
||||
{agentsQuery.isPending || agentsView.kind === "reconnecting" ? <p role="status" className="text-sm text-muted-foreground">Loading agents…</p>
|
||||
: agentsView.kind === "error" ? <QueryErrorState size="inline" error={agentsQuery.error} action="load agents" onRetry={agentsView.retry} retrying={agentsView.isFetching} />
|
||||
: usingAgents.length ? <ul className="flex flex-wrap gap-x-6 gap-y-3">{usingAgents.map(agent => <li key={agent.id} className="min-w-0 max-w-full"><Link to={`/agents/${agent.id}`} className="inline-flex max-w-full rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><AgentIdentity agent={agent} /></Link></li>)}</ul>
|
||||
: <p className="text-sm text-muted-foreground">No agents yet.</p>}
|
||||
</section>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import type { AiConnectionUsage, AiConnectionUsageLimit, AiManagedConnectionSummary } from "@paperclipai/shared";
|
||||
import { supportsAiConnectionUsage } from "@paperclipai/shared";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { describeError } from "@/api/errors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QuotaBar } from "@/components/QuotaBar";
|
||||
import { formatDateTime, formatNumber } from "@/lib/utils";
|
||||
@@ -88,7 +89,7 @@ export function AiConnectionUsagePanel({ account, observation, cachedOnly = fals
|
||||
</div>
|
||||
{cachedOnly && !usage && <p className="text-xs text-muted-foreground">Usage not observed.</p>}
|
||||
{!supported && <p className="text-xs text-muted-foreground">Unavailable for this sign-in method.</p>}
|
||||
{probe.error && <p role="alert" className="text-sm text-destructive">{probe.error.message}</p>}
|
||||
{probe.error && <p role="alert" className="text-sm text-destructive">{describeError(probe.error).body}</p>}
|
||||
{usage && usage.status !== "ok" && <p role={usage.status === "unsupported" ? "status" : "alert"} className="text-sm text-muted-foreground">{usageError(usage)}</p>}
|
||||
{usage?.status === "ok" && (
|
||||
<div className="space-y-3" aria-live="polite">
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from "@paperclipai/shared";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { describeError } from "@/api/errors";
|
||||
import { QueryErrorState, useQueryView } from "@/components/QueryView";
|
||||
import { ConnectionChoiceList } from "@/features/connections/ConnectionChoiceList";
|
||||
import { ConnectionAccessDefaults, connectionDefaultSummarySentence } from "@/features/connections/ConnectionSetupFlow";
|
||||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
@@ -126,6 +128,7 @@ export function AiProviderSetup({
|
||||
queryKey: ["ai-connections", companyId, agentId],
|
||||
queryFn: () => aiConnectionsApi.list(companyId, agentId),
|
||||
});
|
||||
const accountsView = useQueryView(accounts);
|
||||
const canManageConnections = accounts.data?.canManageConnections ?? false;
|
||||
const ownership = reconnect?.ownership ?? ownershipChoice ?? (!agentId && canManageConnections ? "shared" : "personal");
|
||||
const allAgents = allAgentsChoice ?? (!agentId && canManageConnections);
|
||||
@@ -133,6 +136,7 @@ export function AiProviderSetup({
|
||||
queryKey: ["agents", companyId, "provider-access"],
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
});
|
||||
const agentsView = useQueryView(agents);
|
||||
const label = providerLabel ?? providers.find((p) => p.id === provider)?.name ?? (provider === "github" ? "GitHub Copilot" : "provider");
|
||||
const advanced = reconnect ? Boolean(reconnect.routing) : ["openrouter", "bedrock", "gateway", "local"].includes(provider ?? "");
|
||||
const nativeProvider: AiProvider =
|
||||
@@ -272,8 +276,8 @@ export function AiProviderSetup({
|
||||
/>
|
||||
) : undefined;
|
||||
const cancel = () => reconnect || initialProvider ? onCancel() : setStep("provider");
|
||||
if (!reconnect && accounts.isPending) return <p role="status">Loading connection permissions…</p>;
|
||||
if (!reconnect && accounts.isError) return <p role="alert">Could not load connection permissions. <Button type="button" variant="ghost" onClick={() => void accounts.refetch()}>Retry</Button></p>;
|
||||
if (!reconnect && (accounts.isPending || accountsView.kind === "reconnecting")) return <p role="status">Loading connection permissions…</p>;
|
||||
if (!reconnect && accountsView.kind === "error") return <QueryErrorState error={accounts.error} action="load connection permissions" onRetry={accountsView.retry} retrying={accountsView.isFetching} />;
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl space-y-6" onSubmit={(event) => event.stopPropagation()}>
|
||||
<div className="space-y-2">
|
||||
@@ -283,7 +287,7 @@ export function AiProviderSetup({
|
||||
: `${reconnect ? "Reconnect" : "Connect"} ${label}`}
|
||||
</h2>
|
||||
</div>
|
||||
{agents.isError && <p role="alert" className="text-sm text-destructive">Could not load agents. <Button type="button" variant="ghost" onClick={() => void agents.refetch()}>Retry</Button></p>}
|
||||
{agentsView.kind === "error" && <QueryErrorState size="inline" error={agents.error} action="load agents" onRetry={agentsView.retry} retrying={agentsView.isFetching} />}
|
||||
{step === "provider" ? (
|
||||
<>
|
||||
{advancedOnly ? choices(true) : <>
|
||||
@@ -435,7 +439,7 @@ export function AiProviderSetup({
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{save.error instanceof Error && save.error.name === "ZodError"
|
||||
? "Check the URL, API format, and authentication fields."
|
||||
: save.error.message}
|
||||
: describeError(save.error).body}
|
||||
</p>
|
||||
)}
|
||||
{accessDefaults}
|
||||
@@ -451,7 +455,7 @@ export function AiProviderSetup({
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
save.isPending || accounts.isPending || accounts.isError ||
|
||||
save.isPending || accounts.isPending || accountsView.kind === "reconnecting" || accountsView.kind === "error" ||
|
||||
(!reconnect && ownership === "shared" && !allAgents && agentIds.size === 0) ||
|
||||
(auth !== "none" && !apiKey.trim())
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { heartbeatsApi } from "@/api/heartbeats";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { describeError } from "@/api/errors";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { QueryErrorState, useQueryView } from "@/components/QueryView";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { AiConnectionAccountControls } from "./AiConnectionAccountControls";
|
||||
import { AiConnectionUsagePanel } from "./AiConnectionUsagePanel";
|
||||
@@ -11,7 +13,6 @@ import type { ToolConnection, AiConnectionMetadata } from "@paperclipai/shared";
|
||||
import { aiMethodLabel } from "./model";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { retryTransientOnly } from "@/lib/query-client";
|
||||
import { describeError } from "@/api/errors";
|
||||
|
||||
export function ManagedAiConnectionRow({
|
||||
connection,
|
||||
@@ -51,6 +52,9 @@ export function ManagedAiConnectionDetails({
|
||||
queryFn: ({ signal }) => toolsApi.listConnectionGrants(connection.id, { signal }),
|
||||
retry: retryTransientOnly(1),
|
||||
});
|
||||
const runsView = useQueryView(runs);
|
||||
const accountsView = useQueryView(accounts);
|
||||
const grantsView = useQueryView(grants);
|
||||
const refresh = () => client.invalidateQueries();
|
||||
const makeDefault = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
@@ -70,7 +74,7 @@ export function ManagedAiConnectionDetails({
|
||||
(a) => a.id === connection.id,
|
||||
);
|
||||
const grant = grants.data?.grants.find((g) => g.id === account?.grantId);
|
||||
const loadError = accounts.error ?? grants.error;
|
||||
const loadError = [accountsView, grantsView].find((view) => view.kind === "error")?.error;
|
||||
const error = makeDefault.error ?? stop.error;
|
||||
const loadFailure = loadError ? (
|
||||
<div role="alert" className="space-y-2">
|
||||
@@ -92,7 +96,7 @@ export function ManagedAiConnectionDetails({
|
||||
if (!account || !grant)
|
||||
return (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{accounts.isPending || grants.isPending
|
||||
{accounts.isPending || grants.isPending || accountsView.kind === "reconnecting" || grantsView.kind === "reconnecting"
|
||||
? "Loading AI account…"
|
||||
: "This account is not available to you."}
|
||||
</p>
|
||||
@@ -100,7 +104,7 @@ export function ManagedAiConnectionDetails({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{loadFailure}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error.message}</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{describeError(error).body}</p>}
|
||||
<AiConnectionAccountDetails account={account} grant={grant} />
|
||||
{account.routing && <details className="rounded-lg border border-border p-4"><summary className="cursor-pointer text-sm font-medium">Provider settings</summary><dl className="mt-4 grid grid-cols-2 gap-3 text-sm"><dt className="text-muted-foreground">Destination</dt><dd className="break-all">{account.routing.baseUrl ?? (account.routing.kind === "bedrock" ? account.routing.region : "OpenRouter")}</dd><dt className="text-muted-foreground">API format</dt><dd>{account.routing.kind === "openrouter" ? "Selected by harness" : account.routing.protocol}</dd><dt className="text-muted-foreground">Authentication</dt><dd>{account.routing.auth}</dd><dt className="text-muted-foreground">Models</dt><dd>{account.routing.models.map(m => m.label ?? m.id).join(", ") || "Enter a model ID on the agent"}</dd></dl></details>}
|
||||
<AiConnectionAccountControls
|
||||
@@ -111,10 +115,14 @@ export function ManagedAiConnectionDetails({
|
||||
onRevoke={() => revoke.mutateAsync(grant.id).then(() => undefined)}
|
||||
revocationDetails={
|
||||
<div className="space-y-2">
|
||||
{runs.error && (
|
||||
<p role="alert">
|
||||
Could not load active runs. Retry before revoking.
|
||||
</p>
|
||||
{runsView.kind === "error" && (
|
||||
<QueryErrorState
|
||||
size="inline"
|
||||
error={runs.error}
|
||||
action="load active runs"
|
||||
onRetry={runsView.retry}
|
||||
retrying={runsView.isFetching}
|
||||
/>
|
||||
)}
|
||||
{runs.data?.map((run) => (
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { aiRoutingModel, type AiConnectionBinding, type AiProviderRouting } from "@paperclipai/shared";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { useQueryView } from "@/components/QueryView";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
export function useConnectionModelsState(
|
||||
@@ -30,8 +31,8 @@ export function useConnectionModelsState(
|
||||
queryFn: () => agentsApi.adapterModels(companyId!, "opencode_local", { provider: "openrouter" }),
|
||||
enabled: Boolean(companyId && discover),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
const catalogView = useQueryView(catalog);
|
||||
const effectiveRouting: AiProviderRouting | undefined = routing ?? (openRouter
|
||||
? { kind: "openrouter", protocol: "chat", auth: "bearer", models: [] }
|
||||
: undefined);
|
||||
@@ -44,8 +45,8 @@ export function useConnectionModelsState(
|
||||
id: aiRoutingModel(effectiveRouting, harness, m.id),
|
||||
label: m.label ?? m.id,
|
||||
})),
|
||||
isLoading: discover && catalog.isLoading,
|
||||
error: discover ? catalog.error : null,
|
||||
isLoading: discover && (catalog.isLoading || catalogView.kind === "reconnecting"),
|
||||
error: discover && catalogView.kind === "error" ? catalog.error : null,
|
||||
refreshing: discover && catalog.isFetching,
|
||||
refreshModels: discover ? async () => { await catalog.refetch(); } : undefined,
|
||||
resolveModel: (model: string) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AiConnectionLoginIntent, LocalAiLoginAttempt, LocalAiLoginStatus } from "@paperclipai/shared";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { describeError } from "@/api/errors";
|
||||
|
||||
/** Every authentication host uses the same local credential check and login lifecycle. */
|
||||
export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLoginIntent, enabled: boolean) {
|
||||
@@ -68,7 +69,7 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo
|
||||
// browser visit; sign-in never requires repeated Connect clicks.
|
||||
if (next.status === "sign_in_required") timer = setTimeout(() => void check(), 5000);
|
||||
} catch (cause) {
|
||||
if (!cancelled) setError(cause instanceof Error ? cause.message : "Could not check local sign-in.");
|
||||
if (!cancelled) setError(describeError(cause).body);
|
||||
} finally { checking = false; }
|
||||
}
|
||||
const onFocus = () => { if (!document.hidden) void check(); };
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
CSV_PREVIEW_MAX_BYTES,
|
||||
loadArtifactCsv,
|
||||
} from "@/lib/artifact-card-data";
|
||||
import { QueryErrorState, useQueryView } from "@/components/QueryView";
|
||||
import {
|
||||
CommitCard,
|
||||
DataCard,
|
||||
@@ -83,9 +84,13 @@ export function IssueArtifactFile(props: IssueArtifactFileProps) {
|
||||
queryKey: ["artifact-csv", props.id, props.contentPath, props.updatedAt],
|
||||
queryFn: ({ signal }) => loadArtifactCsv(props.contentPath, signal),
|
||||
enabled: localCsv && !tooLarge && csvRequested,
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
// Read the CSV preview through the shared view: a loaded preview stays
|
||||
// visible during a transient refetch failure,and a no-data failure shows
|
||||
// readable copy with Retry.
|
||||
|
||||
const dataView = useQueryView(data);
|
||||
const contentPath = artifactUrl(props.contentPath);
|
||||
const downloadPath = artifactUrl(props.downloadPath);
|
||||
const onOpen = () => {
|
||||
@@ -163,7 +168,7 @@ export function IssueArtifactFile(props: IssueArtifactFileProps) {
|
||||
actions={
|
||||
<>
|
||||
{openTextAction}
|
||||
{localCsv && !tooLarge && !data.isError ? (
|
||||
{localCsv && !tooLarge && dataView.kind !== "error" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -176,22 +181,13 @@ export function IssueArtifactFile(props: IssueArtifactFileProps) {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{csv && (tooLarge || data.isError || !localCsv) && (
|
||||
{csv && (tooLarge || dataView.kind === "error" || !localCsv) && (
|
||||
<div className="px-2 text-xs text-muted-foreground" role="status">
|
||||
{tooLarge
|
||||
? "CSV is too large to preview. Download the file to view it."
|
||||
: data.isError
|
||||
? data.error.message
|
||||
: dataView.kind === "error"
|
||||
? <QueryErrorState size="inline" error={dataView.error} action="load the CSV preview" onRetry={dataView.retry} retrying={dataView.isFetching} />
|
||||
: "CSV preview is unavailable. Download the file to view it."}
|
||||
{data.isError && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void data.refetch()}
|
||||
>
|
||||
Retry preview
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useQueryView } from "@/components/QueryView";
|
||||
import { Download, ExternalLink } from "lucide-react";
|
||||
import { useContext, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -54,14 +55,14 @@ export function ArtifactFileRow({
|
||||
queryKey: ["artifact-csv", id, contentPath, updatedAtLabel],
|
||||
queryFn: ({ signal }) => loadArtifactCsv(contentPath, signal),
|
||||
enabled: localCsv && !tooLarge && csvRequested,
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const dataView = useQueryView(data);
|
||||
|
||||
const trailing = (
|
||||
<>
|
||||
{statusBadge}
|
||||
{localCsv && !tooLarge && !data.data && !data.isError ? (
|
||||
{localCsv && !tooLarge && !data.data && !(dataView.kind === "error") ? (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={data.isFetching} onClick={() => setCsvRequested(true)}>
|
||||
{data.isFetching ? "Loading preview…" : "Preview data"}
|
||||
</Button>
|
||||
@@ -96,10 +97,10 @@ export function ArtifactFileRow({
|
||||
{localCsv && data.data ? (
|
||||
<DataCard title={filename} summary="" author="" updatedAt={updatedAtLabel} filename={filename} {...data.data} downloadUrl={downloadPath} />
|
||||
) : null}
|
||||
{csv && (tooLarge || data.isError) ? (
|
||||
{csv && (tooLarge || (dataView.kind === "error")) ? (
|
||||
<div className="px-2 text-xs text-muted-foreground" role="status">
|
||||
{tooLarge ? "CSV is too large to preview. Download the file to view it." : data.error?.message}
|
||||
{data.isError ? (
|
||||
{(dataView.kind === "error") ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => void data.refetch()}>
|
||||
Retry preview
|
||||
</Button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useQueryView } from "@/components/QueryView";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FileText } from "lucide-react";
|
||||
@@ -23,6 +24,7 @@ export function CaseDocumentPreview({ caseIdentifier, document: documentRef }: {
|
||||
queryFn: () => taskRef ? issuesApi.getDocument(taskRef.taskId, taskRef.key) : casesApi.getDocument(caseIdentifier, documentRef.key),
|
||||
enabled: open,
|
||||
});
|
||||
const currentView = useQueryView(current);
|
||||
const document = current.data;
|
||||
const title = document?.title ?? documentRef.document.title ?? documentRef.key;
|
||||
return <Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -41,7 +43,7 @@ export function CaseDocumentPreview({ caseIdentifier, document: documentRef }: {
|
||||
{document?.latestRevisionNumber != null ? ` · Revision ${document.latestRevisionNumber}` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{current.isLoading ? <p className="text-sm text-muted-foreground">Loading document…</p> : current.isError ? <p role="alert" className="text-sm text-destructive">This document couldn’t be loaded.</p> : document ? <div className="space-y-6">
|
||||
{current.isLoading ? <p className="text-sm text-muted-foreground">Loading document…</p> : (currentView.kind === "error") ? <p role="alert" className="text-sm text-destructive">This document couldn’t be loaded.</p> : document ? <div className="space-y-6">
|
||||
<SourceTrustBadge sourceTrust={document.sourceTrust} artifactLabel="document" />
|
||||
{document.body ? <MarkdownBody linkIssueReferences linkCaseReferences>{document.body}</MarkdownBody> : <p className="text-sm text-muted-foreground">This document is empty.</p>}
|
||||
<details open={historyOpen} onToggle={event => setHistoryOpen(event.currentTarget.open)} className="space-y-3">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useQueryView } from "@/components/QueryView";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, ChevronRight } from "lucide-react";
|
||||
import { casesApi, type CaseHandoffView, type CaseWorkIssue } from "@/api/cases";
|
||||
@@ -26,12 +27,14 @@ export function CaseHandoffHistory({ caseId, task, agents }: { caseId: string; t
|
||||
queryFn: ({ pageParam }) => casesApi.listHandoffs(caseId, pageParam),
|
||||
getNextPageParam: page => page.nextCursor ?? undefined,
|
||||
});
|
||||
const historyView = useQueryView(history);
|
||||
const waiting = ["loading", "reconnecting"].includes(historyView.kind);
|
||||
const handoffs = history.data?.pages.flatMap(page => page.items) ?? [];
|
||||
return <section className="space-y-3" aria-label="Case handoffs">
|
||||
<h2 className="text-sm font-semibold">Handoffs</h2>
|
||||
{history.isLoading ? <p className="text-sm text-muted-foreground">Loading handoffs…</p> : null}
|
||||
{history.isError ? <p role="alert" className="text-sm text-destructive">Handoffs couldn’t be loaded.</p> : null}
|
||||
{!history.isLoading && !history.isError && handoffs.length === 0 ? <p className="text-sm text-muted-foreground">No handoffs yet.</p> : null}
|
||||
{waiting ? <p className="text-sm text-muted-foreground">Loading handoffs…</p> : null}
|
||||
{(historyView.kind === "error") ? <p role="alert" className="text-sm text-destructive">Handoffs couldn’t be loaded.</p> : null}
|
||||
{!waiting && !(historyView.kind === "error") && handoffs.length === 0 ? <p className="text-sm text-muted-foreground">No handoffs yet.</p> : null}
|
||||
<ol className="divide-y divide-border">{handoffs.map(handoff => <HandoffRow key={handoff.id} handoff={handoff} task={task} agents={agents} />)}</ol>
|
||||
{history.hasNextPage ? <Button variant="ghost" size="sm" disabled={history.isFetchingNextPage} onClick={() => void history.fetchNextPage()}>Load older handoffs</Button> : null}
|
||||
</section>;
|
||||
|
||||
@@ -27,6 +27,8 @@ import { classifySendFailure, type SendOutcome } from "@/lib/pending-send";
|
||||
import { useAutoResend } from "@/hooks/useDurableSubmit";
|
||||
import { useDraftOwner, useDraftOwnerReader } from "@/hooks/useDraftOwner";
|
||||
import { useAccountBoundMutation } from "@/hooks/useAccountBoundMutation";
|
||||
import { retryTransientOnly } from "@/lib/query-client";
|
||||
import { useQueryView } from "@/components/QueryView";
|
||||
import {
|
||||
boardSendDraftKey,
|
||||
clearBoardSendDraft,
|
||||
@@ -301,8 +303,12 @@ function ConnectedTaskComposer({
|
||||
staleTime: 0,
|
||||
refetchInterval: 2_000,
|
||||
refetchIntervalInBackground: false,
|
||||
retry: false,
|
||||
retry: retryTransientOnly(0),
|
||||
});
|
||||
// Read publication status through the shared view: the receipt render uses
|
||||
// the cached batch during transient refetch failures,and a no-data failure
|
||||
// drops into readable copy.
|
||||
const publicationStatusView = useQueryView(publicationStatus);
|
||||
useEffect(() => {
|
||||
const batch = publicationStatus.data;
|
||||
if (
|
||||
@@ -462,7 +468,7 @@ function ConnectedTaskComposer({
|
||||
} catch (error) {
|
||||
if (mounted.current) {
|
||||
setUploadError(
|
||||
`${error instanceof Error ? error.message : "Upload could not be confirmed."} No channel message was sent. Check task files before retrying the upload.`,
|
||||
`${describeError(error).body} No channel message was sent. Check task files before retrying the upload.`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -518,7 +524,7 @@ function ConnectedTaskComposer({
|
||||
const currentPublication = publicationStatus.data?.publication ?? publication;
|
||||
const batch = publicationStatus.data;
|
||||
const dismissible =
|
||||
!publicationStatus.isError &&
|
||||
Boolean(publicationStatus.data) &&
|
||||
!publicationStatus.isFetching &&
|
||||
canDismissBoardSendBatch(batch);
|
||||
const mixedTerminal =
|
||||
@@ -853,7 +859,7 @@ function ConnectedTaskComposer({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{publicationStatus.isError && (
|
||||
{publicationStatusView.kind === "error" && (
|
||||
<p role="alert" className="text-muted-foreground">
|
||||
Delivery status could not be refreshed. Your draft is kept;
|
||||
Paperclip will check again without sending another update.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useQueryView } from "@/components/QueryView";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
@@ -16,6 +17,8 @@ export function CoordinatorAttentionTasks({ toolbar }: { toolbar: ReactNode }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const feed = useQuery({ queryKey: coordinatorAttentionQueryKey(selectedCompanyId!),
|
||||
queryFn: () => attentionApi.list(selectedCompanyId!, { all: true }), enabled: !!selectedCompanyId, refetchOnWindowFocus: true });
|
||||
const feedView = useQueryView(feed);
|
||||
const waiting = ["loading", "reconnecting"].includes(feedView.kind);
|
||||
const items = feed.data?.items ?? [];
|
||||
const ids = pendingDecisionTaskIds(items);
|
||||
const tasks = [...new Map(items.flatMap((item) => {
|
||||
@@ -25,9 +28,9 @@ export function CoordinatorAttentionTasks({ toolbar }: { toolbar: ReactNode }) {
|
||||
return <section className="space-y-5">
|
||||
<h1 className="text-2xl font-medium">Tasks</h1>
|
||||
<div className="flex flex-wrap items-center gap-3">{toolbar}<ToolbarSearch query={search} onQueryChange={setSearch} label="Search tasks needing attention" placeholder="Search tasks…" /></div>
|
||||
{feed.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading tasks…</p> : null}
|
||||
{feed.isError ? <div role="alert" className="space-y-2"><p>Tasks needing attention could not be loaded.</p><Button variant="outline" onClick={() => void feed.refetch()}>Retry</Button></div> : null}
|
||||
{!feed.isPending && !feed.isError && !tasks.length ? <p className="py-8 text-sm text-muted-foreground">{search ? "No tasks match this search." : "No tasks need a decision."}</p> : null}
|
||||
{waiting ? <p role="status" className="text-sm text-muted-foreground">Loading tasks…</p> : null}
|
||||
{(feedView.kind === "error") ? <div role="alert" className="space-y-2"><p>Tasks needing attention could not be loaded.</p><Button variant="outline" onClick={() => void feed.refetch()}>Retry</Button></div> : null}
|
||||
{!waiting && !(feedView.kind === "error") && !tasks.length ? <p className="py-8 text-sm text-muted-foreground">{search ? "No tasks match this search." : "No tasks need a decision."}</p> : null}
|
||||
<div className="divide-y divide-border">{tasks.map((task) => <button key={task.id} type="button" disabled={!task.href} onClick={() => { if (task.href) navigate(task.href); }} className="flex w-full items-center gap-3 py-4 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<StatusIcon status={task.status ?? "todo"} /><span className="text-sm text-muted-foreground">{task.identifier}</span><span className="flex-1 text-sm">{task.title}</span><span className="text-sm text-muted-foreground">{items.filter((item) => item.relatedIssue?.id === task.id || item.subject.id === task.id).length} pending</span><ArrowUpRight className="size-4 text-muted-foreground" />
|
||||
</button>)}</div>
|
||||
|
||||
@@ -24,8 +24,7 @@ export function useCoordinatorChatDecisionReceipts(issueId: string | null, conve
|
||||
return api.get<DecisionHistoryPage>(path, { signal, cache: "no-store" });
|
||||
},
|
||||
getNextPageParam: page => page.nextCursor,
|
||||
retry: false,
|
||||
});
|
||||
});
|
||||
const accessUnavailable = !!query.error && "status" in query.error && [401, 403, 404].includes(Number(query.error.status));
|
||||
const receipts = useMemo(() => {
|
||||
if (!enabled || !conversationMode || accessUnavailable) return [];
|
||||
|
||||
@@ -21,8 +21,7 @@ export function useCoordinatorChatPendingQuestions(issueId: string | null, conve
|
||||
detachInflightGet(path);
|
||||
return api.get<AttentionItem[]>(path, { signal, cache: "no-store" });
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
});
|
||||
const accessUnavailable = !!query.error && "status" in query.error && [401, 403, 404].includes(Number(query.error.status));
|
||||
return { ...query, enabled: active, questions: active && !accessUnavailable ? query.data ?? EMPTY_QUESTIONS : EMPTY_QUESTIONS };
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ export function useCoordinatorChatTaskHandoffs(issueId: string | null, conversat
|
||||
return api.get<ConversationTaskHandoffPage>(path, { signal, cache: "no-store" });
|
||||
},
|
||||
getNextPageParam: page => page.nextCursor,
|
||||
retry: false,
|
||||
});
|
||||
});
|
||||
const accessUnavailable = !!query.error && "status" in query.error && [401, 403, 404].includes(Number(query.error.status));
|
||||
const handoffs = useMemo(() => {
|
||||
if (!enabled || !conversationMode || accessUnavailable) return [];
|
||||
|
||||
Loaded 100 of 330 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user