authorclover caruso <git@paperclover.net> 2026-10-15 15:36:18-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log1af2d44259972f472c2e230e50e41d841169aab4
treebf6d6864b312dda4b3c2f0585b5bb581b1c38e7c
parent311da51db22ffc7e759634b785b8327b5d075fd8
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

feat: show profile usage meters in the sidebar

Keep subscription headroom visible while working: a full-width sidebar control shows each account's tightest allowance with the provider and profile badge. Exclude Claude Fable limits, retain unavailable profiles as unknown, and deduplicate accounts reported by multiple environments or hubs. Keep the sidebar compact: omit its header and use 18px meters with half-size provider marks and profile badges. Hover for detailed limits and reset credits; use Arrow Down from the keyboard. Clicking opens cost and token history. The context menu opens limits, usage-reset settings, or provider configuration. Retire the full-page limits tab while preserving saved range and provider filters. Assisted-by: gpt-6.1-sol

19 files changed, 1003 insertions(+), 512 deletions(-)

apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+17-1
......@@ -83,7 +83,6 @@ describe("KeybindingsSettings.logic", () => {
8383 "usage.open",
8484 "usage.cost",
8585 "usage.tokens",
86 "usage.limits",
8786 "usage.period.day",
8887 "usage.period.week",
8988 "usage.period.month",
......@@ -101,6 +100,23 @@ describe("KeybindingsSettings.logic", () => {
101100 }
102101 });
103102
103 it("omits retired Limits bindings from settings while retaining other configured shortcuts", () => {
104 const bindings = [
105 ...DEFAULT_RESOLVED_KEYBINDINGS,
106 {
107 ...DEFAULT_RESOLVED_KEYBINDINGS.find((binding) => binding.command === "usage.tokens")!,
108 command: "usage.limits" as const,
109 },
110 ];
111 expect(buildKeybindingRows(bindings, "").some((row) => row.command === "usage.limits")).toBe(
112 false,
113 );
114 expect(buildKeybindingCommandOptions(bindings)).not.toContain("usage.limits");
115 expect(buildKeybindingRows(bindings, "").some((row) => row.command === "usage.tokens")).toBe(
116 true,
117 );
118 });
119
104120 it("builds searchable rows with readable key and when values", () => {
105121 const rows = buildKeybindingRows(
106122 [
apps/web/src/components/settings/KeybindingsSettings.logic.ts+20-14
......@@ -253,21 +253,25 @@ export function buildKeybindingRows(
253253 query: string,
254254): ReadonlyArray<KeybindingRow> {
255255 const normalizedQuery = query.trim().toLowerCase();
256 const rows = keybindings.map((binding, index) => {
256 const rows = keybindings.flatMap((binding, index) => {
257 if (binding.command === "usage.limits") return [];
258
257259 const defaultBinding = defaultBindingForBinding(binding);
258260 const key = shortcutToKeybindingInput(binding.shortcut);
259261 const when = whenAstToExpression(binding.whenAst);
260 return {
261 id: `${keybindingRowId(binding.command, key, when)}\u0000${index}`,
262 command: binding.command,
263 key,
264 when,
265 source: sourceForBinding(binding),
266 defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null,
267 defaultWhen: whenAstToExpression(defaultBinding?.whenAst),
268 binding,
269 conflicts: [],
270 } satisfies KeybindingRow;
262 return [
263 {
264 id: `${keybindingRowId(binding.command, key, when)}\u0000${index}`,
265 command: binding.command,
266 key,
267 when,
268 source: sourceForBinding(binding),
269 defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null,
270 defaultWhen: whenAstToExpression(defaultBinding?.whenAst),
271 binding,
272 conflicts: [],
273 } satisfies KeybindingRow,
274 ];
271275 });
272276
273277 const rowsWithConflicts = rows.map((row) => {
......@@ -351,9 +355,11 @@ export function buildWhenVariableOptions(): ReadonlyArray<WhenVariableOption> {
351355export function buildKeybindingCommandOptions(
352356 keybindings: ResolvedKeybindingsConfig,
353357): ReadonlyArray<KeybindingCommandOption> {
354 const commands = new Set<KeybindingCommand>(STATIC_KEYBINDING_COMMANDS);
358 const commands = new Set<KeybindingCommand>(
359 STATIC_KEYBINDING_COMMANDS.filter((command) => command !== "usage.limits"),
360 );
355361 for (const binding of keybindings) {
356 commands.add(binding.command);
362 if (binding.command !== "usage.limits") commands.add(binding.command);
357363 }
358364 return [...commands].toSorted((left, right) => compareCommands(left, right, commandLabel));
359365}
apps/web/src/components/sidebar/SidebarChrome.tsx+3-13
......@@ -1,4 +1,4 @@
1import { ArrowLeftIcon, ChartNoAxesColumnIcon } from "lucide-react";
1import { ArrowLeftIcon } from "lucide-react";
22import type { ReactNode } from "react";
33import { memo, useCallback } from "react";
44import { useLocation, useNavigate } from "@tanstack/react-router";
......@@ -25,6 +25,7 @@ import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation";
2525import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice";
2626import { AppMenu } from "./AppMenu";
2727import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
28import { SidebarUsageButton } from "./SidebarUsageButton";
2829
2930export const SidebarChromeHeader = memo(function SidebarChromeHeader({
3031 isElectron,
......@@ -109,13 +110,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
109110 search: readPullRequestListPreferences(),
110111 });
111112 }, [closeMobileSidebar, navigate]);
112 const handleUsageClick = useCallback(() => {
113 if (isMobile) {
114 setOpenMobile(false);
115 }
116 void navigate({ to: "/usage" });
117 }, [isMobile, navigate, setOpenMobile]);
118
119113 const handleBackClick = useCallback(() => {
120114 closeMobileSidebar();
121115 void navigateToMainApp();
......@@ -139,11 +133,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
139133 onClick={handlePullRequestsClick}
140134 />
141135 ) : null}
142 <SidebarUtilityItem
143 icon={<ChartNoAxesColumnIcon />}
144 label="Usage"
145 onClick={handleUsageClick}
146 />
147136 </>
148137 )}
149138 </SidebarMenu>
......@@ -154,6 +143,7 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
154143 return (
155144 <SidebarFooter>
156145 <SidebarThreadUndoNotice />
146 <SidebarUsageButton />
157147 <SidebarUtilityMenu />
158148 </SidebarFooter>
159149 );
apps/web/src/components/sidebar/SidebarUsageButton.tsx created+292
......@@ -0,0 +1,292 @@
1import { useAtomValue } from "@effect/atom-react";
2import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";
3import { useNavigate } from "@tanstack/react-router";
4import { Atom } from "effect/reactivity";
5import { useEffect, useEffectEvent, useRef, useState } from "react";
6
7import { ensureLocalApi } from "../../localApi";
8import { environmentPresentations } from "../../state/presentation";
9import { appAtomRegistry } from "../../rpc/atomRegistry";
10import { serverEnvironment } from "../../state/server";
11import { useAtomCommand } from "../../state/use-atom-command";
12import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
13import { providerClients } from "../settings/providerDriverMeta";
14import { Button } from "../ui/button";
15import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
16import { RefreshIcon } from "../ui/refresh-icon";
17import { useSidebar } from "../ui/sidebar";
18import { accountInitials, UsageLimitsPooled } from "../usage/UsageLimitsPooled";
19import { sidebarUsageProfiles } from "../usage/sidebarUsageProfiles";
20
21const refreshableEnvironmentIdsAtom = Atom.make((get) =>
22 [...get(environmentPresentations.presentationsAtom)]
23 .filter(
24 ([id, presentation]) =>
25 presentation.connection.phase === "connected" &&
26 get(serverEnvironment.refreshProviders.permissionAtom(id)),
27 )
28 .map(([id]) => id),
29);
30
31export function SidebarUsageButton() {
32 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
33 const navigate = useNavigate();
34 const { isMobile, setOpenMobile } = useSidebar();
35 const [open, setOpen] = useState(false);
36 const [contextMenuOpen, setContextMenuOpen] = useState(false);
37 const [hasOpened, setHasOpened] = useState(false);
38 const triggerRef = useRef<HTMLButtonElement>(null);
39 const profiles = sidebarUsageProfiles(presentations);
40
41 const closeSidebar = () => {
42 setOpen(false);
43 if (isMobile) setOpenMobile(false);
44 };
45 const showMenu = async (position: { x: number; y: number }) => {
46 setOpen(false);
47 setContextMenuOpen(true);
48 try {
49 const action = await ensureLocalApi().contextMenu.show(
50 [
51 { id: "limits", label: "Usage limits" },
52 { id: "resets", label: "Usage reset settings…" },
53 { id: "providers", label: "Configure providers…" },
54 ],
55 position,
56 );
57 if (action === null) return;
58 if (action === "limits") {
59 setHasOpened(true);
60 setOpen(true);
61 return;
62 }
63 closeSidebar();
64 if (action === "resets") {
65 await navigate({ to: "/settings/general", hash: "auto-resume-limited-threads" });
66 } else {
67 await navigate({ to: "/settings/providers" });
68 }
69 } finally {
70 setContextMenuOpen(false);
71 }
72 };
73
74 return (
75 <Popover
76 open={open}
77 onOpenChange={(nextOpen, details) => {
78 if (contextMenuOpen || details.reason === "trigger-press") {
79 details.cancel();
80 return;
81 }
82 if (nextOpen) setHasOpened(true);
83 setOpen(nextOpen);
84 }}
85 >
86 <PopoverTrigger
87 render={
88 <button
89 ref={triggerRef}
90 type="button"
91 className="flex min-h-9 w-full flex-col rounded-(--control-radius) px-(--sidebar-row-content-inset) py-1.5 text-sidebar-foreground outline-none hover:bg-sidebar-row-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
92 />
93 }
94 openOnHover
95 delay={300}
96 onClick={() => {
97 closeSidebar();
98 void navigate({ to: "/usage" });
99 }}
100 onContextMenu={(event) => {
101 event.preventDefault();
102 void showMenu({ x: event.clientX, y: event.clientY });
103 }}
104 onKeyDown={(event) => {
105 if (event.key === "ArrowDown" || event.key === "ArrowUp") {
106 event.preventDefault();
107 setHasOpened(true);
108 setOpen(true);
109 return;
110 }
111 if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return;
112 event.preventDefault();
113 const bounds = event.currentTarget.getBoundingClientRect();
114 void showMenu({ x: bounds.left, y: bounds.top });
115 }}
116 aria-label="Usage and costs"
117 aria-description="Press Arrow Down for limits. Open for costs and tokens."
118 >
119 {profiles.length > 0 ? (
120 <span className="grid w-full grid-cols-[repeat(auto-fit,minmax(2.75rem,1fr))] gap-x-2 gap-y-2">
121 {profiles.map(({ account, remainingPercent, window }) => {
122 const providerLabel = providerClients.get(account.driver)?.label ?? account.driver;
123 const label =
124 account.displayName ??
125 (account.email ? accountInitials(account.email) : providerLabel);
126 const badgeLabel =
127 account.displayName ?? (account.email ? accountInitials(account.email) : label);
128 const summary = `${providerLabel}${label === providerLabel ? "" : ` · ${label}`}: ${remainingPercent === null ? "usage unavailable" : `${remainingPercent}% left, ${window?.label}`}`;
129 return (
130 <span
131 key={account.key}
132 role="img"
133 aria-label={summary}
134 className="flex min-w-0 flex-col items-center gap-0.5"
135 >
136 <span className="relative flex size-4.5 items-center justify-center">
137 <svg
138 viewBox="0 0 36 36"
139 className="absolute inset-0 size-full -rotate-90"
140 aria-hidden
141 >
142 <circle
143 cx="18"
144 cy="18"
145 r="16"
146 fill="none"
147 stroke="currentColor"
148 strokeWidth="2"
149 className={
150 remainingPercent !== null && remainingPercent <= 10
151 ? "text-destructive/30"
152 : "text-sidebar-foreground/15"
153 }
154 strokeDasharray={remainingPercent === null ? "2 3" : undefined}
155 />
156 {remainingPercent !== null && remainingPercent > 0 ? (
157 <circle
158 cx="18"
159 cy="18"
160 r="16"
161 pathLength="100"
162 fill="none"
163 stroke="currentColor"
164 strokeWidth="2"
165 strokeLinecap="round"
166 strokeDasharray={`${remainingPercent} 100`}
167 className={remainingPercent <= 10 ? "text-destructive" : "text-primary"}
168 />
169 ) : null}
170 </svg>
171 <ProviderInstanceIcon
172 driverKind={account.driver}
173 displayName={badgeLabel}
174 accentColor={account.accentColor}
175 showBadge={Boolean(account.displayName || account.email)}
176 indicatorBackground="var(--sidebar)"
177 className="size-5 scale-50"
178 iconClassName="size-4"
179 />
180 </span>
181 <span
182 aria-hidden
183 className={`w-full truncate text-center text-3xs tabular-nums ${remainingPercent !== null && remainingPercent <= 10 ? "text-destructive" : ""}`}
184 >
185 {remainingPercent === null ? "—" : `${remainingPercent}%`}
186 </span>
187 </span>
188 );
189 })}
190 </span>
191 ) : (
192 <span className="text-xs text-sidebar-muted-foreground">View costs and tokens</span>
193 )}
194 </PopoverTrigger>
195 <PopoverPopup
196 keepMounted
197 initialFocus={(input) => input === "keyboard"}
198 finalFocus={triggerRef}
199 side="right"
200 align="end"
201 padding="none"
202 className="w-[min(46rem,calc(100vw-2rem))]"
203 >
204 {hasOpened ? (
205 <SidebarUsageDetails
206 open={open}
207 onRedeem={() => setOpen(false)}
208 resetReturnFocus={triggerRef}
209 />
210 ) : null}
211 </PopoverPopup>
212 </Popover>
213 );
214}
215
216function SidebarUsageDetails({
217 open,
218 onRedeem,
219 resetReturnFocus,
220}: {
221 readonly open: boolean;
222 readonly onRedeem: () => void;
223 readonly resetReturnFocus: React.RefObject<HTMLButtonElement | null>;
224}) {
225 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
226 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
227 reportFailure: false,
228 });
229 const [now, setNow] = useState(Date.now);
230 const [refreshing, setRefreshing] = useState(false);
231 const [refreshFailed, setRefreshFailed] = useState(false);
232 const refreshableIds = useAtomValue(refreshableEnvironmentIdsAtom);
233 const refresh = useEffectEvent(async (automatic: boolean) => {
234 try {
235 const results = await Promise.all(
236 refreshableIds
237 .filter((id) =>
238 appAtomRegistry.get(serverEnvironment.refreshProviders.permissionAtom(id)),
239 )
240 .map((environmentId) =>
241 refreshUsageLimits(
242 environmentId,
243 () => refreshProviders({ environmentId, input: { fresh: true } }),
244 automatic,
245 ),
246 ),
247 );
248 setRefreshFailed(results.some((result) => result?._tag === "Failure"));
249 } catch {
250 setRefreshFailed(true);
251 } finally {
252 setNow(Date.now());
253 }
254 });
255
256 useEffect(() => {
257 // oxlint-disable-next-line react/set-state-in-effect -- State changes only after the provider response arrives.
258 if (open && refreshableIds.length > 0) void refresh(true);
259 }, [open, refreshableIds]);
260
261 return (
262 <div className="max-h-[min(40rem,calc(100dvh-3rem))] overflow-y-auto p-4">
263 <div className="mb-4 flex items-center justify-between gap-3">
264 <PopoverTitle>Usage limits</PopoverTitle>
265 <Button
266 variant="ghost"
267 size="icon-sm"
268 aria-label="Refresh limits"
269 disabled={refreshing || refreshableIds.length === 0}
270 onClick={() => {
271 setRefreshing(true);
272 setRefreshFailed(false);
273 void refresh(false).finally(() => setRefreshing(false));
274 }}
275 >
276 <RefreshIcon refreshing={refreshing} />
277 </Button>
278 </div>
279 {refreshFailed ? (
280 <p role="status" className="mb-3 text-xs text-destructive">
281 Could not refresh limits. Try again.
282 </p>
283 ) : null}
284 <UsageLimitsPooled
285 presentations={presentations}
286 now={now}
287 onRedeem={onRedeem}
288 resetReturnFocus={resetReturnFocus}
289 />
290 </div>
291 );
292}
apps/web/src/components/usage/UsageLimits.tsx+4-57
......@@ -8,7 +8,6 @@ import {
88 ServerProviderUsageWindow,
99 UsageProviderKind,
1010} from "@t3tools/contracts";
11import { useAtomValue } from "@effect/atom-react";
1211import {
1312 elapsedShare,
1413 formatDuration,
......@@ -18,10 +17,9 @@ import {
1817 remainingPercent,
1918} from "@t3tools/shared/usageLimits";
2019import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
21import { Fragment, type ReactNode, useState } from "react";
20import { Fragment, type RefObject, useState } from "react";
2221
2322import { usePrimarySettings } from "../../hooks/useSettings";
24import { environmentPresentations } from "../../state/presentation";
2523import { useEnvironmentScope, readEnvironmentScope } from "../../state/session";
2624import { serverEnvironment } from "../../state/server";
2725import { useAtomCommand } from "../../state/use-atom-command";
......@@ -37,7 +35,6 @@ import {
3735} from "../ui/alert-dialog";
3836import { Button } from "../ui/button";
3937import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
40import { UsageLimitsPooled } from "./UsageLimitsPooled";
4138import { PROVIDER_PRESENTATION } from "./usageProviders";
4239
4340const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof GaugeIcon }> = {
......@@ -252,15 +249,17 @@ export function ResetCreditDialog({
252249 onOpenChange,
253250 onConfirm,
254251 disabled = false,
252 finalFocus,
255253}: {
256254 readonly open: boolean;
257255 readonly onOpenChange: (open: boolean) => void;
258256 readonly onConfirm: () => void;
259257 readonly disabled?: boolean;
258 readonly finalFocus?: RefObject<HTMLElement | null> | undefined;
260259}) {
261260 return (
262261 <AlertDialog open={open} onOpenChange={onOpenChange}>
263 <AlertDialogPopup>
262 <AlertDialogPopup finalFocus={finalFocus}>
264263 <AlertDialogHeader>
265264 <AlertDialogTitle>Use a reset credit?</AlertDialogTitle>
266265 <AlertDialogDescription>
......@@ -338,55 +337,3 @@ export function ResetCredits({
338337 </div>
339338 );
340339}
341
342/**
343 * Subscription quota across every connected environment's providers and hubs,
344 * pooled per provider. The page advances `now` on explicit refresh rather than
345 * ticking: a live clock would repaint the page for no decision-changing gain.
346 */
347export function UsageLimitsSection({
348 selectedEnvironmentIds,
349 hiddenProviders,
350 now,
351 cursorPrompt,
352}: {
353 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
354 readonly hiddenProviders: ReadonlySet<UsageProviderKind>;
355 readonly now: number;
356 readonly cursorPrompt?: ReactNode;
357}) {
358 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
359 const hiddenDrivers = new Set<ServerProvider["driver"]>(
360 [...hiddenProviders].map((provider) => PROVIDER_PRESENTATION[provider].driverKind),
361 );
362 const isVisible = (driver: ServerProvider["driver"]) => !hiddenDrivers.has(driver);
363 // Hidden drivers leave both native accounts and hub accounts, so their
364 // bars, notices, and external links all drop out together.
365 const selected = new Map(
366 [...presentations].flatMap(([id, presentation]) => {
367 if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(id)) return [];
368 const config = presentation.serverConfig;
369 if (hiddenDrivers.size === 0 || config === null) return [[id, presentation] as const];
370 return [
371 [
372 id,
373 {
374 ...presentation,
375 serverConfig: {
376 ...config,
377 providers: config.providers.filter((provider) => isVisible(provider.driver)),
378 // A hub left with no visible accounts is dropped, not reported as empty.
379 usageLimitSources: config.usageLimitSources?.flatMap((source) => {
380 const accounts = source.accounts.filter((account) => isVisible(account.driver));
381 return accounts.length === 0 && source.accounts.length > 0
382 ? []
383 : [{ ...source, accounts }];
384 }),
385 },
386 },
387 ] as const,
388 ];
389 }),
390 );
391 return <UsageLimitsPooled presentations={selected} now={now} cursorPrompt={cursorPrompt} />;
392}
apps/web/src/components/usage/UsageLimitsPooled.tsx+65-19
......@@ -7,6 +7,7 @@ import {
77 cursorUsageWindowDetails,
88 displayLimitWindows,
99 formatResetsIn,
10 isUsageLimitWindowVisible,
1011 type LimitAccount,
1112 type LimitPool,
1213 type LimitPoolMember,
......@@ -14,7 +15,7 @@ import {
1415 remainingPercent,
1516} from "@t3tools/shared/usageLimits";
1617import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react";
17import { Fragment, type ReactNode, useState } from "react";
18import { type ReactNode, type RefObject, useState } from "react";
1819
1920import { ensureLocalApi } from "../../localApi";
2021import { usePrimarySettings } from "../../hooks/useSettings";
......@@ -36,7 +37,7 @@ import {
3637} from "./UsageLimits";
3738
3839/** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */
39function accountInitials(email: string): string {
40export function accountInitials(email: string): string {
4041 const [local = "", domain = ""] = email.split("@");
4142 return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "?";
4243}
......@@ -232,6 +233,8 @@ function PoolSegment({
232233 now,
233234 index,
234235 showAccountName,
236 onRedeem,
237 resetReturnFocus,
235238}: {
236239 readonly account: LimitAccount;
237240 readonly window: LimitPoolMember["window"];
......@@ -241,6 +244,8 @@ function PoolSegment({
241244 /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */
242245 readonly index: number;
243246 readonly showAccountName: boolean;
247 readonly onRedeem: () => void;
248 readonly resetReturnFocus: RefObject<HTMLElement | null>;
244249}) {
245250 const [open, setOpen] = useState(false);
246251 const remaining = remainingPercent(window);
......@@ -318,6 +323,8 @@ function PoolSegment({
318323 now={now}
319324 redeemAt={account.redeem}
320325 closePopover={() => setOpen(false)}
326 onRedeem={onRedeem}
327 resetReturnFocus={resetReturnFocus}
321328 />
322329 ) : (
323330 <PopoverPopup side="top" sideOffset={6}>
......@@ -403,6 +410,8 @@ function RedeemableSegmentPopup({
403410 now,
404411 redeemAt,
405412 closePopover,
413 onRedeem,
414 resetReturnFocus,
406415}: {
407416 readonly account: LimitAccount;
408417 readonly window: LimitPoolMember["window"];
......@@ -410,6 +419,8 @@ function RedeemableSegmentPopup({
410419 readonly now: number;
411420 readonly redeemAt: NonNullable<LimitAccount["redeem"]>;
412421 readonly closePopover: () => void;
422 readonly onRedeem: () => void;
423 readonly resetReturnFocus: RefObject<HTMLElement | null>;
413424}) {
414425 const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input);
415426 return (
......@@ -423,11 +434,13 @@ function RedeemableSegmentPopup({
423434 redeem={redeem}
424435 onRedeem={() => {
425436 closePopover();
437 onRedeem();
426438 redeem.setConfirming(true);
427439 }}
428440 />
429441 </PopoverPopup>
430442 <ResetCreditDialog
443 finalFocus={resetReturnFocus}
431444 open={redeem.confirming}
432445 onOpenChange={redeem.setConfirming}
433446 onConfirm={() => void redeem.redeem()}
......@@ -456,10 +469,14 @@ function PoolBar({
456469 pool,
457470 color,
458471 now,
472 onRedeem,
473 resetReturnFocus,
459474}: {
460475 readonly pool: LimitPoolWindow;
461476 readonly color: string;
462477 readonly now: number;
478 readonly onRedeem: () => void;
479 readonly resetReturnFocus: RefObject<HTMLElement | null>;
463480}) {
464481 const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset]));
465482 return (
......@@ -479,6 +496,8 @@ function PoolBar({
479496 now={now}
480497 index={position + 1}
481498 showAccountName={pool.columns.length > 1}
499 onRedeem={onRedeem}
500 resetReturnFocus={resetReturnFocus}
482501 />
483502 ) : null,
484503 )}
......@@ -497,12 +516,16 @@ function PoolWindowCard({
497516 now,
498517 label,
499518 description,
519 onRedeem,
520 resetReturnFocus,
500521}: {
501522 readonly pool: LimitPoolWindow;
502523 readonly color: string;
503524 readonly now: number;
504525 readonly label?: string | undefined;
505526 readonly description?: string | undefined;
527 readonly onRedeem: () => void;
528 readonly resetReturnFocus: RefObject<HTMLElement | null>;
506529}) {
507530 // The soonest reset that hands anything back; an untouched account resets to no effect.
508531 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
......@@ -525,7 +548,13 @@ function PoolWindowCard({
525548 </span>
526549 ) : null}
527550 </div>
528 <PoolBar pool={pool} color={color} now={now} />
551 <PoolBar
552 pool={pool}
553 color={color}
554 now={now}
555 onRedeem={onRedeem}
556 resetReturnFocus={resetReturnFocus}
557 />
529558 {description ? (
530559 <p className="text-xs text-muted-foreground md:col-span-2">{description}</p>
531560 ) : null}
......@@ -533,7 +562,17 @@ function PoolWindowCard({
533562 );
534563}
535564
536function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) {
565function PoolSection({
566 pool,
567 now,
568 onRedeem,
569 resetReturnFocus,
570}: {
571 readonly pool: LimitPool;
572 readonly now: number;
573 readonly onRedeem: () => void;
574 readonly resetReturnFocus: RefObject<HTMLElement | null>;
575}) {
537576 const color = barColor(pool.driver);
538577 const label = providerClients.get(pool.driver)?.label ?? String(pool.driver);
539578 const windows = displayLimitWindows(pool);
......@@ -559,6 +598,8 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu
559598 now={now}
560599 label={details?.label}
561600 description={details?.description}
601 onRedeem={onRedeem}
602 resetReturnFocus={resetReturnFocus}
562603 />
563604 );
564605 })}
......@@ -574,34 +615,39 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu
574615export function UsageLimitsPooled({
575616 presentations,
576617 now,
577 cursorPrompt,
618 onRedeem,
619 resetReturnFocus,
578620}: {
579621 readonly presentations: Parameters<typeof collectLimitAccounts>[0];
580622 readonly now: number;
581 readonly cursorPrompt?: ReactNode;
623 readonly onRedeem: () => void;
624 readonly resetReturnFocus: RefObject<HTMLElement | null>;
582625}) {
583 const pools = collectLimitPools(collectLimitAccounts(presentations), now);
626 const accounts = collectLimitAccounts(presentations).flatMap((account) => {
627 const windows = account.limits.windows.filter((window) =>
628 isUsageLimitWindowVisible(account.driver, window),
629 );
630 return windows.length > 0 ? [{ ...account, limits: { ...account.limits, windows } }] : [];
631 });
632 const pools = collectLimitPools(accounts, now);
584633 const notices = collectLimitNotices(presentations);
585634 const externalLinks = collectExternalUsageLinks(presentations);
586 const cursorPromptAt =
587 Math.max(
588 pools.findIndex((pool) => pool.driver === "codex"),
589 pools.findIndex((pool) => pool.driver === "claudeAgent"),
590 ) + 1;
591635 return (
592636 <div className="flex flex-col gap-8">
593 {pools.length === 0 && notices.length === 0 && !cursorPrompt && externalLinks.length === 0 ? (
637 {pools.length === 0 && notices.length === 0 && externalLinks.length === 0 ? (
594638 <p className="text-sm text-muted-foreground">
595639 No provider on the selected environments reports subscription limits.
596640 </p>
597641 ) : null}
598 {pools.map((pool, index) => (
599 <Fragment key={pool.driver}>
600 {index === cursorPromptAt ? cursorPrompt : null}
601 <PoolSection pool={pool} now={now} />
602 </Fragment>
642 {pools.map((pool) => (
643 <PoolSection
644 key={pool.driver}
645 pool={pool}
646 now={now}
647 onRedeem={onRedeem}
648 resetReturnFocus={resetReturnFocus}
649 />
603650 ))}
604 {cursorPromptAt === pools.length ? cursorPrompt : null}
605651 {externalLinks.map((link) => (
606652 <section
607653 key={link.url}
apps/web/src/components/usage/UsagePage.refresh.test.tsx+51-159
......@@ -1,6 +1,6 @@
1import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
1import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
22import { mergeUsage } from "@t3tools/shared/usageMerge";
3import { StrictMode, act } from "react";
3import { act } from "react";
44import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
55import { create, type ReactTestRenderer } from "react-test-renderer";
66import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
......@@ -8,7 +8,8 @@ import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
88const state = vi.hoisted(() => ({
99 presentations: new Map(),
1010 refreshProviders: vi.fn(async () => undefined),
11 metric: "limits",
11 metric: "cost",
12 refresh: vi.fn(async (_window?: unknown) => undefined),
1213}));
1314vi.mock("@effect/atom-react", () => ({
1415 useAtomValue: (atom: unknown) =>
......@@ -38,6 +39,7 @@ vi.mock("../../state/usage", () => ({
3839 isPending: false,
3940 error: null,
4041 summary: null,
42 canReadDiagnostics: true,
4143 },
4244 ],
4345 selectedEnvironments: [
......@@ -47,12 +49,13 @@ vi.mock("../../state/usage", () => ({
4749 isPending: false,
4850 error: null,
4951 summary: null,
52 canReadDiagnostics: true,
5053 },
5154 ],
5255 isPending: false,
5356 shown: null,
5457 isPartial: false,
55 refresh: async () => undefined,
58 refresh: state.refresh,
5659 }),
5760}));
5861vi.mock("./usagePagePreferences", () => ({
......@@ -99,190 +102,79 @@ vi.mock("../settings/providerDriverMeta", () => ({
99102import { UsagePage } from "./UsagePage";
100103
101104let renderer: ReactTestRenderer;
102let environmentNumber = 0;
103105beforeEach(() => {
104106 vi.stubGlobal("window", new EventTarget());
105107 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
106 vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z"));
107 environmentNumber += 1;
108 state.metric = "limits";
108 vi.useFakeTimers({ toFake: ["Date"] });
109 vi.setSystemTime(new Date("2026-09-11T12:00:00Z"));
110 state.metric = "cost";
111 state.refresh.mockReset().mockResolvedValue(undefined);
109112 state.refreshProviders.mockClear();
110 state.presentations = new Map([
111 [
112 EnvironmentId.make(`test-${environmentNumber}`),
113 {
114 entry: { target: { label: "Test" } },
115 connection: { phase: "connected" },
116 serverConfig: {
117 providers: [
118 {
119 instanceId: ProviderInstanceId.make("codex"),
120 driver: "codex",
121 enabled: true,
122 installed: true,
123 version: null,
124 status: "ready",
125 auth: { status: "authenticated" },
126 checkedAt: "2026-09-11T12:00:00Z",
127 models: [],
128 slashCommands: [],
129 skills: [],
130 usageLimits: {
131 checkedAt: "2026-09-11T12:00:00Z",
132 windows: [
133 {
134 id: "five_hour",
135 kind: "session",
136 label: "Session",
137 usedPercent: 40,
138 windowDurationMins: 300,
139 resetsAt: "2026-09-11T14:00:00Z",
140 },
141 ],
142 },
143 },
144 ],
145 },
146 },
147 ],
148 ]);
113 state.presentations = new Map();
149114});
150115afterEach(async () => {
151116 await act(() => renderer?.unmount());
117 vi.useRealTimers();
152118 vi.restoreAllMocks();
153119 vi.unstubAllGlobals();
154120});
155121
156it.each([0, 1])(
157 "refreshes the visible limits countdown with refresh button %i without switching tabs, even when quota is unchanged",
158 async (buttonIndex) => {
159 await act(() => {
160 renderer = create(<UsagePage />);
161 });
162 expect(
163 JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
164 ).toContain("in 2h 0m");
165 vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:30:00Z"));
166 await act(async () => {
167 renderer.root
168 .findAllByProps({ "aria-label": "Refresh limits" })
169 .filter((node) => node.type === "button")
170 .at(buttonIndex)!
171 .props.onClick();
172 });
173 expect(state.refreshProviders).toHaveBeenCalledWith({
174 environmentId: `test-${environmentNumber}`,
175 input: {},
176 });
177 expect(
178 JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
179 ).toContain("in 1h 30m");
180 expect(
181 JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
182 ).not.toContain("in 2h 0m");
183 },
184);
185
186it("uses the current time when returning to limits from tokens", async () => {
122it.each([0, 1])("refreshes history from toolbar button %i", async (buttonIndex) => {
187123 await act(() => {
188124 renderer = create(<UsagePage />);
189125 });
190 const selectMetric = (metric: string) => {
191 renderer.root
192 .findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]!
193 .props.onValueChange([metric]);
194 };
195 await act(() => selectMetric("tokens"));
196 vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T13:00:00Z"));
197 await act(() => selectMetric("limits"));
198 expect(
199 JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
200 ).toContain("in 1h 0m");
201 expect(state.refreshProviders).toHaveBeenCalledTimes(2);
202});
203
204it("refreshes once on opening Limits and suppresses rapid returns and remounts", async () => {
205 state.metric = "tokens";
206 await act(() => {
207 renderer = create(
208 <StrictMode>
209 <UsagePage />
210 </StrictMode>,
211 );
212 });
126 expect(state.refresh).not.toHaveBeenCalled();
213127 expect(state.refreshProviders).not.toHaveBeenCalled();
214 const selectMetric = (metric: string) =>
215 renderer.root
216 .findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]!
217 .props.onValueChange([metric]);
218 await act(() => selectMetric("limits"));
219 expect(state.refreshProviders).toHaveBeenCalledTimes(1);
220 await act(() => selectMetric("tokens"));
221 await act(() => selectMetric("limits"));
222 await act(() => renderer.unmount());
223 state.metric = "limits";
224 await act(() => {
225 renderer = create(
226 <StrictMode>
227 <UsagePage />
228 </StrictMode>,
229 );
230 });
231 expect(state.refreshProviders).toHaveBeenCalledTimes(1);
232 await act(() => selectMetric("tokens"));
233 vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:05:00Z"));
234 await act(() => selectMetric("limits"));
235 expect(state.refreshProviders).toHaveBeenCalledTimes(2);
236});
237128
238it("waits for connection and refreshes new environments during a slow refresh", async () => {
239 const [id, presentation] = [...state.presentations][0]!;
240 state.presentations = new Map([[id, { ...presentation, connection: { phase: "disconnected" } }]]);
241 await act(() => {
242 renderer = create(<UsagePage />);
129 vi.setSystemTime(new Date("2026-09-12T12:00:00Z"));
130 await act(async () => {
131 renderer.root
132 .findAll((node) => node.type === "button" && node.props["aria-label"] === "Refresh usage")
133 .at(buttonIndex)!
134 .props.onClick();
243135 });
136 expect(state.refresh).toHaveBeenCalledOnce();
137 expect(state.refresh).toHaveBeenCalledWith(expect.objectContaining({ untilDay: "2026-09-12" }));
244138 expect(state.refreshProviders).not.toHaveBeenCalled();
245 let finishRefresh!: () => void;
246 state.refreshProviders.mockImplementationOnce(
247 () =>
248 new Promise<undefined>((resolve) => {
249 finishRefresh = () => resolve(undefined);
250 }),
251 );
252 state.presentations = new Map([[id, presentation]]);
253 await act(() => renderer.update(<UsagePage />));
254 expect(state.refreshProviders).toHaveBeenCalledTimes(1);
255 const nextId = EnvironmentId.make(`${id}-next`);
256 state.presentations = new Map([...state.presentations, [nextId, presentation]]);
257 await act(() => renderer.update(<UsagePage />));
258 expect(state.refreshProviders).toHaveBeenCalledTimes(2);
259 expect(state.refreshProviders).toHaveBeenLastCalledWith({ environmentId: nextId, input: {} });
260 await act(() => finishRefresh());
261139});
262140
263it("keeps manual refresh busy until the already-running automatic check settles", async () => {
141it("keeps refresh busy and suppresses repeated requests until history arrives", async () => {
264142 let finishRefresh!: () => void;
265143 const pending = new Promise<undefined>((resolve) => {
266144 finishRefresh = () => resolve(undefined);
267145 });
268 state.refreshProviders.mockImplementationOnce(() => pending);
146 state.refresh.mockImplementationOnce(() => pending);
269147 await act(() => {
270148 renderer = create(<UsagePage />);
271149 });
272150 const button = () =>
273151 renderer.root.findAll(
274 (node) => node.type === "button" && node.props["aria-label"] === "Refresh limits",
152 (node) => node.type === "button" && node.props["aria-label"] === "Refresh usage",
275153 )[0]!;
276 expect(state.refreshProviders).toHaveBeenCalledTimes(1);
277 await act(() => button().props.onClick());
278 try {
279 expect(button().props["aria-busy"]).toBe(true);
280 expect(state.refreshProviders).toHaveBeenCalledTimes(1);
281 } finally {
282 await act(async () => {
283 finishRefresh();
284 await pending;
285 });
286 }
154 await act(() => {
155 button().props.onClick();
156 button().props.onClick();
157 });
158 expect(button().props["aria-busy"]).toBe(true);
159 expect(state.refresh).toHaveBeenCalledOnce();
160 await act(async () => {
161 finishRefresh();
162 await pending;
163 });
287164 expect(button().props["aria-busy"]).toBe(false);
288165});
166
167it("switches costs and tokens without checking subscription limits", async () => {
168 await act(() => {
169 renderer = create(<UsagePage />);
170 });
171 const metricControl = () =>
172 renderer.root.findAll(
173 (node) => node.type === "div" && node.props["aria-label"] === "Usage metric",
174 )[0]!;
175 await act(() => metricControl().props.onValueChange(["tokens"]));
176 expect(metricControl().props.value).toEqual(["tokens"]);
177 await act(() => metricControl().props.onValueChange(["limits"]));
178 expect(metricControl().props.value).toEqual(["tokens"]);
179 expect(state.refreshProviders).not.toHaveBeenCalled();
180});
apps/web/src/components/usage/UsagePage.tsx+58-204
......@@ -3,7 +3,6 @@ import { usesChatGptSharing } from "@t3tools/shared/usageLimits";
33import { RefreshIcon } from "~/components/ui/refresh-icon";
44import { useAtomValue } from "@effect/atom-react";
55import {
6 ProviderDriverKind,
76 USAGE_CONTRACT_VERSION,
87 type EnvironmentId,
98 type UsageProviderKind,
......@@ -75,7 +74,6 @@ import {
7574} from "../WorkspaceBreadcrumb";
7675import { WorkspacePageContainer } from "../WorkspacePageContainer";
7776import { WorkspacePageHeader } from "../WorkspacePageHeader";
78import { UsageLimitsSection } from "./UsageLimits";
7977import { UsagePriceOverrides } from "./UsagePriceOverrides";
8078import { UsageProviderChart } from "./UsageProviderChart";
8179import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog";
......@@ -132,9 +130,7 @@ export function UsagePage() {
132130 ),
133131 }));
134132 const metric = preferences.metric;
135 const showingLimits = metric === "limits";
136133 const [isRefreshing, setIsRefreshing] = useState(false);
137 const [limitsNow, setLimitsNow] = useState(() => Date.now());
138134 const refreshingRef = useRef(false);
139135 const [breakdown, setBreakdown] = useState<"model" | "time">("model");
140136 const [priceDialog, setPriceDialog] = useState<{ readonly model?: string } | null>(null);
......@@ -159,14 +155,13 @@ export function UsagePage() {
159155 const merged = shown?.merged ?? answeredUsage;
160156 const shownWindow = shown?.window ?? window;
161157 const shownHourly = shownWindow.resolution === "hour";
162 const refreshingUsage = isRefreshing && !showingLimits;
163158 // Usage kept from another window is all old, so every figure stays muted.
164159 const showingKept = shown !== null && shown.window !== window;
165160 const loading = useMemo(() => {
166161 if (showingKept) {
167162 return { partial: true, everyProvider: true, providers: new Set<UsageProviderKind>() };
168163 }
169 const state = usageLoadingState(selectedEnvironments, refreshingUsage);
164 const state = usageLoadingState(selectedEnvironments, isRefreshing);
170165 // A hidden provider still refreshing must not add its row or mute the totals.
171166 const providers = new Set([...state.providers].filter((p) => !hiddenProviders.has(p)));
172167 return {
......@@ -174,7 +169,7 @@ export function UsagePage() {
174169 everyProvider: state.everyProvider,
175170 providers,
176171 };
177 }, [showingKept, refreshingUsage, selectedEnvironments, hiddenProviders]);
172 }, [showingKept, isRefreshing, selectedEnvironments, hiddenProviders]);
178173 const isProviderLoading = (provider: UsageProviderKind) =>
179174 loading.everyProvider || loading.providers.has(provider);
180175 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
......@@ -283,28 +278,8 @@ export function UsagePage() {
283278 });
284279 };
285280 const selectMetric = (nextMetric: UsageMetric) => {
286 if (nextMetric === "limits") setLimitsNow(Date.now());
287281 updatePreferences({ metric: nextMetric });
288282 };
289 const refreshLimits = async (automatic = false, afterPending = false) => {
290 try {
291 await Promise.all(
292 Array.from(presentations, ([environmentId, presentation]) => {
293 if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return;
294 if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) {
295 return refreshUsageLimits(
296 environmentId,
297 () => refreshProviders({ environmentId, input: {} }),
298 automatic,
299 afterPending,
300 );
301 }
302 }),
303 );
304 } finally {
305 setLimitsNow(Date.now());
306 }
307 };
308283 const onUsageKeyDown = useEffectEvent((event: KeyboardEvent) => {
309284 if (
310285 event.defaultPrevented ||
......@@ -323,7 +298,7 @@ export function UsagePage() {
323298 event.preventDefault();
324299 event.stopPropagation();
325300 if (metricOption) selectMetric(metricOption.value);
326 if (periodOption && !showingLimits) selectWindow(periodOption.days);
301 if (periodOption) selectWindow(periodOption.days);
327302 });
328303
329304 useEffect(() => {
......@@ -334,15 +309,6 @@ export function UsagePage() {
334309 const refreshWindow = () => {
335310 if (refreshingRef.current) return;
336311
337 if (showingLimits) {
338 refreshingRef.current = true;
339 setIsRefreshing(true);
340 void refreshLimits().finally(() => {
341 refreshingRef.current = false;
342 setIsRefreshing(false);
343 });
344 return;
345 }
346312 const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");
347313 if (
348314 nextWindow.sinceDay !== window.sinceDay ||
......@@ -359,23 +325,6 @@ export function UsagePage() {
359325 setIsRefreshing(false);
360326 });
361327 };
362 const connectedLimitsEnvironments = [...presentations]
363 .filter(
364 ([environmentId, presentation]) =>
365 presentation.connection.phase === "connected" &&
366 presentation.serverConfig !== null &&
367 (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)),
368 )
369 .map(([environmentId]) => environmentId)
370 .sort()
371 .join(",");
372 const autoRefreshLimits = useEffectEvent(() => {
373 void refreshLimits(true);
374 });
375 useEffect(() => {
376 if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits();
377 }, [showingLimits, connectedLimitsEnvironments]);
378
379328 // Names the period on screen, which is the previous one until the new one answers.
380329 const windowLabel =
381330 shownHourly && shownWindow.sinceTime !== undefined && shownWindow.untilTime !== undefined
......@@ -394,8 +343,7 @@ export function UsagePage() {
394343 selectedEnvironments={selectedEnvironments}
395344 selectedEnvironmentIds={selectedEnvironmentIds}
396345 onSelectionChange={setSelectedEnvironmentIds}
397 showUsageStatus={!showingLimits}
398 refreshing={refreshingUsage}
346 refreshing={isRefreshing}
399347 isPartial={isPartial}
400348 duplicateSources={merged.duplicateSources}
401349 contractMismatches={merged.contractMismatches}
......@@ -410,11 +358,9 @@ export function UsagePage() {
410358 />
411359 </WorkspaceBreadcrumbItem>
412360 </WorkspaceBreadcrumb>
413 {!showingLimits ? (
414 <span className="hidden min-w-0 truncate text-xs text-muted-foreground 2xl:block">
415 {windowLabel}
416 </span>
417 ) : null}
361 <span className="hidden min-w-0 truncate text-xs text-muted-foreground 2xl:block">
362 {windowLabel}
363 </span>
418364 <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 xl:flex">
419365 <ToggleGroup
420366 aria-label="Usage metric"
......@@ -431,13 +377,10 @@ export function UsagePage() {
431377 </Toggle>
432378 ))}
433379 </ToggleGroup>
434 {/* The period does not apply to Limits, so it stays in place but
435 disabled; unmounting it shifted the metric toggle ~300px. */}
436380 <ToggleGroup
437381 aria-label="Usage period"
438382 variant="segmented"
439383 value={[String(windowDays)]}
440 disabled={showingLimits}
441384 onValueChange={(next) => {
442385 const value = next[0];
443386 if (value) selectWindow(Number(value));
......@@ -451,9 +394,9 @@ export function UsagePage() {
451394 </ToggleGroup>
452395 <Button
453396 onClick={refreshWindow}
454 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}
397 aria-label="Refresh usage"
455398 aria-busy={isRefreshing}
456 disabled={isRefreshing || (!showingLimits && !canReadDiagnostics)}
399 disabled={isRefreshing || !canReadDiagnostics}
457400 size="icon-sm"
458401 variant="ghost"
459402 >
......@@ -485,11 +428,7 @@ export function UsagePage() {
485428 ))}
486429 </SelectPopup>
487430 </Select>
488 <Select
489 value={String(windowDays)}
490 disabled={showingLimits}
491 onValueChange={(value) => selectWindow(Number(value))}
492 >
431 <Select value={String(windowDays)} onValueChange={(value) => selectWindow(Number(value))}>
493432 <SelectTrigger
494433 aria-label="Usage period"
495434 size="compact"
......@@ -514,9 +453,9 @@ export function UsagePage() {
514453 </Select>
515454 <Button
516455 onClick={refreshWindow}
517 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}
456 aria-label="Refresh usage"
518457 aria-busy={isRefreshing}
519 disabled={isRefreshing || (!showingLimits && !canReadDiagnostics)}
458 disabled={isRefreshing || !canReadDiagnostics}
520459 size="icon-sm"
521460 variant="ghost"
522461 >
......@@ -536,26 +475,9 @@ export function UsagePage() {
536475 {selectedEnvironments.length === 0 ? (
537476 <p className="text-sm text-muted-foreground">
538477 {environments.length === 0
539 ? `Connect an environment to see ${showingLimits ? "limits" : "usage"}.`
540 : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}
478 ? "Connect an environment to see usage."
479 : "Select an environment to see usage."}
541480 </p>
542 ) : showingLimits ? (
543 <UsageLimitsSection
544 selectedEnvironmentIds={selectedEnvironmentIds}
545 hiddenProviders={hiddenProviders}
546 now={limitsNow}
547 cursorPrompt={
548 cursorAccessEnvironments.length > 0 ? (
549 <CursorEnableLimits
550 environments={cursorAccessEnvironments}
551 onEnabled={() => {
552 void refresh();
553 void refreshLimits(false, true);
554 }}
555 />
556 ) : null
557 }
558 />
559481 ) : shown === null ? (
560482 <UsageSkeleton />
561483 ) : !canReadDiagnostics ? (
......@@ -634,7 +556,16 @@ export function UsagePage() {
634556 showEnvironment={selectedEnvironments.length > 1}
635557 onEnabled={() => {
636558 void refresh();
637 void refreshLimits(false, true);
559 void refreshUsageLimits(
560 row.environment.environmentId,
561 () =>
562 refreshProviders({
563 environmentId: row.environment.environmentId,
564 input: {},
565 }),
566 false,
567 true,
568 );
638569 }}
639570 />
640571 );
......@@ -978,7 +909,7 @@ export function UsagePage() {
978909 </WorkspacePageContainer>
979910 </ScrollArea>
980911 </div>
981 {selectedModel !== undefined && !showingLimits ? (
912 {selectedModel !== undefined ? (
982913 <UsageModelDialog
983914 model={selectedModel}
984915 environments={selectedEnvironments}
......@@ -1013,18 +944,16 @@ export function UsagePage() {
1013944
1014945const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain.";
1015946
1016function CursorEnableButton({
947function CursorEnableRow({
1017948 environmentId,
1018949 label,
950 showEnvironment,
1019951 onEnabled,
1020 tooltip,
1021 buttonText = "Enable",
1022952}: {
1023953 readonly environmentId: EnvironmentId;
1024954 readonly label: string;
955 readonly showEnvironment: boolean;
1025956 readonly onEnabled: () => void;
1026 readonly tooltip: boolean;
1027 readonly buttonText?: string;
1028957}) {
1029958 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
1030959 label: "enable Cursor account usage",
......@@ -1042,47 +971,7 @@ function CursorEnableButton({
1042971 setPending(false);
1043972 }
1044973 };
1045 const button = tooltip ? (
1046 <InlineButton
1047 disabled={pending}
1048 aria-busy={pending}
1049 aria-label={`Enable Cursor usage from ${label}`}
1050 onClick={() => void enable()}
1051 >
1052 {buttonText}
1053 </InlineButton>
1054 ) : (
1055 <Button
1056 size="sm"
1057 variant="outline"
1058 disabled={pending}
1059 aria-busy={pending}
1060 aria-label={`Enable Cursor usage from ${label}`}
1061 onClick={() => void enable()}
1062 >
1063 {buttonText}
1064 </Button>
1065 );
1066 if (!tooltip) return button;
1067 return (
1068 <Tooltip>
1069 <TooltipTrigger render={button} />
1070 <TooltipPopup>{CURSOR_KEYCHAIN_COPY}</TooltipPopup>
1071 </Tooltip>
1072 );
1073}
1074974
1075function CursorEnableRow({
1076 environmentId,
1077 label,
1078 showEnvironment,
1079 onEnabled,
1080}: {
1081 readonly environmentId: EnvironmentId;
1082 readonly label: string;
1083 readonly showEnvironment: boolean;
1084 readonly onEnabled: () => void;
1085}) {
1086975 return (
1087976 <div className="flex min-w-0 items-baseline justify-between gap-4 text-sm">
1088977 <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">
......@@ -1094,51 +983,22 @@ function CursorEnableRow({
1094983 <ProviderMark provider="cursor" className="size-4" />
1095984 <span className="truncate">Cursor{showEnvironment ? ` · ${label}` : ""}</span>
1096985 </span>
1097 <CursorEnableButton
1098 environmentId={environmentId}
1099 label={label}
1100 onEnabled={onEnabled}
1101 tooltip
1102 />
1103 </div>
1104 );
1105}
1106
1107function CursorEnableLimits({
1108 environments,
1109 onEnabled,
1110}: {
1111 readonly environments: readonly EnvironmentUsageStatus[];
1112 readonly onEnabled: () => void;
1113}) {
1114 return (
1115 <section className="flex flex-col gap-3">
1116 <h2 className="flex items-center gap-2 text-sm font-medium text-foreground">
1117 <ProviderInstanceIcon
1118 driverKind={ProviderDriverKind.make("cursor")}
1119 displayName="Cursor"
1120 indicatorBackground="var(--background)"
1121 className="size-5"
1122 iconClassName="size-4 text-foreground/80"
986 <Tooltip>
987 <TooltipTrigger
988 render={
989 <InlineButton
990 disabled={pending}
991 aria-busy={pending}
992 aria-label={`Enable Cursor usage from ${label}`}
993 onClick={() => void enable()}
994 >
995 Enable
996 </InlineButton>
997 }
1123998 />
1124 Cursor
1125 </h2>
1126 <div className="flex flex-col items-start gap-3 rounded-lg border border-border/60 p-4">
1127 <p className="text-xs text-muted-foreground">{CURSOR_KEYCHAIN_COPY}</p>
1128 <div className="flex flex-wrap gap-2">
1129 {environments.map((environment) => (
1130 <CursorEnableButton
1131 key={environment.environmentId}
1132 environmentId={environment.environmentId}
1133 label={environment.label}
1134 buttonText={environments.length > 1 ? `Enable on ${environment.label}` : "Enable"}
1135 onEnabled={onEnabled}
1136 tooltip={false}
1137 />
1138 ))}
1139 </div>
1140 </div>
1141 </section>
999 <TooltipPopup>{CURSOR_KEYCHAIN_COPY}</TooltipPopup>
1000 </Tooltip>
1001 </div>
11421002 );
11431003}
11441004
......@@ -1239,7 +1099,6 @@ function UsageEnvironmentFilter({
12391099 selectedEnvironments,
12401100 selectedEnvironmentIds,
12411101 onSelectionChange,
1242 showUsageStatus,
12431102 refreshing,
12441103 isPartial,
12451104 duplicateSources,
......@@ -1250,7 +1109,6 @@ function UsageEnvironmentFilter({
12501109 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
12511110 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
12521111 readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId> | null) => void;
1253 readonly showUsageStatus: boolean;
12541112 readonly refreshing: boolean;
12551113 readonly isPartial: boolean;
12561114 readonly duplicateSources: readonly string[];
......@@ -1272,7 +1130,7 @@ function UsageEnvironmentFilter({
12721130 <MenuTrigger render={<InlineButton />} className="group/usage-environment min-w-0 max-w-full">
12731131 <WorkspaceBreadcrumbText>{label}</WorkspaceBreadcrumbText>
12741132 <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
1275 {showUsageStatus && hasIssue ? (
1133 {hasIssue ? (
12761134 <CircleAlertIcon
12771135 className="size-3.5 text-warning-foreground"
12781136 aria-label="Some environments could not report usage"
......@@ -1331,16 +1189,14 @@ function UsageEnvironmentFilter({
13311189 >
13321190 <span className="flex min-w-0 items-center gap-3">
13331191 <span className="min-w-0 flex-1 truncate">{environment.label}</span>
1334 {showUsageStatus ? (
1335 <span
1336 className={cn(
1337 "shrink-0 text-xs text-muted-foreground",
1338 environment.error !== null && "text-destructive",
1339 )}
1340 >
1341 {status}
1342 </span>
1343 ) : null}
1192 <span
1193 className={cn(
1194 "shrink-0 text-xs text-muted-foreground",
1195 environment.error !== null && "text-destructive",
1196 )}
1197 >
1198 {status}
1199 </span>
13441200 </span>
13451201 </MenuCheckboxItem>
13461202 );
......@@ -1348,18 +1204,16 @@ function UsageEnvironmentFilter({
13481204 {environments.length === 0 ? (
13491205 <p className="px-2 py-2 text-xs text-muted-foreground">No environments connected.</p>
13501206 ) : null}
1351 {showUsageStatus && isPartial ? (
1207 {isPartial ? (
13521208 <p className="px-2 py-2 text-xs text-muted-foreground">
13531209 Totals are partial while selected environments scan.
13541210 </p>
13551211 ) : null}
1356 {showUsageStatus ? (
1357 <UsageCoverageNotice
1358 environments={selectedEnvironments}
1359 duplicateSources={duplicateSources}
1360 contractMismatches={contractMismatches}
1361 />
1362 ) : null}
1212 <UsageCoverageNotice
1213 environments={selectedEnvironments}
1214 duplicateSources={duplicateSources}
1215 contractMismatches={contractMismatches}
1216 />
13631217 <MenuSeparator />
13641218 <MenuItem onClick={onOpenModelPrices}>
13651219 <SlidersHorizontalIcon aria-hidden />
apps/web/src/components/usage/sidebarUsageProfiles.test.ts created+276
......@@ -0,0 +1,276 @@
1import {
2 EnvironmentId,
3 ProviderDriverKind,
4 ProviderInstanceId,
5 UsageLimitSourceId,
6 type ServerProvider,
7 type ServerProviderUsageWindow,
8 type UsageLimitSourceSnapshots,
9} from "@t3tools/contracts";
10import { describe, expect, it } from "vite-plus/test";
11
12import { sidebarUsageProfiles } from "./sidebarUsageProfiles";
13
14const checkedAt = "2026-10-15T12:00:00.000Z";
15const session: ServerProviderUsageWindow = {
16 id: "five_hour",
17 kind: "session",
18 label: "Session",
19 usedPercent: 20,
20};
21
22function provider(overrides: Partial<ServerProvider> = {}): ServerProvider {
23 return {
24 instanceId: ProviderInstanceId.make("personal"),
25 driver: ProviderDriverKind.make("claudeAgent"),
26 displayName: "Personal",
27 accentColor: "#abc123",
28 enabled: true,
29 installed: true,
30 version: null,
31 status: "ready",
32 auth: { status: "authenticated", email: "person@example.com" },
33 checkedAt,
34 models: [],
35 slashCommands: [],
36 skills: [],
37 usageLimits: { checkedAt, windows: [session] },
38 ...overrides,
39 };
40}
41
42function environment(
43 providers: readonly ServerProvider[],
44 phase: "connected" | "offline" = "connected",
45 usageLimitSources: UsageLimitSourceSnapshots = [],
46) {
47 return {
48 entry: { target: { label: "Laptop" } },
49 connection: { phase },
50 serverConfig: { providers, usageLimitSources },
51 };
52}
53
54describe("sidebar usage profiles", () => {
55 it("summarizes the most constrained window and preserves native identity", () => {
56 const weekly = { ...session, id: "seven_day", kind: "weekly" as const, usedPercent: 55 };
57 const result = sidebarUsageProfiles(
58 new Map([
59 [
60 EnvironmentId.make("laptop"),
61 environment([provider({ usageLimits: { checkedAt, windows: [session, weekly] } })]),
62 ],
63 ]),
64 );
65
66 expect(result).toHaveLength(1);
67 expect(result[0]).toMatchObject({
68 remainingPercent: 45,
69 window: weekly,
70 account: { displayName: "Personal", accentColor: "#abc123", limits: { checkedAt } },
71 });
72 });
73
74 it("excludes Fable by id or label only for Claude", () => {
75 const windows = [
76 session,
77 { ...session, id: "seven_day_fable", usedPercent: 100 },
78 { ...session, id: "seven_day_overage_included", label: "Weekly · Fable", usedPercent: 99 },
79 ];
80 const profiles = sidebarUsageProfiles(
81 new Map([
82 [
83 EnvironmentId.make("laptop"),
84 environment([
85 provider({ usageLimits: { checkedAt, windows } }),
86 provider({
87 instanceId: ProviderInstanceId.make("codex"),
88 driver: ProviderDriverKind.make("codex"),
89 usageLimits: { checkedAt, windows },
90 }),
91 ]),
92 ],
93 ]),
94 );
95
96 expect(profiles.map((profile) => profile.remainingPercent)).toEqual([80, 0]);
97 });
98
99 it("keeps Fable-only and failed profiles visible with an unknown ring", () => {
100 const profiles = sidebarUsageProfiles(
101 new Map([
102 [
103 EnvironmentId.make("laptop"),
104 environment([
105 provider({
106 usageLimits: { checkedAt, windows: [{ ...session, id: "seven_day_fable" }] },
107 }),
108 provider({
109 instanceId: ProviderInstanceId.make("unreadable"),
110 auth: { status: "authenticated", email: "other@example.com" },
111 usageLimits: {
112 checkedAt,
113 windows: [session],
114 unavailable: { reason: "probeFailed" },
115 },
116 }),
117 ]),
118 ],
119 ]),
120 );
121
122 expect(profiles).toHaveLength(2);
123 expect(profiles.map(({ remainingPercent, window }) => ({ remainingPercent, window }))).toEqual([
124 { remainingPercent: null, window: null },
125 { remainingPercent: null, window: null },
126 ]);
127 });
128
129 it("does not present cached offline usage as a full ring", () => {
130 const profiles = sidebarUsageProfiles(
131 new Map([
132 [
133 EnvironmentId.make("laptop"),
134 environment(
135 [provider({ usageLimits: { checkedAt, windows: [{ ...session, usedPercent: 0 }] } })],
136 "offline",
137 ),
138 ],
139 ]),
140 );
141
142 expect(profiles[0]?.remainingPercent).toBeNull();
143 });
144
145 it.each([
146 { phase: "connected" as const, error: undefined, remaining: 80 },
147 { phase: "connected" as const, error: "Could not refresh", remaining: null },
148 { phase: "offline" as const, error: undefined, remaining: null },
149 ])(
150 "represents hub-only profiles in $phase with source error $error",
151 ({ phase, error, remaining }) => {
152 const profiles = sidebarUsageProfiles(
153 new Map([
154 [
155 EnvironmentId.make("laptop"),
156 environment([], phase, [
157 {
158 id: UsageLimitSourceId.make("hub"),
159 kind: "cliproxy",
160 label: "Hub",
161 checkedAt,
162 error,
163 accounts: [
164 {
165 id: "profile.json",
166 driver: ProviderDriverKind.make("claudeAgent"),
167 email: "person@example.com",
168 usageLimits: { checkedAt, windows: [session] },
169 },
170 ],
171 },
172 ]),
173 ],
174 ]),
175 );
176
177 expect(profiles).toHaveLength(1);
178 expect(profiles[0]?.remainingPercent).toBe(remaining);
179 expect(profiles[0]?.account).toMatchObject({ displayName: null, sourceLabel: "Hub" });
180 },
181 );
182
183 it("deduplicates native, offline, and hub copies while retaining the connected snapshot", () => {
184 const profiles = sidebarUsageProfiles(
185 new Map([
186 [EnvironmentId.make("laptop"), environment([provider()], "offline")],
187 [
188 EnvironmentId.make("desktop"),
189 {
190 ...environment([provider()]),
191 serverConfig: {
192 providers: [provider()],
193 usageLimitSources: [
194 {
195 id: UsageLimitSourceId.make("hub"),
196 kind: "cliproxy" as const,
197 label: "Hub",
198 checkedAt,
199 accounts: [
200 {
201 id: "personal.json",
202 driver: ProviderDriverKind.make("claudeAgent"),
203 email: "PERSON@example.com",
204 usageLimits: {
205 checkedAt: "2026-10-15T12:01:00.000Z",
206 windows: [{ ...session, usedPercent: 60 }],
207 },
208 },
209 ],
210 },
211 ],
212 },
213 },
214 ],
215 ]),
216 );
217
218 expect(profiles).toHaveLength(1);
219 expect(profiles[0]).toMatchObject({
220 remainingPercent: 40,
221 account: {
222 displayName: "Personal",
223 environments: [
224 { environmentId: "laptop", label: "Laptop" },
225 { environmentId: "desktop", label: "Laptop" },
226 ],
227 },
228 });
229 });
230
231 it("does not synthesize a refill after the reported reset time", () => {
232 const profiles = sidebarUsageProfiles(
233 new Map([
234 [
235 EnvironmentId.make("laptop"),
236 environment([
237 provider({
238 usageLimits: {
239 checkedAt,
240 windows: [{ ...session, usedPercent: 100, resetsAt: checkedAt }],
241 },
242 }),
243 ]),
244 ],
245 ]),
246 );
247
248 expect(profiles[0]?.remainingPercent).toBe(0);
249 });
250
251 it("keeps workspace quotas separate and omits disabled or unsupported profiles", () => {
252 const profiles = sidebarUsageProfiles(
253 new Map([
254 [
255 EnvironmentId.make("laptop"),
256 environment([
257 provider({
258 auth: { status: "authenticated", email: "person@example.com", workspaceId: "one" },
259 }),
260 provider({
261 instanceId: ProviderInstanceId.make("two"),
262 auth: { status: "authenticated", email: "person@example.com", workspaceId: "two" },
263 }),
264 provider({ instanceId: ProviderInstanceId.make("disabled"), enabled: false }),
265 provider({
266 instanceId: ProviderInstanceId.make("api-key"),
267 usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } },
268 }),
269 ]),
270 ],
271 ]),
272 );
273
274 expect(profiles).toHaveLength(2);
275 });
276});
apps/web/src/components/usage/sidebarUsageProfiles.ts created+66
......@@ -0,0 +1,66 @@
1import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
2import type { EnvironmentId } from "@t3tools/contracts";
3import {
4 collectLimitAccounts,
5 isUsageLimitWindowVisible,
6 limitsNotice,
7 remainingPercent,
8 type LimitPresentation,
9} from "@t3tools/shared/usageLimits";
10
11export function sidebarUsageProfiles(
12 presentations: ReadonlyMap<
13 EnvironmentId,
14 LimitPresentation & {
15 readonly connection: Pick<EnvironmentConnectionPresentation, "phase">;
16 }
17 >,
18) {
19 const snapshots = new Map(
20 [...presentations].map(([environmentId, presentation]) => {
21 const config = presentation.serverConfig;
22 const connected = presentation.connection.phase === "connected";
23
24 return [
25 environmentId,
26 {
27 ...presentation,
28 serverConfig: config && {
29 ...config,
30 providers: config.providers?.map((provider) =>
31 connected || !provider.usageLimits
32 ? provider
33 : { ...provider, usageLimits: { ...provider.usageLimits, windows: [] } },
34 ),
35 usageLimitSources: config.usageLimitSources?.map((source) => ({
36 ...source,
37 accounts:
38 connected && !source.error
39 ? source.accounts
40 : source.accounts.map((account) => ({
41 ...account,
42 usageLimits: { ...account.usageLimits, windows: [] },
43 })),
44 })),
45 },
46 },
47 ] as const;
48 }),
49 );
50
51 return collectLimitAccounts(snapshots, { includeUnavailable: true }).map((account) => {
52 const windows =
53 limitsNotice(account.limits) === null
54 ? account.limits.windows.filter((window) =>
55 isUsageLimitWindowVisible(account.driver, window),
56 )
57 : [];
58 const window = windows.reduce<(typeof windows)[number] | null>(
59 (mostUsed, candidate) =>
60 mostUsed === null || candidate.usedPercent > mostUsed.usedPercent ? candidate : mostUsed,
61 null,
62 );
63
64 return { account, window, remainingPercent: window ? remainingPercent(window) : null };
65 });
66}
apps/web/src/components/usage/usagePagePreferences.test.ts+18-6
......@@ -25,16 +25,28 @@ afterEach(() => {
2525
2626describe("Usage page preferences", () => {
2727 it("uses defaults when no preference has been saved", () => {
28 expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 30 });
28 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
2929 });
3030
3131 it.each([1, 7, 30, 90] as const)("round-trips every metric with a %i-day range", (windowDays) => {
32 for (const metric of ["cost", "tokens", "limits"] as const) {
32 for (const metric of ["cost", "tokens"] as const) {
3333 saveUsagePagePreferences({ metric, windowDays });
3434 expect(readUsagePagePreferences()).toEqual({ metric, windowDays });
3535 }
3636 });
3737
38 it("opens saved limits preferences as cost without discarding filters", () => {
39 values.set(
40 key,
41 JSON.stringify({ metric: "limits", windowDays: 7, hiddenProviders: ["claude"] }),
42 );
43 expect(readUsagePagePreferences()).toEqual({
44 metric: "cost",
45 windowDays: 7,
46 hiddenProviders: ["claude"],
47 });
48 });
49
3850 it("round-trips hidden providers and reads older saves without them", () => {
3951 saveUsagePagePreferences({ metric: "cost", windowDays: 7, hiddenProviders: ["codex"] });
4052 expect(readUsagePagePreferences().hiddenProviders).toEqual(["codex"]);
......@@ -49,7 +61,7 @@ describe("Usage page preferences", () => {
4961 '{"metric":"cost","windowDays":365}',
5062 ])("replaces invalid preferences on the next save: %s", (value) => {
5163 values.set(key, value);
52 expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 30 });
64 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
5365 saveUsagePagePreferences({ metric: "tokens", windowDays: 7 });
5466 expect(readUsagePagePreferences()).toEqual({ metric: "tokens", windowDays: 7 });
5567 });
......@@ -62,8 +74,8 @@ describe("Usage page preferences", () => {
6274 expect(() => saveUsagePagePreferences({ metric: "tokens", windowDays: 7 })).not.toThrow();
6375 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
6476 write.mockRestore();
65 saveUsagePagePreferences({ metric: "limits", windowDays: 7 });
66 expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 7 });
77 saveUsagePagePreferences({ metric: "cost", windowDays: 7 });
78 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 7 });
6779 });
6880
6981 it("contains failures when the browser blocks storage access", () => {
......@@ -72,7 +84,7 @@ describe("Usage page preferences", () => {
7284 throw new Error("SecurityError");
7385 },
7486 });
75 expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 30 });
87 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
7688 expect(() => saveUsagePagePreferences({ metric: "tokens", windowDays: 7 })).not.toThrow();
7789 });
7890});
apps/web/src/components/usage/usagePagePreferences.ts+8-6
......@@ -10,16 +10,18 @@ const UsagePagePreferencesSchema = Schema.Struct({
1010 /** Providers filtered out of the page. Stored as hidden so new providers show by default. */
1111 hiddenProviders: Schema.optional(Schema.Array(UsageProviderKind)),
1212});
13export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type;
13export type UsagePagePreferences = Omit<typeof UsagePagePreferencesSchema.Type, "metric"> & {
14 readonly metric: "cost" | "tokens";
15};
1416
15// Limits is what most people open the page for (how much subscription quota is
16// left, and when it resets), so it is the first-visit default; the last picked
17// tab sticks after that.
18const DEFAULT_PREFERENCES: UsagePagePreferences = { metric: "limits", windowDays: 30 };
17const DEFAULT_PREFERENCES: UsagePagePreferences = { metric: "cost", windowDays: 30 };
1918
2019export function readUsagePagePreferences(): UsagePagePreferences {
2120 try {
22 return getLocalStorageItem(STORAGE_KEY, UsagePagePreferencesSchema) ?? DEFAULT_PREFERENCES;
21 const saved = getLocalStorageItem(STORAGE_KEY, UsagePagePreferencesSchema);
22 return saved === null
23 ? DEFAULT_PREFERENCES
24 : { ...saved, metric: saved.metric === "limits" ? "cost" : saved.metric };
2325 } catch (error) {
2426 console.error("Could not read Usage page preferences.", error);
2527 return DEFAULT_PREFERENCES;
apps/web/src/components/usage/usageShortcuts.test.ts+18
......@@ -17,6 +17,24 @@ class TestElement extends EventTarget {
1717
1818afterEach(() => vi.unstubAllGlobals());
1919
20it("does not reserve the former Limits shortcut", () => {
21 vi.stubGlobal("HTMLElement", TestElement);
22 vi.stubGlobal("navigator", { platform: "Linux" });
23 expect(
24 resolveUsageShortcut(
25 {
26 key: "l",
27 metaKey: false,
28 ctrlKey: false,
29 shiftKey: false,
30 altKey: false,
31 target: new TestElement("BODY"),
32 },
33 DEFAULT_RESOLVED_KEYBINDINGS,
34 ),
35 ).toBeNull();
36});
37
2038describe("Usage shortcuts while typing", () => {
2139 it.each([new TestElement("INPUT"), new TestElement("TEXTAREA"), new TestElement("DIV", true)])(
2240 "leaves letters to the focused $tagName field",
apps/web/src/components/usage/usageShortcuts.ts+1-2
......@@ -2,11 +2,10 @@ import type { KeybindingCommand, ResolvedKeybindingsConfig } from "@t3tools/cont
22import type { UsageChartMetric } from "./UsageProviderChart";
33import { resolveShortcutCommand, type ShortcutEventLike } from "../../keybindings";
44
5export type UsageMetric = UsageChartMetric | "limits";
5export type UsageMetric = UsageChartMetric;
66export const METRIC_OPTIONS = [
77 { value: "cost", label: "Cost", command: "usage.cost" },
88 { value: "tokens", label: "Tokens", command: "usage.tokens" },
9 { value: "limits", label: "Limits", command: "usage.limits" },
109] as const satisfies readonly { value: UsageMetric; label: string; command: KeybindingCommand }[];
1110
1211export const WINDOW_OPTIONS = [
docs/user/providers-antigravity.md+2-1
......@@ -125,7 +125,8 @@ is refused while the runtime is in use.
125125
126126## Check access and troubleshoot
127127
128Open **Usage > Limits** to see the remaining Gemini and shared Claude/GPT allowances
128Hover or focus the sidebar **Usage** button on web and desktop, or open **Usage → Limits** on
129mobile, to see the remaining Gemini and shared Claude/GPT allowances
129130for Google sign-ins. Each group has separate five-hour and weekly limits and reset
130131times. Limits refresh with provider status and include usage outside T3 Code.
131132
docs/user/usage.md+15-12
......@@ -41,7 +41,7 @@ sharing a history directory count once.
4141When your app and server support different providers, usage totals may cover only the providers
4242your app understands. Update the app to include newly supported providers.
4343
44On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All
44On web and desktop, use the environment dropdown to filter costs and tokens. All
4545environments are selected by default. The dropdown shows which environments are still scanning;
4646results appear as each one responds, and figures still updating are dimmed. Cursor shows its last
4747saved totals first, then updates them when Cursor's API responds.
......@@ -78,9 +78,12 @@ the dialog.
7878
7979## Track subscription limits
8080
81**Usage → Limits** pools every subscription account it can see per provider, so with several Codex
82or Claude accounts across your environments and hubs you read one number per window rather than a
83list. Each window card shows how much of the pool is left and a bar with one segment per account,
81**On web and desktop**, hover the sidebar Usage button to inspect subscription
82limits, or focus it and press Arrow Down.
83Click it to open costs and tokens. Right-click it for usage reset settings and provider configuration.
84**On mobile**, open **Usage → Limits**. The detailed limits view pools subscription accounts per
85provider across your environments and hubs. Each window card shows how much of the pool is left
86and a bar with one segment per account,
8487kept in the same column across windows. Accounts are ordered by their 5-hour reset, soonest
8588first, or by the first available window when no account reports a 5-hour limit. A gap means the
8689account does not report that window. When the provider reports reset times, the card also says
......@@ -93,16 +96,16 @@ Keychain. On narrow screens, numbered rows below
9396the bar show each account's quota, countdown, and credits. Tap a row to open its details.
9497
9598The same account signed in on more than one environment, or reported by a hub as well, counts once.
96Filter with the environment dropdown to see what a single machine has.
99On mobile, filter with the environment dropdown to see what a single machine has.
97100
98Opening Limits checks the selected connected environments automatically. Each client waits at
101Opening the detailed limits view checks connected environments automatically. Each client waits at
99102least five minutes between automatic checks of an environment, including after a failed check.
100If a window still looks stale, refresh Limits to re-check every provider and hub.
103If a window still looks stale, refresh the limits view to re-check every provider and hub.
101104
102105Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the
103106current model's limits without leaving the conversation. The result opens above the composer and
104closes when you dismiss it or send your next message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh
105anything. The command is offered only for providers that appear under **Usage → Limits**.
107closes when you dismiss it or send your next message. It uses the same limits snapshot, so it does
108not run the agent or refresh anything. The command is offered only for providers reporting limits.
106109
107110OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in
108111the environment. T3 cannot report limits for external OpenCode servers because their credentials
......@@ -126,7 +129,7 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`.
126129To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the
127130environment that will connect to the hub and enter its URL and management key.
128131
129The accounts appear under **Usage → Limits**. Codex accounts show banked reset credits; select an
132The accounts appear in the detailed limits view. Codex accounts show banked reset credits; select an
130133account and choose **Use reset** to redeem one. No hub plugin is required.
131134
132135This connection supplies usage information; configure
......@@ -143,8 +146,8 @@ Session, Weekly, or both for each provider. Reopen T3 to refresh expired reading
143146## Keyboard shortcuts
144147
145148On web and desktop, open Usage from the command palette. While on Usage,
146press `C`, `T`, or `L` for Cost, Tokens, or Limits while not typing in a field.
149press `C` or `T` for Cost or Tokens while not typing in a field.
147150Use `Ctrl+Shift+1/2/3/4` (`Cmd+Shift+1/2/3/4` on macOS) for the past
14824 hours, 7 days, 30 days, or 90 days. Period shortcuts do nothing on Limits.
15124 hours, 7 days, 30 days, or 90 days.
149152Press `Escape` to return to the previous page. Customize these shortcuts in
150153**Settings → Keybindings**.
packages/shared/src/keybindings.ts-1
......@@ -202,7 +202,6 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
202202 })),
203203 { key: "c", command: "usage.cost", when: "usagePageOpen" },
204204 { key: "t", command: "usage.tokens", when: "usagePageOpen" },
205 { key: "l", command: "usage.limits", when: "usagePageOpen" },
206205 { key: "mod+shift+1", command: "usage.period.day", when: "usagePageOpen" },
207206 { key: "mod+shift+2", command: "usage.period.week", when: "usagePageOpen" },
208207 { key: "mod+shift+3", command: "usage.period.month", when: "usagePageOpen" },
packages/shared/src/usageLimits.test.ts+43
......@@ -161,6 +161,49 @@ describe("pools", () => {
161161 };
162162 const laptop = { entry: { target: { label: "Laptop" } } };
163163
164 it("can retain failed profiles without changing the default pooled accounts", () => {
165 const failed = provider({
166 usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } },
167 });
168 const input = new Map([
169 [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [failed] } }],
170 ]);
171
172 expect(collectLimitAccounts(input)).toEqual([]);
173 expect(collectLimitAccounts(input, { includeUnavailable: true })).toMatchObject([
174 { key: "env-a:codex", limits: failed.usageLimits },
175 ]);
176 });
177
178 it.each([false, true])(
179 "prefers usable quotas to a newer failure, failure first: %s",
180 (failedFirst) => {
181 const healthy = provider({
182 auth: { status: "authenticated", email: "person@example.com" },
183 usageLimits: { checkedAt, windows: [window] },
184 });
185 const failed = {
186 ...healthy,
187 usageLimits: {
188 checkedAt: "2026-09-03T12:00:00.000Z",
189 windows: [],
190 unavailable: { reason: "probeFailed" as const },
191 },
192 };
193 const providers = failedFirst ? [failed, healthy] : [healthy, failed];
194 const input = new Map(
195 providers.map((provider, index) => [
196 EnvironmentId.make(`env-${index}`),
197 { ...laptop, serverConfig: { providers: [provider] } },
198 ]),
199 );
200
201 const accounts = collectLimitAccounts(input, { includeUnavailable: true });
202 expect(accounts).toHaveLength(1);
203 expect(accounts[0]?.limits).toEqual(healthy.usageLimits);
204 },
205 );
206
164207 it("merges one account reported natively on two environments and by a hub into one entry", () => {
165208 const native = provider({
166209 driver: claude,
packages/shared/src/usageLimits.ts+46-17
......@@ -52,6 +52,13 @@ export function cursorUsageWindowDetails(id: string) {
5252 return CURSOR_USAGE_WINDOWS.find((window) => window.id === id);
5353}
5454
55export function isUsageLimitWindowVisible(
56 driver: ServerProvider["driver"],
57 window: Pick<ServerProviderUsageWindow, "id" | "label">,
58) {
59 return driver !== "claudeAgent" || !/fable/i.test(`${window.id} ${window.label}`);
60}
61
5562function cursorUsageWindowRank(id: string): number {
5663 const rank = CURSOR_USAGE_WINDOWS.findIndex((window) => window.id === id);
5764 return rank < 0 ? CURSOR_USAGE_WINDOWS.length : rank;
......@@ -74,16 +81,15 @@ export function providersWithLimits(
7481 );
7582}
7683
77export type LimitPresentations = ReadonlyMap<
78 EnvironmentId,
79 {
80 readonly entry: { readonly target: { readonly label: string } };
81 readonly serverConfig: {
82 readonly providers?: readonly ServerProvider[] | undefined;
83 readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined;
84 } | null;
85 }
86>;
84export interface LimitPresentation {
85 readonly entry: { readonly target: { readonly label: string } };
86 readonly serverConfig: {
87 readonly providers?: readonly ServerProvider[] | undefined;
88 readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined;
89 } | null;
90}
91
92export type LimitPresentations = ReadonlyMap<EnvironmentId, LimitPresentation>;
8793
8894/** One destination per service, even when several accounts or environments use it. */
8995export function collectExternalUsageLinks(presentations: LimitPresentations) {
......@@ -196,10 +202,13 @@ export interface LimitAccount {
196202
197203/**
198204 * Every account with usable windows across the connected environments, one
199 * entry per distinct account. The freshest reads supply windows and credits;
200 * native instances supply names and environment labels.
205 * entry per distinct account. The freshest usable reads supply windows and
206 * credits; native instances supply names and environment labels.
201207 */
202export function collectLimitAccounts(presentations: LimitPresentations): readonly LimitAccount[] {
208export function collectLimitAccounts(
209 presentations: LimitPresentations,
210 options?: { readonly includeUnavailable: boolean },
211): readonly LimitAccount[] {
203212 const accounts = new Map<string, LimitAccount>();
204213 const creditSources = new Map<string, LimitAccount>();
205214 const hubRedeems = new Map<string, LimitAccount>();
......@@ -230,7 +239,12 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl
230239 accounts.set(key, next);
231240 return;
232241 }
233 const fresher = Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt);
242 const previousUsable = limitsNotice(previous.limits) === null;
243 const nextUsable = limitsNotice(next.limits) === null;
244 const fresher =
245 previousUsable === nextUsable
246 ? Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt)
247 : nextUsable;
234248 // Two instances on one machine sharing an account still name it once.
235249 const environments = [
236250 ...previous.environments,
......@@ -272,8 +286,19 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl
272286 );
273287 for (const [environmentId, presentation] of presentations) {
274288 const label = presentation.entry.target.label;
275 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
276 if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue;
289 const providers = options?.includeUnavailable
290 ? (presentation.serverConfig?.providers ?? []).filter(
291 (provider) =>
292 provider.enabled && provider.installed && provider.usageLimits !== undefined,
293 )
294 : providersWithLimits(presentation.serverConfig?.providers ?? []);
295 for (const provider of providers) {
296 if (
297 !provider.usageLimits ||
298 provider.usageLimits.unavailable?.reason === "unsupported" ||
299 (!options?.includeUnavailable && limitsNotice(provider.usageLimits) !== null)
300 )
301 continue;
277302 merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, {
278303 key: `${environmentId}:${provider.instanceId}`,
279304 driver: provider.driver,
......@@ -298,7 +323,11 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl
298323 ? `${presentation.entry.target.label} · ${source.label}`
299324 : source.label;
300325 for (const account of source.accounts) {
301 if (limitsNotice(account.usageLimits) !== null) continue;
326 if (
327 account.usageLimits.unavailable?.reason === "unsupported" ||
328 (!options?.includeUnavailable && limitsNotice(account.usageLimits) !== null)
329 )
330 continue;
302331 const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`;
303332 merge(resolveKey(account) ?? key, {
304333 key,