From 88dab607a4dadad8dcabf2956716c4decd01af4a Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:27:52 -0800 Subject: [PATCH 1/2] feat(claude): Fable resets are offered and redeemed apart from weekly resets Co-Authored-By: Claude Opus 5.5 --- .../src/features/usage/UsageLimitsSection.tsx | 85 +++++++--------- .../src/provider/Drivers/ClaudeDriver.ts | 17 ++-- .../provider/ProviderInstanceRegistry.test.ts | 2 +- .../src/provider/claudeResetCredits.test.ts | 44 ++++++++- .../server/src/provider/claudeResetCredits.ts | 62 +++++++++++- apps/server/src/ws.ts | 2 +- apps/web/src/components/usage/UsageLimits.tsx | 99 +++++++++---------- .../components/usage/UsageLimitsPooled.tsx | 61 ++++++------ packages/contracts/src/providerUsageLimits.ts | 17 +++- packages/provider-core/src/server/driver.ts | 7 +- packages/shared/src/usageLimits.ts | 51 ++++++++++ 11 files changed, 297 insertions(+), 150 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 214e64fab628b..be4bf0c929d11 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -11,7 +11,8 @@ import { type ServerProviderUsageWindow, } from "@t3tools/contracts"; import { - formatDuration, + type ResetCreditChoice, + resetCreditChoices, formatResetsIn, limitsNotice, paceOf, @@ -243,21 +244,11 @@ export function ResetCredits(props: { const [status, setStatus] = useState(null); if (dense && credits.availableCount === 0 && status === null) return null; - const expiresIn = credits.nextExpiresAt - ? formatDuration(Date.parse(credits.nextExpiresAt) - now) - : null; - const summary = - credits.availableCount === 0 - ? "No reset credits banked" - : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ - expiresIn ? ` · next expires in ${expiresIn}` : "" - }`; - - const redeem = async () => { + const redeem = async (choice: ResetCreditChoice) => { if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; setBusy(true); setStatus(null); - const result = await consume({ environmentId, input }); + const result = await consume({ environmentId, input: choice.input }); setBusy(false); if (result._tag === "Success") { setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]); @@ -270,44 +261,44 @@ export function ResetCredits(props: { ); }; - const confirm = () => { + const confirm = (choice: ResetCreditChoice) => { if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; - Dialog.alert( - "Use a reset credit?", - "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.", - [ - { text: "Cancel", style: "cancel" }, - { text: "Use credit", onPress: () => void redeem() }, - ], - ); + Dialog.alert("Use a reset credit?", choice.confirmDescription, [ + { text: "Cancel", style: "cancel" }, + { text: "Use credit", onPress: () => void redeem(choice) }, + ]); }; return ( - - {summary} - {credits.availableCount > 0 ? ( - - - {busy ? "Using…" : "Use reset"} - - - ) : null} + + {resetCreditChoices(credits, input, now).map((choice) => ( + + {choice.summary} + {choice.availableCount > 0 ? ( + confirm(choice)} + className={ + dense + ? "rounded-full bg-subtle-strong px-2.5 py-1 disabled:opacity-[0.45]" + : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5 disabled:opacity-[0.45]" + } + > + + {busy ? "Using…" : choice.actionLabel} + + + ) : null} + + ))} {!canManageProviders ? ( This connection cannot manage provider accounts. diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c2d4b63861074..09205a9ab09cd 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -303,19 +303,24 @@ export const ClaudeDriver: ProviderDriver = Effect.fn( "ClaudeDriver.consumeResetCredit", )( - function* () { + function* (creditId) { const current = yield* snapshot.getSnapshot; - const grantId = current.usageLimits?.resetCredits?.nextCreditId; + const credits = current.usageLimits?.resetCredits; + const grantId = + creditId === undefined + ? credits?.nextCreditId + : credits?.options?.some((option) => option.creditId === creditId) + ? creditId + : undefined; if (!grantId || !current.version) return "noCredit" as const; const version = current.version; return yield* resetCreditCoordinator.redeem( - configDir, + `${configDir}\0${grantId}`, (requestId) => ClaudeResetCredits.consumeClaudeResetCredit({ configDir, diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index e998332d3e92f..95866780189ac 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -664,7 +664,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { const before = yield* instance!.snapshot.refresh; expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100); expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a"); - const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result); + const outcome = yield* instance!.consumeResetCredit!(undefined).pipe(Effect.result); return { outcome, after: yield* instance!.snapshot.getSnapshot }; }).pipe( // macOS logins live in the Keychain, where resets are never read. diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 8db751657454c..a3a1110d87d34 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -62,9 +62,51 @@ describe("claudeResetCreditsToContract", () => { availableCount: 2, nextCreditId: "grant_a", nextExpiresAt: "2026-10-01T00:00:00.000Z", + options: [ + expect.objectContaining({ + creditId: "grant_a", + label: "Full reset", + availableCount: 2, + expiresAt: "2026-10-01T00:00:00.000Z", + }), + ], }); }); + it("offers each kind of reset by its own grant", () => { + const credits = ClaudeResetCredits.claudeResetCreditsToContract( + { + eligible: true, + next_grant_id: "weekly_a", + grants: [ + grant({ id: "weekly_a", clears: ["seven_day"], ends_at: "2026-12-10T22:00:00Z" }), + grant({ + id: "fable_a", + clears: ["seven_day_overage_included"], + gives_back_weekly: true, + ends_at: "2027-03-09T07:59:00Z", + }), + grant({ id: "fable_b", gives_back_weekly: true, type: "weekly" }), + grant({ id: "session_a", type: "session" }), + grant({ id: "spent", clears: ["seven_day"], resets_left: 0 }), + ], + }, + NOW, + ); + expect( + credits?.options?.map(({ creditId, label, availableCount }) => [ + creditId, + label, + availableCount, + ]), + ).toEqual([ + ["session_a", "5-hour reset", 1], + ["weekly_a", "Weekly reset", 1], + ["fable_a", "Fable reset", 2], + ]); + expect(credits).toMatchObject({ availableCount: 4, nextCreditId: "weekly_a" }); + }); + it("offers nothing to redeem without a usable next grant or an eligible account", () => { expect( ClaudeResetCredits.claudeResetCreditsToContract( @@ -111,7 +153,7 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, client), ); - expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" }); + expect(credits).toMatchObject({ availableCount: 1, nextCreditId: "grant_a" }); }), ); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 35973378edada..e1dbacc34ba71 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -1,5 +1,6 @@ import type { ProviderConsumeResetCreditOutcome, + ServerProviderResetCreditOption, ServerProviderResetCredits, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -31,6 +32,9 @@ const Grant = Schema.Struct({ ends_at: Schema.optional(Schema.NullOr(Schema.String)), paused: Schema.optional(Schema.Boolean), usable_now: Schema.optional(Schema.Boolean), + clears: Schema.optional(Schema.Array(Schema.Unknown)), + type: Schema.optional(Schema.Unknown), + gives_back_weekly: Schema.optional(Schema.Boolean), }); const decodeGrant = Schema.decodeUnknownOption(Grant); const CedarEmber = Schema.Struct({ @@ -95,6 +99,31 @@ const isFutureTimestamp = (value: string, nowMs: number) => { ); }; +// Claude Code's own grouping: a grant that gives back weekly usage is a Fable reset, else its +// declared type, else what it clears. +const RESET_KINDS = { + session: { label: "5-hour reset", description: "Refills your 5-hour limit." }, + weekly: { label: "Weekly reset", description: "Refills your weekly limit." }, + fable: { + label: "Fable reset", + description: + "Refills your Fable allowance and gives back what Fable used on your weekly limit. It doesn't reset your whole week.", + }, + full: { label: "Full reset", description: "Refills your 5-hour and weekly limits." }, +} as const; +type ResetKind = keyof typeof RESET_KINDS; + +const grantKind = (grant: typeof Grant.Type): ResetKind => { + if (grant.gives_back_weekly) return "fable"; + if (grant.type === "session" || grant.type === "weekly" || grant.type === "full") { + return grant.type; + } + const clears = grant.clears ?? []; + const session = clears.includes("five_hour"); + const other = clears.some((limit) => limit !== "five_hour"); + return session && !other ? "session" : other && !session ? "weekly" : "full"; +}; + export function claudeResetCreditsToContract( block: unknown, nowMs: number, @@ -110,13 +139,38 @@ export function claudeResetCreditsToContract( (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), ); const next = live.find((grant) => grant.id === parsed.value.next_grant_id); - const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none(); + const expiresAt = (grant: typeof Grant.Type | undefined) => { + const at = grant?.ends_at ? DateTime.make(grant.ends_at) : Option.none(); + return Option.isSome(at) ? DateTime.formatIso(at.value) : undefined; + }; + const byKind = new Map>(); + for (const grant of live) { + if (grant.resets_left === 0) continue; + const kind = grantKind(grant); + byKind.set(kind, [...(byKind.get(kind) ?? []), grant]); + } + const options = (Object.keys(RESET_KINDS) as Array).flatMap( + (kind): Array => { + const grants = byKind.get(kind); + const target = grants?.[0]; + if (!grants || !target) return []; + const optionExpiresAt = expiresAt(target); + return [ + { + creditId: target.id, + ...RESET_KINDS[kind], + availableCount: grants.reduce((sum, grant) => sum + grant.resets_left, 0), + ...(optionExpiresAt ? { expiresAt: optionExpiresAt } : {}), + }, + ]; + }, + ); + const nextExpiresAt = expiresAt(next); return { availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0, - ...(Option.isSome(nextExpiresAt) - ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) } - : {}), + ...(nextExpiresAt ? { nextExpiresAt } : {}), ...(next ? { nextCreditId: next.id } : {}), + ...(next ? { options } : {}), }; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8a8c4e1be719c..9721864fcdfd5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2649,7 +2649,7 @@ export const layerWsRpc = ( detail: "This provider does not bank reset credits.", }); } - const outcome = yield* instance.consumeResetCredit().pipe( + const outcome = yield* instance.consumeResetCredit(input.creditId).pipe( Effect.mapError( (error) => new ProviderSetupError({ diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 43ee023af2400..912f0a36c5162 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -11,12 +11,13 @@ import { import { useAtomValue } from "@effect/atom-react"; import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display"; import { - formatDuration, formatResetsIn, type LimitPace, paceOf, paceRemainingPercent, remainingPercent, + type ResetCreditChoice, + resetCreditChoices, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; import { Fragment, useLayoutEffect, useState } from "react"; @@ -237,22 +238,20 @@ const OUTCOME_TEXT: Record = { alreadyRedeemed: "That credit was already redeemed.", }; -export function useResetCredit( - environmentId: EnvironmentId, - input: ProviderConsumeResetCreditInput, -) { +export function useResetCredit(environmentId: EnvironmentId) { const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); - const [confirming, setConfirming] = useState(false); + const [confirming, setConfirming] = useState(null); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); const redeem = async () => { - setConfirming(false); - if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; + const choice = confirming; + setConfirming(null); + if (!choice || !readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; setBusy(true); setStatus(null); - const result = await consume({ environmentId, input }); + const result = await consume({ environmentId, input: choice.input }); setBusy(false); if (result._tag === "Success") { setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]); @@ -265,31 +264,37 @@ export function useResetCredit( ); }; - return { canManageProviders, confirming, setConfirming, busy, status, redeem }; + const confirm = (choice: ResetCreditChoice) => { + if (readEnvironmentScope(environmentId, AuthProvidersManageScope)) setConfirming(choice); + }; + + return { canManageProviders, confirming, setConfirming, confirm, busy, status, redeem }; } /** Require confirmation because redemption spends credits. Mount outside popovers, which stack above * dialogs and unmount on close. */ export function ResetCreditDialog({ - open, - onOpenChange, + choice, + onCancel, onConfirm, disabled = false, }: { - readonly open: boolean; - readonly onOpenChange: (open: boolean) => void; + readonly choice: ResetCreditChoice | null; + readonly onCancel: () => void; readonly onConfirm: () => void; readonly disabled?: boolean; }) { return ( - + { + if (!open) onCancel(); + }} + > Use a reset credit? - - This redeems one credit on your account and clears the current rate-limit windows. It - cannot be undone. - + {choice?.confirmDescription} }>Cancel @@ -302,22 +307,6 @@ export function ResetCreditDialog({ ); } -export function resetCreditsSummary( - credits: ServerProviderResetCredits, - now: number, - compact = false, -): string { - const expiresIn = credits.nextExpiresAt - ? formatDuration(Date.parse(credits.nextExpiresAt) - now) - : null; - if (credits.availableCount === 0) return "No reset credits banked"; - if (compact) - return `${credits.availableCount} banked${expiresIn ? ` · expires in ${expiresIn}` : ""}`; - return `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ - expiresIn ? ` · next expires in ${expiresIn}` : "" - }`; -} - export function ResetCredits({ environmentId, input, @@ -329,30 +318,30 @@ export function ResetCredits({ readonly credits: ServerProviderResetCredits; readonly now: number; }) { - const { canManageProviders, confirming, setConfirming, busy, status, redeem } = useResetCredit( - environmentId, - input, - ); + const { canManageProviders, confirming, setConfirming, confirm, busy, status, redeem } = + useResetCredit(environmentId); if (credits.availableCount === 0 && status === null) return null; return ( -
- {resetCreditsSummary(credits, now)} - {credits.availableCount > 0 ? ( - - ) : null} +
+ {resetCreditChoices(credits, input, now).map((choice) => ( +
+ {choice.summary} + {choice.availableCount > 0 ? ( + + ) : null} +
+ ))} {status ? {status} : null} setConfirming(null)} onConfirm={() => void redeem()} disabled={!canManageProviders} /> diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 00f437246584f..a5edfe6cb7bc4 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -13,6 +13,8 @@ import { type LimitPoolWindow, paceRemainingPercent, remainingPercent, + type ResetCreditChoice, + resetCreditChoices, } from "@t3tools/shared/usageLimits"; import type { Popover as PopoverPrimitive } from "@base-ui/react/popover"; import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display"; @@ -39,14 +41,7 @@ import { Button } from "../ui/button"; import { OpenAI } from "../Icons"; import { Alert, AlertTitle } from "../ui/alert"; import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover"; -import { - PaceIcon, - ResetCreditDialog, - barColor, - usePaceMark, - resetCreditsSummary, - useResetCredit, -} from "./UsageLimits"; +import { PaceIcon, ResetCreditDialog, barColor, usePaceMark, useResetCredit } from "./UsageLimits"; function accountInitials(email: string): string { const [local = "", domain = ""] = email.split("@"); @@ -146,7 +141,7 @@ function SegmentPopover({ readonly reset: LimitPoolWindow["resets"][number] | undefined; readonly now: number; readonly redeem: ReturnType | null; - readonly onRedeem: () => void; + readonly onRedeem: (choice: ResetCreditChoice) => void; }) { const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); const remaining = remainingPercent(window); @@ -156,8 +151,10 @@ function SegmentPopover({ account.environments.length > 0 ? account.environments.map((environment) => environment.label).join(", ") : account.sourceLabel; - const credits = - redeem && account.limits.resetCredits?.availableCount ? account.limits.resetCredits : null; + const choices = + redeem && account.redeem && account.limits.resetCredits?.availableCount + ? resetCreditChoices(account.limits.resetCredits, account.redeem.input, now, true) + : []; return (
@@ -196,20 +193,24 @@ function SegmentPopover({ +{reset.restoresPercent}% of pool ) : null}
- {credits && redeem ? ( -
- - {resetCreditsSummary(credits, now, true)} - - + {choices.length > 0 && redeem ? ( +
+ {choices.map((choice) => ( + + {choice.summary} + {choice.availableCount > 0 ? ( + + ) : null} + + ))}
) : null}
@@ -264,9 +265,9 @@ function ActiveSegmentPopover({ { + onRedeem={(choice) => { handle.close(); - payload.redeem?.setConfirming(true); + payload.redeem?.confirm(choice); }} /> @@ -303,13 +304,13 @@ function RedeemableSegment({ }: Omit[0], "redeem"> & { readonly redeemAt: NonNullable; }) { - const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input); + const redeem = useResetCredit(redeemAt.environmentId); return ( <> redeem.setConfirming(null)} onConfirm={() => void redeem.redeem()} disabled={!redeem.canManageProviders} /> diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 5c4927d4a164e..67acbe0d35b05 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -20,11 +20,23 @@ export const ServerProviderUsageWindow = Schema.Struct({ }); export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; +/** One kind of banked reset, such as Claude's weekly reset or its Fable-only reset. */ +export const ServerProviderResetCreditOption = Schema.Struct({ + creditId: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + description: TrimmedNonEmptyString, + availableCount: NonNegativeInt, + expiresAt: Schema.optional(IsoDateTime), +}); +export type ServerProviderResetCreditOption = typeof ServerProviderResetCreditOption.Type; + export const ServerProviderResetCredits = Schema.Struct({ availableCount: NonNegativeInt, nextExpiresAt: Schema.optional(IsoDateTime), /** Pins hub redemption to the displayed credit, including retries from another client. */ nextCreditId: Schema.optional(TrimmedNonEmptyString), + /** Present when resets differ in what they refill; redeem one by its `creditId`. */ + options: Schema.optional(ForwardCompatibleArray(ServerProviderResetCreditOption)), }); export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; @@ -87,7 +99,10 @@ export type UsageLimitSourceConsumeResetCreditInput = typeof UsageLimitSourceConsumeResetCreditInput.Type; export const ProviderConsumeResetCreditInput = Schema.Union([ - Schema.Struct({ instanceId: ProviderInstanceId }), + Schema.Struct({ + instanceId: ProviderInstanceId, + creditId: Schema.optional(TrimmedNonEmptyString), + }), UsageLimitSourceConsumeResetCreditInput, ]); export type ProviderConsumeResetCreditInput = typeof ProviderConsumeResetCreditInput.Type; diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index a1771e2e748e9..3fee55c6fbcab 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -45,10 +45,9 @@ export interface ProviderInstance { ) => Effect.Effect; readonly refreshModels?: () => Effect.Effect; readonly invalidateCaches?: Effect.Effect; - readonly consumeResetCredit?: () => Effect.Effect< - ProviderConsumeResetCreditOutcome, - ProviderDriverError - >; + readonly consumeResetCredit?: ( + creditId: string | undefined, + ) => Effect.Effect; readonly orchestrationAdapter: ProviderAdapter.ProviderAdapterV2["Service"]; readonly textGeneration: ProviderTextGeneration; readonly auth?: ProviderAuthController; diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 1707deb735a49..24403fe8a6c5b 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -11,6 +11,7 @@ import { isProviderAvailable, PROVIDER_DISPLAY_NAMES, type ServerProvider, + type ServerProviderResetCredits, type ServerProviderUsageLimits, type ServerProviderUsageWindow, type UsageLimitSourceSnapshots, @@ -675,6 +676,56 @@ export function formatDuration(ms: number): string { return `${minutes}m`; } +/** One redeemable reset as clients offer it; `input` already names the chosen credit. */ +export interface ResetCreditChoice { + readonly key: string; + readonly summary: string; + readonly actionLabel: string; + readonly confirmDescription: string; + readonly availableCount: number; + readonly input: ProviderConsumeResetCreditInput; +} + +export function resetCreditChoices( + credits: ServerProviderResetCredits, + input: ProviderConsumeResetCreditInput, + now: number, + compact = false, +): ReadonlyArray { + const expiresIn = (at: string | undefined) => (at ? formatDuration(Date.parse(at) - now) : null); + if (credits.options && "instanceId" in input) { + return credits.options.map((option) => { + const expires = expiresIn(option.expiresAt); + return { + key: option.creditId, + summary: `${option.label} · ${option.availableCount} left${expires ? ` · expires in ${expires}` : ""}`, + actionLabel: "Use", + confirmDescription: `${option.label}: ${option.description} This cannot be undone.`, + availableCount: option.availableCount, + input: { ...input, creditId: option.creditId }, + }; + }); + } + const count = credits.availableCount; + const expires = expiresIn(credits.nextExpiresAt); + return [ + { + key: "next", + summary: + count === 0 + ? "No reset credits banked" + : compact + ? `${count} banked${expires ? ` · expires in ${expires}` : ""}` + : `${count} ${count === 1 ? "reset credit" : "reset credits"} banked${expires ? ` · next expires in ${expires}` : ""}`, + actionLabel: "Use reset", + confirmDescription: + "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.", + availableCount: count, + input, + }, + ]; +} + export function formatResetsIn(window: ServerProviderUsageWindow, now: number): string | null { const resetsAt = resetMillis(window); if (resetsAt === null) return null; From 18e168f6b0b23d3730df7ca5177655ebde03c5f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:37:39 -0800 Subject: [PATCH 2/2] fix(claude): one pending reset claim per login; keep dialog text while it closes Co-Authored-By: Claude Opus 5.5 --- .../src/provider/Drivers/ClaudeDriver.ts | 7 ++-- apps/web/src/components/usage/UsageLimits.tsx | 35 ++++++++++--------- .../components/usage/UsageLimitsPooled.tsx | 5 +-- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 09205a9ab09cd..3ada12297649d 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -303,8 +303,9 @@ export const ClaudeDriver: ProviderDriver = Effect.fn( "ClaudeDriver.consumeResetCredit", )( @@ -320,7 +321,7 @@ export const ClaudeDriver: ProviderDriver ClaudeResetCredits.consumeClaudeResetCredit({ configDir, diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 912f0a36c5162..e41aa20998b1c 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -241,13 +241,14 @@ const OUTCOME_TEXT: Record = { export function useResetCredit(environmentId: EnvironmentId) { const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); - const [confirming, setConfirming] = useState(null); + // Kept after close so the dialog's text survives its exit animation. + const [choice, setChoice] = useState(null); + const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); const redeem = async () => { - const choice = confirming; - setConfirming(null); + setConfirming(false); if (!choice || !readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; setBusy(true); setStatus(null); @@ -264,33 +265,32 @@ export function useResetCredit(environmentId: EnvironmentId) { ); }; - const confirm = (choice: ResetCreditChoice) => { - if (readEnvironmentScope(environmentId, AuthProvidersManageScope)) setConfirming(choice); + const confirm = (next: ResetCreditChoice) => { + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; + setChoice(next); + setConfirming(true); }; - return { canManageProviders, confirming, setConfirming, confirm, busy, status, redeem }; + return { canManageProviders, choice, confirming, setConfirming, confirm, busy, status, redeem }; } /** Require confirmation because redemption spends credits. Mount outside popovers, which stack above * dialogs and unmount on close. */ export function ResetCreditDialog({ + open, + onOpenChange, choice, - onCancel, onConfirm, disabled = false, }: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; readonly choice: ResetCreditChoice | null; - readonly onCancel: () => void; readonly onConfirm: () => void; readonly disabled?: boolean; }) { return ( - { - if (!open) onCancel(); - }} - > + Use a reset credit? @@ -318,7 +318,7 @@ export function ResetCredits({ readonly credits: ServerProviderResetCredits; readonly now: number; }) { - const { canManageProviders, confirming, setConfirming, confirm, busy, status, redeem } = + const { canManageProviders, choice, confirming, setConfirming, confirm, busy, status, redeem } = useResetCredit(environmentId); if (credits.availableCount === 0 && status === null) return null; return ( @@ -340,8 +340,9 @@ export function ResetCredits({ ))} {status ? {status} : null} setConfirming(null)} + open={confirming} + onOpenChange={setConfirming} + choice={choice} onConfirm={() => void redeem()} disabled={!canManageProviders} /> diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index a5edfe6cb7bc4..f2d5790ece53b 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -309,8 +309,9 @@ function RedeemableSegment({ <> redeem.setConfirming(null)} + open={redeem.confirming} + onOpenChange={redeem.setConfirming} + choice={redeem.choice} onConfirm={() => void redeem.redeem()} disabled={!redeem.canManageProviders} />