| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { |
| 3 | AuthProvidersManageScope, |
| 4 | type EnvironmentId, |
| 5 | type ProviderConsumeResetCreditOutcome, |
| 6 | type ProviderConsumeResetCreditInput, |
| 7 | type ServerProvider, |
| 8 | type ServerProviderResetCredits, |
| 9 | type ServerProviderUsageWindow, |
| 10 | type UsageProviderKind, |
| 11 | } from "@t3tools/contracts"; |
| 12 | import { |
| 13 | elapsedShare, |
| 14 | formatDuration, |
| 15 | formatResetsIn, |
| 16 | limitsNotice, |
| 17 | paceOf, |
| 18 | remainingPercent, |
| 19 | } from "@t3tools/shared/usageLimits"; |
| 20 | import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; |
| 21 | import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; |
| 22 | import { Alert, Linking, Pressable, View } from "react-native"; |
| 23 | |
| 24 | import { AppText as Text } from "../../components/AppText"; |
| 25 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 26 | import { environmentPresentations } from "../../state/presentation"; |
| 27 | import { serverEnvironment } from "../../state/server"; |
| 28 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; |
| 29 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 30 | import { useProviderColors } from "./usageProviders"; |
| 31 | |
| 32 | const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; |
| 33 | |
| 34 | type Driver = ServerProvider["driver"]; |
| 35 | |
| 36 | /** The series colour the usage chart uses for this driver, so the two views read as one. */ |
| 37 | function useBarColor(driver: Driver): string | null { |
| 38 | const colors = useProviderColors(); |
| 39 | const kind: UsageProviderKind | null = |
| 40 | driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : null; |
| 41 | return kind ? colors[kind] : null; |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * One window as a bar spanning its whole duration: the fill is quota left, |
| 46 | * the hairline is how much of the window is left, so even spending keeps the |
| 47 | * fill on the line. Pace sits under the left edge, the countdown under the |
| 48 | * right, so a row reads in one glance. |
| 49 | */ |
| 50 | function WindowRow(props: { |
| 51 | readonly window: ServerProviderUsageWindow; |
| 52 | readonly color: string | null; |
| 53 | readonly now: number; |
| 54 | }) { |
| 55 | const { window, now } = props; |
| 56 | const remaining = remainingPercent(window); |
| 57 | const elapsed = elapsedShare(window, now); |
| 58 | const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); |
| 59 | const pace = paceOf(window, now); |
| 60 | const resetsIn = formatResetsIn(window, now); |
| 61 | return ( |
| 62 | <View className="gap-1"> |
| 63 | <View className="flex-row items-baseline justify-between gap-3"> |
| 64 | <Text className="text-sm text-foreground">{window.label}</Text> |
| 65 | <Text className="text-sm font-t3-medium tabular-nums text-foreground"> |
| 66 | {remaining}% left |
| 67 | </Text> |
| 68 | </View> |
| 69 | <View className="h-3 justify-center"> |
| 70 | <View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle"> |
| 71 | <View |
| 72 | className={ |
| 73 | remaining <= 10 |
| 74 | ? "h-full rounded-full bg-red-500" |
| 75 | : remaining <= 30 |
| 76 | ? "h-full rounded-full bg-amber-500" |
| 77 | : "h-full rounded-full bg-foreground" |
| 78 | } |
| 79 | style={[ |
| 80 | { flex: remaining }, |
| 81 | remaining > 30 && props.color ? { backgroundColor: props.color } : null, |
| 82 | ]} |
| 83 | /> |
| 84 | <View style={{ flex: 100 - remaining }} /> |
| 85 | </View> |
| 86 | {timeLeft !== null ? ( |
| 87 | <View |
| 88 | className="absolute top-0 bottom-0 w-px bg-foreground" |
| 89 | style={{ left: `${timeLeft}%`, opacity: 0.6 }} |
| 90 | /> |
| 91 | ) : null} |
| 92 | </View> |
| 93 | {pace || resetsIn ? ( |
| 94 | <View className="flex-row justify-between gap-3"> |
| 95 | <Text className="text-xs text-foreground-tertiary">{pace ? PACE_LABEL[pace] : ""}</Text> |
| 96 | <Text className="text-xs tabular-nums text-foreground-tertiary">{resetsIn ?? ""}</Text> |
| 97 | </View> |
| 98 | ) : null} |
| 99 | </View> |
| 100 | ); |
| 101 | } |
| 102 | |
| 103 | function AccountInstanceLabel({ value }: { readonly value: string }) { |
| 104 | const [revealed, setRevealed] = useState(false); |
| 105 | if (!value.includes("@")) { |
| 106 | return ( |
| 107 | <Text className="shrink text-xs text-foreground-tertiary" numberOfLines={1}> |
| 108 | · {value} |
| 109 | </Text> |
| 110 | ); |
| 111 | } |
| 112 | return ( |
| 113 | <Pressable |
| 114 | className="shrink active:opacity-60" |
| 115 | accessibilityRole="button" |
| 116 | accessibilityLabel={revealed ? "Hide account label" : "Reveal account label"} |
| 117 | onPress={() => setRevealed((current) => !current)} |
| 118 | > |
| 119 | <Text className="text-xs text-foreground-tertiary" numberOfLines={1}> |
| 120 | · {revealed ? value : "••••••@••••••"} |
| 121 | </Text> |
| 122 | </Pressable> |
| 123 | ); |
| 124 | } |
| 125 | |
| 126 | /** One account: icon, name and plan on a single line, then its windows. */ |
| 127 | export function AccountLimits(props: { |
| 128 | readonly driver: Driver; |
| 129 | readonly label: string; |
| 130 | readonly instanceLabel: string; |
| 131 | readonly detail: string | undefined; |
| 132 | readonly limits: ServerProvider["usageLimits"]; |
| 133 | readonly now: number; |
| 134 | readonly first: boolean; |
| 135 | /** Tighter padding for the composer card. */ |
| 136 | readonly dense?: boolean; |
| 137 | /** Sits at the end of the heading row, such as a close control. */ |
| 138 | readonly trailing?: ReactNode; |
| 139 | readonly footer?: ReactNode; |
| 140 | }) { |
| 141 | const { limits, now, dense = false } = props; |
| 142 | const color = useBarColor(props.driver); |
| 143 | if (!limits) return null; |
| 144 | const notice = limitsNotice(limits); |
| 145 | const externalUsage = limits.externalUsage; |
| 146 | const padding = dense ? "px-4 py-3" : "p-4"; |
| 147 | return ( |
| 148 | <View |
| 149 | className={ |
| 150 | props.first ? `gap-3 ${padding}` : `gap-3 border-t border-border-subtle ${padding}` |
| 151 | } |
| 152 | > |
| 153 | <View className="flex-row items-center gap-2"> |
| 154 | <ProviderIcon provider={props.driver} size={16} /> |
| 155 | <View className="min-w-0 flex-1 flex-row items-baseline gap-2"> |
| 156 | <Text className="text-base font-t3-medium text-foreground">{props.label}</Text> |
| 157 | {props.instanceLabel !== props.label ? ( |
| 158 | <AccountInstanceLabel key={props.instanceLabel} value={props.instanceLabel} /> |
| 159 | ) : null} |
| 160 | {props.detail ? ( |
| 161 | <Text className="shrink text-sm text-foreground-muted" numberOfLines={1}> |
| 162 | · {props.detail} |
| 163 | </Text> |
| 164 | ) : null} |
| 165 | </View> |
| 166 | {props.trailing} |
| 167 | </View> |
| 168 | {notice ? ( |
| 169 | <Text className="text-sm text-foreground-muted">{notice}</Text> |
| 170 | ) : ( |
| 171 | <View className="gap-3"> |
| 172 | {limits.windows.map((window) => ( |
| 173 | <WindowRow key={window.id} window={window} color={color} now={now} /> |
| 174 | ))} |
| 175 | </View> |
| 176 | )} |
| 177 | {externalUsage ? ( |
| 178 | <Pressable |
| 179 | accessibilityRole="link" |
| 180 | className="min-h-11 justify-center" |
| 181 | onPress={() => void Linking.openURL(externalUsage.url).catch(() => undefined)} |
| 182 | > |
| 183 | <Text className="text-sm font-t3-medium text-primary">Manage usage</Text> |
| 184 | </Pressable> |
| 185 | ) : null} |
| 186 | {props.footer} |
| 187 | </View> |
| 188 | ); |
| 189 | } |
| 190 | |
| 191 | const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { |
| 192 | reset: "Reset applied. Your windows have cleared.", |
| 193 | nothingToReset: "Nothing to reset right now.", |
| 194 | noCredit: "No reset credit left.", |
| 195 | alreadyRedeemed: "That credit was already redeemed.", |
| 196 | }; |
| 197 | |
| 198 | /** |
| 199 | * Banked reset credits with a confirmed redeem action. Redeeming spends a |
| 200 | * credit the provider granted the user, so it goes through the native |
| 201 | * confirm alert rather than firing on a bare tap. |
| 202 | */ |
| 203 | export function ResetCredits(props: { |
| 204 | readonly environmentId: EnvironmentId; |
| 205 | readonly input: ProviderConsumeResetCreditInput; |
| 206 | readonly credits: ServerProviderResetCredits; |
| 207 | readonly now: number; |
| 208 | /** A smaller pill for the composer card. */ |
| 209 | readonly dense?: boolean; |
| 210 | }) { |
| 211 | const { environmentId, input, credits, now, dense = false } = props; |
| 212 | const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); |
| 213 | const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { |
| 214 | reportFailure: false, |
| 215 | }); |
| 216 | const [busy, setBusy] = useState(false); |
| 217 | const [status, setStatus] = useState<string | null>(null); |
| 218 | if (dense && credits.availableCount === 0 && status === null) return null; |
| 219 | |
| 220 | const expiresIn = credits.nextExpiresAt |
| 221 | ? formatDuration(Date.parse(credits.nextExpiresAt) - now) |
| 222 | : null; |
| 223 | const summary = |
| 224 | credits.availableCount === 0 |
| 225 | ? "No reset credits banked" |
| 226 | : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ |
| 227 | expiresIn ? ` · next expires in ${expiresIn}` : "" |
| 228 | }`; |
| 229 | |
| 230 | const redeem = async () => { |
| 231 | if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; |
| 232 | setBusy(true); |
| 233 | setStatus(null); |
| 234 | const result = await consume({ environmentId, input }); |
| 235 | setBusy(false); |
| 236 | if (result._tag === "Success") { |
| 237 | setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]); |
| 238 | return; |
| 239 | } |
| 240 | setStatus( |
| 241 | "error" in result.cause && result.cause.error instanceof Error |
| 242 | ? result.cause.error.message |
| 243 | : "Could not use the reset credit.", |
| 244 | ); |
| 245 | }; |
| 246 | |
| 247 | const confirm = () => { |
| 248 | if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; |
| 249 | Alert.alert( |
| 250 | "Use a reset credit?", |
| 251 | "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.", |
| 252 | [ |
| 253 | { text: "Cancel", style: "cancel" }, |
| 254 | { text: "Use credit", onPress: () => void redeem() }, |
| 255 | ], |
| 256 | ); |
| 257 | }; |
| 258 | |
| 259 | return ( |
| 260 | <View className="flex-row flex-wrap items-center gap-x-3 gap-y-1"> |
| 261 | <Text className="text-xs tabular-nums text-foreground-tertiary">{summary}</Text> |
| 262 | {credits.availableCount > 0 ? ( |
| 263 | <Pressable |
| 264 | accessibilityRole="button" |
| 265 | accessibilityState={{ disabled: busy || !canManageProviders }} |
| 266 | disabled={busy || !canManageProviders} |
| 267 | onPress={confirm} |
| 268 | className={ |
| 269 | dense |
| 270 | ? "rounded-full bg-subtle-strong px-2.5 py-1 disabled:opacity-[0.45]" |
| 271 | : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5 disabled:opacity-[0.45]" |
| 272 | } |
| 273 | > |
| 274 | <Text |
| 275 | className={ |
| 276 | dense |
| 277 | ? "text-xs font-t3-medium text-foreground" |
| 278 | : "text-sm font-t3-medium text-foreground" |
| 279 | } |
| 280 | > |
| 281 | {busy ? "Using…" : "Use reset"} |
| 282 | </Text> |
| 283 | </Pressable> |
| 284 | ) : null} |
| 285 | {!canManageProviders ? ( |
| 286 | <Text className="text-xs text-foreground-tertiary"> |
| 287 | This connection cannot manage provider accounts. |
| 288 | </Text> |
| 289 | ) : null} |
| 290 | {status ? <Text className="text-sm text-foreground">{status}</Text> : null} |
| 291 | </View> |
| 292 | ); |
| 293 | } |
| 294 | |
| 295 | /** |
| 296 | * Re-probes every provider (and usage-limit source) on each connected |
| 297 | * environment; the fresh snapshots then arrive over the config stream. |
| 298 | * Countdowns and pace anchor to `now` rather than ticking, so a refresh also |
| 299 | * re-anchors the clock: quota and elapsed time move together, or not at all. |
| 300 | * Environments whose probe failed are named, since their rows keep showing |
| 301 | * the previous quota with nothing else to say so. |
| 302 | */ |
| 303 | export function useRefreshLimits( |
| 304 | selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null, |
| 305 | active = false, |
| 306 | ) { |
| 307 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 308 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { |
| 309 | reportFailure: false, |
| 310 | }); |
| 311 | const [now, setNow] = useState(() => Date.now()); |
| 312 | const [refreshing, setRefreshing] = useState(false); |
| 313 | const refreshingRef = useRef(false); |
| 314 | const [failedEnvironments, setFailedEnvironments] = useState< |
| 315 | readonly { environmentId: EnvironmentId; label: string }[] |
| 316 | >([]); |
| 317 | const refresh = async (automatic = false, afterPending = false) => { |
| 318 | const connected = [...presentations].filter( |
| 319 | ([environmentId, presentation]) => |
| 320 | presentation.connection.phase === "connected" && |
| 321 | (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), |
| 322 | ); |
| 323 | try { |
| 324 | await Promise.all( |
| 325 | connected.map(async ([environmentId, presentation]) => { |
| 326 | const result = await refreshUsageLimits( |
| 327 | environmentId, |
| 328 | () => refreshProviders({ environmentId, input: {} }), |
| 329 | automatic, |
| 330 | afterPending, |
| 331 | ); |
| 332 | if (result === undefined) return; |
| 333 | setFailedEnvironments((previous) => [ |
| 334 | ...previous.filter((failed) => failed.environmentId !== environmentId), |
| 335 | ...(result._tag === "Failure" |
| 336 | ? [{ environmentId, label: presentation.entry.target.label }] |
| 337 | : []), |
| 338 | ]); |
| 339 | }), |
| 340 | ); |
| 341 | } finally { |
| 342 | setNow(Date.now()); |
| 343 | } |
| 344 | }; |
| 345 | // Always toggles `refreshing`, even with nothing to probe: Android's |
| 346 | // RefreshControl keeps its spinner up until it sees true then false. |
| 347 | const refreshManually = async () => { |
| 348 | if (refreshingRef.current) return; |
| 349 | refreshingRef.current = true; |
| 350 | setRefreshing(true); |
| 351 | try { |
| 352 | await refresh(); |
| 353 | } finally { |
| 354 | refreshingRef.current = false; |
| 355 | setRefreshing(false); |
| 356 | } |
| 357 | }; |
| 358 | const connectedLimitsEnvironments = [...presentations] |
| 359 | .filter( |
| 360 | ([environmentId, presentation]) => |
| 361 | presentation.connection.phase === "connected" && |
| 362 | (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), |
| 363 | ) |
| 364 | .map(([environmentId]) => environmentId) |
| 365 | .sort() |
| 366 | .join(","); |
| 367 | const autoRefreshLimits = useEffectEvent(() => refresh(true)); |
| 368 | useEffect(() => { |
| 369 | if (active && connectedLimitsEnvironments) void autoRefreshLimits(); |
| 370 | }, [active, connectedLimitsEnvironments]); |
| 371 | |
| 372 | const failedLabels = failedEnvironments |
| 373 | .filter( |
| 374 | ({ environmentId }) => |
| 375 | selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), |
| 376 | ) |
| 377 | .map(({ label }) => label); |
| 378 | return { |
| 379 | now, |
| 380 | refreshing, |
| 381 | failedLabels, |
| 382 | refresh: refreshManually, |
| 383 | refreshAfterEnable: () => refresh(false, true), |
| 384 | }; |
| 385 | } |