authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 14:10:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 14:10:30-07:00
log24aef0e6c1bd5373a82eeed8054b50fc6e041b76
tree7a0d29dcfcb32e9f180fe6b81983eb069baee074
parentb7d6e65021b021207424c56cb32d6d711fd875fb
signature Signed by PGP key B5690EEEBB952194

feat(mobile): split the usage page into Usage and Limits tabs (#9775)

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 {
77 ServerProviderResetCredits,
88 ServerProviderUsageWindow,
99 UsageLimitSourceAccount,
10 UsageProviderKind,
1011} from "@t3tools/contracts";
1112import {
1213 collectLimitSources,
......@@ -22,30 +23,46 @@ import { type ReactNode, useState } from "react";
2223import { Alert, Pressable, View } from "react-native";
2324
2425import { AppText as Text } from "../../components/AppText";
26import { ProviderIcon } from "../../components/ProviderIcon";
2527import { environmentPresentations } from "../../state/presentation";
2628import { serverEnvironment } from "../../state/server";
2729import { useAtomCommand } from "../../state/use-atom-command";
2830import { SettingsSection } from "../settings/components/SettingsSection";
31import { useProviderColors } from "./usageProviders";
2932
3033const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;
3134const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };
3235
36type Driver = ServerProvider["driver"];
37
38/** The series colour the usage chart uses for this driver, so the two views read as one. */
39function 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
3346/**
3447 * 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.
3650 */
37function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly now: number }) {
51function WindowRow(props: {
52 readonly window: ServerProviderUsageWindow;
53 readonly color: string | null;
54 readonly now: number;
55}) {
3856 const { window, now } = props;
3957 const used = Math.round(Math.max(0, Math.min(100, window.usedPercent)));
4058 const elapsed = elapsedShare(window, now);
4159 const pace = paceOf(window, now);
4260 const resetsIn = formatResetsIn(window, now);
43 const detail = [pace ? PACE_LABEL[pace] : null, resetsIn].filter(Boolean).join(" · ");
4461 return (
45 <View className="gap-1.5">
62 <View className="gap-1">
4663 <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>
4966 </View>
5067 <View className="h-3 justify-center">
5168 <View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle">
......@@ -57,7 +74,10 @@ function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly
5774 ? "h-full rounded-full bg-warning"
5875 : "h-full rounded-full bg-foreground"
5976 }
60 style={{ flex: used }}
77 style={[
78 { flex: used },
79 used < 70 && props.color ? { backgroundColor: props.color } : null,
80 ]}
6181 />
6282 <View style={{ flex: 100 - used }} />
6383 </View>
......@@ -68,12 +88,19 @@ function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly
6888 />
6989 ) : null}
7090 </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}
7297 </View>
7398 );
7499}
75100
101/** One account: icon, name and plan on a single line, then its windows. */
76102function AccountLimits(props: {
103 readonly driver: Driver;
77104 readonly label: string;
78105 readonly instanceLabel: string;
79106 readonly detail: string | undefined;
......@@ -83,23 +110,35 @@ function AccountLimits(props: {
83110 readonly footer?: ReactNode;
84111}) {
85112 const { limits, now } = props;
113 const color = useBarColor(props.driver);
86114 if (!limits) return null;
87115 const notice = limitsNotice(limits);
88116 return (
89117 <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>
98133 </View>
99134 {notice ? (
100135 <Text className="text-sm text-foreground-muted">{notice}</Text>
101136 ) : (
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>
103142 )}
104143 {props.footer}
105144 </View>
......@@ -170,7 +209,7 @@ function ResetCredits(props: {
170209 };
171210
172211 return (
173 <View className="gap-2">
212 <View className="flex-row flex-wrap items-center gap-x-3 gap-y-1">
174213 <Text className="text-xs tabular-nums text-foreground-tertiary">{summary}</Text>
175214 {credits.availableCount > 0 ? (
176215 <Pressable
......@@ -178,7 +217,7 @@ function ResetCredits(props: {
178217 accessibilityState={{ disabled: busy }}
179218 disabled={busy}
180219 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"
182221 >
183222 <Text className="text-sm font-t3-medium text-foreground">
184223 {busy ? "Using credit…" : "Use a reset credit"}
......@@ -200,6 +239,7 @@ function ProviderLimits(props: {
200239 const credits = provider.usageLimits?.resetCredits;
201240 return (
202241 <AccountLimits
242 driver={provider.driver}
203243 label={DRIVER_LABEL[provider.driver] ?? String(provider.driver)}
204244 instanceLabel={providerLimitsLabel(provider, (driver) => DRIVER_LABEL[driver])}
205245 detail={provider.auth.label}
......@@ -229,6 +269,7 @@ function SourceAccountLimits(props: {
229269 const { account } = props;
230270 return (
231271 <AccountLimits
272 driver={account.driver}
232273 label={DRIVER_LABEL[account.driver] ?? String(account.driver)}
233274 instanceLabel="CLI Proxy"
234275 detail={account.plan}
......@@ -239,21 +280,93 @@ function SourceAccountLimits(props: {
239280 );
240281}
241282
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 */
291export 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
242323/**
243324 * 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.
246326 */
247export function UsageLimitsSection() {
327export function UsageLimitsSection(props: {
328 readonly now: number;
329 readonly failedLabels: readonly string[];
330}) {
331 const { now } = props;
248332 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
249333 const groups = collectLimitsGroups(presentations);
250334 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 }
254343
255344 return (
256345 <>
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 ))}
257370 {sources.map((source) => (
258371 <SettingsSection key={source.key} card>
259372 {source.error ? (
......@@ -276,23 +389,6 @@ export function UsageLimitsSection() {
276389 )}
277390 </SettingsSection>
278391 ))}
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 ))}
296392 </>
297393 );
298394}
apps/mobile/src/features/usage/UsageRouteScreen.tsx+134-95
......@@ -11,32 +11,52 @@ import {
1111 formatUsd,
1212 makeWindow,
1313} from "@t3tools/shared/usageFormat";
14import { useMemo, useState } from "react";
14import { useMemo, useRef, useState } from "react";
1515import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
1616import { useSafeAreaInsets } from "react-native-safe-area-context";
1717
1818import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
1919import { AppText as Text } from "../../components/AppText";
20import { cn } from "../../lib/cn";
2021import { NativeStackScreenOptions } from "../../native/StackHeader";
2122import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
2223import { SettingsSection } from "../settings/components/SettingsSection";
2324import { UsageDailyChart } from "./UsageDailyChart";
24import { UsageLimitsSection } from "./UsageLimitsSection";
25import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection";
2526import type { UsageChartMetric } from "./usageChartData";
2627import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";
2728
29type UsageTab = "usage" | "limits";
30const 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.
2837const 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" },
3342] as const;
3443
44const METRIC_OPTIONS = [
45 { value: "cost", label: "Cost" },
46 { value: "tokens", label: "Tokens" },
47] as const satisfies readonly { value: UsageChartMetric; label: string }[];
48
3549const CHART_HEIGHT = 180;
3650
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 */
3756export function UsageRouteScreen() {
3857 const navigation = useNavigation();
3958 const insets = useSafeAreaInsets();
59 const [tab, setTab] = useState<UsageTab>("usage");
4060 const [windowSelection, setWindowSelection] = useState(() => ({
4161 days: 30,
4262 window: makeWindow(30),
......@@ -45,6 +65,7 @@ export function UsageRouteScreen() {
4565 const { days: windowDays, window } = windowSelection;
4666 const isPast24Hours = windowDays === 1;
4767 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
68 const limits = useRefreshLimits();
4869
4970 const days = useMemo(
5071 () => enumerateDays(window.sinceDay, window.untilDay),
......@@ -73,7 +94,16 @@ export function UsageRouteScreen() {
7394 // The pull spinner tracks re-scans of environments that have answered
7495 // before. The initial scan renders its own placeholder, and an unreachable
7596 // 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 };
77107 const selectWindow = (days: number) => {
78108 setWindowSelection({
79109 days,
......@@ -103,46 +133,73 @@ export function UsageRouteScreen() {
103133 </>
104134 ) : null}
105135 <ScrollView
136 ref={scrollRef}
106137 contentInsetAdjustmentBehavior="automatic"
107138 showsVerticalScrollIndicator={false}
108139 className="flex-1"
109140 contentContainerClassName="gap-6 px-5 pt-4"
110141 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 }
112148 >
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" />
120150
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} />
129153 ) : (
130154 <>
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}
132175 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}
141177 />
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 )}
146203 </>
147204 )}
148205 </ScrollView>
......@@ -151,30 +208,48 @@ export function UsageRouteScreen() {
151208}
152209
153210function 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 }[];
155216 readonly selected: Value;
156217 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;
157223}) {
224 const compact = props.size === "compact";
158225 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 >
160233 {props.options.map((option) => {
161234 const active = option.value === props.selected;
162235 return (
163236 <Pressable
164237 key={String(option.value)}
165 accessibilityRole="button"
238 accessibilityRole={props.role ?? "button"}
239 accessibilityLabel={option.accessibilityLabel}
166240 accessibilityState={{ selected: active }}
167241 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 )}
173247 >
174248 <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 )}
178253 >
179254 {option.label}
180255 </Text>
......@@ -191,7 +266,6 @@ function ChartCard(props: {
191266 readonly days: readonly string[];
192267 readonly daily: readonly DailyTotals[];
193268 readonly metric: UsageChartMetric;
194 readonly onMetricChange: (metric: UsageChartMetric) => void;
195269 readonly sinceDay: string;
196270 readonly untilDay: string;
197271 readonly isPast24Hours: boolean;
......@@ -203,21 +277,18 @@ function ChartCard(props: {
203277
204278 return (
205279 <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>
221292 </View>
222293
223294 {hasActivity ? (
......@@ -262,38 +333,6 @@ function ChartCard(props: {
262333 );
263334}
264335
265function 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
297336function ProviderSection(props: {
298337 readonly merged: MergedUsage;
299338 readonly metric: UsageChartMetric;
docs/user/usage.md+2
......@@ -18,6 +18,8 @@ update model pricing.
1818compares quota consumed with time elapsed in each window, so you can judge your pace before the
1919next reset.
2020
21If a window looks stale, refresh Limits to re-check every provider and hub.
22
2123API-key accounts may not report subscription limits. This also applies to Claude connections
2224using a proxy through `ANTHROPIC_AUTH_TOKEN`.
2325