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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user