| 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 | collectExternalUsageLinks, |
| 7 | collectLimitNotices, |
| 8 | collectLimitPools, |
| 9 | cursorUsageWindowDetails, |
| 10 | displayLimitWindows, |
| 11 | formatResetsIn, |
| 12 | remainingPercent, |
| 13 | type LimitAccount, |
| 14 | type LimitPoolWindow, |
| 15 | } from "@t3tools/shared/usageLimits"; |
| 16 | import { Fragment, type ReactNode, useId, useState } from "react"; |
| 17 | import { Linking, Pressable, ScrollView, View } from "react-native"; |
| 18 | import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; |
| 19 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 20 | |
| 21 | import { SymbolView } from "../../components/AppSymbol"; |
| 22 | import { AppText as Text } from "../../components/AppText"; |
| 23 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 24 | import { SettingsScreen } from "../settings/components/SettingsScreen"; |
| 25 | import { environmentPresentations } from "../../state/presentation"; |
| 26 | import { ResetCredits } from "./UsageLimitsSection"; |
| 27 | import { useProviderColors } from "./usageProviders"; |
| 28 | |
| 29 | const DRIVER_LABEL: Partial<Record<string, string>> = { |
| 30 | codex: "Codex", |
| 31 | claudeAgent: "Claude", |
| 32 | antigravity: "Antigravity", |
| 33 | }; |
| 34 | const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; |
| 35 | |
| 36 | function accountName(account: LimitAccount) { |
| 37 | if (account.displayName) return account.displayName; |
| 38 | if (!account.email) return DRIVER_LABEL[account.driver] ?? String(account.driver); |
| 39 | const [local = "", domain = ""] = account.email.split("@"); |
| 40 | return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account"; |
| 41 | } |
| 42 | |
| 43 | /** The spent share comes back at reset. SVG keeps the hatching static on both platforms. */ |
| 44 | function AccountSegment({ |
| 45 | remaining, |
| 46 | color, |
| 47 | pending, |
| 48 | }: { |
| 49 | readonly remaining: number; |
| 50 | readonly color: string; |
| 51 | readonly pending: boolean; |
| 52 | }) { |
| 53 | const patternId = useId().replace(/:/g, ""); |
| 54 | return ( |
| 55 | <Svg width="100%" height="100%" accessible={false}> |
| 56 | <Defs> |
| 57 | <Pattern id={patternId} width={6} height={6} patternUnits="userSpaceOnUse"> |
| 58 | <Path d="M-1 1L1 -1M0 6L6 0M5 7L7 5" stroke={color} strokeWidth={1} opacity={0.22} /> |
| 59 | </Pattern> |
| 60 | </Defs> |
| 61 | {pending ? ( |
| 62 | <Rect |
| 63 | x={`${remaining}%`} |
| 64 | width={`${100 - remaining}%`} |
| 65 | height="100%" |
| 66 | fill={`url(#${patternId})`} |
| 67 | /> |
| 68 | ) : null} |
| 69 | <Rect width={`${remaining}%`} height="100%" fill={color} opacity={0.35} /> |
| 70 | </Svg> |
| 71 | ); |
| 72 | } |
| 73 | |
| 74 | function PoolWindowCard({ |
| 75 | pool, |
| 76 | color, |
| 77 | now, |
| 78 | environmentIds, |
| 79 | label, |
| 80 | description, |
| 81 | }: { |
| 82 | readonly pool: LimitPoolWindow; |
| 83 | readonly color: string; |
| 84 | readonly now: number; |
| 85 | readonly environmentIds: readonly string[] | null; |
| 86 | readonly label?: string; |
| 87 | readonly description?: string; |
| 88 | }) { |
| 89 | const navigation = useNavigation(); |
| 90 | const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); |
| 91 | const openAccount = (account: LimitAccount) => |
| 92 | navigation.navigate("SettingsSheet", { |
| 93 | screen: "SettingsContent", |
| 94 | params: { |
| 95 | screen: "SettingsUsageAccount", |
| 96 | params: { |
| 97 | accountKey: account.key, |
| 98 | windowId: pool.id, |
| 99 | windowKind: pool.kind, |
| 100 | environmentIds, |
| 101 | now, |
| 102 | }, |
| 103 | }, |
| 104 | }); |
| 105 | return ( |
| 106 | <View className="gap-3 rounded-[24px] border-continuous bg-grouped-card p-4"> |
| 107 | <View className="flex-row items-start justify-between gap-3"> |
| 108 | <View className="gap-1"> |
| 109 | <Text className="text-sm font-t3-medium text-foreground">{label ?? pool.label}</Text> |
| 110 | <View className="flex-row items-baseline gap-1.5"> |
| 111 | <Text className="text-3xl font-t3-bold tabular-nums text-foreground"> |
| 112 | {pool.remainingPercent}% |
| 113 | </Text> |
| 114 | <Text className="text-sm text-foreground-muted">left</Text> |
| 115 | </View> |
| 116 | </View> |
| 117 | {pool.pace ? ( |
| 118 | <Text className="text-xs text-foreground-tertiary">{PACE_LABEL[pool.pace]}</Text> |
| 119 | ) : null} |
| 120 | </View> |
| 121 | {description ? <Text className="text-xs text-foreground-muted">{description}</Text> : null} |
| 122 | {nextRefill ? ( |
| 123 | <Text className="text-xs tabular-nums text-foreground-muted"> |
| 124 | ↻ +{nextRefill.restoresPercent}%{" "} |
| 125 | {formatResetsIn(nextRefill.member.window, now) |
| 126 | ?.replace("resets in ", "in ") |
| 127 | .replace("resets now", "now")} |
| 128 | </Text> |
| 129 | ) : null} |
| 130 | <View className="flex-row gap-1"> |
| 131 | {pool.columns.map(({ account, window }, index) => { |
| 132 | if (!window) return <View key={account.key} className="h-7 min-w-0 flex-1" />; |
| 133 | return ( |
| 134 | <Pressable |
| 135 | key={account.key} |
| 136 | accessibilityRole="button" |
| 137 | accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`} |
| 138 | accessibilityHint="Show account details" |
| 139 | onPress={() => openAccount(account)} |
| 140 | className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" |
| 141 | > |
| 142 | <AccountSegment |
| 143 | remaining={remainingPercent(window)} |
| 144 | color={color} |
| 145 | pending={Boolean(window.resetsAt)} |
| 146 | /> |
| 147 | <View pointerEvents="none" className="absolute inset-0 items-center justify-center"> |
| 148 | <Text className="text-xs font-t3-medium tabular-nums text-foreground"> |
| 149 | {index + 1} |
| 150 | </Text> |
| 151 | </View> |
| 152 | </Pressable> |
| 153 | ); |
| 154 | })} |
| 155 | </View> |
| 156 | <View> |
| 157 | {pool.columns.map(({ account, window }, index) => { |
| 158 | if (!window) return null; |
| 159 | const credits = account.limits.resetCredits?.availableCount ?? 0; |
| 160 | const resetsIn = formatResetsIn(window, now); |
| 161 | return ( |
| 162 | <Pressable |
| 163 | key={account.key} |
| 164 | accessibilityRole="button" |
| 165 | accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset credits banked` : ""}`} |
| 166 | accessibilityHint="Show account details" |
| 167 | onPress={() => openAccount(account)} |
| 168 | className="min-h-[44px] flex-row items-center gap-2 active:opacity-60" |
| 169 | > |
| 170 | <View className="size-5 items-center justify-center overflow-hidden rounded-md bg-subtle-strong"> |
| 171 | <Text className="text-xs font-t3-medium tabular-nums text-foreground"> |
| 172 | {index + 1} |
| 173 | </Text> |
| 174 | </View> |
| 175 | <Text |
| 176 | numberOfLines={1} |
| 177 | className="min-w-0 flex-1 text-sm font-t3-medium text-foreground" |
| 178 | > |
| 179 | {accountName(account)} |
| 180 | </Text> |
| 181 | <Text className="text-sm font-t3-medium tabular-nums text-foreground"> |
| 182 | {remainingPercent(window)}% |
| 183 | </Text> |
| 184 | <View className="flex-row items-center gap-1"> |
| 185 | {resetsIn ? ( |
| 186 | <> |
| 187 | <SymbolView |
| 188 | name="arrow.clockwise" |
| 189 | size={12} |
| 190 | tintColorClassName="accent-foreground-muted" |
| 191 | /> |
| 192 | <Text className="text-xs tabular-nums text-foreground-muted"> |
| 193 | {resetsIn.replace("resets in ", "")} |
| 194 | </Text> |
| 195 | </> |
| 196 | ) : null} |
| 197 | {credits ? ( |
| 198 | <> |
| 199 | {resetsIn ? <Text className="text-xs text-foreground-tertiary">·</Text> : null} |
| 200 | <SymbolView name="ticket" size={13} tintColorClassName="accent-icon" /> |
| 201 | <Text className="text-xs font-t3-medium tabular-nums text-foreground"> |
| 202 | {credits} |
| 203 | </Text> |
| 204 | </> |
| 205 | ) : null} |
| 206 | </View> |
| 207 | </Pressable> |
| 208 | ); |
| 209 | })} |
| 210 | </View> |
| 211 | </View> |
| 212 | ); |
| 213 | } |
| 214 | |
| 215 | export function UsageLimitsSection({ |
| 216 | now, |
| 217 | failedLabels, |
| 218 | selectedEnvironmentIds, |
| 219 | cursorPrompt, |
| 220 | }: { |
| 221 | readonly now: number; |
| 222 | readonly failedLabels: readonly string[]; |
| 223 | readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null; |
| 224 | readonly cursorPrompt?: ReactNode; |
| 225 | }) { |
| 226 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 227 | const selected = |
| 228 | selectedEnvironmentIds === null |
| 229 | ? presentations |
| 230 | : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); |
| 231 | const pools = collectLimitPools(collectLimitAccounts(selected), now); |
| 232 | const notices = collectLimitNotices(selected); |
| 233 | const externalLinks = collectExternalUsageLinks(selected); |
| 234 | const colors = useProviderColors(); |
| 235 | const cursorPromptAt = |
| 236 | Math.max( |
| 237 | pools.findIndex((pool) => pool.driver === "codex"), |
| 238 | pools.findIndex((pool) => pool.driver === "claudeAgent"), |
| 239 | ) + 1; |
| 240 | return ( |
| 241 | <View className="gap-6"> |
| 242 | {pools.length === 0 && |
| 243 | notices.length === 0 && |
| 244 | failedLabels.length === 0 && |
| 245 | !cursorPrompt && |
| 246 | externalLinks.length === 0 ? ( |
| 247 | <Text className="py-12 text-center text-base text-foreground-muted"> |
| 248 | {selected.size === 0 |
| 249 | ? "Select an environment to see limits." |
| 250 | : "No provider on the selected environments reports subscription limits."} |
| 251 | </Text> |
| 252 | ) : null} |
| 253 | {pools.map((pool, index) => { |
| 254 | const windows = displayLimitWindows(pool); |
| 255 | return ( |
| 256 | <Fragment key={pool.driver}> |
| 257 | {index === cursorPromptAt ? cursorPrompt : null} |
| 258 | <View className="gap-3"> |
| 259 | <View className="flex-row items-center gap-2 px-1"> |
| 260 | <ProviderIcon provider={pool.driver} size={18} /> |
| 261 | <Text className="text-base font-t3-medium text-foreground"> |
| 262 | {DRIVER_LABEL[pool.driver] ?? pool.driver} |
| 263 | </Text> |
| 264 | </View> |
| 265 | {windows.map((window) => { |
| 266 | const details = |
| 267 | pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined; |
| 268 | return ( |
| 269 | <PoolWindowCard |
| 270 | key={`${window.kind}:${window.id}`} |
| 271 | pool={window} |
| 272 | color={ |
| 273 | pool.driver === "claudeAgent" |
| 274 | ? colors.claude |
| 275 | : pool.driver === "antigravity" |
| 276 | ? colors.antigravity |
| 277 | : colors.codex |
| 278 | } |
| 279 | now={now} |
| 280 | environmentIds={ |
| 281 | selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds] |
| 282 | } |
| 283 | label={details?.label} |
| 284 | description={details?.description} |
| 285 | /> |
| 286 | ); |
| 287 | })} |
| 288 | </View> |
| 289 | </Fragment> |
| 290 | ); |
| 291 | })} |
| 292 | {cursorPromptAt === pools.length ? cursorPrompt : null} |
| 293 | {externalLinks.map((link) => ( |
| 294 | <View key={link.url} className="gap-3 rounded-xl border border-border-subtle p-4"> |
| 295 | <Text className="text-base font-t3-medium text-foreground">{link.label}</Text> |
| 296 | <Text className="text-xs text-foreground-muted">{link.accounts.join(", ")}</Text> |
| 297 | {link.message ? ( |
| 298 | <Text className="text-sm text-foreground-muted">{link.message}</Text> |
| 299 | ) : null} |
| 300 | <Pressable |
| 301 | accessibilityRole="link" |
| 302 | className="min-h-11 justify-center" |
| 303 | onPress={() => void Linking.openURL(link.url).catch(() => undefined)} |
| 304 | > |
| 305 | <Text className="text-sm font-t3-medium text-primary">Manage usage</Text> |
| 306 | </Pressable> |
| 307 | </View> |
| 308 | ))} |
| 309 | {notices.length > 0 || failedLabels.length > 0 ? ( |
| 310 | <View |
| 311 | accessible |
| 312 | accessibilityRole="alert" |
| 313 | accessibilityLiveRegion="polite" |
| 314 | className="flex-row items-start gap-2 rounded-xl border border-warning-border bg-warning px-3.5 py-3" |
| 315 | > |
| 316 | <SymbolView |
| 317 | name="exclamationmark.triangle" |
| 318 | size={16} |
| 319 | tintColorClassName="accent-warning-foreground" |
| 320 | /> |
| 321 | <View className="min-w-0 flex-1 gap-0.5"> |
| 322 | {notices.map((notice) => ( |
| 323 | <Text key={notice} className="text-sm font-t3-medium text-warning-foreground"> |
| 324 | {notice} |
| 325 | </Text> |
| 326 | ))} |
| 327 | {failedLabels.length > 0 ? ( |
| 328 | <Text className="text-sm font-t3-medium text-warning-foreground"> |
| 329 | {failedLabels.join(", ")} could not refresh limits. Showing the last known values. |
| 330 | </Text> |
| 331 | ) : null} |
| 332 | </View> |
| 333 | </View> |
| 334 | ) : null} |
| 335 | </View> |
| 336 | ); |
| 337 | } |
| 338 | |
| 339 | type AccountScreenProps = StaticScreenProps<{ |
| 340 | accountKey: string; |
| 341 | windowId: string; |
| 342 | windowKind: LimitPoolWindow["kind"]; |
| 343 | environmentIds: readonly string[] | null; |
| 344 | now: number; |
| 345 | }>; |
| 346 | |
| 347 | /** Resolve the account again so live quota and credit updates reach the open detail screen. */ |
| 348 | export function UsageLimitAccountScreen({ route }: AccountScreenProps) { |
| 349 | const insets = useSafeAreaInsets(); |
| 350 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 351 | const { accountKey, windowId, windowKind, environmentIds, now } = route.params; |
| 352 | const selectedIds = |
| 353 | environmentIds === null ? null : new Set(environmentIds.map((id) => EnvironmentId.make(id))); |
| 354 | const selected = |
| 355 | selectedIds === null |
| 356 | ? presentations |
| 357 | : new Map([...presentations].filter(([id]) => selectedIds.has(id))); |
| 358 | const accounts = collectLimitAccounts(selected); |
| 359 | const account = accounts.find((candidate) => candidate.key === accountKey); |
| 360 | const pool = collectLimitPools(accounts, now) |
| 361 | .find((candidate) => candidate.driver === account?.driver) |
| 362 | ?.windows.find((candidate) => candidate.id === windowId && candidate.kind === windowKind); |
| 363 | const window = pool?.members.find((member) => member.account.key === accountKey)?.window; |
| 364 | const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey); |
| 365 | const [revealed, setRevealed] = useState(false); |
| 366 | return ( |
| 367 | <SettingsScreen title="Account"> |
| 368 | <ScrollView |
| 369 | contentInsetAdjustmentBehavior="automatic" |
| 370 | contentContainerClassName="gap-5 p-5" |
| 371 | contentContainerStyle={{ paddingBottom: insets.bottom + 24 }} |
| 372 | > |
| 373 | {!account || !window ? ( |
| 374 | <Text className="text-base text-foreground-muted"> |
| 375 | This account is no longer reporting limits on the selected environments. |
| 376 | </Text> |
| 377 | ) : ( |
| 378 | <> |
| 379 | <View className="gap-2"> |
| 380 | <View className="flex-row items-center gap-2"> |
| 381 | <ProviderIcon provider={account.driver} size={24} /> |
| 382 | <Text className="flex-1 text-xl font-t3-bold text-foreground"> |
| 383 | {account.displayName ?? DRIVER_LABEL[account.driver] ?? account.driver} |
| 384 | </Text> |
| 385 | </View> |
| 386 | {account.email ? ( |
| 387 | <Pressable |
| 388 | accessibilityRole="button" |
| 389 | accessibilityLabel={revealed ? "Hide account email" : "Reveal account email"} |
| 390 | onPress={() => setRevealed((value) => !value)} |
| 391 | className="min-h-[44px] justify-center" |
| 392 | > |
| 393 | <Text className="text-sm text-foreground-muted"> |
| 394 | {revealed ? account.email : "••••••@••••••"} |
| 395 | </Text> |
| 396 | </Pressable> |
| 397 | ) : null} |
| 398 | {account.plan ? ( |
| 399 | <Text selectable className="text-sm text-foreground-muted"> |
| 400 | {account.plan} |
| 401 | </Text> |
| 402 | ) : null} |
| 403 | </View> |
| 404 | <View className="gap-3 rounded-[24px] border-continuous bg-grouped-card p-4"> |
| 405 | <Text className="text-sm font-t3-medium text-foreground">{window.label}</Text> |
| 406 | <Text className="text-3xl font-t3-bold tabular-nums text-foreground"> |
| 407 | {remainingPercent(window)}% left |
| 408 | </Text> |
| 409 | {window.resetsAt ? ( |
| 410 | <Text selectable className="text-sm text-foreground-muted"> |
| 411 | Resets{" "} |
| 412 | {new Date(window.resetsAt).toLocaleString(undefined, { |
| 413 | dateStyle: "medium", |
| 414 | timeStyle: "short", |
| 415 | })} |
| 416 | </Text> |
| 417 | ) : null} |
| 418 | {reset && reset.restoresPercent > 0 ? ( |
| 419 | <Text className="text-sm text-foreground-muted"> |
| 420 | Restores {reset.restoresPercent}% of the pool |
| 421 | </Text> |
| 422 | ) : null} |
| 423 | </View> |
| 424 | <View className="gap-2 rounded-[24px] border-continuous bg-grouped-card p-4"> |
| 425 | <Text className="text-sm font-t3-medium text-foreground"> |
| 426 | {account.environments.length ? "Signed in" : "Source"} |
| 427 | </Text> |
| 428 | {account.environments.length ? ( |
| 429 | account.environments.map((environment) => ( |
| 430 | <Text key={environment.environmentId} className="text-sm text-foreground-muted"> |
| 431 | {environment.label} |
| 432 | </Text> |
| 433 | )) |
| 434 | ) : ( |
| 435 | <Text className="text-sm text-foreground-muted">{account.sourceLabel}</Text> |
| 436 | )} |
| 437 | </View> |
| 438 | {account.redeem && account.limits.resetCredits ? ( |
| 439 | <View className="gap-3 rounded-[24px] border-continuous bg-grouped-card p-4"> |
| 440 | <Text className="text-sm font-t3-medium text-foreground">Reset credits</Text> |
| 441 | <ResetCredits |
| 442 | key={account.key} |
| 443 | environmentId={account.redeem.environmentId} |
| 444 | input={account.redeem.input} |
| 445 | credits={account.limits.resetCredits} |
| 446 | now={now} |
| 447 | /> |
| 448 | </View> |
| 449 | ) : null} |
| 450 | </> |
| 451 | )} |
| 452 | </ScrollView> |
| 453 | </SettingsScreen> |
| 454 | ); |
| 455 | } |