Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions apps/web/src/shell/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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 (
<MenuItem
Expand Down
28 changes: 28 additions & 0 deletions apps/web/test/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,34 @@ describe("Sidebar", () => {
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");
Expand Down
4 changes: 2 additions & 2 deletions packages/insights/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
45 changes: 45 additions & 0 deletions packages/insights/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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");
});
});
23 changes: 23 additions & 0 deletions packages/insights/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading