diff --git a/apps/web/src/shell/sidebar.tsx b/apps/web/src/shell/sidebar.tsx index e009ab0b9..d388ae489 100644 --- a/apps/web/src/shell/sidebar.tsx +++ b/apps/web/src/shell/sidebar.tsx @@ -53,8 +53,7 @@ import { useMemo } from "react"; import { AVATAR_IDENTITY_CLASS, generatedAvatarStyle } from "@corbits/chat-ui"; import { createInsightsWindow, - formatUsd, - tokensLabel, + usageChromeLabel, } from "@corbits/insights/client"; import webPackage from "../../package.json"; @@ -81,7 +80,8 @@ const FEEDBACK_URL = `${webPackage.repository.url}/issues`; * One-line 7-day cost/token summary, read off the same cheap `/usage` * route the Insights landing tiles already use (CL-6132). No tenant, no * data yet, or a load error all render the same honest fallback — a plain - * "Weekly usage" link with no number — never a fabricated figure. + * "Weekly usage" link with no number — never a fabricated figure. Ready + * zero usage uses `usageChromeLabel` (`$0.00`, never `$0.00 · 0 tok`). */ function WeeklyUsageMenuItem({ onNavigate, @@ -95,10 +95,7 @@ function WeeklyUsageMenuItem({ OverallUsageSchema, ); const usage = usageQuery.kind === "ready" ? usageQuery.data : null; - const summary = - usage === null - ? null - : `${formatUsd(usage.costUsd)} · ${tokensLabel(usage.tokens) ?? "0 tok"}`; + const summary = usage === null ? null : usageChromeLabel(usage); return ( { container.remove(); }); + // CL-6877: empty weekly usage is `$0.00`, never `$0.00 · 0 tok`. + test("Weekly usage at zero spend shows $0.00 without 0 tok chrome", async () => { + stubFetch({ usageTurns: 0 }); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = await openAccountMenu(container); + + // Wait for the usage query to settle into the menu value. + for (let i = 0; i < 20; i++) { + const value = document.querySelector( + ".shell-sidebar-account-menu-usage-value", + ); + if (value?.textContent?.includes("$0.00") === true) break; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + + const value = document.querySelector( + ".shell-sidebar-account-menu-usage-value", + ); + expect(value?.textContent).toContain("$0.00"); + expect(value?.textContent).not.toContain("0 tok"); + + act(() => root.unmount()); + container.remove(); + }); + test("Log out is danger-styled and calls onSignOut", async () => { stubFetch(); const container = document.createElement("div"); diff --git a/packages/insights/README.md b/packages/insights/README.md index fb09530d9..bd8e47fa5 100644 --- a/packages/insights/README.md +++ b/packages/insights/README.md @@ -14,8 +14,8 @@ and routes so a browser bundle never drags in postgres, drizzle, or hono (see `client.test.ts`, which asserts no such import reaches this module). **Owns:** `formatUsd`, `formatCount`, `formatRate`, `durationLabel`, -`tokensLabel`, `modelsWithMissingRates` — pure formatting; `InsightsRange`, -`createInsightsWindow`, `activitySeriesForWindow`, +`tokensLabel`, `usageChromeLabel`, `modelsWithMissingRates` — pure formatting; +`InsightsRange`, `createInsightsWindow`, `activitySeriesForWindow`, `INSIGHTS_WINDOW_DAYS` — the fixed day-window math every Insights chart uses; `EMPTY_TOKEN_TOTALS`, `EMPTY_OVERALL_USAGE` — the single empty-state default so no caller invents its own zero object; and the plain `TokenTotals` diff --git a/packages/insights/src/client.test.ts b/packages/insights/src/client.test.ts index 6b1308c99..425e91a62 100644 --- a/packages/insights/src/client.test.ts +++ b/packages/insights/src/client.test.ts @@ -7,7 +7,9 @@ import { formatCount, formatUsd, INSIGHTS_WINDOW_DAYS, + tokensLabel, topModelsByCost, + usageChromeLabel, } from "./client"; // Fixed clock so range math is deterministic regardless of suite time. @@ -120,3 +122,46 @@ describe("empty usage defaults", () => { expect(formatUsd(1.5)).toBe("$1.50"); }); }); + +// CL-6877: chrome must never invent "0 tok". Zero spend is `$0.00`; zero +// tokens omit the tok segment entirely. tokensLabel and usageChromeLabel +// are the single consumer-safe path sidebar / insights share. +describe("CL-6877 usage chrome labels", () => { + const zeroTokens = { + input: 0, + cacheRead: 0, + cacheWrite: 0, + output: 0, + thinking: 0, + }; + + const someTokens = { + input: 1000, + cacheRead: 200, + cacheWrite: 0, + output: 50, + thinking: 0, + }; + + test("tokensLabel omits null and zero totals — never returns 0 tok", () => { + expect(tokensLabel(null)).toBeUndefined(); + expect(tokensLabel(zeroTokens)).toBeUndefined(); + expect(tokensLabel(EMPTY_OVERALL_USAGE.tokens)).toBeUndefined(); + expect(tokensLabel(someTokens)).toBe("1,250 tok"); + }); + + test("usageChromeLabel is cost-only at zero tokens, cost · tok when nonzero", () => { + expect(usageChromeLabel({ costUsd: 0, tokens: zeroTokens })).toBe("$0.00"); + expect( + usageChromeLabel({ costUsd: 0, tokens: EMPTY_OVERALL_USAGE.tokens }), + ).toBe("$0.00"); + expect(usageChromeLabel({ costUsd: 0, tokens: null })).toBe("$0.00"); + expect(usageChromeLabel({ costUsd: 1.5, tokens: someTokens })).toBe( + "$1.50 · 1,250 tok", + ); + expect(usageChromeLabel({ costUsd: null, tokens: someTokens })).toBe( + "— · 1,250 tok", + ); + expect(usageChromeLabel(EMPTY_OVERALL_USAGE)).not.toContain("0 tok"); + }); +}); diff --git a/packages/insights/src/client.ts b/packages/insights/src/client.ts index 75e640aca..d830c10c2 100644 --- a/packages/insights/src/client.ts +++ b/packages/insights/src/client.ts @@ -198,9 +198,32 @@ export function tokensLabel( tokens.cacheWrite + tokens.output + tokens.thinking; + // Zero total is real empty usage — omit chrome entirely. Never invent + // "0 tok" (CL-6877 / DESIGN empty-zero). + if (total === 0) return undefined; return `${total.toLocaleString()} tok`; } +/** + * Consumer-safe cost · tokens chrome for sidebar / compact usage lines. + * Zero spend stays `$0.00`; zero or absent tokens omit the tok segment — + * never `$0.00 · 0 tok`. Callers must not invent a `"0 tok"` fallback. + */ +export function usageChromeLabel(usage: { + readonly costUsd: number | null | undefined; + readonly tokens: { + readonly input: number; + readonly cacheRead: number; + readonly cacheWrite: number; + readonly output: number; + readonly thinking: number; + } | null; +}): string { + const cost = formatUsd(usage.costUsd); + const tok = tokensLabel(usage.tokens); + return tok === undefined ? cost : `${cost} · ${tok}`; +} + /** Models with tokens but no known rate (costUsd null). */ export function modelsWithMissingRates(usage: OverallUsage): readonly string[] { return usage.byModel