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