1import { useAtomValue } from "@effect/atom-react";
2import {
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";
12import {
13 elapsedShare,
14 formatDuration,
15 formatResetsIn,
16 limitsNotice,
17 paceOf,
18 remainingPercent,
19} from "@t3tools/shared/usageLimits";
20import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
21import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";
22import { Alert, Linking, Pressable, View } from "react-native";
23
24import { AppText as Text } from "../../components/AppText";
25import { ProviderIcon } from "../../components/ProviderIcon";
26import { environmentPresentations } from "../../state/presentation";
27import { serverEnvironment } from "../../state/server";
28import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
29import { useAtomCommand } from "../../state/use-atom-command";
30import { useProviderColors } from "./usageProviders";
31
32const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;
33
34type Driver = ServerProvider["driver"];
35
36/** The series colour the usage chart uses for this driver, so the two views read as one. */
37function 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 */
50function 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
103function 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. */
127export 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
191const 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 */
203export 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 */
303export 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}