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