feat(desktop): per-message usage stats UI (tokens, cost, TTFT, tok/s) (#10210)

Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
filip
2026-07-06 13:51:06 -07:00
committed by GitHub
parent 8694a8d104
commit af2c765c82
40 changed files with 2093 additions and 22 deletions
+23 -4
View File
@@ -20,6 +20,7 @@ import {
import ToolCallConfirmation from './ToolCallConfirmation';
import ElicitationRequest from './ElicitationRequest';
import MessageCopyLink from './MessageCopyLink';
import MessageUsageStats from './MessageUsageStats';
import { cn } from '../utils';
import { identifyConsecutiveToolCalls, shouldHideTimestamp } from '../utils/toolCallChaining';
@@ -150,7 +151,7 @@ export default function GooseMessage({
)}
{toolRequests.length === 0 && (
<div className="relative flex justify-start">
<div className="relative flex items-center justify-between">
{!isStreaming && (
<div className="text-xs font-mono text-text-secondary pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{timestamp}
@@ -161,6 +162,11 @@ export default function GooseMessage({
<MessageCopyLink text={displayText} contentRef={contentRef} />
</div>
)}
{!isStreaming && message.metadata.usage && (
<div className="pt-1 transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0">
<MessageUsageStats usage={message.metadata.usage} />
</div>
)}
</div>
)}
</div>
@@ -168,7 +174,7 @@ export default function GooseMessage({
{toolRequests.length > 0 && (
<div className={cn(displayText && 'mt-2')}>
<div className="relative flex flex-col w-full">
<div className="relative flex flex-col w-full group">
<div className="flex flex-col gap-3">
{toolRequests.map((toolRequest) => {
const hasResponse = toolResponsesMap.has(toolRequest.id);
@@ -193,8 +199,21 @@ export default function GooseMessage({
);
})}
</div>
<div className="text-xs text-text-secondary transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0 pt-1">
{!isStreaming && !hideTimestamp && timestamp}
<div className="flex items-center justify-between">
<div
className={cn(
'text-xs text-text-secondary pt-1',
message.metadata.usage &&
'transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0'
)}
>
{!isStreaming && !hideTimestamp && timestamp}
</div>
{!isStreaming && message.metadata.usage && (
<div className="pt-1 transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0">
<MessageUsageStats usage={message.metadata.usage} />
</div>
)}
</div>
</div>
</div>
@@ -0,0 +1,96 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { MessageUsage } from '../types/message';
import { IntlTestWrapper } from '../i18n/test-utils';
import MessageUsageStats from './MessageUsageStats';
// Radix Tooltip positioning (floating-ui) needs ResizeObserver, which jsdom lacks.
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
beforeAll(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
});
afterAll(() => {
vi.unstubAllGlobals();
});
const FULL_USAGE: MessageUsage = {
inputTokens: 1200,
outputTokens: 340,
totalTokens: 1540,
cacheReadTokens: 800,
cacheWriteTokens: 100,
cost: 0.0123,
costSource: 'estimated',
elapsedMs: 4200,
timeToFirstTokenMs: 840,
isCompaction: false,
};
function renderUsage(usage: MessageUsage) {
return render(<MessageUsageStats usage={usage} />, { wrapper: IntlTestWrapper });
}
describe('MessageUsageStats', () => {
it('shows speed, estimated cost, and total tokens in the chip', () => {
renderUsage(FULL_USAGE);
// 340 tokens over 4.2s -> 80.95 tok/s, one decimal at this magnitude.
expect(screen.getByText('81.0 tok/s')).toBeInTheDocument();
expect(screen.getByText('~$0.012')).toBeInTheDocument();
expect(screen.getByText('1.5k tok')).toBeInTheDocument();
});
it('drops the ~ prefix for provider-reported cost', () => {
renderUsage({ ...FULL_USAGE, costSource: 'provider_reported' });
expect(screen.getByText('$0.012')).toBeInTheDocument();
expect(screen.queryByText('~$0.012')).not.toBeInTheDocument();
});
it('shows only the token segment when cost and timing are missing', () => {
const { container } = renderUsage({ totalTokens: 1540 });
expect(container).toHaveTextContent('1.5k tok');
expect(container.textContent).not.toContain('tok/s');
expect(container.textContent).not.toContain('$');
expect(container.textContent).not.toContain('·');
});
it('renders nothing when the usage carries no displayable data', () => {
const { container } = renderUsage({});
expect(container).toBeEmptyDOMElement();
const { container: nullContainer } = renderUsage({
inputTokens: null,
outputTokens: null,
totalTokens: null,
cost: null,
elapsedMs: null,
});
expect(nullContainer).toBeEmptyDOMElement();
});
it('breaks down tokens and cost in the tooltip on hover', async () => {
const user = userEvent.setup();
renderUsage(FULL_USAGE);
await user.hover(screen.getByText('1.5k tok'));
// Radix mirrors the tooltip children into the role="tooltip" node; scoping
// there keeps queries unique (the visible popper duplicates the text).
const tooltip = within(await screen.findByRole('tooltip'));
expect(tooltip.getByText('Input')).toBeInTheDocument();
expect(tooltip.getByText('1.2k')).toBeInTheDocument();
expect(tooltip.getByText('Output')).toBeInTheDocument();
expect(tooltip.getByText('340')).toBeInTheDocument();
expect(tooltip.getByText('(estimated)')).toBeInTheDocument();
});
});
@@ -0,0 +1,287 @@
import { Fragment, type ReactNode } from 'react';
import { Zap } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/Tooltip';
import { defineMessages, useIntl } from '../i18n';
import { cn } from '../utils';
import type { MessageUsage } from '../types/message';
import {
formatCost,
formatDuration,
formatTokenCount,
formatTokensPerSecond,
tokensPerSecond,
} from '../utils/usageFormatting';
const i18n = defineMessages({
tokensPerSecondUnit: {
id: 'messageUsageStats.tokensPerSecondUnit',
defaultMessage: 'tok/s',
},
tokenUnit: {
id: 'messageUsageStats.tokenUnit',
defaultMessage: 'tok',
},
input: {
id: 'messageUsageStats.input',
defaultMessage: 'Input',
},
cacheRead: {
id: 'messageUsageStats.cacheRead',
defaultMessage: 'cache read',
},
cacheWrite: {
id: 'messageUsageStats.cacheWrite',
defaultMessage: 'cache write',
},
cacheHitRate: {
id: 'messageUsageStats.cacheHitRate',
defaultMessage: '({percent}% of input)',
},
output: {
id: 'messageUsageStats.output',
defaultMessage: 'Output',
},
total: {
id: 'messageUsageStats.total',
defaultMessage: 'Total',
},
firstToken: {
id: 'messageUsageStats.firstToken',
defaultMessage: 'First token',
},
totalTime: {
id: 'messageUsageStats.totalTime',
defaultMessage: 'Total time',
},
speed: {
id: 'messageUsageStats.speed',
defaultMessage: 'Speed',
},
cost: {
id: 'messageUsageStats.cost',
defaultMessage: 'Cost',
},
estimated: {
id: 'messageUsageStats.estimated',
defaultMessage: '(estimated)',
},
reported: {
id: 'messageUsageStats.reported',
defaultMessage: '(reported)',
},
compaction: {
id: 'messageUsageStats.compaction',
defaultMessage: 'Compaction',
},
});
function hasValue(value: number | null | undefined): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
/** One label/value row of the tooltip's stats card. */
function StatRow({
label,
value,
sub,
}: {
label: string;
value: ReactNode;
/** Dimmer, indented breakdown row (e.g. cache read/write). */
sub?: boolean;
}) {
return (
<div className={cn('flex items-baseline justify-between gap-8', sub && 'pl-3')}>
<span className={cn('text-text-inverse/55', sub && 'text-text-inverse/40')}>{label}</span>
<span
className={cn(
'font-mono tabular-nums text-right',
sub ? 'text-text-inverse/60' : 'text-text-inverse/90'
)}
>
{value}
</span>
</div>
);
}
/**
* Per-message usage chip (tok/s, cost, total tokens) with a tooltip breaking
* down tokens, caching, timing, and cost. Renders nothing without data.
*/
export default function MessageUsageStats({ usage }: { usage: MessageUsage }) {
const intl = useIntl();
const {
inputTokens,
outputTokens,
totalTokens,
cacheReadTokens,
cacheWriteTokens,
cost,
costSource,
elapsedMs,
timeToFirstTokenMs,
isCompaction,
} = usage;
const tps = tokensPerSecond(outputTokens, elapsedMs);
const chipSegments: ReactNode[] = [];
if (tps !== null) {
chipSegments.push(
<span key="tps" className="flex items-center gap-1">
<Zap className="h-3 w-3" aria-hidden="true" />
{formatTokensPerSecond(tps)} {intl.formatMessage(i18n.tokensPerSecondUnit)}
</span>
);
}
if (hasValue(cost)) {
chipSegments.push(
<span key="cost">
{costSource === 'estimated' ? '~' : ''}
{formatCost(cost)}
</span>
);
}
if (hasValue(totalTokens)) {
chipSegments.push(
<span key="tokens">
{formatTokenCount(totalTokens)} {intl.formatMessage(i18n.tokenUnit)}
</span>
);
}
if (chipSegments.length === 0) {
return null;
}
const hasTokensSection =
hasValue(inputTokens) ||
hasValue(outputTokens) ||
hasValue(totalTokens) ||
hasValue(cacheReadTokens) ||
hasValue(cacheWriteTokens);
const hasTimingSection = hasValue(timeToFirstTokenMs) || hasValue(elapsedMs) || tps !== null;
const hasCostSection = hasValue(cost);
const section = 'py-1.5 first:pt-0 last:pb-0 space-y-[3px]';
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs font-mono text-text-secondary hover:text-text-primary transition-colors cursor-default">
{chipSegments.map((segment, index) => (
<Fragment key={index}>
{index > 0 && <span className="text-text-tertiary">·</span>}
{segment}
</Fragment>
))}
</div>
</TooltipTrigger>
<TooltipContent side="top" align="end" className="px-3.5 py-2.5 rounded-lg text-left">
<div className="min-w-44 text-xs divide-y divide-text-inverse/10">
{hasTokensSection && (
<div className={section}>
{hasValue(inputTokens) && (
<StatRow
label={intl.formatMessage(i18n.input)}
value={formatTokenCount(inputTokens)}
/>
)}
{hasValue(cacheReadTokens) && (
<StatRow
sub
label={intl.formatMessage(i18n.cacheRead)}
value={
<>
{formatTokenCount(cacheReadTokens)}
{hasValue(inputTokens) && inputTokens > 0 && (
<span className="text-text-inverse/40">
{' '}
{intl.formatMessage(i18n.cacheHitRate, {
percent: Math.round((cacheReadTokens / inputTokens) * 100),
})}
</span>
)}
</>
}
/>
)}
{hasValue(cacheWriteTokens) && (
<StatRow
sub
label={intl.formatMessage(i18n.cacheWrite)}
value={formatTokenCount(cacheWriteTokens)}
/>
)}
{hasValue(outputTokens) && (
<StatRow
label={intl.formatMessage(i18n.output)}
value={formatTokenCount(outputTokens)}
/>
)}
{hasValue(totalTokens) && (
<StatRow
label={intl.formatMessage(i18n.total)}
value={formatTokenCount(totalTokens)}
/>
)}
</div>
)}
{hasTimingSection && (
<div className={section}>
{hasValue(timeToFirstTokenMs) && (
<StatRow
label={intl.formatMessage(i18n.firstToken)}
value={formatDuration(timeToFirstTokenMs)}
/>
)}
{hasValue(elapsedMs) && (
<StatRow
label={intl.formatMessage(i18n.totalTime)}
value={formatDuration(elapsedMs)}
/>
)}
{tps !== null && (
<StatRow
label={intl.formatMessage(i18n.speed)}
value={`${formatTokensPerSecond(tps)} ${intl.formatMessage(i18n.tokensPerSecondUnit)}`}
/>
)}
</div>
)}
{hasCostSection && (
<div className={section}>
{hasValue(cost) && (
<StatRow
label={intl.formatMessage(i18n.cost)}
value={
<>
{formatCost(cost)}
{(costSource === 'provider_reported' || costSource === 'estimated') && (
<span className="text-text-inverse/60">
{' '}
{intl.formatMessage(
costSource === 'provider_reported' ? i18n.reported : i18n.estimated
)}
</span>
)}
</>
}
/>
)}
</div>
)}
{isCompaction && (
<div className={cn(section, 'text-amber-300/90')}>
{intl.formatMessage(i18n.compaction)}
</div>
)}
</div>
</TooltipContent>
</Tooltip>
);
}