From 1af2d44259972f472c2e230e50e41d841169aab4 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 15:36:18 -0700 Subject: [PATCH] feat: show profile usage meters in the sidebar Keep subscription headroom visible while working: a full-width sidebar control shows each account's tightest allowance with the provider and profile badge. Exclude Claude Fable limits, retain unavailable profiles as unknown, and deduplicate accounts reported by multiple environments or hubs. Keep the sidebar compact: omit its header and use 18px meters with half-size provider marks and profile badges. Hover for detailed limits and reset credits; use Arrow Down from the keyboard. Clicking opens cost and token history. The context menu opens limits, usage-reset settings, or provider configuration. Retire the full-page limits tab while preserving saved range and provider filters. Assisted-by: gpt-6.1-sol --- .../KeybindingsSettings.logic.test.ts | 18 +- .../settings/KeybindingsSettings.logic.ts | 34 +- .../src/components/sidebar/SidebarChrome.tsx | 16 +- .../components/sidebar/SidebarUsageButton.tsx | 292 ++++++++++++++++++ apps/web/src/components/usage/UsageLimits.tsx | 61 +--- .../components/usage/UsageLimitsPooled.tsx | 84 +++-- .../usage/UsagePage.refresh.test.tsx | 210 +++---------- apps/web/src/components/usage/UsagePage.tsx | 262 ++++------------ .../usage/sidebarUsageProfiles.test.ts | 276 +++++++++++++++++ .../components/usage/sidebarUsageProfiles.ts | 66 ++++ .../usage/usagePagePreferences.test.ts | 24 +- .../components/usage/usagePagePreferences.ts | 14 +- .../components/usage/usageShortcuts.test.ts | 18 ++ .../src/components/usage/usageShortcuts.ts | 3 +- docs/user/providers-antigravity.md | 3 +- docs/user/usage.md | 27 +- packages/shared/src/keybindings.ts | 1 - packages/shared/src/usageLimits.test.ts | 43 +++ packages/shared/src/usageLimits.ts | 63 +++- 19 files changed, 1003 insertions(+), 512 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarUsageButton.tsx create mode 100644 apps/web/src/components/usage/sidebarUsageProfiles.test.ts create mode 100644 apps/web/src/components/usage/sidebarUsageProfiles.ts diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index f98c7568961148c9c0211f4000064399d6c870fe..2cd6e8d5aa616744e907fcf99dba707ec88e6767 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -83,7 +83,6 @@ describe("KeybindingsSettings.logic", () => { "usage.open", "usage.cost", "usage.tokens", - "usage.limits", "usage.period.day", "usage.period.week", "usage.period.month", @@ -101,6 +100,23 @@ describe("KeybindingsSettings.logic", () => { } }); + it("omits retired Limits bindings from settings while retaining other configured shortcuts", () => { + const bindings = [ + ...DEFAULT_RESOLVED_KEYBINDINGS, + { + ...DEFAULT_RESOLVED_KEYBINDINGS.find((binding) => binding.command === "usage.tokens")!, + command: "usage.limits" as const, + }, + ]; + expect(buildKeybindingRows(bindings, "").some((row) => row.command === "usage.limits")).toBe( + false, + ); + expect(buildKeybindingCommandOptions(bindings)).not.toContain("usage.limits"); + expect(buildKeybindingRows(bindings, "").some((row) => row.command === "usage.tokens")).toBe( + true, + ); + }); + it("builds searchable rows with readable key and when values", () => { const rows = buildKeybindingRows( [ diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index d5678a7da4ee5bebd2946dbcf221ab9a245353fe..e691fb19dc7b0221ffc0a5c3ff039a745e16490d 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -253,21 +253,25 @@ export function buildKeybindingRows( query: string, ): ReadonlyArray { const normalizedQuery = query.trim().toLowerCase(); - const rows = keybindings.map((binding, index) => { + const rows = keybindings.flatMap((binding, index) => { + if (binding.command === "usage.limits") return []; + const defaultBinding = defaultBindingForBinding(binding); const key = shortcutToKeybindingInput(binding.shortcut); const when = whenAstToExpression(binding.whenAst); - return { - id: `${keybindingRowId(binding.command, key, when)}\u0000${index}`, - command: binding.command, - key, - when, - source: sourceForBinding(binding), - defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null, - defaultWhen: whenAstToExpression(defaultBinding?.whenAst), - binding, - conflicts: [], - } satisfies KeybindingRow; + return [ + { + id: `${keybindingRowId(binding.command, key, when)}\u0000${index}`, + command: binding.command, + key, + when, + source: sourceForBinding(binding), + defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null, + defaultWhen: whenAstToExpression(defaultBinding?.whenAst), + binding, + conflicts: [], + } satisfies KeybindingRow, + ]; }); const rowsWithConflicts = rows.map((row) => { @@ -351,9 +355,11 @@ export function buildWhenVariableOptions(): ReadonlyArray { export function buildKeybindingCommandOptions( keybindings: ResolvedKeybindingsConfig, ): ReadonlyArray { - const commands = new Set(STATIC_KEYBINDING_COMMANDS); + const commands = new Set( + STATIC_KEYBINDING_COMMANDS.filter((command) => command !== "usage.limits"), + ); for (const binding of keybindings) { - commands.add(binding.command); + if (binding.command !== "usage.limits") commands.add(binding.command); } return [...commands].toSorted((left, right) => compareCommands(left, right, commandLabel)); } diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 303233eee8c4ad645efb3dda48f13ba05498d0b4..ad4de4deb21ccbed2b997a5faddfec03e0a5dbde 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftIcon, ChartNoAxesColumnIcon } from "lucide-react"; +import { ArrowLeftIcon } from "lucide-react"; import type { ReactNode } from "react"; import { memo, useCallback } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; @@ -25,6 +25,7 @@ import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; import { AppMenu } from "./AppMenu"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +import { SidebarUsageButton } from "./SidebarUsageButton"; export const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron, @@ -109,13 +110,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { search: readPullRequestListPreferences(), }); }, [closeMobileSidebar, navigate]); - const handleUsageClick = useCallback(() => { - if (isMobile) { - setOpenMobile(false); - } - void navigate({ to: "/usage" }); - }, [isMobile, navigate, setOpenMobile]); - const handleBackClick = useCallback(() => { closeMobileSidebar(); void navigateToMainApp(); @@ -139,11 +133,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { onClick={handlePullRequestsClick} /> ) : null} - } - label="Usage" - onClick={handleUsageClick} - /> )} @@ -154,6 +143,7 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( + ); diff --git a/apps/web/src/components/sidebar/SidebarUsageButton.tsx b/apps/web/src/components/sidebar/SidebarUsageButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..36101f8f6136c2d59d1ed4b5df119bd7041752f8 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUsageButton.tsx @@ -0,0 +1,292 @@ +import { useAtomValue } from "@effect/atom-react"; +import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; +import { useNavigate } from "@tanstack/react-router"; +import { Atom } from "effect/reactivity"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; + +import { ensureLocalApi } from "../../localApi"; +import { environmentPresentations } from "../../state/presentation"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { providerClients } from "../settings/providerDriverMeta"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; +import { RefreshIcon } from "../ui/refresh-icon"; +import { useSidebar } from "../ui/sidebar"; +import { accountInitials, UsageLimitsPooled } from "../usage/UsageLimitsPooled"; +import { sidebarUsageProfiles } from "../usage/sidebarUsageProfiles"; + +const refreshableEnvironmentIdsAtom = Atom.make((get) => + [...get(environmentPresentations.presentationsAtom)] + .filter( + ([id, presentation]) => + presentation.connection.phase === "connected" && + get(serverEnvironment.refreshProviders.permissionAtom(id)), + ) + .map(([id]) => id), +); + +export function SidebarUsageButton() { + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const navigate = useNavigate(); + const { isMobile, setOpenMobile } = useSidebar(); + const [open, setOpen] = useState(false); + const [contextMenuOpen, setContextMenuOpen] = useState(false); + const [hasOpened, setHasOpened] = useState(false); + const triggerRef = useRef(null); + const profiles = sidebarUsageProfiles(presentations); + + const closeSidebar = () => { + setOpen(false); + if (isMobile) setOpenMobile(false); + }; + const showMenu = async (position: { x: number; y: number }) => { + setOpen(false); + setContextMenuOpen(true); + try { + const action = await ensureLocalApi().contextMenu.show( + [ + { id: "limits", label: "Usage limits" }, + { id: "resets", label: "Usage reset settings…" }, + { id: "providers", label: "Configure providers…" }, + ], + position, + ); + if (action === null) return; + if (action === "limits") { + setHasOpened(true); + setOpen(true); + return; + } + closeSidebar(); + if (action === "resets") { + await navigate({ to: "/settings/general", hash: "auto-resume-limited-threads" }); + } else { + await navigate({ to: "/settings/providers" }); + } + } finally { + setContextMenuOpen(false); + } + }; + + return ( + { + if (contextMenuOpen || details.reason === "trigger-press") { + details.cancel(); + return; + } + if (nextOpen) setHasOpened(true); + setOpen(nextOpen); + }} + > + + } + openOnHover + delay={300} + onClick={() => { + closeSidebar(); + void navigate({ to: "/usage" }); + }} + onContextMenu={(event) => { + event.preventDefault(); + void showMenu({ x: event.clientX, y: event.clientY }); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + setHasOpened(true); + setOpen(true); + return; + } + if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return; + event.preventDefault(); + const bounds = event.currentTarget.getBoundingClientRect(); + void showMenu({ x: bounds.left, y: bounds.top }); + }} + aria-label="Usage and costs" + aria-description="Press Arrow Down for limits. Open for costs and tokens." + > + {profiles.length > 0 ? ( + + {profiles.map(({ account, remainingPercent, window }) => { + const providerLabel = providerClients.get(account.driver)?.label ?? account.driver; + const label = + account.displayName ?? + (account.email ? accountInitials(account.email) : providerLabel); + const badgeLabel = + account.displayName ?? (account.email ? accountInitials(account.email) : label); + const summary = `${providerLabel}${label === providerLabel ? "" : ` · ${label}`}: ${remainingPercent === null ? "usage unavailable" : `${remainingPercent}% left, ${window?.label}`}`; + return ( + + + + + {remainingPercent !== null && remainingPercent > 0 ? ( + + ) : null} + + + + + {remainingPercent === null ? "—" : `${remainingPercent}%`} + + + ); + })} + + ) : ( + View costs and tokens + )} + + input === "keyboard"} + finalFocus={triggerRef} + side="right" + align="end" + padding="none" + className="w-[min(46rem,calc(100vw-2rem))]" + > + {hasOpened ? ( + setOpen(false)} + resetReturnFocus={triggerRef} + /> + ) : null} + + + ); +} + +function SidebarUsageDetails({ + open, + onRedeem, + resetReturnFocus, +}: { + readonly open: boolean; + readonly onRedeem: () => void; + readonly resetReturnFocus: React.RefObject; +}) { + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const [now, setNow] = useState(Date.now); + const [refreshing, setRefreshing] = useState(false); + const [refreshFailed, setRefreshFailed] = useState(false); + const refreshableIds = useAtomValue(refreshableEnvironmentIdsAtom); + const refresh = useEffectEvent(async (automatic: boolean) => { + try { + const results = await Promise.all( + refreshableIds + .filter((id) => + appAtomRegistry.get(serverEnvironment.refreshProviders.permissionAtom(id)), + ) + .map((environmentId) => + refreshUsageLimits( + environmentId, + () => refreshProviders({ environmentId, input: { fresh: true } }), + automatic, + ), + ), + ); + setRefreshFailed(results.some((result) => result?._tag === "Failure")); + } catch { + setRefreshFailed(true); + } finally { + setNow(Date.now()); + } + }); + + useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- State changes only after the provider response arrives. + if (open && refreshableIds.length > 0) void refresh(true); + }, [open, refreshableIds]); + + return ( +
+
+ Usage limits + +
+ {refreshFailed ? ( +

+ Could not refresh limits. Try again. +

+ ) : null} + +
+ ); +} diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 45e567f346cb7a11d8b3112df390773170191028..a23d6f9971e258b3f3a8d3edd8b4d5665fa27a09 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -8,7 +8,6 @@ import { ServerProviderUsageWindow, UsageProviderKind, } from "@t3tools/contracts"; -import { useAtomValue } from "@effect/atom-react"; import { elapsedShare, formatDuration, @@ -18,10 +17,9 @@ import { remainingPercent, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; -import { Fragment, type ReactNode, useState } from "react"; +import { Fragment, type RefObject, useState } from "react"; import { usePrimarySettings } from "../../hooks/useSettings"; -import { environmentPresentations } from "../../state/presentation"; import { useEnvironmentScope, readEnvironmentScope } from "../../state/session"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -37,7 +35,6 @@ import { } from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { UsageLimitsPooled } from "./UsageLimitsPooled"; import { PROVIDER_PRESENTATION } from "./usageProviders"; const PACE: Record = { @@ -252,15 +249,17 @@ export function ResetCreditDialog({ onOpenChange, onConfirm, disabled = false, + finalFocus, }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly onConfirm: () => void; readonly disabled?: boolean; + readonly finalFocus?: RefObject | undefined; }) { return ( - + Use a reset credit? @@ -338,55 +337,3 @@ export function ResetCredits({ ); } - -/** - * Subscription quota across every connected environment's providers and hubs, - * pooled per provider. The page advances `now` on explicit refresh rather than - * ticking: a live clock would repaint the page for no decision-changing gain. - */ -export function UsageLimitsSection({ - selectedEnvironmentIds, - hiddenProviders, - now, - cursorPrompt, -}: { - readonly selectedEnvironmentIds: ReadonlySet | null; - readonly hiddenProviders: ReadonlySet; - readonly now: number; - readonly cursorPrompt?: ReactNode; -}) { - const presentations = useAtomValue(environmentPresentations.presentationsAtom); - const hiddenDrivers = new Set( - [...hiddenProviders].map((provider) => PROVIDER_PRESENTATION[provider].driverKind), - ); - const isVisible = (driver: ServerProvider["driver"]) => !hiddenDrivers.has(driver); - // Hidden drivers leave both native accounts and hub accounts, so their - // bars, notices, and external links all drop out together. - const selected = new Map( - [...presentations].flatMap(([id, presentation]) => { - if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(id)) return []; - const config = presentation.serverConfig; - if (hiddenDrivers.size === 0 || config === null) return [[id, presentation] as const]; - return [ - [ - id, - { - ...presentation, - serverConfig: { - ...config, - providers: config.providers.filter((provider) => isVisible(provider.driver)), - // A hub left with no visible accounts is dropped, not reported as empty. - usageLimitSources: config.usageLimitSources?.flatMap((source) => { - const accounts = source.accounts.filter((account) => isVisible(account.driver)); - return accounts.length === 0 && source.accounts.length > 0 - ? [] - : [{ ...source, accounts }]; - }), - }, - }, - ] as const, - ]; - }), - ); - return ; -} diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index b8603d30af73584823b4959254a3892ea0b77040..a96bf567646acb57e4e53b58a8a05e5b67e70492 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -7,6 +7,7 @@ import { cursorUsageWindowDetails, displayLimitWindows, formatResetsIn, + isUsageLimitWindowVisible, type LimitAccount, type LimitPool, type LimitPoolMember, @@ -14,7 +15,7 @@ import { remainingPercent, } from "@t3tools/shared/usageLimits"; import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react"; -import { Fragment, type ReactNode, useState } from "react"; +import { type ReactNode, type RefObject, useState } from "react"; import { ensureLocalApi } from "../../localApi"; import { usePrimarySettings } from "../../hooks/useSettings"; @@ -36,7 +37,7 @@ import { } from "./UsageLimits"; /** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */ -function accountInitials(email: string): string { +export function accountInitials(email: string): string { const [local = "", domain = ""] = email.split("@"); return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "?"; } @@ -232,6 +233,8 @@ function PoolSegment({ now, index, showAccountName, + onRedeem, + resetReturnFocus, }: { readonly account: LimitAccount; readonly window: LimitPoolMember["window"]; @@ -241,6 +244,8 @@ function PoolSegment({ /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */ readonly index: number; readonly showAccountName: boolean; + readonly onRedeem: () => void; + readonly resetReturnFocus: RefObject; }) { const [open, setOpen] = useState(false); const remaining = remainingPercent(window); @@ -318,6 +323,8 @@ function PoolSegment({ now={now} redeemAt={account.redeem} closePopover={() => setOpen(false)} + onRedeem={onRedeem} + resetReturnFocus={resetReturnFocus} /> ) : ( @@ -403,6 +410,8 @@ function RedeemableSegmentPopup({ now, redeemAt, closePopover, + onRedeem, + resetReturnFocus, }: { readonly account: LimitAccount; readonly window: LimitPoolMember["window"]; @@ -410,6 +419,8 @@ function RedeemableSegmentPopup({ readonly now: number; readonly redeemAt: NonNullable; readonly closePopover: () => void; + readonly onRedeem: () => void; + readonly resetReturnFocus: RefObject; }) { const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input); return ( @@ -423,11 +434,13 @@ function RedeemableSegmentPopup({ redeem={redeem} onRedeem={() => { closePopover(); + onRedeem(); redeem.setConfirming(true); }} /> void redeem.redeem()} @@ -456,10 +469,14 @@ function PoolBar({ pool, color, now, + onRedeem, + resetReturnFocus, }: { readonly pool: LimitPoolWindow; readonly color: string; readonly now: number; + readonly onRedeem: () => void; + readonly resetReturnFocus: RefObject; }) { const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset])); return ( @@ -479,6 +496,8 @@ function PoolBar({ now={now} index={position + 1} showAccountName={pool.columns.length > 1} + onRedeem={onRedeem} + resetReturnFocus={resetReturnFocus} /> ) : null, )} @@ -497,12 +516,16 @@ function PoolWindowCard({ now, label, description, + onRedeem, + resetReturnFocus, }: { readonly pool: LimitPoolWindow; readonly color: string; readonly now: number; readonly label?: string | undefined; readonly description?: string | undefined; + readonly onRedeem: () => void; + readonly resetReturnFocus: RefObject; }) { // The soonest reset that hands anything back; an untouched account resets to no effect. const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); @@ -525,7 +548,13 @@ function PoolWindowCard({ ) : null} - + {description ? (

{description}

) : null} @@ -533,7 +562,17 @@ function PoolWindowCard({ ); } -function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { +function PoolSection({ + pool, + now, + onRedeem, + resetReturnFocus, +}: { + readonly pool: LimitPool; + readonly now: number; + readonly onRedeem: () => void; + readonly resetReturnFocus: RefObject; +}) { const color = barColor(pool.driver); const label = providerClients.get(pool.driver)?.label ?? String(pool.driver); const windows = displayLimitWindows(pool); @@ -559,6 +598,8 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu now={now} label={details?.label} description={details?.description} + onRedeem={onRedeem} + resetReturnFocus={resetReturnFocus} /> ); })} @@ -574,34 +615,39 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu export function UsageLimitsPooled({ presentations, now, - cursorPrompt, + onRedeem, + resetReturnFocus, }: { readonly presentations: Parameters[0]; readonly now: number; - readonly cursorPrompt?: ReactNode; + readonly onRedeem: () => void; + readonly resetReturnFocus: RefObject; }) { - const pools = collectLimitPools(collectLimitAccounts(presentations), now); + const accounts = collectLimitAccounts(presentations).flatMap((account) => { + const windows = account.limits.windows.filter((window) => + isUsageLimitWindowVisible(account.driver, window), + ); + return windows.length > 0 ? [{ ...account, limits: { ...account.limits, windows } }] : []; + }); + const pools = collectLimitPools(accounts, now); const notices = collectLimitNotices(presentations); const externalLinks = collectExternalUsageLinks(presentations); - const cursorPromptAt = - Math.max( - pools.findIndex((pool) => pool.driver === "codex"), - pools.findIndex((pool) => pool.driver === "claudeAgent"), - ) + 1; return (
- {pools.length === 0 && notices.length === 0 && !cursorPrompt && externalLinks.length === 0 ? ( + {pools.length === 0 && notices.length === 0 && externalLinks.length === 0 ? (

No provider on the selected environments reports subscription limits.

) : null} - {pools.map((pool, index) => ( - - {index === cursorPromptAt ? cursorPrompt : null} - - + {pools.map((pool) => ( + ))} - {cursorPromptAt === pools.length ? cursorPrompt : null} {externalLinks.map((link) => (
({ presentations: new Map(), refreshProviders: vi.fn(async () => undefined), - metric: "limits", + metric: "cost", + refresh: vi.fn(async (_window?: unknown) => undefined), })); vi.mock("@effect/atom-react", () => ({ useAtomValue: (atom: unknown) => @@ -38,6 +39,7 @@ vi.mock("../../state/usage", () => ({ isPending: false, error: null, summary: null, + canReadDiagnostics: true, }, ], selectedEnvironments: [ @@ -47,12 +49,13 @@ vi.mock("../../state/usage", () => ({ isPending: false, error: null, summary: null, + canReadDiagnostics: true, }, ], isPending: false, shown: null, isPartial: false, - refresh: async () => undefined, + refresh: state.refresh, }), })); vi.mock("./usagePagePreferences", () => ({ @@ -99,190 +102,79 @@ vi.mock("../settings/providerDriverMeta", () => ({ import { UsagePage } from "./UsagePage"; let renderer: ReactTestRenderer; -let environmentNumber = 0; beforeEach(() => { vi.stubGlobal("window", new EventTarget()); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z")); - environmentNumber += 1; - state.metric = "limits"; + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-09-11T12:00:00Z")); + state.metric = "cost"; + state.refresh.mockReset().mockResolvedValue(undefined); state.refreshProviders.mockClear(); - state.presentations = new Map([ - [ - EnvironmentId.make(`test-${environmentNumber}`), - { - entry: { target: { label: "Test" } }, - connection: { phase: "connected" }, - serverConfig: { - providers: [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: "codex", - enabled: true, - installed: true, - version: null, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-09-11T12:00:00Z", - models: [], - slashCommands: [], - skills: [], - usageLimits: { - checkedAt: "2026-09-11T12:00:00Z", - windows: [ - { - id: "five_hour", - kind: "session", - label: "Session", - usedPercent: 40, - windowDurationMins: 300, - resetsAt: "2026-09-11T14:00:00Z", - }, - ], - }, - }, - ], - }, - }, - ], - ]); + state.presentations = new Map(); }); afterEach(async () => { await act(() => renderer?.unmount()); + vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); -it.each([0, 1])( - "refreshes the visible limits countdown with refresh button %i without switching tabs, even when quota is unchanged", - async (buttonIndex) => { - await act(() => { - renderer = create(); - }); - expect( - JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), - ).toContain("in 2h 0m"); - vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:30:00Z")); - await act(async () => { - renderer.root - .findAllByProps({ "aria-label": "Refresh limits" }) - .filter((node) => node.type === "button") - .at(buttonIndex)! - .props.onClick(); - }); - expect(state.refreshProviders).toHaveBeenCalledWith({ - environmentId: `test-${environmentNumber}`, - input: {}, - }); - expect( - JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), - ).toContain("in 1h 30m"); - expect( - JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), - ).not.toContain("in 2h 0m"); - }, -); - -it("uses the current time when returning to limits from tokens", async () => { +it.each([0, 1])("refreshes history from toolbar button %i", async (buttonIndex) => { await act(() => { renderer = create(); }); - const selectMetric = (metric: string) => { - renderer.root - .findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]! - .props.onValueChange([metric]); - }; - await act(() => selectMetric("tokens")); - vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T13:00:00Z")); - await act(() => selectMetric("limits")); - expect( - JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), - ).toContain("in 1h 0m"); - expect(state.refreshProviders).toHaveBeenCalledTimes(2); -}); - -it("refreshes once on opening Limits and suppresses rapid returns and remounts", async () => { - state.metric = "tokens"; - await act(() => { - renderer = create( - - - , - ); - }); + expect(state.refresh).not.toHaveBeenCalled(); expect(state.refreshProviders).not.toHaveBeenCalled(); - const selectMetric = (metric: string) => - renderer.root - .findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]! - .props.onValueChange([metric]); - await act(() => selectMetric("limits")); - expect(state.refreshProviders).toHaveBeenCalledTimes(1); - await act(() => selectMetric("tokens")); - await act(() => selectMetric("limits")); - await act(() => renderer.unmount()); - state.metric = "limits"; - await act(() => { - renderer = create( - - - , - ); - }); - expect(state.refreshProviders).toHaveBeenCalledTimes(1); - await act(() => selectMetric("tokens")); - vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:05:00Z")); - await act(() => selectMetric("limits")); - expect(state.refreshProviders).toHaveBeenCalledTimes(2); -}); -it("waits for connection and refreshes new environments during a slow refresh", async () => { - const [id, presentation] = [...state.presentations][0]!; - state.presentations = new Map([[id, { ...presentation, connection: { phase: "disconnected" } }]]); - await act(() => { - renderer = create(); + vi.setSystemTime(new Date("2026-09-12T12:00:00Z")); + await act(async () => { + renderer.root + .findAll((node) => node.type === "button" && node.props["aria-label"] === "Refresh usage") + .at(buttonIndex)! + .props.onClick(); }); + expect(state.refresh).toHaveBeenCalledOnce(); + expect(state.refresh).toHaveBeenCalledWith(expect.objectContaining({ untilDay: "2026-09-12" })); expect(state.refreshProviders).not.toHaveBeenCalled(); - let finishRefresh!: () => void; - state.refreshProviders.mockImplementationOnce( - () => - new Promise((resolve) => { - finishRefresh = () => resolve(undefined); - }), - ); - state.presentations = new Map([[id, presentation]]); - await act(() => renderer.update()); - expect(state.refreshProviders).toHaveBeenCalledTimes(1); - const nextId = EnvironmentId.make(`${id}-next`); - state.presentations = new Map([...state.presentations, [nextId, presentation]]); - await act(() => renderer.update()); - expect(state.refreshProviders).toHaveBeenCalledTimes(2); - expect(state.refreshProviders).toHaveBeenLastCalledWith({ environmentId: nextId, input: {} }); - await act(() => finishRefresh()); }); -it("keeps manual refresh busy until the already-running automatic check settles", async () => { +it("keeps refresh busy and suppresses repeated requests until history arrives", async () => { let finishRefresh!: () => void; const pending = new Promise((resolve) => { finishRefresh = () => resolve(undefined); }); - state.refreshProviders.mockImplementationOnce(() => pending); + state.refresh.mockImplementationOnce(() => pending); await act(() => { renderer = create(); }); const button = () => renderer.root.findAll( - (node) => node.type === "button" && node.props["aria-label"] === "Refresh limits", + (node) => node.type === "button" && node.props["aria-label"] === "Refresh usage", )[0]!; - expect(state.refreshProviders).toHaveBeenCalledTimes(1); - await act(() => button().props.onClick()); - try { - expect(button().props["aria-busy"]).toBe(true); - expect(state.refreshProviders).toHaveBeenCalledTimes(1); - } finally { - await act(async () => { - finishRefresh(); - await pending; - }); - } + await act(() => { + button().props.onClick(); + button().props.onClick(); + }); + expect(button().props["aria-busy"]).toBe(true); + expect(state.refresh).toHaveBeenCalledOnce(); + await act(async () => { + finishRefresh(); + await pending; + }); expect(button().props["aria-busy"]).toBe(false); }); + +it("switches costs and tokens without checking subscription limits", async () => { + await act(() => { + renderer = create(); + }); + const metricControl = () => + renderer.root.findAll( + (node) => node.type === "div" && node.props["aria-label"] === "Usage metric", + )[0]!; + await act(() => metricControl().props.onValueChange(["tokens"])); + expect(metricControl().props.value).toEqual(["tokens"]); + await act(() => metricControl().props.onValueChange(["limits"])); + expect(metricControl().props.value).toEqual(["tokens"]); + expect(state.refreshProviders).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 08a07241b612375d0f1ca1a23ac945f674e20628..b3f7ebe4803290b0813c498214e26063548aa63c 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -3,7 +3,6 @@ import { usesChatGptSharing } from "@t3tools/shared/usageLimits"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { - ProviderDriverKind, USAGE_CONTRACT_VERSION, type EnvironmentId, type UsageProviderKind, @@ -75,7 +74,6 @@ import { } from "../WorkspaceBreadcrumb"; import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; -import { UsageLimitsSection } from "./UsageLimits"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart } from "./UsageProviderChart"; import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog"; @@ -132,9 +130,7 @@ export function UsagePage() { ), })); const metric = preferences.metric; - const showingLimits = metric === "limits"; const [isRefreshing, setIsRefreshing] = useState(false); - const [limitsNow, setLimitsNow] = useState(() => Date.now()); const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); const [priceDialog, setPriceDialog] = useState<{ readonly model?: string } | null>(null); @@ -159,14 +155,13 @@ export function UsagePage() { const merged = shown?.merged ?? answeredUsage; const shownWindow = shown?.window ?? window; const shownHourly = shownWindow.resolution === "hour"; - const refreshingUsage = isRefreshing && !showingLimits; // Usage kept from another window is all old, so every figure stays muted. const showingKept = shown !== null && shown.window !== window; const loading = useMemo(() => { if (showingKept) { return { partial: true, everyProvider: true, providers: new Set() }; } - const state = usageLoadingState(selectedEnvironments, refreshingUsage); + const state = usageLoadingState(selectedEnvironments, isRefreshing); // A hidden provider still refreshing must not add its row or mute the totals. const providers = new Set([...state.providers].filter((p) => !hiddenProviders.has(p))); return { @@ -174,7 +169,7 @@ export function UsagePage() { everyProvider: state.everyProvider, providers, }; - }, [showingKept, refreshingUsage, selectedEnvironments, hiddenProviders]); + }, [showingKept, isRefreshing, selectedEnvironments, hiddenProviders]); const isProviderLoading = (provider: UsageProviderKind) => loading.everyProvider || loading.providers.has(provider); const presentations = useAtomValue(environmentPresentations.presentationsAtom); @@ -283,28 +278,8 @@ export function UsagePage() { }); }; const selectMetric = (nextMetric: UsageMetric) => { - if (nextMetric === "limits") setLimitsNow(Date.now()); updatePreferences({ metric: nextMetric }); }; - const refreshLimits = async (automatic = false, afterPending = false) => { - try { - await Promise.all( - Array.from(presentations, ([environmentId, presentation]) => { - if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; - if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { - return refreshUsageLimits( - environmentId, - () => refreshProviders({ environmentId, input: {} }), - automatic, - afterPending, - ); - } - }), - ); - } finally { - setLimitsNow(Date.now()); - } - }; const onUsageKeyDown = useEffectEvent((event: KeyboardEvent) => { if ( event.defaultPrevented || @@ -323,7 +298,7 @@ export function UsagePage() { event.preventDefault(); event.stopPropagation(); if (metricOption) selectMetric(metricOption.value); - if (periodOption && !showingLimits) selectWindow(periodOption.days); + if (periodOption) selectWindow(periodOption.days); }); useEffect(() => { @@ -334,15 +309,6 @@ export function UsagePage() { const refreshWindow = () => { if (refreshingRef.current) return; - if (showingLimits) { - refreshingRef.current = true; - setIsRefreshing(true); - void refreshLimits().finally(() => { - refreshingRef.current = false; - setIsRefreshing(false); - }); - return; - } const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( nextWindow.sinceDay !== window.sinceDay || @@ -359,23 +325,6 @@ export function UsagePage() { setIsRefreshing(false); }); }; - const connectedLimitsEnvironments = [...presentations] - .filter( - ([environmentId, presentation]) => - presentation.connection.phase === "connected" && - presentation.serverConfig !== null && - (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), - ) - .map(([environmentId]) => environmentId) - .sort() - .join(","); - const autoRefreshLimits = useEffectEvent(() => { - void refreshLimits(true); - }); - useEffect(() => { - if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits(); - }, [showingLimits, connectedLimitsEnvironments]); - // Names the period on screen, which is the previous one until the new one answers. const windowLabel = shownHourly && shownWindow.sinceTime !== undefined && shownWindow.untilTime !== undefined @@ -394,8 +343,7 @@ export function UsagePage() { selectedEnvironments={selectedEnvironments} selectedEnvironmentIds={selectedEnvironmentIds} onSelectionChange={setSelectedEnvironmentIds} - showUsageStatus={!showingLimits} - refreshing={refreshingUsage} + refreshing={isRefreshing} isPartial={isPartial} duplicateSources={merged.duplicateSources} contractMismatches={merged.contractMismatches} @@ -410,11 +358,9 @@ export function UsagePage() { /> - {!showingLimits ? ( - - {windowLabel} - - ) : null} + + {windowLabel} +
))} - {/* The period does not apply to Limits, so it stays in place but - disabled; unmounting it shifted the metric toggle ~300px. */} { const value = next[0]; if (value) selectWindow(Number(value)); @@ -451,9 +394,9 @@ export function UsagePage() {
- {selectedModel !== undefined && !showingLimits ? ( + {selectedModel !== undefined ? ( void; - readonly tooltip: boolean; - readonly buttonText?: string; }) { const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "enable Cursor account usage", @@ -1042,47 +971,7 @@ function CursorEnableButton({ setPending(false); } }; - const button = tooltip ? ( - void enable()} - > - {buttonText} - - ) : ( - - ); - if (!tooltip) return button; - return ( - - - {CURSOR_KEYCHAIN_COPY} - - ); -} -function CursorEnableRow({ - environmentId, - label, - showEnvironment, - onEnabled, -}: { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly showEnvironment: boolean; - readonly onEnabled: () => void; -}) { return (
@@ -1094,51 +983,22 @@ function CursorEnableRow({ Cursor{showEnvironment ? ` · ${label}` : ""} - -
- ); -} - -function CursorEnableLimits({ - environments, - onEnabled, -}: { - readonly environments: readonly EnvironmentUsageStatus[]; - readonly onEnabled: () => void; -}) { - return ( -
-

- + void enable()} + > + Enable + + } /> - Cursor -

-
-

{CURSOR_KEYCHAIN_COPY}

-
- {environments.map((environment) => ( - 1 ? `Enable on ${environment.label}` : "Enable"} - onEnabled={onEnabled} - tooltip={false} - /> - ))} -
-
-
+ {CURSOR_KEYCHAIN_COPY} + +
); } @@ -1239,7 +1099,6 @@ function UsageEnvironmentFilter({ selectedEnvironments, selectedEnvironmentIds, onSelectionChange, - showUsageStatus, refreshing, isPartial, duplicateSources, @@ -1250,7 +1109,6 @@ function UsageEnvironmentFilter({ readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; readonly selectedEnvironmentIds: ReadonlySet | null; readonly onSelectionChange: (ids: ReadonlySet | null) => void; - readonly showUsageStatus: boolean; readonly refreshing: boolean; readonly isPartial: boolean; readonly duplicateSources: readonly string[]; @@ -1272,7 +1130,7 @@ function UsageEnvironmentFilter({ } className="group/usage-environment min-w-0 max-w-full"> {label} - {showUsageStatus && hasIssue ? ( + {hasIssue ? ( {environment.label} - {showUsageStatus ? ( - - {status} - - ) : null} + + {status} + ); @@ -1348,18 +1204,16 @@ function UsageEnvironmentFilter({ {environments.length === 0 ? (

No environments connected.

) : null} - {showUsageStatus && isPartial ? ( + {isPartial ? (

Totals are partial while selected environments scan.

) : null} - {showUsageStatus ? ( - - ) : null} + diff --git a/apps/web/src/components/usage/sidebarUsageProfiles.test.ts b/apps/web/src/components/usage/sidebarUsageProfiles.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b57fa60b6b14da71c2f14ab3f9272bbf037351ac --- /dev/null +++ b/apps/web/src/components/usage/sidebarUsageProfiles.test.ts @@ -0,0 +1,276 @@ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + UsageLimitSourceId, + type ServerProvider, + type ServerProviderUsageWindow, + type UsageLimitSourceSnapshots, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { sidebarUsageProfiles } from "./sidebarUsageProfiles"; + +const checkedAt = "2026-10-15T12:00:00.000Z"; +const session: ServerProviderUsageWindow = { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 20, +}; + +function provider(overrides: Partial = {}): ServerProvider { + return { + instanceId: ProviderInstanceId.make("personal"), + driver: ProviderDriverKind.make("claudeAgent"), + displayName: "Personal", + accentColor: "#abc123", + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", email: "person@example.com" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + usageLimits: { checkedAt, windows: [session] }, + ...overrides, + }; +} + +function environment( + providers: readonly ServerProvider[], + phase: "connected" | "offline" = "connected", + usageLimitSources: UsageLimitSourceSnapshots = [], +) { + return { + entry: { target: { label: "Laptop" } }, + connection: { phase }, + serverConfig: { providers, usageLimitSources }, + }; +} + +describe("sidebar usage profiles", () => { + it("summarizes the most constrained window and preserves native identity", () => { + const weekly = { ...session, id: "seven_day", kind: "weekly" as const, usedPercent: 55 }; + const result = sidebarUsageProfiles( + new Map([ + [ + EnvironmentId.make("laptop"), + environment([provider({ usageLimits: { checkedAt, windows: [session, weekly] } })]), + ], + ]), + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + remainingPercent: 45, + window: weekly, + account: { displayName: "Personal", accentColor: "#abc123", limits: { checkedAt } }, + }); + }); + + it("excludes Fable by id or label only for Claude", () => { + const windows = [ + session, + { ...session, id: "seven_day_fable", usedPercent: 100 }, + { ...session, id: "seven_day_overage_included", label: "Weekly · Fable", usedPercent: 99 }, + ]; + const profiles = sidebarUsageProfiles( + new Map([ + [ + EnvironmentId.make("laptop"), + environment([ + provider({ usageLimits: { checkedAt, windows } }), + provider({ + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + usageLimits: { checkedAt, windows }, + }), + ]), + ], + ]), + ); + + expect(profiles.map((profile) => profile.remainingPercent)).toEqual([80, 0]); + }); + + it("keeps Fable-only and failed profiles visible with an unknown ring", () => { + const profiles = sidebarUsageProfiles( + new Map([ + [ + EnvironmentId.make("laptop"), + environment([ + provider({ + usageLimits: { checkedAt, windows: [{ ...session, id: "seven_day_fable" }] }, + }), + provider({ + instanceId: ProviderInstanceId.make("unreadable"), + auth: { status: "authenticated", email: "other@example.com" }, + usageLimits: { + checkedAt, + windows: [session], + unavailable: { reason: "probeFailed" }, + }, + }), + ]), + ], + ]), + ); + + expect(profiles).toHaveLength(2); + expect(profiles.map(({ remainingPercent, window }) => ({ remainingPercent, window }))).toEqual([ + { remainingPercent: null, window: null }, + { remainingPercent: null, window: null }, + ]); + }); + + it("does not present cached offline usage as a full ring", () => { + const profiles = sidebarUsageProfiles( + new Map([ + [ + EnvironmentId.make("laptop"), + environment( + [provider({ usageLimits: { checkedAt, windows: [{ ...session, usedPercent: 0 }] } })], + "offline", + ), + ], + ]), + ); + + expect(profiles[0]?.remainingPercent).toBeNull(); + }); + + it.each([ + { phase: "connected" as const, error: undefined, remaining: 80 }, + { phase: "connected" as const, error: "Could not refresh", remaining: null }, + { phase: "offline" as const, error: undefined, remaining: null }, + ])( + "represents hub-only profiles in $phase with source error $error", + ({ phase, error, remaining }) => { + const profiles = sidebarUsageProfiles( + new Map([ + [ + EnvironmentId.make("laptop"), + environment([], phase, [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy", + label: "Hub", + checkedAt, + error, + accounts: [ + { + id: "profile.json", + driver: ProviderDriverKind.make("claudeAgent"), + email: "person@example.com", + usageLimits: { checkedAt, windows: [session] }, + }, + ], + }, + ]), + ], + ]), + ); + + expect(profiles).toHaveLength(1); + expect(profiles[0]?.remainingPercent).toBe(remaining); + expect(profiles[0]?.account).toMatchObject({ displayName: null, sourceLabel: "Hub" }); + }, + ); + + it("deduplicates native, offline, and hub copies while retaining the connected snapshot", () => { + const profiles = sidebarUsageProfiles( + new Map([ + [EnvironmentId.make("laptop"), environment([provider()], "offline")], + [ + EnvironmentId.make("desktop"), + { + ...environment([provider()]), + serverConfig: { + providers: [provider()], + usageLimitSources: [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Hub", + checkedAt, + accounts: [ + { + id: "personal.json", + driver: ProviderDriverKind.make("claudeAgent"), + email: "PERSON@example.com", + usageLimits: { + checkedAt: "2026-10-15T12:01:00.000Z", + windows: [{ ...session, usedPercent: 60 }], + }, + }, + ], + }, + ], + }, + }, + ], + ]), + ); + + expect(profiles).toHaveLength(1); + expect(profiles[0]).toMatchObject({ + remainingPercent: 40, + account: { + displayName: "Personal", + environments: [ + { environmentId: "laptop", label: "Laptop" }, + { environmentId: "desktop", label: "Laptop" }, + ], + }, + }); + }); + + it("does not synthesize a refill after the reported reset time", () => { + const profiles = sidebarUsageProfiles( + new Map([ + [ + EnvironmentId.make("laptop"), + environment([ + provider({ + usageLimits: { + checkedAt, + windows: [{ ...session, usedPercent: 100, resetsAt: checkedAt }], + }, + }), + ]), + ], + ]), + ); + + expect(profiles[0]?.remainingPercent).toBe(0); + }); + + it("keeps workspace quotas separate and omits disabled or unsupported profiles", () => { + const profiles = sidebarUsageProfiles( + new Map([ + [ + EnvironmentId.make("laptop"), + environment([ + provider({ + auth: { status: "authenticated", email: "person@example.com", workspaceId: "one" }, + }), + provider({ + instanceId: ProviderInstanceId.make("two"), + auth: { status: "authenticated", email: "person@example.com", workspaceId: "two" }, + }), + provider({ instanceId: ProviderInstanceId.make("disabled"), enabled: false }), + provider({ + instanceId: ProviderInstanceId.make("api-key"), + usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } }, + }), + ]), + ], + ]), + ); + + expect(profiles).toHaveLength(2); + }); +}); diff --git a/apps/web/src/components/usage/sidebarUsageProfiles.ts b/apps/web/src/components/usage/sidebarUsageProfiles.ts new file mode 100644 index 0000000000000000000000000000000000000000..f3e647bb2ce3e1ba0f3619b9cd331ba406502f83 --- /dev/null +++ b/apps/web/src/components/usage/sidebarUsageProfiles.ts @@ -0,0 +1,66 @@ +import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + collectLimitAccounts, + isUsageLimitWindowVisible, + limitsNotice, + remainingPercent, + type LimitPresentation, +} from "@t3tools/shared/usageLimits"; + +export function sidebarUsageProfiles( + presentations: ReadonlyMap< + EnvironmentId, + LimitPresentation & { + readonly connection: Pick; + } + >, +) { + const snapshots = new Map( + [...presentations].map(([environmentId, presentation]) => { + const config = presentation.serverConfig; + const connected = presentation.connection.phase === "connected"; + + return [ + environmentId, + { + ...presentation, + serverConfig: config && { + ...config, + providers: config.providers?.map((provider) => + connected || !provider.usageLimits + ? provider + : { ...provider, usageLimits: { ...provider.usageLimits, windows: [] } }, + ), + usageLimitSources: config.usageLimitSources?.map((source) => ({ + ...source, + accounts: + connected && !source.error + ? source.accounts + : source.accounts.map((account) => ({ + ...account, + usageLimits: { ...account.usageLimits, windows: [] }, + })), + })), + }, + }, + ] as const; + }), + ); + + return collectLimitAccounts(snapshots, { includeUnavailable: true }).map((account) => { + const windows = + limitsNotice(account.limits) === null + ? account.limits.windows.filter((window) => + isUsageLimitWindowVisible(account.driver, window), + ) + : []; + const window = windows.reduce<(typeof windows)[number] | null>( + (mostUsed, candidate) => + mostUsed === null || candidate.usedPercent > mostUsed.usedPercent ? candidate : mostUsed, + null, + ); + + return { account, window, remainingPercent: window ? remainingPercent(window) : null }; + }); +} diff --git a/apps/web/src/components/usage/usagePagePreferences.test.ts b/apps/web/src/components/usage/usagePagePreferences.test.ts index a06d4e6218c3846c7079e32602f7d01f3729d66e..3e98df197b06e659a86e96a0dbf90e0d50dc87f1 100644 --- a/apps/web/src/components/usage/usagePagePreferences.test.ts +++ b/apps/web/src/components/usage/usagePagePreferences.test.ts @@ -25,16 +25,28 @@ afterEach(() => { describe("Usage page preferences", () => { it("uses defaults when no preference has been saved", () => { - expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 30 }); + expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 }); }); it.each([1, 7, 30, 90] as const)("round-trips every metric with a %i-day range", (windowDays) => { - for (const metric of ["cost", "tokens", "limits"] as const) { + for (const metric of ["cost", "tokens"] as const) { saveUsagePagePreferences({ metric, windowDays }); expect(readUsagePagePreferences()).toEqual({ metric, windowDays }); } }); + it("opens saved limits preferences as cost without discarding filters", () => { + values.set( + key, + JSON.stringify({ metric: "limits", windowDays: 7, hiddenProviders: ["claude"] }), + ); + expect(readUsagePagePreferences()).toEqual({ + metric: "cost", + windowDays: 7, + hiddenProviders: ["claude"], + }); + }); + it("round-trips hidden providers and reads older saves without them", () => { saveUsagePagePreferences({ metric: "cost", windowDays: 7, hiddenProviders: ["codex"] }); expect(readUsagePagePreferences().hiddenProviders).toEqual(["codex"]); @@ -49,7 +61,7 @@ describe("Usage page preferences", () => { '{"metric":"cost","windowDays":365}', ])("replaces invalid preferences on the next save: %s", (value) => { values.set(key, value); - expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 30 }); + expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 }); saveUsagePagePreferences({ metric: "tokens", windowDays: 7 }); expect(readUsagePagePreferences()).toEqual({ metric: "tokens", windowDays: 7 }); }); @@ -62,8 +74,8 @@ describe("Usage page preferences", () => { expect(() => saveUsagePagePreferences({ metric: "tokens", windowDays: 7 })).not.toThrow(); expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 }); write.mockRestore(); - saveUsagePagePreferences({ metric: "limits", windowDays: 7 }); - expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 7 }); + saveUsagePagePreferences({ metric: "cost", windowDays: 7 }); + expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 7 }); }); it("contains failures when the browser blocks storage access", () => { @@ -72,7 +84,7 @@ describe("Usage page preferences", () => { throw new Error("SecurityError"); }, }); - expect(readUsagePagePreferences()).toEqual({ metric: "limits", windowDays: 30 }); + expect(readUsagePagePreferences()).toEqual({ metric: "cost", windowDays: 30 }); expect(() => saveUsagePagePreferences({ metric: "tokens", windowDays: 7 })).not.toThrow(); }); }); diff --git a/apps/web/src/components/usage/usagePagePreferences.ts b/apps/web/src/components/usage/usagePagePreferences.ts index 84e6723658c93a4fd5c62a0a1df6633e044e23f6..4ba5faac235652d9f6df626d03897178497551d6 100644 --- a/apps/web/src/components/usage/usagePagePreferences.ts +++ b/apps/web/src/components/usage/usagePagePreferences.ts @@ -10,16 +10,18 @@ const UsagePagePreferencesSchema = Schema.Struct({ /** Providers filtered out of the page. Stored as hidden so new providers show by default. */ hiddenProviders: Schema.optional(Schema.Array(UsageProviderKind)), }); -export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type; +export type UsagePagePreferences = Omit & { + readonly metric: "cost" | "tokens"; +}; -// Limits is what most people open the page for (how much subscription quota is -// left, and when it resets), so it is the first-visit default; the last picked -// tab sticks after that. -const DEFAULT_PREFERENCES: UsagePagePreferences = { metric: "limits", windowDays: 30 }; +const DEFAULT_PREFERENCES: UsagePagePreferences = { metric: "cost", windowDays: 30 }; export function readUsagePagePreferences(): UsagePagePreferences { try { - return getLocalStorageItem(STORAGE_KEY, UsagePagePreferencesSchema) ?? DEFAULT_PREFERENCES; + const saved = getLocalStorageItem(STORAGE_KEY, UsagePagePreferencesSchema); + return saved === null + ? DEFAULT_PREFERENCES + : { ...saved, metric: saved.metric === "limits" ? "cost" : saved.metric }; } catch (error) { console.error("Could not read Usage page preferences.", error); return DEFAULT_PREFERENCES; diff --git a/apps/web/src/components/usage/usageShortcuts.test.ts b/apps/web/src/components/usage/usageShortcuts.test.ts index 81eac013c856ba4ea0fa9cd70402dc1acd6e0c8a..a0c3b94ecf253e8fcd7d611a29a6a72af1b0e6ae 100644 --- a/apps/web/src/components/usage/usageShortcuts.test.ts +++ b/apps/web/src/components/usage/usageShortcuts.test.ts @@ -17,6 +17,24 @@ class TestElement extends EventTarget { afterEach(() => vi.unstubAllGlobals()); +it("does not reserve the former Limits shortcut", () => { + vi.stubGlobal("HTMLElement", TestElement); + vi.stubGlobal("navigator", { platform: "Linux" }); + expect( + resolveUsageShortcut( + { + key: "l", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + target: new TestElement("BODY"), + }, + DEFAULT_RESOLVED_KEYBINDINGS, + ), + ).toBeNull(); +}); + describe("Usage shortcuts while typing", () => { it.each([new TestElement("INPUT"), new TestElement("TEXTAREA"), new TestElement("DIV", true)])( "leaves letters to the focused $tagName field", diff --git a/apps/web/src/components/usage/usageShortcuts.ts b/apps/web/src/components/usage/usageShortcuts.ts index a7132c7caeb70adc6727ac65224f804277d1ba91..574e87d2f98a2c0367ca3121bc4c86d4ed4c40e1 100644 --- a/apps/web/src/components/usage/usageShortcuts.ts +++ b/apps/web/src/components/usage/usageShortcuts.ts @@ -2,11 +2,10 @@ import type { KeybindingCommand, ResolvedKeybindingsConfig } from "@t3tools/cont import type { UsageChartMetric } from "./UsageProviderChart"; import { resolveShortcutCommand, type ShortcutEventLike } from "../../keybindings"; -export type UsageMetric = UsageChartMetric | "limits"; +export type UsageMetric = UsageChartMetric; export const METRIC_OPTIONS = [ { value: "cost", label: "Cost", command: "usage.cost" }, { value: "tokens", label: "Tokens", command: "usage.tokens" }, - { value: "limits", label: "Limits", command: "usage.limits" }, ] as const satisfies readonly { value: UsageMetric; label: string; command: KeybindingCommand }[]; export const WINDOW_OPTIONS = [ diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index 5af00816652269961e55dd11dddfc9b8a3d63847..4d537e178b7902f89e02d3926da2366523bd5eeb 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -125,7 +125,8 @@ is refused while the runtime is in use. ## Check access and troubleshoot -Open **Usage > Limits** to see the remaining Gemini and shared Claude/GPT allowances +Hover or focus the sidebar **Usage** button on web and desktop, or open **Usage → Limits** on +mobile, to see the remaining Gemini and shared Claude/GPT allowances for Google sign-ins. Each group has separate five-hour and weekly limits and reset times. Limits refresh with provider status and include usage outside T3 Code. diff --git a/docs/user/usage.md b/docs/user/usage.md index ed1dd19ce1a109077a67ec401ce3eff060b8dc66..d4469ed76fc506c1bd3197522a95e765dfbdfc2b 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -41,7 +41,7 @@ sharing a history directory count once. When your app and server support different providers, usage totals may cover only the providers your app understands. Update the app to include newly supported providers. -On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All +On web and desktop, use the environment dropdown to filter costs and tokens. All environments are selected by default. The dropdown shows which environments are still scanning; results appear as each one responds, and figures still updating are dimmed. Cursor shows its last saved totals first, then updates them when Cursor's API responds. @@ -78,9 +78,12 @@ the dialog. ## Track subscription limits -**Usage → Limits** pools every subscription account it can see per provider, so with several Codex -or Claude accounts across your environments and hubs you read one number per window rather than a -list. Each window card shows how much of the pool is left and a bar with one segment per account, +**On web and desktop**, hover the sidebar Usage button to inspect subscription +limits, or focus it and press Arrow Down. +Click it to open costs and tokens. Right-click it for usage reset settings and provider configuration. +**On mobile**, open **Usage → Limits**. The detailed limits view pools subscription accounts per +provider across your environments and hubs. Each window card shows how much of the pool is left +and a bar with one segment per account, kept in the same column across windows. Accounts are ordered by their 5-hour reset, soonest first, or by the first available window when no account reports a 5-hour limit. A gap means the 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 the bar show each account's quota, countdown, and credits. Tap a row to open its details. The same account signed in on more than one environment, or reported by a hub as well, counts once. -Filter with the environment dropdown to see what a single machine has. +On mobile, filter with the environment dropdown to see what a single machine has. -Opening Limits checks the selected connected environments automatically. Each client waits at +Opening the detailed limits view checks connected environments automatically. Each client waits at least five minutes between automatic checks of an environment, including after a failed check. -If a window still looks stale, refresh Limits to re-check every provider and hub. +If a window still looks stale, refresh the limits view to re-check every provider and hub. Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the current model's limits without leaving the conversation. The result opens above the composer and -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 -anything. The command is offered only for providers that appear under **Usage → Limits**. +closes when you dismiss it or send your next message. It uses the same limits snapshot, so it does +not run the agent or refresh anything. The command is offered only for providers reporting limits. OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in the environment. T3 cannot report limits for external OpenCode servers because their credentials @@ -126,7 +129,7 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`. To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the environment that will connect to the hub and enter its URL and management key. -The accounts appear under **Usage → Limits**. Codex accounts show banked reset credits; select an +The accounts appear in the detailed limits view. Codex accounts show banked reset credits; select an account and choose **Use reset** to redeem one. No hub plugin is required. This connection supplies usage information; configure @@ -143,8 +146,8 @@ Session, Weekly, or both for each provider. Reopen T3 to refresh expired reading ## Keyboard shortcuts On web and desktop, open Usage from the command palette. While on Usage, -press `C`, `T`, or `L` for Cost, Tokens, or Limits while not typing in a field. +press `C` or `T` for Cost or Tokens while not typing in a field. Use `Ctrl+Shift+1/2/3/4` (`Cmd+Shift+1/2/3/4` on macOS) for the past -24 hours, 7 days, 30 days, or 90 days. Period shortcuts do nothing on Limits. +24 hours, 7 days, 30 days, or 90 days. Press `Escape` to return to the previous page. Customize these shortcuts in **Settings → Keybindings**. diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index afadd35b6a1af21724499ad4e9027fb583116746..262047d38bb1fe933bc346adcac0a7e1bdb03099 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -202,7 +202,6 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ })), { key: "c", command: "usage.cost", when: "usagePageOpen" }, { key: "t", command: "usage.tokens", when: "usagePageOpen" }, - { key: "l", command: "usage.limits", when: "usagePageOpen" }, { key: "mod+shift+1", command: "usage.period.day", when: "usagePageOpen" }, { key: "mod+shift+2", command: "usage.period.week", when: "usagePageOpen" }, { key: "mod+shift+3", command: "usage.period.month", when: "usagePageOpen" }, diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 657b84fd73141e997fdfc0c11707a0c8cbd34fc8..77ad9e1bbb90b97ab3854a549979b183cded631a 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -161,6 +161,49 @@ describe("pools", () => { }; const laptop = { entry: { target: { label: "Laptop" } } }; + it("can retain failed profiles without changing the default pooled accounts", () => { + const failed = provider({ + usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, + }); + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [failed] } }], + ]); + + expect(collectLimitAccounts(input)).toEqual([]); + expect(collectLimitAccounts(input, { includeUnavailable: true })).toMatchObject([ + { key: "env-a:codex", limits: failed.usageLimits }, + ]); + }); + + it.each([false, true])( + "prefers usable quotas to a newer failure, failure first: %s", + (failedFirst) => { + const healthy = provider({ + auth: { status: "authenticated", email: "person@example.com" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const failed = { + ...healthy, + usageLimits: { + checkedAt: "2026-09-03T12:00:00.000Z", + windows: [], + unavailable: { reason: "probeFailed" as const }, + }, + }; + const providers = failedFirst ? [failed, healthy] : [healthy, failed]; + const input = new Map( + providers.map((provider, index) => [ + EnvironmentId.make(`env-${index}`), + { ...laptop, serverConfig: { providers: [provider] } }, + ]), + ); + + const accounts = collectLimitAccounts(input, { includeUnavailable: true }); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.limits).toEqual(healthy.usageLimits); + }, + ); + it("merges one account reported natively on two environments and by a hub into one entry", () => { const native = provider({ driver: claude, diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index dd24b20c7e76958ec5bc48cde0e646f5ce018483..36616b0eee5a0ae6e51d672abe64c1785e50976b 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -52,6 +52,13 @@ export function cursorUsageWindowDetails(id: string) { return CURSOR_USAGE_WINDOWS.find((window) => window.id === id); } +export function isUsageLimitWindowVisible( + driver: ServerProvider["driver"], + window: Pick, +) { + return driver !== "claudeAgent" || !/fable/i.test(`${window.id} ${window.label}`); +} + function cursorUsageWindowRank(id: string): number { const rank = CURSOR_USAGE_WINDOWS.findIndex((window) => window.id === id); return rank < 0 ? CURSOR_USAGE_WINDOWS.length : rank; @@ -74,16 +81,15 @@ export function providersWithLimits( ); } -export type LimitPresentations = ReadonlyMap< - EnvironmentId, - { - readonly entry: { readonly target: { readonly label: string } }; - readonly serverConfig: { - readonly providers?: readonly ServerProvider[] | undefined; - readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined; - } | null; - } ->; +export interface LimitPresentation { + readonly entry: { readonly target: { readonly label: string } }; + readonly serverConfig: { + readonly providers?: readonly ServerProvider[] | undefined; + readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined; + } | null; +} + +export type LimitPresentations = ReadonlyMap; /** One destination per service, even when several accounts or environments use it. */ export function collectExternalUsageLinks(presentations: LimitPresentations) { @@ -196,10 +202,13 @@ export interface LimitAccount { /** * Every account with usable windows across the connected environments, one - * entry per distinct account. The freshest reads supply windows and credits; - * native instances supply names and environment labels. + * entry per distinct account. The freshest usable reads supply windows and + * credits; native instances supply names and environment labels. */ -export function collectLimitAccounts(presentations: LimitPresentations): readonly LimitAccount[] { +export function collectLimitAccounts( + presentations: LimitPresentations, + options?: { readonly includeUnavailable: boolean }, +): readonly LimitAccount[] { const accounts = new Map(); const creditSources = new Map(); const hubRedeems = new Map(); @@ -230,7 +239,12 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl accounts.set(key, next); return; } - const fresher = Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt); + const previousUsable = limitsNotice(previous.limits) === null; + const nextUsable = limitsNotice(next.limits) === null; + const fresher = + previousUsable === nextUsable + ? Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt) + : nextUsable; // Two instances on one machine sharing an account still name it once. const environments = [ ...previous.environments, @@ -272,8 +286,19 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl ); for (const [environmentId, presentation] of presentations) { const label = presentation.entry.target.label; - for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { - if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; + const providers = options?.includeUnavailable + ? (presentation.serverConfig?.providers ?? []).filter( + (provider) => + provider.enabled && provider.installed && provider.usageLimits !== undefined, + ) + : providersWithLimits(presentation.serverConfig?.providers ?? []); + for (const provider of providers) { + if ( + !provider.usageLimits || + provider.usageLimits.unavailable?.reason === "unsupported" || + (!options?.includeUnavailable && limitsNotice(provider.usageLimits) !== null) + ) + continue; merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, { key: `${environmentId}:${provider.instanceId}`, driver: provider.driver, @@ -298,7 +323,11 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl ? `${presentation.entry.target.label} · ${source.label}` : source.label; for (const account of source.accounts) { - if (limitsNotice(account.usageLimits) !== null) continue; + if ( + account.usageLimits.unavailable?.reason === "unsupported" || + (!options?.includeUnavailable && limitsNotice(account.usageLimits) !== null) + ) + continue; const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`; merge(resolveKey(account) ?? key, { key, -- 2.54.0