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