| author | |
| committer | |
| log | 24aef0e6c1bd5373a82eeed8054b50fc6e041b76 |
| tree | 7a0d29dcfcb32e9f180fe6b81983eb069baee074 |
| parent | b7d6e65021b021207424c56cb32d6d711fd875fb |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>3 files changed, 274 insertions(+), 137 deletions(-)
apps/mobile/src/features/usage/UsageLimitsSection.tsx+138-42| ... | ... | @@ -7,6 +7,7 @@ import type { |
| 7 | 7 | ServerProviderResetCredits, |
| 8 | 8 | ServerProviderUsageWindow, |
| 9 | 9 | UsageLimitSourceAccount, |
| 10 | UsageProviderKind, | |
| 10 | 11 | } from "@t3tools/contracts"; |
| 11 | 12 | import { |
| 12 | 13 | collectLimitSources, |
| ... | ... | @@ -22,30 +23,46 @@ import { type ReactNode, useState } from "react"; |
| 22 | 23 | import { Alert, Pressable, View } from "react-native"; |
| 23 | 24 | |
| 24 | 25 | import { AppText as Text } from "../../components/AppText"; |
| 26 | import { ProviderIcon } from "../../components/ProviderIcon"; | |
| 25 | 27 | import { environmentPresentations } from "../../state/presentation"; |
| 26 | 28 | import { serverEnvironment } from "../../state/server"; |
| 27 | 29 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 28 | 30 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 31 | import { useProviderColors } from "./usageProviders"; | |
| 29 | 32 | |
| 30 | 33 | const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; |
| 31 | 34 | const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" }; |
| 32 | 35 | |
| 36 | type Driver = ServerProvider["driver"]; | |
| 37 | ||
| 38 | /** The series colour the usage chart uses for this driver, so the two views read as one. */ | |
| 39 | function useBarColor(driver: Driver): string | null { | |
| 40 | const colors = useProviderColors(); | |
| 41 | const kind: UsageProviderKind | null = | |
| 42 | driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : null; | |
| 43 | return kind ? colors[kind] : null; | |
| 44 | } | |
| 45 | ||
| 33 | 46 | /** |
| 34 | 47 | * One window as a bar spanning its whole duration: the fill is quota spent, |
| 35 | * the hairline is how far into the window the clock is. | |
| 48 | * the hairline is how far into the window the clock is. Pace sits under the | |
| 49 | * left edge, the countdown under the right, so a row reads in one glance. | |
| 36 | 50 | */ |
| 37 | function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly now: number }) { | |
| 51 | function WindowRow(props: { | |
| 52 | readonly window: ServerProviderUsageWindow; | |
| 53 | readonly color: string | null; | |
| 54 | readonly now: number; | |
| 55 | }) { | |
| 38 | 56 | const { window, now } = props; |
| 39 | 57 | const used = Math.round(Math.max(0, Math.min(100, window.usedPercent))); |
| 40 | 58 | const elapsed = elapsedShare(window, now); |
| 41 | 59 | const pace = paceOf(window, now); |
| 42 | 60 | const resetsIn = formatResetsIn(window, now); |
| 43 | const detail = [pace ? PACE_LABEL[pace] : null, resetsIn].filter(Boolean).join(" · "); | |
| 44 | 61 | return ( |
| 45 | <View className="gap-1.5"> | |
| 62 | <View className="gap-1"> | |
| 46 | 63 | <View className="flex-row items-baseline justify-between gap-3"> |
| 47 | <Text className="text-base text-foreground">{window.label}</Text> | |
| 48 | <Text className="text-base tabular-nums text-foreground">{used}% used</Text> | |
| 64 | <Text className="text-sm text-foreground">{window.label}</Text> | |
| 65 | <Text className="text-sm font-t3-medium tabular-nums text-foreground">{used}%</Text> | |
| 49 | 66 | </View> |
| 50 | 67 | <View className="h-3 justify-center"> |
| 51 | 68 | <View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle"> |
| ... | ... | @@ -57,7 +74,10 @@ function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly |
| 57 | 74 | ? "h-full rounded-full bg-warning" |
| 58 | 75 | : "h-full rounded-full bg-foreground" |
| 59 | 76 | } |
| 60 | style={{ flex: used }} | |
| 77 | style={[ | |
| 78 | { flex: used }, | |
| 79 | used < 70 && props.color ? { backgroundColor: props.color } : null, | |
| 80 | ]} | |
| 61 | 81 | /> |
| 62 | 82 | <View style={{ flex: 100 - used }} /> |
| 63 | 83 | </View> |
| ... | ... | @@ -68,12 +88,19 @@ function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly |
| 68 | 88 | /> |
| 69 | 89 | ) : null} |
| 70 | 90 | </View> |
| 71 | {detail ? <Text className="text-xs text-foreground-tertiary">{detail}</Text> : null} | |
| 91 | {pace || resetsIn ? ( | |
| 92 | <View className="flex-row justify-between gap-3"> | |
| 93 | <Text className="text-xs text-foreground-tertiary">{pace ? PACE_LABEL[pace] : ""}</Text> | |
| 94 | <Text className="text-xs tabular-nums text-foreground-tertiary">{resetsIn ?? ""}</Text> | |
| 95 | </View> | |
| 96 | ) : null} | |
| 72 | 97 | </View> |
| 73 | 98 | ); |
| 74 | 99 | } |
| 75 | 100 | |
| 101 | /** One account: icon, name and plan on a single line, then its windows. */ | |
| 76 | 102 | function AccountLimits(props: { |
| 103 | readonly driver: Driver; | |
| 77 | 104 | readonly label: string; |
| 78 | 105 | readonly instanceLabel: string; |
| 79 | 106 | readonly detail: string | undefined; |
| ... | ... | @@ -83,23 +110,35 @@ function AccountLimits(props: { |
| 83 | 110 | readonly footer?: ReactNode; |
| 84 | 111 | }) { |
| 85 | 112 | const { limits, now } = props; |
| 113 | const color = useBarColor(props.driver); | |
| 86 | 114 | if (!limits) return null; |
| 87 | 115 | const notice = limitsNotice(limits); |
| 88 | 116 | return ( |
| 89 | 117 | <View className={props.first ? "gap-3 p-4" : "gap-3 border-t border-border-subtle p-4"}> |
| 90 | <View className="flex-row flex-wrap items-baseline gap-x-2 gap-y-1"> | |
| 91 | <Text className="text-lg text-foreground">{props.label}</Text> | |
| 92 | {props.instanceLabel !== props.label ? ( | |
| 93 | <Text className="shrink text-xs text-foreground-tertiary">· {props.instanceLabel}</Text> | |
| 94 | ) : null} | |
| 95 | {props.detail ? ( | |
| 96 | <Text className="shrink text-sm text-foreground-muted">· {props.detail}</Text> | |
| 97 | ) : null} | |
| 118 | <View className="flex-row items-center gap-2"> | |
| 119 | <ProviderIcon provider={props.driver} size={16} /> | |
| 120 | <View className="min-w-0 flex-1 flex-row items-baseline gap-2"> | |
| 121 | <Text className="text-base font-t3-medium text-foreground">{props.label}</Text> | |
| 122 | {props.instanceLabel !== props.label ? ( | |
| 123 | <Text className="shrink text-xs text-foreground-tertiary" numberOfLines={1}> | |
| 124 | · {props.instanceLabel} | |
| 125 | </Text> | |
| 126 | ) : null} | |
| 127 | {props.detail ? ( | |
| 128 | <Text className="shrink text-sm text-foreground-muted" numberOfLines={1}> | |
| 129 | · {props.detail} | |
| 130 | </Text> | |
| 131 | ) : null} | |
| 132 | </View> | |
| 98 | 133 | </View> |
| 99 | 134 | {notice ? ( |
| 100 | 135 | <Text className="text-sm text-foreground-muted">{notice}</Text> |
| 101 | 136 | ) : ( |
| 102 | limits.windows.map((window) => <WindowBar key={window.id} window={window} now={now} />) | |
| 137 | <View className="gap-3"> | |
| 138 | {limits.windows.map((window) => ( | |
| 139 | <WindowRow key={window.id} window={window} color={color} now={now} /> | |
| 140 | ))} | |
| 141 | </View> | |
| 103 | 142 | )} |
| 104 | 143 | {props.footer} |
| 105 | 144 | </View> |
| ... | ... | @@ -170,7 +209,7 @@ function ResetCredits(props: { |
| 170 | 209 | }; |
| 171 | 210 | |
| 172 | 211 | return ( |
| 173 | <View className="gap-2"> | |
| 212 | <View className="flex-row flex-wrap items-center gap-x-3 gap-y-1"> | |
| 174 | 213 | <Text className="text-xs tabular-nums text-foreground-tertiary">{summary}</Text> |
| 175 | 214 | {credits.availableCount > 0 ? ( |
| 176 | 215 | <Pressable |
| ... | ... | @@ -178,7 +217,7 @@ function ResetCredits(props: { |
| 178 | 217 | accessibilityState={{ disabled: busy }} |
| 179 | 218 | disabled={busy} |
| 180 | 219 | onPress={confirm} |
| 181 | className="self-start rounded-full bg-subtle-strong px-3 py-1.5" | |
| 220 | className="rounded-full bg-subtle-strong px-3 py-1.5" | |
| 182 | 221 | > |
| 183 | 222 | <Text className="text-sm font-t3-medium text-foreground"> |
| 184 | 223 | {busy ? "Using credit…" : "Use a reset credit"} |
| ... | ... | @@ -200,6 +239,7 @@ function ProviderLimits(props: { |
| 200 | 239 | const credits = provider.usageLimits?.resetCredits; |
| 201 | 240 | return ( |
| 202 | 241 | <AccountLimits |
| 242 | driver={provider.driver} | |
| 203 | 243 | label={DRIVER_LABEL[provider.driver] ?? String(provider.driver)} |
| 204 | 244 | instanceLabel={providerLimitsLabel(provider, (driver) => DRIVER_LABEL[driver])} |
| 205 | 245 | detail={provider.auth.label} |
| ... | ... | @@ -229,6 +269,7 @@ function SourceAccountLimits(props: { |
| 229 | 269 | const { account } = props; |
| 230 | 270 | return ( |
| 231 | 271 | <AccountLimits |
| 272 | driver={account.driver} | |
| 232 | 273 | label={DRIVER_LABEL[account.driver] ?? String(account.driver)} |
| 233 | 274 | instanceLabel="CLI Proxy" |
| 234 | 275 | detail={account.plan} |
| ... | ... | @@ -239,21 +280,93 @@ function SourceAccountLimits(props: { |
| 239 | 280 | ); |
| 240 | 281 | } |
| 241 | 282 | |
| 283 | /** | |
| 284 | * Re-probes every provider (and usage-limit source) on each connected | |
| 285 | * environment; the fresh snapshots then arrive over the config stream. | |
| 286 | * Countdowns and pace anchor to `now` rather than ticking, so a refresh also | |
| 287 | * re-anchors the clock: quota and elapsed time move together, or not at all. | |
| 288 | * Environments whose probe failed are named, since their rows keep showing | |
| 289 | * the previous quota with nothing else to say so. | |
| 290 | */ | |
| 291 | export function useRefreshLimits() { | |
| 292 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); | |
| 293 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { | |
| 294 | reportFailure: false, | |
| 295 | }); | |
| 296 | const [now, setNow] = useState(() => Date.now()); | |
| 297 | const [refreshing, setRefreshing] = useState(false); | |
| 298 | const [failedLabels, setFailedLabels] = useState<readonly string[]>([]); | |
| 299 | // Always toggles `refreshing`, even with nothing to probe: Android's | |
| 300 | // RefreshControl keeps its spinner up until it sees true then false. | |
| 301 | const refresh = async () => { | |
| 302 | const connected = [...presentations].filter( | |
| 303 | ([, presentation]) => presentation.connection.phase === "connected", | |
| 304 | ); | |
| 305 | setRefreshing(true); | |
| 306 | try { | |
| 307 | const results = await Promise.all( | |
| 308 | connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })), | |
| 309 | ); | |
| 310 | setFailedLabels( | |
| 311 | connected | |
| 312 | .filter((_, index) => results[index]?._tag === "Failure") | |
| 313 | .map(([, presentation]) => presentation.entry.target.label), | |
| 314 | ); | |
| 315 | } finally { | |
| 316 | setNow(Date.now()); | |
| 317 | setRefreshing(false); | |
| 318 | } | |
| 319 | }; | |
| 320 | return { now, refreshing, failedLabels, refresh }; | |
| 321 | } | |
| 322 | ||
| 242 | 323 | /** |
| 243 | 324 | * Subscription quota windows from every connected environment's providers, |
| 244 | * read from the config each environment already streams. Countdowns anchor to | |
| 245 | * render time rather than ticking. | |
| 325 | * read from the config each environment already streams. | |
| 246 | 326 | */ |
| 247 | export function UsageLimitsSection() { | |
| 327 | export function UsageLimitsSection(props: { | |
| 328 | readonly now: number; | |
| 329 | readonly failedLabels: readonly string[]; | |
| 330 | }) { | |
| 331 | const { now } = props; | |
| 248 | 332 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 249 | 333 | const groups = collectLimitsGroups(presentations); |
| 250 | 334 | const sources = collectLimitSources(presentations); |
| 251 | // Anchored once per mount on purpose: countdowns must not tick. | |
| 252 | const [now] = useState(() => Date.now()); | |
| 253 | if (groups.length === 0 && sources.length === 0) return null; | |
| 335 | ||
| 336 | if (groups.length === 0 && sources.length === 0) { | |
| 337 | return ( | |
| 338 | <Text className="py-16 text-center text-base text-foreground-muted"> | |
| 339 | No provider on a connected environment reports subscription limits. | |
| 340 | </Text> | |
| 341 | ); | |
| 342 | } | |
| 254 | 343 | |
| 255 | 344 | return ( |
| 256 | 345 | <> |
| 346 | {props.failedLabels.length > 0 ? ( | |
| 347 | <View className="rounded-[16px] border-continuous bg-card px-4 py-3"> | |
| 348 | <Text className="text-sm text-foreground-muted"> | |
| 349 | {props.failedLabels.join(", ")} could not refresh limits. Showing the last known values. | |
| 350 | </Text> | |
| 351 | </View> | |
| 352 | ) : null} | |
| 353 | {groups.map((group) => ( | |
| 354 | <SettingsSection | |
| 355 | key={group.environmentId} | |
| 356 | title={group.environmentLabel ?? "Providers"} | |
| 357 | card | |
| 358 | > | |
| 359 | {group.providers.map((provider, index) => ( | |
| 360 | <ProviderLimits | |
| 361 | key={provider.instanceId} | |
| 362 | provider={provider} | |
| 363 | environmentId={group.environmentId} | |
| 364 | now={now} | |
| 365 | first={index === 0} | |
| 366 | /> | |
| 367 | ))} | |
| 368 | </SettingsSection> | |
| 369 | ))} | |
| 257 | 370 | {sources.map((source) => ( |
| 258 | 371 | <SettingsSection key={source.key} card> |
| 259 | 372 | {source.error ? ( |
| ... | ... | @@ -276,23 +389,6 @@ export function UsageLimitsSection() { |
| 276 | 389 | )} |
| 277 | 390 | </SettingsSection> |
| 278 | 391 | ))} |
| 279 | {groups.map((group) => ( | |
| 280 | <SettingsSection | |
| 281 | key={group.environmentId} | |
| 282 | title={group.environmentLabel ? `Limits · ${group.environmentLabel}` : "Limits"} | |
| 283 | card | |
| 284 | > | |
| 285 | {group.providers.map((provider, index) => ( | |
| 286 | <ProviderLimits | |
| 287 | key={provider.instanceId} | |
| 288 | provider={provider} | |
| 289 | environmentId={group.environmentId} | |
| 290 | now={now} | |
| 291 | first={index === 0} | |
| 292 | /> | |
| 293 | ))} | |
| 294 | </SettingsSection> | |
| 295 | ))} | |
| 296 | 392 | </> |
| 297 | 393 | ); |
| 298 | 394 | } |
apps/mobile/src/features/usage/UsageRouteScreen.tsx+134-95| ... | ... | @@ -11,32 +11,52 @@ import { |
| 11 | 11 | formatUsd, |
| 12 | 12 | makeWindow, |
| 13 | 13 | } from "@t3tools/shared/usageFormat"; |
| 14 | import { useMemo, useState } from "react"; | |
| 14 | import { useMemo, useRef, useState } from "react"; | |
| 15 | 15 | import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; |
| 16 | 16 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 17 | 17 | |
| 18 | 18 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 19 | 19 | import { AppText as Text } from "../../components/AppText"; |
| 20 | import { cn } from "../../lib/cn"; | |
| 20 | 21 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 21 | 22 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 22 | 23 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 23 | 24 | import { UsageDailyChart } from "./UsageDailyChart"; |
| 24 | import { UsageLimitsSection } from "./UsageLimitsSection"; | |
| 25 | import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection"; | |
| 25 | 26 | import type { UsageChartMetric } from "./usageChartData"; |
| 26 | 27 | import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; |
| 27 | 28 | |
| 29 | type UsageTab = "usage" | "limits"; | |
| 30 | const TAB_OPTIONS = [ | |
| 31 | { value: "usage", label: "Usage" }, | |
| 32 | { value: "limits", label: "Limits" }, | |
| 33 | ] as const satisfies readonly { value: UsageTab; label: string }[]; | |
| 34 | ||
| 35 | // Labels are abbreviated to share a row with the metric toggle; screen | |
| 36 | // readers get the full phrase. | |
| 28 | 37 | const WINDOW_OPTIONS = [ |
| 29 | { days: 1, label: "Past 24h" }, | |
| 30 | { days: 7, label: "7 days" }, | |
| 31 | { days: 30, label: "30 days" }, | |
| 32 | { days: 90, label: "90 days" }, | |
| 38 | { value: 1, label: "24h", accessibilityLabel: "Past 24 hours" }, | |
| 39 | { value: 7, label: "7d", accessibilityLabel: "Past 7 days" }, | |
| 40 | { value: 30, label: "30d", accessibilityLabel: "Past 30 days" }, | |
| 41 | { value: 90, label: "90d", accessibilityLabel: "Past 90 days" }, | |
| 33 | 42 | ] as const; |
| 34 | 43 | |
| 44 | const METRIC_OPTIONS = [ | |
| 45 | { value: "cost", label: "Cost" }, | |
| 46 | { value: "tokens", label: "Tokens" }, | |
| 47 | ] as const satisfies readonly { value: UsageChartMetric; label: string }[]; | |
| 48 | ||
| 35 | 49 | const CHART_HEIGHT = 180; |
| 36 | 50 | |
| 51 | /** | |
| 52 | * Two tabs over one screen. Usage is the transcript-derived spend for a | |
| 53 | * period; Limits is the live subscription quota, which has no period. Both | |
| 54 | * pull to refresh, each refreshing its own data. | |
| 55 | */ | |
| 37 | 56 | export function UsageRouteScreen() { |
| 38 | 57 | const navigation = useNavigation(); |
| 39 | 58 | const insets = useSafeAreaInsets(); |
| 59 | const [tab, setTab] = useState<UsageTab>("usage"); | |
| 40 | 60 | const [windowSelection, setWindowSelection] = useState(() => ({ |
| 41 | 61 | days: 30, |
| 42 | 62 | window: makeWindow(30), |
| ... | ... | @@ -45,6 +65,7 @@ export function UsageRouteScreen() { |
| 45 | 65 | const { days: windowDays, window } = windowSelection; |
| 46 | 66 | const isPast24Hours = windowDays === 1; |
| 47 | 67 | const { merged, environments, isPending, isPartial, refresh } = useUsage(window); |
| 68 | const limits = useRefreshLimits(); | |
| 48 | 69 | |
| 49 | 70 | const days = useMemo( |
| 50 | 71 | () => enumerateDays(window.sinceDay, window.untilDay), |
| ... | ... | @@ -73,7 +94,16 @@ export function UsageRouteScreen() { |
| 73 | 94 | // The pull spinner tracks re-scans of environments that have answered |
| 74 | 95 | // before. The initial scan renders its own placeholder, and an unreachable |
| 75 | 96 | // environment stays pending forever — neither may pin the spinner on. |
| 76 | const refreshing = environments.some((entry) => entry.isPending && entry.summary !== null); | |
| 97 | const refreshingUsage = environments.some((entry) => entry.isPending && entry.summary !== null); | |
| 98 | const showingLimits = tab === "limits"; | |
| 99 | // One ScrollView serves both tabs, so the offset would otherwise carry over | |
| 100 | // and a short Limits list could open scrolled past its own content. | |
| 101 | const scrollRef = useRef<ScrollView>(null); | |
| 102 | const selectTab = (next: UsageTab) => { | |
| 103 | if (next === tab) return; | |
| 104 | setTab(next); | |
| 105 | scrollRef.current?.scrollTo({ y: 0, animated: false }); | |
| 106 | }; | |
| 77 | 107 | const selectWindow = (days: number) => { |
| 78 | 108 | setWindowSelection({ |
| 79 | 109 | days, |
| ... | ... | @@ -103,46 +133,73 @@ export function UsageRouteScreen() { |
| 103 | 133 | </> |
| 104 | 134 | ) : null} |
| 105 | 135 | <ScrollView |
| 136 | ref={scrollRef} | |
| 106 | 137 | contentInsetAdjustmentBehavior="automatic" |
| 107 | 138 | showsVerticalScrollIndicator={false} |
| 108 | 139 | className="flex-1" |
| 109 | 140 | contentContainerClassName="gap-6 px-5 pt-4" |
| 110 | 141 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} |
| 111 | refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refreshWindow} />} | |
| 142 | refreshControl={ | |
| 143 | <RefreshControl | |
| 144 | refreshing={showingLimits ? limits.refreshing : refreshingUsage} | |
| 145 | onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow} | |
| 146 | /> | |
| 147 | } | |
| 112 | 148 | > |
| 113 | <SegmentedControl | |
| 114 | options={WINDOW_OPTIONS.map((option) => ({ value: option.days, label: option.label }))} | |
| 115 | selected={windowDays} | |
| 116 | onSelect={selectWindow} | |
| 117 | /> | |
| 118 | ||
| 119 | <UsageCoverageNotice environments={environments} merged={merged} isPartial={isPartial} /> | |
| 149 | <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={selectTab} role="tab" /> | |
| 120 | 150 | |
| 121 | {isPending ? ( | |
| 122 | <Text className="py-16 text-center text-base text-foreground-muted"> | |
| 123 | Scanning provider transcripts… | |
| 124 | </Text> | |
| 125 | ) : environments.length === 0 ? ( | |
| 126 | <Text className="py-16 text-center text-base text-foreground-muted"> | |
| 127 | Connect an environment to see usage. | |
| 128 | </Text> | |
| 151 | {showingLimits ? ( | |
| 152 | <UsageLimitsSection now={limits.now} failedLabels={limits.failedLabels} /> | |
| 129 | 153 | ) : ( |
| 130 | 154 | <> |
| 131 | <ChartCard | |
| 155 | {/* Period and metric together: neither applies to Limits, and | |
| 156 | both change every number below, so they share one bar. */} | |
| 157 | <View className="flex-row items-center gap-3"> | |
| 158 | <SegmentedControl | |
| 159 | options={WINDOW_OPTIONS} | |
| 160 | selected={windowDays} | |
| 161 | onSelect={selectWindow} | |
| 162 | size="compact" | |
| 163 | className="flex-1" | |
| 164 | /> | |
| 165 | <SegmentedControl | |
| 166 | options={METRIC_OPTIONS} | |
| 167 | selected={metric} | |
| 168 | onSelect={setMetric} | |
| 169 | size="compact" | |
| 170 | className="w-36" | |
| 171 | /> | |
| 172 | </View> | |
| 173 | <UsageCoverageNotice | |
| 174 | environments={environments} | |
| 132 | 175 | merged={merged} |
| 133 | days={chartDays} | |
| 134 | daily={chartTotals} | |
| 135 | metric={metric} | |
| 136 | onMetricChange={setMetric} | |
| 137 | sinceDay={window.sinceDay} | |
| 138 | untilDay={window.untilDay} | |
| 139 | isPast24Hours={isPast24Hours} | |
| 140 | timeZone={window.timeZone} | |
| 176 | isPartial={isPartial} | |
| 141 | 177 | /> |
| 142 | <ProviderSection merged={merged} metric={metric} /> | |
| 143 | <UsageLimitsSection /> | |
| 144 | <TotalsSection merged={merged} isPast24Hours={isPast24Hours} /> | |
| 145 | <ModelsSection merged={merged} /> | |
| 178 | {isPending ? ( | |
| 179 | <Text className="py-16 text-center text-base text-foreground-muted"> | |
| 180 | Scanning provider transcripts… | |
| 181 | </Text> | |
| 182 | ) : environments.length === 0 ? ( | |
| 183 | <Text className="py-16 text-center text-base text-foreground-muted"> | |
| 184 | Connect an environment to see usage. | |
| 185 | </Text> | |
| 186 | ) : ( | |
| 187 | <> | |
| 188 | <ChartCard | |
| 189 | merged={merged} | |
| 190 | days={chartDays} | |
| 191 | daily={chartTotals} | |
| 192 | metric={metric} | |
| 193 | sinceDay={window.sinceDay} | |
| 194 | untilDay={window.untilDay} | |
| 195 | isPast24Hours={isPast24Hours} | |
| 196 | timeZone={window.timeZone} | |
| 197 | /> | |
| 198 | <ProviderSection merged={merged} metric={metric} /> | |
| 199 | <TotalsSection merged={merged} isPast24Hours={isPast24Hours} /> | |
| 200 | <ModelsSection merged={merged} /> | |
| 201 | </> | |
| 202 | )} | |
| 146 | 203 | </> |
| 147 | 204 | )} |
| 148 | 205 | </ScrollView> |
| ... | ... | @@ -151,30 +208,48 @@ export function UsageRouteScreen() { |
| 151 | 208 | } |
| 152 | 209 | |
| 153 | 210 | function SegmentedControl<Value extends number | string>(props: { |
| 154 | readonly options: readonly { readonly value: Value; readonly label: string }[]; | |
| 211 | readonly options: readonly { | |
| 212 | readonly value: Value; | |
| 213 | readonly label: string; | |
| 214 | readonly accessibilityLabel?: string; | |
| 215 | }[]; | |
| 155 | 216 | readonly selected: Value; |
| 156 | 217 | readonly onSelect: (value: Value) => void; |
| 218 | /** The tab bar is full height; filters under it are shorter so it stays primary. */ | |
| 219 | readonly size?: "default" | "compact"; | |
| 220 | /** "tab" for the view switcher; filters stay plain buttons. */ | |
| 221 | readonly role?: "tab" | "button"; | |
| 222 | readonly className?: string; | |
| 157 | 223 | }) { |
| 224 | const compact = props.size === "compact"; | |
| 158 | 225 | return ( |
| 159 | <View className="flex-row overflow-hidden rounded-full border-continuous bg-card"> | |
| 226 | <View | |
| 227 | accessibilityRole={props.role === "tab" ? "tablist" : undefined} | |
| 228 | className={cn( | |
| 229 | "flex-row overflow-hidden rounded-full border-continuous bg-card", | |
| 230 | props.className, | |
| 231 | )} | |
| 232 | > | |
| 160 | 233 | {props.options.map((option) => { |
| 161 | 234 | const active = option.value === props.selected; |
| 162 | 235 | return ( |
| 163 | 236 | <Pressable |
| 164 | 237 | key={String(option.value)} |
| 165 | accessibilityRole="button" | |
| 238 | accessibilityRole={props.role ?? "button"} | |
| 239 | accessibilityLabel={option.accessibilityLabel} | |
| 166 | 240 | accessibilityState={{ selected: active }} |
| 167 | 241 | onPress={() => props.onSelect(option.value)} |
| 168 | className={ | |
| 169 | active | |
| 170 | ? "flex-1 items-center rounded-full bg-subtle-strong py-2" | |
| 171 | : "flex-1 items-center py-2" | |
| 172 | } | |
| 242 | className={cn( | |
| 243 | "flex-1 items-center justify-center rounded-full", | |
| 244 | compact ? "h-9" : "h-11", | |
| 245 | active && "bg-subtle-strong", | |
| 246 | )} | |
| 173 | 247 | > |
| 174 | 248 | <Text |
| 175 | className={ | |
| 176 | active ? "text-sm font-t3-medium text-foreground" : "text-sm text-foreground-muted" | |
| 177 | } | |
| 249 | className={cn( | |
| 250 | compact ? "text-xs" : "text-sm", | |
| 251 | active ? "font-t3-medium text-foreground" : "text-foreground-muted", | |
| 252 | )} | |
| 178 | 253 | > |
| 179 | 254 | {option.label} |
| 180 | 255 | </Text> |
| ... | ... | @@ -191,7 +266,6 @@ function ChartCard(props: { |
| 191 | 266 | readonly days: readonly string[]; |
| 192 | 267 | readonly daily: readonly DailyTotals[]; |
| 193 | 268 | readonly metric: UsageChartMetric; |
| 194 | readonly onMetricChange: (metric: UsageChartMetric) => void; | |
| 195 | 269 | readonly sinceDay: string; |
| 196 | 270 | readonly untilDay: string; |
| 197 | 271 | readonly isPast24Hours: boolean; |
| ... | ... | @@ -203,21 +277,18 @@ function ChartCard(props: { |
| 203 | 277 | |
| 204 | 278 | return ( |
| 205 | 279 | <View className="gap-4 rounded-[24px] border-continuous bg-card p-4"> |
| 206 | <View className="flex-row items-start justify-between gap-3"> | |
| 207 | <View className="min-w-0 flex-1 gap-0.5"> | |
| 208 | <Text className="text-sm text-foreground-muted"> | |
| 209 | {metric === "cost" ? "Raw token cost" : "Processed tokens"} | |
| 210 | </Text> | |
| 211 | <Text className="text-4xl font-t3-bold tabular-nums text-foreground"> | |
| 212 | {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} | |
| 213 | </Text> | |
| 214 | <Text className="text-sm text-foreground-muted"> | |
| 215 | {metric === "cost" | |
| 216 | ? "* if billed at full API rate" | |
| 217 | : `Across ${formatCount(merged.sessions)} sessions`} | |
| 218 | </Text> | |
| 219 | </View> | |
| 220 | <MetricToggle metric={metric} onChange={props.onMetricChange} /> | |
| 280 | <View className="gap-0.5"> | |
| 281 | <Text className="text-sm text-foreground-muted"> | |
| 282 | {metric === "cost" ? "Raw token cost" : "Processed tokens"} | |
| 283 | </Text> | |
| 284 | <Text className="text-4xl font-t3-bold tabular-nums text-foreground"> | |
| 285 | {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} | |
| 286 | </Text> | |
| 287 | <Text className="text-sm text-foreground-muted"> | |
| 288 | {metric === "cost" | |
| 289 | ? "* if billed at full API rate" | |
| 290 | : `Across ${formatCount(merged.sessions)} sessions`} | |
| 291 | </Text> | |
| 221 | 292 | </View> |
| 222 | 293 | |
| 223 | 294 | {hasActivity ? ( |
| ... | ... | @@ -262,38 +333,6 @@ function ChartCard(props: { |
| 262 | 333 | ); |
| 263 | 334 | } |
| 264 | 335 | |
| 265 | function MetricToggle(props: { | |
| 266 | readonly metric: UsageChartMetric; | |
| 267 | readonly onChange: (metric: UsageChartMetric) => void; | |
| 268 | }) { | |
| 269 | return ( | |
| 270 | <View className="flex-row overflow-hidden rounded-full bg-subtle"> | |
| 271 | {(["cost", "tokens"] as const).map((option) => { | |
| 272 | const active = option === props.metric; | |
| 273 | return ( | |
| 274 | <Pressable | |
| 275 | key={option} | |
| 276 | accessibilityRole="button" | |
| 277 | accessibilityState={{ selected: active }} | |
| 278 | onPress={() => props.onChange(option)} | |
| 279 | className={active ? "rounded-full bg-subtle-strong px-3 py-1.5" : "px-3 py-1.5"} | |
| 280 | > | |
| 281 | <Text | |
| 282 | className={ | |
| 283 | active | |
| 284 | ? "text-xs font-t3-medium uppercase text-foreground" | |
| 285 | : "text-xs uppercase text-foreground-muted" | |
| 286 | } | |
| 287 | > | |
| 288 | {option} | |
| 289 | </Text> | |
| 290 | </Pressable> | |
| 291 | ); | |
| 292 | })} | |
| 293 | </View> | |
| 294 | ); | |
| 295 | } | |
| 296 | ||
| 297 | 336 | function ProviderSection(props: { |
| 298 | 337 | readonly merged: MergedUsage; |
| 299 | 338 | readonly metric: UsageChartMetric; |
docs/user/usage.md+2| ... | ... | @@ -18,6 +18,8 @@ update model pricing. |
| 18 | 18 | compares quota consumed with time elapsed in each window, so you can judge your pace before the |
| 19 | 19 | next reset. |
| 20 | 20 | |
| 21 | If a window looks stale, refresh Limits to re-check every provider and hub. | |
| 22 | ||
| 21 | 23 | API-key accounts may not report subscription limits. This also applies to Claude connections |
| 22 | 24 | using a proxy through `ANTHROPIC_AUTH_TOKEN`. |
| 23 | 25 |