From 34d142dbe14d50c5f7a95343f15ac4392d071574 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:08:18 +0000 Subject: [PATCH] fix(web): refresh usage limit countdowns without switching tabs Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com> --- apps/web/src/components/usage/UsageLimits.tsx | 8 +- .../usage/UsagePage.refresh.test.tsx | 179 ++++++++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 5 +- 3 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/usage/UsagePage.refresh.test.tsx diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index e50547b66008..6d30d49a7e4a 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -316,17 +316,17 @@ export function ResetCredits({ /** * Subscription quota across every connected environment's providers and hubs, - * pooled per provider. Countdowns anchor to render time rather than ticking: a - * live clock would repaint the page every minute for no decision-changing gain. + * pooled per provider. The page advances `now` on explicit refresh rather than + * ticking: a live clock would repaint the page for no decision-changing gain. */ export function UsageLimitsSection({ selectedEnvironmentIds, + now, }: { readonly selectedEnvironmentIds: ReadonlySet | null; + readonly now: number; }) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); - // Anchored once per mount on purpose: countdowns must not tick (see above). - const [now] = useState(() => Date.now()); const selected = selectedEnvironmentIds === null ? presentations diff --git a/apps/web/src/components/usage/UsagePage.refresh.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.test.tsx new file mode 100644 index 000000000000..d6d300ce35b0 --- /dev/null +++ b/apps/web/src/components/usage/UsagePage.refresh.test.tsx @@ -0,0 +1,179 @@ +import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; +import { mergeUsage } from "@t3tools/shared/usageMerge"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + presentations: new Map(), + refreshProviders: vi.fn(async () => undefined), +})); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); +vi.mock("../../state/presentation", () => ({ + environmentPresentations: { presentationsAtom: null }, +})); +vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders })); +vi.mock("../../env", () => ({ isElectron: false })); +vi.mock("../../hooks/useSettings", () => ({ usePrimarySettings: () => "24h" })); +vi.mock("../../state/usage", () => ({ + useUsage: () => ({ + merged: mergeUsage([], USAGE_CONTRACT_VERSION), + environments: [ + { + environmentId: EnvironmentId.make("test"), + label: "Test", + isPending: false, + error: null, + summary: null, + }, + ], + selectedEnvironments: [ + { + environmentId: EnvironmentId.make("test"), + label: "Test", + isPending: false, + error: null, + summary: null, + }, + ], + isPending: false, + isPartial: false, + refresh: async () => undefined, + }), +})); +vi.mock("./usagePagePreferences", () => ({ + readUsagePagePreferences: () => ({ metric: "limits", windowDays: 30 }), + saveUsagePagePreferences: vi.fn(), +})); +vi.mock("../ui/button", () => ({ Button: "button" })); +vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); +vi.mock("../ui/select", () => ({ + Select: "select", + SelectItem: "option", + SelectPopup: "div", + SelectTrigger: "div", + SelectValue: "span", +})); +vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" })); +vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" })); +vi.mock("../ui/tooltip", () => ({ Tooltip: "div", TooltipPopup: "div", TooltipTrigger: "div" })); +vi.mock("../ui/popover", () => ({ Popover: "div", PopoverPopup: "div", PopoverTrigger: "div" })); +vi.mock("../ui/menu", () => ({ + Menu: "div", + MenuCheckboxItem: "div", + MenuItem: "div", + MenuPopup: "div", + MenuSeparator: "hr", + MenuTrigger: "div", +})); +vi.mock("../WorkspaceBreadcrumb", () => ({ + WorkspaceBreadcrumb: "div", + WorkspaceBreadcrumbItem: "div", + WorkspaceBreadcrumbSeparator: "span", +})); +vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); +vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); +vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); +vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); +vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null })); +vi.mock("../settings/RedactedSensitiveText", () => ({ RedactedSensitiveText: "span" })); +vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ label: "Codex" }) })); + +import { UsagePage } from "./UsagePage"; + +let renderer: ReactTestRenderer; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z")); + state.refreshProviders.mockClear(); + state.presentations = new Map([ + [ + EnvironmentId.make("test"), + { + entry: { target: { label: "Test" } }, + connection: { phase: "connected" }, + serverConfig: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-11T12:00:00Z", + models: [], + slashCommands: [], + skills: [], + usageLimits: { + checkedAt: "2026-09-11T12:00:00Z", + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 40, + windowDurationMins: 300, + resetsAt: "2026-09-11T14:00:00Z", + }, + ], + }, + }, + ], + }, + }, + ], + ]); +}); +afterEach(async () => { + await act(() => renderer?.unmount()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it.each([0, 1])( + "refreshes the visible limits countdown with refresh button %i without switching tabs, even when quota is unchanged", + async (buttonIndex) => { + await act(() => { + renderer = create(); + }); + expect( + JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), + ).toContain("in 2h 0m"); + vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:30:00Z")); + await act(async () => { + renderer.root + .findAllByProps({ "aria-label": "Refresh limits" }) + .filter((node) => node.type === "button") + .at(buttonIndex)! + .props.onClick(); + }); + expect(state.refreshProviders).toHaveBeenCalledWith({ environmentId: "test", input: {} }); + expect( + JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), + ).toContain("in 1h 30m"); + expect( + JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), + ).not.toContain("in 2h 0m"); + }, +); + +it("uses the current time when returning to limits from tokens", async () => { + await act(() => { + renderer = create(); + }); + const selectMetric = (metric: string) => { + renderer.root + .findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]! + .props.onValueChange([metric]); + }; + await act(() => selectMetric("tokens")); + vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T13:00:00Z")); + await act(() => selectMetric("limits")); + expect( + JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), + ).toContain("in 1h 0m"); + expect(state.refreshProviders).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 21970c675596..2da7414d9337 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -104,6 +104,7 @@ export function UsagePage() { const metric = preferences.metric; const showingLimits = metric === "limits"; const [isRefreshing, setIsRefreshing] = useState(false); + const [limitsNow, setLimitsNow] = useState(() => Date.now()); const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = @@ -159,6 +160,7 @@ export function UsagePage() { }); }; const selectMetric = (nextMetric: UsageMetric) => { + if (nextMetric === "limits") setLimitsNow(Date.now()); const nextPreferences = { metric: nextMetric, windowDays }; setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); @@ -177,6 +179,7 @@ export function UsagePage() { } }), ).finally(() => { + setLimitsNow(Date.now()); refreshingRef.current = false; setIsRefreshing(false); }); @@ -350,7 +353,7 @@ export function UsagePage() { : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}

) : showingLimits ? ( - + ) : isPending ? ( ) : (