| 1 | import { |
| 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"; |
| 17 | import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react"; |
| 18 | import { type ReactNode, type RefObject, useState } from "react"; |
| 19 | |
| 20 | import { ensureLocalApi } from "../../localApi"; |
| 21 | import { usePrimarySettings } from "../../hooks/useSettings"; |
| 22 | import { cn } from "../../lib/utils"; |
| 23 | import { formatUpcomingTimestamp } from "../../timestampFormat"; |
| 24 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 25 | import { providerClients } from "../settings/providerDriverMeta"; |
| 26 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; |
| 27 | import { Button } from "../ui/button"; |
| 28 | import { OpenAI } from "../Icons"; |
| 29 | import { Alert, AlertTitle } from "../ui/alert"; |
| 30 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 31 | import { |
| 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. */ |
| 40 | export 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. */ |
| 46 | function 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. */ |
| 55 | function 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 | */ |
| 73 | function 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 | */ |
| 105 | function 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 | |
| 127 | function 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 | */ |
| 141 | function 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 | */ |
| 228 | function 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 | */ |
| 350 | function 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. */ |
| 406 | function 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 | */ |
| 468 | function 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 | */ |
| 513 | function 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 | |
| 565 | function 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 | */ |
| 615 | export 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. */ |
| 687 | function 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 | } |