authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 00:02:37-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 00:02:37-07:00
logb273d1cfe9515f905d37a9146bb275b905d14399
tree14495405932936b886ced1f8123579b28a9a5e4f
parentd924fe266483e1d400775e3c3ce784f8e90f355f
signature Signed by PGP key B5690EEEBB952194

feat(usage): pool subscription limits per provider across accounts and environments (#10300)


9 files changed, 1823 insertions(+), 246 deletions(-)

apps/server/src/usage/cliproxyUsageLimits.test.ts+16
......@@ -102,6 +102,22 @@ describe("cliproxyStatusToAccounts", () => {
102102 },
103103 ]);
104104 });
105
106 it("names a Codex five-hour window `primary`, as the Codex driver does", () => {
107 const accounts = cliproxyStatusToAccounts(
108 {
109 accounts: {
110 "codex-abc-someone@example.com-pro.json": {
111 provider: "codex",
112 plan: "pro",
113 five_hour: { hard_limited: false, known: true, used_percent: 40 },
114 },
115 },
116 },
117 checkedAt,
118 );
119 expect(accounts[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["primary"]);
120 });
105121});
106122
107123describe("accountEmailFromAuthFile", () => {
apps/server/src/usage/cliproxyUsageLimits.ts+3-1
......@@ -124,7 +124,9 @@ export function cliproxyAccountToUsageLimits(
124124 if (!window || window.known === false) continue;
125125 const resetsAt = isoFromHub(window.reset_at);
126126 windows.push({
127 id: spec.id,
127 // Codex names its five-hour window by position, so a hub row and a
128 // native row for the same account pool together.
129 id: spec.key === "five_hour" && account.provider === "codex" ? "primary" : spec.id,
128130 kind: spec.kind,
129131 label: spec.label,
130132 windowDurationMins: spec.windowDurationMins,
apps/web/src/components/usage/UsageLimits.tsx+93-234
......@@ -5,21 +5,16 @@ import {
55 ServerProvider,
66 ServerProviderResetCredits,
77 ServerProviderUsageWindow,
8 UsageLimitSourceAccount,
9 UsageLimitSourceSnapshot,
108 UsageProviderKind,
119} from "@t3tools/contracts";
1210import { useAtomValue } from "@effect/atom-react";
1311import {
14 collectLimitSources,
15 collectLimitsGroups,
12 collectLimitAccounts,
1613 elapsedShare,
1714 formatDuration,
1815 formatResetsIn,
19 limitsNotice,
2016 type LimitPace,
2117 paceOf,
22 providerLimitsLabel,
2318 remainingPercent,
2419} from "@t3tools/shared/usageLimits";
2520import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
......@@ -30,9 +25,6 @@ import { environmentPresentations } from "../../state/presentation";
3025import { serverEnvironment } from "../../state/server";
3126import { useAtomCommand } from "../../state/use-atom-command";
3227import { formatUpcomingTimestamp } from "../../timestampFormat";
33import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
34import { getDriverOption } from "../settings/providerDriverMeta";
35import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";
3628import {
3729 AlertDialog,
3830 AlertDialogClose,
......@@ -44,6 +36,8 @@ import {
4436} from "../ui/alert-dialog";
4537import { Button } from "../ui/button";
4638import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
39import type { makeLimitsFixture } from "./usageLimitsFixture";
40import { UsageLimitsPooled } from "./UsageLimitsPooled";
4741import { PROVIDER_PRESENTATION } from "./usageProviders";
4842
4943const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof GaugeIcon }> = {
......@@ -53,14 +47,14 @@ const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof Ga
5347};
5448
5549/** The series colour the cost chart uses for this driver, so the two views read as one. */
56function barColor(driver: ServerProvider["driver"]): string {
50export function barColor(driver: ServerProvider["driver"]): string {
5751 const kind: UsageProviderKind | undefined =
5852 driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : undefined;
5953 return kind ? PROVIDER_PRESENTATION[kind].color : "var(--foreground)";
6054}
6155
6256/** Pace as a glyph with the words on hover. */
63function PaceIcon({ pace }: { readonly pace: LimitPace }) {
57export function PaceIcon({ pace }: { readonly pace: LimitPace }) {
6458 const Icon = PACE[pace].icon;
6559 return (
6660 <Tooltip>
......@@ -202,94 +196,6 @@ export function LimitWindows({
202196 );
203197}
204198
205/**
206 * Heading shared by local providers and source accounts: icon, driver, instance, plan,
207 * and the signed-in email blurred until clicked, as provider settings do.
208 */
209function AccountHeading({
210 driver,
211 label,
212 instanceLabel,
213 plan,
214 email,
215 accentColor,
216}: {
217 readonly driver: ServerProvider["driver"];
218 readonly label: string;
219 readonly instanceLabel: string;
220 readonly plan: string | undefined;
221 readonly email: string | undefined;
222 readonly accentColor?: string | undefined;
223}) {
224 return (
225 <h2 className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium text-foreground">
226 <ProviderInstanceIcon
227 driverKind={driver}
228 displayName={instanceLabel}
229 accentColor={accentColor}
230 showBadge={Boolean(accentColor)}
231 indicatorBackground="var(--background)"
232 className="size-5"
233 iconClassName="size-4 text-foreground/80"
234 />
235 <span className="truncate">{label}</span>
236 {instanceLabel !== label ? (
237 <span className="min-w-0 truncate text-xs font-normal text-muted-foreground">
238 · {instanceLabel}
239 </span>
240 ) : null}
241 {plan ? <span className="font-normal text-muted-foreground">· {plan}</span> : null}
242 {email ? (
243 <RedactedSensitiveText
244 value={email}
245 ariaLabel="Toggle account email visibility"
246 revealTooltip="Click to reveal email"
247 hideTooltip="Click to hide email"
248 />
249 ) : null}
250 </h2>
251 );
252}
253
254function ProviderLimits({
255 provider,
256 environmentId,
257 now,
258}: {
259 readonly provider: ServerProvider;
260 readonly environmentId: EnvironmentId;
261 readonly now: number;
262}) {
263 const limits = provider.usageLimits;
264 if (!limits) return null;
265 const notice = limitsNotice(limits);
266 return (
267 <section className="flex flex-col gap-3">
268 <AccountHeading
269 driver={provider.driver}
270 label={getDriverOption(provider.driver)?.label ?? String(provider.driver)}
271 instanceLabel={providerLimitsLabel(provider, (driver) => getDriverOption(driver)?.label)}
272 plan={provider.auth.label}
273 email={provider.auth.email}
274 accentColor={provider.accentColor}
275 />
276 {notice ? (
277 <span className="text-xs text-muted-foreground">{notice}</span>
278 ) : (
279 <LimitWindows driver={provider.driver} windows={limits.windows} now={now} />
280 )}
281 {limits.resetCredits ? (
282 <ResetCredits
283 environmentId={environmentId}
284 instanceId={provider.instanceId}
285 credits={limits.resetCredits}
286 now={now}
287 />
288 ) : null}
289 </section>
290 );
291}
292
293199const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
294200 reset: "Reset applied. Your windows have cleared.",
295201 nothingToReset: "Nothing to reset right now.",
......@@ -297,36 +203,12 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
297203 alreadyRedeemed: "That credit was already redeemed.",
298204};
299205
300/**
301 * Banked reset credits with a confirmed redeem action. Redeeming spends a
302 * credit the provider granted the user, so it never fires on a bare click.
303 */
304export function ResetCredits({
305 environmentId,
306 instanceId,
307 credits,
308 now,
309}: {
310 readonly environmentId: EnvironmentId;
311 readonly instanceId: ProviderInstanceId;
312 readonly credits: ServerProviderResetCredits;
313 readonly now: number;
314}) {
206/** Everything a redeem needs: where to send it and what to say afterwards. */
207export function useResetCredit(environmentId: EnvironmentId, instanceId: ProviderInstanceId) {
315208 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false });
316209 const [confirming, setConfirming] = useState(false);
317210 const [busy, setBusy] = useState(false);
318211 const [status, setStatus] = useState<string | null>(null);
319 if (credits.availableCount === 0 && status === null) return null;
320
321 const expiresIn = credits.nextExpiresAt
322 ? formatDuration(Date.parse(credits.nextExpiresAt) - now)
323 : null;
324 const summary =
325 credits.availableCount === 0
326 ? "No reset credits banked"
327 : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${
328 expiresIn ? ` · next expires in ${expiresIn}` : ""
329 }`;
330212
331213 const redeem = async () => {
332214 setConfirming(false);
......@@ -345,138 +227,115 @@ export function ResetCredits({
345227 );
346228 };
347229
348 return (
349 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
350 <span className="tabular-nums">{summary}</span>
351 {credits.availableCount > 0 ? (
352 <Button size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(true)}>
353 {busy ? "Using…" : "Use reset"}
354 </Button>
355 ) : null}
356 {status ? <span className="text-foreground">{status}</span> : null}
357 <AlertDialog open={confirming} onOpenChange={setConfirming}>
358 <AlertDialogPopup>
359 <AlertDialogHeader>
360 <AlertDialogTitle>Use a reset credit?</AlertDialogTitle>
361 <AlertDialogDescription>
362 This redeems one credit on your account and clears the current rate-limit windows. It
363 cannot be undone.
364 </AlertDialogDescription>
365 </AlertDialogHeader>
366 <AlertDialogFooter>
367 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
368 <Button onClick={() => void redeem()}>Use credit</Button>
369 </AlertDialogFooter>
370 </AlertDialogPopup>
371 </AlertDialog>
372 </div>
373 );
230 return { confirming, setConfirming, busy, status, redeem };
374231}
375232
376/** One account pooled by a usage-limit source, drawn like a provider row. */
377function SourceAccountLimits({
378 account,
379 sourceKind,
380 now,
233/**
234 * The confirm for a redeem. Redeeming spends a credit the provider granted the
235 * user, so it never fires on a bare click. Mount it outside any popover that
236 * holds the button: dialogs stack under popovers, and closing the popover
237 * would unmount a dialog rendered inside it.
238 */
239export function ResetCreditDialog({
240 open,
241 onOpenChange,
242 onConfirm,
381243}: {
382 readonly account: UsageLimitSourceAccount;
383 readonly sourceKind: string;
384 readonly now: number;
244 readonly open: boolean;
245 readonly onOpenChange: (open: boolean) => void;
246 readonly onConfirm: () => void;
385247}) {
386 const notice = limitsNotice(account.usageLimits);
387248 return (
388 <section className="flex flex-col gap-3">
389 <AccountHeading
390 driver={account.driver}
391 label={getDriverOption(account.driver)?.label ?? String(account.driver)}
392 instanceLabel={sourceKind}
393 plan={account.plan}
394 email={account.email}
395 />
396 {notice ? (
397 <span className="text-xs text-muted-foreground">{notice}</span>
398 ) : (
399 <LimitWindows driver={account.driver} windows={account.usageLimits.windows} now={now} />
400 )}
401 </section>
249 <AlertDialog open={open} onOpenChange={onOpenChange}>
250 <AlertDialogPopup>
251 <AlertDialogHeader>
252 <AlertDialogTitle>Use a reset credit?</AlertDialogTitle>
253 <AlertDialogDescription>
254 This redeems one credit on your account and clears the current rate-limit windows. It
255 cannot be undone.
256 </AlertDialogDescription>
257 </AlertDialogHeader>
258 <AlertDialogFooter>
259 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
260 <Button onClick={onConfirm}>Use credit</Button>
261 </AlertDialogFooter>
262 </AlertDialogPopup>
263 </AlertDialog>
402264 );
403265}
404266
405const SOURCE_KIND_LABEL: Record<UsageLimitSourceSnapshot["kind"], string> = {
406 cliproxy: "CLI Proxy",
407};
408
409type LimitsSource = ReturnType<typeof collectLimitSources>[number];
267/** `2 reset credits banked · next expires in 27d 23h`, or the short form for a popover. */
268export function resetCreditsSummary(
269 credits: ServerProviderResetCredits,
270 now: number,
271 compact = false,
272): string {
273 const expiresIn = credits.nextExpiresAt
274 ? formatDuration(Date.parse(credits.nextExpiresAt) - now)
275 : null;
276 if (credits.availableCount === 0) return "No reset credits banked";
277 if (compact)
278 return `${credits.availableCount} banked${expiresIn ? ` · expires in ${expiresIn}` : ""}`;
279 return `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${
280 expiresIn ? ` · next expires in ${expiresIn}` : ""
281 }`;
282}
410283
411/** Read-only accounts pooled by a configured usage source. */
412function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly now: number }) {
413 const kind = SOURCE_KIND_LABEL[source.kind];
284/** Banked reset credits with the redeem button and its confirm, self-contained. */
285export function ResetCredits({
286 environmentId,
287 instanceId,
288 credits,
289 now,
290}: {
291 readonly environmentId: EnvironmentId;
292 readonly instanceId: ProviderInstanceId;
293 readonly credits: ServerProviderResetCredits;
294 readonly now: number;
295}) {
296 const { confirming, setConfirming, busy, status, redeem } = useResetCredit(
297 environmentId,
298 instanceId,
299 );
300 if (credits.availableCount === 0 && status === null) return null;
414301 return (
415 <div className="flex flex-col gap-6">
416 {source.error ? (
417 <span className="text-xs text-muted-foreground">{source.error}</span>
418 ) : source.accounts.length === 0 ? (
419 <span className="text-xs text-muted-foreground">
420 {source.hiddenAccountCount > 0
421 ? "All accounts are shown by connected providers."
422 : "No accounts reported."}
423 </span>
424 ) : (
425 source.accounts.map((account) => (
426 <SourceAccountLimits key={account.id} account={account} sourceKind={kind} now={now} />
427 ))
428 )}
302 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
303 <span className="tabular-nums">{resetCreditsSummary(credits, now)}</span>
304 {credits.availableCount > 0 ? (
305 <Button size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(true)}>
306 {busy ? "Using…" : "Use reset"}
307 </Button>
308 ) : null}
309 {status ? <span className="text-foreground">{status}</span> : null}
310 <ResetCreditDialog
311 open={confirming}
312 onOpenChange={setConfirming}
313 onConfirm={() => void redeem()}
314 />
429315 </div>
430316 );
431317}
432318
433319/**
434 * Subscription quota windows from every connected environment's providers.
435 * Countdowns anchor to render time rather than ticking: a live clock would
436 * repaint the page every minute for no decision-changing gain.
320 * Subscription quota across every connected environment's providers and hubs,
321 * pooled per provider. Countdowns anchor to render time rather than ticking: a
322 * live clock would repaint the page every minute for no decision-changing gain.
437323 */
438324export function UsageLimitsSection({
439325 selectedEnvironmentIds,
326 fixture = null,
440327}: {
441328 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
329 /** Dev-only synthetic presentations standing in for the live ones. */
330 readonly fixture?: ReturnType<typeof makeLimitsFixture> | null;
442331}) {
443 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
332 const live = useAtomValue(environmentPresentations.presentationsAtom);
333 // Anchored once per mount on purpose: countdowns must not tick (see above).
334 const [now] = useState(() => Date.now());
335 const presentations: Parameters<typeof collectLimitAccounts>[0] = fixture ?? live;
444336 const selected =
445337 selectedEnvironmentIds === null
446338 ? presentations
447339 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
448 const groups = collectLimitsGroups(selected);
449 const sources = collectLimitSources(selected);
450 // Anchored once per mount on purpose: countdowns must not tick (see below).
451 const [now] = useState(() => Date.now());
452
453 return (
454 <div className="flex flex-col gap-8">
455 {groups.length === 0 && sources.length === 0 ? (
456 <p className="text-sm text-muted-foreground">
457 No provider on the selected environments reports subscription limits.
458 </p>
459 ) : null}
460 {sources.map((source) => (
461 <SourceLimits key={source.key} source={source} now={now} />
462 ))}
463 {groups.map((group) => (
464 <div key={group.environmentId} className="flex flex-col gap-6">
465 {group.environmentLabel ? (
466 <h2 className="text-xs tracking-wide text-muted-foreground uppercase">
467 {group.environmentLabel}
468 </h2>
469 ) : null}
470 {group.providers.map((provider) => (
471 <ProviderLimits
472 key={provider.instanceId}
473 provider={provider}
474 environmentId={group.environmentId}
475 now={now}
476 />
477 ))}
478 </div>
479 ))}
480 </div>
481 );
340 return <UsageLimitsPooled presentations={selected} now={now} />;
482341}
apps/web/src/components/usage/UsageLimitsPooled.tsx created+570
......@@ -0,0 +1,570 @@
1import {
2 collectLimitAccounts,
3 collectLimitNotices,
4 collectLimitPools,
5 formatDuration,
6 formatResetsIn,
7 type LimitAccount,
8 type LimitPool,
9 type LimitPoolMember,
10 type LimitPoolWindow,
11 remainingPercent,
12} from "@t3tools/shared/usageLimits";
13import { TicketIcon } from "lucide-react";
14import { type ReactNode, useState } from "react";
15
16import { usePrimarySettings } from "../../hooks/useSettings";
17import { cn } from "../../lib/utils";
18import { formatUpcomingTimestamp } from "../../timestampFormat";
19import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
20import { getDriverOption } from "../settings/providerDriverMeta";
21import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";
22import { Button } from "../ui/button";
23import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
24import {
25 PaceIcon,
26 ResetCreditDialog,
27 barColor,
28 resetCreditsSummary,
29 useResetCredit,
30} from "./UsageLimits";
31
32/** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */
33function accountInitials(email: string): string {
34 const [local = "", domain = ""] = email.split("@");
35 return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "?";
36}
37
38/** A stable hue per email, so the same account gets the same chip on every visit. */
39function accountHue(email: string): number {
40 let hash = 0;
41 for (let index = 0; index < email.length; index += 1) {
42 hash = (hash * 31 + email.charCodeAt(index)) | 0;
43 }
44 return Math.abs(hash) % 360;
45}
46
47/** The two-letter chip for an email, coloured by a stable hue per address. */
48function AccountChip({ email }: { readonly email: string }) {
49 const hue = accountHue(email);
50 return (
51 <span
52 role="img"
53 aria-label={`Account ${accountInitials(email)}`}
54 className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-[9px] leading-none font-semibold"
55 style={{ backgroundColor: `oklch(0.85 0.08 ${hue})`, color: `oklch(0.35 0.1 ${hue})` }}
56 >
57 {accountInitials(email)}
58 </span>
59 );
60}
61
62/**
63 * The same mark the model picker uses for a native instance (provider glyph,
64 * initials badge, accent); hub accounts have no instance, so they get the chip.
65 */
66function AccountAvatar({
67 account,
68 className,
69}: {
70 readonly account: LimitAccount;
71 readonly className?: string;
72}) {
73 if (account.redeem) {
74 return (
75 <ProviderInstanceIcon
76 driverKind={account.driver}
77 displayName={
78 account.displayName ?? getDriverOption(account.driver)?.label ?? String(account.driver)
79 }
80 accentColor={account.accentColor}
81 showBadge={Boolean(account.displayName)}
82 indicatorBackground="var(--popover)"
83 className={cn("size-5", className)}
84 iconClassName="size-4 text-foreground/80"
85 />
86 );
87 }
88 return account.email ? <AccountChip email={account.email} /> : null;
89}
90
91/**
92 * Who an account is, without printing the email: the instance name when there
93 * is one, else a two-letter chip. The address itself is revealed on demand in
94 * the segment's popover.
95 */
96function AccountName({
97 account,
98 className,
99}: {
100 readonly account: LimitAccount;
101 readonly className?: string;
102}) {
103 if (account.displayName) return <span className={className}>{account.displayName}</span>;
104 if (account.email) {
105 return (
106 <span className={cn("inline-flex min-w-0 items-center", className)}>
107 <AccountChip email={account.email} />
108 </span>
109 );
110 }
111 return (
112 <span className={className}>
113 {getDriverOption(account.driver)?.label ?? String(account.driver)}
114 </span>
115 );
116}
117
118function Row({ label, children }: { readonly label: string; readonly children: ReactNode }) {
119 return (
120 <div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-x-3">
121 <span className="text-muted-foreground">{label}</span>
122 <span className="min-w-0 text-foreground tabular-nums">{children}</span>
123 </div>
124 );
125}
126
127/**
128 * Everything about one account in one window: plan, where it is signed in,
129 * the email on request, reset time and share of the pool it restores, and the
130 * reset-credit action. Opens on hover for a glance, on click to act.
131 */
132function SegmentPopover({
133 account,
134 window,
135 reset,
136 now,
137 redeem,
138 onRedeem,
139}: {
140 readonly account: LimitAccount;
141 readonly window: LimitPoolMember["window"];
142 readonly reset: LimitPoolWindow["resets"][number] | undefined;
143 readonly now: number;
144 /** Redeem state owned by the segment, since the confirm lives outside this popover. */
145 readonly redeem: ReturnType<typeof useResetCredit> | null;
146 readonly onRedeem: () => void;
147}) {
148 const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);
149 const remaining = remainingPercent(window);
150 const resetsIn = formatResetsIn(window, now);
151 const where =
152 account.environments.length > 0
153 ? account.environments.map((environment) => environment.label).join(", ")
154 : account.sourceLabel;
155 const credits =
156 redeem && account.limits.resetCredits?.availableCount ? account.limits.resetCredits : null;
157 return (
158 <div className="flex w-72 max-w-[calc(100vw-3rem)] flex-col gap-2.5 text-xs">
159 <div className="flex min-w-0 flex-col gap-0.5">
160 <span className="flex items-center gap-2 text-sm font-medium text-foreground">
161 <AccountAvatar account={account} />
162 <span className="truncate">
163 {account.displayName ?? getDriverOption(account.driver)?.label ?? account.driver}
164 </span>
165 </span>
166 {account.email ? (
167 <RedactedSensitiveText
168 value={account.email}
169 ariaLabel="Toggle account email visibility"
170 revealTooltip="Click to reveal email"
171 hideTooltip="Click to hide email"
172 className="w-fit"
173 />
174 ) : null}
175 </div>
176 <div className="flex flex-col gap-1 border-t border-border/60 pt-2.5">
177 {account.plan ? <Row label="Plan">{account.plan}</Row> : null}
178 {where ? (
179 <Row label={account.environments.length > 0 ? "Signed in" : "Via"}>{where}</Row>
180 ) : null}
181 </div>
182 <div className="flex flex-col gap-1 border-t border-border/60 pt-2.5">
183 <Row label="Left">{remaining}%</Row>
184 {window.resetsAt ? (
185 <Row label="Resets">
186 {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)}
187 {resetsIn ? ` · ${resetsIn.replace("resets in ", "in ")}` : ""}
188 </Row>
189 ) : null}
190 {reset && reset.restoresPercent > 0 ? (
191 <Row label="Restores">+{reset.restoresPercent}% of pool</Row>
192 ) : null}
193 </div>
194 {credits && redeem ? (
195 <div className="border-t border-border/60 pt-2.5 text-muted-foreground">
196 <span className="flex items-center gap-3">
197 <span className="tabular-nums">{resetCreditsSummary(credits, now, true)}</span>
198 <Button
199 size="xs"
200 variant="outline"
201 disabled={redeem.busy}
202 className="ms-auto"
203 onClick={onRedeem}
204 >
205 {redeem.busy ? "Using…" : "Use reset"}
206 </Button>
207 </span>
208 </div>
209 ) : null}
210 </div>
211 );
212}
213
214/**
215 * One account's share of one pooled window: the segment, its popover, and the
216 * reset confirm. The confirm is a sibling of the popover, not a child: dialogs
217 * stack under popovers, and the popover closes as the confirm opens.
218 */
219function PoolSegment({
220 account,
221 window,
222 reset,
223 color,
224 now,
225 index,
226}: {
227 readonly account: LimitAccount;
228 readonly window: LimitPoolMember["window"];
229 readonly reset: LimitPoolWindow["resets"][number] | undefined;
230 readonly color: string;
231 readonly now: number;
232 /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */
233 readonly index: number;
234}) {
235 const [open, setOpen] = useState(false);
236 const remaining = remainingPercent(window);
237 const resetsIn = formatResetsIn(window, now);
238 const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0;
239 return (
240 <Popover open={open} onOpenChange={setOpen}>
241 <PopoverTrigger
242 openOnHover
243 render={
244 <button
245 type="button"
246 style={{ gridColumn: index, gridRow: 1 }}
247 aria-label={`${account.displayName ?? (account.email ? accountInitials(account.email) : account.driver)}: ${remaining}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset ${credits === 1 ? "credit" : "credits"} banked` : ""}`}
248 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-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[popup-open]:ring-1 data-[popup-open]:ring-border @2xl/pool:h-8"
249 />
250 }
251 >
252 {/* Translucent so the label reads over the fill for any provider colour and theme. */}
253 <div
254 aria-hidden
255 className="absolute inset-y-0 left-0 rounded-md opacity-35"
256 style={{ width: `${remaining}%`, backgroundColor: color }}
257 />
258 {/* The spent share is hatched, not blank: it is what the countdown restores. */}
259 {remaining < 100 && reset ? (
260 <div
261 aria-hidden
262 className="absolute inset-y-0 right-0 opacity-20"
263 style={{
264 width: `${100 - remaining}%`,
265 backgroundImage: `repeating-linear-gradient(135deg, ${color} 0 1px, transparent 1px 5px)`,
266 }}
267 />
268 ) : null}
269 <span
270 aria-hidden
271 className="absolute inset-0 flex items-center justify-center text-[10px] leading-none font-semibold text-foreground/80 tabular-nums @2xl/pool:hidden"
272 >
273 {index}
274 </span>
275 <div className="relative hidden h-full min-w-0 items-center gap-1.5 px-2 text-xs @2xl/pool:flex">
276 <AccountName account={account} className="min-w-0 truncate font-medium text-foreground" />
277 <span className="shrink-0 font-semibold text-foreground tabular-nums">{remaining}%</span>
278 {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */}
279 <span className="ms-auto flex shrink-0 items-center gap-1.5 rounded-sm bg-background/85 px-1.5 py-0.5 text-[11px] text-foreground tabular-nums">
280 {resetsIn?.replace("resets in ", "↻ ") ?? ""}
281 {credits ? (
282 <>
283 {resetsIn ? (
284 <span aria-hidden className="text-muted-foreground">
285 ·
286 </span>
287 ) : null}
288 <span aria-hidden className="inline-flex items-center gap-0.5 font-semibold">
289 <TicketIcon className="size-3" aria-hidden />
290 {credits}
291 </span>
292 </>
293 ) : null}
294 </span>
295 </div>
296 </PopoverTrigger>
297 <LegendRow account={account} window={window} color={color} now={now} index={index} />
298 {account.redeem ? (
299 <RedeemableSegmentPopup
300 account={account}
301 window={window}
302 reset={reset}
303 now={now}
304 redeemAt={account.redeem}
305 closePopover={() => setOpen(false)}
306 />
307 ) : (
308 <PopoverPopup side="top" sideOffset={6}>
309 <SegmentPopover
310 account={account}
311 window={window}
312 reset={reset}
313 now={now}
314 redeem={null}
315 onRedeem={() => {}}
316 />
317 </PopoverPopup>
318 )}
319 </Popover>
320 );
321}
322
323/**
324 * Below the strip at narrow widths: one row per account in bar order, carrying
325 * the text the segment has no room for. Tapping a row opens the same popover
326 * as its segment, so the two are one control with two handles.
327 */
328function LegendRow({
329 account,
330 window,
331 color,
332 now,
333 index,
334}: {
335 readonly account: LimitAccount;
336 readonly window: LimitPoolMember["window"];
337 readonly color: string;
338 readonly now: number;
339 readonly index: number;
340}) {
341 const remaining = remainingPercent(window);
342 const resetsIn = formatResetsIn(window, now);
343 const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0;
344 return (
345 <PopoverTrigger
346 style={{ gridColumn: "1 / -1", gridRow: index + 1 }}
347 className="flex min-h-7 min-w-0 cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-start text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring @2xl/pool:hidden"
348 >
349 <span className="relative inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-[10px] leading-none font-semibold text-foreground/80 tabular-nums">
350 <span
351 aria-hidden
352 className="absolute inset-0 rounded-sm opacity-35"
353 style={{ backgroundColor: color }}
354 />
355 <span className="sr-only">Segment </span>
356 <span className="relative">{index}</span>
357 </span>
358 <AccountName account={account} className="min-w-0 truncate font-medium text-foreground" />
359 <span className="shrink-0 font-semibold text-foreground tabular-nums">{remaining}%</span>
360 <span className="ms-auto flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
361 {resetsIn?.replace("resets in ", "↻ ") ?? ""}
362 {credits ? (
363 <>
364 {resetsIn ? <span aria-hidden>·</span> : null}
365 <span
366 aria-hidden
367 className="inline-flex items-center gap-0.5 font-semibold text-foreground"
368 >
369 <TicketIcon className="size-3" aria-hidden />
370 {credits}
371 </span>
372 <span className="sr-only">
373 {credits} reset {credits === 1 ? "credit" : "credits"} banked
374 </span>
375 </>
376 ) : null}
377 </span>
378 </PopoverTrigger>
379 );
380}
381
382/** Split out so the redeem hook only runs for accounts that can redeem. */
383function RedeemableSegmentPopup({
384 account,
385 window,
386 reset,
387 now,
388 redeemAt,
389 closePopover,
390}: {
391 readonly account: LimitAccount;
392 readonly window: LimitPoolMember["window"];
393 readonly reset: LimitPoolWindow["resets"][number] | undefined;
394 readonly now: number;
395 readonly redeemAt: NonNullable<LimitAccount["redeem"]>;
396 readonly closePopover: () => void;
397}) {
398 const redeem = useResetCredit(redeemAt.environmentId, redeemAt.instanceId);
399 return (
400 <>
401 <PopoverPopup side="top" sideOffset={6}>
402 <SegmentPopover
403 account={account}
404 window={window}
405 reset={reset}
406 now={now}
407 redeem={redeem}
408 onRedeem={() => {
409 closePopover();
410 redeem.setConfirming(true);
411 }}
412 />
413 </PopoverPopup>
414 <ResetCreditDialog
415 open={redeem.confirming}
416 onOpenChange={redeem.setConfirming}
417 onConfirm={() => void redeem.redeem()}
418 />
419 {/* The popover closed before the confirm, so the outcome needs a home outside it. */}
420 {redeem.status ? (
421 <span role="status" className="col-span-full text-xs text-muted-foreground">
422 <AccountName account={account} className="font-medium text-foreground" /> {redeem.status}
423 </span>
424 ) : null}
425 </>
426 );
427}
428
429/**
430 * One pooled window as equal-width segments, one per account, each filled by
431 * the share of that account's quota still open. Equal widths are honest: every
432 * account contributes the same share of the pool, whatever its plan.
433 *
434 * Wide, each segment carries its own label. Narrow, the bar is a bare strip
435 * and a legend below lists the accounts in the same order; both open the
436 * same popover.
437 */
438function PoolBar({
439 pool,
440 color,
441 now,
442}: {
443 readonly pool: LimitPoolWindow;
444 readonly color: string;
445 readonly now: number;
446}) {
447 const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset]));
448 return (
449 <div className="@container/pool min-w-0">
450 <div
451 className="grid gap-x-1 gap-y-1"
452 style={{ gridTemplateColumns: `repeat(${pool.members.length}, minmax(0, 1fr))` }}
453 >
454 {pool.members.map(({ account, window }, position) => (
455 <PoolSegment
456 key={account.key}
457 account={account}
458 window={window}
459 reset={restores.get(account.key)}
460 color={color}
461 now={now}
462 index={position + 1}
463 />
464 ))}
465 </div>
466 </div>
467 );
468}
469
470/**
471 * Big pooled number and the segment bar. The bar is sorted by reset, so who
472 * refills next is its left edge; the exact time and share restored live in
473 * each segment's popover rather than a list restating the bar.
474 */
475function PoolWindowCard({
476 pool,
477 color,
478 now,
479}: {
480 readonly pool: LimitPoolWindow;
481 readonly color: string;
482 readonly now: number;
483}) {
484 // The soonest reset that hands anything back; an untouched account resets to no effect.
485 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
486 return (
487 <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)]">
488 <div className="flex flex-col gap-1">
489 <span className="text-sm font-medium text-foreground">{pool.label}</span>
490 <span className="flex items-baseline gap-2">
491 <span className="text-3xl font-semibold text-foreground tabular-nums">
492 {pool.remainingPercent}%
493 </span>
494 <span className="text-sm text-muted-foreground">left</span>
495 {pool.pace ? <PaceIcon pace={pool.pace} /> : null}
496 </span>
497 {nextRefill ? (
498 <span className="text-xs text-muted-foreground tabular-nums">
499 <span className="font-medium text-foreground">↻ +{nextRefill.restoresPercent}%</span>{" "}
500 {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`}
501 </span>
502 ) : null}
503 </div>
504 <PoolBar pool={pool} color={color} now={now} />
505 </div>
506 );
507}
508
509function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) {
510 const color = barColor(pool.driver);
511 const label = getDriverOption(pool.driver)?.label ?? String(pool.driver);
512 return (
513 <section className="flex flex-col gap-3">
514 <h2 className="flex items-center gap-2 text-sm font-medium text-foreground">
515 <ProviderInstanceIcon
516 driverKind={pool.driver}
517 displayName={label}
518 indicatorBackground="var(--background)"
519 className="size-5"
520 iconClassName="size-4 text-foreground/80"
521 />
522 {label}
523 </h2>
524 {pool.windows.map((window) => (
525 <PoolWindowCard key={`${window.kind}:${window.id}`} pool={window} color={color} now={now} />
526 ))}
527 </section>
528 );
529}
530
531/**
532 * Accounts pooled per provider: what is open across all of them, who resets
533 * next, and how much of the pool that hands back. Answers "can I keep going"
534 * before "on which account".
535 */
536export function UsageLimitsPooled({
537 presentations,
538 now,
539}: {
540 readonly presentations: Parameters<typeof collectLimitAccounts>[0];
541 readonly now: number;
542}) {
543 const pools = collectLimitPools(collectLimitAccounts(presentations), now);
544 const notices = collectLimitNotices(presentations);
545 return (
546 <div className="flex flex-col gap-8">
547 {pools.length === 0 ? (
548 <p className="text-sm text-muted-foreground">
549 No provider on the selected environments reports subscription limits.
550 </p>
551 ) : null}
552 {pools.map((pool) => (
553 <PoolSection key={pool.driver} pool={pool} now={now} />
554 ))}
555 <LimitNotices notices={notices} />
556 </div>
557 );
558}
559
560/** Sources and providers that could not be read, so a missing bar is not mistaken for a full one. */
561function LimitNotices({ notices }: { readonly notices: readonly string[] }) {
562 if (notices.length === 0) return null;
563 return (
564 <ul className="flex flex-col gap-1 text-xs text-muted-foreground">
565 {notices.map((notice) => (
566 <li key={notice}>{notice}</li>
567 ))}
568 </ul>
569 );
570}
apps/web/src/components/usage/UsagePage.tsx+34-5
......@@ -59,6 +59,7 @@ import {
5959import { WorkspacePageContainer } from "../WorkspacePageContainer";
6060import { WorkspacePageHeader } from "../WorkspacePageHeader";
6161import { UsageLimitsSection } from "./UsageLimits";
62import { makeLimitsFixture } from "./usageLimitsFixture";
6263import { UsagePriceOverrides } from "./UsagePriceOverrides";
6364import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
6465import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";
......@@ -109,10 +110,33 @@ export function UsagePage() {
109110 useState<ReadonlySet<EnvironmentId> | null>(null);
110111 const { days: windowDays, window } = windowSelection;
111112 const isPast24Hours = windowDays === 1;
112 const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage(
113 window,
114 selectedEnvironmentIds,
115 );
113 const usage = useUsage(window, selectedEnvironmentIds);
114 // Dev only: `/usage?limitsFixture=<name>` lists synthetic environments in the
115 // picker and feeds the Limits view from them, so merge rules can be eyeballed.
116 const [fixture] = useState(() => {
117 if (!import.meta.env.DEV) return null;
118 const name = new URLSearchParams(globalThis.location.search).get("limitsFixture");
119 return name ? makeLimitsFixture(name, Date.now()) : null;
120 });
121 const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } =
122 useMemo(() => {
123 if (!fixture || !showingLimits) return usage;
124 const all = [...fixture].map(([environmentId, presentation]) => ({
125 environmentId,
126 label: presentation.entry.target.label,
127 isPending: false,
128 error: null,
129 summary: null,
130 }));
131 return {
132 ...usage,
133 environments: all,
134 selectedEnvironments:
135 selectedEnvironmentIds === null
136 ? all
137 : all.filter((environment) => selectedEnvironmentIds.has(environment.environmentId)),
138 };
139 }, [fixture, selectedEnvironmentIds, showingLimits, usage]);
116140 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
117141 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
118142 reportFailure: false,
......@@ -166,6 +190,8 @@ export function UsagePage() {
166190 if (refreshingRef.current) return;
167191
168192 if (showingLimits) {
193 // Synthetic data has nothing to re-read.
194 if (fixture) return;
169195 refreshingRef.current = true;
170196 setIsRefreshing(true);
171197 void Promise.all(
......@@ -349,7 +375,10 @@ export function UsagePage() {
349375 : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}
350376 </p>
351377 ) : showingLimits ? (
352 <UsageLimitsSection selectedEnvironmentIds={selectedEnvironmentIds} />
378 <UsageLimitsSection
379 selectedEnvironmentIds={selectedEnvironmentIds}
380 fixture={fixture}
381 />
353382 ) : isPending ? (
354383 <UsageSkeleton />
355384 ) : (
apps/web/src/components/usage/usageLimitsFixture.ts created+413
......@@ -0,0 +1,413 @@
1/**
2 * Dev-only stand-ins for `environmentPresentations` that exercise the pooled
3 * Limits view's merge rules, one scenario per named fixture. Reached with
4 * `/usage?limitsFixture=<name>` on a dev build; never bundled otherwise.
5 */
6import {
7 EnvironmentId,
8 ProviderDriverKind,
9 ProviderInstanceId,
10 type ServerProvider,
11 type ServerProviderUsageWindow,
12 type UsageLimitSourceSnapshot,
13 UsageLimitSourceId,
14} from "@t3tools/contracts";
15
16const MINUTE = 60_000;
17const HOUR = 60 * MINUTE;
18const DAY = 24 * HOUR;
19
20interface Presentation {
21 readonly entry: { readonly target: { readonly label: string } };
22 readonly serverConfig: {
23 readonly providers: readonly ServerProvider[];
24 readonly usageLimitSources: readonly UsageLimitSourceSnapshot[];
25 };
26}
27
28type Fixture = ReadonlyMap<EnvironmentId, Presentation>;
29
30const codex = ProviderDriverKind.make("codex");
31const claude = ProviderDriverKind.make("claudeAgent");
32
33function makeHelpers(now: number) {
34 const at = (ms: number) => new Date(now + ms).toISOString();
35 const checked = (agoMs: number) => new Date(now - agoMs).toISOString();
36
37 const session = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({
38 id: "five_hour",
39 kind: "session",
40 label: "Session",
41 usedPercent: used,
42 windowDurationMins: 300,
43 resetsAt: at(resetsInMs),
44 });
45 const weekly = (
46 id: string,
47 label: string,
48 used: number,
49 resetsInMs: number,
50 ): ServerProviderUsageWindow => ({
51 id,
52 kind: "weekly",
53 label,
54 usedPercent: used,
55 windowDurationMins: 7 * 24 * 60,
56 resetsAt: at(resetsInMs),
57 });
58 /** Codex names its five-hour window `primary` and its weekly one `secondary`. */
59 const codexSession = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({
60 ...session(used, resetsInMs),
61 id: "primary",
62 });
63 const codexWeekly = (used: number, resetsInMs: number) =>
64 weekly("secondary", "Weekly", used, resetsInMs);
65
66 const provider = (
67 overrides: Partial<ServerProvider> & Pick<ServerProvider, "instanceId" | "driver">,
68 ): ServerProvider => ({
69 enabled: true,
70 installed: true,
71 version: null,
72 status: "ready",
73 auth: { status: "authenticated" },
74 checkedAt: checked(0),
75 models: [],
76 slashCommands: [],
77 skills: [],
78 ...overrides,
79 });
80
81 const codexInstance = (input: {
82 readonly instanceId: string;
83 readonly displayName?: string;
84 readonly accentColor?: string;
85 readonly email: string;
86 readonly plan?: string;
87 readonly checkedAgoMs?: number;
88 readonly windows: readonly ServerProviderUsageWindow[];
89 readonly credits?: number;
90 }) =>
91 provider({
92 instanceId: ProviderInstanceId.make(input.instanceId),
93 driver: codex,
94 ...(input.displayName ? { displayName: input.displayName } : {}),
95 ...(input.accentColor ? { accentColor: input.accentColor } : {}),
96 auth: {
97 status: "authenticated",
98 label: input.plan ?? "ChatGPT Pro 20x Subscription",
99 email: input.email,
100 },
101 usageLimits: {
102 checkedAt: checked(input.checkedAgoMs ?? MINUTE),
103 windows: input.windows,
104 ...(input.credits
105 ? { resetCredits: { availableCount: input.credits, nextExpiresAt: at(28 * DAY) } }
106 : {}),
107 },
108 });
109
110 const claudeHubAccount = (
111 email: string | null,
112 windows: readonly ServerProviderUsageWindow[],
113 checkedAgoMs = 4 * MINUTE,
114 ): UsageLimitSourceSnapshot["accounts"][number] => ({
115 id: email ? `claude-${email}.json` : "claude-team-seat.json",
116 driver: claude,
117 ...(email ? { email } : {}),
118 plan: "Claude Subscription",
119 usageLimits: { checkedAt: checked(checkedAgoMs), windows },
120 });
121
122 const hub = (
123 id: string,
124 label: string,
125 accounts: UsageLimitSourceSnapshot["accounts"],
126 error?: string,
127 ): UsageLimitSourceSnapshot => ({
128 id: UsageLimitSourceId.make(id),
129 kind: "cliproxy",
130 label,
131 checkedAt: checked(2 * MINUTE),
132 accounts,
133 ...(error ? { error } : {}),
134 });
135
136 const environment = (
137 id: string,
138 label: string,
139 providers: readonly ServerProvider[],
140 usageLimitSources: readonly UsageLimitSourceSnapshot[] = [],
141 ): readonly [EnvironmentId, Presentation] => [
142 EnvironmentId.make(id),
143 { entry: { target: { label } }, serverConfig: { providers, usageLimitSources } },
144 ];
145
146 return {
147 at,
148 checked,
149 session,
150 weekly,
151 codexSession,
152 codexWeekly,
153 provider,
154 codexInstance,
155 claudeHubAccount,
156 hub,
157 environment,
158 };
159}
160
161const FIXTURES: Record<string, (now: number) => Fixture> = {
162 /**
163 * The same Codex account signed in on two machines with different snapshot
164 * ages, plus a hub that also reports it. Must collapse to one segment with
165 * the freshest figures and both machines listed.
166 */
167 "same-account": (now) => {
168 const h = makeHelpers(now);
169 const email = "main@example.com";
170 const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [
171 {
172 id: `codex-abc-${email}-pro.json`,
173 driver: codex,
174 email,
175 plan: "ChatGPT Pro 20x Subscription",
176 usageLimits: { checkedAt: h.checked(14 * MINUTE), windows: [h.codexWeekly(70, 5 * DAY)] },
177 },
178 ];
179 return new Map([
180 h.environment(
181 "env-macbook",
182 "MacBook Pro",
183 [
184 h.codexInstance({
185 instanceId: "codex",
186 displayName: "Codex Personal",
187 accentColor: "#6366f1",
188 email,
189 windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)],
190 credits: 2,
191 }),
192 ],
193 [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)],
194 ),
195 h.environment("env-nucbox", "nucbox-1", [
196 h.codexInstance({
197 instanceId: "codex",
198 email,
199 checkedAgoMs: 9 * MINUTE,
200 windows: [h.codexSession(30, 3 * HOUR), h.codexWeekly(60, 5 * DAY)],
201 credits: 2,
202 }),
203 ]),
204 ]);
205 },
206
207 /**
208 * Three machines, no two alike: one has only Codex, one only Claude via a
209 * hub, one has both natively. Filtering to any single environment should
210 * drop whole provider sections.
211 */
212 "uneven-environments": (now) => {
213 const h = makeHelpers(now);
214 return new Map([
215 h.environment("env-macbook", "MacBook Pro", [
216 h.codexInstance({
217 instanceId: "codex",
218 displayName: "Codex Personal",
219 accentColor: "#6366f1",
220 email: "main@example.com",
221 windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)],
222 credits: 2,
223 }),
224 h.provider({
225 instanceId: ProviderInstanceId.make("claude"),
226 driver: claude,
227 auth: { status: "authenticated", label: "Claude Max", email: "main@example.com" },
228 usageLimits: {
229 checkedAt: h.checked(MINUTE),
230 windows: [
231 h.session(2, 4 * HOUR),
232 h.weekly("seven_day", "Weekly", 38, 4 * DAY),
233 h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY),
234 ],
235 },
236 }),
237 ]),
238 h.environment(
239 "env-nucbox",
240 "nucbox-1",
241 [],
242 [
243 h.hub("cliproxy-nucbox", "CLI Proxy", [
244 h.claudeHubAccount("personal@example.com", [
245 h.session(100, 4 * HOUR),
246 h.weekly("seven_day", "Weekly", 50, DAY),
247 h.weekly("seven_day_fable", "Weekly · Fable", 96, DAY),
248 ]),
249 h.claudeHubAccount("second@example.org", [
250 h.session(63, 2 * HOUR),
251 h.weekly("seven_day", "Weekly", 37, 4 * DAY),
252 h.weekly("seven_day_fable", "Weekly · Fable", 72, 4 * DAY),
253 ]),
254 ]),
255 ],
256 ),
257 h.environment("env-macmini", "Mac Mini", [
258 h.codexInstance({
259 instanceId: "codex",
260 displayName: "Codex Work",
261 email: "work@example.com",
262 windows: [h.codexSession(0, 5 * HOUR), h.codexWeekly(95, 5 * DAY)],
263 credits: 1,
264 }),
265 ]),
266 ]);
267 },
268
269 /**
270 * Codex plans that report only one window (Go reports a monthly allowance;
271 * a hub often has no five-hour figure for an account) mixed with a plan that
272 * reports both. Each pool lists only the accounts that have that window.
273 */
274 "codex-window-mix": (now) => {
275 const h = makeHelpers(now);
276 return new Map([
277 h.environment(
278 "env-macbook",
279 "MacBook Pro",
280 [
281 h.codexInstance({
282 instanceId: "codex",
283 displayName: "Codex Personal",
284 accentColor: "#6366f1",
285 email: "main@example.com",
286 windows: [h.codexSession(40, 2 * HOUR), h.codexWeekly(55, 3 * DAY)],
287 credits: 2,
288 }),
289 h.codexInstance({
290 instanceId: "codex-go",
291 displayName: "Codex Go",
292 accentColor: "#10b981",
293 email: "go@example.com",
294 plan: "ChatGPT Go Subscription",
295 windows: [
296 {
297 id: "primary",
298 kind: "monthly",
299 label: "Monthly",
300 usedPercent: 82,
301 windowDurationMins: 30 * 24 * 60,
302 resetsAt: h.at(11 * DAY),
303 },
304 ],
305 }),
306 ],
307 [
308 h.hub("cliproxy-nucbox", "CLI Proxy", [
309 {
310 id: "codex-def-work@example.com-pro.json",
311 driver: codex,
312 email: "work@example.com",
313 plan: "ChatGPT Pro 20x Subscription",
314 usageLimits: {
315 checkedAt: h.checked(3 * MINUTE),
316 windows: [h.codexWeekly(88, 6 * DAY)],
317 },
318 },
319 {
320 id: "codex-ghi-team@example.net-plus.json",
321 driver: codex,
322 email: "team@example.net",
323 plan: "ChatGPT Plus Subscription",
324 usageLimits: {
325 checkedAt: h.checked(3 * MINUTE),
326 windows: [h.codexWeekly(12, DAY)],
327 },
328 },
329 ]),
330 ],
331 ),
332 ]);
333 },
334
335 /**
336 * A hub configured on two environments, a hub that is down, a provider
337 * whose probe failed, an API-key account, and a hub account with no email.
338 */
339 "failures-and-strays": (now) => {
340 const h = makeHelpers(now);
341 const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [
342 h.claudeHubAccount("main@example.com", [
343 h.session(2, 4 * HOUR),
344 h.weekly("seven_day", "Weekly", 38, 4 * DAY),
345 h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY),
346 ]),
347 h.claudeHubAccount(null, [
348 h.session(40, 2 * HOUR),
349 h.weekly("seven_day", "Weekly", 20, 6 * DAY),
350 ]),
351 ];
352 return new Map([
353 h.environment(
354 "env-macbook",
355 "MacBook Pro",
356 [
357 h.provider({
358 instanceId: ProviderInstanceId.make("claude"),
359 driver: claude,
360 auth: { status: "authenticated", label: "Claude API Key" },
361 usageLimits: {
362 checkedAt: h.checked(MINUTE),
363 windows: [],
364 unavailable: {
365 reason: "unsupported",
366 message: "This account has no subscription limits.",
367 },
368 },
369 }),
370 ],
371 [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)],
372 ),
373 h.environment(
374 "env-nucbox",
375 "nucbox-1",
376 [],
377 [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)],
378 ),
379 h.environment(
380 "env-macmini",
381 "Mac Mini",
382 [
383 h.provider({
384 instanceId: ProviderInstanceId.make("claude"),
385 driver: claude,
386 auth: {
387 status: "authenticated",
388 label: "Claude Max",
389 email: "work@example.com",
390 },
391 usageLimits: {
392 checkedAt: h.checked(MINUTE),
393 windows: [],
394 unavailable: { reason: "probeFailed", message: "Claude timed out reading usage." },
395 },
396 }),
397 ],
398 [
399 h.hub(
400 "cliproxy-aws",
401 "AWS proxy",
402 [],
403 "fetch failed: connect ECONNREFUSED 10.0.0.4:8318",
404 ),
405 ],
406 ),
407 ]);
408 },
409};
410
411export function makeLimitsFixture(name: string, now: number): Fixture | null {
412 return Object.hasOwn(FIXTURES, name) ? FIXTURES[name]!(now) : null;
413}
docs/user/usage.md+12-3
......@@ -39,9 +39,18 @@ the dialog.
3939
4040## Track subscription limits
4141
42**Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and
43Claude subscriptions. For windows with timing data, each bar also marks how much of the window is
44left, so you can judge your pace before the next reset.
42On web and desktop, **Usage → Limits** pools every subscription account it can see per provider, so with several Codex
43or Claude accounts across your environments and hubs you read one number per window rather than a
44list. Each window card shows how much of the pool is left and a bar with one segment per account,
45ordered by which resets soonest; when the provider reports reset times, the card also says when
46the next reset lands and how much it hands back. The hatched
47part of a segment is what that reset restores. Tap or hover a segment for the account's plan, where it is
48signed in, and its reset time; Codex accounts with banked reset credits show a ticket count on the
49segment and the **Use reset** action in that popover. On narrow screens, numbered rows below
50the bar show each account's quota, countdown, and credits. Tap a row to open its details.
51
52The same account signed in on more than one environment, or reported by a hub as well, counts once.
53Filter with the environment dropdown to see what a single machine has.
4554
4655If a window looks stale, refresh Limits to re-check every provider and hub.
4756
packages/shared/src/usageLimits.test.ts+378
......@@ -13,6 +13,9 @@ import {
1313 collectProviderUsageLimits,
1414 sameUsageLimitCommandCoverage,
1515 withUsageLimitsCommands,
16 collectLimitAccounts,
17 collectLimitNotices,
18 collectLimitPools,
1619 collectLimitSources,
1720 collectLimitsGroups,
1821 elapsedShare,
......@@ -310,6 +313,381 @@ describe("collectLimitSources", () => {
310313 });
311314});
312315
316describe("pools", () => {
317 const checkedAt = "2026-09-03T11:00:00.000Z";
318 const weekly = {
319 id: "seven_day",
320 kind: "weekly",
321 label: "Weekly",
322 windowDurationMins: 7 * 24 * 60,
323 resetsAt: "2026-09-06T12:00:00.000Z",
324 } as const;
325 const claude = ProviderDriverKind.make("claudeAgent");
326 const source = {
327 id: UsageLimitSourceId.make("hub"),
328 kind: "cliproxy" as const,
329 label: "hub",
330 checkedAt,
331 };
332 const laptop = { entry: { target: { label: "Laptop" } } };
333
334 it("merges one account reported natively on two environments and by a hub into one entry", () => {
335 const native = provider({
336 driver: claude,
337 instanceId: ProviderInstanceId.make("claude"),
338 auth: { status: "authenticated", email: "Same@example.com" },
339 usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 40 }] },
340 });
341 const input = new Map([
342 [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [native] } }],
343 [
344 EnvironmentId.make("env-b"),
345 {
346 entry: { target: { label: "Desktop" } },
347 serverConfig: {
348 providers: [
349 {
350 ...native,
351 usageLimits: {
352 checkedAt: "2026-09-03T11:30:00.000Z",
353 windows: [{ ...window, usedPercent: 55 }],
354 },
355 },
356 ],
357 usageLimitSources: [
358 {
359 ...source,
360 accounts: [
361 {
362 id: "claude-same@example.com.json",
363 driver: claude,
364 email: "same@example.com",
365 plan: "Claude Subscription",
366 usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 10 }] },
367 },
368 ],
369 },
370 ],
371 },
372 },
373 ],
374 ]);
375 const accounts = collectLimitAccounts(input);
376 expect(accounts).toHaveLength(1);
377 expect(accounts[0]).toMatchObject({
378 key: "env-a:claude",
379 sourceLabel: null,
380 // Desktop's read is fresher, so its credits and its redeem are the ones on show.
381 redeem: { environmentId: "env-b", instanceId: "claude" },
382 environments: [
383 { environmentId: "env-a", label: "Laptop" },
384 { environmentId: "env-b", label: "Desktop" },
385 ],
386 });
387 // The fresher native snapshot wins; the hub row is pre-filtered by email.
388 expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55);
389 });
390
391 it("takes windows from a fresher hub read but credits and redeem from the native instance", () => {
392 const native = provider({
393 driver: claude,
394 instanceId: ProviderInstanceId.make("claude"),
395 auth: { status: "authenticated", email: "same@example.com" },
396 usageLimits: {
397 checkedAt,
398 windows: [{ ...window, usedPercent: 40 }],
399 resetCredits: { availableCount: 2 },
400 },
401 });
402 const input = new Map([
403 [
404 EnvironmentId.make("env-a"),
405 {
406 ...laptop,
407 serverConfig: {
408 providers: [native],
409 usageLimitSources: [
410 {
411 ...source,
412 accounts: [
413 {
414 id: "claude-same@example.com.json",
415 driver: claude,
416 email: "same@example.com",
417 usageLimits: {
418 checkedAt: "2026-09-03T11:30:00.000Z",
419 windows: [{ ...window, usedPercent: 55 }],
420 },
421 },
422 ],
423 },
424 ],
425 },
426 },
427 ],
428 ]);
429 const [account] = collectLimitAccounts(input);
430 expect(account?.limits.windows[0]?.usedPercent).toBe(55);
431 expect(account?.limits.resetCredits?.availableCount).toBe(2);
432 expect(account?.redeem).toEqual({ environmentId: "env-a", instanceId: "claude" });
433 expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]);
434 });
435
436 it("redeems on the environment whose snapshot supplied the credits on show", () => {
437 const stale = provider({
438 auth: { status: "authenticated", email: "same@example.com" },
439 usageLimits: {
440 checkedAt,
441 windows: [window],
442 resetCredits: { availableCount: 0 },
443 },
444 });
445 const fresh = {
446 ...stale,
447 usageLimits: {
448 checkedAt: "2026-09-03T11:30:00.000Z",
449 windows: [window],
450 resetCredits: { availableCount: 2 },
451 },
452 };
453 const input = new Map([
454 [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [stale] } }],
455 [
456 EnvironmentId.make("env-b"),
457 { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [fresh] } },
458 ],
459 ]);
460 const [account] = collectLimitAccounts(input);
461 expect(account?.limits.resetCredits?.availableCount).toBe(2);
462 expect(account?.redeem).toEqual({ environmentId: "env-b", instanceId: "codex" });
463 });
464
465 it("names an environment once however many of its instances share the account", () => {
466 const shared = provider({
467 auth: { status: "authenticated", email: "same@example.com" },
468 usageLimits: { checkedAt, windows: [window] },
469 });
470 const input = new Map([
471 [
472 EnvironmentId.make("env-a"),
473 {
474 ...laptop,
475 serverConfig: {
476 providers: [shared, { ...shared, instanceId: ProviderInstanceId.make("work") }],
477 },
478 },
479 ],
480 ]);
481 expect(collectLimitAccounts(input)[0]?.environments).toEqual([
482 { environmentId: "env-a", label: "Laptop" },
483 ]);
484 });
485
486 it("keys a hub account without an email by hub, so two environments on one hub share it", () => {
487 const seat = {
488 id: "claude-team-seat.json",
489 driver: claude,
490 usageLimits: { checkedAt, windows: [window] },
491 };
492 const hub = { ...source, accounts: [seat] };
493 const input = new Map([
494 [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { usageLimitSources: [hub] } }],
495 [
496 EnvironmentId.make("env-b"),
497 { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [hub] } },
498 ],
499 ]);
500 const accounts = collectLimitAccounts(input);
501 expect(accounts.map((account) => account.key)).toEqual(["hub:claude-team-seat.json"]);
502 expect(accounts[0]?.displayName).toBe("claude-team-seat");
503 });
504
505 it("pools windows by id across accounts and orders resets by when they land", () => {
506 const input = new Map([
507 [
508 EnvironmentId.make("env-a"),
509 {
510 ...laptop,
511 serverConfig: {
512 providers: [],
513 usageLimitSources: [
514 {
515 ...source,
516 accounts: [
517 {
518 id: "a",
519 driver: claude,
520 usageLimits: {
521 checkedAt,
522 windows: [
523 { ...window, usedPercent: 80, resetsAt: "2026-09-03T13:00:00.000Z" },
524 { ...weekly, usedPercent: 20 },
525 ],
526 },
527 },
528 {
529 id: "b",
530 driver: claude,
531 usageLimits: {
532 checkedAt,
533 windows: [{ ...window, usedPercent: 40 }],
534 },
535 },
536 {
537 id: "c",
538 driver: ProviderDriverKind.make("codex"),
539 usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] },
540 },
541 {
542 id: "unsupported",
543 driver: claude,
544 usageLimits: {
545 checkedAt,
546 windows: [],
547 unavailable: { reason: "unsupported" as const },
548 },
549 },
550 ],
551 },
552 ],
553 },
554 },
555 ],
556 ]);
557 const pools = collectLimitPools(collectLimitAccounts(input), now);
558 expect(pools.map((pool) => [pool.driver, pool.accounts.length])).toEqual([
559 ["claudeAgent", 2],
560 ["codex", 1],
561 ]);
562 const [session, week] = pools[0]!.windows;
563 // A member with no reset has no clock, so it does not vote on pace.
564 const untimed = collectLimitPools(
565 collectLimitAccounts(input).map((account) =>
566 account.key === "hub:b"
567 ? {
568 ...account,
569 limits: {
570 ...account.limits,
571 windows: account.limits.windows.map((w) => ({ ...w, resetsAt: undefined })),
572 },
573 }
574 : account,
575 ),
576 now,
577 );
578 // Only a votes: 80% used, 80% elapsed.
579 expect(untimed[0]?.windows[0]?.pace).toBe("on");
580 // a is 80% through its window and b 60%: the pool is 70% elapsed, 60% used.
581 expect(session).toMatchObject({
582 id: "five_hour",
583 remainingPercent: 40,
584 usedPercent: 60,
585 pace: "under",
586 });
587 expect(
588 session?.resets.map((reset) => [reset.member.account.key, reset.restoresPercent]),
589 ).toEqual([
590 ["hub:a", 40],
591 ["hub:b", 20],
592 ]);
593 expect(week).toMatchObject({ id: "seven_day", remainingPercent: 80, members: [{}] });
594 // Codex reports `primary` for both its five-hour and (on Go) monthly window.
595 const mixed = collectLimitPools(
596 [
597 ...collectLimitAccounts(input),
598 {
599 key: "go",
600 driver: claude,
601 displayName: "Go",
602 email: undefined,
603 plan: undefined,
604 accentColor: undefined,
605 environments: [],
606 sourceLabel: null,
607 redeem: null,
608 limits: {
609 checkedAt,
610 windows: [
611 {
612 id: "five_hour",
613 kind: "monthly",
614 label: "Monthly",
615 usedPercent: 82,
616 windowDurationMins: 30 * 24 * 60,
617 resetsAt: "2026-09-14T12:00:00.000Z",
618 },
619 ],
620 },
621 },
622 ],
623 now,
624 );
625 expect(mixed[0]?.windows.map((window) => [window.kind, window.members.length])).toEqual([
626 ["session", 2],
627 ["weekly", 1],
628 ["monthly", 1],
629 ]);
630 // Segments read left to right as "who refills next", matching the reset list.
631 expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]);
632 expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]);
633 });
634});
635
636describe("collectLimitNotices", () => {
637 const checkedAt = "2026-09-03T11:00:00.000Z";
638 const claude = ProviderDriverKind.make("claudeAgent");
639 const laptop = { entry: { target: { label: "Laptop" } } };
640 const hub = {
641 id: UsageLimitSourceId.make("hub"),
642 kind: "cliproxy" as const,
643 label: "hub",
644 checkedAt,
645 accounts: [],
646 };
647
648 it("names failures and silence, skips unsupported accounts, and labels environments only when several", () => {
649 const failed = provider({
650 instanceId: ProviderInstanceId.make("claude"),
651 driver: claude,
652 displayName: "Claude Max",
653 usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } },
654 });
655 const apiKey = provider({
656 instanceId: ProviderInstanceId.make("api"),
657 driver: claude,
658 usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } },
659 });
660 const silent = provider({ usageLimits: { checkedAt, windows: [] } });
661 const one = new Map([
662 [
663 EnvironmentId.make("env-a"),
664 {
665 ...laptop,
666 serverConfig: {
667 providers: [failed, apiKey, silent],
668 usageLimitSources: [
669 hub,
670 { ...hub, id: UsageLimitSourceId.make("down"), label: "down", error: "ECONNREFUSED" },
671 ],
672 },
673 },
674 ],
675 ]);
676 expect(collectLimitNotices(one)).toEqual([
677 "Claude Max: Could not read limits.",
678 "codex: No limits reported.",
679 "hub: No accounts reported.",
680 "down: ECONNREFUSED",
681 ]);
682
683 one.set(EnvironmentId.make("env-b"), {
684 entry: { target: { label: "Desktop" } },
685 serverConfig: { providers: [], usageLimitSources: [] },
686 });
687 expect(collectLimitNotices(one)[0]).toBe("Laptop · Claude Max: Could not read limits.");
688 });
689});
690
313691describe("/usage-limits", () => {
314692 const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] };
315693 const selected = provider({
packages/shared/src/usageLimits.ts+304-3
......@@ -58,7 +58,9 @@ export function collectLimitsGroups(
5858 EnvironmentId,
5959 {
6060 readonly entry: { readonly target: { readonly label: string } };
61 readonly serverConfig: { readonly providers: readonly ServerProvider[] } | null;
61 readonly serverConfig: {
62 readonly providers?: readonly ServerProvider[] | undefined;
63 } | null;
6264 }
6365 >,
6466): readonly LimitsGroup[] {
......@@ -147,6 +149,302 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined)
147149 return normalizedEmail ? `${driver}:${normalizedEmail}` : null;
148150}
149151
152/**
153 * One subscription account as the pooled views see it, whichever way it was
154 * reported. The same email signed in natively on two environments, or reported
155 * by a hub as well as natively, is one account: its quota is one bucket, so
156 * counting it twice would misstate what is left.
157 */
158export interface LimitAccount {
159 readonly key: string;
160 readonly driver: ServerProvider["driver"];
161 /** The instance's configured name, which is not sensitive; null for hub accounts. */
162 readonly displayName: string | null;
163 readonly email: string | undefined;
164 readonly plan: string | undefined;
165 readonly accentColor: string | undefined;
166 /** Environments the account is signed in on; empty when only a hub reports it. */
167 readonly environments: ReadonlyArray<{
168 readonly environmentId: EnvironmentId;
169 readonly label: string;
170 }>;
171 /** The hub that reported it, when no environment has it natively. */
172 readonly sourceLabel: string | null;
173 /** Where a reset credit can be redeemed; only native instances can. */
174 readonly redeem: {
175 readonly environmentId: EnvironmentId;
176 readonly instanceId: ProviderInstanceId;
177 } | null;
178 readonly limits: ServerProviderUsageLimits;
179}
180
181/**
182 * Every account with usable windows across the connected environments, one
183 * entry per distinct account. Native instances win over hub reports, and the
184 * freshest snapshot wins when the same account is reported twice.
185 */
186export function collectLimitAccounts(
187 presentations: Parameters<typeof collectLimitSources>[0],
188): readonly LimitAccount[] {
189 const accounts = new Map<string, LimitAccount>();
190 const merge = (key: string, next: LimitAccount) => {
191 const previous = accounts.get(key);
192 if (!previous) {
193 accounts.set(key, next);
194 return;
195 }
196 const fresher = Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt);
197 // Two instances on one machine sharing an account still name it once.
198 const environments = [
199 ...previous.environments,
200 ...next.environments.filter(
201 (candidate) =>
202 !previous.environments.some((seen) => seen.environmentId === candidate.environmentId),
203 ),
204 ];
205 const winner = fresher ? next : previous;
206 // Windows come from the freshest snapshot, wherever it was read. Reset
207 // credits only ever come from a native instance, and the redeem must go
208 // to the instance whose credits are on show, so the two travel together:
209 // the freshest native snapshot supplies both, or neither.
210 const native = [previous, next]
211 .filter((candidate) => candidate.redeem !== null)
212 .toSorted((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0];
213 accounts.set(key, {
214 ...previous,
215 displayName: previous.displayName ?? next.displayName,
216 plan: previous.plan ?? next.plan,
217 accentColor: previous.accentColor ?? next.accentColor,
218 environments,
219 // A hub only names the account when no environment has it natively.
220 sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel),
221 redeem: native?.redeem ?? null,
222 limits: {
223 ...winner.limits,
224 ...(native?.limits.resetCredits
225 ? { resetCredits: native.limits.resetCredits }
226 : { resetCredits: undefined }),
227 },
228 });
229 };
230 for (const [environmentId, presentation] of presentations) {
231 const label = presentation.entry.target.label;
232 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
233 if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue;
234 merge(
235 accountKey(provider.driver, provider.auth.email) ??
236 `${environmentId}:${provider.instanceId}`,
237 {
238 key: `${environmentId}:${provider.instanceId}`,
239 driver: provider.driver,
240 displayName: provider.displayName?.trim() || null,
241 email: provider.auth.email,
242 plan: provider.auth.label,
243 accentColor: provider.accentColor,
244 environments: [{ environmentId, label }],
245 sourceLabel: null,
246 redeem: { environmentId, instanceId: provider.instanceId },
247 limits: provider.usageLimits,
248 },
249 );
250 }
251 }
252 // Every hub account, including those a native instance also knows: the hub
253 // may hold a fresher read of the same subscription, and the merge above
254 // keeps the redeem target consistent with whichever snapshot wins.
255 const labelEnvironment = presentations.size > 1;
256 for (const presentation of presentations.values()) {
257 for (const source of presentation.serverConfig?.usageLimitSources ?? []) {
258 const sourceLabel = labelEnvironment
259 ? `${presentation.entry.target.label} · ${source.label}`
260 : source.label;
261 for (const account of source.accounts) {
262 if (limitsNotice(account.usageLimits) !== null) continue;
263 merge(accountKey(account.driver, account.email) ?? `${source.id}:${account.id}`, {
264 key: `${source.id}:${account.id}`,
265 driver: account.driver,
266 displayName: account.email ? null : account.id.replace(/\.json$/i, ""),
267 email: account.email,
268 plan: account.plan,
269 accentColor: undefined,
270 environments: [],
271 sourceLabel,
272 redeem: null,
273 limits: account.usageLimits,
274 });
275 }
276 }
277 }
278 return [...accounts.values()];
279}
280
281/**
282 * What the pooled views cannot draw as a bar: a hub that failed to read, a
283 * provider whose probe failed. Accounts that can never report (API keys)
284 * are left out; there is nothing for the user to act on. The environment
285 * is named only when more than one is connected.
286 */
287export function collectLimitNotices(
288 presentations: Parameters<typeof collectLimitSources>[0],
289): readonly string[] {
290 const label = (environmentLabel: string, subject: string) =>
291 presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject;
292 const notices: string[] = [];
293 for (const presentation of presentations.values()) {
294 const environmentLabel = presentation.entry.target.label;
295 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
296 // An account that can never report (API key) is left out; one that
297 // failed, or reported nothing at all, is worth a line.
298 if (provider.usageLimits?.unavailable?.reason === "unsupported") continue;
299 const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null;
300 const name = provider.displayName?.trim() || String(provider.driver);
301 if (notice) notices.push(`${label(environmentLabel, name)}: ${notice}`);
302 }
303 for (const source of presentation.serverConfig?.usageLimitSources ?? []) {
304 if (source.error) {
305 notices.push(`${label(environmentLabel, source.label)}: ${source.error}`);
306 } else if (source.accounts.length === 0) {
307 notices.push(`${label(environmentLabel, source.label)}: No accounts reported.`);
308 }
309 }
310 }
311 return notices;
312}
313
314export interface LimitPoolMember {
315 readonly account: LimitAccount;
316 readonly window: ServerProviderUsageWindow;
317}
318
319/**
320 * One window id across every account that reports it: the pooled share left,
321 * pace against the clock, and the resets in the order they will land, each
322 * with the share of the pool it hands back.
323 */
324export interface LimitPoolWindow {
325 readonly id: string;
326 readonly kind: ServerProviderUsageWindow["kind"];
327 readonly label: string;
328 readonly members: readonly LimitPoolMember[];
329 readonly remainingPercent: number;
330 readonly usedPercent: number;
331 readonly pace: LimitPace | null;
332 readonly resets: ReadonlyArray<{
333 readonly member: LimitPoolMember;
334 readonly at: number;
335 /** Points of the pool the reset restores: the member's used share over the member count. */
336 readonly restoresPercent: number;
337 }>;
338}
339
340export interface LimitPool {
341 readonly driver: ServerProvider["driver"];
342 readonly accounts: readonly LimitAccount[];
343 readonly windows: readonly LimitPoolWindow[];
344}
345
346const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = {
347 session: 0,
348 weekly: 1,
349 monthly: 2,
350 other: 3,
351};
352
353/**
354 * Accounts grouped by driver, each with its windows pooled by kind and id.
355 * Window ids are stable per provider, so a hub row and a native row for the
356 * same window land in the same pool; the kind is part of the key because
357 * Codex's `primary` is a position, not a duration (five hours on paid plans,
358 * a month on Free/Go), and a monthly allowance must not average into a
359 * five-hour pool. Pools order by kind, then first appearance.
360 *
361 * `accounts` is the table order: instances the user can act on (native,
362 * named) before hub-only accounts, each group alphabetical. Each window's
363 * `members` sort by reset instead, soonest first, so a bar reads left to
364 * right as "who refills next" and matches the reset list under it.
365 */
366export function collectLimitPools(
367 accounts: readonly LimitAccount[],
368 now: number,
369): readonly LimitPool[] {
370 const byDriver = new Map<ServerProvider["driver"], LimitAccount[]>();
371 for (const account of accounts) {
372 const list = byDriver.get(account.driver);
373 if (list) list.push(account);
374 else byDriver.set(account.driver, [account]);
375 }
376 return [...byDriver].map(([driver, members]) => {
377 const sorted = members.toSorted(
378 (left, right) =>
379 Number(left.redeem === null) - Number(right.redeem === null) ||
380 accountSortName(left).localeCompare(accountSortName(right)),
381 );
382 return { driver, accounts: sorted, windows: poolWindows(sorted, now) };
383 });
384}
385
386function accountSortName(account: LimitAccount): string {
387 return (account.displayName ?? account.email ?? account.key).toLowerCase();
388}
389
390function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] {
391 const byKey = new Map<string, LimitPoolMember[]>();
392 for (const account of accounts) {
393 for (const window of account.limits.windows) {
394 const key = `${window.kind}:${window.id}`;
395 const list = byKey.get(key);
396 if (list) list.push({ account, window });
397 else byKey.set(key, [{ account, window }]);
398 }
399 }
400 const pools = [...byKey.values()].map((unordered): LimitPoolWindow => {
401 const members = unordered.toSorted(
402 (left, right) =>
403 (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) -
404 (resetMillis(right.window) ?? Number.POSITIVE_INFINITY),
405 );
406 const first = members[0]!.window;
407 const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length;
408 // Pace compares spend against the clock, so it is judged only over the
409 // members that have a clock; a window with no reset would otherwise
410 // count as spend with no time elapsed and skew the verdict.
411 const timed = members.flatMap((m) => {
412 const share = elapsedShare(m.window, now);
413 return share === null ? [] : [{ used: m.window.usedPercent, elapsed: share }];
414 });
415 const timedUsed = timed.reduce((sum, t) => sum + t.used, 0) / timed.length;
416 const meanElapsed =
417 timed.length > 0 ? timed.reduce((sum, t) => sum + t.elapsed, 0) / timed.length : null;
418 const resets = members
419 .flatMap((member) => {
420 const at = resetMillis(member.window);
421 return at === null
422 ? []
423 : [
424 {
425 member,
426 at,
427 restoresPercent: Math.round(member.window.usedPercent / members.length),
428 },
429 ];
430 })
431 .toSorted((left, right) => left.at - right.at);
432 return {
433 id: first.id,
434 kind: first.kind,
435 label: first.label,
436 members,
437 usedPercent: Math.round(usedPercent),
438 remainingPercent: Math.round(100 - usedPercent),
439 pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed),
440 resets,
441 };
442 });
443 return pools.toSorted(
444 (left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind],
445 );
446}
447
150448/** The instance's configured name, else the driver's, else its raw kind. */
151449export function providerLimitsLabel(
152450 provider: Pick<ServerProvider, "driver" | "displayName">,
......@@ -195,8 +493,11 @@ export type LimitPace = "ahead" | "on" | "under";
195493 */
196494export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null {
197495 const elapsed = elapsedShare(window, now);
198 if (elapsed === null) return null;
199 const gap = window.usedPercent - elapsed * 100;
496 return elapsed === null ? null : paceOfShares(window.usedPercent, elapsed);
497}
498
499function paceOfShares(usedPercent: number, elapsed: number): LimitPace {
500 const gap = usedPercent - elapsed * 100;
200501 if (gap > 5) return "ahead";
201502 if (gap < -5) return "under";
202503 return "on";