fix(ui): read popover cost from usage.cost and key usage empty state off content (#123269)

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.
This commit is contained in:
Peter Steinberger
2026-08-13 20:46:20 -07:00
committed by GitHub
parent 575467aa58
commit f2862fa265
4 changed files with 69 additions and 2 deletions
@@ -161,7 +161,12 @@ export function extractGroupMeta(
cacheWrite += callCacheWrite;
maxPromptTokens = Math.max(maxPromptTokens, callInput + callCacheRead + callCacheWrite);
}
const c = m.cost as Record<string, number> | 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<string, number> | undefined);
if (c?.total) {
cost += c.total;
}
@@ -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<string, number>,
10_000,
);
expect(withCost.querySelector(".msg-meta__cost")?.textContent).toContain("$0.12");
});
it("previews message context from the timestamp and pins it on click", () => {
+43
View File
@@ -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();
});
});
+8 -1
View File
@@ -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 ||