authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 00:57:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 00:57:43-07:00
logb155c2199bdcb4fdf1920da02a223536f8c2d729
tree54543891481c7be2c0ffdf5f269a2d7b99ad778f
parente5d086c262daab13a8adbb253e281c07ab235533
signature Signed by PGP key B5690EEEBB952194

feat(mobile): pool usage limits across selected environments (#10334)


11 files changed, 716 insertions(+), 276 deletions(-)

apps/mobile/src/Stack.tsx+5
......@@ -56,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
5656import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
5757import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
5858import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
59import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled";
5960import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
6061import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
6162import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
......@@ -192,6 +193,10 @@ const SettingsContentStack = createNativeStackNavigator({
192193 title: "Client Storage",
193194 },
194195 }),
196 SettingsUsageAccount: createNativeStackScreen({
197 screen: UsageLimitAccountScreen,
198 options: { title: "Account" },
199 }),
195200 SettingsUsage: createNativeStackScreen({
196201 screen: UsageRouteScreen,
197202 linking: "usage",
apps/mobile/src/components/AppSymbol.tsx+3
......@@ -31,6 +31,7 @@ import IconChevronRight from "@tabler/icons-react-native/IconChevronRight";
3131import IconChevronUp from "@tabler/icons-react-native/IconChevronUp";
3232import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck";
3333import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled";
34import IconTicket from "@tabler/icons-react-native/IconTicket";
3435import IconClock from "@tabler/icons-react-native/IconClock";
3536import IconCode from "@tabler/icons-react-native/IconCode";
3637import IconCopy from "@tabler/icons-react-native/IconCopy";
......@@ -116,6 +117,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
116117 checkmark: IconCheck,
117118 "checkmark.circle": IconCircleCheck,
118119 clock: IconClock,
120 ticket: IconTicket,
119121 cloud: IconCloud,
120122 cube: IconBox,
121123 "chevron.down": IconChevronDown,
......@@ -138,6 +140,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
138140 "info.circle": IconInfoCircle,
139141 laptopcomputer: IconDeviceLaptop,
140142 link: IconLink,
143 "line.3.horizontal.decrease": IconFilter,
141144 "line.3.horizontal.decrease.circle": IconFilter,
142145 "line.3.horizontal.decrease.circle.fill": IconFilterFilled,
143146 // Tabler has no Apple desktops; the closest silhouettes stand in on Android.
apps/mobile/src/components/ControlPill.tsx+13-5
......@@ -9,7 +9,14 @@ import {
99 useMemo,
1010 useRef,
1111} from "react";
12import { Platform, Pressable, View, type ColorValue, type PressableProps } from "react-native";
12import {
13 Platform,
14 Pressable,
15 View,
16 type ColorValue,
17 type PressableProps,
18 type AccessibilityProps,
19} from "react-native";
1320import { withUniwind } from "uniwind";
1421import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1522
......@@ -144,10 +151,11 @@ export function ControlPill(props: {
144151// AppCompat popup can't be themed past its stock animation, metrics, and
145152// submenu chrome.
146153export function ControlPillMenu(
147 props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & {
148 readonly children: ReactNode;
149 readonly className?: string;
150 },
154 props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> &
155 Pick<AccessibilityProps, "accessible" | "accessibilityLabel" | "accessibilityRole"> & {
156 readonly children: ReactNode;
157 readonly className?: string;
158 },
151159) {
152160 const { themeAppearance } = useAppearancePreferences();
153161 const isDarkMode = themeAppearance === "dark";
apps/mobile/src/features/usage/UsageLimitsPooled.tsx created+374
......@@ -0,0 +1,374 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { EnvironmentId } from "@t3tools/contracts";
4import {
5 collectLimitAccounts,
6 collectLimitNotices,
7 collectLimitPools,
8 formatDuration,
9 formatResetsIn,
10 remainingPercent,
11 type LimitAccount,
12 type LimitPoolWindow,
13} from "@t3tools/shared/usageLimits";
14import { useId, useState } from "react";
15import { Platform, Pressable, ScrollView, View } from "react-native";
16import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";
17import { useSafeAreaInsets } from "react-native-safe-area-context";
18
19import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
20import { SymbolView } from "../../components/AppSymbol";
21import { AppText as Text } from "../../components/AppText";
22import { ProviderIcon } from "../../components/ProviderIcon";
23import { NativeStackScreenOptions } from "../../native/StackHeader";
24import { environmentPresentations } from "../../state/presentation";
25import { ResetCredits } from "./UsageLimitsSection";
26import { useProviderColors } from "./usageProviders";
27
28const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };
29const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const;
30
31function accountName(account: LimitAccount) {
32 if (account.displayName) return account.displayName;
33 if (!account.email) return DRIVER_LABEL[account.driver] ?? String(account.driver);
34 const [local = "", domain = ""] = account.email.split("@");
35 return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account";
36}
37
38/** The spent share comes back at reset. SVG keeps the hatching static on both platforms. */
39function AccountSegment({
40 remaining,
41 color,
42 pending,
43}: {
44 readonly remaining: number;
45 readonly color: string;
46 readonly pending: boolean;
47}) {
48 const patternId = useId().replace(/:/g, "");
49 return (
50 <Svg width="100%" height="100%" accessible={false}>
51 <Defs>
52 <Pattern id={patternId} width={6} height={6} patternUnits="userSpaceOnUse">
53 <Path d="M-1 1L1 -1M0 6L6 0M5 7L7 5" stroke={color} strokeWidth={1} opacity={0.22} />
54 </Pattern>
55 </Defs>
56 {pending ? (
57 <Rect
58 x={`${remaining}%`}
59 width={`${100 - remaining}%`}
60 height="100%"
61 fill={`url(#${patternId})`}
62 />
63 ) : null}
64 <Rect width={`${remaining}%`} height="100%" fill={color} opacity={0.35} />
65 </Svg>
66 );
67}
68
69function PoolWindowCard({
70 pool,
71 color,
72 now,
73 environmentIds,
74}: {
75 readonly pool: LimitPoolWindow;
76 readonly color: string;
77 readonly now: number;
78 readonly environmentIds: readonly string[] | null;
79}) {
80 const navigation = useNavigation();
81 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
82 const openAccount = (account: LimitAccount) =>
83 navigation.navigate("SettingsSheet", {
84 screen: "SettingsContent",
85 params: {
86 screen: "SettingsUsageAccount",
87 params: {
88 accountKey: account.key,
89 windowId: pool.id,
90 windowKind: pool.kind,
91 environmentIds,
92 now,
93 },
94 },
95 });
96 return (
97 <View className="gap-3 rounded-[24px] border-continuous bg-card p-4">
98 <View className="flex-row items-start justify-between gap-3">
99 <View className="gap-1">
100 <Text className="text-sm font-t3-medium text-foreground">{pool.label}</Text>
101 <View className="flex-row items-baseline gap-1.5">
102 <Text className="text-3xl font-t3-bold tabular-nums text-foreground">
103 {pool.remainingPercent}%
104 </Text>
105 <Text className="text-sm text-foreground-muted">left</Text>
106 </View>
107 </View>
108 {pool.pace ? (
109 <Text className="text-xs text-foreground-tertiary">{PACE_LABEL[pool.pace]}</Text>
110 ) : null}
111 </View>
112 {nextRefill ? (
113 <Text className="text-xs tabular-nums text-foreground-muted">
114 ↻ +{nextRefill.restoresPercent}%{" "}
115 {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`}
116 </Text>
117 ) : null}
118 <View className="flex-row gap-1">
119 {pool.members.map(({ account, window }, index) => (
120 <Pressable
121 key={account.key}
122 accessibilityRole="button"
123 accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`}
124 accessibilityHint="Show account details"
125 onPress={() => openAccount(account)}
126 className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle"
127 >
128 <AccountSegment
129 remaining={remainingPercent(window)}
130 color={color}
131 pending={Boolean(window.resetsAt)}
132 />
133 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
134 <Text className="text-xs font-t3-medium tabular-nums text-foreground">
135 {index + 1}
136 </Text>
137 </View>
138 </Pressable>
139 ))}
140 </View>
141 <View>
142 {pool.members.map(({ account, window }, index) => {
143 const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0;
144 const resetsIn = formatResetsIn(window, now);
145 return (
146 <Pressable
147 key={account.key}
148 accessibilityRole="button"
149 accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset credits banked` : ""}`}
150 accessibilityHint="Show account details"
151 onPress={() => openAccount(account)}
152 className="min-h-[44px] flex-row items-center gap-2 active:opacity-60"
153 >
154 <View className="size-5 items-center justify-center overflow-hidden rounded-md bg-subtle-strong">
155 <Text className="text-xs font-t3-medium tabular-nums text-foreground">
156 {index + 1}
157 </Text>
158 </View>
159 <Text
160 numberOfLines={1}
161 className="min-w-0 flex-1 text-sm font-t3-medium text-foreground"
162 >
163 {accountName(account)}
164 </Text>
165 <Text className="text-sm font-t3-medium tabular-nums text-foreground">
166 {remainingPercent(window)}%
167 </Text>
168 <View className="flex-row items-center gap-1">
169 {resetsIn ? (
170 <Text className="text-xs tabular-nums text-foreground-muted">
171 {resetsIn.replace("resets in ", "↻ ")}
172 </Text>
173 ) : null}
174 {credits ? (
175 <>
176 {resetsIn ? <Text className="text-xs text-foreground-tertiary">·</Text> : null}
177 <SymbolView name="ticket" size={13} tintColorClassName="accent-icon" />
178 <Text className="text-xs font-t3-medium tabular-nums text-foreground">
179 {credits}
180 </Text>
181 </>
182 ) : null}
183 </View>
184 </Pressable>
185 );
186 })}
187 </View>
188 </View>
189 );
190}
191
192export function UsageLimitsSection({
193 now,
194 failedLabels,
195 selectedEnvironmentIds,
196}: {
197 readonly now: number;
198 readonly failedLabels: readonly string[];
199 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
200}) {
201 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
202 const selected =
203 selectedEnvironmentIds === null
204 ? presentations
205 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
206 const pools = collectLimitPools(collectLimitAccounts(selected), now);
207 const notices = collectLimitNotices(selected);
208 const colors = useProviderColors();
209 return (
210 <View className="gap-6">
211 {failedLabels.length ? (
212 <Text className="text-sm text-foreground-muted">
213 {failedLabels.join(", ")} could not refresh limits. Showing the last known values.
214 </Text>
215 ) : null}
216 {pools.length === 0 ? (
217 <Text className="py-12 text-center text-base text-foreground-muted">
218 {selected.size === 0
219 ? "Select an environment to see limits."
220 : "No provider on the selected environments reports subscription limits."}
221 </Text>
222 ) : null}
223 {pools.map((pool) => (
224 <View key={pool.driver} className="gap-3">
225 <View className="flex-row items-center gap-2 px-1">
226 <ProviderIcon provider={pool.driver} size={18} />
227 <Text className="text-base font-t3-medium text-foreground">
228 {DRIVER_LABEL[pool.driver] ?? pool.driver}
229 </Text>
230 </View>
231 {pool.windows.map((window) => (
232 <PoolWindowCard
233 key={`${window.kind}:${window.id}`}
234 pool={window}
235 color={pool.driver === "claudeAgent" ? colors.claude : colors.codex}
236 now={now}
237 environmentIds={selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds]}
238 />
239 ))}
240 </View>
241 ))}
242 {notices.map((notice) => (
243 <Text key={notice} className="text-sm text-foreground-muted">
244 {notice}
245 </Text>
246 ))}
247 </View>
248 );
249}
250
251type AccountScreenProps = StaticScreenProps<{
252 accountKey: string;
253 windowId: string;
254 windowKind: LimitPoolWindow["kind"];
255 environmentIds: readonly string[] | null;
256 now: number;
257}>;
258
259/** Resolve the account again so live quota and credit updates reach the open detail screen. */
260export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
261 const navigation = useNavigation();
262 const insets = useSafeAreaInsets();
263 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
264 const { accountKey, windowId, windowKind, environmentIds, now } = route.params;
265 const selectedIds =
266 environmentIds === null ? null : new Set(environmentIds.map((id) => EnvironmentId.make(id)));
267 const selected =
268 selectedIds === null
269 ? presentations
270 : new Map([...presentations].filter(([id]) => selectedIds.has(id)));
271 const accounts = collectLimitAccounts(selected);
272 const account = accounts.find((candidate) => candidate.key === accountKey);
273 const pool = collectLimitPools(accounts, now)
274 .find((candidate) => candidate.driver === account?.driver)
275 ?.windows.find((candidate) => candidate.id === windowId && candidate.kind === windowKind);
276 const window = pool?.members.find((member) => member.account.key === accountKey)?.window;
277 const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey);
278 const [revealed, setRevealed] = useState(false);
279 return (
280 <View collapsable={false} className="flex-1 bg-sheet">
281 {Platform.OS === "android" ? (
282 <>
283 <NativeStackScreenOptions options={{ headerShown: false }} />
284 <AndroidScreenHeader title="Account" onBack={() => navigation.goBack()} />
285 </>
286 ) : null}
287 <ScrollView
288 contentInsetAdjustmentBehavior="automatic"
289 contentContainerClassName="gap-5 p-5"
290 contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
291 >
292 {!account || !window ? (
293 <Text className="text-base text-foreground-muted">
294 This account is no longer reporting limits on the selected environments.
295 </Text>
296 ) : (
297 <>
298 <View className="gap-2">
299 <View className="flex-row items-center gap-2">
300 <ProviderIcon provider={account.driver} size={24} />
301 <Text className="flex-1 text-xl font-t3-bold text-foreground">
302 {account.displayName ?? DRIVER_LABEL[account.driver] ?? account.driver}
303 </Text>
304 </View>
305 {account.email ? (
306 <Pressable
307 accessibilityRole="button"
308 accessibilityLabel={revealed ? "Hide account email" : "Reveal account email"}
309 onPress={() => setRevealed((value) => !value)}
310 className="min-h-[44px] justify-center"
311 >
312 <Text className="text-sm text-foreground-muted">
313 {revealed ? account.email : "••••••@••••••"}
314 </Text>
315 </Pressable>
316 ) : null}
317 {account.plan ? (
318 <Text selectable className="text-sm text-foreground-muted">
319 {account.plan}
320 </Text>
321 ) : null}
322 </View>
323 <View className="gap-3 rounded-[24px] border-continuous bg-card p-4">
324 <Text className="text-sm font-t3-medium text-foreground">{window.label}</Text>
325 <Text className="text-3xl font-t3-bold tabular-nums text-foreground">
326 {remainingPercent(window)}% left
327 </Text>
328 {window.resetsAt ? (
329 <Text selectable className="text-sm text-foreground-muted">
330 Resets{" "}
331 {new Date(window.resetsAt).toLocaleString(undefined, {
332 dateStyle: "medium",
333 timeStyle: "short",
334 })}
335 </Text>
336 ) : null}
337 {reset && reset.restoresPercent > 0 ? (
338 <Text className="text-sm text-foreground-muted">
339 Restores {reset.restoresPercent}% of the pool
340 </Text>
341 ) : null}
342 </View>
343 <View className="gap-2 rounded-[24px] border-continuous bg-card p-4">
344 <Text className="text-sm font-t3-medium text-foreground">
345 {account.environments.length ? "Signed in" : "Source"}
346 </Text>
347 {account.environments.length ? (
348 account.environments.map((environment) => (
349 <Text key={environment.environmentId} className="text-sm text-foreground-muted">
350 {environment.label}
351 </Text>
352 ))
353 ) : (
354 <Text className="text-sm text-foreground-muted">{account.sourceLabel}</Text>
355 )}
356 </View>
357 {account.redeem && account.limits.resetCredits ? (
358 <View className="gap-3 rounded-[24px] border-continuous bg-card p-4">
359 <Text className="text-sm font-t3-medium text-foreground">Reset credits</Text>
360 <ResetCredits
361 key={`${account.redeem.environmentId}:${account.redeem.instanceId}`}
362 environmentId={account.redeem.environmentId}
363 instanceId={account.redeem.instanceId}
364 credits={account.limits.resetCredits}
365 now={now}
366 />
367 </View>
368 ) : null}
369 </>
370 )}
371 </ScrollView>
372 </View>
373 );
374}
apps/mobile/src/features/usage/UsageLimitsSection.tsx+20-137
......@@ -6,18 +6,14 @@ import type {
66 ServerProvider,
77 ServerProviderResetCredits,
88 ServerProviderUsageWindow,
9 UsageLimitSourceAccount,
109 UsageProviderKind,
1110} from "@t3tools/contracts";
1211import {
13 collectLimitSources,
14 collectLimitsGroups,
1512 elapsedShare,
1613 formatDuration,
1714 formatResetsIn,
1815 limitsNotice,
1916 paceOf,
20 providerLimitsLabel,
2117 remainingPercent,
2218} from "@t3tools/shared/usageLimits";
2319import { type ReactNode, useState } from "react";
......@@ -28,11 +24,9 @@ import { ProviderIcon } from "../../components/ProviderIcon";
2824import { environmentPresentations } from "../../state/presentation";
2925import { serverEnvironment } from "../../state/server";
3026import { useAtomCommand } from "../../state/use-atom-command";
31import { SettingsSection } from "../settings/components/SettingsSection";
3227import { useProviderColors } from "./usageProviders";
3328
3429const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;
35const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };
3630
3731type Driver = ServerProvider["driver"];
3832
......@@ -186,7 +180,7 @@ export function ResetCredits(props: {
186180 });
187181 const [busy, setBusy] = useState(false);
188182 const [status, setStatus] = useState<string | null>(null);
189 if (credits.availableCount === 0 && status === null) return null;
183 if (dense && credits.availableCount === 0 && status === null) return null;
190184
191185 const expiresIn = credits.nextExpiresAt
192186 ? formatDuration(Date.parse(credits.nextExpiresAt) - now)
......@@ -237,7 +231,7 @@ export function ResetCredits(props: {
237231 className={
238232 dense
239233 ? "rounded-full bg-subtle-strong px-2.5 py-1"
240 : "rounded-full bg-subtle-strong px-3 py-1.5"
234 : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5"
241235 }
242236 >
243237 <Text
......@@ -256,57 +250,6 @@ export function ResetCredits(props: {
256250 );
257251}
258252
259function ProviderLimits(props: {
260 readonly provider: ServerProvider;
261 readonly environmentId: EnvironmentId;
262 readonly now: number;
263 readonly first: boolean;
264}) {
265 const { provider, environmentId, now } = props;
266 const credits = provider.usageLimits?.resetCredits;
267 return (
268 <AccountLimits
269 driver={provider.driver}
270 label={DRIVER_LABEL[provider.driver] ?? String(provider.driver)}
271 instanceLabel={providerLimitsLabel(provider, (driver) => DRIVER_LABEL[driver])}
272 detail={provider.auth.label}
273 limits={provider.usageLimits}
274 now={now}
275 first={props.first}
276 footer={
277 credits ? (
278 <ResetCredits
279 environmentId={environmentId}
280 instanceId={provider.instanceId}
281 credits={credits}
282 now={now}
283 />
284 ) : undefined
285 }
286 />
287 );
288}
289
290/** Emails stay off the phone screen; the plan and driver identify the row. */
291function SourceAccountLimits(props: {
292 readonly account: UsageLimitSourceAccount;
293 readonly now: number;
294 readonly first: boolean;
295}) {
296 const { account } = props;
297 return (
298 <AccountLimits
299 driver={account.driver}
300 label={DRIVER_LABEL[account.driver] ?? String(account.driver)}
301 instanceLabel="CLI Proxy"
302 detail={account.plan}
303 limits={account.usageLimits}
304 now={props.now}
305 first={props.first}
306 />
307 );
308}
309
310253/**
311254 * Re-probes every provider (and usage-limit source) on each connected
312255 * environment; the fresh snapshots then arrive over the config stream.
......@@ -315,107 +258,47 @@ function SourceAccountLimits(props: {
315258 * Environments whose probe failed are named, since their rows keep showing
316259 * the previous quota with nothing else to say so.
317260 */
318export function useRefreshLimits() {
261export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null) {
319262 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
320263 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
321264 reportFailure: false,
322265 });
323266 const [now, setNow] = useState(() => Date.now());
324267 const [refreshing, setRefreshing] = useState(false);
325 const [failedLabels, setFailedLabels] = useState<readonly string[]>([]);
268 const [failedEnvironments, setFailedEnvironments] = useState<
269 readonly { environmentId: EnvironmentId; label: string }[]
270 >([]);
326271 // Always toggles `refreshing`, even with nothing to probe: Android's
327272 // RefreshControl keeps its spinner up until it sees true then false.
328273 const refresh = async () => {
329274 const connected = [...presentations].filter(
330 ([, presentation]) => presentation.connection.phase === "connected",
275 ([environmentId, presentation]) =>
276 presentation.connection.phase === "connected" &&
277 (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)),
331278 );
332279 setRefreshing(true);
333280 try {
334281 const results = await Promise.all(
335282 connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })),
336283 );
337 setFailedLabels(
284 setFailedEnvironments(
338285 connected
339286 .filter((_, index) => results[index]?._tag === "Failure")
340 .map(([, presentation]) => presentation.entry.target.label),
287 .map(([environmentId, presentation]) => ({
288 environmentId,
289 label: presentation.entry.target.label,
290 })),
341291 );
342292 } finally {
343293 setNow(Date.now());
344294 setRefreshing(false);
345295 }
346296 };
297 const failedLabels = failedEnvironments
298 .filter(
299 ({ environmentId }) =>
300 selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId),
301 )
302 .map(({ label }) => label);
347303 return { now, refreshing, failedLabels, refresh };
348304}
349
350/**
351 * Subscription quota windows from every connected environment's providers,
352 * read from the config each environment already streams.
353 */
354export function UsageLimitsSection(props: {
355 readonly now: number;
356 readonly failedLabels: readonly string[];
357}) {
358 const { now } = props;
359 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
360 const groups = collectLimitsGroups(presentations);
361 const sources = collectLimitSources(presentations);
362
363 if (groups.length === 0 && sources.length === 0) {
364 return (
365 <Text className="py-16 text-center text-base text-foreground-muted">
366 No provider on a connected environment reports subscription limits.
367 </Text>
368 );
369 }
370
371 return (
372 <>
373 {props.failedLabels.length > 0 ? (
374 <View className="rounded-[16px] border-continuous bg-card px-4 py-3">
375 <Text className="text-sm text-foreground-muted">
376 {props.failedLabels.join(", ")} could not refresh limits. Showing the last known values.
377 </Text>
378 </View>
379 ) : null}
380 {groups.map((group) => (
381 <SettingsSection
382 key={group.environmentId}
383 title={group.environmentLabel ?? "Providers"}
384 card
385 >
386 {group.providers.map((provider, index) => (
387 <ProviderLimits
388 key={provider.instanceId}
389 provider={provider}
390 environmentId={group.environmentId}
391 now={now}
392 first={index === 0}
393 />
394 ))}
395 </SettingsSection>
396 ))}
397 {sources.map((source) => (
398 <SettingsSection key={source.key} card>
399 {source.error ? (
400 <Text className="p-4 text-sm text-foreground-muted">{source.error}</Text>
401 ) : source.accounts.length === 0 ? (
402 <Text className="p-4 text-sm text-foreground-muted">
403 {source.hiddenAccountCount > 0
404 ? "All accounts are shown by connected providers."
405 : "No accounts reported."}
406 </Text>
407 ) : (
408 source.accounts.map((account, index) => (
409 <SourceAccountLimits
410 key={account.id}
411 account={account}
412 now={now}
413 first={index === 0}
414 />
415 ))
416 )}
417 </SettingsSection>
418 ))}
419 </>
420 );
421}
apps/mobile/src/features/usage/UsageRouteScreen.tsx+211-106
......@@ -1,5 +1,10 @@
1import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
12import { useNavigation } from "@react-navigation/native";
2import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge";
3import {
4 isCompatibleUsageContractVersion,
5 type DailyTotals,
6 type MergedUsage,
7} from "@t3tools/shared/usageMerge";
38import {
49 enumerateDays,
510 enumerateHourStarts,
......@@ -11,8 +16,9 @@ import {
1116 formatUsd,
1217 makeWindow,
1318} from "@t3tools/shared/usageFormat";
14import { useMemo, useRef, useState } from "react";
19import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
1520import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
21import Animated, { Easing, FadeIn, LinearTransition, ReduceMotion } from "react-native-reanimated";
1622import { useSafeAreaInsets } from "react-native-safe-area-context";
1723
1824import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
......@@ -22,7 +28,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
2228import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
2329import { SettingsSection } from "../settings/components/SettingsSection";
2430import { UsageDailyChart } from "./UsageDailyChart";
25import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection";
31import { toggleUsageEnvironment } from "./usageEnvironmentSelection";
32import { useRefreshLimits } from "./UsageLimitsSection";
33import { UsageLimitsSection } from "./UsageLimitsPooled";
34import { ControlPillMenu } from "../../components/ControlPill";
35import { SymbolView } from "../../components/AppSymbol";
2636import type { UsageChartMetric } from "./usageChartData";
2737import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";
2838
......@@ -64,8 +74,13 @@ export function UsageRouteScreen() {
6474 const [metric, setMetric] = useState<UsageChartMetric>("cost");
6575 const { days: windowDays, window } = windowSelection;
6676 const isPast24Hours = windowDays === 1;
67 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
68 const limits = useRefreshLimits();
77 const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
78 useState<ReadonlySet<EnvironmentId> | null>(null);
79 const { merged, environments, selectedEnvironments, isPending, refresh } = useUsage(
80 window,
81 selectedEnvironmentIds,
82 );
83 const limits = useRefreshLimits(selectedEnvironmentIds);
6984
7085 const days = useMemo(
7186 () => enumerateDays(window.sinceDay, window.untilDay),
......@@ -119,18 +134,104 @@ export function UsageRouteScreen() {
119134 });
120135 };
121136
137 const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null;
138 const hasLoadingEnvironments = selectedEnvironments.some(isUsageLoading);
139 const filterAccessibilityLabel = hasLoadingEnvironments
140 ? "Filter usage environments, some environments are loading"
141 : "Filter usage environments";
142 const filterIcon =
143 selectedEnvironmentIds === null
144 ? "line.3.horizontal.decrease"
145 : "line.3.horizontal.decrease.circle.fill";
146 const environmentActions = useMemo(
147 () => [
148 {
149 id: "all",
150 title: "All environments",
151 subtitle: undefined,
152 state: selectedEnvironmentIds === null ? ("on" as const) : ("off" as const),
153 },
154 ...environments.map((environment) => ({
155 id: environment.environmentId,
156 title: environment.label,
157 subtitle: usageEnvironmentStatus(environment),
158 state:
159 selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId)
160 ? ("on" as const)
161 : ("off" as const),
162 })),
163 ],
164 [environments, selectedEnvironmentIds],
165 );
166 const selectEnvironment = useCallback(
167 (value: string) => {
168 if (value === "all") {
169 setSelectedEnvironmentIds(null);
170 return;
171 }
172 const id = EnvironmentId.make(value);
173 setSelectedEnvironmentIds((selected) => toggleUsageEnvironment(selected, environments, id));
174 },
175 [environments],
176 );
177 const environmentFilter = useMemo(
178 () =>
179 showEnvironmentFilter ? (
180 <ControlPillMenu
181 accessible
182 accessibilityRole="button"
183 accessibilityLabel={filterAccessibilityLabel}
184 title="Environments"
185 actions={environmentActions}
186 onPressAction={({ nativeEvent }) => selectEnvironment(nativeEvent.event)}
187 >
188 <Pressable
189 accessibilityRole="button"
190 accessibilityLabel={filterAccessibilityLabel}
191 className={cn(
192 "items-center justify-center rounded-full",
193 Platform.OS === "ios" ? "size-[28px]" : "size-[44px]",
194 )}
195 >
196 <SymbolView name={filterIcon} size={22} tintColorClassName="accent-icon" />
197 {hasLoadingEnvironments ? (
198 <View
199 pointerEvents="none"
200 className="absolute -right-[2px] -top-[2px] size-[9px] rounded-full bg-amber-500"
201 />
202 ) : null}
203 </Pressable>
204 </ControlPillMenu>
205 ) : null,
206 [
207 showEnvironmentFilter,
208 environmentActions,
209 selectEnvironment,
210 filterAccessibilityLabel,
211 filterIcon,
212 hasLoadingEnvironments,
213 ],
214 );
215
216 useLayoutEffect(() => {
217 if (Platform.OS === "ios") {
218 navigation.setOptions({ headerRight: () => environmentFilter });
219 }
220 }, [navigation, environmentFilter]);
221
122222 return (
123223 <View collapsable={false} className="flex-1 bg-sheet">
124224 {Platform.OS === "android" ? (
125225 <>
126226 <NativeStackScreenOptions options={{ headerShown: false }} />
127 <AndroidScreenHeader title="Usage" onBack={() => navigation.goBack()} />
227 <AndroidScreenHeader
228 title="Usage"
229 onBack={() => navigation.goBack()}
230 trailing={environmentFilter}
231 />
128232 </>
129233 ) : null}
130234 <ScrollView
131 // Remount at each tab's native top. Scrolling to y: 0 ignores iOS's
132 // automatic header inset and hides the tab bar under the header.
133 key={tab}
134235 contentInsetAdjustmentBehavior="automatic"
135236 showsVerticalScrollIndicator={false}
136237 className="flex-1"
......@@ -145,60 +246,73 @@ export function UsageRouteScreen() {
145246 >
146247 <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" />
147248
148 {showingLimits ? (
149 <UsageLimitsSection now={limits.now} failedLabels={limits.failedLabels} />
150 ) : (
151 <>
152 {/* Period and metric together: neither applies to Limits, and
153 both change every number below, so they share one bar. */}
154 <View className="flex-row items-center gap-3">
155 <SegmentedControl
156 options={WINDOW_OPTIONS}
157 selected={windowDays}
158 onSelect={selectWindow}
159 size="compact"
160 className="flex-1"
161 />
162 <SegmentedControl
163 options={METRIC_OPTIONS}
164 selected={metric}
165 onSelect={setMetric}
166 size="compact"
167 className="w-36"
168 />
169 </View>
170 <UsageCoverageNotice
171 environments={environments}
172 merged={merged}
173 isPartial={isPartial}
249 <Animated.View
250 key={tab}
251 entering={FadeIn.duration(160).reduceMotion(ReduceMotion.System)}
252 className="gap-6"
253 >
254 {showingLimits ? (
255 <UsageLimitsSection
256 now={limits.now}
257 failedLabels={limits.failedLabels}
258 selectedEnvironmentIds={selectedEnvironmentIds}
174259 />
175 {isPending ? (
176 <Text className="py-16 text-center text-base text-foreground-muted">
177 Scanning provider transcripts…
178 </Text>
179 ) : environments.length === 0 ? (
180 <Text className="py-16 text-center text-base text-foreground-muted">
181 Connect an environment to see usage.
182 </Text>
183 ) : (
184 <>
185 <ChartCard
186 merged={merged}
187 days={chartDays}
188 daily={chartTotals}
189 metric={metric}
190 sinceDay={window.sinceDay}
191 untilDay={window.untilDay}
192 isPast24Hours={isPast24Hours}
193 timeZone={window.timeZone}
260 ) : (
261 <>
262 {/* Period and metric together: neither applies to Limits, and
263 both change every number below, so they share one bar. */}
264 <View className="flex-row items-center gap-3">
265 <SegmentedControl
266 options={WINDOW_OPTIONS}
267 selected={windowDays}
268 onSelect={selectWindow}
269 size="compact"
270 className="flex-1"
194271 />
195 <ProviderSection merged={merged} metric={metric} />
196 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
197 <ModelsSection merged={merged} />
198 </>
199 )}
200 </>
201 )}
272 <SegmentedControl
273 options={METRIC_OPTIONS}
274 selected={metric}
275 onSelect={setMetric}
276 size="compact"
277 className="w-36"
278 />
279 </View>
280 {merged.duplicateSources.length > 0 ? (
281 <Text className="text-sm text-foreground-muted">
282 Counted once across environments sharing a transcript directory:{" "}
283 {merged.duplicateSources.join(", ")}
284 </Text>
285 ) : null}
286 {isPending ? (
287 <Text className="py-16 text-center text-base text-foreground-muted">
288 Scanning provider transcripts…
289 </Text>
290 ) : selectedEnvironments.length === 0 ? (
291 <Text className="py-16 text-center text-base text-foreground-muted">
292 {environments.length === 0
293 ? "Connect an environment to see usage."
294 : "Select an environment to see usage."}
295 </Text>
296 ) : (
297 <>
298 <ChartCard
299 merged={merged}
300 days={chartDays}
301 daily={chartTotals}
302 metric={metric}
303 sinceDay={window.sinceDay}
304 untilDay={window.untilDay}
305 isPast24Hours={isPast24Hours}
306 timeZone={window.timeZone}
307 />
308 <ProviderSection merged={merged} metric={metric} />
309 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
310 <ModelsSection merged={merged} />
311 </>
312 )}
313 </>
314 )}
315 </Animated.View>
202316 </ScrollView>
203317 </View>
204318 );
......@@ -221,25 +335,42 @@ function SegmentedControl<Value extends number | string>(props: {
221335 const compact = props.size === "compact";
222336 return (
223337 <View
224 accessibilityRole={props.role === "tab" ? "tablist" : undefined}
338 accessible={false}
225339 className={cn(
226340 "flex-row overflow-hidden rounded-full border-continuous bg-card",
227341 props.className,
228342 )}
229343 >
344 <Animated.View
345 pointerEvents="none"
346 layout={LinearTransition.duration(200)
347 .easing(Easing.out(Easing.cubic))
348 .reduceMotion(ReduceMotion.System)}
349 className="absolute bottom-0 top-0 rounded-full bg-subtle-strong"
350 style={{
351 width: `${100 / props.options.length}%`,
352 start: `${
353 (Math.max(
354 0,
355 props.options.findIndex((option) => option.value === props.selected),
356 ) *
357 100) /
358 props.options.length
359 }%`,
360 }}
361 />
230362 {props.options.map((option) => {
231363 const active = option.value === props.selected;
232364 return (
233365 <Pressable
234366 key={String(option.value)}
235 accessibilityRole={props.role ?? "button"}
236 accessibilityLabel={option.accessibilityLabel}
367 accessibilityRole={Platform.OS === "ios" ? "button" : (props.role ?? "button")}
368 accessibilityLabel={option.accessibilityLabel ?? option.label}
237369 accessibilityState={{ selected: active }}
238370 onPress={() => props.onSelect(option.value)}
239371 className={cn(
240372 "flex-1 items-center justify-center rounded-full",
241373 compact ? "h-9" : "h-11",
242 active && "bg-subtle-strong",
243374 )}
244375 >
245376 <Text
......@@ -491,48 +622,22 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {
491622 * one that failed, or one whose transcripts another environment already
492623 * reported.
493624 */
494function UsageCoverageNotice(props: {
495 readonly environments: readonly EnvironmentUsageStatus[];
496 readonly merged: MergedUsage;
497 readonly isPartial: boolean;
498}) {
499 const failed = props.environments.filter((environment) => environment.error !== null);
500 const stale = props.environments.filter((environment) =>
501 props.merged.staleEnvironments.includes(environment.environmentId),
502 );
503 const duplicateSources = props.merged.duplicateSources;
625function isUsageLoading(environment: EnvironmentUsageStatus) {
626 return environment.isPending || (environment.summary === null && environment.error === null);
627}
628
629function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
504630 if (
505 failed.length === 0 &&
506 stale.length === 0 &&
507 duplicateSources.length === 0 &&
508 !props.isPartial
631 environment.summary &&
632 !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION)
509633 ) {
510 return null;
634 return "Older server · excluded from usage totals";
511635 }
512
513 return (
514 <View className="gap-1 rounded-[16px] border-continuous bg-card px-4 py-3">
515 {props.isPartial ? (
516 <Text className="text-sm text-foreground-muted">
517 Some environments are still reporting. Totals are partial.
518 </Text>
519 ) : null}
520 {failed.map((environment) => (
521 <Text key={environment.environmentId} className="text-sm text-foreground-muted">
522 {environment.label} could not report usage.
523 </Text>
524 ))}
525 {stale.map((environment) => (
526 <Text key={environment.environmentId} className="text-sm text-foreground-muted">
527 {environment.label} runs an older server version and is excluded from totals.
528 </Text>
529 ))}
530 {duplicateSources.length > 0 ? (
531 <Text className="text-sm text-foreground-muted">
532 Counted once across environments sharing a transcript directory:{" "}
533 {duplicateSources.join(", ")}
534 </Text>
535 ) : null}
536 </View>
537 );
636 if (!environment.isConnected)
637 return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…";
638 if (environment.error)
639 return environment.summary ? "Usage unavailable · showing saved totals" : "Usage unavailable";
640 if (isUsageLoading(environment))
641 return environment.summary ? "Updating usage…" : "Loading usage…";
642 return "Usage up to date";
538643}
apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts created+40
......@@ -0,0 +1,40 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { toggleUsageEnvironment } from "./usageEnvironmentSelection";
5
6const a = EnvironmentId.make("a");
7const b = EnvironmentId.make("b");
8const c = EnvironmentId.make("c");
9const removed = EnvironmentId.make("removed");
10const environments = [a, b, c].map((environmentId) => ({ environmentId }));
11
12describe("usage environment selection", () => {
13 it("can exclude an environment from all, then select all again", () => {
14 const selected = toggleUsageEnvironment(null, environments, b);
15 expect(selected).toEqual(new Set([a, c]));
16 expect(toggleUsageEnvironment(selected, environments, b)).toBeNull();
17 });
18
19 it("can deselect the last environment", () => {
20 expect(toggleUsageEnvironment(new Set([a]), environments, a)).toEqual(new Set());
21 });
22
23 it("does not count removed IDs toward selecting all current environments", () => {
24 expect(toggleUsageEnvironment(new Set([a, removed]), environments, b)).toEqual(new Set([a, b]));
25 });
26
27 it("returns to all mode despite stale IDs when every current environment is selected", () => {
28 expect(toggleUsageEnvironment(new Set([a, c, removed]), environments, b)).toBeNull();
29 });
30
31 it("ignores a menu action for an environment that was removed", () => {
32 expect(toggleUsageEnvironment(new Set([a]), environments, removed)).toEqual(new Set([a]));
33 });
34
35 it("includes newly connected environments only in all mode", () => {
36 const expanded = [...environments, { environmentId: removed }];
37 expect(toggleUsageEnvironment(null, expanded, a)).toEqual(new Set([b, c, removed]));
38 expect(toggleUsageEnvironment(new Set([a, b, c]), expanded, a)).toEqual(new Set([b, c]));
39 });
40});
apps/mobile/src/features/usage/usageEnvironmentSelection.ts created+16
......@@ -0,0 +1,16 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3/** Null follows all environments, including ones connected after the menu opened. */
4export function toggleUsageEnvironment(
5 selected: ReadonlySet<EnvironmentId> | null,
6 environments: readonly { readonly environmentId: EnvironmentId }[],
7 toggledId: EnvironmentId,
8): ReadonlySet<EnvironmentId> | null {
9 const ids = environments.map(({ environmentId }) => environmentId);
10 const next = new Set(ids.filter((id) => selected === null || selected.has(id)));
11 if (ids.includes(toggledId)) {
12 if (next.has(toggledId)) next.delete(toggledId);
13 else next.add(toggledId);
14 }
15 return ids.every((id) => next.has(id)) ? null : next;
16}
apps/mobile/src/state/usage.ts+24-8
......@@ -30,6 +30,7 @@ export interface EnvironmentUsageStatus {
3030 readonly environmentId: EnvironmentId;
3131 readonly label: string;
3232 readonly isPending: boolean;
33 readonly isConnected: boolean;
3334 readonly error: string | null;
3435 readonly summary: UsageSummary | null;
3536}
......@@ -53,6 +54,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
5354 environmentId,
5455 label: presentation.entry.target.label,
5556 isPending: result.waiting,
57 isConnected: presentation.connection.phase === "connected",
5658 error: result._tag === "Failure" ? "This environment could not report usage." : null,
5759 summary: Option.getOrNull(AsyncResult.value(result)),
5860 });
......@@ -64,18 +66,22 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
6466export interface UsageView {
6567 readonly merged: MergedUsage;
6668 readonly environments: readonly EnvironmentUsageStatus[];
69 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
6770 /** True until at least one environment has answered. */
6871 readonly isPending: boolean;
6972 /**
7073 * True while environments that have not failed are still answering. Failed
71 * environments are reported through their own error rows: totals will not
74 * environments are reported in the environment menu: totals will not
7275 * improve by waiting on them, so they must not read as "still reporting".
7376 */
7477 readonly isPartial: boolean;
7578 readonly refresh: (input?: UsageSummaryInput) => Promise<void>;
7679}
7780
78export function useUsage(input: UsageSummaryInput): UsageView {
81export function useUsage(
82 input: UsageSummaryInput,
83 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
84): UsageView {
7985 const windowKey = useMemo(
8086 () =>
8187 JSON.stringify({
......@@ -97,6 +103,13 @@ export function useUsage(input: UsageSummaryInput): UsageView {
97103 );
98104 const atom = usageByWindowAtom(windowKey);
99105 const environments = useAtomValue(atom);
106 const selectedEnvironments = useMemo(
107 () =>
108 selectedEnvironmentIds === null
109 ? environments
110 : environments.filter(({ environmentId }) => selectedEnvironmentIds.has(environmentId)),
111 [environments, selectedEnvironmentIds],
112 );
100113
101114 const refresh = useCallback(
102115 (nextInput?: UsageSummaryInput) =>
......@@ -104,14 +117,14 @@ export function useUsage(input: UsageSummaryInput): UsageView {
104117 registry: appAtomRegistry,
105118 server: serverEnvironment,
106119 presentations: environmentPresentations,
107 environmentIds: environments.map(({ environmentId }) => environmentId),
120 environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId),
108121 input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),
109122 }),
110 [environments, windowKey],
123 [selectedEnvironments, windowKey],
111124 );
112125
113126 const merged = useMemo(() => {
114 const answered: EnvironmentUsage[] = environments.flatMap((environment) =>
127 const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) =>
115128 environment.summary === null
116129 ? []
117130 : [
......@@ -123,16 +136,19 @@ export function useUsage(input: UsageSummaryInput): UsageView {
123136 ],
124137 );
125138 return mergeUsage(answered, USAGE_CONTRACT_VERSION);
126 }, [environments]);
139 }, [selectedEnvironments]);
127140
128 const answeredCount = environments.filter((environment) => environment.summary !== null).length;
129 const stillReporting = environments.filter(
141 const answeredCount = selectedEnvironments.filter(
142 (environment) => environment.summary !== null,
143 ).length;
144 const stillReporting = selectedEnvironments.filter(
130145 (environment) => environment.summary === null && environment.error === null,
131146 ).length;
132147
133148 return {
134149 merged,
135150 environments,
151 selectedEnvironments,
136152 isPending: answeredCount === 0 && stillReporting > 0,
137153 isPartial: answeredCount > 0 && stillReporting > 0,
138154 refresh,
docs/user/usage.md+4-4
......@@ -39,14 +39,14 @@ the dialog.
3939
4040## Track subscription limits
4141
42On web and desktop, **Usage → Limits** pools every subscription account it can see per provider, so with several Codex
42**Usage → Limits** pools every subscription account it can see per provider, so with several Codex
4343or Claude accounts across your environments and hubs you read one number per window rather than a
4444list. Each window card shows how much of the pool is left and a bar with one segment per account,
4545ordered by which resets soonest; when the provider reports reset times, the card also says when
4646the next reset lands and how much it hands back. The hatched
47part of a segment is what that reset restores. Tap or hover a segment for the account's plan, where it is
48signed in, and its reset time; Codex accounts with banked reset credits show a ticket count on the
49segment and the **Use reset** action in that popover. On narrow screens, numbered rows below
47part of a segment is what that reset restores. Tap a segment or account row for the account's plan,
48where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked
49reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below
5050the bar show each account's quota, countdown, and credits. Tap a row to open its details.
5151
5252The same account signed in on more than one environment, or reported by a hub as well, counts once.
packages/shared/src/usageLimits.ts+6-16
......@@ -209,7 +209,7 @@ export function collectLimitAccounts(
209209 // the freshest native snapshot supplies both, or neither.
210210 const native = [previous, next]
211211 .filter((candidate) => candidate.redeem !== null)
212 .toSorted((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0];
212 .sort((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0];
213213 accounts.set(key, {
214214 ...previous,
215215 displayName: previous.displayName ?? next.displayName,
......@@ -374,7 +374,7 @@ export function collectLimitPools(
374374 else byDriver.set(account.driver, [account]);
375375 }
376376 return [...byDriver].map(([driver, members]) => {
377 const sorted = members.toSorted(
377 const sorted = [...members].sort(
378378 (left, right) =>
379379 Number(left.redeem === null) - Number(right.redeem === null) ||
380380 accountSortName(left).localeCompare(accountSortName(right)),
......@@ -398,7 +398,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
398398 }
399399 }
400400 const pools = [...byKey.values()].map((unordered): LimitPoolWindow => {
401 const members = unordered.toSorted(
401 const members = [...unordered].sort(
402402 (left, right) =>
403403 (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) -
404404 (resetMillis(right.window) ?? Number.POSITIVE_INFINITY),
......@@ -428,7 +428,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
428428 },
429429 ];
430430 })
431 .toSorted((left, right) => left.at - right.at);
431 .sort((left, right) => left.at - right.at);
432432 return {
433433 id: first.id,
434434 kind: first.kind,
......@@ -440,17 +440,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
440440 resets,
441441 };
442442 });
443 return pools.toSorted(
444 (left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind],
445 );
446}
447
448/** The instance's configured name, else the driver's, else its raw kind. */
449export function providerLimitsLabel(
450 provider: Pick<ServerProvider, "driver" | "displayName">,
451 driverLabel: (driver: ServerProvider["driver"]) => string | undefined,
452): string {
453 return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver);
443 return pools.sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind]);
454444}
455445
456446/** The one-line status under a provider heading when there are no bars to draw. */
......@@ -627,7 +617,7 @@ export function collectProviderUsageLimits(
627617 accounts.push({
628618 id: provider.instanceId,
629619 driver: provider.driver,
630 label: `${providerLimitsLabel(provider, () => undefined)} [${provider.instanceId}]`,
620 label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`,
631621 ...(provider.auth.label ? { plan: provider.auth.label } : {}),
632622 instanceId: provider.instanceId,
633623 ...(provider.displayName ? { displayName: provider.displayName } : {}),