fix(ui): present missing costs as an informational notice (#15459)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Costs dashboard shows spending and gaps in cost data.
> - Historical usage can have token counts without a recorded price.
> - The red notice implies that the user must fix those records.
> - This pull request shows the missing costs as a neutral note below
the totals.
> - Users can see the limit of the totals without a false call to
action.

## Linked Issues or Issue Description

Refs #14997.

**What happened?**

The Costs dashboard shows a red notice when a selected period contains
usage without prices. It also shows “0 runs await accounting” when no
runs are pending. Historical pricing gaps can therefore look like
current errors that need cleanup.

**Expected behavior**

Show a neutral explanation below the totals. State that totals include
only known costs. Show pending runs separately and only when the count
is positive.

**Steps to reproduce**

1. Open Costs for a period with unpriced usage and no pending runs.
2. Observe the red notice above the totals.
3. Apply this change. Confirm that the notice is muted and below the
totals, with no zero-count pending message.

**Paperclip version or commit**

Reproduced on master after #14997.

**Deployment mode**

Board UI in both the embedded Activity page and the standalone Costs
page.

## What Changed

- Move the cost-data notice below the summary tiles and use the muted
text token.
- Explain unavailable prices with “Totals include known costs only.”
- Separate pending runs from missing prices. Omit zero counts and use
singular or plural copy as needed.
- Update the existing UI tests for both page variants, including
missing-only, pending-only, and fully accounted states after refresh.

## Verification

- `pnpm --dir ui exec vitest run src/pages/Costs.test.tsx`: 21 tests
passed on the final commit. Both page variants cover mixed,
missing-only, pending-only, and fully accounted states.
- `pnpm check:token-gates`: passed on the final commit.
- `pnpm build`: passed locally.
- `pnpm -r typecheck`: passed locally.
- Full Linux CI passed on `0c23544c0f17e6641cc0c8de6ed498a731748bde`,
including browser tests, server tests, shared-package tests, build, and
typecheck. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/37661883464).
- Greptile Apex: 5/5 on the final commit, with no unresolved review
threads.
- Local full-suite limit: `pnpm test:run` first failed because the fresh
checkout lacked embedded PostgreSQL library links. A runtime-skill
fixture also selected an unrelated parent directory, and a load test
timed out. After setup repair, the database suite (70 tests), skill
suite (3 tests), email suite (39 tests), and load suite (4 tests) passed
individually. The subsequent full local rerun was stopped after the
complete Linux CI run passed. This is not a claim that a full local test
invocation passed.

## Risks

Low risk. This changes presentation only. The note remains visible and
retains its accessible status role. Cost calculations, stored records,
and budget enforcement do not change. The copy does not assume that all
missing prices are historical. No schema migration or operator
documentation change is needed.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository inspection,
code editing, and test execution. The exact served model identifier and
context-window size are not exposed in this session.

## 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 either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [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 any 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: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-10-07 12:34:12 -07:00
1 parent 465140596f
commit 572cec0344
2 files changed
+33 -7

No files matched your search

+19 -2
View File
@@ -241,7 +241,7 @@ describe("Shared Costs surfaces", () => {
} finally { queryClient.clear(); vi.useRealTimers(); }
});
it.each(surfaces)("shows incomplete accounting and currency boundaries on the %s page", async (_name, props) => {
it.each(surfaces)("explains missing costs and pending runs neutrally on the %s page", async (_name, props) => {
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: false, unpricedEventCount: 2, pendingRunCount: 1 });
@@ -254,9 +254,26 @@ describe("Shared Costs surfaces", () => {
root.render(<MemoryRouter><QueryClientProvider client={queryClient}><Costs {...props} /></QueryClientProvider></MemoryRouter>);
});
await act(async () => {
await vi.waitFor(() => expect(container.textContent).toContain("Spend is incomplete: 2 usage events have no reliable price; 1 runs await accounting."));
await vi.waitFor(() => expect(container.textContent).toContain("Costs are unavailable for 2 usage entries in this period. Totals include known costs only."));
});
const notice = [...container.querySelectorAll('[role="status"]')].find(element => element.textContent?.includes("Costs are unavailable"))!;
expect(notice.classList.contains("text-muted-foreground")).toBe(true);
expect(notice.querySelector(".text-destructive")).toBeNull();
expect(notice.classList.contains("text-destructive")).toBe(false);
expect(notice.textContent).toContain("1 run is awaiting cost data.");
expect(notice.previousElementSibling?.textContent).toContain("Inference spend");
expect(container.textContent).toContain("Finance headline totals are USD only");
costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: false, unpricedEventCount: 10, pendingRunCount: 0 });
await act(async () => { await queryClient.invalidateQueries(); });
await vi.waitFor(() => expect(notice.textContent).toContain("Costs are unavailable for 10 usage entries"));
expect(notice.textContent).not.toContain("awaiting cost data");
expect(notice.textContent).not.toContain("0 runs");
costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: false, unpricedEventCount: 0, pendingRunCount: 1 });
await act(async () => { await queryClient.invalidateQueries(); });
await vi.waitFor(() => expect(notice.textContent).toBe("1 run is awaiting cost data."));
costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: true, unpricedEventCount: 0, pendingRunCount: 0 });
await act(async () => { await queryClient.invalidateQueries(); });
await vi.waitFor(() => expect(container.contains(notice)).toBe(false));
});
it.each(surfaces)("labels each agent and expanded model independently on the %s page", async (_name, props) => {
+14 -5
View File
@@ -622,11 +622,6 @@ export function Costs({
</div>
) : null}
{spendData?.summary.pricingComplete === false && (
<p role="status" className="text-sm text-destructive">
Spend is incomplete: {spendData.summary.unpricedEventCount} usage events have no reliable price; {spendData.summary.pendingRunCount} runs await accounting. Known spend is shown below.
</p>
)}
{financeData?.summary.currencies?.some((row) => row.currency !== "USD") && (
<p role="status" className="text-sm text-muted-foreground">
Finance headline totals are USD only. Other currencies are listed separately; no exchange-rate conversion is applied.
@@ -673,6 +668,20 @@ export function Costs({
icon={ArrowUpRight}
/>
</div>
{spendData?.summary.pricingComplete === false && (
<div role="status" className="space-y-1 text-sm text-muted-foreground">
{spendData.summary.unpricedEventCount > 0 && (
<p>
Costs are unavailable for {spendData.summary.unpricedEventCount} usage {spendData.summary.unpricedEventCount === 1 ? "entry" : "entries"} in this period. Totals include known costs only.
</p>
)}
{spendData.summary.pendingRunCount > 0 && (
<p>
{spendData.summary.pendingRunCount} {spendData.summary.pendingRunCount === 1 ? "run is" : "runs are"} awaiting cost data.
</p>
)}
</div>
)}
</div>
) : null}