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", () => {...@@ -83,7 +83,6 @@ describe("KeybindingsSettings.logic", () => {
83 "usage.open",83 "usage.open",
84 "usage.cost",84 "usage.cost",
85 "usage.tokens",85 "usage.tokens",
86 "usage.limits",
87 "usage.period.day",86 "usage.period.day",
88 "usage.period.week",87 "usage.period.week",
89 "usage.period.month",88 "usage.period.month",
...@@ -101,6 +100,23 @@ describe("KeybindingsSettings.logic", () => {...@@ -101,6 +100,23 @@ describe("KeybindingsSettings.logic", () => {
101 }100 }
102 });101 });
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
104 it("builds searchable rows with readable key and when values", () => {120 it("builds searchable rows with readable key and when values", () => {
105 const rows = buildKeybindingRows(121 const rows = buildKeybindingRows(
106 [122 [
apps/web/src/components/settings/KeybindingsSettings.logic.ts+20-14
...@@ -253,21 +253,25 @@ export function buildKeybindingRows(...@@ -253,21 +253,25 @@ export function buildKeybindingRows(
253 query: string,253 query: string,
254): ReadonlyArray<KeybindingRow> {254): ReadonlyArray<KeybindingRow> {
255 const normalizedQuery = query.trim().toLowerCase();255 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
257 const defaultBinding = defaultBindingForBinding(binding);259 const defaultBinding = defaultBindingForBinding(binding);
258 const key = shortcutToKeybindingInput(binding.shortcut);260 const key = shortcutToKeybindingInput(binding.shortcut);
259 const when = whenAstToExpression(binding.whenAst);261 const when = whenAstToExpression(binding.whenAst);
260 return {262 return [
261 id: `${keybindingRowId(binding.command, key, when)}\u0000${index}`,263 {
262 command: binding.command,264 id: `${keybindingRowId(binding.command, key, when)}\u0000${index}`,
263 key,265 command: binding.command,
264 when,266 key,
265 source: sourceForBinding(binding),267 when,
266 defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null,268 source: sourceForBinding(binding),
267 defaultWhen: whenAstToExpression(defaultBinding?.whenAst),269 defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null,
268 binding,270 defaultWhen: whenAstToExpression(defaultBinding?.whenAst),
269 conflicts: [],271 binding,
270 } satisfies KeybindingRow;272 conflicts: [],
273 } satisfies KeybindingRow,
274 ];
271 });275 });
272276
273 const rowsWithConflicts = rows.map((row) => {277 const rowsWithConflicts = rows.map((row) => {
...@@ -351,9 +355,11 @@ export function buildWhenVariableOptions(): ReadonlyArray<WhenVariableOption> {...@@ -351,9 +355,11 @@ export function buildWhenVariableOptions(): ReadonlyArray<WhenVariableOption> {
351export function buildKeybindingCommandOptions(355export function buildKeybindingCommandOptions(
352 keybindings: ResolvedKeybindingsConfig,356 keybindings: ResolvedKeybindingsConfig,
353): ReadonlyArray<KeybindingCommandOption> {357): 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 );
355 for (const binding of keybindings) {361 for (const binding of keybindings) {
356 commands.add(binding.command);362 if (binding.command !== "usage.limits") commands.add(binding.command);
357 }363 }
358 return [...commands].toSorted((left, right) => compareCommands(left, right, commandLabel));364 return [...commands].toSorted((left, right) => compareCommands(left, right, commandLabel));
359}365}
apps/web/src/components/sidebar/SidebarChrome.tsx+3-13
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { ArrowLeftIcon, ChartNoAxesColumnIcon } from "lucide-react";1import { ArrowLeftIcon } from "lucide-react";
2import type { ReactNode } from "react";2import type { ReactNode } from "react";
3import { memo, useCallback } from "react";3import { memo, useCallback } from "react";
4import { useLocation, useNavigate } from "@tanstack/react-router";4import { useLocation, useNavigate } from "@tanstack/react-router";
...@@ -25,6 +25,7 @@ import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation";...@@ -25,6 +25,7 @@ import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation";
25import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice";25import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice";
26import { AppMenu } from "./AppMenu";26import { AppMenu } from "./AppMenu";
27import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";27import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
28import { SidebarUsageButton } from "./SidebarUsageButton";
2829
29export const SidebarChromeHeader = memo(function SidebarChromeHeader({30export const SidebarChromeHeader = memo(function SidebarChromeHeader({
30 isElectron,31 isElectron,
...@@ -109,13 +110,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {...@@ -109,13 +110,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
109 search: readPullRequestListPreferences(),110 search: readPullRequestListPreferences(),
110 });111 });
111 }, [closeMobileSidebar, navigate]);112 }, [closeMobileSidebar, navigate]);
112 const handleUsageClick = useCallback(() => {
113 if (isMobile) {
114 setOpenMobile(false);
115 }
116 void navigate({ to: "/usage" });
117 }, [isMobile, navigate, setOpenMobile]);
118
119 const handleBackClick = useCallback(() => {113 const handleBackClick = useCallback(() => {
120 closeMobileSidebar();114 closeMobileSidebar();
121 void navigateToMainApp();115 void navigateToMainApp();
...@@ -139,11 +133,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {...@@ -139,11 +133,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
139 onClick={handlePullRequestsClick}133 onClick={handlePullRequestsClick}
140 />134 />
141 ) : null}135 ) : null}
142 <SidebarUtilityItem
143 icon={<ChartNoAxesColumnIcon />}
144 label="Usage"
145 onClick={handleUsageClick}
146 />
147 </>136 </>
148 )}137 )}
149 </SidebarMenu>138 </SidebarMenu>
...@@ -154,6 +143,7 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {...@@ -154,6 +143,7 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
154 return (143 return (
155 <SidebarFooter>144 <SidebarFooter>
156 <SidebarThreadUndoNotice />145 <SidebarThreadUndoNotice />
146 <SidebarUsageButton />
157 <SidebarUtilityMenu />147 <SidebarUtilityMenu />
158 </SidebarFooter>148 </SidebarFooter>
159 );149 );
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 {...@@ -8,7 +8,6 @@ import {
8 ServerProviderUsageWindow,8 ServerProviderUsageWindow,
9 UsageProviderKind,9 UsageProviderKind,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { useAtomValue } from "@effect/atom-react";
12import {11import {
13 elapsedShare,12 elapsedShare,
14 formatDuration,13 formatDuration,
...@@ -18,10 +17,9 @@ import {...@@ -18,10 +17,9 @@ import {
18 remainingPercent,17 remainingPercent,
19} from "@t3tools/shared/usageLimits";18} from "@t3tools/shared/usageLimits";
20import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";19import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
21import { Fragment, type ReactNode, useState } from "react";20import { Fragment, type RefObject, useState } from "react";
2221
23import { usePrimarySettings } from "../../hooks/useSettings";22import { usePrimarySettings } from "../../hooks/useSettings";
24import { environmentPresentations } from "../../state/presentation";
25import { useEnvironmentScope, readEnvironmentScope } from "../../state/session";23import { useEnvironmentScope, readEnvironmentScope } from "../../state/session";
26import { serverEnvironment } from "../../state/server";24import { serverEnvironment } from "../../state/server";
27import { useAtomCommand } from "../../state/use-atom-command";25import { useAtomCommand } from "../../state/use-atom-command";
...@@ -37,7 +35,6 @@ import {...@@ -37,7 +35,6 @@ import {
37} from "../ui/alert-dialog";35} from "../ui/alert-dialog";
38import { Button } from "../ui/button";36import { Button } from "../ui/button";
39import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";37import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
40import { UsageLimitsPooled } from "./UsageLimitsPooled";
41import { PROVIDER_PRESENTATION } from "./usageProviders";38import { PROVIDER_PRESENTATION } from "./usageProviders";
4239
43const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof GaugeIcon }> = {40const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof GaugeIcon }> = {
...@@ -252,15 +249,17 @@ export function ResetCreditDialog({...@@ -252,15 +249,17 @@ export function ResetCreditDialog({
252 onOpenChange,249 onOpenChange,
253 onConfirm,250 onConfirm,
254 disabled = false,251 disabled = false,
252 finalFocus,
255}: {253}: {
256 readonly open: boolean;254 readonly open: boolean;
257 readonly onOpenChange: (open: boolean) => void;255 readonly onOpenChange: (open: boolean) => void;
258 readonly onConfirm: () => void;256 readonly onConfirm: () => void;
259 readonly disabled?: boolean;257 readonly disabled?: boolean;
258 readonly finalFocus?: RefObject<HTMLElement | null> | undefined;
260}) {259}) {
261 return (260 return (
262 <AlertDialog open={open} onOpenChange={onOpenChange}>261 <AlertDialog open={open} onOpenChange={onOpenChange}>
263 <AlertDialogPopup>262 <AlertDialogPopup finalFocus={finalFocus}>
264 <AlertDialogHeader>263 <AlertDialogHeader>
265 <AlertDialogTitle>Use a reset credit?</AlertDialogTitle>264 <AlertDialogTitle>Use a reset credit?</AlertDialogTitle>
266 <AlertDialogDescription>265 <AlertDialogDescription>
...@@ -338,55 +337,3 @@ export function ResetCredits({...@@ -338,55 +337,3 @@ export function ResetCredits({
338 </div>337 </div>
339 );338 );
340}339}
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 {...@@ -7,6 +7,7 @@ import {
7 cursorUsageWindowDetails,7 cursorUsageWindowDetails,
8 displayLimitWindows,8 displayLimitWindows,
9 formatResetsIn,9 formatResetsIn,
10 isUsageLimitWindowVisible,
10 type LimitAccount,11 type LimitAccount,
11 type LimitPool,12 type LimitPool,
12 type LimitPoolMember,13 type LimitPoolMember,
...@@ -14,7 +15,7 @@ import {...@@ -14,7 +15,7 @@ import {
14 remainingPercent,15 remainingPercent,
15} from "@t3tools/shared/usageLimits";16} from "@t3tools/shared/usageLimits";
16import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react";17import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react";
17import { Fragment, type ReactNode, useState } from "react";18import { type ReactNode, type RefObject, useState } from "react";
1819
19import { ensureLocalApi } from "../../localApi";20import { ensureLocalApi } from "../../localApi";
20import { usePrimarySettings } from "../../hooks/useSettings";21import { usePrimarySettings } from "../../hooks/useSettings";
...@@ -36,7 +37,7 @@ import {...@@ -36,7 +37,7 @@ import {
36} from "./UsageLimits";37} from "./UsageLimits";
3738
38/** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */39/** `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 {
40 const [local = "", domain = ""] = email.split("@");41 const [local = "", domain = ""] = email.split("@");
41 return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "?";42 return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "?";
42}43}
...@@ -232,6 +233,8 @@ function PoolSegment({...@@ -232,6 +233,8 @@ function PoolSegment({
232 now,233 now,
233 index,234 index,
234 showAccountName,235 showAccountName,
236 onRedeem,
237 resetReturnFocus,
235}: {238}: {
236 readonly account: LimitAccount;239 readonly account: LimitAccount;
237 readonly window: LimitPoolMember["window"];240 readonly window: LimitPoolMember["window"];
...@@ -241,6 +244,8 @@ function PoolSegment({...@@ -241,6 +244,8 @@ function PoolSegment({
241 /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */244 /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */
242 readonly index: number;245 readonly index: number;
243 readonly showAccountName: boolean;246 readonly showAccountName: boolean;
247 readonly onRedeem: () => void;
248 readonly resetReturnFocus: RefObject<HTMLElement | null>;
244}) {249}) {
245 const [open, setOpen] = useState(false);250 const [open, setOpen] = useState(false);
246 const remaining = remainingPercent(window);251 const remaining = remainingPercent(window);
...@@ -318,6 +323,8 @@ function PoolSegment({...@@ -318,6 +323,8 @@ function PoolSegment({
318 now={now}323 now={now}
319 redeemAt={account.redeem}324 redeemAt={account.redeem}
320 closePopover={() => setOpen(false)}325 closePopover={() => setOpen(false)}
326 onRedeem={onRedeem}
327 resetReturnFocus={resetReturnFocus}
321 />328 />
322 ) : (329 ) : (
323 <PopoverPopup side="top" sideOffset={6}>330 <PopoverPopup side="top" sideOffset={6}>
...@@ -403,6 +410,8 @@ function RedeemableSegmentPopup({...@@ -403,6 +410,8 @@ function RedeemableSegmentPopup({
403 now,410 now,
404 redeemAt,411 redeemAt,
405 closePopover,412 closePopover,
413 onRedeem,
414 resetReturnFocus,
406}: {415}: {
407 readonly account: LimitAccount;416 readonly account: LimitAccount;
408 readonly window: LimitPoolMember["window"];417 readonly window: LimitPoolMember["window"];
...@@ -410,6 +419,8 @@ function RedeemableSegmentPopup({...@@ -410,6 +419,8 @@ function RedeemableSegmentPopup({
410 readonly now: number;419 readonly now: number;
411 readonly redeemAt: NonNullable<LimitAccount["redeem"]>;420 readonly redeemAt: NonNullable<LimitAccount["redeem"]>;
412 readonly closePopover: () => void;421 readonly closePopover: () => void;
422 readonly onRedeem: () => void;
423 readonly resetReturnFocus: RefObject<HTMLElement | null>;
413}) {424}) {
414 const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input);425 const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input);
415 return (426 return (
...@@ -423,11 +434,13 @@ function RedeemableSegmentPopup({...@@ -423,11 +434,13 @@ function RedeemableSegmentPopup({
423 redeem={redeem}434 redeem={redeem}
424 onRedeem={() => {435 onRedeem={() => {
425 closePopover();436 closePopover();
437 onRedeem();
426 redeem.setConfirming(true);438 redeem.setConfirming(true);
427 }}439 }}
428 />440 />
429 </PopoverPopup>441 </PopoverPopup>
430 <ResetCreditDialog442 <ResetCreditDialog
443 finalFocus={resetReturnFocus}
431 open={redeem.confirming}444 open={redeem.confirming}
432 onOpenChange={redeem.setConfirming}445 onOpenChange={redeem.setConfirming}
433 onConfirm={() => void redeem.redeem()}446 onConfirm={() => void redeem.redeem()}
...@@ -456,10 +469,14 @@ function PoolBar({...@@ -456,10 +469,14 @@ function PoolBar({
456 pool,469 pool,
457 color,470 color,
458 now,471 now,
472 onRedeem,
473 resetReturnFocus,
459}: {474}: {
460 readonly pool: LimitPoolWindow;475 readonly pool: LimitPoolWindow;
461 readonly color: string;476 readonly color: string;
462 readonly now: number;477 readonly now: number;
478 readonly onRedeem: () => void;
479 readonly resetReturnFocus: RefObject<HTMLElement | null>;
463}) {480}) {
464 const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset]));481 const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset]));
465 return (482 return (
...@@ -479,6 +496,8 @@ function PoolBar({...@@ -479,6 +496,8 @@ function PoolBar({
479 now={now}496 now={now}
480 index={position + 1}497 index={position + 1}
481 showAccountName={pool.columns.length > 1}498 showAccountName={pool.columns.length > 1}
499 onRedeem={onRedeem}
500 resetReturnFocus={resetReturnFocus}
482 />501 />
483 ) : null,502 ) : null,
484 )}503 )}
...@@ -497,12 +516,16 @@ function PoolWindowCard({...@@ -497,12 +516,16 @@ function PoolWindowCard({
497 now,516 now,
498 label,517 label,
499 description,518 description,
519 onRedeem,
520 resetReturnFocus,
500}: {521}: {
501 readonly pool: LimitPoolWindow;522 readonly pool: LimitPoolWindow;
502 readonly color: string;523 readonly color: string;
503 readonly now: number;524 readonly now: number;
504 readonly label?: string | undefined;525 readonly label?: string | undefined;
505 readonly description?: string | undefined;526 readonly description?: string | undefined;
527 readonly onRedeem: () => void;
528 readonly resetReturnFocus: RefObject<HTMLElement | null>;
506}) {529}) {
507 // The soonest reset that hands anything back; an untouched account resets to no effect.530 // The soonest reset that hands anything back; an untouched account resets to no effect.
508 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);531 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
...@@ -525,7 +548,13 @@ function PoolWindowCard({...@@ -525,7 +548,13 @@ function PoolWindowCard({
525 </span>548 </span>
526 ) : null}549 ) : null}
527 </div>550 </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 />
529 {description ? (558 {description ? (
530 <p className="text-xs text-muted-foreground md:col-span-2">{description}</p>559 <p className="text-xs text-muted-foreground md:col-span-2">{description}</p>
531 ) : null}560 ) : null}
...@@ -533,7 +562,17 @@ function PoolWindowCard({...@@ -533,7 +562,17 @@ function PoolWindowCard({
533 );562 );
534}563}
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}) {
537 const color = barColor(pool.driver);576 const color = barColor(pool.driver);
538 const label = providerClients.get(pool.driver)?.label ?? String(pool.driver);577 const label = providerClients.get(pool.driver)?.label ?? String(pool.driver);
539 const windows = displayLimitWindows(pool);578 const windows = displayLimitWindows(pool);
...@@ -559,6 +598,8 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu...@@ -559,6 +598,8 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu
559 now={now}598 now={now}
560 label={details?.label}599 label={details?.label}
561 description={details?.description}600 description={details?.description}
601 onRedeem={onRedeem}
602 resetReturnFocus={resetReturnFocus}
562 />603 />
563 );604 );
564 })}605 })}
...@@ -574,34 +615,39 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu...@@ -574,34 +615,39 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu
574export function UsageLimitsPooled({615export function UsageLimitsPooled({
575 presentations,616 presentations,
576 now,617 now,
577 cursorPrompt,618 onRedeem,
619 resetReturnFocus,
578}: {620}: {
579 readonly presentations: Parameters<typeof collectLimitAccounts>[0];621 readonly presentations: Parameters<typeof collectLimitAccounts>[0];
580 readonly now: number;622 readonly now: number;
581 readonly cursorPrompt?: ReactNode;623 readonly onRedeem: () => void;
624 readonly resetReturnFocus: RefObject<HTMLElement | null>;
582}) {625}) {
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);
584 const notices = collectLimitNotices(presentations);633 const notices = collectLimitNotices(presentations);
585 const externalLinks = collectExternalUsageLinks(presentations);634 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;
591 return (635 return (
592 <div className="flex flex-col gap-8">636 <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 ? (
594 <p className="text-sm text-muted-foreground">638 <p className="text-sm text-muted-foreground">
595 No provider on the selected environments reports subscription limits.639 No provider on the selected environments reports subscription limits.
596 </p>640 </p>
597 ) : null}641 ) : null}
598 {pools.map((pool, index) => (642 {pools.map((pool) => (
599 <Fragment key={pool.driver}>643 <PoolSection
600 {index === cursorPromptAt ? cursorPrompt : null}644 key={pool.driver}
601 <PoolSection pool={pool} now={now} />645 pool={pool}
602 </Fragment>646 now={now}
647 onRedeem={onRedeem}
648 resetReturnFocus={resetReturnFocus}
649 />
603 ))}650 ))}
604 {cursorPromptAt === pools.length ? cursorPrompt : null}
605 {externalLinks.map((link) => (651 {externalLinks.map((link) => (
606 <section652 <section
607 key={link.url}653 key={link.url}
apps/web/src/components/usage/UsagePage.refresh.test.tsx+51-159
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";1import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
2import { mergeUsage } from "@t3tools/shared/usageMerge";2import { mergeUsage } from "@t3tools/shared/usageMerge";
3import { StrictMode, act } from "react";3import { act } from "react";
4import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";4import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
5import { create, type ReactTestRenderer } from "react-test-renderer";5import { create, type ReactTestRenderer } from "react-test-renderer";
6import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";6import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
...@@ -8,7 +8,8 @@ import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";...@@ -8,7 +8,8 @@ import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
8const state = vi.hoisted(() => ({8const state = vi.hoisted(() => ({
9 presentations: new Map(),9 presentations: new Map(),
10 refreshProviders: vi.fn(async () => undefined),10 refreshProviders: vi.fn(async () => undefined),
11 metric: "limits",11 metric: "cost",
12 refresh: vi.fn(async (_window?: unknown) => undefined),
12}));13}));
13vi.mock("@effect/atom-react", () => ({14vi.mock("@effect/atom-react", () => ({
14 useAtomValue: (atom: unknown) =>15 useAtomValue: (atom: unknown) =>
...@@ -38,6 +39,7 @@ vi.mock("../../state/usage", () => ({...@@ -38,6 +39,7 @@ vi.mock("../../state/usage", () => ({
38 isPending: false,39 isPending: false,
39 error: null,40 error: null,
40 summary: null,41 summary: null,
42 canReadDiagnostics: true,
41 },43 },
42 ],44 ],
43 selectedEnvironments: [45 selectedEnvironments: [
...@@ -47,12 +49,13 @@ vi.mock("../../state/usage", () => ({...@@ -47,12 +49,13 @@ vi.mock("../../state/usage", () => ({
47 isPending: false,49 isPending: false,
48 error: null,50 error: null,
49 summary: null,51 summary: null,
52 canReadDiagnostics: true,
50 },53 },
51 ],54 ],
52 isPending: false,55 isPending: false,
53 shown: null,56 shown: null,
54 isPartial: false,57 isPartial: false,
55 refresh: async () => undefined,58 refresh: state.refresh,
56 }),59 }),
57}));60}));
58vi.mock("./usagePagePreferences", () => ({61vi.mock("./usagePagePreferences", () => ({
...@@ -99,190 +102,79 @@ vi.mock("../settings/providerDriverMeta", () => ({...@@ -99,190 +102,79 @@ vi.mock("../settings/providerDriverMeta", () => ({
99import { UsagePage } from "./UsagePage";102import { UsagePage } from "./UsagePage";
100103
101let renderer: ReactTestRenderer;104let renderer: ReactTestRenderer;
102let environmentNumber = 0;
103beforeEach(() => {105beforeEach(() => {
104 vi.stubGlobal("window", new EventTarget());106 vi.stubGlobal("window", new EventTarget());
105 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);107 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
106 vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z"));108 vi.useFakeTimers({ toFake: ["Date"] });
107 environmentNumber += 1;109 vi.setSystemTime(new Date("2026-09-11T12:00:00Z"));
108 state.metric = "limits";110 state.metric = "cost";
111 state.refresh.mockReset().mockResolvedValue(undefined);
109 state.refreshProviders.mockClear();112 state.refreshProviders.mockClear();
110 state.presentations = new Map([113 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 ]);
149});114});
150afterEach(async () => {115afterEach(async () => {
151 await act(() => renderer?.unmount());116 await act(() => renderer?.unmount());
117 vi.useRealTimers();
152 vi.restoreAllMocks();118 vi.restoreAllMocks();
153 vi.unstubAllGlobals();119 vi.unstubAllGlobals();
154});120});
155121
156it.each([0, 1])(122it.each([0, 1])("refreshes history from toolbar button %i", async (buttonIndex) => {
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 () => {
187 await act(() => {123 await act(() => {
188 renderer = create(<UsagePage />);124 renderer = create(<UsagePage />);
189 });125 });
190 const selectMetric = (metric: string) => {126 expect(state.refresh).not.toHaveBeenCalled();
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 });
213 expect(state.refreshProviders).not.toHaveBeenCalled();127 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 () => {129 vi.setSystemTime(new Date("2026-09-12T12:00:00Z"));
239 const [id, presentation] = [...state.presentations][0]!;130 await act(async () => {
240 state.presentations = new Map([[id, { ...presentation, connection: { phase: "disconnected" } }]]);131 renderer.root
241 await act(() => {132 .findAll((node) => node.type === "button" && node.props["aria-label"] === "Refresh usage")
242 renderer = create(<UsagePage />);133 .at(buttonIndex)!
134 .props.onClick();
243 });135 });
136 expect(state.refresh).toHaveBeenCalledOnce();
137 expect(state.refresh).toHaveBeenCalledWith(expect.objectContaining({ untilDay: "2026-09-12" }));
244 expect(state.refreshProviders).not.toHaveBeenCalled();138 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());
261});139});
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 () => {
264 let finishRefresh!: () => void;142 let finishRefresh!: () => void;
265 const pending = new Promise<undefined>((resolve) => {143 const pending = new Promise<undefined>((resolve) => {
266 finishRefresh = () => resolve(undefined);144 finishRefresh = () => resolve(undefined);
267 });145 });
268 state.refreshProviders.mockImplementationOnce(() => pending);146 state.refresh.mockImplementationOnce(() => pending);
269 await act(() => {147 await act(() => {
270 renderer = create(<UsagePage />);148 renderer = create(<UsagePage />);
271 });149 });
272 const button = () =>150 const button = () =>
273 renderer.root.findAll(151 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",
275 )[0]!;153 )[0]!;
276 expect(state.refreshProviders).toHaveBeenCalledTimes(1);154 await act(() => {
277 await act(() => button().props.onClick());155 button().props.onClick();
278 try {156 button().props.onClick();
279 expect(button().props["aria-busy"]).toBe(true);157 });
280 expect(state.refreshProviders).toHaveBeenCalledTimes(1);158 expect(button().props["aria-busy"]).toBe(true);
281 } finally {159 expect(state.refresh).toHaveBeenCalledOnce();
282 await act(async () => {160 await act(async () => {
283 finishRefresh();161 finishRefresh();
284 await pending;162 await pending;
285 });163 });
286 }
287 expect(button().props["aria-busy"]).toBe(false);164 expect(button().props["aria-busy"]).toBe(false);
288});165});
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";...@@ -3,7 +3,6 @@ import { usesChatGptSharing } from "@t3tools/shared/usageLimits";
3import { RefreshIcon } from "~/components/ui/refresh-icon";3import { RefreshIcon } from "~/components/ui/refresh-icon";
4import { useAtomValue } from "@effect/atom-react";4import { useAtomValue } from "@effect/atom-react";
5import {5import {
6 ProviderDriverKind,
7 USAGE_CONTRACT_VERSION,6 USAGE_CONTRACT_VERSION,
8 type EnvironmentId,7 type EnvironmentId,
9 type UsageProviderKind,8 type UsageProviderKind,
...@@ -75,7 +74,6 @@ import {...@@ -75,7 +74,6 @@ import {
75} from "../WorkspaceBreadcrumb";74} from "../WorkspaceBreadcrumb";
76import { WorkspacePageContainer } from "../WorkspacePageContainer";75import { WorkspacePageContainer } from "../WorkspacePageContainer";
77import { WorkspacePageHeader } from "../WorkspacePageHeader";76import { WorkspacePageHeader } from "../WorkspacePageHeader";
78import { UsageLimitsSection } from "./UsageLimits";
79import { UsagePriceOverrides } from "./UsagePriceOverrides";77import { UsagePriceOverrides } from "./UsagePriceOverrides";
80import { UsageProviderChart } from "./UsageProviderChart";78import { UsageProviderChart } from "./UsageProviderChart";
81import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog";79import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog";
...@@ -132,9 +130,7 @@ export function UsagePage() {...@@ -132,9 +130,7 @@ export function UsagePage() {
132 ),130 ),
133 }));131 }));
134 const metric = preferences.metric;132 const metric = preferences.metric;
135 const showingLimits = metric === "limits";
136 const [isRefreshing, setIsRefreshing] = useState(false);133 const [isRefreshing, setIsRefreshing] = useState(false);
137 const [limitsNow, setLimitsNow] = useState(() => Date.now());
138 const refreshingRef = useRef(false);134 const refreshingRef = useRef(false);
139 const [breakdown, setBreakdown] = useState<"model" | "time">("model");135 const [breakdown, setBreakdown] = useState<"model" | "time">("model");
140 const [priceDialog, setPriceDialog] = useState<{ readonly model?: string } | null>(null);136 const [priceDialog, setPriceDialog] = useState<{ readonly model?: string } | null>(null);
...@@ -159,14 +155,13 @@ export function UsagePage() {...@@ -159,14 +155,13 @@ export function UsagePage() {
159 const merged = shown?.merged ?? answeredUsage;155 const merged = shown?.merged ?? answeredUsage;
160 const shownWindow = shown?.window ?? window;156 const shownWindow = shown?.window ?? window;
161 const shownHourly = shownWindow.resolution === "hour";157 const shownHourly = shownWindow.resolution === "hour";
162 const refreshingUsage = isRefreshing && !showingLimits;
163 // Usage kept from another window is all old, so every figure stays muted.158 // Usage kept from another window is all old, so every figure stays muted.
164 const showingKept = shown !== null && shown.window !== window;159 const showingKept = shown !== null && shown.window !== window;
165 const loading = useMemo(() => {160 const loading = useMemo(() => {
166 if (showingKept) {161 if (showingKept) {
167 return { partial: true, everyProvider: true, providers: new Set<UsageProviderKind>() };162 return { partial: true, everyProvider: true, providers: new Set<UsageProviderKind>() };
168 }163 }
169 const state = usageLoadingState(selectedEnvironments, refreshingUsage);164 const state = usageLoadingState(selectedEnvironments, isRefreshing);
170 // A hidden provider still refreshing must not add its row or mute the totals.165 // A hidden provider still refreshing must not add its row or mute the totals.
171 const providers = new Set([...state.providers].filter((p) => !hiddenProviders.has(p)));166 const providers = new Set([...state.providers].filter((p) => !hiddenProviders.has(p)));
172 return {167 return {
...@@ -174,7 +169,7 @@ export function UsagePage() {...@@ -174,7 +169,7 @@ export function UsagePage() {
174 everyProvider: state.everyProvider,169 everyProvider: state.everyProvider,
175 providers,170 providers,
176 };171 };
177 }, [showingKept, refreshingUsage, selectedEnvironments, hiddenProviders]);172 }, [showingKept, isRefreshing, selectedEnvironments, hiddenProviders]);
178 const isProviderLoading = (provider: UsageProviderKind) =>173 const isProviderLoading = (provider: UsageProviderKind) =>
179 loading.everyProvider || loading.providers.has(provider);174 loading.everyProvider || loading.providers.has(provider);
180 const presentations = useAtomValue(environmentPresentations.presentationsAtom);175 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
...@@ -283,28 +278,8 @@ export function UsagePage() {...@@ -283,28 +278,8 @@ export function UsagePage() {
283 });278 });
284 };279 };
285 const selectMetric = (nextMetric: UsageMetric) => {280 const selectMetric = (nextMetric: UsageMetric) => {
286 if (nextMetric === "limits") setLimitsNow(Date.now());
287 updatePreferences({ metric: nextMetric });281 updatePreferences({ metric: nextMetric });
288 };282 };
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 };
308 const onUsageKeyDown = useEffectEvent((event: KeyboardEvent) => {283 const onUsageKeyDown = useEffectEvent((event: KeyboardEvent) => {
309 if (284 if (
310 event.defaultPrevented ||285 event.defaultPrevented ||
...@@ -323,7 +298,7 @@ export function UsagePage() {...@@ -323,7 +298,7 @@ export function UsagePage() {
323 event.preventDefault();298 event.preventDefault();
324 event.stopPropagation();299 event.stopPropagation();
325 if (metricOption) selectMetric(metricOption.value);300 if (metricOption) selectMetric(metricOption.value);
326 if (periodOption && !showingLimits) selectWindow(periodOption.days);301 if (periodOption) selectWindow(periodOption.days);
327 });302 });
328303
329 useEffect(() => {304 useEffect(() => {
...@@ -334,15 +309,6 @@ export function UsagePage() {...@@ -334,15 +309,6 @@ export function UsagePage() {
334 const refreshWindow = () => {309 const refreshWindow = () => {
335 if (refreshingRef.current) return;310 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 }
346 const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");312 const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");
347 if (313 if (
348 nextWindow.sinceDay !== window.sinceDay ||314 nextWindow.sinceDay !== window.sinceDay ||
...@@ -359,23 +325,6 @@ export function UsagePage() {...@@ -359,23 +325,6 @@ export function UsagePage() {
359 setIsRefreshing(false);325 setIsRefreshing(false);
360 });326 });
361 };327 };
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
379 // Names the period on screen, which is the previous one until the new one answers.328 // Names the period on screen, which is the previous one until the new one answers.
380 const windowLabel =329 const windowLabel =
381 shownHourly && shownWindow.sinceTime !== undefined && shownWindow.untilTime !== undefined330 shownHourly && shownWindow.sinceTime !== undefined && shownWindow.untilTime !== undefined
...@@ -394,8 +343,7 @@ export function UsagePage() {...@@ -394,8 +343,7 @@ export function UsagePage() {
394 selectedEnvironments={selectedEnvironments}343 selectedEnvironments={selectedEnvironments}
395 selectedEnvironmentIds={selectedEnvironmentIds}344 selectedEnvironmentIds={selectedEnvironmentIds}
396 onSelectionChange={setSelectedEnvironmentIds}345 onSelectionChange={setSelectedEnvironmentIds}
397 showUsageStatus={!showingLimits}346 refreshing={isRefreshing}
398 refreshing={refreshingUsage}
399 isPartial={isPartial}347 isPartial={isPartial}
400 duplicateSources={merged.duplicateSources}348 duplicateSources={merged.duplicateSources}
401 contractMismatches={merged.contractMismatches}349 contractMismatches={merged.contractMismatches}
...@@ -410,11 +358,9 @@ export function UsagePage() {...@@ -410,11 +358,9 @@ export function UsagePage() {
410 />358 />
411 </WorkspaceBreadcrumbItem>359 </WorkspaceBreadcrumbItem>
412 </WorkspaceBreadcrumb>360 </WorkspaceBreadcrumb>
413 {!showingLimits ? (361 <span className="hidden min-w-0 truncate text-xs text-muted-foreground 2xl:block">
414 <span className="hidden min-w-0 truncate text-xs text-muted-foreground 2xl:block">362 {windowLabel}
415 {windowLabel}363 </span>
416 </span>
417 ) : null}
418 <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 xl:flex">364 <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 xl:flex">
419 <ToggleGroup365 <ToggleGroup
420 aria-label="Usage metric"366 aria-label="Usage metric"
...@@ -431,13 +377,10 @@ export function UsagePage() {...@@ -431,13 +377,10 @@ export function UsagePage() {
431 </Toggle>377 </Toggle>
432 ))}378 ))}
433 </ToggleGroup>379 </ToggleGroup>
434 {/* The period does not apply to Limits, so it stays in place but
435 disabled; unmounting it shifted the metric toggle ~300px. */}
436 <ToggleGroup380 <ToggleGroup
437 aria-label="Usage period"381 aria-label="Usage period"
438 variant="segmented"382 variant="segmented"
439 value={[String(windowDays)]}383 value={[String(windowDays)]}
440 disabled={showingLimits}
441 onValueChange={(next) => {384 onValueChange={(next) => {
442 const value = next[0];385 const value = next[0];
443 if (value) selectWindow(Number(value));386 if (value) selectWindow(Number(value));
...@@ -451,9 +394,9 @@ export function UsagePage() {...@@ -451,9 +394,9 @@ export function UsagePage() {
451 </ToggleGroup>394 </ToggleGroup>
452 <Button395 <Button
453 onClick={refreshWindow}396 onClick={refreshWindow}
454 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}397 aria-label="Refresh usage"
455 aria-busy={isRefreshing}398 aria-busy={isRefreshing}
456 disabled={isRefreshing || (!showingLimits && !canReadDiagnostics)}399 disabled={isRefreshing || !canReadDiagnostics}
457 size="icon-sm"400 size="icon-sm"
458 variant="ghost"401 variant="ghost"
459 >402 >
...@@ -485,11 +428,7 @@ export function UsagePage() {...@@ -485,11 +428,7 @@ export function UsagePage() {
485 ))}428 ))}
486 </SelectPopup>429 </SelectPopup>
487 </Select>430 </Select>
488 <Select431 <Select value={String(windowDays)} onValueChange={(value) => selectWindow(Number(value))}>
489 value={String(windowDays)}
490 disabled={showingLimits}
491 onValueChange={(value) => selectWindow(Number(value))}
492 >
493 <SelectTrigger432 <SelectTrigger
494 aria-label="Usage period"433 aria-label="Usage period"
495 size="compact"434 size="compact"
...@@ -514,9 +453,9 @@ export function UsagePage() {...@@ -514,9 +453,9 @@ export function UsagePage() {
514 </Select>453 </Select>
515 <Button454 <Button
516 onClick={refreshWindow}455 onClick={refreshWindow}
517 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}456 aria-label="Refresh usage"
518 aria-busy={isRefreshing}457 aria-busy={isRefreshing}
519 disabled={isRefreshing || (!showingLimits && !canReadDiagnostics)}458 disabled={isRefreshing || !canReadDiagnostics}
520 size="icon-sm"459 size="icon-sm"
521 variant="ghost"460 variant="ghost"
522 >461 >
...@@ -536,26 +475,9 @@ export function UsagePage() {...@@ -536,26 +475,9 @@ export function UsagePage() {
536 {selectedEnvironments.length === 0 ? (475 {selectedEnvironments.length === 0 ? (
537 <p className="text-sm text-muted-foreground">476 <p className="text-sm text-muted-foreground">
538 {environments.length === 0477 {environments.length === 0
539 ? `Connect an environment to see ${showingLimits ? "limits" : "usage"}.`478 ? "Connect an environment to see usage."
540 : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}479 : "Select an environment to see usage."}
541 </p>480 </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 />
559 ) : shown === null ? (481 ) : shown === null ? (
560 <UsageSkeleton />482 <UsageSkeleton />
561 ) : !canReadDiagnostics ? (483 ) : !canReadDiagnostics ? (
...@@ -634,7 +556,16 @@ export function UsagePage() {...@@ -634,7 +556,16 @@ export function UsagePage() {
634 showEnvironment={selectedEnvironments.length > 1}556 showEnvironment={selectedEnvironments.length > 1}
635 onEnabled={() => {557 onEnabled={() => {
636 void refresh();558 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 );
638 }}569 }}
639 />570 />
640 );571 );
...@@ -978,7 +909,7 @@ export function UsagePage() {...@@ -978,7 +909,7 @@ export function UsagePage() {
978 </WorkspacePageContainer>909 </WorkspacePageContainer>
979 </ScrollArea>910 </ScrollArea>
980 </div>911 </div>
981 {selectedModel !== undefined && !showingLimits ? (912 {selectedModel !== undefined ? (
982 <UsageModelDialog913 <UsageModelDialog
983 model={selectedModel}914 model={selectedModel}
984 environments={selectedEnvironments}915 environments={selectedEnvironments}
...@@ -1013,18 +944,16 @@ export function UsagePage() {...@@ -1013,18 +944,16 @@ export function UsagePage() {
1013944
1014const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain.";945const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain.";
1015946
1016function CursorEnableButton({947function CursorEnableRow({
1017 environmentId,948 environmentId,
1018 label,949 label,
950 showEnvironment,
1019 onEnabled,951 onEnabled,
1020 tooltip,
1021 buttonText = "Enable",
1022}: {952}: {
1023 readonly environmentId: EnvironmentId;953 readonly environmentId: EnvironmentId;
1024 readonly label: string;954 readonly label: string;
955 readonly showEnvironment: boolean;
1025 readonly onEnabled: () => void;956 readonly onEnabled: () => void;
1026 readonly tooltip: boolean;
1027 readonly buttonText?: string;
1028}) {957}) {
1029 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {958 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
1030 label: "enable Cursor account usage",959 label: "enable Cursor account usage",
...@@ -1042,47 +971,7 @@ function CursorEnableButton({...@@ -1042,47 +971,7 @@ function CursorEnableButton({
1042 setPending(false);971 setPending(false);
1043 }972 }
1044 };973 };
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}) {
1086 return (975 return (
1087 <div className="flex min-w-0 items-baseline justify-between gap-4 text-sm">976 <div className="flex min-w-0 items-baseline justify-between gap-4 text-sm">
1088 <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">977 <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">
...@@ -1094,51 +983,22 @@ function CursorEnableRow({...@@ -1094,51 +983,22 @@ function CursorEnableRow({
1094 <ProviderMark provider="cursor" className="size-4" />983 <ProviderMark provider="cursor" className="size-4" />
1095 <span className="truncate">Cursor{showEnvironment ? ` · ${label}` : ""}</span>984 <span className="truncate">Cursor{showEnvironment ? ` · ${label}` : ""}</span>
1096 </span>985 </span>
1097 <CursorEnableButton986 <Tooltip>
1098 environmentId={environmentId}987 <TooltipTrigger
1099 label={label}988 render={
1100 onEnabled={onEnabled}989 <InlineButton
1101 tooltip990 disabled={pending}
1102 />991 aria-busy={pending}
1103 </div>992 aria-label={`Enable Cursor usage from ${label}`}
1104 );993 onClick={() => void enable()}
1105}994 >
1106995 Enable
1107function CursorEnableLimits({996 </InlineButton>
1108 environments,997 }
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"
1123 />998 />
1124 Cursor999 <TooltipPopup>{CURSOR_KEYCHAIN_COPY}</TooltipPopup>
1125 </h2>1000 </Tooltip>
1126 <div className="flex flex-col items-start gap-3 rounded-lg border border-border/60 p-4">1001 </div>
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>
1142 );1002 );
1143}1003}
11441004
...@@ -1239,7 +1099,6 @@ function UsageEnvironmentFilter({...@@ -1239,7 +1099,6 @@ function UsageEnvironmentFilter({
1239 selectedEnvironments,1099 selectedEnvironments,
1240 selectedEnvironmentIds,1100 selectedEnvironmentIds,
1241 onSelectionChange,1101 onSelectionChange,
1242 showUsageStatus,
1243 refreshing,1102 refreshing,
1244 isPartial,1103 isPartial,
1245 duplicateSources,1104 duplicateSources,
...@@ -1250,7 +1109,6 @@ function UsageEnvironmentFilter({...@@ -1250,7 +1109,6 @@ function UsageEnvironmentFilter({
1250 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];1109 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
1251 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;1110 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
1252 readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId> | null) => void;1111 readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId> | null) => void;
1253 readonly showUsageStatus: boolean;
1254 readonly refreshing: boolean;1112 readonly refreshing: boolean;
1255 readonly isPartial: boolean;1113 readonly isPartial: boolean;
1256 readonly duplicateSources: readonly string[];1114 readonly duplicateSources: readonly string[];
...@@ -1272,7 +1130,7 @@ function UsageEnvironmentFilter({...@@ -1272,7 +1130,7 @@ function UsageEnvironmentFilter({
1272 <MenuTrigger render={<InlineButton />} className="group/usage-environment min-w-0 max-w-full">1130 <MenuTrigger render={<InlineButton />} className="group/usage-environment min-w-0 max-w-full">
1273 <WorkspaceBreadcrumbText>{label}</WorkspaceBreadcrumbText>1131 <WorkspaceBreadcrumbText>{label}</WorkspaceBreadcrumbText>
1274 <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">1132 <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
1275 {showUsageStatus && hasIssue ? (1133 {hasIssue ? (
1276 <CircleAlertIcon1134 <CircleAlertIcon
1277 className="size-3.5 text-warning-foreground"1135 className="size-3.5 text-warning-foreground"
1278 aria-label="Some environments could not report usage"1136 aria-label="Some environments could not report usage"
...@@ -1331,16 +1189,14 @@ function UsageEnvironmentFilter({...@@ -1331,16 +1189,14 @@ function UsageEnvironmentFilter({
1331 >1189 >
1332 <span className="flex min-w-0 items-center gap-3">1190 <span className="flex min-w-0 items-center gap-3">
1333 <span className="min-w-0 flex-1 truncate">{environment.label}</span>1191 <span className="min-w-0 flex-1 truncate">{environment.label}</span>
1334 {showUsageStatus ? (1192 <span
1335 <span1193 className={cn(
1336 className={cn(1194 "shrink-0 text-xs text-muted-foreground",
1337 "shrink-0 text-xs text-muted-foreground",1195 environment.error !== null && "text-destructive",
1338 environment.error !== null && "text-destructive",1196 )}
1339 )}1197 >
1340 >1198 {status}
1341 {status}1199 </span>
1342 </span>
1343 ) : null}
1344 </span>1200 </span>
1345 </MenuCheckboxItem>1201 </MenuCheckboxItem>
1346 );1202 );
...@@ -1348,18 +1204,16 @@ function UsageEnvironmentFilter({...@@ -1348,18 +1204,16 @@ function UsageEnvironmentFilter({
1348 {environments.length === 0 ? (1204 {environments.length === 0 ? (
1349 <p className="px-2 py-2 text-xs text-muted-foreground">No environments connected.</p>1205 <p className="px-2 py-2 text-xs text-muted-foreground">No environments connected.</p>
1350 ) : null}1206 ) : null}
1351 {showUsageStatus && isPartial ? (1207 {isPartial ? (
1352 <p className="px-2 py-2 text-xs text-muted-foreground">1208 <p className="px-2 py-2 text-xs text-muted-foreground">
1353 Totals are partial while selected environments scan.1209 Totals are partial while selected environments scan.
1354 </p>1210 </p>
1355 ) : null}1211 ) : null}
1356 {showUsageStatus ? (1212 <UsageCoverageNotice
1357 <UsageCoverageNotice1213 environments={selectedEnvironments}
1358 environments={selectedEnvironments}1214 duplicateSources={duplicateSources}
1359 duplicateSources={duplicateSources}1215 contractMismatches={contractMismatches}
1360 contractMismatches={contractMismatches}1216 />
1361 />
1362 ) : null}
1363 <MenuSeparator />1217 <MenuSeparator />
1364 <MenuItem onClick={onOpenModelPrices}>1218 <MenuItem onClick={onOpenModelPrices}>
1365 <SlidersHorizontalIcon aria-hidden />1219 <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(() => {...@@ -25,16 +25,28 @@ afterEach(() => {
2525
26describe("Usage page preferences", () => {26describe("Usage page preferences", () => {
27 it("uses defaults when no preference has been saved", () => {27 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 });
29 });29 });
3030
31 it.each([1, 7, 30, 90] as const)("round-trips every metric with a %i-day range", (windowDays) => {31 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) {
33 saveUsagePagePreferences({ metric, windowDays });33 saveUsagePagePreferences({ metric, windowDays });
34 expect(readUsagePagePreferences()).toEqual({ metric, windowDays });34 expect(readUsagePagePreferences()).toEqual({ metric, windowDays });
35 }35 }
36 });36 });
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
38 it("round-trips hidden providers and reads older saves without them", () => {50 it("round-trips hidden providers and reads older saves without them", () => {
39 saveUsagePagePreferences({ metric: "cost", windowDays: 7, hiddenProviders: ["codex"] });51 saveUsagePagePreferences({ metric: "cost", windowDays: 7, hiddenProviders: ["codex"] });
40 expect(readUsagePagePreferences().hiddenProviders).toEqual(["codex"]);52 expect(readUsagePagePreferences().hiddenProviders).toEqual(["codex"]);
...@@ -49,7 +61,7 @@ describe("Usage page preferences", () => {...@@ -49,7 +61,7 @@ describe("Usage page preferences", () => {
49 '{"metric":"cost","windowDays":365}',61 '{"metric":"cost","windowDays":365}',
50 ])("replaces invalid preferences on the next save: %s", (value) => {62 ])("replaces invalid preferences on the next save: %s", (value) => {
51 values.set(key, value);63 values.set(key, value);
52 expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 30 });64 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
53 saveUsagePagePreferences({ metric: "tokens", windowDays: 7 });65 saveUsagePagePreferences({ metric: "tokens", windowDays: 7 });
54 expect(readUsagePagePreferences()).toEqual({ metric: "tokens", windowDays: 7 });66 expect(readUsagePagePreferences()).toEqual({ metric: "tokens", windowDays: 7 });
55 });67 });
...@@ -62,8 +74,8 @@ describe("Usage page preferences", () => {...@@ -62,8 +74,8 @@ describe("Usage page preferences", () => {
62 expect(() => saveUsagePagePreferences({ metric: "tokens", windowDays: 7 })).not.toThrow();74 expect(() => saveUsagePagePreferences({ metric: "tokens", windowDays: 7 })).not.toThrow();
63 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });75 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
64 write.mockRestore();76 write.mockRestore();
65 saveUsagePagePreferences({ metric: "limits", windowDays: 7 });77 saveUsagePagePreferences({ metric: "cost", windowDays: 7 });
66 expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 7 });78 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 7 });
67 });79 });
6880
69 it("contains failures when the browser blocks storage access", () => {81 it("contains failures when the browser blocks storage access", () => {
...@@ -72,7 +84,7 @@ describe("Usage page preferences", () => {...@@ -72,7 +84,7 @@ describe("Usage page preferences", () => {
72 throw new Error("SecurityError");84 throw new Error("SecurityError");
73 },85 },
74 });86 });
75 expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 30 });87 expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 });
76 expect(() => saveUsagePagePreferences({ metric: "tokens", windowDays: 7 })).not.toThrow();88 expect(() => saveUsagePagePreferences({ metric: "tokens", windowDays: 7 })).not.toThrow();
77 });89 });
78});90});
apps/web/src/components/usage/usagePagePreferences.ts+8-6
...@@ -10,16 +10,18 @@ const UsagePagePreferencesSchema = Schema.Struct({...@@ -10,16 +10,18 @@ const UsagePagePreferencesSchema = Schema.Struct({
10 /** Providers filtered out of the page. Stored as hidden so new providers show by default. */10 /** Providers filtered out of the page. Stored as hidden so new providers show by default. */
11 hiddenProviders: Schema.optional(Schema.Array(UsageProviderKind)),11 hiddenProviders: Schema.optional(Schema.Array(UsageProviderKind)),
12});12});
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 is17const DEFAULT_PREFERENCES: UsagePagePreferences = { metric: "cost", windowDays: 30 };
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 };
1918
20export function readUsagePagePreferences(): UsagePagePreferences {19export function readUsagePagePreferences(): UsagePagePreferences {
21 try {20 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 };
23 } catch (error) {25 } catch (error) {
24 console.error("Could not read Usage page preferences.", error);26 console.error("Could not read Usage page preferences.", error);
25 return DEFAULT_PREFERENCES;27 return DEFAULT_PREFERENCES;
apps/web/src/components/usage/usageShortcuts.test.ts+18
...@@ -17,6 +17,24 @@ class TestElement extends EventTarget {...@@ -17,6 +17,24 @@ class TestElement extends EventTarget {
1717
18afterEach(() => vi.unstubAllGlobals());18afterEach(() => 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
20describe("Usage shortcuts while typing", () => {38describe("Usage shortcuts while typing", () => {
21 it.each([new TestElement("INPUT"), new TestElement("TEXTAREA"), new TestElement("DIV", true)])(39 it.each([new TestElement("INPUT"), new TestElement("TEXTAREA"), new TestElement("DIV", true)])(
22 "leaves letters to the focused $tagName field",40 "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...@@ -2,11 +2,10 @@ import type { KeybindingCommand, ResolvedKeybindingsConfig } from "@t3tools/cont
2import type { UsageChartMetric } from "./UsageProviderChart";2import type { UsageChartMetric } from "./UsageProviderChart";
3import { resolveShortcutCommand, type ShortcutEventLike } from "../../keybindings";3import { resolveShortcutCommand, type ShortcutEventLike } from "../../keybindings";
44
5export type UsageMetric = UsageChartMetric | "limits";5export type UsageMetric = UsageChartMetric;
6export const METRIC_OPTIONS = [6export const METRIC_OPTIONS = [
7 { value: "cost", label: "Cost", command: "usage.cost" },7 { value: "cost", label: "Cost", command: "usage.cost" },
8 { value: "tokens", label: "Tokens", command: "usage.tokens" },8 { value: "tokens", label: "Tokens", command: "usage.tokens" },
9 { value: "limits", label: "Limits", command: "usage.limits" },
10] as const satisfies readonly { value: UsageMetric; label: string; command: KeybindingCommand }[];9] as const satisfies readonly { value: UsageMetric; label: string; command: KeybindingCommand }[];
1110
12export const WINDOW_OPTIONS = [11export const WINDOW_OPTIONS = [
docs/user/providers-antigravity.md+2-1
...@@ -125,7 +125,8 @@ is refused while the runtime is in use....@@ -125,7 +125,8 @@ is refused while the runtime is in use.
125125
126## Check access and troubleshoot126## Check access and troubleshoot
127127
128Open **Usage > Limits** to see the remaining Gemini and shared Claude/GPT allowances128Hover 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
129for Google sign-ins. Each group has separate five-hour and weekly limits and reset130for Google sign-ins. Each group has separate five-hour and weekly limits and reset
130times. Limits refresh with provider status and include usage outside T3 Code.131times. 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....@@ -41,7 +41,7 @@ sharing a history directory count once.
41When your app and server support different providers, usage totals may cover only the providers41When your app and server support different providers, usage totals may cover only the providers
42your app understands. Update the app to include newly supported providers.42your 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. All44On web and desktop, use the environment dropdown to filter costs and tokens. All
45environments are selected by default. The dropdown shows which environments are still scanning;45environments are selected by default. The dropdown shows which environments are still scanning;
46results appear as each one responds, and figures still updating are dimmed. Cursor shows its last46results appear as each one responds, and figures still updating are dimmed. Cursor shows its last
47saved totals first, then updates them when Cursor's API responds.47saved totals first, then updates them when Cursor's API responds.
...@@ -78,9 +78,12 @@ the dialog....@@ -78,9 +78,12 @@ the dialog.
7878
79## Track subscription limits79## Track subscription limits
8080
81**Usage → Limits** pools every subscription account it can see per provider, so with several Codex81**On web and desktop**, hover the sidebar Usage button to inspect subscription
82or Claude accounts across your environments and hubs you read one number per window rather than a82limits, or focus it and press Arrow Down.
83list. Each window card shows how much of the pool is left and a bar with one segment per account,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,
84kept in the same column across windows. Accounts are ordered by their 5-hour reset, soonest87kept in the same column across windows. Accounts are ordered by their 5-hour reset, soonest
85first, or by the first available window when no account reports a 5-hour limit. A gap means the88first, or by the first available window when no account reports a 5-hour limit. A gap means the
86account does not report that window. When the provider reports reset times, the card also says89account 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...@@ -93,16 +96,16 @@ Keychain. On narrow screens, numbered rows below
93the bar show each account's quota, countdown, and credits. Tap a row to open its details.96the bar show each account's quota, countdown, and credits. Tap a row to open its details.
9497
95The same account signed in on more than one environment, or reported by a hub as well, counts once.98The 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 at101Opening the detailed limits view checks connected environments automatically. Each client waits at
99least five minutes between automatic checks of an environment, including after a failed check.102least 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
102Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the105Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the
103current model's limits without leaving the conversation. The result opens above the composer and106current 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 refresh107closes when you dismiss it or send your next message. It uses the same limits snapshot, so it does
105anything. The command is offered only for providers that appear under **Usage → Limits**.108not run the agent or refresh anything. The command is offered only for providers reporting limits.
106109
107OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in110OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in
108the environment. T3 cannot report limits for external OpenCode servers because their credentials111the environment. T3 cannot report limits for external OpenCode servers because their credentials
...@@ -126,7 +129,7 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`....@@ -126,7 +129,7 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`.
126To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the129To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the
127environment that will connect to the hub and enter its URL and management key.130environment 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 an132The accounts appear in the detailed limits view. Codex accounts show banked reset credits; select an
130account and choose **Use reset** to redeem one. No hub plugin is required.133account and choose **Use reset** to redeem one. No hub plugin is required.
131134
132This connection supplies usage information; configure135This connection supplies usage information; configure
...@@ -143,8 +146,8 @@ Session, Weekly, or both for each provider. Reopen T3 to refresh expired reading...@@ -143,8 +146,8 @@ Session, Weekly, or both for each provider. Reopen T3 to refresh expired reading
143## Keyboard shortcuts146## Keyboard shortcuts
144147
145On web and desktop, open Usage from the command palette. While on Usage,148On 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.
147Use `Ctrl+Shift+1/2/3/4` (`Cmd+Shift+1/2/3/4` on macOS) for the past150Use `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.
149Press `Escape` to return to the previous page. Customize these shortcuts in152Press `Escape` to return to the previous page. Customize these shortcuts in
150**Settings → Keybindings**.153**Settings → Keybindings**.
packages/shared/src/keybindings.ts-1
...@@ -202,7 +202,6 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -202,7 +202,6 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
202 })),202 })),
203 { key: "c", command: "usage.cost", when: "usagePageOpen" },203 { key: "c", command: "usage.cost", when: "usagePageOpen" },
204 { key: "t", command: "usage.tokens", when: "usagePageOpen" },204 { key: "t", command: "usage.tokens", when: "usagePageOpen" },
205 { key: "l", command: "usage.limits", when: "usagePageOpen" },
206 { key: "mod+shift+1", command: "usage.period.day", when: "usagePageOpen" },205 { key: "mod+shift+1", command: "usage.period.day", when: "usagePageOpen" },
207 { key: "mod+shift+2", command: "usage.period.week", when: "usagePageOpen" },206 { key: "mod+shift+2", command: "usage.period.week", when: "usagePageOpen" },
208 { key: "mod+shift+3", command: "usage.period.month", when: "usagePageOpen" },207 { key: "mod+shift+3", command: "usage.period.month", when: "usagePageOpen" },
packages/shared/src/usageLimits.test.ts+43
...@@ -161,6 +161,49 @@ describe("pools", () => {...@@ -161,6 +161,49 @@ describe("pools", () => {
161 };161 };
162 const laptop = { entry: { target: { label: "Laptop" } } };162 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
164 it("merges one account reported natively on two environments and by a hub into one entry", () => {207 it("merges one account reported natively on two environments and by a hub into one entry", () => {
165 const native = provider({208 const native = provider({
166 driver: claude,209 driver: claude,
packages/shared/src/usageLimits.ts+46-17
...@@ -52,6 +52,13 @@ export function cursorUsageWindowDetails(id: string) {...@@ -52,6 +52,13 @@ export function cursorUsageWindowDetails(id: string) {
52 return CURSOR_USAGE_WINDOWS.find((window) => window.id === id);52 return CURSOR_USAGE_WINDOWS.find((window) => window.id === id);
53}53}
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
55function cursorUsageWindowRank(id: string): number {62function cursorUsageWindowRank(id: string): number {
56 const rank = CURSOR_USAGE_WINDOWS.findIndex((window) => window.id === id);63 const rank = CURSOR_USAGE_WINDOWS.findIndex((window) => window.id === id);
57 return rank < 0 ? CURSOR_USAGE_WINDOWS.length : rank;64 return rank < 0 ? CURSOR_USAGE_WINDOWS.length : rank;
...@@ -74,16 +81,15 @@ export function providersWithLimits(...@@ -74,16 +81,15 @@ export function providersWithLimits(
74 );81 );
75}82}
7683
77export type LimitPresentations = ReadonlyMap<84export interface LimitPresentation {
78 EnvironmentId,85 readonly entry: { readonly target: { readonly label: string } };
79 {86 readonly serverConfig: {
80 readonly entry: { readonly target: { readonly label: string } };87 readonly providers?: readonly ServerProvider[] | undefined;
81 readonly serverConfig: {88 readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined;
82 readonly providers?: readonly ServerProvider[] | undefined;89 } | null;
83 readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined;90}
84 } | null;91
85 }92export type LimitPresentations = ReadonlyMap<EnvironmentId, LimitPresentation>;
86>;
8793
88/** One destination per service, even when several accounts or environments use it. */94/** One destination per service, even when several accounts or environments use it. */
89export function collectExternalUsageLinks(presentations: LimitPresentations) {95export function collectExternalUsageLinks(presentations: LimitPresentations) {
...@@ -196,10 +202,13 @@ export interface LimitAccount {...@@ -196,10 +202,13 @@ export interface LimitAccount {
196202
197/**203/**
198 * Every account with usable windows across the connected environments, one204 * Every account with usable windows across the connected environments, one
199 * entry per distinct account. The freshest reads supply windows and credits;205 * entry per distinct account. The freshest usable reads supply windows and
200 * native instances supply names and environment labels.206 * credits; native instances supply names and environment labels.
201 */207 */
202export function collectLimitAccounts(presentations: LimitPresentations): readonly LimitAccount[] {208export function collectLimitAccounts(
209 presentations: LimitPresentations,
210 options?: { readonly includeUnavailable: boolean },
211): readonly LimitAccount[] {
203 const accounts = new Map<string, LimitAccount>();212 const accounts = new Map<string, LimitAccount>();
204 const creditSources = new Map<string, LimitAccount>();213 const creditSources = new Map<string, LimitAccount>();
205 const hubRedeems = new Map<string, LimitAccount>();214 const hubRedeems = new Map<string, LimitAccount>();
...@@ -230,7 +239,12 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl...@@ -230,7 +239,12 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl
230 accounts.set(key, next);239 accounts.set(key, next);
231 return;240 return;
232 }241 }
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;
234 // Two instances on one machine sharing an account still name it once.248 // Two instances on one machine sharing an account still name it once.
235 const environments = [249 const environments = [
236 ...previous.environments,250 ...previous.environments,
...@@ -272,8 +286,19 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl...@@ -272,8 +286,19 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl
272 );286 );
273 for (const [environmentId, presentation] of presentations) {287 for (const [environmentId, presentation] of presentations) {
274 const label = presentation.entry.target.label;288 const label = presentation.entry.target.label;
275 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {289 const providers = options?.includeUnavailable
276 if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue;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;
277 merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, {302 merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, {
278 key: `${environmentId}:${provider.instanceId}`,303 key: `${environmentId}:${provider.instanceId}`,
279 driver: provider.driver,304 driver: provider.driver,
...@@ -298,7 +323,11 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl...@@ -298,7 +323,11 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl
298 ? `${presentation.entry.target.label} · ${source.label}`323 ? `${presentation.entry.target.label} · ${source.label}`
299 : source.label;324 : source.label;
300 for (const account of source.accounts) {325 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;
302 const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`;331 const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`;
303 merge(resolveKey(account) ?? key, {332 merge(resolveKey(account) ?? key, {
304 key,333 key,