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 {...@@ -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";
11import {12import {
12 collectLimitSources,13 collectLimitSources,
...@@ -22,30 +23,46 @@ import { type ReactNode, useState } from "react";...@@ -22,30 +23,46 @@ import { type ReactNode, useState } from "react";
22import { Alert, Pressable, View } from "react-native";23import { Alert, Pressable, View } from "react-native";
2324
24import { AppText as Text } from "../../components/AppText";25import { AppText as Text } from "../../components/AppText";
26import { ProviderIcon } from "../../components/ProviderIcon";
25import { environmentPresentations } from "../../state/presentation";27import { environmentPresentations } from "../../state/presentation";
26import { serverEnvironment } from "../../state/server";28import { serverEnvironment } from "../../state/server";
27import { useAtomCommand } from "../../state/use-atom-command";29import { useAtomCommand } from "../../state/use-atom-command";
28import { SettingsSection } from "../settings/components/SettingsSection";30import { SettingsSection } from "../settings/components/SettingsSection";
31import { useProviderColors } from "./usageProviders";
2932
30const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;33const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;
31const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };34const 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
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 */
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}) {
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}
75100
101/** One account: icon, name and plan on a single line, then its windows. */
76function AccountLimits(props: {102function 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 };
171210
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 <Pressable215 <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 <AccountLimits241 <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 <AccountLimits271 <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}
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
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 to325 * read from the config each environment already streams.
245 * render time rather than ticking.
246 */326 */
247export function UsageLimitsSection() {327export 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 }
254343
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";
14import { useMemo, useState } from "react";14import { useMemo, useRef, useState } from "react";
15import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";15import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
16import { useSafeAreaInsets } from "react-native-safe-area-context";16import { useSafeAreaInsets } from "react-native-safe-area-context";
1717
18import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";18import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
19import { AppText as Text } from "../../components/AppText";19import { AppText as Text } from "../../components/AppText";
20import { cn } from "../../lib/cn";
20import { NativeStackScreenOptions } from "../../native/StackHeader";21import { NativeStackScreenOptions } from "../../native/StackHeader";
21import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";22import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
22import { SettingsSection } from "../settings/components/SettingsSection";23import { SettingsSection } from "../settings/components/SettingsSection";
23import { UsageDailyChart } from "./UsageDailyChart";24import { UsageDailyChart } from "./UsageDailyChart";
24import { UsageLimitsSection } from "./UsageLimitsSection";25import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection";
25import type { UsageChartMetric } from "./usageChartData";26import type { UsageChartMetric } from "./usageChartData";
26import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";27import { 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.
28const WINDOW_OPTIONS = [37const 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;
3443
44const METRIC_OPTIONS = [
45 { value: "cost", label: "Cost" },
46 { value: "tokens", label: "Tokens" },
47] as const satisfies readonly { value: UsageChartMetric; label: string }[];
48
35const CHART_HEIGHT = 180;49const 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 */
37export function UsageRouteScreen() {56export 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();
4869
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 answered94 // The pull spinner tracks re-scans of environments that have answered
74 // before. The initial scan renders its own placeholder, and an unreachable95 // 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 <ScrollView135 <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 <SegmentedControl149 <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} />
120150
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 <ChartCard155 {/* 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}
152209
153function SegmentedControl<Value extends number | string>(props: {210function 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 <Pressable236 <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 active243 "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 <Text248 <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: {
203277
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>
222293
223 {hasActivity ? (294 {hasActivity ? (
...@@ -262,38 +333,6 @@ function ChartCard(props: {...@@ -262,38 +333,6 @@ function ChartCard(props: {
262 );333 );
263}334}
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
297function ProviderSection(props: {336function 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.
18compares quota consumed with time elapsed in each window, so you can judge your pace before the18compares quota consumed with time elapsed in each window, so you can judge your pace before the
19next reset.19next reset.
2020
21If a window looks stale, refresh Limits to re-check every provider and hub.
22
21API-key accounts may not report subscription limits. This also applies to Claude connections23API-key accounts may not report subscription limits. This also applies to Claude connections
22using a proxy through `ANTHROPIC_AUTH_TOKEN`.24using a proxy through `ANTHROPIC_AUTH_TOKEN`.
2325