History

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

Julius Marminge committed 2 months ago

Commit b155c211 parent e5d086c11 files · +716 −276Browse files

Unified · Split

11 files
  1. apps/mobile/src/Stack.tsx +5 −0
  2. apps/mobile/src/components/AppSymbol.tsx +3 −0
  3. apps/mobile/src/components/ControlPill.tsx +13 −5
  4. apps/mobile/src/features/usage/UsageLimitsPooled.tsx +374 −0
  5. apps/mobile/src/features/usage/UsageLimitsSection.tsx +20 −137
  6. apps/mobile/src/features/usage/UsageRouteScreen.tsx +211 −106
  7. apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts +40 −0
  8. apps/mobile/src/features/usage/usageEnvironmentSelection.ts +16 −0
  9. apps/mobile/src/state/usage.ts +24 −8
  10. docs/user/usage.md +4 −4
  11. packages/shared/src/usageLimits.ts +6 −16
apps/mobile/src/Stack.tsx+5−0
@@ -56,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc · 55 unmodified lines
5656 import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
5757 import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
5858 import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
59+import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled";
5960 import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
6061 import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
6162 import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
@@ -192,6 +193,10 @@ const SettingsContentStack = createNativeStackNavigator({ · 130 unmodified lines
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−0
@@ -31,6 +31,7 @@ import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; · 30 unmodified lines
3131 import IconChevronUp from "@tabler/icons-react-native/IconChevronUp";
3232 import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck";
3333 import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled";
34+import IconTicket from "@tabler/icons-react-native/IconTicket";
3435 import IconClock from "@tabler/icons-react-native/IconClock";
3536 import IconCode from "@tabler/icons-react-native/IconCode";
3637 import IconCopy from "@tabler/icons-react-native/IconCopy";
@@ -116,6 +117,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { · 79 unmodified lines
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>> = { · 16 unmodified lines
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 { · 8 unmodified lines
99 useMemo,
1010 useRef,
1111 } from "react";
12−import { Platform, Pressable, View, type ColorValue, type PressableProps } from "react-native";
12+import {
13+ Platform,
14+ Pressable,
15+ View,
16+ type ColorValue,
17+ type PressableProps,
18+ type AccessibilityProps,
19+} from "react-native";
1320 import { withUniwind } from "uniwind";
1421 import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1522
@@ -144,10 +151,11 @@ export function ControlPill(props: { · 128 unmodified lines
144151 // AppCompat popup can't be themed past its stock animation, metrics, and
145152 // submenu chrome.
146153 export 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+374−0
@@ -0,0 +1,374 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3+import { EnvironmentId } from "@t3tools/contracts";
4+import {
5+ collectLimitAccounts,
6+ collectLimitNotices,
7+ collectLimitPools,
8+ formatDuration,
9+ formatResetsIn,
10+ remainingPercent,
11+ type LimitAccount,
12+ type LimitPoolWindow,
13+} from "@t3tools/shared/usageLimits";
14+import { useId, useState } from "react";
15+import { Platform, Pressable, ScrollView, View } from "react-native";
16+import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";
17+import { useSafeAreaInsets } from "react-native-safe-area-context";
18+
19+import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
20+import { SymbolView } from "../../components/AppSymbol";
21+import { AppText as Text } from "../../components/AppText";
22+import { ProviderIcon } from "../../components/ProviderIcon";
23+import { NativeStackScreenOptions } from "../../native/StackHeader";
24+import { environmentPresentations } from "../../state/presentation";
25+import { ResetCredits } from "./UsageLimitsSection";
26+import { useProviderColors } from "./usageProviders";
27+
28+const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };
29+const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const;
30+
31+function 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. */
39+function 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+
69+function 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+
192+export 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+
251+type 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. */
260+export 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 { · 5 unmodified lines
66 ServerProvider,
77 ServerProviderResetCredits,
88 ServerProviderUsageWindow,
9− UsageLimitSourceAccount,
109 UsageProviderKind,
1110 } from "@t3tools/contracts";
1211 import {
13− collectLimitSources,
14− collectLimitsGroups,
1512 elapsedShare,
1613 formatDuration,
1714 formatResetsIn,
1815 limitsNotice,
1916 paceOf,
20− providerLimitsLabel,
2117 remainingPercent,
2218 } from "@t3tools/shared/usageLimits";
2319 import { type ReactNode, useState } from "react";
@@ -28,11 +24,9 @@ import { ProviderIcon } from "../../components/ProviderIcon"; · 4 unmodified lines
2824 import { environmentPresentations } from "../../state/presentation";
2925 import { serverEnvironment } from "../../state/server";
3026 import { useAtomCommand } from "../../state/use-atom-command";
31−import { SettingsSection } from "../settings/components/SettingsSection";
3227 import { useProviderColors } from "./usageProviders";
3328
3429 const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;
35−const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };
3630
3731 type Driver = ServerProvider["driver"];
3832
@@ -186,7 +180,7 @@ export function ResetCredits(props: { · 147 unmodified lines
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: { · 44 unmodified lines
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: { · 12 unmodified lines
256250 );
257251 }
258252
259−function 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. */
291−function 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: { · 2 unmodified lines
315258 * Environments whose probe failed are named, since their rows keep showing
316259 * the previous quota with nothing else to say so.
317260 */
318−export function useRefreshLimits() {
261+export 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− */
354−export 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 @@
1+import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
12 import { useNavigation } from "@react-navigation/native";
2−import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge";
3+import {
4+ isCompatibleUsageContractVersion,
5+ type DailyTotals,
6+ type MergedUsage,
7+} from "@t3tools/shared/usageMerge";
38 import {
49 enumerateDays,
510 enumerateHourStarts,
@@ -11,8 +16,9 @@ import { · 5 unmodified lines
1116 formatUsd,
1217 makeWindow,
1318 } from "@t3tools/shared/usageFormat";
14−import { useMemo, useRef, useState } from "react";
19+import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
1520 import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
21+import Animated, { Easing, FadeIn, LinearTransition, ReduceMotion } from "react-native-reanimated";
1622 import { useSafeAreaInsets } from "react-native-safe-area-context";
1723
1824 import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
@@ -22,7 +28,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; · 3 unmodified lines
2228 import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
2329 import { SettingsSection } from "../settings/components/SettingsSection";
2430 import { UsageDailyChart } from "./UsageDailyChart";
25−import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection";
31+import { toggleUsageEnvironment } from "./usageEnvironmentSelection";
32+import { useRefreshLimits } from "./UsageLimitsSection";
33+import { UsageLimitsSection } from "./UsageLimitsPooled";
34+import { ControlPillMenu } from "../../components/ControlPill";
35+import { SymbolView } from "../../components/AppSymbol";
2636 import type { UsageChartMetric } from "./usageChartData";
2737 import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";
2838
@@ -64,8 +74,13 @@ export function UsageRouteScreen() { · 35 unmodified lines
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() { · 47 unmodified lines
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() { · 8 unmodified lines
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: { · 16 unmodified lines
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 }) { · 245 unmodified lines
491622 * one that failed, or one whose transcripts another environment already
492623 * reported.
493624 */
494−function 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;
625+function isUsageLoading(environment: EnvironmentUsageStatus) {
626+ return environment.isPending || (environment.summary === null && environment.error === null);
627+}
628+
629+function 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+40−0
@@ -0,0 +1,40 @@
1+import { EnvironmentId } from "@t3tools/contracts";
2+import { describe, expect, it } from "vite-plus/test";
3+
4+import { toggleUsageEnvironment } from "./usageEnvironmentSelection";
5+
6+const a = EnvironmentId.make("a");
7+const b = EnvironmentId.make("b");
8+const c = EnvironmentId.make("c");
9+const removed = EnvironmentId.make("removed");
10+const environments = [a, b, c].map((environmentId) => ({ environmentId }));
11+
12+describe("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+16−0
@@ -0,0 +1,16 @@
1+import type { EnvironmentId } from "@t3tools/contracts";
2+
3+/** Null follows all environments, including ones connected after the menu opened. */
4+export 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 { · 29 unmodified lines
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) => · 17 unmodified lines
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) => · 5 unmodified lines
6466 export 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
78−export function useUsage(input: UsageSummaryInput): UsageView {
81+export 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 { · 15 unmodified lines
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 { · 1 unmodified line
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 { · 5 unmodified lines
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. · 38 unmodified lines
3939
4040 ## Track subscription limits
4141
42−On 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
4343 or Claude accounts across your environments and hubs you read one number per window rather than a
4444 list. Each window card shows how much of the pool is left and a bar with one segment per account,
4545 ordered by which resets soonest; when the provider reports reset times, the card also says when
4646 the next reset lands and how much it hands back. The hatched
47−part of a segment is what that reset restores. Tap or hover a segment for the account's plan, where it is
48−signed in, and its reset time; Codex accounts with banked reset credits show a ticket count on the
49−segment and the **Use reset** action in that popover. On narrow screens, numbered rows below
47+part of a segment is what that reset restores. Tap a segment or account row for the account's plan,
48+where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked
49+reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below
5050 the bar show each account's quota, countdown, and credits. Tap a row to open its details.
5151
5252 The 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( · 208 unmodified lines
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( · 158 unmodified lines
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 · 17 unmodified lines
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 · 23 unmodified lines
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 · 5 unmodified lines
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. */
449−export 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( · 170 unmodified lines
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 } : {}),