| 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,6 +7,7 @@ import type { |
| 7 | ServerProviderResetCredits, | 7 | ServerProviderResetCredits, |
| 8 | ServerProviderUsageWindow, | 8 | ServerProviderUsageWindow, |
| 9 | UsageLimitSourceAccount, | 9 | UsageLimitSourceAccount, |
| 10 | UsageProviderKind, | ||
| 10 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 11 | import { | 12 | import { |
| 12 | collectLimitSources, | 13 | collectLimitSources, |
| ... | @@ -22,30 +23,46 @@ import { type ReactNode, useState } from "react"; | ... | @@ -22,30 +23,46 @@ import { type ReactNode, useState } from "react"; |
| 22 | import { Alert, Pressable, View } from "react-native"; | 23 | import { Alert, Pressable, View } from "react-native"; |
| 23 | 24 | ||
| 24 | import { AppText as Text } from "../../components/AppText"; | 25 | import { AppText as Text } from "../../components/AppText"; |
| 26 | import { ProviderIcon } from "../../components/ProviderIcon"; | ||
| 25 | import { environmentPresentations } from "../../state/presentation"; | 27 | import { environmentPresentations } from "../../state/presentation"; |
| 26 | import { serverEnvironment } from "../../state/server"; | 28 | import { serverEnvironment } from "../../state/server"; |
| 27 | import { useAtomCommand } from "../../state/use-atom-command"; | 29 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 28 | import { SettingsSection } from "../settings/components/SettingsSection"; | 30 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 31 | import { useProviderColors } from "./usageProviders"; | ||
| 29 | 32 | ||
| 30 | const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; | 33 | const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; |
| 31 | const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" }; | 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 | * One window as a bar spanning its whole duration: the fill is quota spent, | 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 | const { window, now } = props; | 56 | const { window, now } = props; |
| 39 | const used = Math.round(Math.max(0, Math.min(100, window.usedPercent))); | 57 | const used = Math.round(Math.max(0, Math.min(100, window.usedPercent))); |
| 40 | const elapsed = elapsedShare(window, now); | 58 | const elapsed = elapsedShare(window, now); |
| 41 | const pace = paceOf(window, now); | 59 | const pace = paceOf(window, now); |
| 42 | const resetsIn = formatResetsIn(window, now); | 60 | const resetsIn = formatResetsIn(window, now); |
| 43 | const detail = [pace ? PACE_LABEL[pace] : null, resetsIn].filter(Boolean).join(" · "); | ||
| 44 | return ( | 61 | return ( |
| 45 | <View className="gap-1.5"> | 62 | <View className="gap-1"> |
| 46 | <View className="flex-row items-baseline justify-between gap-3"> | 63 | <View className="flex-row items-baseline justify-between gap-3"> |
| 47 | <Text className="text-base text-foreground">{window.label}</Text> | 64 | <Text className="text-sm text-foreground">{window.label}</Text> |
| 48 | <Text className="text-base tabular-nums text-foreground">{used}% used</Text> | 65 | <Text className="text-sm font-t3-medium tabular-nums text-foreground">{used}%</Text> |
| 49 | </View> | 66 | </View> |
| 50 | <View className="h-3 justify-center"> | 67 | <View className="h-3 justify-center"> |
| 51 | <View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle"> | 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,7 +74,10 @@ function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly |
| 57 | ? "h-full rounded-full bg-warning" | 74 | ? "h-full rounded-full bg-warning" |
| 58 | : "h-full rounded-full bg-foreground" | 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 | <View style={{ flex: 100 - used }} /> | 82 | <View style={{ flex: 100 - used }} /> |
| 63 | </View> | 83 | </View> |
| ... | @@ -68,12 +88,19 @@ function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly | ... | @@ -68,12 +88,19 @@ function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly |
| 68 | /> | 88 | /> |
| 69 | ) : null} | 89 | ) : null} |
| 70 | </View> | 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 | </View> | 97 | </View> |
| 73 | ); | 98 | ); |
| 74 | } | 99 | } |
| 75 | 100 | ||
| 101 | /** One account: icon, name and plan on a single line, then its windows. */ | ||
| 76 | function AccountLimits(props: { | 102 | function AccountLimits(props: { |
| 103 | readonly driver: Driver; | ||
| 77 | readonly label: string; | 104 | readonly label: string; |
| 78 | readonly instanceLabel: string; | 105 | readonly instanceLabel: string; |
| 79 | readonly detail: string | undefined; | 106 | readonly detail: string | undefined; |
| ... | @@ -83,23 +110,35 @@ function AccountLimits(props: { | ... | @@ -83,23 +110,35 @@ function AccountLimits(props: { |
| 83 | readonly footer?: ReactNode; | 110 | readonly footer?: ReactNode; |
| 84 | }) { | 111 | }) { |
| 85 | const { limits, now } = props; | 112 | const { limits, now } = props; |
| 113 | const color = useBarColor(props.driver); | ||
| 86 | if (!limits) return null; | 114 | if (!limits) return null; |
| 87 | const notice = limitsNotice(limits); | 115 | const notice = limitsNotice(limits); |
| 88 | return ( | 116 | return ( |
| 89 | <View className={props.first ? "gap-3 p-4" : "gap-3 border-t border-border-subtle p-4"}> | 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"> | 118 | <View className="flex-row items-center gap-2"> |
| 91 | <Text className="text-lg text-foreground">{props.label}</Text> | 119 | <ProviderIcon provider={props.driver} size={16} /> |
| 92 | {props.instanceLabel !== props.label ? ( | 120 | <View className="min-w-0 flex-1 flex-row items-baseline gap-2"> |
| 93 | <Text className="shrink text-xs text-foreground-tertiary">· {props.instanceLabel}</Text> | 121 | <Text className="text-base font-t3-medium text-foreground">{props.label}</Text> |
| 94 | ) : null} | 122 | {props.instanceLabel !== props.label ? ( |
| 95 | {props.detail ? ( | 123 | <Text className="shrink text-xs text-foreground-tertiary" numberOfLines={1}> |
| 96 | <Text className="shrink text-sm text-foreground-muted">· {props.detail}</Text> | 124 | · {props.instanceLabel} |
| 97 | ) : null} | 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 | </View> | 133 | </View> |
| 99 | {notice ? ( | 134 | {notice ? ( |
| 100 | <Text className="text-sm text-foreground-muted">{notice}</Text> | 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 | {props.footer} | 143 | {props.footer} |
| 105 | </View> | 144 | </View> |
| ... | @@ -170,7 +209,7 @@ function ResetCredits(props: { | ... | @@ -170,7 +209,7 @@ function ResetCredits(props: { |
| 170 | }; | 209 | }; |
| 171 | 210 | ||
| 172 | return ( | 211 | return ( |
| 173 | <View className="gap-2"> | 212 | <View className="flex-row flex-wrap items-center gap-x-3 gap-y-1"> |
| 174 | <Text className="text-xs tabular-nums text-foreground-tertiary">{summary}</Text> | 213 | <Text className="text-xs tabular-nums text-foreground-tertiary">{summary}</Text> |
| 175 | {credits.availableCount > 0 ? ( | 214 | {credits.availableCount > 0 ? ( |
| 176 | <Pressable | 215 | <Pressable |
| ... | @@ -178,7 +217,7 @@ function ResetCredits(props: { | ... | @@ -178,7 +217,7 @@ function ResetCredits(props: { |
| 178 | accessibilityState={{ disabled: busy }} | 217 | accessibilityState={{ disabled: busy }} |
| 179 | disabled={busy} | 218 | disabled={busy} |
| 180 | onPress={confirm} | 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 | <Text className="text-sm font-t3-medium text-foreground"> | 222 | <Text className="text-sm font-t3-medium text-foreground"> |
| 184 | {busy ? "Using credit…" : "Use a reset credit"} | 223 | {busy ? "Using credit…" : "Use a reset credit"} |
| ... | @@ -200,6 +239,7 @@ function ProviderLimits(props: { | ... | @@ -200,6 +239,7 @@ function ProviderLimits(props: { |
| 200 | const credits = provider.usageLimits?.resetCredits; | 239 | const credits = provider.usageLimits?.resetCredits; |
| 201 | return ( | 240 | return ( |
| 202 | <AccountLimits | 241 | <AccountLimits |
| 242 | driver={provider.driver} | ||
| 203 | label={DRIVER_LABEL[provider.driver] ?? String(provider.driver)} | 243 | label={DRIVER_LABEL[provider.driver] ?? String(provider.driver)} |
| 204 | instanceLabel={providerLimitsLabel(provider, (driver) => DRIVER_LABEL[driver])} | 244 | instanceLabel={providerLimitsLabel(provider, (driver) => DRIVER_LABEL[driver])} |
| 205 | detail={provider.auth.label} | 245 | detail={provider.auth.label} |
| ... | @@ -229,6 +269,7 @@ function SourceAccountLimits(props: { | ... | @@ -229,6 +269,7 @@ function SourceAccountLimits(props: { |
| 229 | const { account } = props; | 269 | const { account } = props; |
| 230 | return ( | 270 | return ( |
| 231 | <AccountLimits | 271 | <AccountLimits |
| 272 | driver={account.driver} | ||
| 232 | label={DRIVER_LABEL[account.driver] ?? String(account.driver)} | 273 | label={DRIVER_LABEL[account.driver] ?? String(account.driver)} |
| 233 | instanceLabel="CLI Proxy" | 274 | instanceLabel="CLI Proxy" |
| 234 | detail={account.plan} | 275 | detail={account.plan} |
| ... | @@ -239,21 +280,93 @@ function SourceAccountLimits(props: { | ... | @@ -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 | * Subscription quota windows from every connected environment's providers, | 324 | * Subscription quota windows from every connected environment's providers, |
| 244 | * read from the config each environment already streams. Countdowns anchor to | 325 | * read from the config each environment already streams. |
| 245 | * render time rather than ticking. | ||
| 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 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); | 332 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 249 | const groups = collectLimitsGroups(presentations); | 333 | const groups = collectLimitsGroups(presentations); |
| 250 | const sources = collectLimitSources(presentations); | 334 | const sources = collectLimitSources(presentations); |
| 251 | // Anchored once per mount on purpose: countdowns must not tick. | 335 | |
| 252 | const [now] = useState(() => Date.now()); | 336 | if (groups.length === 0 && sources.length === 0) { |
| 253 | if (groups.length === 0 && sources.length === 0) return null; | 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 | return ( | 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 | {sources.map((source) => ( | 370 | {sources.map((source) => ( |
| 258 | <SettingsSection key={source.key} card> | 371 | <SettingsSection key={source.key} card> |
| 259 | {source.error ? ( | 372 | {source.error ? ( |
| ... | @@ -276,23 +389,6 @@ export function UsageLimitsSection() { | ... | @@ -276,23 +389,6 @@ export function UsageLimitsSection() { |
| 276 | )} | 389 | )} |
| 277 | </SettingsSection> | 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,32 +11,52 @@ import { |
| 11 | formatUsd, | 11 | formatUsd, |
| 12 | makeWindow, | 12 | makeWindow, |
| 13 | } from "@t3tools/shared/usageFormat"; | 13 | } from "@t3tools/shared/usageFormat"; |
| 14 | import { useMemo, useState } from "react"; | 14 | import { useMemo, useRef, useState } from "react"; |
| 15 | import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; | 15 | import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; |
| 16 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 16 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 17 | 17 | ||
| 18 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 18 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 19 | import { AppText as Text } from "../../components/AppText"; | 19 | import { AppText as Text } from "../../components/AppText"; |
| 20 | import { cn } from "../../lib/cn"; | ||
| 20 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 21 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 21 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; | 22 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 22 | import { SettingsSection } from "../settings/components/SettingsSection"; | 23 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 23 | import { UsageDailyChart } from "./UsageDailyChart"; | 24 | import { UsageDailyChart } from "./UsageDailyChart"; |
| 24 | import { UsageLimitsSection } from "./UsageLimitsSection"; | 25 | import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection"; |
| 25 | import type { UsageChartMetric } from "./usageChartData"; | 26 | import type { UsageChartMetric } from "./usageChartData"; |
| 26 | import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; | 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 | const WINDOW_OPTIONS = [ | 37 | const WINDOW_OPTIONS = [ |
| 29 | { days: 1, label: "Past 24h" }, | 38 | { value: 1, label: "24h", accessibilityLabel: "Past 24 hours" }, |
| 30 | { days: 7, label: "7 days" }, | 39 | { value: 7, label: "7d", accessibilityLabel: "Past 7 days" }, |
| 31 | { days: 30, label: "30 days" }, | 40 | { value: 30, label: "30d", accessibilityLabel: "Past 30 days" }, |
| 32 | { days: 90, label: "90 days" }, | 41 | { value: 90, label: "90d", accessibilityLabel: "Past 90 days" }, |
| 33 | ] as const; | 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 | const CHART_HEIGHT = 180; | 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 | export function UsageRouteScreen() { | 56 | export function UsageRouteScreen() { |
| 38 | const navigation = useNavigation(); | 57 | const navigation = useNavigation(); |
| 39 | const insets = useSafeAreaInsets(); | 58 | const insets = useSafeAreaInsets(); |
| 59 | const [tab, setTab] = useState<UsageTab>("usage"); | ||
| 40 | const [windowSelection, setWindowSelection] = useState(() => ({ | 60 | const [windowSelection, setWindowSelection] = useState(() => ({ |
| 41 | days: 30, | 61 | days: 30, |
| 42 | window: makeWindow(30), | 62 | window: makeWindow(30), |
| ... | @@ -45,6 +65,7 @@ export function UsageRouteScreen() { | ... | @@ -45,6 +65,7 @@ export function UsageRouteScreen() { |
| 45 | const { days: windowDays, window } = windowSelection; | 65 | const { days: windowDays, window } = windowSelection; |
| 46 | const isPast24Hours = windowDays === 1; | 66 | const isPast24Hours = windowDays === 1; |
| 47 | const { merged, environments, isPending, isPartial, refresh } = useUsage(window); | 67 | const { merged, environments, isPending, isPartial, refresh } = useUsage(window); |
| 68 | const limits = useRefreshLimits(); | ||
| 48 | 69 | ||
| 49 | const days = useMemo( | 70 | const days = useMemo( |
| 50 | () => enumerateDays(window.sinceDay, window.untilDay), | 71 | () => enumerateDays(window.sinceDay, window.untilDay), |
| ... | @@ -73,7 +94,16 @@ export function UsageRouteScreen() { | ... | @@ -73,7 +94,16 @@ export function UsageRouteScreen() { |
| 73 | // The pull spinner tracks re-scans of environments that have answered | 94 | // The pull spinner tracks re-scans of environments that have answered |
| 74 | // before. The initial scan renders its own placeholder, and an unreachable | 95 | // before. The initial scan renders its own placeholder, and an unreachable |
| 75 | // environment stays pending forever — neither may pin the spinner on. | 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 | const selectWindow = (days: number) => { | 107 | const selectWindow = (days: number) => { |
| 78 | setWindowSelection({ | 108 | setWindowSelection({ |
| 79 | days, | 109 | days, |
| ... | @@ -103,46 +133,73 @@ export function UsageRouteScreen() { | ... | @@ -103,46 +133,73 @@ export function UsageRouteScreen() { |
| 103 | </> | 133 | </> |
| 104 | ) : null} | 134 | ) : null} |
| 105 | <ScrollView | 135 | <ScrollView |
| 136 | ref={scrollRef} | ||
| 106 | contentInsetAdjustmentBehavior="automatic" | 137 | contentInsetAdjustmentBehavior="automatic" |
| 107 | showsVerticalScrollIndicator={false} | 138 | showsVerticalScrollIndicator={false} |
| 108 | className="flex-1" | 139 | className="flex-1" |
| 109 | contentContainerClassName="gap-6 px-5 pt-4" | 140 | contentContainerClassName="gap-6 px-5 pt-4" |
| 110 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} | 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 | 149 | <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={selectTab} role="tab" /> |
| 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} /> | ||
| 120 | 150 | ||
| 121 | {isPending ? ( | 151 | {showingLimits ? ( |
| 122 | <Text className="py-16 text-center text-base text-foreground-muted"> | 152 | <UsageLimitsSection now={limits.now} failedLabels={limits.failedLabels} /> |
| 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> | ||
| 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 | merged={merged} | 175 | merged={merged} |
| 133 | days={chartDays} | 176 | isPartial={isPartial} |
| 134 | daily={chartTotals} | ||
| 135 | metric={metric} | ||
| 136 | onMetricChange={setMetric} | ||
| 137 | sinceDay={window.sinceDay} | ||
| 138 | untilDay={window.untilDay} | ||
| 139 | isPast24Hours={isPast24Hours} | ||
| 140 | timeZone={window.timeZone} | ||
| 141 | /> | 177 | /> |
| 142 | <ProviderSection merged={merged} metric={metric} /> | 178 | {isPending ? ( |
| 143 | <UsageLimitsSection /> | 179 | <Text className="py-16 text-center text-base text-foreground-muted"> |
| 144 | <TotalsSection merged={merged} isPast24Hours={isPast24Hours} /> | 180 | Scanning provider transcripts… |
| 145 | <ModelsSection merged={merged} /> | 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 | </ScrollView> | 205 | </ScrollView> |
| ... | @@ -151,30 +208,48 @@ export function UsageRouteScreen() { | ... | @@ -151,30 +208,48 @@ export function UsageRouteScreen() { |
| 151 | } | 208 | } |
| 152 | 209 | ||
| 153 | function SegmentedControl<Value extends number | string>(props: { | 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 | readonly selected: Value; | 216 | readonly selected: Value; |
| 156 | readonly onSelect: (value: Value) => void; | 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 | return ( | 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 | {props.options.map((option) => { | 233 | {props.options.map((option) => { |
| 161 | const active = option.value === props.selected; | 234 | const active = option.value === props.selected; |
| 162 | return ( | 235 | return ( |
| 163 | <Pressable | 236 | <Pressable |
| 164 | key={String(option.value)} | 237 | key={String(option.value)} |
| 165 | accessibilityRole="button" | 238 | accessibilityRole={props.role ?? "button"} |
| 239 | accessibilityLabel={option.accessibilityLabel} | ||
| 166 | accessibilityState={{ selected: active }} | 240 | accessibilityState={{ selected: active }} |
| 167 | onPress={() => props.onSelect(option.value)} | 241 | onPress={() => props.onSelect(option.value)} |
| 168 | className={ | 242 | className={cn( |
| 169 | active | 243 | "flex-1 items-center justify-center rounded-full", |
| 170 | ? "flex-1 items-center rounded-full bg-subtle-strong py-2" | 244 | compact ? "h-9" : "h-11", |
| 171 | : "flex-1 items-center py-2" | 245 | active && "bg-subtle-strong", |
| 172 | } | 246 | )} |
| 173 | > | 247 | > |
| 174 | <Text | 248 | <Text |
| 175 | className={ | 249 | className={cn( |
| 176 | active ? "text-sm font-t3-medium text-foreground" : "text-sm text-foreground-muted" | 250 | compact ? "text-xs" : "text-sm", |
| 177 | } | 251 | active ? "font-t3-medium text-foreground" : "text-foreground-muted", |
| 252 | )} | ||
| 178 | > | 253 | > |
| 179 | {option.label} | 254 | {option.label} |
| 180 | </Text> | 255 | </Text> |
| ... | @@ -191,7 +266,6 @@ function ChartCard(props: { | ... | @@ -191,7 +266,6 @@ function ChartCard(props: { |
| 191 | readonly days: readonly string[]; | 266 | readonly days: readonly string[]; |
| 192 | readonly daily: readonly DailyTotals[]; | 267 | readonly daily: readonly DailyTotals[]; |
| 193 | readonly metric: UsageChartMetric; | 268 | readonly metric: UsageChartMetric; |
| 194 | readonly onMetricChange: (metric: UsageChartMetric) => void; | ||
| 195 | readonly sinceDay: string; | 269 | readonly sinceDay: string; |
| 196 | readonly untilDay: string; | 270 | readonly untilDay: string; |
| 197 | readonly isPast24Hours: boolean; | 271 | readonly isPast24Hours: boolean; |
| ... | @@ -203,21 +277,18 @@ function ChartCard(props: { | ... | @@ -203,21 +277,18 @@ function ChartCard(props: { |
| 203 | 277 | ||
| 204 | return ( | 278 | return ( |
| 205 | <View className="gap-4 rounded-[24px] border-continuous bg-card p-4"> | 279 | <View className="gap-4 rounded-[24px] border-continuous bg-card p-4"> |
| 206 | <View className="flex-row items-start justify-between gap-3"> | 280 | <View className="gap-0.5"> |
| 207 | <View className="min-w-0 flex-1 gap-0.5"> | 281 | <Text className="text-sm text-foreground-muted"> |
| 208 | <Text className="text-sm text-foreground-muted"> | 282 | {metric === "cost" ? "Raw token cost" : "Processed tokens"} |
| 209 | {metric === "cost" ? "Raw token cost" : "Processed tokens"} | 283 | </Text> |
| 210 | </Text> | 284 | <Text className="text-4xl font-t3-bold tabular-nums text-foreground"> |
| 211 | <Text className="text-4xl font-t3-bold tabular-nums text-foreground"> | 285 | {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} |
| 212 | {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} | 286 | </Text> |
| 213 | </Text> | 287 | <Text className="text-sm text-foreground-muted"> |
| 214 | <Text className="text-sm text-foreground-muted"> | 288 | {metric === "cost" |
| 215 | {metric === "cost" | 289 | ? "* if billed at full API rate" |
| 216 | ? "* if billed at full API rate" | 290 | : `Across ${formatCount(merged.sessions)} sessions`} |
| 217 | : `Across ${formatCount(merged.sessions)} sessions`} | 291 | </Text> |
| 218 | </Text> | ||
| 219 | </View> | ||
| 220 | <MetricToggle metric={metric} onChange={props.onMetricChange} /> | ||
| 221 | </View> | 292 | </View> |
| 222 | 293 | ||
| 223 | {hasActivity ? ( | 294 | {hasActivity ? ( |
| ... | @@ -262,38 +333,6 @@ function ChartCard(props: { | ... | @@ -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 | function ProviderSection(props: { | 336 | function ProviderSection(props: { |
| 298 | readonly merged: MergedUsage; | 337 | readonly merged: MergedUsage; |
| 299 | readonly metric: UsageChartMetric; | 338 | readonly metric: UsageChartMetric; |
docs/user/usage.md+2| ... | @@ -18,6 +18,8 @@ update model pricing. | ... | @@ -18,6 +18,8 @@ update model pricing. |
| 18 | compares quota consumed with time elapsed in each window, so you can judge your pace before the | 18 | compares quota consumed with time elapsed in each window, so you can judge your pace before the |
| 19 | next reset. | 19 | next reset. |
| 20 | 20 | ||
| 21 | If a window looks stale, refresh Limits to re-check every provider and hub. | ||
| 22 | |||
| 21 | API-key accounts may not report subscription limits. This also applies to Claude connections | 23 | API-key accounts may not report subscription limits. This also applies to Claude connections |
| 22 | using a proxy through `ANTHROPIC_AUTH_TOKEN`. | 24 | using a proxy through `ANTHROPIC_AUTH_TOKEN`. |
| 23 | 25 |