1import { useAutoAnimate } from "@formkit/auto-animate/react";2 3import { AlertTriangleIcon, BarChart3Icon, ChevronDownIcon, ChevronRightIcon } from "lucide-react";4import { useEffect, useMemo, useState } from "react";5import { formatCost, formatTokenCount } from "../../../../../../src/session/pricing";6import { useAppStore }
from
"../../../app/store"
;
7import type { ThreadRuntime } from "../../../app/types";
8import { Badge } from "../../../components/ui/badge";
9import { Button, buttonVariants } from "../../../components/ui/button";
10import {
11 Dialog,
12 DialogContent,
13 DialogDescription,
14 DialogFooter,
15 DialogHeader,
16 DialogTitle,
17} from "../../../components/ui/dialog";
18import { useOptionalSettingsChrome } from "../SettingsChromeContext";
19import {
20 SettingsEmptyState,
21 SettingsPage,
22 SettingsSection,
23 SettingsStatTile,
24} from "../SettingsPrimitives";
25
26// ── Aggregation types ────────────────────────────────────────────────
27
28type AggregateModelEntry = {
29 provider: string;
30 model: string;
31 turns: number;
32 sessions: number;
33 totalPromptTokens: number;
34 totalCompletionTokens: number;
35 totalCachedPromptTokens: number;
36 totalCacheWritePromptTokens: number;
37 totalReasoningOutputTokens: number;
38 totalTokens: number;
39 estimatedCostUsd: number | null;
40};
41
42type ProviderGroup = {
43 provider: string;
44 models: AggregateModelEntry[];
45 totalTokens: number;
46 totalCachedPromptTokens: number;
47 totalCacheWritePromptTokens: number;
48 totalReasoningOutputTokens: number;
49 totalTurns: number;
50 estimatedCostUsd: number | null;
51};
52
53export type AggregateUsage = {
54 totalCostUsd: number | null;
55 costTrackingAvailable: boolean;
56 totalTokens: number;
57 totalPromptTokens: number;
58 totalCompletionTokens: number;
59 totalCachedPromptTokens: number;
60 totalCacheWritePromptTokens: number;
61 totalReasoningOutputTokens: number;
62 totalTurns: number;
63 totalSessions: number;
64 providers: ProviderGroup[];
65};
66
67// ── Pure aggregation (exported for testing) ──────────────────────────
68
69export function aggregateUsageFromRuntimes(
70 runtimes: Record<string, ThreadRuntime>,
71): AggregateUsage {
72 const byKey = new Map<string, AggregateModelEntry>();
73 let totalCostUsd: number | null = null;
74 let costTrackingAvailable = false;
75 let totalTokens = 0;
76 let totalPromptTokens = 0;
77 let totalCompletionTokens = 0;
78 let totalCachedPromptTokens = 0;
79 let totalCacheWritePromptTokens = 0;
80 let totalReasoningOutputTokens = 0;
81 let totalTurns = 0;
82 let totalSessions = 0;
83
84 for (const runtime of Object.values(runtimes)) {
85 const usage = runtime.sessionUsage;
86 if (!usage) continue;
87
88 totalSessions++;
89 totalTurns += usage.totalTurns;
90 totalTokens += usage.totalTokens;
91 totalPromptTokens += usage.totalPromptTokens;
92 totalCompletionTokens += usage.totalCompletionTokens;
93 totalCachedPromptTokens += usage.totalCachedPromptTokens ?? 0;
94 totalCacheWritePromptTokens += usage.totalCacheWritePromptTokens ?? 0;
95 totalReasoningOutputTokens += usage.totalReasoningOutputTokens ?? 0;
96
97 if (usage.costTrackingAvailable) costTrackingAvailable = true;
98 if (typeof usage.estimatedTotalCostUsd === "number") {
99 totalCostUsd = (totalCostUsd ?? 0) + usage.estimatedTotalCostUsd;
100 }
101
102 for (const entry of usage.byModel) {
103 const key = `${entry.provider}:${entry.model}`;
104 const existing = byKey.get(key);
105 if (existing) {
106 existing.turns += entry.turns;
107 existing.sessions += 1;
108 existing.totalPromptTokens += entry.totalPromptTokens;
109 existing.totalCompletionTokens += entry.totalCompletionTokens;
110 existing.totalCachedPromptTokens += entry.totalCachedPromptTokens ?? 0;
111 existing.totalCacheWritePromptTokens += entry.totalCacheWritePromptTokens ?? 0;
112 existing.totalReasoningOutputTokens += entry.totalReasoningOutputTokens ?? 0;
113 existing.totalTokens += entry.totalTokens;
114 if (typeof entry.estimatedCostUsd === "number") {
115 existing.estimatedCostUsd = (existing.estimatedCostUsd ?? 0) + entry.estimatedCostUsd;
116 }
117 } else {
118 byKey.set(key, {
119 provider: entry.provider,
120 model: entry.model,
121 turns: entry.turns,
122 sessions: 1,
123 totalPromptTokens: entry.totalPromptTokens,
124 totalCompletionTokens: entry.totalCompletionTokens,
125 totalCachedPromptTokens: entry.totalCachedPromptTokens ?? 0,
126 totalCacheWritePromptTokens: entry.totalCacheWritePromptTokens ?? 0,
127 totalReasoningOutputTokens: entry.totalReasoningOutputTokens ?? 0,
128 totalTokens: entry.totalTokens,
129 estimatedCostUsd: entry.estimatedCostUsd,
130 });
131 }
132 }
133 }
134
135 // Group by provider, sort providers by total cost desc, models within by cost desc
136 const providerMap = new Map<string, AggregateModelEntry[]>();
137 for (const entry of byKey.values()) {
138 const list = providerMap.get(entry.provider) ?? [];
139 list.push(entry);
140 providerMap.set(entry.provider, list);
141 }
142
143 const providers: ProviderGroup[] = [];
144 for (const [provider, models] of providerMap) {
145 models.sort((a, b) => (b.estimatedCostUsd ?? 0) - (a.estimatedCostUsd ?? 0));
146 let providerCost: number | null = null;
147 let providerTokens = 0;
148 let providerCachedPromptTokens = 0;
149 let providerCacheWritePromptTokens = 0;
150 let providerReasoningOutputTokens = 0;
151 let providerTurns = 0;
152 for (const m of models) {
153 providerTokens += m.totalTokens;
154 providerCachedPromptTokens += m.totalCachedPromptTokens;
155 providerCacheWritePromptTokens += m.totalCacheWritePromptTokens;
156 providerReasoningOutputTokens += m.totalReasoningOutputTokens;
157 providerTurns += m.turns;
158 if (typeof m.estimatedCostUsd === "number") {
159 providerCost = (providerCost ?? 0) + m.estimatedCostUsd;
160 }
161 }
162 providers.push({
163 provider,
164 models,
165 totalTokens: providerTokens,
166 totalCachedPromptTokens: providerCachedPromptTokens,
167 totalCacheWritePromptTokens: providerCacheWritePromptTokens,
168 totalReasoningOutputTokens: providerReasoningOutputTokens,
169 totalTurns: providerTurns,
170 estimatedCostUsd: providerCost,
171 });
172 }
173 providers.sort((a, b) => (b.estimatedCostUsd ?? 0) - (a.estimatedCostUsd ?? 0));
174
175 return {
176 totalCostUsd,
177 costTrackingAvailable,
178 totalTokens,
179 totalPromptTokens,
180 totalCompletionTokens,
181 totalCachedPromptTokens,
182 totalCacheWritePromptTokens,
183 totalReasoningOutputTokens,
184 totalTurns,
185 totalSessions,
186 providers,
187 };
188}
189
190// ── Helpers ──────────────────────────────────────────────────────────
191
192function formatEstimatedCost(value: number | null, available: boolean): string {
193 if (available && value !== null) return formatCost(value);
194 return "—";
195}
196
197// ── Component ────────────────────────────────────────────────────────
198
199export type UsagePageProps = {
200 aggregate?: AggregateUsage | null;
201 estimateNoticeOpen?: boolean;
202};
203
204export function UsagePage(props: UsagePageProps = {}) {
205 const threadRuntimeByIdFromStore = useAppStore((s) => s.threadRuntimeById);
206 const loadAllThreadUsage = useAppStore((s) => s.loadAllThreadUsage);
207 const serverState = typeof window === "undefined" ? useAppStore.getState() : null;
208 const threadRuntimeById = serverState?.threadRuntimeById ?? threadRuntimeByIdFromStore;
209
210 // Load usage data for all threads on mount so the aggregate view is complete
211 useEffect(() => {
212 if (props.aggregate !== undefined) return; // skip when overridden (tests)
213 void loadAllThreadUsage();
214 }, [props.aggregate, loadAllThreadUsage]);
215
216 const computedAggregate = useMemo(
217 () => aggregateUsageFromRuntimes(threadRuntimeById),
218 [threadRuntimeById],
219 );
220 const aggregate = props.aggregate ?? computedAggregate;
221
222 const [estimateNoticeOpenInternal, setEstimateNoticeOpenInternal] = useState(false);
223 const estimateNoticeOpen = props.estimateNoticeOpen ?? estimateNoticeOpenInternal;
224 const handleEstimateNoticeOpenChange =
225 props.estimateNoticeOpen === undefined ? setEstimateNoticeOpenInternal : undefined;
226
227 const [expandedProviders, setExpandedProviders] = useState<Record<string, boolean>>({});
228 const toggleProvider = (provider: string) => {
229 setExpandedProviders((prev) => ({ ...prev, [provider]: !prev[provider] }));
230 };
231
232 const [parent] = useAutoAnimate();
233
234 const hasUsage = aggregate && aggregate.totalSessions > 0;
235
236 const settingsChrome = useOptionalSettingsChrome();
237 const estimateNoticeDialog = (
238 <Dialog open={estimateNoticeOpen} onOpenChange={handleEstimateNoticeOpenChange}>
239 <DialogContent showCloseButton className="max-w-lg">
240 <DialogHeader>
241 <DialogTitle>Usage estimates</DialogTitle>
242 <DialogDescription>
243 These numbers are estimates based on provider-reported token usage and Cowork's
244 local pricing catalog.
245 </DialogDescription>
246 </DialogHeader>
247 <div className="space-y-3 text-sm text-muted-foreground">
248 <p>
249 Billing may vary. Providers can round differently, apply cached-token discounts
250 differently, or change prices independently of what is bundled in the app.
251 </p>
252 <p>
253 Be careful while using these estimates for spend decisions. Treat totals as protective
254 guidance, not exact invoices.
255 </p>
256 </div>
257 <DialogFooter>
258 <Button
259 type="button"
260 variant="secondary"
261 onClick={() => handleEstimateNoticeOpenChange?.(false)}
262 >
263 Got it
264 </Button>
265 </DialogFooter>
266 </DialogContent>
267 </Dialog>
268 );
269
270 useEffect(() => {
271 if (!settingsChrome) return;
272 settingsChrome.setChrome({
273 headerActions: (
274 <button
275 type="button"
276 className={buttonVariants({ variant: "outline", size: "sm", className: "gap-2" })}
277 onClick={() => handleEstimateNoticeOpenChange?.(true)}
278 >
279 <AlertTriangleIcon data-icon="inline-start" />
280 How estimates work
281 </button>
282 ),
283 });
284 return () => {
285 settingsChrome.setChrome(null);
286 };
287 }, [settingsChrome, handleEstimateNoticeOpenChange]);
288
289 return (
290 <SettingsPage data-usage-page="true">
291 {/* ── Overview stats ──────────────────────────────────────────── */}
292 <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
293 <SettingsStatTile
294 label="Estimated total cost"
295 value={
296 hasUsage
297 ? formatEstimatedCost(aggregate.totalCostUsd, aggregate.costTrackingAvailable)
298 : "—"
299 }
300 hint={
301 hasUsage && aggregate.costTrackingAvailable
302 ? "Based on local pricing data"
303 : hasUsage
304 ? "Pricing unavailable for some models"
305 : "No usage recorded yet"
306 }
307 />
308 <SettingsStatTile
309 label="Total tokens"
310 value={hasUsage ? formatTokenCount(aggregate.totalTokens) : "0"}
311 hint={
312 hasUsage
313 ? `${formatTokenCount(aggregate.totalPromptTokens)} in · ${formatTokenCount(aggregate.totalCompletionTokens)} out${aggregate.totalCachedPromptTokens > 0 ? ` · ${formatTokenCount(aggregate.totalCachedPromptTokens)} cache read` : ""}${aggregate.totalCacheWritePromptTokens > 0 ? ` · ${formatTokenCount(aggregate.totalCacheWritePromptTokens)} cache write` : ""}${aggregate.totalReasoningOutputTokens > 0 ? ` · ${formatTokenCount(aggregate.totalReasoningOutputTokens)} reasoning` : ""}`
314 : "No usage recorded yet"
315 }
316 />
317 <SettingsStatTile
318 label="Total turns"
319 value={hasUsage ? String(aggregate.totalTurns) : "0"}
320 hint={
321 hasUsage
322 ? `Across ${aggregate.totalSessions} session${aggregate.totalSessions === 1 ? "" : "s"}`
323 : "No sessions yet"
324 }
325 />
326 <SettingsStatTile
327 label="Providers"
328 value={hasUsage ? String(aggregate.providers.length) : "0"}
329 hint={
330 hasUsage
331 ? `${aggregate.providers.reduce((n, p) => n + p.models.length, 0)} model${aggregate.providers.reduce((n, p) => n + p.models.length, 0) === 1 ? "" : "s"} used`
332 : "No models used yet"
333 }
334 />
335 </div>
336
337 {/* ── Provider / model breakdown ──────────────────────────────── */}
338 {hasUsage && aggregate.providers.length > 0 ? (
339 <SettingsSection
340 title="By provider"
341 description="Aggregated token and cost totals per provider and model."
342 >
343 <div ref={parent} className="divide-y divide-border/45">
344 {aggregate.providers.map((group) => {
345 const isExpanded = expandedProviders[group.provider] ?? true;
346 return (
347 <div key={group.provider}>
348 {/* Provider header */}
349 <button
350 type="button"
351 className="flex w-full items-center justify-between px-4 py-3.5 text-left transition-colors hover:bg-card/60"
352 onClick={() => toggleProvider(group.provider)}
353 >
354 <div className="flex items-center gap-3">
355 {isExpanded ? (
356 <ChevronDownIcon className="w-4 h-4 text-muted-foreground" />
357 ) : (
358 <ChevronRightIcon className="w-4 h-4 text-muted-foreground" />
359 )}
360 <span className="font-medium text-foreground text-sm capitalize">
361 {group.provider}
362 </span>
363 <Badge variant="secondary" className="text-xs uppercase h-5">
364 {group.models.length} model{group.models.length === 1 ? "" : "s"}
365 </Badge>
366 </div>
367 <div className="flex items-center gap-4 text-xs text-muted-foreground">
368 <span>{formatTokenCount(group.totalTokens)} tokens</span>
369 {group.totalCachedPromptTokens > 0 ? (
370 <span>{formatTokenCount(group.totalCachedPromptTokens)} cache read</span>
371 ) : null}
372 {group.totalCacheWritePromptTokens > 0 ? (
373 <span>
374 {formatTokenCount(group.totalCacheWritePromptTokens)} cache write
375 </span>
376 ) : null}
377 {group.totalReasoningOutputTokens > 0 ? (
378 <span>{formatTokenCount(group.totalReasoningOutputTokens)} reasoning</span>
379 ) : null}
380 <span>
381 {group.totalTurns} turn{group.totalTurns === 1 ? "" : "s"}
382 </span>
383 {typeof group.estimatedCostUsd === "number" ? (
384 <Badge variant="outline">{formatCost(group.estimatedCostUsd)}</Badge>
385 ) : null}
386 </div>
387 </button>
388
389 {/* Model rows */}
390 {isExpanded && (
391 <div className="border-t border-border/50">
392 {group.models.map((model) => (
393 <div
394 key={model.model}
395 className="px-10 py-3 border-b border-border/40 last:border-b-0 bg-card/20"
396 >
397 <div className="flex items-center justify-between mb-2">
398 <div className="flex items-center gap-2">
399 <span className="text-sm font-medium text-foreground">
400 {model.model}
401 </span>
402 <span className="text-xs text-muted-foreground">
403 {model.turns} turn{model.turns === 1 ? "" : "s"} across{" "}
404 {model.sessions} session{model.sessions === 1 ? "" : "s"}
405 </span>
406 </div>
407 {typeof model.estimatedCostUsd === "number" ? (
408 <Badge variant="outline">{formatCost(model.estimatedCostUsd)}</Badge>
409 ) : (
410 <span className="text-xs text-muted-foreground">No pricing</span>
411 )}
412 </div>
413 <div className="grid gap-2 grid-cols-2 lg:grid-cols-6 text-xs">
414 <div>
415 <span className="text-muted-foreground">Prompt: </span>
416 <span className="text-foreground font-medium">
417 {formatTokenCount(model.totalPromptTokens)}
418 </span>
419 </div>
420 <div>
421 <span className="text-muted-foreground">Completion: </span>
422 <span className="text-foreground font-medium">
423 {formatTokenCount(model.totalCompletionTokens)}
424 </span>
425 </div>
426 <div>
427 <span className="text-muted-foreground">Cache read: </span>
428 <span className="text-foreground font-medium">
429 {formatTokenCount(model.totalCachedPromptTokens)}
430 </span>
431 </div>
432 <div>
433 <span className="text-muted-foreground">Cache write: </span>
434 <span className="text-foreground font-medium">
435 {formatTokenCount(model.totalCacheWritePromptTokens)}
436 </span>
437 </div>
438 <div>
439 <span className="text-muted-foreground">Reasoning: </span>
440 <span className="text-foreground font-medium">
441 {formatTokenCount(model.totalReasoningOutputTokens)}
442 </span>
443 </div>
444 <div>
445 <span className="text-muted-foreground">Total: </span>
446 <span className="text-foreground font-medium">
447 {formatTokenCount(model.totalTokens)}
448 </span>
449 </div>
450 </div>
451 </div>
452 ))}
453 </div>
454 )}
455 </div>
456 );
457 })}
458 </div>
459 </SettingsSection>
460 ) : (
461 <SettingsEmptyState
462 icon={<BarChart3Icon />}
463 title="No usage data recorded yet"
464 description="Usage will appear here as you use models across sessions."
465 />
466 )}
467
468 {!settingsChrome ? (
469 <div className="flex justify-end">
470 <Button
471 type="button"
472 variant="outline"
473 size="sm"
474 className="gap-2"
475 onClick={() => handleEstimateNoticeOpenChange?.(true)}
476 >
477 <AlertTriangleIcon data-icon="inline-start" />
478 How estimates work
479 </Button>
480 </div>
481 ) : null}
482 {estimateNoticeDialog}
483 </SettingsPage>
484 );
485}
486