Repository navigation
feat(web): show provider usage limits in the sidebar #16481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
128 changes: 128 additions & 0 deletions
128
apps/web/src/components/sidebar/SidebarUsageLimits.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| // @vitest-environment jsdom | ||
| import { | ||
| EnvironmentId, | ||
| ProviderDriverKind, | ||
| ProviderInstanceId, | ||
| type ServerProvider, | ||
| } from "@t3tools/contracts"; | ||
| import type { LimitPresentations } from "@t3tools/shared/usageLimits"; | ||
| import { act } from "react"; | ||
| import { createRoot, type Root } from "react-dom/client"; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
|
|
||
| const testState = vi.hoisted(() => ({ | ||
| presentations: new Map() as LimitPresentations, | ||
| navigate: vi.fn(), | ||
| setOpenMobile: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock("@effect/atom-react", () => ({ useAtomValue: () => testState.presentations })); | ||
| vi.mock("../../state/presentation", () => ({ | ||
| environmentPresentations: { presentationsAtom: {} }, | ||
| })); | ||
| vi.mock("../../hooks/useNowMinute", () => ({ useNowMinute: () => "2026-09-03T12:00" })); | ||
| vi.mock("@tanstack/react-router", () => ({ useNavigate: () => testState.navigate })); | ||
| vi.mock("../ui/sidebar", () => ({ | ||
| useSidebar: () => ({ | ||
| isMobile: false, | ||
| open: true, | ||
| openMobile: false, | ||
| setOpenMobile: testState.setOpenMobile, | ||
| }), | ||
| })); | ||
| vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null })); | ||
| vi.mock("../usage/UsageLimits", () => ({ barColor: () => "#d97757", PaceIcon: () => null })); | ||
|
|
||
| import { SidebarUsageLimits } from "./SidebarUsageLimits"; | ||
|
|
||
| function provider(overrides: Partial<ServerProvider>): ServerProvider { | ||
| return { | ||
| instanceId: ProviderInstanceId.make("codex"), | ||
| driver: ProviderDriverKind.make("codex"), | ||
| enabled: true, | ||
| installed: true, | ||
| version: null, | ||
| status: "ready", | ||
| auth: { status: "authenticated" }, | ||
| checkedAt: "2026-09-03T11:00:00.000Z", | ||
| models: [], | ||
| slashCommands: [], | ||
| skills: [], | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| const codex = provider({ | ||
| usageLimits: { | ||
| checkedAt: "2026-09-03T11:00:00.000Z", | ||
| windows: [ | ||
| { | ||
| id: "five_hour", | ||
| kind: "session", | ||
| label: "Session", | ||
| usedPercent: 38, | ||
| windowDurationMins: 300, | ||
| resetsAt: "2026-09-03T14:00:00.000Z", | ||
| }, | ||
| ], | ||
| }, | ||
| }); | ||
|
|
||
| function presentationsFor(providers: readonly ServerProvider[]): LimitPresentations { | ||
| return new Map([ | ||
| [ | ||
| EnvironmentId.make("test-environment"), | ||
| { entry: { target: { label: "Local" } }, serverConfig: { providers } }, | ||
| ], | ||
| ]); | ||
| } | ||
|
|
||
| describe("SidebarUsageLimits", () => { | ||
| let renderer: Root; | ||
| let container: HTMLDivElement; | ||
|
|
||
| beforeEach(() => { | ||
| vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| testState.navigate.mockClear(); | ||
| testState.presentations = presentationsFor([codex]); | ||
| container = document.createElement("div"); | ||
| document.body.append(container); | ||
| renderer = createRoot(container); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await act(() => renderer.unmount()); | ||
| container.remove(); | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it("shows each provider's remaining quota and opens Usage on click", async () => { | ||
| await act(() => { | ||
| renderer.render(<SidebarUsageLimits />); | ||
| }); | ||
|
|
||
| const row = container.querySelector("button[aria-label^='Codex:']"); | ||
| expect(row?.getAttribute("aria-label")).toBe("Codex: Session 62% left, resets in 2h 0m"); | ||
| expect(row?.textContent).toContain("62%"); | ||
| expect(row?.textContent).toContain("↻ 2h 0m"); | ||
|
|
||
| await act(() => { | ||
| (row as HTMLButtonElement).click(); | ||
| }); | ||
| expect(testState.navigate).toHaveBeenCalledWith({ to: "/usage" }); | ||
| }); | ||
|
|
||
| it("renders nothing when no provider reports usable limits", async () => { | ||
| testState.presentations = presentationsFor([ | ||
| provider({ | ||
| usageLimits: { checkedAt: "2026-09-03T11:00:00.000Z", windows: [] }, | ||
| }), | ||
| ]); | ||
| await act(() => { | ||
| renderer.render(<SidebarUsageLimits />); | ||
| }); | ||
|
|
||
| expect(container.querySelector("button")).toBeNull(); | ||
| expect(container.textContent).toBe(""); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import { useAtomValue } from "@effect/atom-react"; | ||
| import { | ||
| collectLimitAccounts, | ||
| collectLimitNotices, | ||
| collectLimitPools, | ||
| displayLimitWindows, | ||
| formatDuration, | ||
| type LimitPool, | ||
| type LimitPoolWindow, | ||
| } from "@t3tools/shared/usageLimits"; | ||
| import { useNavigate } from "@tanstack/react-router"; | ||
| import { TriangleAlertIcon } from "lucide-react"; | ||
| import { useCallback, useMemo } from "react"; | ||
|
|
||
| import { useNowMinute } from "../../hooks/useNowMinute"; | ||
| import { environmentPresentations } from "../../state/presentation"; | ||
| import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | ||
| import { getDriverOption } from "../settings/providerDriverMeta"; | ||
| import { useSidebar } from "../ui/sidebar"; | ||
| import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| import { PaceIcon, barColor } from "../usage/UsageLimits"; | ||
|
|
||
| /** The window that decides whether work can continue: the one with the least left. */ | ||
| function leadWindow(pool: LimitPool): LimitPoolWindow | null { | ||
| let lead: LimitPoolWindow | null = null; | ||
| for (const window of displayLimitWindows(pool)) { | ||
| if (lead === null || window.remainingPercent < lead.remainingPercent) lead = window; | ||
| } | ||
| return lead; | ||
| } | ||
|
|
||
| function SidebarUsageLimitRow({ | ||
| pool, | ||
| now, | ||
| onOpen, | ||
| }: { | ||
| readonly pool: LimitPool; | ||
| readonly now: number; | ||
| readonly onOpen: () => void; | ||
| }) { | ||
| const window = leadWindow(pool); | ||
| if (window === null) return null; | ||
| const label = getDriverOption(pool.driver)?.label ?? String(pool.driver); | ||
| const remaining = window.remainingPercent; | ||
| const resetAt = window.resets[0]?.at; | ||
| const resetShort = | ||
| resetAt === undefined ? null : resetAt <= now ? "now" : formatDuration(resetAt - now); | ||
| const resetAria = | ||
| resetAt === undefined | ||
| ? "" | ||
| : resetAt <= now | ||
| ? ", resets now" | ||
| : `, resets in ${formatDuration(resetAt - now)}`; | ||
| return ( | ||
| <button | ||
| type="button" | ||
| onClick={onOpen} | ||
| aria-label={`${label}: ${window.label} ${remaining}% left${resetAria}`} | ||
| className="relative flex h-6 w-full min-w-0 cursor-pointer items-center gap-1.5 overflow-hidden rounded-md bg-sidebar-control-surface px-2 text-2xs text-sidebar-foreground outline-hidden ring-ring hover:bg-sidebar-row-hover focus-visible:ring-2" | ||
| > | ||
| {/* Quota left, in the provider's Usage-page colour. */} | ||
| <span | ||
| aria-hidden | ||
| className="absolute inset-y-0 left-0 rounded-md opacity-35" | ||
| style={{ width: `${remaining}%`, backgroundColor: barColor(pool.driver) }} | ||
| /> | ||
| <ProviderInstanceIcon | ||
| driverKind={pool.driver} | ||
| displayName={label} | ||
| indicatorBackground="var(--sidebar)" | ||
| className="relative size-4 shrink-0" | ||
| iconClassName="size-3.5 text-foreground/80" | ||
| /> | ||
| <span className="relative min-w-0 flex-1 truncate text-left font-medium">{label}</span> | ||
| {window.pace ? ( | ||
| <span className="relative shrink-0"> | ||
| <PaceIcon pace={window.pace} /> | ||
| </span> | ||
| ) : null} | ||
| <span className="relative shrink-0 font-semibold tabular-nums">{remaining}%</span> | ||
| {resetShort ? ( | ||
| <span className="relative shrink-0 text-sidebar-muted-foreground tabular-nums"> | ||
| ↻ {resetShort} | ||
| </span> | ||
| ) : null} | ||
| </button> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Subscription quota for every provider that reports it, one compact bar per | ||
| * driver, pinned to the sidebar footer. Reads the same global presentations the | ||
| * Usage page does, so it adds no requests, and only the minute clock advances | ||
| * it. Renders nothing when there is no quota to show. | ||
| */ | ||
| export function SidebarUsageLimits() { | ||
| const presentations = useAtomValue(environmentPresentations.presentationsAtom); | ||
| const nowMinute = useNowMinute(); | ||
| const { isMobile, open, openMobile, setOpenMobile } = useSidebar(); | ||
| const navigate = useNavigate(); | ||
|
|
||
| // The minute string is UTC-quantized; pin it back to a UTC instant. | ||
| const now = Date.parse(`${nowMinute}:00.000Z`); | ||
| const { pools, notices } = useMemo(() => { | ||
| const accounts = collectLimitAccounts(presentations); | ||
| return { | ||
| pools: collectLimitPools(accounts, now), | ||
| notices: collectLimitNotices(presentations), | ||
| }; | ||
| }, [presentations, now]); | ||
|
|
||
| const openUsage = useCallback(() => { | ||
| if (isMobile) setOpenMobile(false); | ||
| void navigate({ to: "/usage" }); | ||
| }, [isMobile, navigate, setOpenMobile]); | ||
|
|
||
| // Collapsed offcanvas and the closed mobile sheet both hide the footer. | ||
| const visible = isMobile ? openMobile : open; | ||
| if (!visible || pools.length === 0) return null; | ||
|
|
||
| return ( | ||
| <div className="flex flex-col gap-1"> | ||
| {notices.length > 0 ? ( | ||
| <Tooltip> | ||
| <TooltipTrigger | ||
| render={ | ||
| <p | ||
| role="status" | ||
| className="flex min-w-0 items-center gap-1.5 px-2 text-2xs text-warning" | ||
| /> | ||
| } | ||
| > | ||
| <TriangleAlertIcon className="size-3.5 shrink-0" aria-hidden /> | ||
| <span className="truncate">Some limits could not be read</span> | ||
| </TooltipTrigger> | ||
| <TooltipPopup side="top" className="max-w-64"> | ||
| <div className="flex flex-col gap-0.5"> | ||
| {notices.map((notice) => ( | ||
| <span key={notice}>{notice}</span> | ||
| ))} | ||
| </div> | ||
| </TooltipPopup> | ||
| </Tooltip> | ||
| ) : null} | ||
| {pools.map((pool) => ( | ||
| <SidebarUsageLimitRow key={pool.driver} pool={pool} now={now} onOpen={openUsage} /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Make limit notices available without hover.
When a provider reports an error, this
<p>shows only the generic warning. The error details exist only in its tooltip. The<p>cannot receive keyboard focus, and Base UI tooltips do not work on touch devices. Keyboard and touch users cannot read the error here. Render the details inline or use a focusable, touch-operable control. (raw.githubusercontent.com)🤖 Prompt for AI Agents