diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ce2ef2629549..1dd3445fca46 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -27,6 +27,7 @@ import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPr import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; +import { SidebarUsageLimits } from "./SidebarUsageLimits"; import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; @@ -242,6 +243,7 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { + ); diff --git a/apps/web/src/components/sidebar/SidebarUsageLimits.test.tsx b/apps/web/src/components/sidebar/SidebarUsageLimits.test.tsx new file mode 100644 index 000000000000..f306308960cf --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUsageLimits.test.tsx @@ -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 { + 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(); + }); + + 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(); + }); + + expect(container.querySelector("button")).toBeNull(); + expect(container.textContent).toBe(""); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarUsageLimits.tsx b/apps/web/src/components/sidebar/SidebarUsageLimits.tsx new file mode 100644 index 000000000000..792a86aacc00 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUsageLimits.tsx @@ -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 ( + + {/* Quota left, in the provider's Usage-page colour. */} + + + {label} + {window.pace ? ( + + + + ) : null} + {remaining}% + {resetShort ? ( + + ↻ {resetShort} + + ) : null} + + ); +} + +/** + * 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 ( + + {notices.length > 0 ? ( + + + } + > + + Some limits could not be read + + + + {notices.map((notice) => ( + {notice} + ))} + + + + ) : null} + {pools.map((pool) => ( + + ))} + + ); +}