| 1 | import { ChatGptUsageButton } from "../settings/ChatGptUsageButton"; |
| 2 | import { usesChatGptSharing } from "@t3tools/shared/usageLimits"; |
| 3 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 4 | import { useAtomValue } from "@effect/atom-react"; |
| 5 | import { |
| 6 | USAGE_CONTRACT_VERSION, |
| 7 | type EnvironmentId, |
| 8 | type UsageProviderKind, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import { CircleAlertIcon, ChevronDownIcon, InfoIcon, SlidersHorizontalIcon } from "lucide-react"; |
| 11 | import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| 12 | import { |
| 13 | cursorKeychainAccessEnvironments, |
| 14 | refreshUsageLimits, |
| 15 | } from "@t3tools/client-runtime/state/usage"; |
| 16 | import { |
| 17 | updatingProvidersLabel, |
| 18 | usageEnvironmentProgress, |
| 19 | usageLoadingState, |
| 20 | } from "@t3tools/client-runtime/state/usage-progress"; |
| 21 | |
| 22 | import { |
| 23 | isCompatibleUsageContractVersion, |
| 24 | isModelCostUnknown, |
| 25 | type DailyTotals, |
| 26 | type HourlyTotals, |
| 27 | type MergedUsage, |
| 28 | } from "@t3tools/shared/usageMerge"; |
| 29 | |
| 30 | import { isElectron } from "../../env"; |
| 31 | import { cn } from "../../lib/utils"; |
| 32 | import { environmentPresentations } from "../../state/presentation"; |
| 33 | import { primaryServerKeybindingsAtom, serverEnvironment } from "../../state/server"; |
| 34 | import { isCommandPaletteOpen } from "../../commandPaletteBus"; |
| 35 | import { isModelPickerOpen } from "../../modelPickerVisibility"; |
| 36 | import { shortcutLabelForCommand } from "../../keybindings"; |
| 37 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 38 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 39 | import { |
| 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"; |
| 52 | import { Button, InlineButton } from "../ui/button"; |
| 53 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 54 | import { |
| 55 | Menu, |
| 56 | MenuCheckboxItem, |
| 57 | MenuItem, |
| 58 | MenuPopup, |
| 59 | MenuSeparator, |
| 60 | MenuTrigger, |
| 61 | } from "../ui/menu"; |
| 62 | import { ScrollArea } from "../ui/scroll-area"; |
| 63 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 64 | import { SidebarInset } from "../ui/sidebar"; |
| 65 | import { Skeleton } from "../ui/skeleton"; |
| 66 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 67 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 68 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 69 | import { |
| 70 | WorkspaceBreadcrumb, |
| 71 | WorkspaceBreadcrumbItem, |
| 72 | WorkspaceBreadcrumbSeparator, |
| 73 | WorkspaceBreadcrumbText, |
| 74 | } from "../WorkspaceBreadcrumb"; |
| 75 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; |
| 76 | import { WorkspacePageHeader } from "../WorkspacePageHeader"; |
| 77 | import { UsagePriceOverrides } from "./UsagePriceOverrides"; |
| 78 | import { UsageProviderChart } from "./UsageProviderChart"; |
| 79 | import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog"; |
| 80 | import { UsageShareBar } from "./UsageShareBar"; |
| 81 | import { |
| 82 | costTypeSegments, |
| 83 | modelShare, |
| 84 | sortModelsByTokens, |
| 85 | speedCostSegments, |
| 86 | tokenTypeSegments, |
| 87 | } from "./usageBreakdown"; |
| 88 | import { |
| 89 | METRIC_OPTIONS, |
| 90 | WINDOW_OPTIONS, |
| 91 | resolveUsageShortcut, |
| 92 | type UsageMetric, |
| 93 | } from "./usageShortcuts"; |
| 94 | import { useEscapeToGoBack } from "../../hooks/useNavigateBack"; |
| 95 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; |
| 96 | import { |
| 97 | readUsagePagePreferences, |
| 98 | saveUsagePagePreferences, |
| 99 | type UsagePagePreferences, |
| 100 | } from "./usagePagePreferences"; |
| 101 | |
| 102 | function isUsageMetric(value: string | null | undefined): value is UsageMetric { |
| 103 | return METRIC_OPTIONS.some((option) => option.value === value); |
| 104 | } |
| 105 | |
| 106 | function isUsageWindowDays(value: number): value is UsagePagePreferences["windowDays"] { |
| 107 | return WINDOW_OPTIONS.some((option) => option.days === value); |
| 108 | } |
| 109 | |
| 110 | const providerLabel = (provider: UsageProviderKind) => PROVIDER_PRESENTATION[provider].label; |
| 111 | |
| 112 | export 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 | |
| 945 | const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; |
| 946 | |
| 947 | function 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. */ |
| 1006 | function 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. */ |
| 1024 | function figureClass(loading: boolean) { |
| 1025 | return cn("transition-opacity", loading && "opacity-40 delay-150"); |
| 1026 | } |
| 1027 | |
| 1028 | function 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 | */ |
| 1053 | function 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. */ |
| 1097 | function 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. */ |
| 1228 | function 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 | */ |
| 1292 | function 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 | |
| 1351 | function 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 | } |