| 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,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc |
| 56 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; | 56 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 57 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; | 57 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 58 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; | 58 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; |
| 59 | import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; | ||
| 59 | import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; | 60 | import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; |
| 60 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; | 61 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; |
| 61 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; | 62 | import { 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"; |
| 31 | import IconChevronUp from "@tabler/icons-react-native/IconChevronUp"; | 31 | import IconChevronUp from "@tabler/icons-react-native/IconChevronUp"; |
| 32 | import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck"; | 32 | import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck"; |
| 33 | import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled"; | 33 | import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled"; |
| 34 | import IconTicket from "@tabler/icons-react-native/IconTicket"; | ||
| 34 | import IconClock from "@tabler/icons-react-native/IconClock"; | 35 | import IconClock from "@tabler/icons-react-native/IconClock"; |
| 35 | import IconCode from "@tabler/icons-react-native/IconCode"; | 36 | import IconCode from "@tabler/icons-react-native/IconCode"; |
| 36 | import IconCopy from "@tabler/icons-react-native/IconCopy"; | 37 | import 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"; |
| 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 | import { withUniwind } from "uniwind"; | 20 | import { withUniwind } from "uniwind"; |
| 14 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | 21 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 15 | 22 | ||
| ... | @@ -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, and | 151 | // AppCompat popup can't be themed past its stock animation, metrics, and |
| 145 | // submenu chrome. | 152 | // submenu chrome. |
| 146 | export function ControlPillMenu( | 153 | export 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 @@ | ||
| 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,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"; |
| 12 | import { | 11 | import { |
| 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"; |
| 23 | import { type ReactNode, useState } from "react"; | 19 | import { type ReactNode, useState } from "react"; |
| ... | @@ -28,11 +24,9 @@ import { ProviderIcon } from "../../components/ProviderIcon"; | ... | @@ -28,11 +24,9 @@ import { ProviderIcon } from "../../components/ProviderIcon"; |
| 28 | import { environmentPresentations } from "../../state/presentation"; | 24 | import { environmentPresentations } from "../../state/presentation"; |
| 29 | import { serverEnvironment } from "../../state/server"; | 25 | import { serverEnvironment } from "../../state/server"; |
| 30 | import { useAtomCommand } from "../../state/use-atom-command"; | 26 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 31 | import { SettingsSection } from "../settings/components/SettingsSection"; | ||
| 32 | import { useProviderColors } from "./usageProviders"; | 27 | import { useProviderColors } from "./usageProviders"; |
| 33 | 28 | ||
| 34 | const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; | 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 | type Driver = ServerProvider["driver"]; | 31 | type Driver = ServerProvider["driver"]; |
| 38 | 32 | ||
| ... | @@ -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; |
| 190 | 184 | ||
| 191 | const expiresIn = credits.nextExpiresAt | 185 | 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 | dense | 232 | 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 | <Text | 237 | <Text |
| ... | @@ -256,57 +250,6 @@ export function ResetCredits(props: { | ... | @@ -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 | * Re-probes every provider (and usage-limit source) on each connected | 254 | * 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 showing | 258 | * 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 | */ |
| 318 | export function useRefreshLimits() { | 261 | export 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's | 271 | // 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 | connected | 285 | 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 | */ | ||
| 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,5 +1,10 @@ |
| 1 | import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; | ||
| 1 | import { useNavigation } from "@react-navigation/native"; | 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 | import { | 8 | import { |
| 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"; |
| 14 | import { useMemo, useRef, useState } from "react"; | 19 | import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 15 | import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; | 20 | import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; |
| 21 | import Animated, { Easing, FadeIn, LinearTransition, ReduceMotion } from "react-native-reanimated"; | ||
| 16 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 22 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 17 | 23 | ||
| 18 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 24 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| ... | @@ -22,7 +28,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; | ... | @@ -22,7 +28,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 22 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; | 28 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 23 | import { SettingsSection } from "../settings/components/SettingsSection"; | 29 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 24 | import { UsageDailyChart } from "./UsageDailyChart"; | 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 | import type { UsageChartMetric } from "./usageChartData"; | 36 | import type { UsageChartMetric } from "./usageChartData"; |
| 27 | import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; | 37 | import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; |
| 28 | 38 | ||
| ... | @@ -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); | ||
| 69 | 84 | ||
| 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 | }; |
| 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 | 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 | <ScrollView | 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 | 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" /> |
| 147 | 248 | ||
| 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, and | 253 | > |
| 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 | <SegmentedControl | 256 | 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 | <ChartCard | 270 | 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 | <View | 337 | <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 | <Pressable | 365 | <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 | <Text | 376 | <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 already | 622 | * one that failed, or one whose transcripts another environment already |
| 492 | * reported. | 623 | * reported. |
| 493 | */ | 624 | */ |
| 494 | function UsageCoverageNotice(props: { | 625 | function 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 | }) { | 629 | function 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 | } |
| 512 | 636 | 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 @@ | ||
| 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,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) => |
| 64 | export interface UsageView { | 66 | export 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. Failed | 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 | * 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 | } |
| 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 | 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 | ); | ||
| 100 | 113 | ||
| 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 | ); |
| 112 | 125 | ||
| 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 === null | 128 | 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]); |
| 127 | 140 | ||
| 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; |
| 132 | 147 | ||
| 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. |
| 39 | 39 | ||
| 40 | ## Track subscription limits | 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 | or Claude accounts across your environments and hubs you read one number per window rather than a | 43 | or Claude accounts across your environments and hubs you read one number per window rather than a |
| 44 | list. Each window card shows how much of the pool is left and a bar with one segment per account, | 44 | list. Each window card shows how much of the pool is left and a bar with one segment per account, |
| 45 | ordered by which resets soonest; when the provider reports reset times, the card also says when | 45 | ordered by which resets soonest; when the provider reports reset times, the card also says when |
| 46 | the next reset lands and how much it hands back. The hatched | 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 | 47 | part of a segment is what that reset restores. Tap a segment or account row for the account's plan, |
| 48 | signed in, and its reset time; Codex accounts with banked reset credits show a ticket count on the | 48 | where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked |
| 49 | segment and the **Use reset** action in that popover. On narrow screens, numbered rows below | 49 | reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below |
| 50 | the bar show each account's quota, countdown, and credits. Tap a row to open its details. | 50 | the bar show each account's quota, countdown, and credits. Tap a row to open its details. |
| 51 | 51 | ||
| 52 | The same account signed in on more than one environment, or reported by a hub as well, counts once. | 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,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. */ | ||
| 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); | ||
| 454 | } | 444 | } |
| 455 | 445 | ||
| 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 } : {}), |