| author | |
| committer | |
| log | b273d1cfe9515f905d37a9146bb275b905d14399 |
| tree | 14495405932936b886ced1f8123579b28a9a5e4f |
| parent | d924fe266483e1d400775e3c3ce784f8e90f355f |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 1823 insertions(+), 246 deletions(-)
apps/server/src/usage/cliproxyUsageLimits.test.ts+16| ... | @@ -102,6 +102,22 @@ describe("cliproxyStatusToAccounts", () => { | ... | @@ -102,6 +102,22 @@ describe("cliproxyStatusToAccounts", () => { |
| 102 | }, | 102 | }, |
| 103 | ]); | 103 | ]); |
| 104 | }); | 104 | }); |
| 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 | }); | ||
| 105 | }); | 121 | }); |
| 106 | 122 | ||
| 107 | describe("accountEmailFromAuthFile", () => { | 123 | describe("accountEmailFromAuthFile", () => { |
apps/server/src/usage/cliproxyUsageLimits.ts+3-1| ... | @@ -124,7 +124,9 @@ export function cliproxyAccountToUsageLimits( | ... | @@ -124,7 +124,9 @@ export function cliproxyAccountToUsageLimits( |
| 124 | if (!window || window.known === false) continue; | 124 | if (!window || window.known === false) continue; |
| 125 | const resetsAt = isoFromHub(window.reset_at); | 125 | const resetsAt = isoFromHub(window.reset_at); |
| 126 | windows.push({ | 126 | 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, | ||
| 128 | kind: spec.kind, | 130 | kind: spec.kind, |
| 129 | label: spec.label, | 131 | label: spec.label, |
| 130 | windowDurationMins: spec.windowDurationMins, | 132 | windowDurationMins: spec.windowDurationMins, |
apps/web/src/components/usage/UsageLimits.tsx+93-234| ... | @@ -5,21 +5,16 @@ import { | ... | @@ -5,21 +5,16 @@ import { |
| 5 | ServerProvider, | 5 | ServerProvider, |
| 6 | ServerProviderResetCredits, | 6 | ServerProviderResetCredits, |
| 7 | ServerProviderUsageWindow, | 7 | ServerProviderUsageWindow, |
| 8 | UsageLimitSourceAccount, | ||
| 9 | UsageLimitSourceSnapshot, | ||
| 10 | UsageProviderKind, | 8 | UsageProviderKind, |
| 11 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 12 | import { useAtomValue } from "@effect/atom-react"; | 10 | import { useAtomValue } from "@effect/atom-react"; |
| 13 | import { | 11 | import { |
| 14 | collectLimitSources, | 12 | collectLimitAccounts, |
| 15 | collectLimitsGroups, | ||
| 16 | elapsedShare, | 13 | elapsedShare, |
| 17 | formatDuration, | 14 | formatDuration, |
| 18 | formatResetsIn, | 15 | formatResetsIn, |
| 19 | limitsNotice, | ||
| 20 | type LimitPace, | 16 | type LimitPace, |
| 21 | paceOf, | 17 | paceOf, |
| 22 | providerLimitsLabel, | ||
| 23 | remainingPercent, | 18 | remainingPercent, |
| 24 | } from "@t3tools/shared/usageLimits"; | 19 | } from "@t3tools/shared/usageLimits"; |
| 25 | import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; | 20 | import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; |
| ... | @@ -30,9 +25,6 @@ import { environmentPresentations } from "../../state/presentation"; | ... | @@ -30,9 +25,6 @@ import { environmentPresentations } from "../../state/presentation"; |
| 30 | import { serverEnvironment } from "../../state/server"; | 25 | import { serverEnvironment } from "../../state/server"; |
| 31 | import { useAtomCommand } from "../../state/use-atom-command"; | 26 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 32 | import { formatUpcomingTimestamp } from "../../timestampFormat"; | 27 | import { formatUpcomingTimestamp } from "../../timestampFormat"; |
| 33 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | ||
| 34 | import { getDriverOption } from "../settings/providerDriverMeta"; | ||
| 35 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; | ||
| 36 | import { | 28 | import { |
| 37 | AlertDialog, | 29 | AlertDialog, |
| 38 | AlertDialogClose, | 30 | AlertDialogClose, |
| ... | @@ -44,6 +36,8 @@ import { | ... | @@ -44,6 +36,8 @@ import { |
| 44 | } from "../ui/alert-dialog"; | 36 | } from "../ui/alert-dialog"; |
| 45 | import { Button } from "../ui/button"; | 37 | import { Button } from "../ui/button"; |
| 46 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 39 | import type { makeLimitsFixture } from "./usageLimitsFixture"; | ||
| 40 | import { UsageLimitsPooled } from "./UsageLimitsPooled"; | ||
| 47 | import { PROVIDER_PRESENTATION } from "./usageProviders"; | 41 | import { PROVIDER_PRESENTATION } from "./usageProviders"; |
| 48 | 42 | ||
| 49 | const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof GaugeIcon }> = { | 43 | const 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 | ... | @@ -53,14 +47,14 @@ const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof Ga |
| 53 | }; | 47 | }; |
| 54 | 48 | ||
| 55 | /** The series colour the cost chart uses for this driver, so the two views read as one. */ | 49 | /** The series colour the cost chart uses for this driver, so the two views read as one. */ |
| 56 | function barColor(driver: ServerProvider["driver"]): string { | 50 | export function barColor(driver: ServerProvider["driver"]): string { |
| 57 | const kind: UsageProviderKind | undefined = | 51 | const kind: UsageProviderKind | undefined = |
| 58 | driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : undefined; | 52 | driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : undefined; |
| 59 | return kind ? PROVIDER_PRESENTATION[kind].color : "var(--foreground)"; | 53 | return kind ? PROVIDER_PRESENTATION[kind].color : "var(--foreground)"; |
| 60 | } | 54 | } |
| 61 | 55 | ||
| 62 | /** Pace as a glyph with the words on hover. */ | 56 | /** Pace as a glyph with the words on hover. */ |
| 63 | function PaceIcon({ pace }: { readonly pace: LimitPace }) { | 57 | export function PaceIcon({ pace }: { readonly pace: LimitPace }) { |
| 64 | const Icon = PACE[pace].icon; | 58 | const Icon = PACE[pace].icon; |
| 65 | return ( | 59 | return ( |
| 66 | <Tooltip> | 60 | <Tooltip> |
| ... | @@ -202,94 +196,6 @@ export function LimitWindows({ | ... | @@ -202,94 +196,6 @@ export function LimitWindows({ |
| 202 | ); | 196 | ); |
| 203 | } | 197 | } |
| 204 | 198 | ||
| 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 | */ | ||
| 209 | function 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 | |||
| 254 | function 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 | |||
| 293 | const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { | 199 | const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { |
| 294 | reset: "Reset applied. Your windows have cleared.", | 200 | reset: "Reset applied. Your windows have cleared.", |
| 295 | nothingToReset: "Nothing to reset right now.", | 201 | nothingToReset: "Nothing to reset right now.", |
| ... | @@ -297,36 +203,12 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { | ... | @@ -297,36 +203,12 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { |
| 297 | alreadyRedeemed: "That credit was already redeemed.", | 203 | alreadyRedeemed: "That credit was already redeemed.", |
| 298 | }; | 204 | }; |
| 299 | 205 | ||
| 300 | /** | 206 | /** Everything a redeem needs: where to send it and what to say afterwards. */ |
| 301 | * Banked reset credits with a confirmed redeem action. Redeeming spends a | 207 | export function useResetCredit(environmentId: EnvironmentId, instanceId: ProviderInstanceId) { |
| 302 | * credit the provider granted the user, so it never fires on a bare click. | ||
| 303 | */ | ||
| 304 | export 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 | }) { | ||
| 315 | const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); | 208 | const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); |
| 316 | const [confirming, setConfirming] = useState(false); | 209 | const [confirming, setConfirming] = useState(false); |
| 317 | const [busy, setBusy] = useState(false); | 210 | const [busy, setBusy] = useState(false); |
| 318 | const [status, setStatus] = useState<string | null>(null); | 211 | 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 | }`; | ||
| 330 | 212 | ||
| 331 | const redeem = async () => { | 213 | const redeem = async () => { |
| 332 | setConfirming(false); | 214 | setConfirming(false); |
| ... | @@ -345,138 +227,115 @@ export function ResetCredits({ | ... | @@ -345,138 +227,115 @@ export function ResetCredits({ |
| 345 | ); | 227 | ); |
| 346 | }; | 228 | }; |
| 347 | 229 | ||
| 348 | return ( | 230 | return { confirming, setConfirming, busy, status, redeem }; |
| 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 | ); | ||
| 374 | } | 231 | } |
| 375 | 232 | ||
| 376 | /** One account pooled by a usage-limit source, drawn like a provider row. */ | 233 | /** |
| 377 | function SourceAccountLimits({ | 234 | * The confirm for a redeem. Redeeming spends a credit the provider granted the |
| 378 | account, | 235 | * user, so it never fires on a bare click. Mount it outside any popover that |
| 379 | sourceKind, | 236 | * holds the button: dialogs stack under popovers, and closing the popover |
| 380 | now, | 237 | * would unmount a dialog rendered inside it. |
| 238 | */ | ||
| 239 | export function ResetCreditDialog({ | ||
| 240 | open, | ||
| 241 | onOpenChange, | ||
| 242 | onConfirm, | ||
| 381 | }: { | 243 | }: { |
| 382 | readonly account: UsageLimitSourceAccount; | 244 | readonly open: boolean; |
| 383 | readonly sourceKind: string; | 245 | readonly onOpenChange: (open: boolean) => void; |
| 384 | readonly now: number; | 246 | readonly onConfirm: () => void; |
| 385 | }) { | 247 | }) { |
| 386 | const notice = limitsNotice(account.usageLimits); | ||
| 387 | return ( | 248 | return ( |
| 388 | <section className="flex flex-col gap-3"> | 249 | <AlertDialog open={open} onOpenChange={onOpenChange}> |
| 389 | <AccountHeading | 250 | <AlertDialogPopup> |
| 390 | driver={account.driver} | 251 | <AlertDialogHeader> |
| 391 | label={getDriverOption(account.driver)?.label ?? String(account.driver)} | 252 | <AlertDialogTitle>Use a reset credit?</AlertDialogTitle> |
| 392 | instanceLabel={sourceKind} | 253 | <AlertDialogDescription> |
| 393 | plan={account.plan} | 254 | This redeems one credit on your account and clears the current rate-limit windows. It |
| 394 | email={account.email} | 255 | cannot be undone. |
| 395 | /> | 256 | </AlertDialogDescription> |
| 396 | {notice ? ( | 257 | </AlertDialogHeader> |
| 397 | <span className="text-xs text-muted-foreground">{notice}</span> | 258 | <AlertDialogFooter> |
| 398 | ) : ( | 259 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> |
| 399 | <LimitWindows driver={account.driver} windows={account.usageLimits.windows} now={now} /> | 260 | <Button onClick={onConfirm}>Use credit</Button> |
| 400 | )} | 261 | </AlertDialogFooter> |
| 401 | </section> | 262 | </AlertDialogPopup> |
| 263 | </AlertDialog> | ||
| 402 | ); | 264 | ); |
| 403 | } | 265 | } |
| 404 | 266 | ||
| 405 | const SOURCE_KIND_LABEL: Record<UsageLimitSourceSnapshot["kind"], string> = { | 267 | /** `2 reset credits banked · next expires in 27d 23h`, or the short form for a popover. */ |
| 406 | cliproxy: "CLI Proxy", | 268 | export function resetCreditsSummary( |
| 407 | }; | 269 | credits: ServerProviderResetCredits, |
| 408 | 270 | now: number, | |
| 409 | type LimitsSource = ReturnType<typeof collectLimitSources>[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 | } | ||
| 410 | 283 | ||
| 411 | /** Read-only accounts pooled by a configured usage source. */ | 284 | /** Banked reset credits with the redeem button and its confirm, self-contained. */ |
| 412 | function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly now: number }) { | 285 | export function ResetCredits({ |
| 413 | const kind = SOURCE_KIND_LABEL[source.kind]; | 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; | ||
| 414 | return ( | 301 | return ( |
| 415 | <div className="flex flex-col gap-6"> | 302 | <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> |
| 416 | {source.error ? ( | 303 | <span className="tabular-nums">{resetCreditsSummary(credits, now)}</span> |
| 417 | <span className="text-xs text-muted-foreground">{source.error}</span> | 304 | {credits.availableCount > 0 ? ( |
| 418 | ) : source.accounts.length === 0 ? ( | 305 | <Button size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(true)}> |
| 419 | <span className="text-xs text-muted-foreground"> | 306 | {busy ? "Using…" : "Use reset"} |
| 420 | {source.hiddenAccountCount > 0 | 307 | </Button> |
| 421 | ? "All accounts are shown by connected providers." | 308 | ) : null} |
| 422 | : "No accounts reported."} | 309 | {status ? <span className="text-foreground">{status}</span> : null} |
| 423 | </span> | 310 | <ResetCreditDialog |
| 424 | ) : ( | 311 | open={confirming} |
| 425 | source.accounts.map((account) => ( | 312 | onOpenChange={setConfirming} |
| 426 | <SourceAccountLimits key={account.id} account={account} sourceKind={kind} now={now} /> | 313 | onConfirm={() => void redeem()} |
| 427 | )) | 314 | /> |
| 428 | )} | ||
| 429 | </div> | 315 | </div> |
| 430 | ); | 316 | ); |
| 431 | } | 317 | } |
| 432 | 318 | ||
| 433 | /** | 319 | /** |
| 434 | * Subscription quota windows from every connected environment's providers. | 320 | * Subscription quota across every connected environment's providers and hubs, |
| 435 | * Countdowns anchor to render time rather than ticking: a live clock would | 321 | * pooled per provider. Countdowns anchor to render time rather than ticking: a |
| 436 | * repaint the page every minute for no decision-changing gain. | 322 | * live clock would repaint the page every minute for no decision-changing gain. |
| 437 | */ | 323 | */ |
| 438 | export function UsageLimitsSection({ | 324 | export function UsageLimitsSection({ |
| 439 | selectedEnvironmentIds, | 325 | selectedEnvironmentIds, |
| 326 | fixture = null, | ||
| 440 | }: { | 327 | }: { |
| 441 | readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null; | 328 | readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null; |
| 329 | /** Dev-only synthetic presentations standing in for the live ones. */ | ||
| 330 | readonly fixture?: ReturnType<typeof makeLimitsFixture> | null; | ||
| 442 | }) { | 331 | }) { |
| 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; | ||
| 444 | const selected = | 336 | const selected = |
| 445 | selectedEnvironmentIds === null | 337 | selectedEnvironmentIds === null |
| 446 | ? presentations | 338 | ? presentations |
| 447 | : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); | 339 | : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); |
| 448 | const groups = collectLimitsGroups(selected); | 340 | return <UsageLimitsPooled presentations={selected} now={now} />; |
| 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 | ); | ||
| 482 | } | 341 | } |
apps/web/src/components/usage/UsageLimitsPooled.tsx created+570| ... | @@ -0,0 +1,570 @@ | ||
| 1 | import { | ||
| 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"; | ||
| 13 | import { TicketIcon } from "lucide-react"; | ||
| 14 | import { type ReactNode, useState } from "react"; | ||
| 15 | |||
| 16 | import { usePrimarySettings } from "../../hooks/useSettings"; | ||
| 17 | import { cn } from "../../lib/utils"; | ||
| 18 | import { formatUpcomingTimestamp } from "../../timestampFormat"; | ||
| 19 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | ||
| 20 | import { getDriverOption } from "../settings/providerDriverMeta"; | ||
| 21 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; | ||
| 22 | import { Button } from "../ui/button"; | ||
| 23 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | ||
| 24 | import { | ||
| 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. */ | ||
| 33 | function 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. */ | ||
| 39 | function 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. */ | ||
| 48 | function 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 | */ | ||
| 66 | function 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 | */ | ||
| 96 | function 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 | |||
| 118 | function 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 | */ | ||
| 132 | function 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 | */ | ||
| 219 | function 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 | */ | ||
| 328 | function 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. */ | ||
| 383 | function 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 | */ | ||
| 438 | function 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 | */ | ||
| 475 | function 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 | |||
| 509 | function 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 | */ | ||
| 536 | export 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. */ | ||
| 561 | function 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 { | ... | @@ -59,6 +59,7 @@ import { |
| 59 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; | 59 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; |
| 60 | import { WorkspacePageHeader } from "../WorkspacePageHeader"; | 60 | import { WorkspacePageHeader } from "../WorkspacePageHeader"; |
| 61 | import { UsageLimitsSection } from "./UsageLimits"; | 61 | import { UsageLimitsSection } from "./UsageLimits"; |
| 62 | import { makeLimitsFixture } from "./usageLimitsFixture"; | ||
| 62 | import { UsagePriceOverrides } from "./UsagePriceOverrides"; | 63 | import { UsagePriceOverrides } from "./UsagePriceOverrides"; |
| 63 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; | 64 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; |
| 64 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; | 65 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; |
| ... | @@ -109,10 +110,33 @@ export function UsagePage() { | ... | @@ -109,10 +110,33 @@ export function UsagePage() { |
| 109 | useState<ReadonlySet<EnvironmentId> | null>(null); | 110 | useState<ReadonlySet<EnvironmentId> | null>(null); |
| 110 | const { days: windowDays, window } = windowSelection; | 111 | const { days: windowDays, window } = windowSelection; |
| 111 | const isPast24Hours = windowDays === 1; | 112 | const isPast24Hours = windowDays === 1; |
| 112 | const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( | 113 | const usage = useUsage(window, selectedEnvironmentIds); |
| 113 | window, | 114 | // Dev only: `/usage?limitsFixture=<name>` lists synthetic environments in the |
| 114 | selectedEnvironmentIds, | 115 | // picker and feeds the Limits view from them, so merge rules can be eyeballed. |
| 115 | ); | 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]); | ||
| 116 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); | 140 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 117 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { | 141 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { |
| 118 | reportFailure: false, | 142 | reportFailure: false, |
| ... | @@ -166,6 +190,8 @@ export function UsagePage() { | ... | @@ -166,6 +190,8 @@ export function UsagePage() { |
| 166 | if (refreshingRef.current) return; | 190 | if (refreshingRef.current) return; |
| 167 | 191 | ||
| 168 | if (showingLimits) { | 192 | if (showingLimits) { |
| 193 | // Synthetic data has nothing to re-read. | ||
| 194 | if (fixture) return; | ||
| 169 | refreshingRef.current = true; | 195 | refreshingRef.current = true; |
| 170 | setIsRefreshing(true); | 196 | setIsRefreshing(true); |
| 171 | void Promise.all( | 197 | void Promise.all( |
| ... | @@ -349,7 +375,10 @@ export function UsagePage() { | ... | @@ -349,7 +375,10 @@ export function UsagePage() { |
| 349 | : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`} | 375 | : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`} |
| 350 | </p> | 376 | </p> |
| 351 | ) : showingLimits ? ( | 377 | ) : showingLimits ? ( |
| 352 | <UsageLimitsSection selectedEnvironmentIds={selectedEnvironmentIds} /> | 378 | <UsageLimitsSection |
| 379 | selectedEnvironmentIds={selectedEnvironmentIds} | ||
| 380 | fixture={fixture} | ||
| 381 | /> | ||
| 353 | ) : isPending ? ( | 382 | ) : isPending ? ( |
| 354 | <UsageSkeleton /> | 383 | <UsageSkeleton /> |
| 355 | ) : ( | 384 | ) : ( |
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 | */ | ||
| 6 | import { | ||
| 7 | EnvironmentId, | ||
| 8 | ProviderDriverKind, | ||
| 9 | ProviderInstanceId, | ||
| 10 | type ServerProvider, | ||
| 11 | type ServerProviderUsageWindow, | ||
| 12 | type UsageLimitSourceSnapshot, | ||
| 13 | UsageLimitSourceId, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | const MINUTE = 60_000; | ||
| 17 | const HOUR = 60 * MINUTE; | ||
| 18 | const DAY = 24 * HOUR; | ||
| 19 | |||
| 20 | interface 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 | |||
| 28 | type Fixture = ReadonlyMap<EnvironmentId, Presentation>; | ||
| 29 | |||
| 30 | const codex = ProviderDriverKind.make("codex"); | ||
| 31 | const claude = ProviderDriverKind.make("claudeAgent"); | ||
| 32 | |||
| 33 | function 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 | |||
| 161 | const 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 | |||
| 411 | export 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. | ... | @@ -39,9 +39,18 @@ the dialog. |
| 39 | 39 | ||
| 40 | ## Track subscription limits | 40 | ## Track subscription limits |
| 41 | 41 | ||
| 42 | **Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and | 42 | On web and desktop, **Usage → Limits** pools every subscription account it can see per provider, so with several Codex |
| 43 | Claude subscriptions. For windows with timing data, each bar also marks how much of the window is | 43 | or Claude accounts across your environments and hubs you read one number per window rather than a |
| 44 | left, so you can judge your pace before the next reset. | 44 | list. Each window card shows how much of the pool is left and a bar with one segment per account, |
| 45 | ordered by which resets soonest; when the provider reports reset times, the card also says when | ||
| 46 | the next reset lands and how much it hands back. The hatched | ||
| 47 | part of a segment is what that reset restores. Tap or hover a segment for the account's plan, where it is | ||
| 48 | signed in, and its reset time; Codex accounts with banked reset credits show a ticket count on the | ||
| 49 | segment and the **Use reset** action in that popover. On narrow screens, numbered rows below | ||
| 50 | the bar show each account's quota, countdown, and credits. Tap a row to open its details. | ||
| 51 | |||
| 52 | The same account signed in on more than one environment, or reported by a hub as well, counts once. | ||
| 53 | Filter with the environment dropdown to see what a single machine has. | ||
| 45 | 54 | ||
| 46 | If a window looks stale, refresh Limits to re-check every provider and hub. | 55 | If a window looks stale, refresh Limits to re-check every provider and hub. |
| 47 | 56 |
packages/shared/src/usageLimits.test.ts+378| ... | @@ -13,6 +13,9 @@ import { | ... | @@ -13,6 +13,9 @@ import { |
| 13 | collectProviderUsageLimits, | 13 | collectProviderUsageLimits, |
| 14 | sameUsageLimitCommandCoverage, | 14 | sameUsageLimitCommandCoverage, |
| 15 | withUsageLimitsCommands, | 15 | withUsageLimitsCommands, |
| 16 | collectLimitAccounts, | ||
| 17 | collectLimitNotices, | ||
| 18 | collectLimitPools, | ||
| 16 | collectLimitSources, | 19 | collectLimitSources, |
| 17 | collectLimitsGroups, | 20 | collectLimitsGroups, |
| 18 | elapsedShare, | 21 | elapsedShare, |
| ... | @@ -310,6 +313,381 @@ describe("collectLimitSources", () => { | ... | @@ -310,6 +313,381 @@ describe("collectLimitSources", () => { |
| 310 | }); | 313 | }); |
| 311 | }); | 314 | }); |
| 312 | 315 | ||
| 316 | describe("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 | |||
| 636 | describe("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 | |||
| 313 | describe("/usage-limits", () => { | 691 | describe("/usage-limits", () => { |
| 314 | const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; | 692 | const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; |
| 315 | const selected = provider({ | 693 | const selected = provider({ |
packages/shared/src/usageLimits.ts+304-3| ... | @@ -58,7 +58,9 @@ export function collectLimitsGroups( | ... | @@ -58,7 +58,9 @@ export function collectLimitsGroups( |
| 58 | EnvironmentId, | 58 | EnvironmentId, |
| 59 | { | 59 | { |
| 60 | readonly entry: { readonly target: { readonly label: string } }; | 60 | 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; | ||
| 62 | } | 64 | } |
| 63 | >, | 65 | >, |
| 64 | ): readonly LimitsGroup[] { | 66 | ): readonly LimitsGroup[] { |
| ... | @@ -147,6 +149,302 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) | ... | @@ -147,6 +149,302 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) |
| 147 | return normalizedEmail ? `${driver}:${normalizedEmail}` : null; | 149 | return normalizedEmail ? `${driver}:${normalizedEmail}` : null; |
| 148 | } | 150 | } |
| 149 | 151 | ||
| 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 | */ | ||
| 158 | export 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 | */ | ||
| 186 | export 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 | */ | ||
| 287 | export 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 | |||
| 314 | export 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 | */ | ||
| 324 | export 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 | |||
| 340 | export interface LimitPool { | ||
| 341 | readonly driver: ServerProvider["driver"]; | ||
| 342 | readonly accounts: readonly LimitAccount[]; | ||
| 343 | readonly windows: readonly LimitPoolWindow[]; | ||
| 344 | } | ||
| 345 | |||
| 346 | const 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 | */ | ||
| 366 | export 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 | |||
| 386 | function accountSortName(account: LimitAccount): string { | ||
| 387 | return (account.displayName ?? account.email ?? account.key).toLowerCase(); | ||
| 388 | } | ||
| 389 | |||
| 390 | function 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 | |||
| 150 | /** The instance's configured name, else the driver's, else its raw kind. */ | 448 | /** The instance's configured name, else the driver's, else its raw kind. */ |
| 151 | export function providerLimitsLabel( | 449 | export function providerLimitsLabel( |
| 152 | provider: Pick<ServerProvider, "driver" | "displayName">, | 450 | provider: Pick<ServerProvider, "driver" | "displayName">, |
| ... | @@ -195,8 +493,11 @@ export type LimitPace = "ahead" | "on" | "under"; | ... | @@ -195,8 +493,11 @@ export type LimitPace = "ahead" | "on" | "under"; |
| 195 | */ | 493 | */ |
| 196 | export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { | 494 | export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { |
| 197 | const elapsed = elapsedShare(window, now); | 495 | const elapsed = elapsedShare(window, now); |
| 198 | if (elapsed === null) return null; | 496 | return elapsed === null ? null : paceOfShares(window.usedPercent, elapsed); |
| 199 | const gap = window.usedPercent - elapsed * 100; | 497 | } |
| 498 | |||
| 499 | function paceOfShares(usedPercent: number, elapsed: number): LimitPace { | ||
| 500 | const gap = usedPercent - elapsed * 100; | ||
| 200 | if (gap > 5) return "ahead"; | 501 | if (gap > 5) return "ahead"; |
| 201 | if (gap < -5) return "under"; | 502 | if (gap < -5) return "under"; |
| 202 | return "on"; | 503 | return "on"; |