From c1ad1a0adb5c512d69051839d9d6475f8ef8a009 Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 11 Sep 2026 21:01:16 +0800 Subject: [PATCH 1/3] feat: add cost/tokens toggle to share rows and breakdown table Provider share rows and the model/project breakdown were cost-only, so unpriced usage always showed $0.00 and sorted last. Both now follow a shared cost/tokens metric: share rows rank and render by the active metric next to the chart, and the breakdown header gets the same toggle. Unpriced rows keep the "Unknown"/"+" cost marker in both modes. Refs MayankBansal12/bb-plugin-usage#48 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 3 +- app.tsx | 196 ++++++++++++++++-------- components/usage-dashboard-skeleton.tsx | 26 ++-- 3 files changed, 152 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 9aaa780..4dc4787 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Track coding-agent token usage and estimated API cost across every machine enrol - Collect usage from Codex, Claude Code, FX, Grok Agent, OpenCode, Pi, Prime Agent, and Antigravity. - Separate the coding agent from the underlying model provider. -- Group charts and cost summaries by agent or model provider. +- Group charts and usage shares by agent or model provider. +- Switch the chart, provider shares, and breakdown between cost and tokens, so unpriced usage stays visible. - Break usage down by model, project, or day. - Filter by machine, agent, model provider, and the last 7, 30, or 90 days. - Show exact, alias-matched, agent-reported, and unknown pricing in the breakdown table. diff --git a/app.tsx b/app.tsx index d469fad..47dc5d3 100644 --- a/app.tsx +++ b/app.tsx @@ -18,7 +18,7 @@ import { clampPercent, formatLimitReset, formatLimitValue, type ProviderLimitWin import { formatLocalMoney, localCurrency, usdToLocalRate } from "@/lib/local-currency"; type Range = 7 | 30 | 90; -type ChartMode = "cost" | "tokens"; +type MetricMode = "cost" | "tokens"; type BreakdownMode = "model" | "project" | "day"; type DimensionMode = "agent" | "provider"; @@ -195,6 +195,16 @@ function CostValue({ value, className }: { value: number; className?: string }) ); } +// A row whose priced cost is zero but has unpriced usage shows "Unknown"; a +// partially priced row gets a "+" marker since its share understates usage. +function PricedCost({ cost, unknown }: { cost: number; unknown?: boolean }) { + return ( + + {unknown && cost === 0 ? "Unknown" : <>{unknown ? "+" : ""}} + + ); +} + function parseDay(day: string) { return new Date(`${day}T00:00:00Z`); } @@ -370,7 +380,7 @@ function UsageChart({ records: UsageRecord[]; providers: Array<{ id: string; name: string }>; range: Range; - mode: ChartMode; + mode: MetricMode; groupBy: DimensionMode; compactView?: boolean; }) { @@ -597,14 +607,17 @@ function UsageChart({ ); } -function ProviderCostRow({ +function ProviderShareRow({ item, + mode, total, }: { - item: { id: string; name: string; cost: number; tokens: number }; + item: { id: string; name: string; cost: number; tokens: number; unknown?: boolean }; + mode: MetricMode; total: number; }) { - const share = total > 0 ? (item.cost / total) * 100 : 0; + const value = mode === "cost" ? item.cost : item.tokens; + const share = total > 0 ? (value / total) * 100 : 0; return (
@@ -612,19 +625,29 @@ function ProviderCostRow({ {item.name} - + + {mode === "cost" + ? + : <>{compact(item.tokens)} tokens} +
0 ? 3 : 0, + minWidth: value > 0 ? 3 : 0, backgroundColor: providerColor(item.id), }} />
-
{percentage(item.cost, total)} of cost · {compact(item.tokens)} tokens
+
+ {mode === "cost" + ? <>{percentage(item.cost, total)} of cost · {compact(item.tokens)} tokens + : item.unknown && item.cost === 0 + ? <>{percentage(item.tokens, total)} of tokens · cost unknown + : <>{percentage(item.tokens, total)} of tokens · {money(item.cost)}{item.unknown ? "+" : ""}} +
); } @@ -648,16 +671,18 @@ function ChartLegend({ providers }: { providers: Array<{ id: string; name: strin const CARD_CLASSES = "rounded-xl border border-border/70 bg-muted/[0.08]"; -// A project row names its highest-cost agent and folds the rest into `+N`, -// which lists them with their own cost on hover or focus. +// A project row names its dominant agent by the active metric and folds the +// rest into `+N`, which lists them with their own value on hover or focus. function AgentCell({ agentId, agent, others, + mode, }: { agentId: string; agent: string; - others?: Array<{ id: string; name: string; cost: number }>; + others?: Array<{ id: string; name: string; cost: number; tokens: number }>; + mode: MetricMode; }) { return ( @@ -684,7 +709,7 @@ function AgentCell({ {item.name} - {money(item.cost)} + {mode === "cost" ? money(item.cost) : `${compact(item.tokens)} tokens`}
))} @@ -943,7 +968,7 @@ function UsageDashboard() { const providerLimitsRequestId = useRef(0); const { range, machine, showUsageLimits } = useUsageToolbar(); const [chartGroup, setChartGroup] = useState("agent"); - const [chartMode, setChartMode] = useState("tokens"); + const [metricMode, setMetricMode] = useState("tokens"); const [breakdownMode, setBreakdownMode] = useState("model"); const [mobileSection, setMobileSection] = useState<"chart" | "breakdown">("chart"); const [breakdownPage, setBreakdownPage] = useState(1); @@ -1093,8 +1118,15 @@ function UsageDashboard() { cost: number; tokens: number; unknown?: boolean; // Only project rows fold several agents into one badge; the folded ones are // listed here so the `+N` suffix can name them on hover. - otherAgents?: Array<{ id: string; name: string; cost: number }>; + otherAgents?: Array<{ id: string; name: string; cost: number; tokens: number }>; }; + // Rows rank by the active metric, falling back to the other one so a $0 + // unpriced row still sorts by its token volume (and vice versa). + const byMetric = useCallback( + (a: { cost: number; tokens: number }, b: { cost: number; tokens: number }) => + metricMode === "cost" ? b.cost - a.cost || b.tokens - a.tokens : b.tokens - a.tokens || b.cost - a.cost, + [metricMode], + ); const modelBreakdown = useMemo(() => { const map = new Map(); for (const row of rows) { @@ -1105,31 +1137,33 @@ function UsageDashboard() { current.tokens += row.processedTokens; map.set(key, current); } - return [...map.values()].sort((a, b) => b.cost - a.cost || b.tokens - a.tokens); - }, [rows]); + return [...map.values()].sort(byMetric); + }, [rows, byMetric]); // Projects can be worked on from several agents and providers, so a row keeps - // the dominant one by cost for its badge instead of claiming a single owner. + // the dominant one by the active metric for its badge instead of claiming a + // single owner. const projectBreakdown = useMemo(() => { - const map = new Map }>(); + const map = new Map }>(); for (const row of rows) { - const current: BreakdownRow & { byAgent: Map } = map.get(row.project) ?? { + const current: BreakdownRow & { byAgent: Map } = map.get(row.project) ?? { key: row.project, label: row.project, agent: row.agentName, agentId: row.agentId, provider: row.modelProviderName, providerId: row.modelProviderId, cost: 0, tokens: 0, - byAgent: new Map(), + byAgent: new Map(), }; current.unknown = current.unknown || row.pricingStatus === "unknown"; current.cost += row.costUsd; current.tokens += row.processedTokens; - const agent = current.byAgent.get(row.agentId) ?? { name: row.agentName, cost: 0 }; + const agent = current.byAgent.get(row.agentId) ?? { name: row.agentName, cost: 0, tokens: 0 }; agent.cost += row.costUsd; + agent.tokens += row.processedTokens; current.byAgent.set(row.agentId, agent); map.set(row.project, current); } return [...map.values()].map((item) => { const ranked = [...item.byAgent.entries()] - .map(([id, value]) => ({ id, name: value.name, cost: value.cost })) - .sort((a, b) => b.cost - a.cost); + .map(([id, value]) => ({ id, name: value.name, cost: value.cost, tokens: value.tokens })) + .sort(byMetric); const [dominant, ...others] = ranked; return { ...item, @@ -1137,8 +1171,8 @@ function UsageDashboard() { agent: dominant?.name ?? item.agent, otherAgents: others, }; - }).sort((a, b) => b.cost - a.cost || b.tokens - a.tokens); - }, [rows]); + }).sort(byMetric); + }, [rows, byMetric]); const dayBreakdown = useMemo(() => { const map = new Map(); @@ -1154,7 +1188,7 @@ function UsageDashboard() { const days = useMemo(() => rangeDays(range), [range]); - useEffect(() => setBreakdownPage(1), [breakdownMode, machine, range]); + useEffect(() => setBreakdownPage(1), [breakdownMode, metricMode, machine, range]); useEffect(() => { const element = mainRef.current; @@ -1198,14 +1232,20 @@ function UsageDashboard() { const activeModelProviders = data.modelProviders.filter((item) => usedProviderIds.has(item.id)); const activeProviders = chartGroup === "agent" ? activeAgents : activeModelProviders; // A single dimension control (next to the chart) drives both the chart and - // the cost rows so the agent/provider tabs never repeat. - const costDimension = chartGroup; - const costDimensions = costDimension === "agent" ? activeAgents : activeModelProviders; - const providerTotals = costDimensions.map((item) => ({ - ...item, - cost: rows.filter((row) => (costDimension === "agent" ? row.agentId : row.modelProviderId) === item.id).reduce((sum, row) => sum + row.costUsd, 0), - tokens: rows.filter((row) => (costDimension === "agent" ? row.agentId : row.modelProviderId) === item.id).reduce((sum, row) => sum + row.processedTokens, 0), - })).sort((a, b) => b.cost - a.cost || b.tokens - a.tokens); + // the share rows so the agent/provider tabs never repeat; the shared + // cost/tokens metric sets their sort order and primary value the same way. + const shareDimension = chartGroup; + const shareDimensions = shareDimension === "agent" ? activeAgents : activeModelProviders; + const providerTotals = shareDimensions.map((item) => { + const dimensionRows = rows.filter((row) => (shareDimension === "agent" ? row.agentId : row.modelProviderId) === item.id); + return { + ...item, + cost: dimensionRows.reduce((sum, row) => sum + row.costUsd, 0), + tokens: dimensionRows.reduce((sum, row) => sum + row.processedTokens, 0), + unknown: dimensionRows.some((row) => row.pricingStatus === "unknown"), + }; + }).sort(byMetric); + const metricTotal = metricMode === "cost" ? totals.cost : totals.processed; const visibleMachines = data.machines.filter((item) => machine === "all" || item.id === machine); const visibleSources = data.sources.filter((source) => machine === "all" || source.machineId === machine); const sourceIssueMessage = getSourceIssueMessage(visibleMachines, visibleSources); @@ -1331,7 +1371,7 @@ function UsageDashboard() { {!stackedView && (
{providerTotals.map((item) => ( - + ))}
)} @@ -1339,16 +1379,16 @@ function UsageDashboard() { {stackedView && (
-

Daily {chartMode === "cost" ? "cost" : "tokens"}

+

Daily {metricMode === "cost" ? "cost" : "tokens"}

- +
@@ -1361,7 +1401,7 @@ function UsageDashboard() { {!stackedView && (
-

Daily {chartMode === "cost" ? "cost" : "tokens"}

+

Daily {metricMode === "cost" ? "cost" : "tokens"}

- +
@@ -1392,7 +1432,7 @@ function UsageDashboard() {
{providerTotals.map((item) => (
- +
))}
@@ -1419,14 +1459,22 @@ function UsageDashboard() { {(!stackedView || mobileSection === "breakdown") && (
-
+

Breakdown

- +
+ + +
{compactView ? ( @@ -1440,7 +1488,11 @@ function UsageDashboard() { )} {row.label} - {row.unknown && row.cost === 0 ? "Unknown" : <>{row.unknown ? "+" : ""}} + + {metricMode === "cost" + ? + : <>{compact(row.tokens)} tokens} +
{breakdownMode !== "day" && ( @@ -1450,7 +1502,7 @@ function UsageDashboard() { {row.otherAgents && row.otherAgents.length > 0 && ( `${item.name} ${money(item.cost)}`).join(" · ")} + title={row.otherAgents.map((item) => `${item.name} ${metricMode === "cost" ? money(item.cost) : `${compact(item.tokens)} tokens`}`).join(" · ")} > +{row.otherAgents.length} @@ -1458,10 +1510,22 @@ function UsageDashboard() { )} - {compact(row.tokens)} - tokens + {metricMode === "cost" ? ( + <> + {compact(row.tokens)} + tokens + + ) : row.unknown && row.cost === 0 ? ( + Unknown cost + ) : ( + {money(row.cost)}{row.unknown ? "+" : ""} + )} - {row.unknown && row.cost === 0 ? "—" : `${row.unknown ? "+" : ""}${percentage(row.cost, totals.cost)}`} + + {metricMode === "cost" + ? row.unknown && row.cost === 0 ? "—" : `${row.unknown ? "+" : ""}${percentage(row.cost, totals.cost)}` + : percentage(row.tokens, totals.processed)} +
))} @@ -1477,9 +1541,9 @@ function UsageDashboard() { {breakdownMode !== "day" && ( Agent )} - Cost + {metricMode === "cost" ? "Cost" : "Tokens"} Share - Tokens + {metricMode === "cost" ? "Tokens" : "Cost"} @@ -1497,12 +1561,20 @@ function UsageDashboard() { {breakdownMode !== "day" && ( - + )} - {row.unknown && row.cost === 0 ? "Unknown" : <>{row.unknown ? "+" : ""}} - {row.unknown && row.cost === 0 ? "—" : `${row.unknown ? "+" : ""}${percentage(row.cost, totals.cost)}`} - {compact(row.tokens)} + + {metricMode === "cost" ? : compact(row.tokens)} + + + {metricMode === "cost" + ? row.unknown && row.cost === 0 ? "—" : `${row.unknown ? "+" : ""}${percentage(row.cost, totals.cost)}` + : percentage(row.tokens, totals.processed)} + + + {metricMode === "cost" ? compact(row.tokens) : } + ))} diff --git a/components/usage-dashboard-skeleton.tsx b/components/usage-dashboard-skeleton.tsx index a5fc9bb..fe16903 100644 --- a/components/usage-dashboard-skeleton.tsx +++ b/components/usage-dashboard-skeleton.tsx @@ -331,18 +331,24 @@ export function UsageDashboardSkeleton() {
- {/* breakdown: real heading, real toggle, real column headers */} + {/* breakdown: real heading, real toggles, real column headers */}
-
+
- +
+ + +
From f4db28deece6fc87cc3bc946bd30330083c753bf Mon Sep 17 00:00:00 2001 From: mayank Date: Thu, 17 Sep 2026 18:16:16 +0000 Subject: [PATCH 2/3] feat: sort usage breakdown by token and cost headers --- README.md | 3 +- app.tsx | 255 +++++++++++++----------- components/usage-dashboard-skeleton.tsx | 141 ++++++------- lib/usage-sort.test.ts | 38 ++++ lib/usage-sort.ts | 25 +++ 5 files changed, 271 insertions(+), 191 deletions(-) create mode 100644 lib/usage-sort.test.ts create mode 100644 lib/usage-sort.ts diff --git a/README.md b/README.md index 4dc4787..ced6463 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ Track coding-agent token usage and estimated API cost across every machine enrol - Collect usage from Codex, Claude Code, FX, Grok Agent, OpenCode, Pi, Prime Agent, and Antigravity. - Separate the coding agent from the underlying model provider. - Group charts and usage shares by agent or model provider. -- Switch the chart, provider shares, and breakdown between cost and tokens, so unpriced usage stays visible. +- Switch the chart and provider shares between cost and tokens. +- Sort breakdowns by tokens or cost using column headers, with each metric’s share shown beneath its value. Unknown costs stay visible without a misleading percentage. - Break usage down by model, project, or day. - Filter by machine, agent, model provider, and the last 7, 30, or 90 days. - Show exact, alias-matched, agent-reported, and unknown pricing in the breakdown table. diff --git a/app.tsx b/app.tsx index 47dc5d3..3981e7a 100644 --- a/app.tsx +++ b/app.tsx @@ -11,6 +11,7 @@ import { useMediaQuery } from "@/components/ui/hooks/use-media-query"; import { ProviderLimitsSkeleton, UsageDashboardSkeleton } from "@/components/usage-dashboard-skeleton"; import { ProviderLogo, BRAND_COLORS, modelLogoId } from "@/components/provider-logo"; import { paginateItems } from "@/lib/pagination"; +import { compareUsage, nextUsageSort, type MetricMode, type UsageSort } from "@/lib/usage-sort"; import type { UsageSyncSnapshot } from "@/lib/sync-coordinator"; import { isUsageSyncInProgress, shouldPollUsage, shouldShowInitialUsageLoading, usageRefreshError } from "@/lib/usage-sync-state"; import { getEmptyUsageView, getSourceIssueMessage } from "@/lib/usage-view-state"; @@ -18,7 +19,6 @@ import { clampPercent, formatLimitReset, formatLimitValue, type ProviderLimitWin import { formatLocalMoney, localCurrency, usdToLocalRate } from "@/lib/local-currency"; type Range = 7 | 30 | 90; -type MetricMode = "cost" | "tokens"; type BreakdownMode = "model" | "project" | "day"; type DimensionMode = "agent" | "provider"; @@ -205,6 +205,51 @@ function PricedCost({ cost, unknown }: { cost: number; unknown?: boolean }) { ); } +function UsageSortButton({ metric, sort, onSort }: { + metric: MetricMode; + sort: UsageSort; + onSort: (metric: MetricMode) => void; +}) { + const active = sort.metric === metric; + const next = nextUsageSort(sort, metric); + return ( + + ); +} + +function BreakdownValue({ metric, row, totals }: { + metric: MetricMode; + row: { cost: number; tokens: number; unknown?: boolean }; + totals: { cost: number; processed: number; unknownTokens: number }; +}) { + const unknownCost = row.unknown && row.cost === 0; + return ( +
+
+ {metric === "tokens" ? compact(row.tokens) : } +
+ {(metric === "tokens" || !unknownCost) && ( +
0 ? "Share of priced cost; usage with unknown pricing is excluded." : undefined} + > + {metric === "tokens" ? `${percentage(row.tokens, totals.processed)} of tokens` : `${percentage(row.cost, totals.cost)} of cost`} +
+ )} +
+ ); +} + function parseDay(day: string) { return new Date(`${day}T00:00:00Z`); } @@ -646,7 +691,7 @@ function ProviderShareRow({ ? <>{percentage(item.cost, total)} of cost · {compact(item.tokens)} tokens : item.unknown && item.cost === 0 ? <>{percentage(item.tokens, total)} of tokens · cost unknown - : <>{percentage(item.tokens, total)} of tokens · {money(item.cost)}{item.unknown ? "+" : ""}} + : <>{percentage(item.tokens, total)} of tokens · }
); @@ -681,7 +726,7 @@ function AgentCell({ }: { agentId: string; agent: string; - others?: Array<{ id: string; name: string; cost: number; tokens: number }>; + others?: Array<{ id: string; name: string; cost: number; tokens: number; unknown?: boolean }>; mode: MetricMode; }) { return ( @@ -709,7 +754,7 @@ function AgentCell({ {item.name} - {mode === "cost" ? money(item.cost) : `${compact(item.tokens)} tokens`} + {mode === "cost" ? : `${compact(item.tokens)} tokens`}
))}
@@ -721,14 +766,6 @@ function AgentCell({ ); } -function RowBadge({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} - function ProviderLimits({ limits, contentWidth, @@ -968,7 +1005,8 @@ function UsageDashboard() { const providerLimitsRequestId = useRef(0); const { range, machine, showUsageLimits } = useUsageToolbar(); const [chartGroup, setChartGroup] = useState("agent"); - const [metricMode, setMetricMode] = useState("tokens"); + const [chartMode, setChartMode] = useState("tokens"); + const [breakdownSort, setBreakdownSort] = useState({ metric: "tokens", direction: "descending" }); const [breakdownMode, setBreakdownMode] = useState("model"); const [mobileSection, setMobileSection] = useState<"chart" | "breakdown">("chart"); const [breakdownPage, setBreakdownPage] = useState(1); @@ -1118,15 +1156,23 @@ function UsageDashboard() { cost: number; tokens: number; unknown?: boolean; // Only project rows fold several agents into one badge; the folded ones are // listed here so the `+N` suffix can name them on hover. - otherAgents?: Array<{ id: string; name: string; cost: number; tokens: number }>; + otherAgents?: Array<{ id: string; name: string; cost: number; tokens: number; unknown?: boolean }>; }; - // Rows rank by the active metric, falling back to the other one so a $0 - // unpriced row still sorts by its token volume (and vice versa). - const byMetric = useCallback( + // Chart share rows follow the chart metric; table sorting is independent. + const byChartMetric = useCallback( (a: { cost: number; tokens: number }, b: { cost: number; tokens: number }) => - metricMode === "cost" ? b.cost - a.cost || b.tokens - a.tokens : b.tokens - a.tokens || b.cost - a.cost, - [metricMode], + chartMode === "cost" ? b.cost - a.cost || b.tokens - a.tokens : b.tokens - a.tokens || b.cost - a.cost, + [chartMode], + ); + const byBreakdownMetric = useCallback( + (a: { cost: number; tokens: number; unknown?: boolean }, b: { cost: number; tokens: number; unknown?: boolean }) => + compareUsage(a, b, { metric: breakdownSort.metric, direction: "descending" }), + [breakdownSort.metric], ); + const sortBreakdown = (metric: MetricMode) => { + setBreakdownSort((current) => nextUsageSort(current, metric)); + setBreakdownPage(1); + }; const modelBreakdown = useMemo(() => { const map = new Map(); for (const row of rows) { @@ -1137,33 +1183,34 @@ function UsageDashboard() { current.tokens += row.processedTokens; map.set(key, current); } - return [...map.values()].sort(byMetric); - }, [rows, byMetric]); + return [...map.values()]; + }, [rows]); // Projects can be worked on from several agents and providers, so a row keeps // the dominant one by the active metric for its badge instead of claiming a // single owner. const projectBreakdown = useMemo(() => { - const map = new Map }>(); + const map = new Map }>(); for (const row of rows) { - const current: BreakdownRow & { byAgent: Map } = map.get(row.project) ?? { + const current: BreakdownRow & { byAgent: Map } = map.get(row.project) ?? { key: row.project, label: row.project, agent: row.agentName, agentId: row.agentId, provider: row.modelProviderName, providerId: row.modelProviderId, cost: 0, tokens: 0, - byAgent: new Map(), + byAgent: new Map(), }; current.unknown = current.unknown || row.pricingStatus === "unknown"; current.cost += row.costUsd; current.tokens += row.processedTokens; - const agent = current.byAgent.get(row.agentId) ?? { name: row.agentName, cost: 0, tokens: 0 }; + const agent = current.byAgent.get(row.agentId) ?? { name: row.agentName, cost: 0, tokens: 0, unknown: false }; agent.cost += row.costUsd; agent.tokens += row.processedTokens; + agent.unknown = agent.unknown || row.pricingStatus === "unknown"; current.byAgent.set(row.agentId, agent); map.set(row.project, current); } return [...map.values()].map((item) => { const ranked = [...item.byAgent.entries()] - .map(([id, value]) => ({ id, name: value.name, cost: value.cost, tokens: value.tokens })) - .sort(byMetric); + .map(([id, value]) => ({ id, ...value })) + .sort(byBreakdownMetric); const [dominant, ...others] = ranked; return { ...item, @@ -1171,8 +1218,8 @@ function UsageDashboard() { agent: dominant?.name ?? item.agent, otherAgents: others, }; - }).sort(byMetric); - }, [rows, byMetric]); + }); + }, [rows, byBreakdownMetric]); const dayBreakdown = useMemo(() => { const map = new Map(); @@ -1188,7 +1235,7 @@ function UsageDashboard() { const days = useMemo(() => rangeDays(range), [range]); - useEffect(() => setBreakdownPage(1), [breakdownMode, metricMode, machine, range]); + useEffect(() => setBreakdownPage(1), [breakdownMode, machine, range]); useEffect(() => { const element = mainRef.current; @@ -1244,8 +1291,8 @@ function UsageDashboard() { tokens: dimensionRows.reduce((sum, row) => sum + row.processedTokens, 0), unknown: dimensionRows.some((row) => row.pricingStatus === "unknown"), }; - }).sort(byMetric); - const metricTotal = metricMode === "cost" ? totals.cost : totals.processed; + }).sort(byChartMetric); + const metricTotal = chartMode === "cost" ? totals.cost : totals.processed; const visibleMachines = data.machines.filter((item) => machine === "all" || item.id === machine); const visibleSources = data.sources.filter((source) => machine === "all" || source.machineId === machine); const sourceIssueMessage = getSourceIssueMessage(visibleMachines, visibleSources); @@ -1264,9 +1311,10 @@ function UsageDashboard() { sources: visibleSources, hasRecordsOutsideView: data.records.some((record) => machine === "all" || record.machineId === machine), }); - const breakdown = breakdownMode === "model" ? modelBreakdown + const breakdownRows = breakdownMode === "model" ? modelBreakdown : breakdownMode === "project" ? projectBreakdown : dayBreakdown; + const breakdown = [...breakdownRows].sort((a, b) => compareUsage(a, b, breakdownSort)); const paginatedBreakdown = paginateItems(breakdown, breakdownPage, BREAKDOWN_PAGE_SIZE); const activeDays = new Set(rows.map((row) => row.day)).size; const visibleProviderLimits = providerLimits.filter((limit) => @@ -1371,7 +1419,7 @@ function UsageDashboard() { {!stackedView && (
{providerTotals.map((item) => ( - + ))}
)} @@ -1379,16 +1427,16 @@ function UsageDashboard() { {stackedView && (
-

Daily {metricMode === "cost" ? "cost" : "tokens"}

+

Daily {chartMode === "cost" ? "cost" : "tokens"}

- +
@@ -1401,7 +1449,7 @@ function UsageDashboard() { {!stackedView && (
-

Daily {metricMode === "cost" ? "cost" : "tokens"}

+

Daily {chartMode === "cost" ? "cost" : "tokens"}

- +
@@ -1432,7 +1480,7 @@ function UsageDashboard() {
{providerTotals.map((item) => (
- +
))}
@@ -1461,89 +1509,59 @@ function UsageDashboard() {

Breakdown

-
- - -
+
{compactView ? (
+
+ Sort by +
+ + +
+
{paginatedBreakdown.items.map((row) => ( -
-
- - {breakdownMode === "model" && ( - - )} - {row.label} - - - {metricMode === "cost" - ? - : <>{compact(row.tokens)} tokens} - +
+
+ {breakdownMode === "model" && } + {row.label}
-
- {breakdownMode !== "day" && ( - - - {row.agent} - {row.otherAgents && row.otherAgents.length > 0 && ( - `${item.name} ${metricMode === "cost" ? money(item.cost) : `${compact(item.tokens)} tokens`}`).join(" · ")} - > - +{row.otherAgents.length} - - )} - - )} - - {metricMode === "cost" ? ( - <> - {compact(row.tokens)} - tokens - - ) : row.unknown && row.cost === 0 ? ( - Unknown cost - ) : ( - {money(row.cost)}{row.unknown ? "+" : ""} - )} - - - {metricMode === "cost" - ? row.unknown && row.cost === 0 ? "—" : `${row.unknown ? "+" : ""}${percentage(row.cost, totals.cost)}` - : percentage(row.tokens, totals.processed)} - + {breakdownMode !== "day" && ( +
+ +
+ )} +
+ +
+ +
))}
) : (
- +
- {breakdownMode !== "day" && ( - + )} - - - + {(["tokens", "cost"] as const).map((metric) => ( + + ))} @@ -1561,26 +1579,21 @@ function UsageDashboard() { {breakdownMode !== "day" && ( )} - - - ))} -
+ {breakdownMode === "model" ? "Model" : breakdownMode === "project" ? "Project" : "Day"} AgentAgent{metricMode === "cost" ? "Cost" : "Tokens"}Share{metricMode === "cost" ? "Tokens" : "Cost"} + +
- + - {metricMode === "cost" ? : compact(row.tokens)} - - {metricMode === "cost" - ? row.unknown && row.cost === 0 ? "—" : `${row.unknown ? "+" : ""}${percentage(row.cost, totals.cost)}` - : percentage(row.tokens, totals.processed)} + + - {metricMode === "cost" ? compact(row.tokens) : } + +
-
- )} + +
+ )} {breakdown.length > BREAKDOWN_PAGE_SIZE && (
diff --git a/components/usage-dashboard-skeleton.tsx b/components/usage-dashboard-skeleton.tsx index fe16903..aea0b62 100644 --- a/components/usage-dashboard-skeleton.tsx +++ b/components/usage-dashboard-skeleton.tsx @@ -1,4 +1,5 @@ import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { Icon } from "@/components/ui/icon"; import { ToggleGroupPreview } from "@/components/ui/toggle-group"; // The theme exposes its colors as complete color-mix() values rather than HSL @@ -331,86 +332,88 @@ export function UsageDashboardSkeleton() {
- {/* breakdown: real heading, real toggles, real column headers */} + {/* Match the fixed metric columns and default token sort. */}
-
- - -
+
- {/* wide layouts: the real table header */} - - - - - - - - - - - + {compactView ? ( +
+
+ Sort by + +
{[0, 1, 2, 3, 4].map((row) => ( -
- - - - - - - ))} - -
ModelAgentCostShareTokens
-
- - -
-
-
- - -
-
- - - - - -
- - {/* narrow layouts: the stacked card rows */} -
- {[0, 1, 2, 3, 4].map((row) => ( -
-
-
+
+
- -
-
- - - + +
+ {[0, 1].map((metric) => ( +
+ + +
+ ))} +
-
- ))} -
+ ))} +
+ ) : ( + + + + + + + + + + + {[0, 1, 2, 3, 4].map((row) => ( + + + + {[0, 1].map((metric) => ( + + ))} + + ))} + +
ModelAgent + Tokens + + Cost +
+
+ + +
+
+
+ + +
+
+ + +
+ )}
diff --git a/lib/usage-sort.test.ts b/lib/usage-sort.test.ts new file mode 100644 index 0000000..92ea7cc --- /dev/null +++ b/lib/usage-sort.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { compareUsage, nextUsageSort, type UsageSort } from "./usage-sort"; + +describe("usage sorting", () => { + const rows = [ + { id: "paid", cost: 10, tokens: 100 }, + { id: "unpriced", cost: 0, tokens: 900, unknown: true }, + { id: "free", cost: 0, tokens: 20 }, + { id: "partial", cost: 2, tokens: 400, unknown: true }, + ]; + const ranked = (sort: UsageSort) => [...rows].sort((a, b) => compareUsage(a, b, sort)).map((row) => row.id); + + it("includes unpriced usage in token rankings", () => { + expect(ranked({ metric: "tokens", direction: "descending" })).toEqual(["unpriced", "partial", "paid", "free"]); + expect(ranked({ metric: "tokens", direction: "ascending" })).toEqual(["free", "paid", "partial", "unpriced"]); + }); + + it("distinguishes free from unknown cost in both directions", () => { + expect(ranked({ metric: "cost", direction: "ascending" })).toEqual(["free", "partial", "paid", "unpriced"]); + expect(ranked({ metric: "cost", direction: "descending" })).toEqual(["paid", "partial", "free", "unpriced"]); + }); + + it("keeps token volume useful when all costs are unknown", () => { + const unpriced = rows.map((row) => ({ ...row, cost: 0, unknown: true })); + for (const direction of ["ascending", "descending"] as const) { + expect(unpriced.sort((a, b) => compareUsage(a, b, { metric: "cost", direction })).map((row) => row.id)) + .toEqual(["unpriced", "partial", "paid", "free"]); + } + }); + + it("reverses the active column and starts a new column largest first", () => { + const initial: UsageSort = { metric: "tokens", direction: "descending" }; + const ascending = nextUsageSort(initial, "tokens"); + expect(ascending).toEqual({ metric: "tokens", direction: "ascending" }); + expect(nextUsageSort(ascending, "tokens")).toEqual(initial); + expect(nextUsageSort(ascending, "cost")).toEqual({ metric: "cost", direction: "descending" }); + }); +}); diff --git a/lib/usage-sort.ts b/lib/usage-sort.ts new file mode 100644 index 0000000..aea1c0f --- /dev/null +++ b/lib/usage-sort.ts @@ -0,0 +1,25 @@ +export type MetricMode = "cost" | "tokens"; +export type UsageSort = { metric: MetricMode; direction: "ascending" | "descending" }; +type UsageValue = { cost: number; tokens: number; unknown?: boolean }; + +export function nextUsageSort(current: UsageSort, metric: MetricMode): UsageSort { + return { + metric, + direction: current.metric === metric && current.direction === "descending" ? "ascending" : "descending", + }; +} + +export function compareUsage(a: UsageValue, b: UsageValue, sort: UsageSort): number { + // An unknown cost is not a zero-dollar cost. Keep it after known values in + // either direction, while still allowing its tokens to sort normally. + if (sort.metric === "cost") { + const aUnknown = Boolean(a.unknown && a.cost === 0); + const bUnknown = Boolean(b.unknown && b.cost === 0); + if (aUnknown !== bUnknown) return aUnknown ? 1 : -1; + } + const otherMetric = sort.metric === "cost" ? "tokens" : "cost"; + const difference = a[sort.metric] - b[sort.metric]; + // Equal primary values keep the larger secondary value first in either + // direction, so reversing a sort does not shuffle ties unnecessarily. + return (sort.direction === "ascending" ? difference : -difference) || b[otherMetric] - a[otherMetric]; +} From 28a6143dd5133ad072c31bf4534f5f7fbf6f76aa Mon Sep 17 00:00:00 2001 From: mayank Date: Thu, 17 Sep 2026 18:23:48 +0000 Subject: [PATCH 3/3] chore: bump version to 0.3.14 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bd8d5dc..ec91798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bb-plugin-usage", - "version": "0.3.13", + "version": "0.3.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bb-plugin-usage", - "version": "0.3.13", + "version": "0.3.14", "dependencies": { "@hugeicons/core-free-icons": "^4.1.3", "@hugeicons/react": "^1.1.6", diff --git a/package.json b/package.json index e97021a..96846e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bb-plugin-usage", - "version": "0.3.13", + "version": "0.3.14", "type": "module", "scripts": { "build": "bb plugin build",