1import {
2 CHATGPT_USAGE_URL,
3 collectLimitAccounts,
4 collectExternalUsageLinks,
5 collectLimitNotices,
6 collectLimitPools,
7 cursorUsageWindowDetails,
8 displayLimitWindows,
9 formatResetsIn,
10 isUsageLimitWindowVisible,
11 type LimitAccount,
12 type LimitPool,
13 type LimitPoolMember,
14 type LimitPoolWindow,
15 remainingPercent,
16} from "@t3tools/shared/usageLimits";
17import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react";
18import { type ReactNode, type RefObject, useState } from "react";
19
20import { ensureLocalApi } from "../../localApi";
21import { usePrimarySettings } from "../../hooks/useSettings";
22import { cn } from "../../lib/utils";
23import { formatUpcomingTimestamp } from "../../timestampFormat";
24import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
25import { providerClients } from "../settings/providerDriverMeta";
26import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";
27import { Button } from "../ui/button";
28import { OpenAI } from "../Icons";
29import { Alert, AlertTitle } from "../ui/alert";
30import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
31import {
32 PaceIcon,
33 ResetCreditDialog,
34 barColor,
35 resetCreditsSummary,
36 useResetCredit,
37} from "./UsageLimits";
38
39/** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */
40export function accountInitials(email: string): string {
41 const [local = "", domain = ""] = email.split("@");
42 return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "?";
43}
44
45/** A stable hue per email, so the same account gets the same chip on every visit. */
46function accountHue(email: string): number {
47 let hash = 0;
48 for (let index = 0; index < email.length; index += 1) {
49 hash = (hash * 31 + email.charCodeAt(index)) | 0;
50 }
51 return Math.abs(hash) % 360;
52}
53
54/** The two-letter chip for an email, coloured by a stable hue per address. */
55function AccountChip({ email }: { readonly email: string }) {
56 const hue = accountHue(email);
57 return (
58 <span
59 role="img"
60 aria-label={`Account ${accountInitials(email)}`}
61 className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-3xs leading-none font-semibold"
62 style={{ backgroundColor: `oklch(0.85 0.08 ${hue})`, color: `oklch(0.35 0.1 ${hue})` }}
63 >
64 {accountInitials(email)}
65 </span>
66 );
67}
68
69/**
70 * The same mark the model picker uses for a native instance (provider glyph,
71 * initials badge, accent); hub accounts have no instance, so they get the chip.
72 */
73function AccountAvatar({
74 account,
75 className,
76}: {
77 readonly account: LimitAccount;
78 readonly className?: string;
79}) {
80 if (account.redeem) {
81 return (
82 <ProviderInstanceIcon
83 driverKind={account.driver}
84 displayName={
85 account.displayName ??
86 providerClients.get(account.driver)?.label ??
87 String(account.driver)
88 }
89 accentColor={account.accentColor}
90 showBadge={Boolean(account.displayName)}
91 indicatorBackground="var(--popover)"
92 className={cn("size-5", className)}
93 iconClassName="size-4 text-foreground/80"
94 />
95 );
96 }
97 return account.email ? <AccountChip email={account.email} /> : null;
98}
99
100/**
101 * Who an account is, without printing the email: the instance name when there
102 * is one, else a two-letter chip. The address itself is revealed on demand in
103 * the segment's popover.
104 */
105function AccountName({
106 account,
107 className,
108}: {
109 readonly account: LimitAccount;
110 readonly className?: string;
111}) {
112 if (account.displayName) return <span className={className}>{account.displayName}</span>;
113 if (account.email) {
114 return (
115 <span className={cn("inline-flex min-w-0 items-center", className)}>
116 <AccountChip email={account.email} />
117 </span>
118 );
119 }
120 return (
121 <span className={className}>
122 {providerClients.get(account.driver)?.label ?? String(account.driver)}
123 </span>
124 );
125}
126
127function Row({ label, children }: { readonly label: string; readonly children: ReactNode }) {
128 return (
129 <div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-x-3">
130 <span className="text-muted-foreground">{label}</span>
131 <span className="min-w-0 text-foreground tabular-nums">{children}</span>
132 </div>
133 );
134}
135
136/**
137 * Everything about one account in one window: plan, where it is signed in,
138 * the email on request, reset time and share of the pool it restores, and the
139 * reset-credit action. Opens on hover for a glance, on click to act.
140 */
141function SegmentPopover({
142 account,
143 window,
144 reset,
145 now,
146 redeem,
147 onRedeem,
148}: {
149 readonly account: LimitAccount;
150 readonly window: LimitPoolMember["window"];
151 readonly reset: LimitPoolWindow["resets"][number] | undefined;
152 readonly now: number;
153 /** Redeem state owned by the segment, since the confirm lives outside this popover. */
154 readonly redeem: ReturnType<typeof useResetCredit> | null;
155 readonly onRedeem: () => void;
156}) {
157 const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);
158 const remaining = remainingPercent(window);
159 const resetsIn = formatResetsIn(window, now);
160 const where =
161 account.environments.length > 0
162 ? account.environments.map((environment) => environment.label).join(", ")
163 : account.sourceLabel;
164 const credits =
165 redeem && account.limits.resetCredits?.availableCount ? account.limits.resetCredits : null;
166 return (
167 <div className="flex w-72 max-w-[calc(100vw-3rem)] flex-col gap-2.5 text-xs">
168 <div className="flex min-w-0 flex-col gap-0.5">
169 <span className="flex items-center gap-2 text-sm font-medium text-foreground">
170 <AccountAvatar account={account} />
171 <span className="truncate">
172 {account.displayName ?? providerClients.get(account.driver)?.label ?? account.driver}
173 </span>
174 </span>
175 {account.email ? (
176 <RedactedSensitiveText
177 value={account.email}
178 ariaLabel="Toggle account email visibility"
179 revealTooltip="Click to reveal email"
180 hideTooltip="Click to hide email"
181 className="w-fit"
182 />
183 ) : null}
184 </div>
185 <div className="flex flex-col gap-1 border-t border-border/60 pt-2.5">
186 {account.plan ? <Row label="Plan">{account.plan}</Row> : null}
187 {where ? (
188 <Row label={account.environments.length > 0 ? "Signed in" : "Via"}>{where}</Row>
189 ) : null}
190 </div>
191 <div className="flex flex-col gap-1 border-t border-border/60 pt-2.5">
192 <Row label="Left">{remaining}%</Row>
193 {window.resetsAt ? (
194 <Row label="Resets">
195 {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)}
196 {resetsIn ? ` · ${resetsIn.replace("resets in ", "in ")}` : ""}
197 </Row>
198 ) : null}
199 {reset && reset.restoresPercent > 0 ? (
200 <Row label="Restores">+{reset.restoresPercent}% of pool</Row>
201 ) : null}
202 </div>
203 {credits && redeem ? (
204 <div className="border-t border-border/60 pt-2.5 text-muted-foreground">
205 <span className="flex items-center gap-3">
206 <span className="tabular-nums">{resetCreditsSummary(credits, now, true)}</span>
207 <Button
208 size="xs"
209 variant="outline"
210 disabled={redeem.busy || !redeem.canManageProviders}
211 className="ms-auto"
212 onClick={onRedeem}
213 >
214 {redeem.busy ? "Using…" : "Use reset"}
215 </Button>
216 </span>
217 </div>
218 ) : null}
219 </div>
220 );
221}
222
223/**
224 * One account's share of one pooled window: the segment, its popover, and the
225 * reset confirm. The confirm is a sibling of the popover, not a child: dialogs
226 * stack under popovers, and the popover closes as the confirm opens.
227 */
228function PoolSegment({
229 account,
230 window,
231 reset,
232 color,
233 now,
234 index,
235 showAccountName,
236 onRedeem,
237 resetReturnFocus,
238}: {
239 readonly account: LimitAccount;
240 readonly window: LimitPoolMember["window"];
241 readonly reset: LimitPoolWindow["resets"][number] | undefined;
242 readonly color: string;
243 readonly now: number;
244 /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */
245 readonly index: number;
246 readonly showAccountName: boolean;
247 readonly onRedeem: () => void;
248 readonly resetReturnFocus: RefObject<HTMLElement | null>;
249}) {
250 const [open, setOpen] = useState(false);
251 const remaining = remainingPercent(window);
252 const resetsIn = formatResetsIn(window, now);
253 const credits = account.limits.resetCredits?.availableCount ?? 0;
254 return (
255 <Popover open={open} onOpenChange={setOpen}>
256 <PopoverTrigger
257 openOnHover
258 render={
259 <button
260 type="button"
261 style={{ gridColumn: index, gridRow: 1 }}
262 aria-label={`${account.displayName ?? (account.email ? accountInitials(account.email) : account.driver)}: ${remaining}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset ${credits === 1 ? "credit" : "credits"} banked` : ""}`}
263 className="relative h-5 min-w-0 cursor-pointer overflow-hidden rounded-md bg-muted text-start outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[popup-open]:ring-1 data-[popup-open]:ring-inset data-[popup-open]:ring-border @2xl/pool:h-8"
264 />
265 }
266 >
267 {/* Translucent so the label reads over the fill for any provider colour and theme. */}
268 <div
269 aria-hidden
270 className="absolute inset-y-0 left-0 rounded-md opacity-35"
271 style={{ width: `${remaining}%`, backgroundColor: color }}
272 />
273 {/* The spent share is hatched, not blank: it is what the countdown restores. */}
274 {remaining < 100 && reset ? (
275 <div
276 aria-hidden
277 className="absolute inset-y-0 right-0 opacity-20"
278 style={{
279 width: `${100 - remaining}%`,
280 backgroundImage: `repeating-linear-gradient(135deg, ${color} 0 1px, transparent 1px 5px)`,
281 }}
282 />
283 ) : null}
284 <span
285 aria-hidden
286 className="absolute inset-0 flex items-center justify-center text-3xs leading-none font-semibold text-foreground/80 tabular-nums @2xl/pool:hidden"
287 >
288 {index}
289 </span>
290 <div className="relative hidden h-full min-w-0 items-center gap-1.5 px-2 text-xs @2xl/pool:flex">
291 {showAccountName ? (
292 <AccountName
293 account={account}
294 className="min-w-0 truncate font-medium text-foreground"
295 />
296 ) : null}
297 <span className="shrink-0 font-semibold text-foreground tabular-nums">{remaining}%</span>
298 {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */}
299 <span className="ms-auto flex shrink-0 items-center gap-1.5 rounded-sm bg-background/85 px-1.5 py-0.5 text-2xs text-foreground tabular-nums">
300 {resetsIn?.replace("resets in ", "↻ ") ?? ""}
301 {credits ? (
302 <>
303 {resetsIn ? (
304 <span aria-hidden className="text-muted-foreground">
305 ·
306 </span>
307 ) : null}
308 <span aria-hidden className="inline-flex items-center gap-0.5 font-semibold">
309 <TicketIcon className="size-3" aria-hidden />
310 {credits}
311 </span>
312 </>
313 ) : null}
314 </span>
315 </div>
316 </PopoverTrigger>
317 <LegendRow account={account} window={window} color={color} now={now} index={index} />
318 {account.redeem ? (
319 <RedeemableSegmentPopup
320 account={account}
321 window={window}
322 reset={reset}
323 now={now}
324 redeemAt={account.redeem}
325 closePopover={() => setOpen(false)}
326 onRedeem={onRedeem}
327 resetReturnFocus={resetReturnFocus}
328 />
329 ) : (
330 <PopoverPopup side="top" sideOffset={6}>
331 <SegmentPopover
332 account={account}
333 window={window}
334 reset={reset}
335 now={now}
336 redeem={null}
337 onRedeem={() => {}}
338 />
339 </PopoverPopup>
340 )}
341 </Popover>
342 );
343}
344
345/**
346 * Below the strip at narrow widths: one row per account in bar order, carrying
347 * the text the segment has no room for. Tapping a row opens the same popover
348 * as its segment, so the two are one control with two handles.
349 */
350function LegendRow({
351 account,
352 window,
353 color,
354 now,
355 index,
356}: {
357 readonly account: LimitAccount;
358 readonly window: LimitPoolMember["window"];
359 readonly color: string;
360 readonly now: number;
361 readonly index: number;
362}) {
363 const remaining = remainingPercent(window);
364 const resetsIn = formatResetsIn(window, now);
365 const credits = account.limits.resetCredits?.availableCount ?? 0;
366 return (
367 <PopoverTrigger
368 style={{ gridColumn: "1 / -1", gridRow: index + 1 }}
369 render={<Button variant="ghost" size="compact" />}
370 className="min-w-0 @2xl/pool:hidden"
371 >
372 <span className="relative inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-3xs leading-none font-semibold text-foreground/80 tabular-nums">
373 <span
374 aria-hidden
375 className="absolute inset-0 rounded-sm opacity-35"
376 style={{ backgroundColor: color }}
377 />
378 <span className="sr-only">Segment </span>
379 <span className="relative">{index}</span>
380 </span>
381 <AccountName account={account} className="min-w-0 truncate font-medium text-foreground" />
382 <span className="shrink-0 font-semibold text-foreground tabular-nums">{remaining}%</span>
383 <span className="ms-auto flex shrink-0 items-center gap-1.5 text-2xs text-muted-foreground tabular-nums">
384 {resetsIn?.replace("resets in ", "↻ ") ?? ""}
385 {credits ? (
386 <>
387 {resetsIn ? <span aria-hidden>·</span> : null}
388 <span
389 aria-hidden
390 className="inline-flex items-center gap-0.5 font-semibold text-foreground"
391 >
392 <TicketIcon className="size-3" aria-hidden />
393 {credits}
394 </span>
395 <span className="sr-only">
396 {credits} reset {credits === 1 ? "credit" : "credits"} banked
397 </span>
398 </>
399 ) : null}
400 </span>
401 </PopoverTrigger>
402 );
403}
404
405/** Split out so the redeem hook only runs for accounts that can redeem. */
406function RedeemableSegmentPopup({
407 account,
408 window,
409 reset,
410 now,
411 redeemAt,
412 closePopover,
413 onRedeem,
414 resetReturnFocus,
415}: {
416 readonly account: LimitAccount;
417 readonly window: LimitPoolMember["window"];
418 readonly reset: LimitPoolWindow["resets"][number] | undefined;
419 readonly now: number;
420 readonly redeemAt: NonNullable<LimitAccount["redeem"]>;
421 readonly closePopover: () => void;
422 readonly onRedeem: () => void;
423 readonly resetReturnFocus: RefObject<HTMLElement | null>;
424}) {
425 const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input);
426 return (
427 <>
428 <PopoverPopup side="top" sideOffset={6}>
429 <SegmentPopover
430 account={account}
431 window={window}
432 reset={reset}
433 now={now}
434 redeem={redeem}
435 onRedeem={() => {
436 closePopover();
437 onRedeem();
438 redeem.setConfirming(true);
439 }}
440 />
441 </PopoverPopup>
442 <ResetCreditDialog
443 finalFocus={resetReturnFocus}
444 open={redeem.confirming}
445 onOpenChange={redeem.setConfirming}
446 onConfirm={() => void redeem.redeem()}
447 disabled={!redeem.canManageProviders}
448 />
449 {/* The popover closed before the confirm, so the outcome needs a home outside it. */}
450 {redeem.status ? (
451 <span role="status" className="col-span-full text-xs text-muted-foreground">
452 <AccountName account={account} className="font-medium text-foreground" /> {redeem.status}
453 </span>
454 ) : null}
455 </>
456 );
457}
458
459/**
460 * One pooled window as equal-width segments, one per account, each filled by
461 * the share of that account's quota still open. Equal widths are honest: every
462 * account contributes the same share of the pool, whatever its plan.
463 *
464 * Wide, each segment carries its own label. Narrow, the bar is a bare strip
465 * and a legend below lists the accounts in the same order; both open the
466 * same popover.
467 */
468function PoolBar({
469 pool,
470 color,
471 now,
472 onRedeem,
473 resetReturnFocus,
474}: {
475 readonly pool: LimitPoolWindow;
476 readonly color: string;
477 readonly now: number;
478 readonly onRedeem: () => void;
479 readonly resetReturnFocus: RefObject<HTMLElement | null>;
480}) {
481 const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset]));
482 return (
483 <div className="@container/pool min-w-0">
484 <div
485 className="grid gap-x-1 gap-y-1"
486 style={{ gridTemplateColumns: `repeat(${pool.columns.length}, minmax(0, 1fr))` }}
487 >
488 {pool.columns.map((member, position) =>
489 member.window ? (
490 <PoolSegment
491 key={member.account.key}
492 account={member.account}
493 window={member.window}
494 reset={restores.get(member.account.key)}
495 color={color}
496 now={now}
497 index={position + 1}
498 showAccountName={pool.columns.length > 1}
499 onRedeem={onRedeem}
500 resetReturnFocus={resetReturnFocus}
501 />
502 ) : null,
503 )}
504 </div>
505 </div>
506 );
507}
508
509/**
510 * Big pooled number and the segment bar. Accounts keep the same column across
511 * windows; each segment's popover shows its own reset time and share restored.
512 */
513function PoolWindowCard({
514 pool,
515 color,
516 now,
517 label,
518 description,
519 onRedeem,
520 resetReturnFocus,
521}: {
522 readonly pool: LimitPoolWindow;
523 readonly color: string;
524 readonly now: number;
525 readonly label?: string | undefined;
526 readonly description?: string | undefined;
527 readonly onRedeem: () => void;
528 readonly resetReturnFocus: RefObject<HTMLElement | null>;
529}) {
530 // The soonest reset that hands anything back; an untouched account resets to no effect.
531 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
532 return (
533 <div className="grid items-center gap-x-6 gap-y-3 rounded-lg border border-border/60 p-4 md:grid-cols-[11rem_minmax(0,1fr)]">
534 <div className="flex flex-col gap-1">
535 <span className="text-sm font-medium text-foreground">{label ?? pool.label}</span>
536 <span className="flex items-baseline gap-2">
537 <span className="text-3xl font-semibold text-foreground tabular-nums">
538 {pool.remainingPercent}%
539 </span>
540 <span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
541 <span>left</span>
542 {pool.pace ? <PaceIcon pace={pool.pace} /> : null}
543 </span>
544 </span>
545 {nextRefill && pool.columns.length > 1 ? (
546 <span className="text-xs font-medium text-foreground tabular-nums">
547 ↻ +{nextRefill.restoresPercent}%
548 </span>
549 ) : null}
550 </div>
551 <PoolBar
552 pool={pool}
553 color={color}
554 now={now}
555 onRedeem={onRedeem}
556 resetReturnFocus={resetReturnFocus}
557 />
558 {description ? (
559 <p className="text-xs text-muted-foreground md:col-span-2">{description}</p>
560 ) : null}
561 </div>
562 );
563}
564
565function PoolSection({
566 pool,
567 now,
568 onRedeem,
569 resetReturnFocus,
570}: {
571 readonly pool: LimitPool;
572 readonly now: number;
573 readonly onRedeem: () => void;
574 readonly resetReturnFocus: RefObject<HTMLElement | null>;
575}) {
576 const color = barColor(pool.driver);
577 const label = providerClients.get(pool.driver)?.label ?? String(pool.driver);
578 const windows = displayLimitWindows(pool);
579 return (
580 <section className="flex flex-col gap-3">
581 <h2 className="flex items-center gap-2 text-sm font-medium text-foreground">
582 <ProviderInstanceIcon
583 driverKind={pool.driver}
584 displayName={label}
585 indicatorBackground="var(--background)"
586 className="size-5"
587 iconClassName="size-4 text-foreground/80"
588 />
589 {label}
590 </h2>
591 {windows.map((window) => {
592 const details = pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined;
593 return (
594 <PoolWindowCard
595 key={`${window.kind}:${window.id}`}
596 pool={window}
597 color={color}
598 now={now}
599 label={details?.label}
600 description={details?.description}
601 onRedeem={onRedeem}
602 resetReturnFocus={resetReturnFocus}
603 />
604 );
605 })}
606 </section>
607 );
608}
609
610/**
611 * Accounts pooled per provider: what is open across all of them, who resets
612 * next, and how much of the pool that hands back. Answers "can I keep going"
613 * before "on which account".
614 */
615export function UsageLimitsPooled({
616 presentations,
617 now,
618 onRedeem,
619 resetReturnFocus,
620}: {
621 readonly presentations: Parameters<typeof collectLimitAccounts>[0];
622 readonly now: number;
623 readonly onRedeem: () => void;
624 readonly resetReturnFocus: RefObject<HTMLElement | null>;
625}) {
626 const accounts = collectLimitAccounts(presentations).flatMap((account) => {
627 const windows = account.limits.windows.filter((window) =>
628 isUsageLimitWindowVisible(account.driver, window),
629 );
630 return windows.length > 0 ? [{ ...account, limits: { ...account.limits, windows } }] : [];
631 });
632 const pools = collectLimitPools(accounts, now);
633 const notices = collectLimitNotices(presentations);
634 const externalLinks = collectExternalUsageLinks(presentations);
635 return (
636 <div className="flex flex-col gap-8">
637 {pools.length === 0 && notices.length === 0 && externalLinks.length === 0 ? (
638 <p className="text-sm text-muted-foreground">
639 No provider on the selected environments reports subscription limits.
640 </p>
641 ) : null}
642 {pools.map((pool) => (
643 <PoolSection
644 key={pool.driver}
645 pool={pool}
646 now={now}
647 onRedeem={onRedeem}
648 resetReturnFocus={resetReturnFocus}
649 />
650 ))}
651 {externalLinks.map((link) => (
652 <section
653 key={link.url}
654 className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-4"
655 >
656 <div className="flex min-w-0 flex-1 items-center gap-3">
657 {link.url === CHATGPT_USAGE_URL ? (
658 <OpenAI className="size-5 shrink-0" aria-hidden="true" />
659 ) : null}
660 <div className="min-w-0 space-y-1">
661 <h2 className="text-sm font-medium">{link.label}</h2>
662 {link.url === CHATGPT_USAGE_URL ? (
663 <p className="text-xs text-muted-foreground">
664 View usage in ChatGPT with your connected account.
665 </p>
666 ) : link.message ? (
667 <p className="max-w-xl text-xs text-muted-foreground">{link.message}</p>
668 ) : null}
669 </div>
670 </div>
671 <Button
672 variant="ghost-muted"
673 size="xs"
674 onClick={() => void ensureLocalApi().shell.openExternal(link.url)}
675 >
676 Manage usage
677 <ExternalLinkIcon className="size-3.5" aria-hidden="true" />
678 </Button>
679 </section>
680 ))}
681 <LimitNotices notices={notices} />
682 </div>
683 );
684}
685
686/** Sources and providers that could not be read, so a missing bar is not mistaken for a full one. */
687function LimitNotices({ notices }: { readonly notices: readonly string[] }) {
688 if (notices.length === 0) return null;
689 return (
690 <Alert variant="warning" controlAlignment="first-line">
691 <AlertTriangleIcon />
692 {notices.map((notice) => (
693 <AlertTitle key={notice} className="break-words">
694 {notice}
695 </AlertTitle>
696 ))}
697 </Alert>
698 );
699}