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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* UsageBreakdownTables — 用量历史页的两张明细表: 按 agent / harness 与按模型。
*
* 两张表共用同一套表格样式与 rank 配色 (usagePalette), 因此放在同一个文件里,
* 两张表共用同一套表格样式与分类配色 (usageHistoryColors), 因此放在同一个文件里,
* 避免为了共享 5 行 class 再拆一层。
*
* 缓存命中率与 shared/turnUsageDetails.ts 的逐轮口径一致 (见 usageHistoryStats.cacheHitRate),
Expand All @@ -14,19 +14,12 @@ import { useTranslation } from 'react-i18next';

import { cn } from '@/lib/utils';
import { formatCompactTokens, formatModelShort } from '@/lib/usageFormat';
import { usageRankColor, usageRankOf } from '@/components/new-chat/usagePalette';
import { type AgentTokenRow, type ModelTokenRow, type UsageAgentKind } from './usageHistoryStats';
import { usageHistoryModelColor } from './usageHistoryColors';
import { usageRankOf } from '@/components/new-chat/usagePalette';
import { type AgentTokenRow, type ModelTokenRow } from './usageHistoryStats';
import { usageHistoryAgentColor, usageHistoryModelColor } from './usageHistoryColors';
import { formatUsagePercent } from './formatUsagePercent';

const UNKNOWN_VALUE = '—';
/** 与 usagePalette 的 rank 阶梯对齐: Agent 标记保持独立的中性配色,不借用模型类别色。 */
const AGENT_RANK: Record<UsageAgentKind, number> = {
'claude-code': 0,
codex: 1,
pi: 2,
};

const TH_CLASS =
'whitespace-nowrap border-b border-[var(--border-default)] pb-2 pl-3 text-right text-12 font-medium text-[var(--text-secondary)]';
const TD_CLASS =
Expand All @@ -40,29 +33,15 @@ const TD_CLASS =
*/
const FIRST_COL_CLASS = 'w-full max-w-0 pl-0';

function Swatch({
rank,
color = usageRankColor(rank),
}: {
rank: number;
color?: string;
}): React.JSX.Element {
function Swatch({ color }: { color: string }): React.JSX.Element {
return <span className="size-2 shrink-0 rounded-[2px]" style={{ backgroundColor: color }} />;
}

function HitRateCell({ value }: { value: number | null }): React.JSX.Element {
return <td className={TD_CLASS}>{value === null ? UNKNOWN_VALUE : formatUsagePercent(value)}</td>;
}

function ShareCell({
share,
rank,
color = usageRankColor(rank),
}: {
share: number;
rank: number;
color?: string;
}): React.JSX.Element {
function ShareCell({ share, color }: { share: number; color: string }): React.JSX.Element {
return (
<td className={cn(TD_CLASS, 'text-[var(--text-tertiary)]')}>
<span
Expand Down Expand Up @@ -90,8 +69,7 @@ export function UsageAgentTable({

return (
<div>
{/* 占比条: 让"谁占大头"在读表之前就成立。
8px + 2px 圆角 = DESIGN.md §5 登记的「非交互 ≤8px 状态微格」豁免档。 */}
{/* 占比条与各行共用 harness 身份色;尺寸与既有布局一致。 */}
<div className="mb-3 flex h-2 overflow-hidden rounded-[2px]">
{rows.map((row) => (
<div
Expand All @@ -101,7 +79,7 @@ export function UsageAgentTable({
})}`}
style={{
width: `${total > 0 ? (row.tokens / total) * 100 : 0}%`,
backgroundColor: usageRankColor(AGENT_RANK[row.agentKind] ?? 3),
backgroundColor: usageHistoryAgentColor(row.agentKind),
}}
/>
))}
Expand All @@ -127,19 +105,19 @@ export function UsageAgentTable({
</thead>
<tbody>
{rows.map((row) => {
const rank = AGENT_RANK[row.agentKind] ?? 3;
const color = usageHistoryAgentColor(row.agentKind);
return (
<tr key={row.agentKind}>
<td className={cn(TD_CLASS, FIRST_COL_CLASS, 'text-left')}>
<span className="flex min-w-0 items-center gap-2">
<Swatch rank={rank} />
<Swatch color={color} />
<span className="truncate font-medium" title={row.agentKind}>
{row.agentKind}
</span>
</span>
</td>
<td className={TD_CLASS}>{formatCompactTokens(row.tokens)}</td>
<ShareCell share={row.share} rank={rank} />
<ShareCell share={row.share} color={color} />
{!hideToday ? (
<td className={cn(TD_CLASS, 'text-[var(--text-tertiary)]')}>
{row.todayTokens > 0 ? formatCompactTokens(row.todayTokens) : UNKNOWN_VALUE}
Expand Down Expand Up @@ -195,7 +173,7 @@ export function UsageModelTable({
<tr key={row.key}>
<td className={cn(TD_CLASS, FIRST_COL_CLASS, 'text-left')}>
<span className="flex min-w-0 items-center gap-2">
<Swatch rank={rank} color={color} />
<Swatch color={color} />
<span className="truncate font-medium" title={row.model}>
{formatModelShort(row.model)}
</span>
Expand All @@ -206,7 +184,7 @@ export function UsageModelTable({
</span>
</td>
<td className={TD_CLASS}>{formatCompactTokens(row.tokens)}</td>
<ShareCell share={row.share} rank={rank} color={color} />
<ShareCell share={row.share} color={color} />
<td className={TD_CLASS}>{formatCompactTokens(row.inputTokens)}</td>
<td className={TD_CLASS}>{formatCompactTokens(row.outputTokens)}</td>
<td className={TD_CLASS}>{formatCompactTokens(row.cacheReadTokens)}</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* - 每日柱图固定展示近 30 天, 与筛选范围无关
*
* 首页的 HomeUsageDashboard 是金额口径的姊妹实现, 本页不复用它的外壳组件
* (见 UsageTokenBars 的注释), 但共享同一条聚合链路与配色。
* (见 UsageTokenBars 的注释), 但共享同一条聚合链路。配色仅在用量历史页内统一。
*/

import './usageCharts.css';
Expand All @@ -28,7 +28,7 @@ import { Spinner } from '@/components/ui/spinner';
import { useAuth } from '@/contexts/AuthContext';
import { useUsageHistory } from '@/hooks/useUsageHistory';
import { UsageHeatmap } from '@/components/new-chat/UsageHeatmap';
import { USAGE_TOP_MODELS, usageModelKey } from '@/components/new-chat/usagePalette';
import { usageModelKey } from '@/components/new-chat/usagePalette';
import { UsageStatRow } from './UsageStatRow';
import { UsageTokenBars } from './UsageTokenBars';
import { UsageAgentTable, UsageModelTable } from './UsageBreakdownTables';
Expand Down Expand Up @@ -115,15 +115,11 @@ export function UsageHistorySection(): React.JSX.Element {
const summary = useMemo(() => buildSummary(filteredHistory), [filteredHistory]);
const modelRows = useMemo(() => buildModelRows(filteredHistory), [filteredHistory]);
const agentRows = useMemo(() => buildAgentRows(filteredHistory), [filteredHistory]);
// 配色顺序必须来自 modelRows 而不是 history.models: 后者由 main 按**可比金额**降序排
// (usageHistory.ts 的 comparable), 本页只讲 token —— 直接用它会让同一个模型在柱图与
// 模型表里配到不同颜色, 还会让"金额高但 token 很少"的模型挤掉真正的 token 前 N 名。
// 完整历史按 token 排序建立所有模型的配色,不截断前五名,也不随日期筛选重排。
// 表格与柱图共用此顺序;history.models 的金额排序不适用于这里。
const colorOrder = useMemo(
() =>
buildModelRows(chartHistory)
.slice(0, USAGE_TOP_MODELS)
.map((m) => usageModelKey(m.agentKind, m.model)),
[chartHistory],
() => buildModelRows(history).map((m) => usageModelKey(m.agentKind, m.model)),
[history],
);

// 任务行与用量聚合是两条数据源: 聚合里有 token, 本地任务却可能一条都没有
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { useTranslation } from 'react-i18next';

import { formatCompactTokens } from '@/lib/usageFormat';
import type { UsageHistoryModelDay } from '@/hooks/useUsageHistory';
import { usageModelKey, usageRankOf } from '@/components/new-chat/usagePalette';
import { usageModelKey } from '@/components/new-chat/usagePalette';

import { usageHistoryModelColor } from './usageHistoryColors';

Expand Down Expand Up @@ -72,7 +72,7 @@ export function UsageTokenBars({
onDayClick,
}: {
modelDaily: UsageHistoryModelDay[];
/** 前 N 名模型 key (payload.models 排序), 决定分段与图例配色。 */
/** 完整历史的模型 key(按 token 排序),决定分段与表格配色。 */
colorOrder: string[];
todayKey: string;
selectedDay?: string | null;
Expand All @@ -92,10 +92,11 @@ export function UsageTokenBars({
);

const bars = useMemo(() => {
const ranks = new Map(colorOrder.map((key, rank) => [key, rank]));
const segsByDay = new Map<string, Map<number, DaySegment>>();
for (const row of modelDaily) {
if (row.tokens <= 0) continue;
const rank = usageRankOf(colorOrder, usageModelKey(row.agentKind, row.model));
const rank = ranks.get(usageModelKey(row.agentKind, row.model)) ?? colorOrder.length;
let daySegs = segsByDay.get(row.day);
if (!daySegs) {
daySegs = new Map();
Expand All @@ -104,7 +105,7 @@ export function UsageTokenBars({
const seg = daySegs.get(rank);
if (seg) {
seg.tokens += row.tokens;
// 尾部档 (rank === colorOrder.length) 会把所有非前 N 名模型并进同一分段,
// 防御性兜底:调用方未登记的模型合并到尾部档 (rank === colorOrder.length),
// 保留首个模型名会把合计错误地挂到它头上 —— 改标「其它」, 与图例同义。
if (rank >= colorOrder.length) seg.label = t('usageDashboard.othersLegend');
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
import React from 'react';
import { cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { usageHistoryModelColor } from '../usageHistoryColors';
import { UsageModelTable } from '../UsageBreakdownTables';
import { usageHistoryAgentColor, usageHistoryModelColor } from '../usageHistoryColors';
import { UsageAgentTable, UsageModelTable } from '../UsageBreakdownTables';
import { UsageTokenBars } from '../UsageTokenBars';
import type { ModelTokenRow } from '../usageHistoryStats';

Expand All @@ -13,13 +13,111 @@ vi.mock('react-i18next', () => ({
afterEach(cleanup);

describe('Usage History model colors', () => {
it('reserves five distinct colors and keeps unlisted models neutral even in short lists', () => {
expect(new Set(Array.from({ length: 5 }, (_, i) => usageHistoryModelColor(i, 5))).size).toBe(5);
it('colors arbitrarily long model lists while retaining the first five theme colors', () => {
const colors = Array.from({ length: 1000 }, (_, i) => usageHistoryModelColor(i, 1000));
expect(new Set(colors).size).toBe(1000);
expect(colors.every((color) => color.includes('var(--usage-model-'))).toBe(true);
expect(colors.slice(0, 5)).toEqual([1, 2, 3, 4, 5].map((i) => `var(--usage-model-${i})`));
expect(usageHistoryModelColor(34, 35)).toBe(usageHistoryModelColor(34, 1000));
// Only an actually missing identity gets the neutral fallback, not rank >= 5.
expect(usageHistoryModelColor(2, 2)).toBe('var(--text-tertiary)');
expect(usageHistoryModelColor(0, 0)).toBe('var(--text-tertiary)');
expect(usageHistoryModelColor(5, 5)).toBe('var(--text-tertiary)');
});

it('matches all three Agent marks and keeps identity colors when rankings reverse', () => {
expect(
['claude-code', 'codex', 'pi'].map((kind) =>
usageHistoryAgentColor(kind as 'claude-code' | 'codex' | 'pi'),
),
).toEqual(['var(--engine-badge-cc)', 'var(--engine-badge-codex)', 'var(--usage-model-1)']);
const rows = (['claude-code', 'codex', 'pi'] as const).map((agentKind, i) => ({
agentKind,
tokens: (3 - i) * 100,
todayTokens: 0,
share: (3 - i) / 6,
cacheHitRate: 0.8,
modelCount: 12,
}));
const view = render(<UsageAgentTable rows={rows} rangeLabel="all" />);
const checkColors = (order: typeof rows) => {
const segments = view.container.querySelectorAll<HTMLElement>('div[title]');
order.forEach((row, i) => {
const color = usageHistoryAgentColor(row.agentKind);
const swatch = view.getByText(row.agentKind).previousElementSibling as HTMLElement;
const share = swatch.closest('tr')!.querySelector<HTMLElement>('.inline-block')!;
expect(swatch.style.backgroundColor).toBe(color);
expect(share.style.backgroundColor).toBe(color);
expect(segments[i].style.backgroundColor).toBe(color);
});
};
checkColors(rows);
view.rerender(<UsageAgentTable rows={[...rows].reverse()} rangeLabel="day" />);
checkColors([...rows].reverse());
});

it('keeps 35 model segments separate and matched to filtered table rows', () => {
const rows: ModelTokenRow[] = Array.from({ length: 35 }, (_, i) => ({
key: `claude-code model-${i}`,
agentKind: 'claude-code',
model: `model-${i}`,
tokens: i + 1,
inputTokens: i + 1,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreateTokens: 0,
share: (i + 1) / 630,
cacheHitRate: 0,
}));
const colorOrder = rows.map((row) => row.key);
const money = {
amount: 0,
currency: 'USD' as const,
approximate: false,
kind: 'actual-cost' as const,
};
const props = {
todayKey: '2026-09-09',
colorOrder,
modelDaily: rows.map((row) => ({
...row,
day: '2026-09-09',
money,
apiMoney: money,
subscriptionEstimateMoney: money,
})),
};
const view = render(
<>
<UsageTokenBars {...props} />
<UsageModelTable rows={rows} colorOrder={colorOrder} rangeLabel="all" />
</>,
);
const mark = view.container.querySelector<HTMLElement>(
'button[title^="2026-09-09"] [data-usage-mark]',
)!;
expect(mark.children).toHaveLength(35);
expect(mark.style.height).toBe('96px');
const segments = [...mark.children].reverse() as HTMLElement[];
rows.forEach((row, i) => {
const swatch = view.getByText(row.model).previousElementSibling as HTMLElement;
expect(swatch.style.backgroundColor).not.toBe('');
expect(swatch.style.backgroundColor).not.toBe('var(--text-tertiary)');
expect(swatch.style.backgroundColor).toBe(segments[i].style.backgroundColor);
expect(parseFloat(segments[i].style.height)).toBeCloseTo((row.tokens / 630) * 100);
});
const lastColor = segments[34].style.backgroundColor;
view.rerender(
<>
<UsageTokenBars {...props} />
<UsageModelTable rows={[rows[34]]} colorOrder={colorOrder} rangeLabel="day" />
</>,
);
expect(
(view.getByText('model-34').previousElementSibling as HTMLElement).style.backgroundColor,
).toBe(lastColor);
});

it('keeps a model swatch matched to its bar segment after filtering and reordering rows', () => {
const colorOrder = ['claude-code first', 'claude-code second'];
const money = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,33 @@ function history(): UsageHistoryPayload {
}

describe('Usage history selection behavior', () => {
it('registers every historical model even when absent from the 30-day chart window', () => {
state.history = history();
state.history.models.push(
...Array.from({ length: 35 }, (_, i) => ({
...state.history!.models[0],
model: `old-model-${i}`,
inputTokens: i + 1,
})),
);
state.history.days.unshift({ day: '2026-05-01', money, tokens: 1 });
state.history.modelDaily.push({
...state.history.modelDaily[0],
day: '2026-05-01',
model: 'old-model-34',
tokens: 35,
inputTokens: 35,
});
const view = render(<UsageHistorySection />);
const colors = () => JSON.parse(view.getByTestId('models').textContent!).colorOrder;
const before = colors();
expect(before).toHaveLength(36);
expect(before).toContain('claude-code old-model-34');
fireEvent.click(view.getAllByRole('button', { name: /May 1, 2026/ })[0]);
expect(colors()).toEqual(before);
expect(JSON.parse(view.getByTestId('models').textContent!).rows[0].model).toBe('old-model-34');
});

it('keeps both chart filters equivalent and preserves default, repeated and outside clicks', () => {
state.history = history();
const view = render(<UsageHistorySection />);
Expand Down
Loading