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...@@ -56,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
56import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";56import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
57import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";57import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
58import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";58import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
59import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled";
59import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";60import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
60import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";61import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
61import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";62import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
...@@ -192,6 +193,10 @@ const SettingsContentStack = createNativeStackNavigator({...@@ -192,6 +193,10 @@ const SettingsContentStack = createNativeStackNavigator({
192 title: "Client Storage",193 title: "Client Storage",
193 },194 },
194 }),195 }),
196 SettingsUsageAccount: createNativeStackScreen({
197 screen: UsageLimitAccountScreen,
198 options: { title: "Account" },
199 }),
195 SettingsUsage: createNativeStackScreen({200 SettingsUsage: createNativeStackScreen({
196 screen: UsageRouteScreen,201 screen: UsageRouteScreen,
197 linking: "usage",202 linking: "usage",
apps/mobile/src/components/AppSymbol.tsx+3
...@@ -31,6 +31,7 @@ import IconChevronRight from "@tabler/icons-react-native/IconChevronRight";...@@ -31,6 +31,7 @@ import IconChevronRight from "@tabler/icons-react-native/IconChevronRight";
31import IconChevronUp from "@tabler/icons-react-native/IconChevronUp";31import IconChevronUp from "@tabler/icons-react-native/IconChevronUp";
32import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck";32import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck";
33import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled";33import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled";
34import IconTicket from "@tabler/icons-react-native/IconTicket";
34import IconClock from "@tabler/icons-react-native/IconClock";35import IconClock from "@tabler/icons-react-native/IconClock";
35import IconCode from "@tabler/icons-react-native/IconCode";36import IconCode from "@tabler/icons-react-native/IconCode";
36import IconCopy from "@tabler/icons-react-native/IconCopy";37import IconCopy from "@tabler/icons-react-native/IconCopy";
...@@ -116,6 +117,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -116,6 +117,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
116 checkmark: IconCheck,117 checkmark: IconCheck,
117 "checkmark.circle": IconCircleCheck,118 "checkmark.circle": IconCircleCheck,
118 clock: IconClock,119 clock: IconClock,
120 ticket: IconTicket,
119 cloud: IconCloud,121 cloud: IconCloud,
120 cube: IconBox,122 cube: IconBox,
121 "chevron.down": IconChevronDown,123 "chevron.down": IconChevronDown,
...@@ -138,6 +140,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -138,6 +140,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
138 "info.circle": IconInfoCircle,140 "info.circle": IconInfoCircle,
139 laptopcomputer: IconDeviceLaptop,141 laptopcomputer: IconDeviceLaptop,
140 link: IconLink,142 link: IconLink,
143 "line.3.horizontal.decrease": IconFilter,
141 "line.3.horizontal.decrease.circle": IconFilter,144 "line.3.horizontal.decrease.circle": IconFilter,
142 "line.3.horizontal.decrease.circle.fill": IconFilterFilled,145 "line.3.horizontal.decrease.circle.fill": IconFilterFilled,
143 // Tabler has no Apple desktops; the closest silhouettes stand in on Android.146 // 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 {...@@ -9,7 +9,14 @@ import {
9 useMemo,9 useMemo,
10 useRef,10 useRef,
11} from "react";11} 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";
13import { withUniwind } from "uniwind";20import { withUniwind } from "uniwind";
14import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";21import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1522
...@@ -144,10 +151,11 @@ export function ControlPill(props: {...@@ -144,10 +151,11 @@ export function ControlPill(props: {
144// AppCompat popup can't be themed past its stock animation, metrics, and151// AppCompat popup can't be themed past its stock animation, metrics, and
145// submenu chrome.152// submenu chrome.
146export function ControlPillMenu(153export function ControlPillMenu(
147 props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & {154 props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> &
148 readonly children: ReactNode;155 Pick<AccessibilityProps, "accessible" | "accessibilityLabel" | "accessibilityRole"> & {
149 readonly className?: string;156 readonly children: ReactNode;
150 },157 readonly className?: string;
158 },
151) {159) {
152 const { themeAppearance } = useAppearancePreferences();160 const { themeAppearance } = useAppearancePreferences();
153 const isDarkMode = themeAppearance === "dark";161 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 {...@@ -6,18 +6,14 @@ import type {
6 ServerProvider,6 ServerProvider,
7 ServerProviderResetCredits,7 ServerProviderResetCredits,
8 ServerProviderUsageWindow,8 ServerProviderUsageWindow,
9 UsageLimitSourceAccount,
10 UsageProviderKind,9 UsageProviderKind,
11} from "@t3tools/contracts";10} from "@t3tools/contracts";
12import {11import {
13 collectLimitSources,
14 collectLimitsGroups,
15 elapsedShare,12 elapsedShare,
16 formatDuration,13 formatDuration,
17 formatResetsIn,14 formatResetsIn,
18 limitsNotice,15 limitsNotice,
19 paceOf,16 paceOf,
20 providerLimitsLabel,
21 remainingPercent,17 remainingPercent,
22} from "@t3tools/shared/usageLimits";18} from "@t3tools/shared/usageLimits";
23import { type ReactNode, useState } from "react";19import { type ReactNode, useState } from "react";
...@@ -28,11 +24,9 @@ import { ProviderIcon } from "../../components/ProviderIcon";...@@ -28,11 +24,9 @@ import { ProviderIcon } from "../../components/ProviderIcon";
28import { environmentPresentations } from "../../state/presentation";24import { environmentPresentations } from "../../state/presentation";
29import { serverEnvironment } from "../../state/server";25import { serverEnvironment } from "../../state/server";
30import { useAtomCommand } from "../../state/use-atom-command";26import { useAtomCommand } from "../../state/use-atom-command";
31import { SettingsSection } from "../settings/components/SettingsSection";
32import { useProviderColors } from "./usageProviders";27import { useProviderColors } from "./usageProviders";
3328
34const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;29const 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
37type Driver = ServerProvider["driver"];31type Driver = ServerProvider["driver"];
3832
...@@ -186,7 +180,7 @@ export function ResetCredits(props: {...@@ -186,7 +180,7 @@ export function ResetCredits(props: {
186 });180 });
187 const [busy, setBusy] = useState(false);181 const [busy, setBusy] = useState(false);
188 const [status, setStatus] = useState<string | null>(null);182 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
191 const expiresIn = credits.nextExpiresAt185 const expiresIn = credits.nextExpiresAt
192 ? formatDuration(Date.parse(credits.nextExpiresAt) - now)186 ? formatDuration(Date.parse(credits.nextExpiresAt) - now)
...@@ -237,7 +231,7 @@ export function ResetCredits(props: {...@@ -237,7 +231,7 @@ export function ResetCredits(props: {
237 className={231 className={
238 dense232 dense
239 ? "rounded-full bg-subtle-strong px-2.5 py-1"233 ? "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"
241 }235 }
242 >236 >
243 <Text237 <Text
...@@ -256,57 +250,6 @@ export function ResetCredits(props: {...@@ -256,57 +250,6 @@ export function ResetCredits(props: {
256 );250 );
257}251}
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
310/**253/**
311 * Re-probes every provider (and usage-limit source) on each connected254 * Re-probes every provider (and usage-limit source) on each connected
312 * environment; the fresh snapshots then arrive over the config stream.255 * environment; the fresh snapshots then arrive over the config stream.
...@@ -315,107 +258,47 @@ function SourceAccountLimits(props: {...@@ -315,107 +258,47 @@ function SourceAccountLimits(props: {
315 * Environments whose probe failed are named, since their rows keep showing258 * Environments whose probe failed are named, since their rows keep showing
316 * the previous quota with nothing else to say so.259 * the previous quota with nothing else to say so.
317 */260 */
318export function useRefreshLimits() {261export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null) {
319 const presentations = useAtomValue(environmentPresentations.presentationsAtom);262 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
320 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {263 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
321 reportFailure: false,264 reportFailure: false,
322 });265 });
323 const [now, setNow] = useState(() => Date.now());266 const [now, setNow] = useState(() => Date.now());
324 const [refreshing, setRefreshing] = useState(false);267 const [refreshing, setRefreshing] = useState(false);
325 const [failedLabels, setFailedLabels] = useState<readonly string[]>([]);268 const [failedEnvironments, setFailedEnvironments] = useState<
269 readonly { environmentId: EnvironmentId; label: string }[]
270 >([]);
326 // Always toggles `refreshing`, even with nothing to probe: Android's271 // Always toggles `refreshing`, even with nothing to probe: Android's
327 // RefreshControl keeps its spinner up until it sees true then false.272 // RefreshControl keeps its spinner up until it sees true then false.
328 const refresh = async () => {273 const refresh = async () => {
329 const connected = [...presentations].filter(274 const connected = [...presentations].filter(
330 ([, presentation]) => presentation.connection.phase === "connected",275 ([environmentId, presentation]) =>
276 presentation.connection.phase === "connected" &&
277 (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)),
331 );278 );
332 setRefreshing(true);279 setRefreshing(true);
333 try {280 try {
334 const results = await Promise.all(281 const results = await Promise.all(
335 connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })),282 connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })),
336 );283 );
337 setFailedLabels(284 setFailedEnvironments(
338 connected285 connected
339 .filter((_, index) => results[index]?._tag === "Failure")286 .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 })),
341 );291 );
342 } finally {292 } finally {
343 setNow(Date.now());293 setNow(Date.now());
344 setRefreshing(false);294 setRefreshing(false);
345 }295 }
346 };296 };
297 const failedLabels = failedEnvironments
298 .filter(
299 ({ environmentId }) =>
300 selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId),
301 )
302 .map(({ label }) => label);
347 return { now, refreshing, failedLabels, refresh };303 return { now, refreshing, failedLabels, refresh };
348}304}
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 @@...@@ -1,5 +1,10 @@
1import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
1import { useNavigation } from "@react-navigation/native";2import { 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";
3import {8import {
4 enumerateDays,9 enumerateDays,
5 enumerateHourStarts,10 enumerateHourStarts,
...@@ -11,8 +16,9 @@ import {...@@ -11,8 +16,9 @@ import {
11 formatUsd,16 formatUsd,
12 makeWindow,17 makeWindow,
13} from "@t3tools/shared/usageFormat";18} from "@t3tools/shared/usageFormat";
14import { useMemo, useRef, useState } from "react";19import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
15import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";20import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
21import Animated, { Easing, FadeIn, LinearTransition, ReduceMotion } from "react-native-reanimated";
16import { useSafeAreaInsets } from "react-native-safe-area-context";22import { useSafeAreaInsets } from "react-native-safe-area-context";
1723
18import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";24import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
...@@ -22,7 +28,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";...@@ -22,7 +28,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
22import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";28import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
23import { SettingsSection } from "../settings/components/SettingsSection";29import { SettingsSection } from "../settings/components/SettingsSection";
24import { UsageDailyChart } from "./UsageDailyChart";30import { 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";
26import type { UsageChartMetric } from "./usageChartData";36import type { UsageChartMetric } from "./usageChartData";
27import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";37import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";
2838
...@@ -64,8 +74,13 @@ export function UsageRouteScreen() {...@@ -64,8 +74,13 @@ export function UsageRouteScreen() {
64 const [metric, setMetric] = useState<UsageChartMetric>("cost");74 const [metric, setMetric] = useState<UsageChartMetric>("cost");
65 const { days: windowDays, window } = windowSelection;75 const { days: windowDays, window } = windowSelection;
66 const isPast24Hours = windowDays === 1;76 const isPast24Hours = windowDays === 1;
67 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);77 const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
68 const limits = useRefreshLimits();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
70 const days = useMemo(85 const days = useMemo(
71 () => enumerateDays(window.sinceDay, window.untilDay),86 () => enumerateDays(window.sinceDay, window.untilDay),
...@@ -119,18 +134,104 @@ export function UsageRouteScreen() {...@@ -119,18 +134,104 @@ export function UsageRouteScreen() {
119 });134 });
120 };135 };
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
122 return (222 return (
123 <View collapsable={false} className="flex-1 bg-sheet">223 <View collapsable={false} className="flex-1 bg-sheet">
124 {Platform.OS === "android" ? (224 {Platform.OS === "android" ? (
125 <>225 <>
126 <NativeStackScreenOptions options={{ headerShown: false }} />226 <NativeStackScreenOptions options={{ headerShown: false }} />
127 <AndroidScreenHeader title="Usage" onBack={() => navigation.goBack()} />227 <AndroidScreenHeader
228 title="Usage"
229 onBack={() => navigation.goBack()}
230 trailing={environmentFilter}
231 />
128 </>232 </>
129 ) : null}233 ) : null}
130 <ScrollView234 <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}
134 contentInsetAdjustmentBehavior="automatic"235 contentInsetAdjustmentBehavior="automatic"
135 showsVerticalScrollIndicator={false}236 showsVerticalScrollIndicator={false}
136 className="flex-1"237 className="flex-1"
...@@ -145,60 +246,73 @@ export function UsageRouteScreen() {...@@ -145,60 +246,73 @@ export function UsageRouteScreen() {
145 >246 >
146 <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" />247 <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" />
147248
148 {showingLimits ? (249 <Animated.View
149 <UsageLimitsSection now={limits.now} failedLabels={limits.failedLabels} />250 key={tab}
150 ) : (251 entering={FadeIn.duration(160).reduceMotion(ReduceMotion.System)}
151 <>252 className="gap-6"
152 {/* Period and metric together: neither applies to Limits, and253 >
153 both change every number below, so they share one bar. */}254 {showingLimits ? (
154 <View className="flex-row items-center gap-3">255 <UsageLimitsSection
155 <SegmentedControl256 now={limits.now}
156 options={WINDOW_OPTIONS}257 failedLabels={limits.failedLabels}
157 selected={windowDays}258 selectedEnvironmentIds={selectedEnvironmentIds}
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}
174 />259 />
175 {isPending ? (260 ) : (
176 <Text className="py-16 text-center text-base text-foreground-muted">261 <>
177 Scanning provider transcripts…262 {/* Period and metric together: neither applies to Limits, and
178 </Text>263 both change every number below, so they share one bar. */}
179 ) : environments.length === 0 ? (264 <View className="flex-row items-center gap-3">
180 <Text className="py-16 text-center text-base text-foreground-muted">265 <SegmentedControl
181 Connect an environment to see usage.266 options={WINDOW_OPTIONS}
182 </Text>267 selected={windowDays}
183 ) : (268 onSelect={selectWindow}
184 <>269 size="compact"
185 <ChartCard270 className="flex-1"
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}
194 />271 />
195 <ProviderSection merged={merged} metric={metric} />272 <SegmentedControl
196 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />273 options={METRIC_OPTIONS}
197 <ModelsSection merged={merged} />274 selected={metric}
198 </>275 onSelect={setMetric}
199 )}276 size="compact"
200 </>277 className="w-36"
201 )}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>
202 </ScrollView>316 </ScrollView>
203 </View>317 </View>
204 );318 );
...@@ -221,25 +335,42 @@ function SegmentedControl<Value extends number | string>(props: {...@@ -221,25 +335,42 @@ function SegmentedControl<Value extends number | string>(props: {
221 const compact = props.size === "compact";335 const compact = props.size === "compact";
222 return (336 return (
223 <View337 <View
224 accessibilityRole={props.role === "tab" ? "tablist" : undefined}338 accessible={false}
225 className={cn(339 className={cn(
226 "flex-row overflow-hidden rounded-full border-continuous bg-card",340 "flex-row overflow-hidden rounded-full border-continuous bg-card",
227 props.className,341 props.className,
228 )}342 )}
229 >343 >
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 />
230 {props.options.map((option) => {362 {props.options.map((option) => {
231 const active = option.value === props.selected;363 const active = option.value === props.selected;
232 return (364 return (
233 <Pressable365 <Pressable
234 key={String(option.value)}366 key={String(option.value)}
235 accessibilityRole={props.role ?? "button"}367 accessibilityRole={Platform.OS === "ios" ? "button" : (props.role ?? "button")}
236 accessibilityLabel={option.accessibilityLabel}368 accessibilityLabel={option.accessibilityLabel ?? option.label}
237 accessibilityState={{ selected: active }}369 accessibilityState={{ selected: active }}
238 onPress={() => props.onSelect(option.value)}370 onPress={() => props.onSelect(option.value)}
239 className={cn(371 className={cn(
240 "flex-1 items-center justify-center rounded-full",372 "flex-1 items-center justify-center rounded-full",
241 compact ? "h-9" : "h-11",373 compact ? "h-9" : "h-11",
242 active && "bg-subtle-strong",
243 )}374 )}
244 >375 >
245 <Text376 <Text
...@@ -491,48 +622,22 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {...@@ -491,48 +622,22 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {
491 * one that failed, or one whose transcripts another environment already622 * one that failed, or one whose transcripts another environment already
492 * reported.623 * reported.
493 */624 */
494function UsageCoverageNotice(props: {625function isUsageLoading(environment: EnvironmentUsageStatus) {
495 readonly environments: readonly EnvironmentUsageStatus[];626 return environment.isPending || (environment.summary === null && environment.error === null);
496 readonly merged: MergedUsage;627}
497 readonly isPartial: boolean;628
498}) {629function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
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;
504 if (630 if (
505 failed.length === 0 &&631 environment.summary &&
506 stale.length === 0 &&632 !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION)
507 duplicateSources.length === 0 &&
508 !props.isPartial
509 ) {633 ) {
510 return null;634 return "Older server · excluded from usage totals";
511 }635 }
512636 if (!environment.isConnected)
513 return (637 return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…";
514 <View className="gap-1 rounded-[16px] border-continuous bg-card px-4 py-3">638 if (environment.error)
515 {props.isPartial ? (639 return environment.summary ? "Usage unavailable · showing saved totals" : "Usage unavailable";
516 <Text className="text-sm text-foreground-muted">640 if (isUsageLoading(environment))
517 Some environments are still reporting. Totals are partial.641 return environment.summary ? "Updating usage…" : "Loading usage…";
518 </Text>642 return "Usage up to date";
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 );
538}643}
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 {...@@ -30,6 +30,7 @@ export interface EnvironmentUsageStatus {
30 readonly environmentId: EnvironmentId;30 readonly environmentId: EnvironmentId;
31 readonly label: string;31 readonly label: string;
32 readonly isPending: boolean;32 readonly isPending: boolean;
33 readonly isConnected: boolean;
33 readonly error: string | null;34 readonly error: string | null;
34 readonly summary: UsageSummary | null;35 readonly summary: UsageSummary | null;
35}36}
...@@ -53,6 +54,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>...@@ -53,6 +54,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
53 environmentId,54 environmentId,
54 label: presentation.entry.target.label,55 label: presentation.entry.target.label,
55 isPending: result.waiting,56 isPending: result.waiting,
57 isConnected: presentation.connection.phase === "connected",
56 error: result._tag === "Failure" ? "This environment could not report usage." : null,58 error: result._tag === "Failure" ? "This environment could not report usage." : null,
57 summary: Option.getOrNull(AsyncResult.value(result)),59 summary: Option.getOrNull(AsyncResult.value(result)),
58 });60 });
...@@ -64,18 +66,22 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>...@@ -64,18 +66,22 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
64export interface UsageView {66export interface UsageView {
65 readonly merged: MergedUsage;67 readonly merged: MergedUsage;
66 readonly environments: readonly EnvironmentUsageStatus[];68 readonly environments: readonly EnvironmentUsageStatus[];
69 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
67 /** True until at least one environment has answered. */70 /** True until at least one environment has answered. */
68 readonly isPending: boolean;71 readonly isPending: boolean;
69 /**72 /**
70 * True while environments that have not failed are still answering. Failed73 * True while environments that have not failed are still answering. Failed
71 * environments are reported through their own error rows: totals will not74 * environments are reported in the environment menu: totals will not
72 * improve by waiting on them, so they must not read as "still reporting".75 * improve by waiting on them, so they must not read as "still reporting".
73 */76 */
74 readonly isPartial: boolean;77 readonly isPartial: boolean;
75 readonly refresh: (input?: UsageSummaryInput) => Promise<void>;78 readonly refresh: (input?: UsageSummaryInput) => Promise<void>;
76}79}
7780
78export function useUsage(input: UsageSummaryInput): UsageView {81export function useUsage(
82 input: UsageSummaryInput,
83 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
84): UsageView {
79 const windowKey = useMemo(85 const windowKey = useMemo(
80 () =>86 () =>
81 JSON.stringify({87 JSON.stringify({
...@@ -97,6 +103,13 @@ export function useUsage(input: UsageSummaryInput): UsageView {...@@ -97,6 +103,13 @@ export function useUsage(input: UsageSummaryInput): UsageView {
97 );103 );
98 const atom = usageByWindowAtom(windowKey);104 const atom = usageByWindowAtom(windowKey);
99 const environments = useAtomValue(atom);105 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
101 const refresh = useCallback(114 const refresh = useCallback(
102 (nextInput?: UsageSummaryInput) =>115 (nextInput?: UsageSummaryInput) =>
...@@ -104,14 +117,14 @@ export function useUsage(input: UsageSummaryInput): UsageView {...@@ -104,14 +117,14 @@ export function useUsage(input: UsageSummaryInput): UsageView {
104 registry: appAtomRegistry,117 registry: appAtomRegistry,
105 server: serverEnvironment,118 server: serverEnvironment,
106 presentations: environmentPresentations,119 presentations: environmentPresentations,
107 environmentIds: environments.map(({ environmentId }) => environmentId),120 environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId),
108 input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),121 input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),
109 }),122 }),
110 [environments, windowKey],123 [selectedEnvironments, windowKey],
111 );124 );
112125
113 const merged = useMemo(() => {126 const merged = useMemo(() => {
114 const answered: EnvironmentUsage[] = environments.flatMap((environment) =>127 const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) =>
115 environment.summary === null128 environment.summary === null
116 ? []129 ? []
117 : [130 : [
...@@ -123,16 +136,19 @@ export function useUsage(input: UsageSummaryInput): UsageView {...@@ -123,16 +136,19 @@ export function useUsage(input: UsageSummaryInput): UsageView {
123 ],136 ],
124 );137 );
125 return mergeUsage(answered, USAGE_CONTRACT_VERSION);138 return mergeUsage(answered, USAGE_CONTRACT_VERSION);
126 }, [environments]);139 }, [selectedEnvironments]);
127140
128 const answeredCount = environments.filter((environment) => environment.summary !== null).length;141 const answeredCount = selectedEnvironments.filter(
129 const stillReporting = environments.filter(142 (environment) => environment.summary !== null,
143 ).length;
144 const stillReporting = selectedEnvironments.filter(
130 (environment) => environment.summary === null && environment.error === null,145 (environment) => environment.summary === null && environment.error === null,
131 ).length;146 ).length;
132147
133 return {148 return {
134 merged,149 merged,
135 environments,150 environments,
151 selectedEnvironments,
136 isPending: answeredCount === 0 && stillReporting > 0,152 isPending: answeredCount === 0 && stillReporting > 0,
137 isPartial: answeredCount > 0 && stillReporting > 0,153 isPartial: answeredCount > 0 && stillReporting > 0,
138 refresh,154 refresh,
docs/user/usage.md+4-4
...@@ -39,14 +39,14 @@ the dialog....@@ -39,14 +39,14 @@ the dialog.
3939
40## Track subscription limits40## Track subscription limits
4141
42On web and desktop, **Usage → Limits** pools every subscription account it can see per provider, so with several Codex42**Usage → Limits** pools every subscription account it can see per provider, so with several Codex
43or Claude accounts across your environments and hubs you read one number per window rather than a43or Claude accounts across your environments and hubs you read one number per window rather than a
44list. Each window card shows how much of the pool is left and a bar with one segment per account,44list. Each window card shows how much of the pool is left and a bar with one segment per account,
45ordered by which resets soonest; when the provider reports reset times, the card also says when45ordered by which resets soonest; when the provider reports reset times, the card also says when
46the next reset lands and how much it hands back. The hatched46the 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 is47part of a segment is what that reset restores. Tap a segment or account row for the account's plan,
48signed in, and its reset time; Codex accounts with banked reset credits show a ticket count on the48where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked
49segment and the **Use reset** action in that popover. On narrow screens, numbered rows below49reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below
50the bar show each account's quota, countdown, and credits. Tap a row to open its details.50the bar show each account's quota, countdown, and credits. Tap a row to open its details.
5151
52The same account signed in on more than one environment, or reported by a hub as well, counts once.52The 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(...@@ -209,7 +209,7 @@ export function collectLimitAccounts(
209 // the freshest native snapshot supplies both, or neither.209 // the freshest native snapshot supplies both, or neither.
210 const native = [previous, next]210 const native = [previous, next]
211 .filter((candidate) => candidate.redeem !== null)211 .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];
213 accounts.set(key, {213 accounts.set(key, {
214 ...previous,214 ...previous,
215 displayName: previous.displayName ?? next.displayName,215 displayName: previous.displayName ?? next.displayName,
...@@ -374,7 +374,7 @@ export function collectLimitPools(...@@ -374,7 +374,7 @@ export function collectLimitPools(
374 else byDriver.set(account.driver, [account]);374 else byDriver.set(account.driver, [account]);
375 }375 }
376 return [...byDriver].map(([driver, members]) => {376 return [...byDriver].map(([driver, members]) => {
377 const sorted = members.toSorted(377 const sorted = [...members].sort(
378 (left, right) =>378 (left, right) =>
379 Number(left.redeem === null) - Number(right.redeem === null) ||379 Number(left.redeem === null) - Number(right.redeem === null) ||
380 accountSortName(left).localeCompare(accountSortName(right)),380 accountSortName(left).localeCompare(accountSortName(right)),
...@@ -398,7 +398,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L...@@ -398,7 +398,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
398 }398 }
399 }399 }
400 const pools = [...byKey.values()].map((unordered): LimitPoolWindow => {400 const pools = [...byKey.values()].map((unordered): LimitPoolWindow => {
401 const members = unordered.toSorted(401 const members = [...unordered].sort(
402 (left, right) =>402 (left, right) =>
403 (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) -403 (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) -
404 (resetMillis(right.window) ?? Number.POSITIVE_INFINITY),404 (resetMillis(right.window) ?? Number.POSITIVE_INFINITY),
...@@ -428,7 +428,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L...@@ -428,7 +428,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
428 },428 },
429 ];429 ];
430 })430 })
431 .toSorted((left, right) => left.at - right.at);431 .sort((left, right) => left.at - right.at);
432 return {432 return {
433 id: first.id,433 id: first.id,
434 kind: first.kind,434 kind: first.kind,
...@@ -440,17 +440,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L...@@ -440,17 +440,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
440 resets,440 resets,
441 };441 };
442 });442 });
443 return pools.toSorted(443 return pools.sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind]);
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);
454}444}
455445
456/** The one-line status under a provider heading when there are no bars to draw. */446/** The one-line status under a provider heading when there are no bars to draw. */
...@@ -627,7 +617,7 @@ export function collectProviderUsageLimits(...@@ -627,7 +617,7 @@ export function collectProviderUsageLimits(
627 accounts.push({617 accounts.push({
628 id: provider.instanceId,618 id: provider.instanceId,
629 driver: provider.driver,619 driver: provider.driver,
630 label: `${providerLimitsLabel(provider, () => undefined)} [${provider.instanceId}]`,620 label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`,
631 ...(provider.auth.label ? { plan: provider.auth.label } : {}),621 ...(provider.auth.label ? { plan: provider.auth.label } : {}),
632 instanceId: provider.instanceId,622 instanceId: provider.instanceId,
633 ...(provider.displayName ? { displayName: provider.displayName } : {}),623 ...(provider.displayName ? { displayName: provider.displayName } : {}),