import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId, ProviderConsumeResetCreditOutcome, ProviderConsumeResetCreditInput, ServerProvider, ServerProviderResetCredits, ServerProviderUsageWindow, UsageProviderKind, } from "@t3tools/contracts"; import { elapsedShare, formatDuration, formatResetsIn, limitsNotice, paceOf, remainingPercent, } from "@t3tools/shared/usageLimits"; import { type ReactNode, useState } from "react"; import { Alert, Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { useProviderColors } from "./usageProviders"; const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; type Driver = ServerProvider["driver"]; /** The series colour the usage chart uses for this driver, so the two views read as one. */ function useBarColor(driver: Driver): string | null { const colors = useProviderColors(); const kind: UsageProviderKind | null = driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : null; return kind ? colors[kind] : null; } /** * One window as a bar spanning its whole duration: the fill is quota left, * the hairline is how much of the window is left, so even spending keeps the * fill on the line. Pace sits under the left edge, the countdown under the * right, so a row reads in one glance. */ function WindowRow(props: { readonly window: ServerProviderUsageWindow; readonly color: string | null; readonly now: number; }) { const { window, now } = props; const remaining = remainingPercent(window); const elapsed = elapsedShare(window, now); const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); return ( {window.label} {remaining}% left 30 && props.color ? { backgroundColor: props.color } : null, ]} /> {timeLeft !== null ? ( ) : null} {pace || resetsIn ? ( {pace ? PACE_LABEL[pace] : ""} {resetsIn ?? ""} ) : null} ); } function AccountInstanceLabel({ value }: { readonly value: string }) { const [revealed, setRevealed] = useState(false); if (!value.includes("@")) { return ( · {value} ); } return ( setRevealed((current) => !current)} > · {revealed ? value : "••••••@••••••"} ); } /** One account: icon, name and plan on a single line, then its windows. */ export function AccountLimits(props: { readonly driver: Driver; readonly label: string; readonly instanceLabel: string; readonly detail: string | undefined; readonly limits: ServerProvider["usageLimits"]; readonly now: number; readonly first: boolean; /** Tighter padding for the composer card. */ readonly dense?: boolean; /** Sits at the end of the heading row, such as a close control. */ readonly trailing?: ReactNode; readonly footer?: ReactNode; }) { const { limits, now, dense = false } = props; const color = useBarColor(props.driver); if (!limits) return null; const notice = limitsNotice(limits); const padding = dense ? "px-4 py-3" : "p-4"; return ( {props.label} {props.instanceLabel !== props.label ? ( ) : null} {props.detail ? ( · {props.detail} ) : null} {props.trailing} {notice ? ( {notice} ) : ( {limits.windows.map((window) => ( ))} )} {props.footer} ); } const OUTCOME_TEXT: Record = { reset: "Reset applied. Your windows have cleared.", nothingToReset: "Nothing to reset right now.", noCredit: "No reset credit left.", alreadyRedeemed: "That credit was already redeemed.", }; /** * Banked reset credits with a confirmed redeem action. Redeeming spends a * credit the provider granted the user, so it goes through the native * confirm alert rather than firing on a bare tap. */ export function ResetCredits(props: { readonly environmentId: EnvironmentId; readonly input: ProviderConsumeResetCreditInput; readonly credits: ServerProviderResetCredits; readonly now: number; /** A smaller pill for the composer card. */ readonly dense?: boolean; }) { const { environmentId, input, credits, now, dense = false } = props; const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false, }); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); if (dense && credits.availableCount === 0 && status === null) return null; const expiresIn = credits.nextExpiresAt ? formatDuration(Date.parse(credits.nextExpiresAt) - now) : null; const summary = credits.availableCount === 0 ? "No reset credits banked" : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ expiresIn ? ` · next expires in ${expiresIn}` : "" }`; const redeem = async () => { setBusy(true); setStatus(null); const result = await consume({ environmentId, input }); setBusy(false); if (result._tag === "Success") { setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]); return; } setStatus( "error" in result.cause && result.cause.error instanceof Error ? result.cause.error.message : "Could not use the reset credit.", ); }; const confirm = () => { Alert.alert( "Use a reset credit?", "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.", [ { text: "Cancel", style: "cancel" }, { text: "Use credit", onPress: () => void redeem() }, ], ); }; return ( {summary} {credits.availableCount > 0 ? ( {busy ? "Using…" : "Use reset"} ) : null} {status ? {status} : null} ); } /** * Re-probes every provider (and usage-limit source) on each connected * environment; the fresh snapshots then arrive over the config stream. * Countdowns and pace anchor to `now` rather than ticking, so a refresh also * re-anchors the clock: quota and elapsed time move together, or not at all. * Environments whose probe failed are named, since their rows keep showing * the previous quota with nothing else to say so. */ export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet | null = null) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); const [now, setNow] = useState(() => Date.now()); const [refreshing, setRefreshing] = useState(false); const [failedEnvironments, setFailedEnvironments] = useState< readonly { environmentId: EnvironmentId; label: string }[] >([]); // Always toggles `refreshing`, even with nothing to probe: Android's // RefreshControl keeps its spinner up until it sees true then false. const refresh = async () => { const connected = [...presentations].filter( ([environmentId, presentation]) => presentation.connection.phase === "connected" && (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), ); setRefreshing(true); try { const results = await Promise.all( connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })), ); setFailedEnvironments( connected .filter((_, index) => results[index]?._tag === "Failure") .map(([environmentId, presentation]) => ({ environmentId, label: presentation.entry.target.label, })), ); } finally { setNow(Date.now()); setRefreshing(false); } }; const failedLabels = failedEnvironments .filter( ({ environmentId }) => selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), ) .map(({ label }) => label); return { now, refreshing, failedLabels, refresh }; }