| author | |
| committer | |
| log | b155c2199bdcb4fdf1920da02a223536f8c2d729 |
| tree | 54543891481c7be2c0ffdf5f269a2d7b99ad778f |
| parent | e5d086c262daab13a8adbb253e281c07ab235533 |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 716 insertions(+), 276 deletions(-)
apps/mobile/src/Stack.tsx+5| ... | ... | @@ -56,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc |
| 56 | 56 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 57 | 57 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 58 | 58 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; |
| 59 | import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; | |
| 59 | 60 | import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; |
| 60 | 61 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; |
| 61 | 62 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; |
| ... | ... | @@ -192,6 +193,10 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 192 | 193 | title: "Client Storage", |
| 193 | 194 | }, |
| 194 | 195 | }), |
| 196 | SettingsUsageAccount: createNativeStackScreen({ | |
| 197 | screen: UsageLimitAccountScreen, | |
| 198 | options: { title: "Account" }, | |
| 199 | }), | |
| 195 | 200 | SettingsUsage: createNativeStackScreen({ |
| 196 | 201 | screen: UsageRouteScreen, |
| 197 | 202 | linking: "usage", |
apps/mobile/src/components/AppSymbol.tsx+3| ... | ... | @@ -31,6 +31,7 @@ import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; |
| 31 | 31 | import IconChevronUp from "@tabler/icons-react-native/IconChevronUp"; |
| 32 | 32 | import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck"; |
| 33 | 33 | import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled"; |
| 34 | import IconTicket from "@tabler/icons-react-native/IconTicket"; | |
| 34 | 35 | import IconClock from "@tabler/icons-react-native/IconClock"; |
| 35 | 36 | import IconCode from "@tabler/icons-react-native/IconCode"; |
| 36 | 37 | import IconCopy from "@tabler/icons-react-native/IconCopy"; |
| ... | ... | @@ -116,6 +117,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 116 | 117 | checkmark: IconCheck, |
| 117 | 118 | "checkmark.circle": IconCircleCheck, |
| 118 | 119 | clock: IconClock, |
| 120 | ticket: IconTicket, | |
| 119 | 121 | cloud: IconCloud, |
| 120 | 122 | cube: IconBox, |
| 121 | 123 | "chevron.down": IconChevronDown, |
| ... | ... | @@ -138,6 +140,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 138 | 140 | "info.circle": IconInfoCircle, |
| 139 | 141 | laptopcomputer: IconDeviceLaptop, |
| 140 | 142 | link: IconLink, |
| 143 | "line.3.horizontal.decrease": IconFilter, | |
| 141 | 144 | "line.3.horizontal.decrease.circle": IconFilter, |
| 142 | 145 | "line.3.horizontal.decrease.circle.fill": IconFilterFilled, |
| 143 | 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 | 9 | useMemo, |
| 10 | 10 | useRef, |
| 11 | 11 | } 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"; | |
| 13 | 20 | import { withUniwind } from "uniwind"; |
| 14 | 21 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 15 | 22 | |
| ... | ... | @@ -144,10 +151,11 @@ export function ControlPill(props: { |
| 144 | 151 | // AppCompat popup can't be themed past its stock animation, metrics, and |
| 145 | 152 | // submenu chrome. |
| 146 | 153 | 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 | }, | |
| 151 | 159 | ) { |
| 152 | 160 | const { themeAppearance } = useAppearancePreferences(); |
| 153 | 161 | const isDarkMode = themeAppearance === "dark"; |
apps/mobile/src/features/usage/UsageLimitsPooled.tsx created+374| ... | ... | @@ -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 { |
| 6 | 6 | ServerProvider, |
| 7 | 7 | ServerProviderResetCredits, |
| 8 | 8 | ServerProviderUsageWindow, |
| 9 | UsageLimitSourceAccount, | |
| 10 | 9 | UsageProviderKind, |
| 11 | 10 | } from "@t3tools/contracts"; |
| 12 | 11 | import { |
| 13 | collectLimitSources, | |
| 14 | collectLimitsGroups, | |
| 15 | 12 | elapsedShare, |
| 16 | 13 | formatDuration, |
| 17 | 14 | formatResetsIn, |
| 18 | 15 | limitsNotice, |
| 19 | 16 | paceOf, |
| 20 | providerLimitsLabel, | |
| 21 | 17 | remainingPercent, |
| 22 | 18 | } from "@t3tools/shared/usageLimits"; |
| 23 | 19 | import { type ReactNode, useState } from "react"; |
| ... | ... | @@ -28,11 +24,9 @@ import { ProviderIcon } from "../../components/ProviderIcon"; |
| 28 | 24 | import { environmentPresentations } from "../../state/presentation"; |
| 29 | 25 | import { serverEnvironment } from "../../state/server"; |
| 30 | 26 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 31 | import { SettingsSection } from "../settings/components/SettingsSection"; | |
| 32 | 27 | import { useProviderColors } from "./usageProviders"; |
| 33 | 28 | |
| 34 | 29 | 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" }; | |
| 36 | 30 | |
| 37 | 31 | type Driver = ServerProvider["driver"]; |
| 38 | 32 | |
| ... | ... | @@ -186,7 +180,7 @@ export function ResetCredits(props: { |
| 186 | 180 | }); |
| 187 | 181 | const [busy, setBusy] = useState(false); |
| 188 | 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; | |
| 190 | 184 | |
| 191 | 185 | const expiresIn = credits.nextExpiresAt |
| 192 | 186 | ? formatDuration(Date.parse(credits.nextExpiresAt) - now) |
| ... | ... | @@ -237,7 +231,7 @@ export function ResetCredits(props: { |
| 237 | 231 | className={ |
| 238 | 232 | dense |
| 239 | 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 | 237 | <Text |
| ... | ... | @@ -256,57 +250,6 @@ export function ResetCredits(props: { |
| 256 | 250 | ); |
| 257 | 251 | } |
| 258 | 252 | |
| 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 | ||
| 310 | 253 | /** |
| 311 | 254 | * Re-probes every provider (and usage-limit source) on each connected |
| 312 | 255 | * environment; the fresh snapshots then arrive over the config stream. |
| ... | ... | @@ -315,107 +258,47 @@ function SourceAccountLimits(props: { |
| 315 | 258 | * Environments whose probe failed are named, since their rows keep showing |
| 316 | 259 | * the previous quota with nothing else to say so. |
| 317 | 260 | */ |
| 318 | export function useRefreshLimits() { | |
| 261 | export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null) { | |
| 319 | 262 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 320 | 263 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { |
| 321 | 264 | reportFailure: false, |
| 322 | 265 | }); |
| 323 | 266 | const [now, setNow] = useState(() => Date.now()); |
| 324 | 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 | 271 | // Always toggles `refreshing`, even with nothing to probe: Android's |
| 327 | 272 | // RefreshControl keeps its spinner up until it sees true then false. |
| 328 | 273 | const refresh = async () => { |
| 329 | 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 | 279 | setRefreshing(true); |
| 333 | 280 | try { |
| 334 | 281 | const results = await Promise.all( |
| 335 | 282 | connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })), |
| 336 | 283 | ); |
| 337 | setFailedLabels( | |
| 284 | setFailedEnvironments( | |
| 338 | 285 | connected |
| 339 | 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 | 292 | } finally { |
| 343 | 293 | setNow(Date.now()); |
| 344 | 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 | 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 | */ | |
| 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"; | |
| 1 | 2 | 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"; | |
| 3 | 8 | import { |
| 4 | 9 | enumerateDays, |
| 5 | 10 | enumerateHourStarts, |
| ... | ... | @@ -11,8 +16,9 @@ import { |
| 11 | 16 | formatUsd, |
| 12 | 17 | makeWindow, |
| 13 | 18 | } from "@t3tools/shared/usageFormat"; |
| 14 | import { useMemo, useRef, useState } from "react"; | |
| 19 | import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 15 | 20 | import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; |
| 21 | import Animated, { Easing, FadeIn, LinearTransition, ReduceMotion } from "react-native-reanimated"; | |
| 16 | 22 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 17 | 23 | |
| 18 | 24 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| ... | ... | @@ -22,7 +28,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 22 | 28 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 23 | 29 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 24 | 30 | 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"; | |
| 26 | 36 | import type { UsageChartMetric } from "./usageChartData"; |
| 27 | 37 | import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; |
| 28 | 38 | |
| ... | ... | @@ -64,8 +74,13 @@ export function UsageRouteScreen() { |
| 64 | 74 | const [metric, setMetric] = useState<UsageChartMetric>("cost"); |
| 65 | 75 | const { days: windowDays, window } = windowSelection; |
| 66 | 76 | 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); | |
| 69 | 84 | |
| 70 | 85 | const days = useMemo( |
| 71 | 86 | () => enumerateDays(window.sinceDay, window.untilDay), |
| ... | ... | @@ -119,18 +134,104 @@ export function UsageRouteScreen() { |
| 119 | 134 | }); |
| 120 | 135 | }; |
| 121 | 136 | |
| 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 | 222 | return ( |
| 123 | 223 | <View collapsable={false} className="flex-1 bg-sheet"> |
| 124 | 224 | {Platform.OS === "android" ? ( |
| 125 | 225 | <> |
| 126 | 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 | 233 | ) : null} |
| 130 | 234 | <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 | 235 | contentInsetAdjustmentBehavior="automatic" |
| 135 | 236 | showsVerticalScrollIndicator={false} |
| 136 | 237 | className="flex-1" |
| ... | ... | @@ -145,60 +246,73 @@ export function UsageRouteScreen() { |
| 145 | 246 | > |
| 146 | 247 | <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" /> |
| 147 | 248 | |
| 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} | |
| 174 | 259 | /> |
| 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" | |
| 194 | 271 | /> |
| 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> | |
| 202 | 316 | </ScrollView> |
| 203 | 317 | </View> |
| 204 | 318 | ); |
| ... | ... | @@ -221,25 +335,42 @@ function SegmentedControl<Value extends number | string>(props: { |
| 221 | 335 | const compact = props.size === "compact"; |
| 222 | 336 | return ( |
| 223 | 337 | <View |
| 224 | accessibilityRole={props.role === "tab" ? "tablist" : undefined} | |
| 338 | accessible={false} | |
| 225 | 339 | className={cn( |
| 226 | 340 | "flex-row overflow-hidden rounded-full border-continuous bg-card", |
| 227 | 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 | 362 | {props.options.map((option) => { |
| 231 | 363 | const active = option.value === props.selected; |
| 232 | 364 | return ( |
| 233 | 365 | <Pressable |
| 234 | 366 | 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} | |
| 237 | 369 | accessibilityState={{ selected: active }} |
| 238 | 370 | onPress={() => props.onSelect(option.value)} |
| 239 | 371 | className={cn( |
| 240 | 372 | "flex-1 items-center justify-center rounded-full", |
| 241 | 373 | compact ? "h-9" : "h-11", |
| 242 | active && "bg-subtle-strong", | |
| 243 | 374 | )} |
| 244 | 375 | > |
| 245 | 376 | <Text |
| ... | ... | @@ -491,48 +622,22 @@ function ModelsSection(props: { readonly merged: MergedUsage }) { |
| 491 | 622 | * one that failed, or one whose transcripts another environment already |
| 492 | 623 | * reported. |
| 493 | 624 | */ |
| 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 { | |
| 504 | 630 | 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) | |
| 509 | 633 | ) { |
| 510 | return null; | |
| 634 | return "Older server · excluded from usage totals"; | |
| 511 | 635 | } |
| 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"; | |
| 538 | 643 | } |
apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts created+40| ... | ... | @@ -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 created+16| ... | ... | @@ -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 { |
| 30 | 30 | readonly environmentId: EnvironmentId; |
| 31 | 31 | readonly label: string; |
| 32 | 32 | readonly isPending: boolean; |
| 33 | readonly isConnected: boolean; | |
| 33 | 34 | readonly error: string | null; |
| 34 | 35 | readonly summary: UsageSummary | null; |
| 35 | 36 | } |
| ... | ... | @@ -53,6 +54,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => |
| 53 | 54 | environmentId, |
| 54 | 55 | label: presentation.entry.target.label, |
| 55 | 56 | isPending: result.waiting, |
| 57 | isConnected: presentation.connection.phase === "connected", | |
| 56 | 58 | error: result._tag === "Failure" ? "This environment could not report usage." : null, |
| 57 | 59 | summary: Option.getOrNull(AsyncResult.value(result)), |
| 58 | 60 | }); |
| ... | ... | @@ -64,18 +66,22 @@ const usageByWindowAtom = Atom.family((windowKey: string) => |
| 64 | 66 | export interface UsageView { |
| 65 | 67 | readonly merged: MergedUsage; |
| 66 | 68 | readonly environments: readonly EnvironmentUsageStatus[]; |
| 69 | readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; | |
| 67 | 70 | /** True until at least one environment has answered. */ |
| 68 | 71 | readonly isPending: boolean; |
| 69 | 72 | /** |
| 70 | 73 | * 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 | |
| 72 | 75 | * improve by waiting on them, so they must not read as "still reporting". |
| 73 | 76 | */ |
| 74 | 77 | readonly isPartial: boolean; |
| 75 | 78 | readonly refresh: (input?: UsageSummaryInput) => Promise<void>; |
| 76 | 79 | } |
| 77 | 80 | |
| 78 | export function useUsage(input: UsageSummaryInput): UsageView { | |
| 81 | export function useUsage( | |
| 82 | input: UsageSummaryInput, | |
| 83 | selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null, | |
| 84 | ): UsageView { | |
| 79 | 85 | const windowKey = useMemo( |
| 80 | 86 | () => |
| 81 | 87 | JSON.stringify({ |
| ... | ... | @@ -97,6 +103,13 @@ export function useUsage(input: UsageSummaryInput): UsageView { |
| 97 | 103 | ); |
| 98 | 104 | const atom = usageByWindowAtom(windowKey); |
| 99 | 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 | ); | |
| 100 | 113 | |
| 101 | 114 | const refresh = useCallback( |
| 102 | 115 | (nextInput?: UsageSummaryInput) => |
| ... | ... | @@ -104,14 +117,14 @@ export function useUsage(input: UsageSummaryInput): UsageView { |
| 104 | 117 | registry: appAtomRegistry, |
| 105 | 118 | server: serverEnvironment, |
| 106 | 119 | presentations: environmentPresentations, |
| 107 | environmentIds: environments.map(({ environmentId }) => environmentId), | |
| 120 | environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), | |
| 108 | 121 | input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), |
| 109 | 122 | }), |
| 110 | [environments, windowKey], | |
| 123 | [selectedEnvironments, windowKey], | |
| 111 | 124 | ); |
| 112 | 125 | |
| 113 | 126 | const merged = useMemo(() => { |
| 114 | const answered: EnvironmentUsage[] = environments.flatMap((environment) => | |
| 127 | const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) => | |
| 115 | 128 | environment.summary === null |
| 116 | 129 | ? [] |
| 117 | 130 | : [ |
| ... | ... | @@ -123,16 +136,19 @@ export function useUsage(input: UsageSummaryInput): UsageView { |
| 123 | 136 | ], |
| 124 | 137 | ); |
| 125 | 138 | return mergeUsage(answered, USAGE_CONTRACT_VERSION); |
| 126 | }, [environments]); | |
| 139 | }, [selectedEnvironments]); | |
| 127 | 140 | |
| 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( | |
| 130 | 145 | (environment) => environment.summary === null && environment.error === null, |
| 131 | 146 | ).length; |
| 132 | 147 | |
| 133 | 148 | return { |
| 134 | 149 | merged, |
| 135 | 150 | environments, |
| 151 | selectedEnvironments, | |
| 136 | 152 | isPending: answeredCount === 0 && stillReporting > 0, |
| 137 | 153 | isPartial: answeredCount > 0 && stillReporting > 0, |
| 138 | 154 | refresh, |
docs/user/usage.md+4-4| ... | ... | @@ -39,14 +39,14 @@ the dialog. |
| 39 | 39 | |
| 40 | 40 | ## Track subscription limits |
| 41 | 41 | |
| 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 | |
| 43 | 43 | or Claude accounts across your environments and hubs you read one number per window rather than a |
| 44 | 44 | list. Each window card shows how much of the pool is left and a bar with one segment per account, |
| 45 | 45 | ordered by which resets soonest; when the provider reports reset times, the card also says when |
| 46 | 46 | 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 | |
| 50 | 50 | the bar show each account's quota, countdown, and credits. Tap a row to open its details. |
| 51 | 51 | |
| 52 | 52 | 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( |
| 209 | 209 | // the freshest native snapshot supplies both, or neither. |
| 210 | 210 | const native = [previous, next] |
| 211 | 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 | 213 | accounts.set(key, { |
| 214 | 214 | ...previous, |
| 215 | 215 | displayName: previous.displayName ?? next.displayName, |
| ... | ... | @@ -374,7 +374,7 @@ export function collectLimitPools( |
| 374 | 374 | else byDriver.set(account.driver, [account]); |
| 375 | 375 | } |
| 376 | 376 | return [...byDriver].map(([driver, members]) => { |
| 377 | const sorted = members.toSorted( | |
| 377 | const sorted = [...members].sort( | |
| 378 | 378 | (left, right) => |
| 379 | 379 | Number(left.redeem === null) - Number(right.redeem === null) || |
| 380 | 380 | accountSortName(left).localeCompare(accountSortName(right)), |
| ... | ... | @@ -398,7 +398,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L |
| 398 | 398 | } |
| 399 | 399 | } |
| 400 | 400 | const pools = [...byKey.values()].map((unordered): LimitPoolWindow => { |
| 401 | const members = unordered.toSorted( | |
| 401 | const members = [...unordered].sort( | |
| 402 | 402 | (left, right) => |
| 403 | 403 | (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) - |
| 404 | 404 | (resetMillis(right.window) ?? Number.POSITIVE_INFINITY), |
| ... | ... | @@ -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 | 432 | return { |
| 433 | 433 | id: first.id, |
| 434 | 434 | kind: first.kind, |
| ... | ... | @@ -440,17 +440,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L |
| 440 | 440 | resets, |
| 441 | 441 | }; |
| 442 | 442 | }); |
| 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]); | |
| 454 | 444 | } |
| 455 | 445 | |
| 456 | 446 | /** The one-line status under a provider heading when there are no bars to draw. */ |
| ... | ... | @@ -627,7 +617,7 @@ export function collectProviderUsageLimits( |
| 627 | 617 | accounts.push({ |
| 628 | 618 | id: provider.instanceId, |
| 629 | 619 | driver: provider.driver, |
| 630 | label: `${providerLimitsLabel(provider, () => undefined)} [${provider.instanceId}]`, | |
| 620 | label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`, | |
| 631 | 621 | ...(provider.auth.label ? { plan: provider.auth.label } : {}), |
| 632 | 622 | instanceId: provider.instanceId, |
| 633 | 623 | ...(provider.displayName ? { displayName: provider.displayName } : {}), |