1import { ChatGptUsageButton } from "../settings/ChatGptUsageButton";
2import { usesChatGptSharing } from "@t3tools/shared/usageLimits";
3import { RefreshIcon } from "~/components/ui/refresh-icon";
4import { useAtomValue } from "@effect/atom-react";
5import {
6 USAGE_CONTRACT_VERSION,
7 type EnvironmentId,
8 type UsageProviderKind,
9} from "@t3tools/contracts";
10import { CircleAlertIcon, ChevronDownIcon, InfoIcon, SlidersHorizontalIcon } from "lucide-react";
11import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
12import {
13 cursorKeychainAccessEnvironments,
14 refreshUsageLimits,
15} from "@t3tools/client-runtime/state/usage";
16import {
17 updatingProvidersLabel,
18 usageEnvironmentProgress,
19 usageLoadingState,
20} from "@t3tools/client-runtime/state/usage-progress";
21
22import {
23 isCompatibleUsageContractVersion,
24 isModelCostUnknown,
25 type DailyTotals,
26 type HourlyTotals,
27 type MergedUsage,
28} from "@t3tools/shared/usageMerge";
29
30import { isElectron } from "../../env";
31import { cn } from "../../lib/utils";
32import { environmentPresentations } from "../../state/presentation";
33import { primaryServerKeybindingsAtom, serverEnvironment } from "../../state/server";
34import { isCommandPaletteOpen } from "../../commandPaletteBus";
35import { isModelPickerOpen } from "../../modelPickerVisibility";
36import { shortcutLabelForCommand } from "../../keybindings";
37import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
38import { useAtomCommand } from "../../state/use-atom-command";
39import {
40 enumerateDays,
41 enumerateHourStarts,
42 formatCount,
43 formatDateTimeShort,
44 formatDayShort,
45 formatHourShort,
46 formatPercent,
47 formatTokens,
48 formatUsageContractMismatch,
49 formatUsd,
50 makeWindow,
51} from "@t3tools/shared/usageFormat";
52import { Button, InlineButton } from "../ui/button";
53import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
54import {
55 Menu,
56 MenuCheckboxItem,
57 MenuItem,
58 MenuPopup,
59 MenuSeparator,
60 MenuTrigger,
61} from "../ui/menu";
62import { ScrollArea } from "../ui/scroll-area";
63import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
64import { SidebarInset } from "../ui/sidebar";
65import { Skeleton } from "../ui/skeleton";
66import { Toggle, ToggleGroup } from "../ui/toggle-group";
67import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
68import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
69import {
70 WorkspaceBreadcrumb,
71 WorkspaceBreadcrumbItem,
72 WorkspaceBreadcrumbSeparator,
73 WorkspaceBreadcrumbText,
74} from "../WorkspaceBreadcrumb";
75import { WorkspacePageContainer } from "../WorkspacePageContainer";
76import { WorkspacePageHeader } from "../WorkspacePageHeader";
77import { UsagePriceOverrides } from "./UsagePriceOverrides";
78import { UsageProviderChart } from "./UsageProviderChart";
79import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog";
80import { UsageShareBar } from "./UsageShareBar";
81import {
82 costTypeSegments,
83 modelShare,
84 sortModelsByTokens,
85 speedCostSegments,
86 tokenTypeSegments,
87} from "./usageBreakdown";
88import {
89 METRIC_OPTIONS,
90 WINDOW_OPTIONS,
91 resolveUsageShortcut,
92 type UsageMetric,
93} from "./usageShortcuts";
94import { useEscapeToGoBack } from "../../hooks/useNavigateBack";
95import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";
96import {
97 readUsagePagePreferences,
98 saveUsagePagePreferences,
99 type UsagePagePreferences,
100} from "./usagePagePreferences";
101
102function isUsageMetric(value: string | null | undefined): value is UsageMetric {
103 return METRIC_OPTIONS.some((option) => option.value === value);
104}
105
106function isUsageWindowDays(value: number): value is UsagePagePreferences["windowDays"] {
107 return WINDOW_OPTIONS.some((option) => option.days === value);
108}
109
110const providerLabel = (provider: UsageProviderKind) => PROVIDER_PRESENTATION[provider].label;
111
112export function UsagePage() {
113 const [preferences, setPreferences] = useState(readUsagePagePreferences);
114 useEscapeToGoBack();
115 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
116 const shortcutTitle = (
117 option: (typeof METRIC_OPTIONS)[number] | (typeof WINDOW_OPTIONS)[number],
118 ) => {
119 const shortcut = shortcutLabelForCommand(keybindings, option.command, {
120 context: { usagePageOpen: true },
121 });
122 return shortcut ? `${option.label} (${shortcut})` : option.label;
123 };
124 const [windowSelection, setWindowSelection] = useState(() => ({
125 days: preferences.windowDays,
126 window: makeWindow(
127 preferences.windowDays,
128 undefined,
129 preferences.windowDays === 1 ? "hour" : "day",
130 ),
131 }));
132 const metric = preferences.metric;
133 const [isRefreshing, setIsRefreshing] = useState(false);
134 const refreshingRef = useRef(false);
135 const [breakdown, setBreakdown] = useState<"model" | "time">("model");
136 const [priceDialog, setPriceDialog] = useState<{ readonly model?: string } | null>(null);
137 const [selectedModelKey, setSelectedModelKey] = useState<string | null>(null);
138 const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
139 useState<ReadonlySet<EnvironmentId> | null>(null);
140 const hiddenProviders = useMemo(
141 () => new Set(preferences.hiddenProviders),
142 [preferences.hiddenProviders],
143 );
144 const { days: windowDays, window } = windowSelection;
145 const isPast24Hours = windowDays === 1;
146 const {
147 merged: answeredUsage,
148 environments,
149 selectedEnvironments,
150 shown,
151 isPartial,
152 refresh,
153 } = useUsage(window, selectedEnvironmentIds, hiddenProviders);
154 // Until a new window's first answer, the previous one stays on screen, muted.
155 const merged = shown?.merged ?? answeredUsage;
156 const shownWindow = shown?.window ?? window;
157 const shownHourly = shownWindow.resolution === "hour";
158 // Usage kept from another window is all old, so every figure stays muted.
159 const showingKept = shown !== null && shown.window !== window;
160 const loading = useMemo(() => {
161 if (showingKept) {
162 return { partial: true, everyProvider: true, providers: new Set<UsageProviderKind>() };
163 }
164 const state = usageLoadingState(selectedEnvironments, isRefreshing);
165 // A hidden provider still refreshing must not add its row or mute the totals.
166 const providers = new Set([...state.providers].filter((p) => !hiddenProviders.has(p)));
167 return {
168 partial: state.everyProvider || providers.size > 0,
169 everyProvider: state.everyProvider,
170 providers,
171 };
172 }, [showingKept, isRefreshing, selectedEnvironments, hiddenProviders]);
173 const isProviderLoading = (provider: UsageProviderKind) =>
174 loading.everyProvider || loading.providers.has(provider);
175 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
176 const cursorAccessEnvironments = hiddenProviders.has("cursor")
177 ? []
178 : cursorKeychainAccessEnvironments(selectedEnvironments);
179 const sourceMessages = [
180 ...new Set(
181 selectedEnvironments.flatMap(
182 (environment) =>
183 environment.summary?.sources.flatMap((source) =>
184 source.message &&
185 !source.action &&
186 !hiddenProviders.has(source.fingerprint.provider) &&
187 (source.status === "partial" ||
188 source.status === "failed" ||
189 source.fingerprint.provider === "cursor")
190 ? [source.message]
191 : [],
192 ) ?? [],
193 ),
194 ),
195 ];
196 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
197 reportFailure: false,
198 });
199
200 const canReadDiagnostics = selectedEnvironments.some(
201 (environment) => environment.canReadDiagnostics,
202 );
203
204 const days = useMemo(
205 () => enumerateDays(shownWindow.sinceDay, shownWindow.untilDay),
206 [shownWindow.sinceDay, shownWindow.untilDay],
207 );
208 const hours = useMemo(
209 () =>
210 shownWindow.sinceTime === undefined || shownWindow.untilTime === undefined
211 ? []
212 : enumerateHourStarts(shownWindow.sinceTime, shownWindow.untilTime),
213 [shownWindow.sinceTime, shownWindow.untilTime],
214 );
215 // Newest first: the window can run 90 periods, so the interesting end
216 // belongs at the top of the table.
217 const breakdownPeriods = useMemo<readonly (DailyTotals | HourlyTotals)[]>(
218 () => (shownHourly ? merged.hourly : merged.daily).toReversed(),
219 [shownHourly, merged.daily, merged.hourly],
220 );
221 const breakdownModels = useMemo(
222 () =>
223 breakdown === "model" && metric === "tokens"
224 ? sortModelsByTokens(merged.models)
225 : merged.models,
226 [breakdown, merged.models, metric],
227 );
228 const providersWithData = useMemo(() => providersWithUsage(merged.providers), [merged.providers]);
229 // A provider still refreshing keeps its row and line before its usage lands.
230 const activeProviders = useMemo(
231 () =>
232 PROVIDER_ORDER.filter(
233 (provider) => providersWithData.includes(provider) || loading.providers.has(provider),
234 ),
235 [loading.providers, providersWithData],
236 );
237 const chartLoadingProviders = useMemo(
238 () =>
239 new Set(
240 activeProviders.filter(
241 (provider) => loading.everyProvider || loading.providers.has(provider),
242 ),
243 ),
244 [activeProviders, loading.everyProvider, loading.providers],
245 );
246 const selectedModel =
247 selectedModelKey === null
248 ? undefined
249 : merged.models.find((model) => `${model.provider}:${model.model}` === selectedModelKey);
250 const breakdownPeak = breakdownModels.reduce(
251 (peak, model) => Math.max(peak, metric === "tokens" ? model.totalTokens : model.costUsd),
252 0,
253 );
254 const summaryRows: Array<
255 | { readonly kind: "usage"; readonly provider: UsageProviderKind }
256 | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus }
257 > = activeProviders.map((provider) => ({ kind: "usage", provider }));
258 const cursorInsertAt =
259 Math.max(activeProviders.indexOf("codex"), activeProviders.indexOf("claude")) + 1;
260 summaryRows.splice(
261 cursorInsertAt,
262 0,
263 ...cursorAccessEnvironments.map((environment) => ({ kind: "enable" as const, environment })),
264 );
265 const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;
266
267 const updatePreferences = (patch: Partial<UsagePagePreferences>) => {
268 const nextPreferences = { ...preferences, ...patch };
269 setPreferences(nextPreferences);
270 saveUsagePagePreferences(nextPreferences);
271 };
272 const selectWindow = (days: number) => {
273 if (!isUsageWindowDays(days)) return;
274 updatePreferences({ windowDays: days });
275 setWindowSelection({
276 days,
277 window: makeWindow(days, undefined, days === 1 ? "hour" : "day"),
278 });
279 };
280 const selectMetric = (nextMetric: UsageMetric) => {
281 updatePreferences({ metric: nextMetric });
282 };
283 const onUsageKeyDown = useEffectEvent((event: KeyboardEvent) => {
284 if (
285 event.defaultPrevented ||
286 event.repeat ||
287 event.isComposing ||
288 isCommandPaletteOpen() ||
289 isModelPickerOpen()
290 )
291 return;
292
293 const command = resolveUsageShortcut(event, keybindings);
294 const metricOption = METRIC_OPTIONS.find((option) => option.command === command);
295 const periodOption = WINDOW_OPTIONS.find((option) => option.command === command);
296 if (!metricOption && !periodOption) return;
297
298 event.preventDefault();
299 event.stopPropagation();
300 if (metricOption) selectMetric(metricOption.value);
301 if (periodOption) selectWindow(periodOption.days);
302 });
303
304 useEffect(() => {
305 globalThis.window.addEventListener("keydown", onUsageKeyDown, true);
306 return () => globalThis.window.removeEventListener("keydown", onUsageKeyDown, true);
307 }, []);
308
309 const refreshWindow = () => {
310 if (refreshingRef.current) return;
311
312 const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");
313 if (
314 nextWindow.sinceDay !== window.sinceDay ||
315 nextWindow.untilDay !== window.untilDay ||
316 nextWindow.sinceTime !== window.sinceTime ||
317 nextWindow.untilTime !== window.untilTime
318 ) {
319 setWindowSelection({ days: windowDays, window: nextWindow });
320 }
321 refreshingRef.current = true;
322 setIsRefreshing(true);
323 void refresh(nextWindow).finally(() => {
324 refreshingRef.current = false;
325 setIsRefreshing(false);
326 });
327 };
328 // Names the period on screen, which is the previous one until the new one answers.
329 const windowLabel =
330 shownHourly && shownWindow.sinceTime !== undefined && shownWindow.untilTime !== undefined
331 ? `${formatDateTimeShort(shownWindow.sinceTime, shownWindow.timeZone)} to ${formatDateTimeShort(shownWindow.untilTime, shownWindow.timeZone)}`
332 : `${formatDayShort(shownWindow.sinceDay)} to ${formatDayShort(shownWindow.untilDay)}`;
333 const topbarContent = (
334 <div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 xl:flex">
335 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
336 <WorkspaceBreadcrumbItem>
337 <h1>Usage</h1>
338 </WorkspaceBreadcrumbItem>
339 <WorkspaceBreadcrumbSeparator />
340 <WorkspaceBreadcrumbItem className="min-w-10 shrink">
341 <UsageEnvironmentFilter
342 environments={environments}
343 selectedEnvironments={selectedEnvironments}
344 selectedEnvironmentIds={selectedEnvironmentIds}
345 onSelectionChange={setSelectedEnvironmentIds}
346 refreshing={isRefreshing}
347 isPartial={isPartial}
348 duplicateSources={merged.duplicateSources}
349 contractMismatches={merged.contractMismatches}
350 onOpenModelPrices={() => setPriceDialog({})}
351 />
352 </WorkspaceBreadcrumbItem>
353 <WorkspaceBreadcrumbSeparator />
354 <WorkspaceBreadcrumbItem current className="min-w-10">
355 <UsageProviderFilter
356 hiddenProviders={hiddenProviders}
357 onChange={(next) => updatePreferences({ hiddenProviders: next })}
358 />
359 </WorkspaceBreadcrumbItem>
360 </WorkspaceBreadcrumb>
361 <span className="hidden min-w-0 truncate text-xs text-muted-foreground 2xl:block">
362 {windowLabel}
363 </span>
364 <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 xl:flex">
365 <ToggleGroup
366 aria-label="Usage metric"
367 variant="segmented"
368 value={[metric]}
369 onValueChange={(next) => {
370 const value = next[0];
371 if (isUsageMetric(value)) selectMetric(value);
372 }}
373 >
374 {METRIC_OPTIONS.map((option) => (
375 <Toggle key={option.value} value={option.value} title={shortcutTitle(option)}>
376 {option.label}
377 </Toggle>
378 ))}
379 </ToggleGroup>
380 <ToggleGroup
381 aria-label="Usage period"
382 variant="segmented"
383 value={[String(windowDays)]}
384 onValueChange={(next) => {
385 const value = next[0];
386 if (value) selectWindow(Number(value));
387 }}
388 >
389 {WINDOW_OPTIONS.map((option) => (
390 <Toggle key={option.days} value={String(option.days)} title={shortcutTitle(option)}>
391 {option.label}
392 </Toggle>
393 ))}
394 </ToggleGroup>
395 <Button
396 onClick={refreshWindow}
397 aria-label="Refresh usage"
398 aria-busy={isRefreshing}
399 disabled={isRefreshing || !canReadDiagnostics}
400 size="icon-sm"
401 variant="ghost"
402 >
403 <RefreshIcon size="sm" refreshing={isRefreshing} />
404 </Button>
405 </div>
406 <div className="ms-auto flex min-w-0 items-center justify-end gap-1 xl:hidden">
407 <Select
408 value={metric}
409 onValueChange={(value) => {
410 if (isUsageMetric(value)) selectMetric(value);
411 }}
412 >
413 <SelectTrigger
414 aria-label="Usage metric"
415 size="compact"
416 variant="ghost"
417 className="w-auto min-w-0"
418 >
419 <SelectValue>
420 {METRIC_OPTIONS.find((option) => option.value === metric)?.label}
421 </SelectValue>
422 </SelectTrigger>
423 <SelectPopup align="end" alignItemWithTrigger={false}>
424 {METRIC_OPTIONS.map((option) => (
425 <SelectItem key={option.value} value={option.value} title={shortcutTitle(option)}>
426 {option.label}
427 </SelectItem>
428 ))}
429 </SelectPopup>
430 </Select>
431 <Select value={String(windowDays)} onValueChange={(value) => selectWindow(Number(value))}>
432 <SelectTrigger
433 aria-label="Usage period"
434 size="compact"
435 variant="ghost"
436 className="w-auto min-w-0"
437 >
438 <SelectValue>
439 {WINDOW_OPTIONS.find((option) => option.days === windowDays)?.label}
440 </SelectValue>
441 </SelectTrigger>
442 <SelectPopup align="end" alignItemWithTrigger={false}>
443 {WINDOW_OPTIONS.map((option) => (
444 <SelectItem
445 key={option.days}
446 value={String(option.days)}
447 title={shortcutTitle(option)}
448 >
449 {option.label}
450 </SelectItem>
451 ))}
452 </SelectPopup>
453 </Select>
454 <Button
455 onClick={refreshWindow}
456 aria-label="Refresh usage"
457 aria-busy={isRefreshing}
458 disabled={isRefreshing || !canReadDiagnostics}
459 size="icon-sm"
460 variant="ghost"
461 >
462 <RefreshIcon size="sm" refreshing={isRefreshing} />
463 </Button>
464 </div>
465 </div>
466 );
467
468 return (
469 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none isolate">
470 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
471 <WorkspacePageHeader electron={isElectron}>{topbarContent}</WorkspacePageHeader>
472
473 <ScrollArea className="min-h-0 flex-1">
474 <WorkspacePageContainer width="wide">
475 {selectedEnvironments.length === 0 ? (
476 <p className="text-sm text-muted-foreground">
477 {environments.length === 0
478 ? "Connect an environment to see usage."
479 : "Select an environment to see usage."}
480 </p>
481 ) : shown === null ? (
482 <UsageSkeleton />
483 ) : !canReadDiagnostics ? (
484 <div className="space-y-2 py-12 text-center text-sm text-muted-foreground">
485 {selectedEnvironments.map((environment) => (
486 <p key={environment.environmentId}>
487 {selectedEnvironments.length > 1 ? `${environment.label}: ` : null}
488 {environment.error}
489 </p>
490 ))}
491 </div>
492 ) : (
493 <div aria-busy={loading.partial} className="flex flex-col gap-6">
494 {sourceMessages.map((message) => (
495 <p key={message} className="mb-4 text-sm text-muted-foreground">
496 {message}
497 </p>
498 ))}
499 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
500 <div className="flex min-w-0 flex-col gap-5">
501 <div className="flex flex-col gap-1">
502 <span
503 className={cn(
504 "text-4xl font-semibold text-foreground tabular-nums",
505 figureClass(loading.partial),
506 )}
507 >
508 {metric === "cost"
509 ? formatUsd(merged.costUsd)
510 : formatTokens(merged.totalTokens)}
511 </span>
512 <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
513 <span>
514 <span className={figureClass(loading.partial)}>
515 {formatCount(merged.sessions)} sessions
516 </span>
517 {metric === "cost" && " · API estimate"}
518 </span>
519 {metric === "cost" && merged.costQuality.unpricedShare > 0 && (
520 <Popover>
521 <PopoverTrigger
522 openOnHover
523 render={<InlineButton tone="muted" />}
524 aria-label="Unpriced usage details"
525 >
526 <InfoIcon className="size-3" aria-hidden />
527 </PopoverTrigger>
528 <PopoverPopup side="top" tooltipStyle>
529 API estimate excludes{" "}
530 {formatPercent(merged.costQuality.unpricedShare)} unpriced records.
531 </PopoverPopup>
532 </Popover>
533 )}
534 </span>
535 </div>
536
537 {!hiddenProviders.has("codex") &&
538 [...presentations].some(
539 ([id, presentation]) =>
540 (selectedEnvironmentIds === null || selectedEnvironmentIds.has(id)) &&
541 presentation.serverConfig?.providers.some(usesChatGptSharing),
542 ) ? (
543 <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
544 <span>ChatGPT shared usage</span>
545 <ChatGptUsageButton size="xs" />
546 </div>
547 ) : null}
548
549 {summaryRows.map((row) => {
550 if (row.kind === "enable") {
551 return (
552 <CursorEnableRow
553 key={`enable:${row.environment.environmentId}`}
554 environmentId={row.environment.environmentId}
555 label={row.environment.label}
556 showEnvironment={selectedEnvironments.length > 1}
557 onEnabled={() => {
558 void refresh();
559 void refreshUsageLimits(
560 row.environment.environmentId,
561 () =>
562 refreshProviders({
563 environmentId: row.environment.environmentId,
564 input: {},
565 }),
566 false,
567 true,
568 );
569 }}
570 />
571 );
572 }
573 const provider = row.provider;
574 const totals = merged.providers.find((entry) => entry.provider === provider);
575 const share =
576 metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0);
577 const providerSessions = totals?.sessions ?? 0;
578 const sessionLabel = `${formatCount(providerSessions)} ${
579 providerSessions === 1 ? "session" : "sessions"
580 }`;
581 const providerLoading = isProviderLoading(provider);
582 const awaitingData = providerLoading && !providersWithData.includes(provider);
583 return (
584 <div key={provider} className="flex flex-col gap-1">
585 <div className="flex items-baseline justify-between gap-4">
586 <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">
587 <span
588 aria-hidden
589 className="size-2 shrink-0 rounded-full"
590 style={{
591 backgroundColor: PROVIDER_PRESENTATION[provider].color,
592 }}
593 />
594 <ProviderMark provider={provider} className="size-4" />
595 <span className="flex min-w-0 items-baseline gap-1.5">
596 <span className="truncate">
597 {PROVIDER_PRESENTATION[provider].label}
598 </span>
599 <span
600 className={cn(
601 "shrink-0 whitespace-nowrap text-2xs text-muted-foreground tabular-nums",
602 figureClass(providerLoading),
603 awaitingData && "invisible",
604 )}
605 >
606 {sessionLabel}
607 </span>
608 </span>
609 </span>
610 <span
611 className={cn(
612 "shrink-0 text-sm font-medium text-foreground tabular-nums",
613 figureClass(providerLoading),
614 )}
615 >
616 {awaitingData
617 ? "—"
618 : metric === "cost"
619 ? formatUsd(totals?.costUsd ?? 0)
620 : formatTokens(totals?.totalTokens ?? 0)}
621 </span>
622 </div>
623 {/* Kept while awaiting data so the row does not grow when it lands. */}
624 <span
625 className={cn(
626 "text-xs text-muted-foreground",
627 figureClass(providerLoading),
628 awaitingData && "invisible",
629 )}
630 >
631 {metric === "cost"
632 ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens`
633 : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}
634 </span>
635 </div>
636 );
637 })}
638 </div>
639
640 <div className="flex min-w-0 flex-col gap-3">
641 <h2 className="text-sm font-medium text-foreground">
642 {shownHourly ? "Hourly" : "Daily"}{" "}
643 {metric === "tokens" ? "processed tokens" : "cost"}
644 </h2>
645 <UsageProviderChart
646 providers={activeProviders}
647 loadingProviders={chartLoadingProviders}
648 days={days}
649 daily={merged.daily}
650 hours={hours}
651 hourly={merged.hourly}
652 metric={metric}
653 referenceTime={shownWindow.untilTime}
654 resolution={shownHourly ? "hour" : "day"}
655 timeZone={shownWindow.timeZone}
656 />
657 </div>
658 </section>
659
660 <section className="flex flex-col gap-2">
661 <h2 className="text-sm font-medium text-foreground">Totals</h2>
662 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
663 <Metric
664 loading={loading.partial}
665 label="Processed tokens"
666 value={formatTokens(merged.totalTokens)}
667 />
668 <Metric
669 loading={loading.partial}
670 label="Cached input"
671 value={formatTokens(merged.cachedInputTokens)}
672 />
673 <Metric
674 loading={loading.partial}
675 label="Uncached input"
676 value={formatTokens(merged.uncachedInputTokens)}
677 />
678 <Metric
679 loading={loading.partial}
680 label="Output"
681 value={formatTokens(merged.outputTokens)}
682 />
683 <Metric
684 loading={loading.partial}
685 label="Cache savings"
686 value={formatUsd(merged.costQuality.cacheSavingsUsd)}
687 />
688 </div>
689 </section>
690
691 {merged.totalTokens > 0 ? (
692 <section
693 className={cn(
694 "grid gap-x-12 gap-y-8 lg:grid-cols-2",
695 figureClass(loading.partial),
696 )}
697 >
698 {metric === "tokens" ? (
699 <UsageShareBar
700 label="Tokens by type"
701 segments={tokenTypeSegments(merged)}
702 format={formatTokens}
703 />
704 ) : (
705 <>
706 <UsageShareBar
707 label="Cost by type"
708 segments={costTypeSegments(merged.categoryCost)}
709 format={formatUsd}
710 />
711 {merged.speedCost.fast + merged.speedCost.ultrafast > 0 ? (
712 <UsageShareBar
713 label="Cost by speed"
714 segments={speedCostSegments(merged.speedCost)}
715 format={formatUsd}
716 aside={<SpeedPremium premiumUsd={merged.speedCost.premium} />}
717 />
718 ) : null}
719 </>
720 )}
721 </section>
722 ) : null}
723
724 <section className="flex flex-col gap-3">
725 <div className="flex items-center justify-between gap-3">
726 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>
727 <ToggleGroup
728 aria-label="Usage breakdown"
729 variant="segmented"
730 value={[breakdown]}
731 onValueChange={(next) => {
732 const value = next[0];
733 if (value === "model" || value === "time") setBreakdown(value);
734 }}
735 >
736 {(
737 [
738 { value: "model", label: "Model" },
739 { value: "time", label: shownHourly ? "Hour" : "Day" },
740 ] as const
741 ).map((option) => (
742 <Toggle key={option.value} value={option.value}>
743 {option.label}
744 </Toggle>
745 ))}
746 </ToggleGroup>
747 </div>
748
749 {breakdown === "model" ? (
750 <table className="w-full text-sm">
751 <thead>
752 <tr className="border-b border-border text-right text-xs text-muted-foreground">
753 <th className="py-2 pr-3 text-left font-normal">#</th>
754 <th className="w-full py-2 text-left font-normal">Model</th>
755 <th className="py-2 pl-6 font-normal">Cost</th>
756 <th className="hidden py-2 pl-6 font-normal sm:table-cell">Share</th>
757 <th className="py-2 pl-6 font-normal">Tokens</th>
758 </tr>
759 </thead>
760 <tbody>
761 {breakdownModels.length === 0 ? (
762 <tr>
763 <td colSpan={5} className="py-6 text-center text-muted-foreground">
764 No activity in this window.
765 </td>
766 </tr>
767 ) : (
768 breakdownModels.map((model, index) => {
769 const key = `${model.provider}:${model.model}`;
770 const value = metric === "tokens" ? model.totalTokens : model.costUsd;
771 const share = modelShare(
772 model,
773 metric === "tokens" ? "tokens" : "cost",
774 );
775 const rowFigures = figureClass(isProviderLoading(model.provider));
776 return (
777 <tr
778 key={key}
779 className="relative border-b border-border/50 text-right whitespace-nowrap text-muted-foreground tabular-nums transition-colors hover:bg-muted/50 has-focus-visible:bg-muted/50"
780 >
781 <td className="py-2.5 pr-3 text-left text-xs">{index + 1}</td>
782 <td className="py-2.5 text-left whitespace-normal">
783 {/* The button's overlay makes the whole row open the model.
784 Focus shows as the row's hover fill, not a ring. */}
785 <button
786 type="button"
787 onClick={() => setSelectedModelKey(key)}
788 className="flex items-center gap-2 text-left text-foreground outline-none after:absolute after:inset-0"
789 >
790 <ProviderMark provider={model.provider} className="size-3.5" />
791 {model.model}
792 </button>
793 <div
794 aria-hidden
795 className={cn("mt-1.5 h-0.5 max-w-48", rowFigures)}
796 >
797 <div
798 className="h-full rounded-full"
799 style={{
800 // A short minimum keeps tiny shares a dash, not a dot.
801 width:
802 value > 0 && breakdownPeak > 0
803 ? `max(0.5rem, ${(value / breakdownPeak) * 100}%)`
804 : 0,
805 backgroundColor:
806 PROVIDER_PRESENTATION[model.provider].color,
807 }}
808 />
809 </div>
810 </td>
811 <td className={cn("py-2.5 pl-6 text-foreground", rowFigures)}>
812 {isModelCostUnknown(model) ? (
813 <span className="text-muted-foreground">Unpriced</span>
814 ) : (
815 formatUsd(model.costUsd)
816 )}
817 </td>
818 <td className={cn("hidden py-2.5 pl-6 sm:table-cell", rowFigures)}>
819 {share === null ? "" : formatPercent(share)}
820 </td>
821 <td className={cn("py-2.5 pl-6", rowFigures)}>
822 {formatTokens(model.totalTokens)}
823 </td>
824 </tr>
825 );
826 })
827 )}
828 </tbody>
829 </table>
830 ) : (
831 <table className="w-full table-fixed text-sm">
832 <colgroup>
833 <col className="w-2/5" />
834 {activeProviders.map((provider) => (
835 <col key={provider} style={{ width: timeValueColumnWidth }} />
836 ))}
837 <col style={{ width: timeValueColumnWidth }} />
838 <col style={{ width: timeValueColumnWidth }} />
839 </colgroup>
840 <thead>
841 <tr className="border-b border-border text-left text-xs text-muted-foreground">
842 <th className="py-2 font-normal">{shownHourly ? "Hour" : "Day"}</th>
843 {activeProviders.map((provider) => (
844 <th key={provider} className="py-2 text-right font-normal">
845 {PROVIDER_PRESENTATION[provider].label}
846 </th>
847 ))}
848 <th className="py-2 text-right font-normal">Total</th>
849 <th className="py-2 text-right font-normal">Tokens</th>
850 </tr>
851 </thead>
852 <tbody>
853 {breakdownPeriods.length === 0 ? (
854 <tr>
855 <td
856 colSpan={activeProviders.length + 3}
857 className="py-6 text-center text-muted-foreground"
858 >
859 No activity in this window.
860 </td>
861 </tr>
862 ) : (
863 breakdownPeriods.map((period) => (
864 <tr
865 key={"hourStart" in period ? period.hourStart : period.day}
866 className="border-b border-border/50 transition-colors hover:bg-muted/50"
867 >
868 <td className="py-2 text-foreground">
869 {"hourStart" in period
870 ? formatHourShort(period.hourStart, shownWindow.timeZone)
871 : formatDayShort(period.day)}
872 </td>
873 {activeProviders.map((provider) => (
874 <td
875 key={provider}
876 className={cn(
877 "py-2 text-right text-muted-foreground tabular-nums",
878 figureClass(isProviderLoading(provider)),
879 )}
880 >
881 {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)}
882 </td>
883 ))}
884 <td
885 className={cn(
886 "py-2 text-right text-foreground tabular-nums",
887 figureClass(loading.partial),
888 )}
889 >
890 {formatUsd(period.costUsd)}
891 </td>
892 <td
893 className={cn(
894 "py-2 text-right text-muted-foreground tabular-nums",
895 figureClass(loading.partial),
896 )}
897 >
898 {formatTokens(period.totalTokens)}
899 </td>
900 </tr>
901 ))
902 )}
903 </tbody>
904 </table>
905 )}
906 </section>
907 </div>
908 )}
909 </WorkspacePageContainer>
910 </ScrollArea>
911 </div>
912 {selectedModel !== undefined ? (
913 <UsageModelDialog
914 model={selectedModel}
915 environments={selectedEnvironments}
916 metric={metric === "tokens" ? "tokens" : "cost"}
917 chartWindow={{
918 days,
919 hours,
920 resolution: shownHourly ? "hour" : "day",
921 timeZone: shownWindow.timeZone,
922 referenceTime: shownWindow.untilTime,
923 }}
924 onSetPrice={() => {
925 setSelectedModelKey(null);
926 setPriceDialog({ model: selectedModel.model });
927 }}
928 onClose={() => setSelectedModelKey(null)}
929 />
930 ) : null}
931 {priceDialog ? (
932 <UsagePriceOverrides
933 usage={environments}
934 initialSelectedEnvironmentIds={selectedEnvironmentIds}
935 initialModel={priceDialog.model}
936 onOpenChange={(open) => {
937 if (!open) setPriceDialog(null);
938 }}
939 />
940 ) : null}
941 </SidebarInset>
942 );
943}
944
945const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain.";
946
947function CursorEnableRow({
948 environmentId,
949 label,
950 showEnvironment,
951 onEnabled,
952}: {
953 readonly environmentId: EnvironmentId;
954 readonly label: string;
955 readonly showEnvironment: boolean;
956 readonly onEnabled: () => void;
957}) {
958 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
959 label: "enable Cursor account usage",
960 });
961 const [pending, setPending] = useState(false);
962 const enable = async () => {
963 setPending(true);
964 try {
965 const result = await updateSettings({
966 environmentId,
967 input: { patch: { cursorKeychainUsageEnabled: true } },
968 });
969 if (result._tag === "Success") onEnabled();
970 } finally {
971 setPending(false);
972 }
973 };
974
975 return (
976 <div className="flex min-w-0 items-baseline justify-between gap-4 text-sm">
977 <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">
978 <span
979 aria-hidden
980 className="size-2 shrink-0 rounded-full"
981 style={{ backgroundColor: PROVIDER_PRESENTATION.cursor.color }}
982 />
983 <ProviderMark provider="cursor" className="size-4" />
984 <span className="truncate">Cursor{showEnvironment ? ` · ${label}` : ""}</span>
985 </span>
986 <Tooltip>
987 <TooltipTrigger
988 render={
989 <InlineButton
990 disabled={pending}
991 aria-busy={pending}
992 aria-label={`Enable Cursor usage from ${label}`}
993 onClick={() => void enable()}
994 >
995 Enable
996 </InlineButton>
997 }
998 />
999 <TooltipPopup>{CURSOR_KEYCHAIN_COPY}</TooltipPopup>
1000 </Tooltip>
1001 </div>
1002 );
1003}
1004
1005/** Brand mark for the harness a row belongs to. */
1006function ProviderMark({
1007 provider,
1008 className,
1009}: {
1010 readonly provider: UsageProviderKind;
1011 readonly className: string;
1012}) {
1013 const presentation = PROVIDER_PRESENTATION[provider];
1014 return (
1015 <ProviderInstanceIcon
1016 driverKind={presentation.driverKind}
1017 displayName={presentation.label}
1018 iconClassName={className}
1019 />
1020 );
1021}
1022
1023/** Mutes a figure that is still coming in. The delay keeps a quick answer from flashing. */
1024function figureClass(loading: boolean) {
1025 return cn("transition-opacity", loading && "opacity-40 delay-150");
1026}
1027
1028function Metric({
1029 label,
1030 value,
1031 loading,
1032}: {
1033 readonly label: string;
1034 readonly value: string;
1035 readonly loading: boolean;
1036}) {
1037 return (
1038 <div className="flex min-w-0 flex-col gap-0.5">
1039 <span className="text-xs text-muted-foreground">{label}</span>
1040 <span
1041 className={cn("text-base font-medium text-foreground tabular-nums", figureClass(loading))}
1042 >
1043 {value}
1044 </span>
1045 </div>
1046 );
1047}
1048
1049/**
1050 * Explains failed or incompatible environments and deduplicated transcripts.
1051 * Shown inside the environment filter so arriving results do not move the page.
1052 */
1053function UsageCoverageNotice({
1054 environments,
1055 duplicateSources,
1056 contractMismatches,
1057}: {
1058 readonly environments: readonly EnvironmentUsageStatus[];
1059 readonly duplicateSources: readonly string[];
1060 readonly contractMismatches: MergedUsage["contractMismatches"];
1061}) {
1062 const failed = environments.filter((environment) => environment.error !== null);
1063 const mismatchByEnvironment = new Map(
1064 contractMismatches.map((mismatch) => [mismatch.environmentId, mismatch]),
1065 );
1066 const incompatible = environments.flatMap((environment) => {
1067 const mismatch = mismatchByEnvironment.get(environment.environmentId);
1068 return mismatch === undefined ? [] : [{ environment, mismatch }];
1069 });
1070 if (failed.length === 0 && incompatible.length === 0 && duplicateSources.length === 0) {
1071 return null;
1072 }
1073
1074 return (
1075 <div className="flex flex-col gap-1 border-t border-border px-2 py-2 text-xs text-muted-foreground">
1076 {failed.map((environment) => (
1077 <span key={environment.label}>
1078 {environment.label}: {environment.error}
1079 </span>
1080 ))}
1081 {incompatible.map(({ environment, mismatch }) => (
1082 <span key={environment.environmentId}>
1083 {formatUsageContractMismatch(environment.label, mismatch)}
1084 </span>
1085 ))}
1086 {duplicateSources.length > 0 ? (
1087 <span>
1088 Counted once across environments sharing a transcript directory:{" "}
1089 {duplicateSources.join(", ")}
1090 </span>
1091 ) : null}
1092 </div>
1093 );
1094}
1095
1096/** Environment selection, with each environment's scan status in the menu. */
1097function UsageEnvironmentFilter({
1098 environments,
1099 selectedEnvironments,
1100 selectedEnvironmentIds,
1101 onSelectionChange,
1102 refreshing,
1103 isPartial,
1104 duplicateSources,
1105 contractMismatches,
1106 onOpenModelPrices,
1107}: {
1108 readonly environments: readonly EnvironmentUsageStatus[];
1109 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
1110 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
1111 readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId> | null) => void;
1112 readonly refreshing: boolean;
1113 readonly isPartial: boolean;
1114 readonly duplicateSources: readonly string[];
1115 readonly contractMismatches: MergedUsage["contractMismatches"];
1116 readonly onOpenModelPrices: () => void;
1117}) {
1118 const allSelected = selectedEnvironmentIds === null;
1119 const label = allSelected
1120 ? "All environments"
1121 : selectedEnvironments.length === 1
1122 ? selectedEnvironments[0]!.label
1123 : `${selectedEnvironments.length} environments`;
1124 const hasIssue =
1125 selectedEnvironments.some((environment) => environment.error !== null) ||
1126 contractMismatches.length > 0;
1127
1128 return (
1129 <Menu>
1130 <MenuTrigger render={<InlineButton />} className="group/usage-environment min-w-0 max-w-full">
1131 <WorkspaceBreadcrumbText>{label}</WorkspaceBreadcrumbText>
1132 <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
1133 {hasIssue ? (
1134 <CircleAlertIcon
1135 className="size-3.5 text-warning-foreground"
1136 aria-label="Some environments could not report usage"
1137 />
1138 ) : (
1139 <ChevronDownIcon
1140 className="size-3.5 opacity-0 transition-opacity group-hover/usage-environment:opacity-100 group-focus-visible/usage-environment:opacity-100 group-data-popup-open/usage-environment:opacity-100"
1141 aria-hidden
1142 />
1143 )}
1144 </span>
1145 </MenuTrigger>
1146 <MenuPopup align="start">
1147 <MenuCheckboxItem
1148 checked={allSelected}
1149 closeOnClick={false}
1150 onCheckedChange={(checked) => onSelectionChange(checked ? null : new Set())}
1151 >
1152 All environments
1153 </MenuCheckboxItem>
1154 <MenuSeparator />
1155 {environments.map((environment) => {
1156 const checked =
1157 selectedEnvironmentIds === null ||
1158 selectedEnvironmentIds.has(environment.environmentId);
1159 const progress = usageEnvironmentProgress(environment, refreshing);
1160 const status =
1161 environment.error !== null
1162 ? "Unavailable"
1163 : environment.summary !== null &&
1164 !isCompatibleUsageContractVersion(
1165 environment.summary.contractVersion,
1166 USAGE_CONTRACT_VERSION,
1167 )
1168 ? "Update required"
1169 : !environment.isConnected
1170 ? "Connecting…"
1171 : progress.phase === "loading"
1172 ? "Scanning…"
1173 : progress.phase === "stale"
1174 ? "Refreshing…"
1175 : progress.phase === "partway"
1176 ? updatingProvidersLabel(progress.providers, providerLabel)
1177 : "Ready";
1178 return (
1179 <MenuCheckboxItem
1180 key={environment.environmentId}
1181 checked={checked}
1182 closeOnClick={false}
1183 onCheckedChange={(nextChecked) => {
1184 const next = new Set(selectedEnvironments.map((entry) => entry.environmentId));
1185 if (nextChecked) next.add(environment.environmentId);
1186 else next.delete(environment.environmentId);
1187 onSelectionChange(next.size === environments.length ? null : next);
1188 }}
1189 >
1190 <span className="flex min-w-0 items-center gap-3">
1191 <span className="min-w-0 flex-1 truncate">{environment.label}</span>
1192 <span
1193 className={cn(
1194 "shrink-0 text-xs text-muted-foreground",
1195 environment.error !== null && "text-destructive",
1196 )}
1197 >
1198 {status}
1199 </span>
1200 </span>
1201 </MenuCheckboxItem>
1202 );
1203 })}
1204 {environments.length === 0 ? (
1205 <p className="px-2 py-2 text-xs text-muted-foreground">No environments connected.</p>
1206 ) : null}
1207 {isPartial ? (
1208 <p className="px-2 py-2 text-xs text-muted-foreground">
1209 Totals are partial while selected environments scan.
1210 </p>
1211 ) : null}
1212 <UsageCoverageNotice
1213 environments={selectedEnvironments}
1214 duplicateSources={duplicateSources}
1215 contractMismatches={contractMismatches}
1216 />
1217 <MenuSeparator />
1218 <MenuItem onClick={onOpenModelPrices}>
1219 <SlidersHorizontalIcon aria-hidden />
1220 Model prices
1221 </MenuItem>
1222 </MenuPopup>
1223 </Menu>
1224 );
1225}
1226
1227/** Provider visibility shared by every tab. Stored as the hidden set. */
1228function UsageProviderFilter({
1229 hiddenProviders,
1230 onChange,
1231}: {
1232 readonly hiddenProviders: ReadonlySet<UsageProviderKind>;
1233 readonly onChange: (hiddenProviders: readonly UsageProviderKind[]) => void;
1234}) {
1235 const visible = PROVIDER_ORDER.filter((provider) => !hiddenProviders.has(provider));
1236 const label =
1237 visible.length === PROVIDER_ORDER.length
1238 ? "All providers"
1239 : visible.length === 0
1240 ? "No providers"
1241 : visible.length === 1
1242 ? PROVIDER_PRESENTATION[visible[0]!].label
1243 : `${visible.length} providers`;
1244
1245 return (
1246 <Menu>
1247 <MenuTrigger render={<InlineButton />} className="group/usage-provider min-w-0 max-w-full">
1248 <span className="min-w-0 truncate">{label}</span>
1249 <ChevronDownIcon
1250 className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/usage-provider:opacity-100 group-focus-visible/usage-provider:opacity-100 group-data-popup-open/usage-provider:opacity-100"
1251 aria-hidden
1252 />
1253 </MenuTrigger>
1254 <MenuPopup align="start">
1255 <MenuCheckboxItem
1256 checked={hiddenProviders.size === 0}
1257 closeOnClick={false}
1258 onCheckedChange={(checked) => onChange(checked ? [] : PROVIDER_ORDER)}
1259 >
1260 All providers
1261 </MenuCheckboxItem>
1262 <MenuSeparator />
1263 {PROVIDER_ORDER.map((provider) => (
1264 <MenuCheckboxItem
1265 key={provider}
1266 checked={!hiddenProviders.has(provider)}
1267 closeOnClick={false}
1268 onCheckedChange={(checked) =>
1269 onChange(
1270 PROVIDER_ORDER.filter((entry) =>
1271 entry === provider ? !checked : hiddenProviders.has(entry),
1272 ),
1273 )
1274 }
1275 >
1276 <span className="flex min-w-0 items-center gap-2">
1277 <ProviderMark provider={provider} className="size-3.5" />
1278 <span className="truncate">{PROVIDER_PRESENTATION[provider].label}</span>
1279 </span>
1280 </MenuCheckboxItem>
1281 ))}
1282 </MenuPopup>
1283 </Menu>
1284 );
1285}
1286
1287/**
1288 * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it
1289 * breathes with the same `animate-skeleton` pulse as every other loading state.
1290 * Replaced by results as soon as the first environment answers.
1291 */
1292function UsageSkeleton() {
1293 return (
1294 <>
1295 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
1296 <div className="flex flex-col gap-5">
1297 <div className="flex flex-col gap-1">
1298 <Skeleton className="h-10 w-36" />
1299 <Skeleton className="h-4 w-32" />
1300 </div>
1301 {PROVIDER_ORDER.map((provider) => (
1302 <div key={provider} className="flex flex-col gap-1">
1303 <div className="flex min-h-5 items-center justify-between gap-4">
1304 <span className="flex items-center gap-2">
1305 <Skeleton shape="pill" className="size-2 shrink-0" />
1306 <Skeleton shape="pill" className="size-4 shrink-0" />
1307 <Skeleton className="h-3.5 w-20" />
1308 </span>
1309 <Skeleton className="h-3.5 w-14" />
1310 </div>
1311 <Skeleton className="h-4 w-36" />
1312 </div>
1313 ))}
1314 </div>
1315
1316 <div className="flex flex-col gap-3">
1317 <Skeleton className="h-5 w-24" />
1318 <div className="flex flex-col gap-1">
1319 <Skeleton className="ml-16 h-56" />
1320 <Skeleton className="ml-16 h-4" />
1321 </div>
1322 </div>
1323 </section>
1324
1325 <section className="flex flex-col gap-2">
1326 <h2 className="text-sm font-medium text-foreground">Totals</h2>
1327 <MetricSkeletons
1328 labels={["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"]}
1329 />
1330 </section>
1331
1332 <section className="grid gap-x-12 gap-y-8 lg:grid-cols-2">
1333 <div className="flex flex-col gap-2.5">
1334 <Skeleton className="h-5 w-28" />
1335 <Skeleton className="h-2" />
1336 <Skeleton className="h-4 w-72" />
1337 </div>
1338 </section>
1339
1340 <section className="flex flex-col gap-3">
1341 <div className="flex items-center justify-between gap-3">
1342 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>
1343 <Skeleton shape="card" className="h-7 w-28" />
1344 </div>
1345 <Skeleton className="h-44" />
1346 </section>
1347 </>
1348 );
1349}
1350
1351function MetricSkeletons({ labels }: { readonly labels: readonly string[] }) {
1352 return (
1353 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
1354 {labels.map((label) => (
1355 <div key={label} className="flex flex-col gap-0.5">
1356 <span className="text-xs text-muted-foreground">{label}</span>
1357 <Skeleton className="h-6 w-16" />
1358 </div>
1359 ))}
1360 </div>
1361 );
1362}