diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 672d454f363f..34c3c489a6cd 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -7,6 +7,7 @@ import { USAGE_CONTRACT_VERSION, type EnvironmentId, type UsageProviderKind, + type UsageSummaryInput, } from "@t3tools/contracts"; import { CircleAlertIcon, ChevronDownIcon, InfoIcon, SlidersHorizontalIcon } from "lucide-react"; import { useEffect, useEffectEvent, useRef, useState } from "react"; @@ -114,12 +115,6 @@ function isUsageWindowDays(value: number): value is UsagePagePreferences["window const providerLabel = (provider: UsageProviderKind) => PROVIDER_PRESENTATION[provider].label; -// useUsage keys its queries on this set, so it must keep its identity. -function useHiddenProviderSet(hiddenProviders: UsagePagePreferences["hiddenProviders"]) { - "use memo"; - return new Set(hiddenProviders); -} - function usageLoading( showingKept: boolean, selectedEnvironments: Parameters[0], @@ -138,37 +133,6 @@ function usageLoading( }; } -// UsageProviderChart memoizes on the provider collections, so they must keep their identity. -function useUsageProviders( - showingKept: boolean, - selectedEnvironments: Parameters[0], - refreshingUsage: boolean, - hiddenProviders: ReadonlySet, - mergedProviders: MergedUsage["providers"], -) { - "use memo"; - const loading = usageLoading(showingKept, selectedEnvironments, refreshingUsage, hiddenProviders); - const providersWithData = providersWithUsage(mergedProviders); - const activeProviders = PROVIDER_ORDER.filter( - (provider) => providersWithData.includes(provider) || loading.providers.has(provider), - ); - const chartLoadingProviders = new Set( - activeProviders.filter((provider) => loading.everyProvider || loading.providers.has(provider)), - ); - return { loading, providersWithData, activeProviders, chartLoadingProviders }; -} - -// UsageProviderChart memoizes on these arrays, so they must keep their identity. -function useUsagePeriods(usageWindow: ReturnType) { - "use memo"; - const days = enumerateDays(usageWindow.sinceDay, usageWindow.untilDay); - const hours = - usageWindow.sinceTime === undefined || usageWindow.untilTime === undefined - ? [] - : enumerateHourStarts(usageWindow.sinceTime, usageWindow.untilTime); - return { days, hours }; -} - export function UsagePage() { const [preferences, setPreferences] = useState(readUsagePagePreferences); useEscapeToGoBack(); @@ -194,12 +158,11 @@ export function UsagePage() { const [isRefreshing, setIsRefreshing] = useState(false); const [limitsNow, setLimitsNow] = useState(() => Date.now()); const refreshingRef = useRef(false); - const [breakdown, setBreakdown] = useState<"model" | "time">("model"); const [priceDialog, setPriceDialog] = useState<{ readonly model?: string } | null>(null); const [selectedModelKey, setSelectedModelKey] = useState(null); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); - const hiddenProviders = useHiddenProviderSet(preferences.hiddenProviders); + const hiddenProviders = new Set(preferences.hiddenProviders); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const { @@ -215,36 +178,10 @@ export function UsagePage() { const shownHourly = shownWindow.resolution === "hour"; const refreshingUsage = isRefreshing && !showingLimits; const showingKept = shown !== null && shown.window !== window; - const { loading, providersWithData, activeProviders, chartLoadingProviders } = useUsageProviders( - showingKept, - selectedEnvironments, - refreshingUsage, - hiddenProviders, - merged.providers, - ); - const isProviderLoading = (provider: UsageProviderKind) => - loading.everyProvider || loading.providers.has(provider); const presentations = useAtomValue(environmentPresentations.presentationsAtom); const cursorAccessEnvironments = hiddenProviders.has("cursor") ? [] : cursorKeychainAccessEnvironments(selectedEnvironments); - const sourceMessages = [ - ...new Set( - selectedEnvironments.flatMap( - (environment) => - environment.summary?.sources.flatMap((source) => - source.message && - !source.action && - !hiddenProviders.has(source.fingerprint.provider) && - (source.status === "partial" || - source.status === "failed" || - source.fingerprint.provider === "cursor") - ? [source.message] - : [], - ) ?? [], - ), - ), - ]; const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); @@ -253,35 +190,6 @@ export function UsagePage() { (environment) => environment.canReadDiagnostics, ); - const { days, hours } = useUsagePeriods(shownWindow); - const breakdownPeriods: readonly (DailyTotals | HourlyTotals)[] = ( - shownHourly ? merged.hourly : merged.daily - ).toReversed(); - const breakdownModels = - breakdown === "model" && metric === "tokens" - ? sortModelsByTokens(merged.models) - : merged.models; - const selectedModel = - selectedModelKey === null - ? undefined - : merged.models.find((model) => `${model.provider}:${model.model}` === selectedModelKey); - const breakdownPeak = breakdownModels.reduce( - (peak, model) => Math.max(peak, metric === "tokens" ? model.totalTokens : model.costUsd), - 0, - ); - const summaryRows: Array< - | { readonly kind: "usage"; readonly provider: UsageProviderKind } - | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus } - > = activeProviders.map((provider) => ({ kind: "usage", provider })); - const cursorInsertAt = - Math.max(activeProviders.indexOf("codex"), activeProviders.indexOf("claude")) + 1; - summaryRows.splice( - cursorInsertAt, - 0, - ...cursorAccessEnvironments.map((environment) => ({ kind: "enable" as const, environment })), - ); - const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; - const updatePreferences = (patch: Partial) => { const nextPreferences = { ...preferences, ...patch }; setPreferences(nextPreferences); @@ -390,6 +298,24 @@ export function UsagePage() { if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits(); }, [showingLimits, connectedLimitsEnvironments]); + const days = enumerateDays(shownWindow.sinceDay, shownWindow.untilDay); + const hours = + shownWindow.sinceTime === undefined || shownWindow.untilTime === undefined + ? [] + : enumerateHourStarts(shownWindow.sinceTime, shownWindow.untilTime); + const selectedModel = + selectedModelKey === null + ? undefined + : merged.models.find((model) => `${model.provider}:${model.model}` === selectedModelKey); + + const showChatGptSharing = + !hiddenProviders.has("codex") && + [...presentations].some( + ([id, presentation]) => + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(id)) && + presentation.serverConfig?.providers.some(usesChatGptSharing), + ); + const windowLabel = shownHourly && shownWindow.sinceTime !== undefined && shownWindow.untilTime !== undefined ? `${formatDateTimeShort(shownWindow.sinceTime, shownWindow.timeZone)} to ${formatDateTimeShort(shownWindow.untilTime, shownWindow.timeZone)}` @@ -579,408 +505,24 @@ export function UsagePage() { ))} ) : ( -
- {sourceMessages.map((message) => ( -

- {message} -

- ))} -
-
-
- - {metric === "cost" - ? formatUsd(merged.costUsd) - : formatTokens(merged.totalTokens)} - - - - - {formatCount(merged.sessions)} sessions - - {metric === "cost" && " · API estimate"} - - {metric === "cost" && merged.costQuality.unpricedShare > 0 && ( - - } - aria-label="Unpriced usage details" - > - - - - API estimate excludes{" "} - {formatPercent(merged.costQuality.unpricedShare)} unpriced records. - - - )} - -
- - {!hiddenProviders.has("codex") && - [...presentations].some( - ([id, presentation]) => - (selectedEnvironmentIds === null || selectedEnvironmentIds.has(id)) && - presentation.serverConfig?.providers.some(usesChatGptSharing), - ) ? ( -
- ChatGPT shared usage - -
- ) : null} - - {summaryRows.map((row) => { - if (row.kind === "enable") { - return ( - 1} - onEnabled={() => { - void refresh(); - void refreshLimits(false, true); - }} - /> - ); - } - const provider = row.provider; - const totals = merged.providers.find((entry) => entry.provider === provider); - const share = - metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); - const providerSessions = totals?.sessions ?? 0; - const sessionLabel = `${formatCount(providerSessions)} ${ - providerSessions === 1 ? "session" : "sessions" - }`; - const providerLoading = isProviderLoading(provider); - const awaitingData = providerLoading && !providersWithData.includes(provider); - return ( -
-
- - - - - - {PROVIDER_PRESENTATION[provider].label} - - - {sessionLabel} - - - - - {awaitingData - ? "—" - : metric === "cost" - ? formatUsd(totals?.costUsd ?? 0) - : formatTokens(totals?.totalTokens ?? 0)} - -
- - {metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`} - -
- ); - })} -
- -
-

- {shownHourly ? "Hourly" : "Daily"}{" "} - {metric === "tokens" ? "processed tokens" : "cost"} -

- -
-
- -
-

Totals

-
- - - - - -
-
- - {merged.totalTokens > 0 ? ( -
- {metric === "tokens" ? ( - - ) : ( - <> - - {merged.speedCost.fast + merged.speedCost.ultrafast > 0 ? ( - } - /> - ) : null} - - )} -
- ) : null} - -
-
-

Breakdown

- { - const value = next[0]; - if (value === "model" || value === "time") setBreakdown(value); - }} - > - {( - [ - { value: "model", label: "Model" }, - { value: "time", label: shownHourly ? "Hour" : "Day" }, - ] as const - ).map((option) => ( - - {option.label} - - ))} - -
- - {breakdown === "model" ? ( - - - - - - - - - - - - {breakdownModels.length === 0 ? ( - - - - ) : ( - breakdownModels.map((model, index) => { - const key = `${model.provider}:${model.model}`; - const value = metric === "tokens" ? model.totalTokens : model.costUsd; - const share = modelShare( - model, - metric === "tokens" ? "tokens" : "cost", - ); - const rowFigures = figureClass(isProviderLoading(model.provider)); - return ( - - - - - - - - ); - }) - )} - -
#ModelCostShareTokens
- No activity in this window. -
{index + 1} - -
-
0 && breakdownPeak > 0 - ? `max(0.5rem, ${(value / breakdownPeak) * 100}%)` - : 0, - backgroundColor: - PROVIDER_PRESENTATION[model.provider].color, - }} - /> -
-
- {isModelCostUnknown(model) ? ( - Unpriced - ) : ( - formatUsd(model.costUsd) - )} - - {share === null ? "" : formatPercent(share)} - - {formatTokens(model.totalTokens)} -
- ) : ( - - - - {activeProviders.map((provider) => ( - - ))} - - - - - - - {activeProviders.map((provider) => ( - - ))} - - - - - - {breakdownPeriods.length === 0 ? ( - - - - ) : ( - breakdownPeriods.map((period) => ( - - - {activeProviders.map((provider) => ( - - ))} - - - - )) - )} - -
{shownHourly ? "Hour" : "Day"} - {PROVIDER_PRESENTATION[provider].label} - TotalTokens
- No activity in this window. -
- {"hourStart" in period - ? formatHourShort(period.hourStart, shownWindow.timeZone) - : formatDayShort(period.day)} - - {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)} - - {formatUsd(period.costUsd)} - - {formatTokens(period.totalTokens)} -
- )} -
-
+ { + void refresh(); + void refreshLimits(false, true); + }} + onSelectModel={setSelectedModelKey} + /> )} @@ -1018,6 +560,480 @@ export function UsagePage() { ); } +function UsageOverview({ + merged, + shownWindow, + days, + hours, + showingKept, + metric, + selectedEnvironments, + hiddenProviders, + refreshingUsage, + cursorAccessEnvironments, + showChatGptSharing, + onCursorEnabled, + onSelectModel, +}: { + readonly merged: MergedUsage; + readonly shownWindow: UsageSummaryInput; + readonly days: readonly string[]; + readonly hours: readonly string[]; + readonly showingKept: boolean; + readonly metric: "tokens" | "cost"; + readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; + readonly hiddenProviders: ReadonlySet; + readonly refreshingUsage: boolean; + readonly cursorAccessEnvironments: readonly EnvironmentUsageStatus[]; + readonly showChatGptSharing: boolean; + readonly onCursorEnabled: () => void; + readonly onSelectModel: (key: string) => void; +}) { + const [breakdown, setBreakdown] = useState<"model" | "time">("model"); + const shownHourly = shownWindow.resolution === "hour"; + const loading = usageLoading(showingKept, selectedEnvironments, refreshingUsage, hiddenProviders); + // A primitive copy: reading `loading.partial` in the JSX would keep `loading` alive + // across every derivation below and fold them into one cache scope. + const loadingPartial = loading.partial === true; + const loadingProviders = new Set( + PROVIDER_ORDER.filter((provider) => loading.everyProvider || loading.providers.has(provider)), + ); + const providersWithData = new Set(providersWithUsage(merged.providers)); + // `everyProvider` marks shown providers as loading; only `loading.providers`, which excludes + // hidden ones, may add a provider that has no data yet. + // Filtering a fresh copy, not the shared module array, lets the compiler cache this on its own. + const activeProviders = [...PROVIDER_ORDER].filter( + (provider) => providersWithData.has(provider) || loading.providers.has(provider), + ); + const chartLoadingProviders = new Set( + activeProviders.filter((provider) => loadingProviders.has(provider)), + ); + const sourceMessages = [ + ...new Set( + selectedEnvironments.flatMap( + (environment) => + environment.summary?.sources.flatMap((source) => + source.message && + !source.action && + !hiddenProviders.has(source.fingerprint.provider) && + (source.status === "partial" || + source.status === "failed" || + source.fingerprint.provider === "cursor") + ? [source.message] + : [], + ) ?? [], + ), + ), + ]; + const breakdownPeriods: readonly (DailyTotals | HourlyTotals)[] = ( + shownHourly ? merged.hourly : merged.daily + ).toReversed(); + const breakdownModels = + breakdown === "model" && metric === "tokens" + ? sortModelsByTokens(merged.models) + : merged.models; + const breakdownPeak = breakdownModels.reduce( + (peak, model) => Math.max(peak, metric === "tokens" ? model.totalTokens : model.costUsd), + 0, + ); + const cursorInsertAt = + Math.max(activeProviders.indexOf("codex"), activeProviders.indexOf("claude")) + 1; + const summaryRows: ReadonlyArray< + | { readonly kind: "usage"; readonly provider: UsageProviderKind } + | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus } + > = [ + ...activeProviders + .slice(0, cursorInsertAt) + .map((provider) => ({ kind: "usage" as const, provider })), + ...cursorAccessEnvironments.map((environment) => ({ kind: "enable" as const, environment })), + ...activeProviders + .slice(cursorInsertAt) + .map((provider) => ({ kind: "usage" as const, provider })), + ]; + const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; + + return ( +
+ {sourceMessages.map((message) => ( +

+ {message} +

+ ))} +
+
+
+ + {metric === "cost" ? formatUsd(merged.costUsd) : formatTokens(merged.totalTokens)} + + + + + {formatCount(merged.sessions)} sessions + + {metric === "cost" && " · API estimate"} + + {metric === "cost" && merged.costQuality.unpricedShare > 0 && ( + + } + aria-label="Unpriced usage details" + > + + + + API estimate excludes {formatPercent(merged.costQuality.unpricedShare)} unpriced + records. + + + )} + +
+ + {showChatGptSharing ? ( +
+ ChatGPT shared usage + +
+ ) : null} + + {summaryRows.map((row) => { + if (row.kind === "enable") { + return ( + 1} + onEnabled={onCursorEnabled} + /> + ); + } + const provider = row.provider; + const totals = merged.providers.find((entry) => entry.provider === provider); + const share = metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); + const providerSessions = totals?.sessions ?? 0; + const sessionLabel = `${formatCount(providerSessions)} ${ + providerSessions === 1 ? "session" : "sessions" + }`; + const providerLoading = loadingProviders.has(provider); + const awaitingData = providerLoading && !providersWithData.has(provider); + return ( +
+
+ + + + + {PROVIDER_PRESENTATION[provider].label} + + {sessionLabel} + + + + + {awaitingData + ? "—" + : metric === "cost" + ? formatUsd(totals?.costUsd ?? 0) + : formatTokens(totals?.totalTokens ?? 0)} + +
+ + {metric === "cost" + ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`} + +
+ ); + })} +
+ +
+

+ {shownHourly ? "Hourly" : "Daily"} {metric === "tokens" ? "processed tokens" : "cost"} +

+ +
+
+ +
+

Totals

+
+ + + + + +
+
+ + {merged.totalTokens > 0 ? ( +
+ {metric === "tokens" ? ( + + ) : ( + <> + + {merged.speedCost.fast + merged.speedCost.ultrafast > 0 ? ( + } + /> + ) : null} + + )} +
+ ) : null} + +
+
+

Breakdown

+ { + const value = next[0]; + if (value === "model" || value === "time") setBreakdown(value); + }} + > + {( + [ + { value: "model", label: "Model" }, + { value: "time", label: shownHourly ? "Hour" : "Day" }, + ] as const + ).map((option) => ( + + {option.label} + + ))} + +
+ + {breakdown === "model" ? ( + + + + + + + + + + + + {breakdownModels.length === 0 ? ( + + + + ) : ( + breakdownModels.map((model, index) => { + const key = `${model.provider}:${model.model}`; + const value = metric === "tokens" ? model.totalTokens : model.costUsd; + const share = modelShare(model, metric === "tokens" ? "tokens" : "cost"); + const rowFigures = figureClass(loadingProviders.has(model.provider)); + return ( + + + + + + + + ); + }) + )} + +
#ModelCostShareTokens
+ No activity in this window. +
{index + 1} + +
+
0 && breakdownPeak > 0 + ? `max(0.5rem, ${(value / breakdownPeak) * 100}%)` + : 0, + backgroundColor: PROVIDER_PRESENTATION[model.provider].color, + }} + /> +
+
+ {isModelCostUnknown(model) ? ( + Unpriced + ) : ( + formatUsd(model.costUsd) + )} + + {share === null ? "" : formatPercent(share)} + + {formatTokens(model.totalTokens)} +
+ ) : ( + + + + {activeProviders.map((provider) => ( + + ))} + + + + + + + {activeProviders.map((provider) => ( + + ))} + + + + + + {breakdownPeriods.length === 0 ? ( + + + + ) : ( + breakdownPeriods.map((period) => ( + + + {activeProviders.map((provider) => ( + + ))} + + + + )) + )} + +
{shownHourly ? "Hour" : "Day"} + {PROVIDER_PRESENTATION[provider].label} + TotalTokens
+ No activity in this window. +
+ {"hourStart" in period + ? formatHourShort(period.hourStart, shownWindow.timeZone) + : formatDayShort(period.day)} + + {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)} + + {formatUsd(period.costUsd)} + + {formatTokens(period.totalTokens)} +
+ )} +
+
+ ); +} + const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; function CursorEnableButton({