From f2862fa265596cd8101acd617e37819a4f8bb2a6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 20:46:20 -0700 Subject: [PATCH] fix(ui): read popover cost from usage.cost and key usage empty state off content (#123269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat message meta popover read message.cost, a field no producer ever writes — AssistantMessage nests cost under usage.cost — so the $ line was permanently dead. Read usage.cost with the bare-field fallback removed to a single expression. The usage page empty state keyed off !data.totals, but the gateway always returns a totals object (all-zero when idle), so the "no usage data yet" card never rendered; it also ignored data.error, so a failed load could show the empty card under the error callout. Key it off actual content and never render it in the error state. --- .../chat/components/chat-message-timestamp.ts | 7 ++- .../chat/components/chat-message.test.ts | 12 ++++++ ui/src/pages/usage/view.test.ts | 43 +++++++++++++++++++ ui/src/pages/usage/view.ts | 9 +++- 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/chat/components/chat-message-timestamp.ts b/ui/src/pages/chat/components/chat-message-timestamp.ts index 2e7c4163be13..ae869c5fe2cd 100644 --- a/ui/src/pages/chat/components/chat-message-timestamp.ts +++ b/ui/src/pages/chat/components/chat-message-timestamp.ts @@ -161,7 +161,12 @@ export function extractGroupMeta( cacheWrite += callCacheWrite; maxPromptTokens = Math.max(maxPromptTokens, callInput + callCacheRead + callCacheWrite); } - const c = m.cost as Record | undefined; + // Producers write cost nested under usage.cost (the AssistantMessage + // shape); a bare message.cost never exists, so reading only it left the + // popover's $ line permanently dead. + const c = + (usage as { cost?: { total?: number } } | undefined)?.cost ?? + (m.cost as Record | undefined); if (c?.total) { cost += c.total; } diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 8b4fe45e541c..97e28b632b86 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -1332,6 +1332,18 @@ describe("grouped chat rendering", () => { 10_000, ); expect(outputHeavy.querySelector(".msg-meta__ctx")?.textContent).toBe("10% ctx"); + + // Cost is nested under usage.cost in the canonical AssistantMessage + // shape; the popover must surface it (it was dead reading message.cost). + const withCost = renderUsage( + { + input: 1_000, + output: 500, + cost: { total: 0.1234 } as unknown as number, + } as Record, + 10_000, + ); + expect(withCost.querySelector(".msg-meta__cost")?.textContent).toContain("$0.12"); }); it("previews message context from the timestamp and pins it on click", () => { diff --git a/ui/src/pages/usage/view.test.ts b/ui/src/pages/usage/view.test.ts index 5bcef57627a3..588e07918b51 100644 --- a/ui/src/pages/usage/view.test.ts +++ b/ui/src/pages/usage/view.test.ts @@ -650,4 +650,47 @@ describe("renderUsage", () => { expect(container.querySelector(".cost-window-analysis")).toBeNull(); } }); + + it("shows the empty state for an all-zero successful response", () => { + const zeroTotals = { + totalTokens: 0, + totalCost: 0, + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + missingCostEntries: 0, + }; + const container = document.createElement("div"); + render( + renderUsage( + createUsageProps({ + data: { + ...createUsageProps().data, + // The gateway always returns a totals object, even with no usage. + totals: zeroTotals as UsageProps["data"]["totals"], + }, + }), + ), + container, + ); + expect(container.querySelector(".usage-empty-state")).not.toBeNull(); + }); + + it("does not render the empty state under an error callout", () => { + const container = document.createElement("div"); + render( + renderUsage( + createUsageProps({ + data: { + ...createUsageProps().data, + error: "usage failed", + }, + }), + ), + container, + ); + expect(container.querySelector(".usage-callout")).not.toBeNull(); + expect(container.querySelector(".usage-empty-state")).toBeNull(); + }); }); diff --git a/ui/src/pages/usage/view.ts b/ui/src/pages/usage/view.ts index 94ec6ca2b8a7..738e39986d80 100644 --- a/ui/src/pages/usage/view.ts +++ b/ui/src/pages/usage/view.ts @@ -369,7 +369,14 @@ export function renderUsage(props: UsageProps) { : data.costDaily; const insightStats = buildUsageInsightStats(aggregateSessions, insightTotals, insightAggregates); - const isEmpty = !data.loading && !data.totals && data.sessions.length === 0; + // The gateway always returns a totals object (all-zero when idle), so key + // the empty state off content — and never render it under an error callout, + // where "no usage data yet" would misexplain the failure. + const isEmpty = + !data.loading && + !data.error && + data.sessions.length === 0 && + (data.totals?.totalTokens ?? 0) === 0; const cacheStatusTitle = getUsageCacheRefreshTitle(data.cacheStatus); const hasMissingCost = (insightTotals?.missingCostEntries ?? 0) > 0 ||