1import {
2 AuthProvidersManageScope,
3 type EnvironmentId,
4 type ProviderConsumeResetCreditOutcome,
5 ProviderConsumeResetCreditInput,
6 ServerProvider,
7 ServerProviderResetCredits,
8 ServerProviderUsageWindow,
9 UsageProviderKind,
10} from "@t3tools/contracts";
11import {
12 elapsedShare,
13 formatDuration,
14 formatResetsIn,
15 type LimitPace,
16 paceOf,
17 remainingPercent,
18} from "@t3tools/shared/usageLimits";
19import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
20import { Fragment, type RefObject, useState } from "react";
21
22import { usePrimarySettings } from "../../hooks/useSettings";
23import { useEnvironmentScope, readEnvironmentScope } from "../../state/session";
24import { serverEnvironment } from "../../state/server";
25import { useAtomCommand } from "../../state/use-atom-command";
26import { formatUpcomingTimestamp } from "../../timestampFormat";
27import {
28 AlertDialog,
29 AlertDialogClose,
30 AlertDialogDescription,
31 AlertDialogFooter,
32 AlertDialogHeader,
33 AlertDialogPopup,
34 AlertDialogTitle,
35} from "../ui/alert-dialog";
36import { Button } from "../ui/button";
37import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
38import { PROVIDER_PRESENTATION } from "./usageProviders";
39
40const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof GaugeIcon }> = {
41 ahead: { label: "Ahead of pace: spending faster than the window elapses", icon: TrendingUpIcon },
42 on: { label: "On pace with the window", icon: GaugeIcon },
43 under: { label: "Under pace: headroom left for the rest of the window", icon: TrendingDownIcon },
44};
45
46/** The series colour the cost chart uses for this driver, so the two views read as one. */
47export function barColor(driver: ServerProvider["driver"]): string {
48 const kind: UsageProviderKind | undefined =
49 driver === "codex"
50 ? "codex"
51 : driver === "claudeAgent"
52 ? "claude"
53 : driver === "antigravity"
54 ? "antigravity"
55 : undefined;
56 return kind ? PROVIDER_PRESENTATION[kind].color : "var(--foreground)";
57}
58
59/** Pace as a glyph with the words on hover. */
60export function PaceIcon({ pace }: { readonly pace: LimitPace }) {
61 const Icon = PACE[pace].icon;
62 return (
63 <Tooltip>
64 <TooltipTrigger
65 render={
66 <span
67 role="img"
68 aria-label={PACE[pace].label}
69 className="inline-flex text-muted-foreground"
70 />
71 }
72 >
73 <Icon className="size-3.5" aria-hidden />
74 </TooltipTrigger>
75 <TooltipPopup side="top">{PACE[pace].label}</TooltipPopup>
76 </Tooltip>
77 );
78}
79
80/**
81 * One window as a full-width bar from the moment it opened to its reset.
82 * The fill is the share of quota spent; the hairline is how far into the
83 * window the clock is, which is also where even spending would have put the
84 * fill. Hover for the exact figures and reset time.
85 */
86function WindowBar({
87 color,
88 window,
89 now,
90}: {
91 readonly color: string;
92 readonly window: ServerProviderUsageWindow;
93 readonly now: number;
94}) {
95 const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);
96 const remaining = remainingPercent(window);
97 const elapsed = elapsedShare(window, now);
98 // The fill is quota left, so the even-spending mark is the time left.
99 const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100);
100 const resetsIn = formatResetsIn(window, now);
101 const resetsAt = window.resetsAt
102 ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)
103 : null;
104 const summary = `${window.label}: ${remaining}% left${
105 timeLeft === null ? "" : `, ${timeLeft}% of the window left`
106 }${resetsIn ? `, ${resetsIn}` : ""}`;
107
108 return (
109 <Tooltip>
110 <TooltipTrigger
111 render={
112 <div
113 role="img"
114 aria-label={summary}
115 tabIndex={0}
116 className="relative h-6 cursor-default rounded-full outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
117 />
118 }
119 >
120 <div className="absolute inset-x-0 inset-y-1.5 rounded-full bg-muted" />
121 {remaining > 0 ? (
122 <div
123 className="absolute inset-y-1.5 left-0 rounded-full"
124 style={{ width: `${remaining}%`, backgroundColor: color }}
125 />
126 ) : null}
127 {timeLeft !== null ? (
128 <span
129 aria-hidden
130 className="absolute inset-y-0.5 w-px -translate-x-1/2 bg-foreground/60"
131 style={{ left: `${timeLeft}%` }}
132 />
133 ) : null}
134 </TooltipTrigger>
135 <TooltipPopup side="top">
136 <div className="flex flex-col gap-0.5">
137 <span className="text-foreground">
138 {remaining}% left{timeLeft !== null ? ` · ${timeLeft}% of the window left` : ""}
139 </span>
140 {timeLeft !== null ? (
141 <span className="text-muted-foreground">The line is where even spending would be.</span>
142 ) : null}
143 {resetsAt ? (
144 <span className="text-muted-foreground">
145 Resets {resetsAt}
146 {resetsIn ? ` · ${resetsIn}` : ""}
147 </span>
148 ) : null}
149 </div>
150 </TooltipPopup>
151 </Tooltip>
152 );
153}
154
155/**
156 * One account's windows as rows: label and percent, bar, pace and countdown.
157 * Compact rows fit the composer panel with narrower columns.
158 */
159export function LimitWindows({
160 driver,
161 windows,
162 now,
163 compact = false,
164}: {
165 readonly driver: ServerProvider["driver"];
166 readonly windows: ReadonlyArray<ServerProviderUsageWindow>;
167 readonly now: number;
168 readonly compact?: boolean;
169}) {
170 const color = barColor(driver);
171 return (
172 <div
173 className={
174 compact
175 ? "grid grid-cols-[minmax(0,9rem)_minmax(3rem,1fr)_auto] gap-x-3 gap-y-0.5"
176 : "grid grid-cols-[11rem_minmax(0,1fr)_7rem] gap-x-4 gap-y-1"
177 }
178 >
179 {windows.map((window) => {
180 const pace = paceOf(window, now);
181 const resetsIn = formatResetsIn(window, now);
182 return (
183 <Fragment key={window.id}>
184 <span className="flex min-w-0 items-center gap-2 text-xs">
185 <span className="truncate text-muted-foreground">{window.label}</span>
186 <span className="ms-auto shrink-0 font-medium text-foreground tabular-nums">
187 {remainingPercent(window)}% left
188 </span>
189 </span>
190 <WindowBar color={color} window={window} now={now} />
191 <span className="flex items-center gap-2 text-xs whitespace-nowrap text-muted-foreground tabular-nums">
192 {pace ? <PaceIcon pace={pace} /> : null}
193 <span className="ms-auto shrink-0">{resetsIn ?? ""}</span>
194 </span>
195 </Fragment>
196 );
197 })}
198 </div>
199 );
200}
201
202const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
203 reset: "Reset applied. Your windows have cleared.",
204 nothingToReset: "Nothing to reset right now.",
205 noCredit: "No reset credit left.",
206 alreadyRedeemed: "That credit was already redeemed.",
207};
208
209/** Everything a redeem needs: where to send it and what to say afterwards. */
210export function useResetCredit(
211 environmentId: EnvironmentId,
212 input: ProviderConsumeResetCreditInput,
213) {
214 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
215 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false });
216 const [confirming, setConfirming] = useState(false);
217 const [busy, setBusy] = useState(false);
218 const [status, setStatus] = useState<string | null>(null);
219
220 const redeem = async () => {
221 setConfirming(false);
222 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
223 setBusy(true);
224 setStatus(null);
225 const result = await consume({ environmentId, input });
226 setBusy(false);
227 if (result._tag === "Success") {
228 setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]);
229 return;
230 }
231 setStatus(
232 "error" in result.cause && result.cause.error instanceof Error
233 ? result.cause.error.message
234 : "Could not use the reset credit.",
235 );
236 };
237
238 return { canManageProviders, confirming, setConfirming, busy, status, redeem };
239}
240
241/**
242 * The confirm for a redeem. Redeeming spends a credit the provider granted the
243 * user, so it never fires on a bare click. Mount it outside any popover that
244 * holds the button: dialogs stack under popovers, and closing the popover
245 * would unmount a dialog rendered inside it.
246 */
247export function ResetCreditDialog({
248 open,
249 onOpenChange,
250 onConfirm,
251 disabled = false,
252 finalFocus,
253}: {
254 readonly open: boolean;
255 readonly onOpenChange: (open: boolean) => void;
256 readonly onConfirm: () => void;
257 readonly disabled?: boolean;
258 readonly finalFocus?: RefObject<HTMLElement | null> | undefined;
259}) {
260 return (
261 <AlertDialog open={open} onOpenChange={onOpenChange}>
262 <AlertDialogPopup finalFocus={finalFocus}>
263 <AlertDialogHeader>
264 <AlertDialogTitle>Use a reset credit?</AlertDialogTitle>
265 <AlertDialogDescription>
266 This redeems one credit on your account and clears the current rate-limit windows. It
267 cannot be undone.
268 </AlertDialogDescription>
269 </AlertDialogHeader>
270 <AlertDialogFooter>
271 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
272 <Button disabled={disabled} onClick={onConfirm}>
273 Use credit
274 </Button>
275 </AlertDialogFooter>
276 </AlertDialogPopup>
277 </AlertDialog>
278 );
279}
280
281/** `2 reset credits banked · next expires in 27d 23h`, or the short form for a popover. */
282export function resetCreditsSummary(
283 credits: ServerProviderResetCredits,
284 now: number,
285 compact = false,
286): string {
287 const expiresIn = credits.nextExpiresAt
288 ? formatDuration(Date.parse(credits.nextExpiresAt) - now)
289 : null;
290 if (credits.availableCount === 0) return "No reset credits banked";
291 if (compact)
292 return `${credits.availableCount} banked${expiresIn ? ` · expires in ${expiresIn}` : ""}`;
293 return `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${
294 expiresIn ? ` · next expires in ${expiresIn}` : ""
295 }`;
296}
297
298/** Banked reset credits with the redeem button and its confirm, self-contained. */
299export function ResetCredits({
300 environmentId,
301 input,
302 credits,
303 now,
304}: {
305 readonly environmentId: EnvironmentId;
306 readonly input: ProviderConsumeResetCreditInput;
307 readonly credits: ServerProviderResetCredits;
308 readonly now: number;
309}) {
310 const { canManageProviders, confirming, setConfirming, busy, status, redeem } = useResetCredit(
311 environmentId,
312 input,
313 );
314 if (credits.availableCount === 0 && status === null) return null;
315 return (
316 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
317 <span className="tabular-nums">{resetCreditsSummary(credits, now)}</span>
318 {credits.availableCount > 0 ? (
319 <Button
320 size="xs"
321 variant="outline"
322 disabled={busy || !canManageProviders}
323 onClick={() => {
324 if (readEnvironmentScope(environmentId, AuthProvidersManageScope)) setConfirming(true);
325 }}
326 >
327 {busy ? "Using…" : "Use reset"}
328 </Button>
329 ) : null}
330 {status ? <span className="text-foreground">{status}</span> : null}
331 <ResetCreditDialog
332 open={confirming}
333 onOpenChange={setConfirming}
334 onConfirm={() => void redeem()}
335 disabled={!canManageProviders}
336 />
337 </div>
338 );
339}