authorgravatar for 269035359+t3-code[bot]@users.noreply.github.comt3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> 2026-09-11 13:21:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 13:21:27-07:00
loge1c94f703f05b43f2935d09ed1ef882d0ee13ff8
tree855d350551bf21f312365f10cf3ab609dece1b1e
parent6e8931d75615ec020e602e97df2694b0541a6272
signature Signed by PGP key B5690EEEBB952194

fix(web): refresh usage limit countdowns without switching tabs (#11187)

Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com>

3 files changed, 187 insertions(+), 5 deletions(-)

apps/web/src/components/usage/UsageLimits.tsx+4-4
......@@ -316,17 +316,17 @@ export function ResetCredits({
316316
317317/**
318318 * Subscription quota across every connected environment's providers and hubs,
319 * pooled per provider. Countdowns anchor to render time rather than ticking: a
320 * live clock would repaint the page every minute for no decision-changing gain.
319 * pooled per provider. The page advances `now` on explicit refresh rather than
320 * ticking: a live clock would repaint the page for no decision-changing gain.
321321 */
322322export function UsageLimitsSection({
323323 selectedEnvironmentIds,
324 now,
324325}: {
325326 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
327 readonly now: number;
326328}) {
327329 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
328 // Anchored once per mount on purpose: countdowns must not tick (see above).
329 const [now] = useState(() => Date.now());
330330 const selected =
331331 selectedEnvironmentIds === null
332332 ? presentations
apps/web/src/components/usage/UsagePage.refresh.test.tsx created+179
......@@ -0,0 +1,179 @@
1import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
2import { mergeUsage } from "@t3tools/shared/usageMerge";
3import { act } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6
7const state = vi.hoisted(() => ({
8 presentations: new Map(),
9 refreshProviders: vi.fn(async () => undefined),
10}));
11vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations }));
12vi.mock("../../state/presentation", () => ({
13 environmentPresentations: { presentationsAtom: null },
14}));
15vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } }));
16vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders }));
17vi.mock("../../env", () => ({ isElectron: false }));
18vi.mock("../../hooks/useSettings", () => ({ usePrimarySettings: () => "24h" }));
19vi.mock("../../state/usage", () => ({
20 useUsage: () => ({
21 merged: mergeUsage([], USAGE_CONTRACT_VERSION),
22 environments: [
23 {
24 environmentId: EnvironmentId.make("test"),
25 label: "Test",
26 isPending: false,
27 error: null,
28 summary: null,
29 },
30 ],
31 selectedEnvironments: [
32 {
33 environmentId: EnvironmentId.make("test"),
34 label: "Test",
35 isPending: false,
36 error: null,
37 summary: null,
38 },
39 ],
40 isPending: false,
41 isPartial: false,
42 refresh: async () => undefined,
43 }),
44}));
45vi.mock("./usagePagePreferences", () => ({
46 readUsagePagePreferences: () => ({ metric: "limits", windowDays: 30 }),
47 saveUsagePagePreferences: vi.fn(),
48}));
49vi.mock("../ui/button", () => ({ Button: "button" }));
50vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" }));
51vi.mock("../ui/select", () => ({
52 Select: "select",
53 SelectItem: "option",
54 SelectPopup: "div",
55 SelectTrigger: "div",
56 SelectValue: "span",
57}));
58vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" }));
59vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" }));
60vi.mock("../ui/tooltip", () => ({ Tooltip: "div", TooltipPopup: "div", TooltipTrigger: "div" }));
61vi.mock("../ui/popover", () => ({ Popover: "div", PopoverPopup: "div", PopoverTrigger: "div" }));
62vi.mock("../ui/menu", () => ({
63 Menu: "div",
64 MenuCheckboxItem: "div",
65 MenuItem: "div",
66 MenuPopup: "div",
67 MenuSeparator: "hr",
68 MenuTrigger: "div",
69}));
70vi.mock("../WorkspaceBreadcrumb", () => ({
71 WorkspaceBreadcrumb: "div",
72 WorkspaceBreadcrumbItem: "div",
73 WorkspaceBreadcrumbSeparator: "span",
74}));
75vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" }));
76vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" }));
77vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" }));
78vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null }));
79vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null }));
80vi.mock("../settings/RedactedSensitiveText", () => ({ RedactedSensitiveText: "span" }));
81vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ label: "Codex" }) }));
82
83import { UsagePage } from "./UsagePage";
84
85let renderer: ReactTestRenderer;
86beforeEach(() => {
87 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
88 vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z"));
89 state.refreshProviders.mockClear();
90 state.presentations = new Map([
91 [
92 EnvironmentId.make("test"),
93 {
94 entry: { target: { label: "Test" } },
95 connection: { phase: "connected" },
96 serverConfig: {
97 providers: [
98 {
99 instanceId: ProviderInstanceId.make("codex"),
100 driver: "codex",
101 enabled: true,
102 installed: true,
103 version: null,
104 status: "ready",
105 auth: { status: "authenticated" },
106 checkedAt: "2026-09-11T12:00:00Z",
107 models: [],
108 slashCommands: [],
109 skills: [],
110 usageLimits: {
111 checkedAt: "2026-09-11T12:00:00Z",
112 windows: [
113 {
114 id: "five_hour",
115 kind: "session",
116 label: "Session",
117 usedPercent: 40,
118 windowDurationMins: 300,
119 resetsAt: "2026-09-11T14:00:00Z",
120 },
121 ],
122 },
123 },
124 ],
125 },
126 },
127 ],
128 ]);
129});
130afterEach(async () => {
131 await act(() => renderer?.unmount());
132 vi.restoreAllMocks();
133 vi.unstubAllGlobals();
134});
135
136it.each([0, 1])(
137 "refreshes the visible limits countdown with refresh button %i without switching tabs, even when quota is unchanged",
138 async (buttonIndex) => {
139 await act(() => {
140 renderer = create(<UsagePage />);
141 });
142 expect(
143 JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
144 ).toContain("in 2h 0m");
145 vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:30:00Z"));
146 await act(async () => {
147 renderer.root
148 .findAllByProps({ "aria-label": "Refresh limits" })
149 .filter((node) => node.type === "button")
150 .at(buttonIndex)!
151 .props.onClick();
152 });
153 expect(state.refreshProviders).toHaveBeenCalledWith({ environmentId: "test", input: {} });
154 expect(
155 JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
156 ).toContain("in 1h 30m");
157 expect(
158 JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
159 ).not.toContain("in 2h 0m");
160 },
161);
162
163it("uses the current time when returning to limits from tokens", async () => {
164 await act(() => {
165 renderer = create(<UsagePage />);
166 });
167 const selectMetric = (metric: string) => {
168 renderer.root
169 .findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]!
170 .props.onValueChange([metric]);
171 };
172 await act(() => selectMetric("tokens"));
173 vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T13:00:00Z"));
174 await act(() => selectMetric("limits"));
175 expect(
176 JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
177 ).toContain("in 1h 0m");
178 expect(state.refreshProviders).not.toHaveBeenCalled();
179});
apps/web/src/components/usage/UsagePage.tsx+4-1
......@@ -104,6 +104,7 @@ export function UsagePage() {
104104 const metric = preferences.metric;
105105 const showingLimits = metric === "limits";
106106 const [isRefreshing, setIsRefreshing] = useState(false);
107 const [limitsNow, setLimitsNow] = useState(() => Date.now());
107108 const refreshingRef = useRef(false);
108109 const [breakdown, setBreakdown] = useState<"model" | "time">("model");
109110 const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
......@@ -159,6 +160,7 @@ export function UsagePage() {
159160 });
160161 };
161162 const selectMetric = (nextMetric: UsageMetric) => {
163 if (nextMetric === "limits") setLimitsNow(Date.now());
162164 const nextPreferences = { metric: nextMetric, windowDays };
163165 setPreferences(nextPreferences);
164166 saveUsagePagePreferences(nextPreferences);
......@@ -177,6 +179,7 @@ export function UsagePage() {
177179 }
178180 }),
179181 ).finally(() => {
182 setLimitsNow(Date.now());
180183 refreshingRef.current = false;
181184 setIsRefreshing(false);
182185 });
......@@ -350,7 +353,7 @@ export function UsagePage() {
350353 : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}
351354 </p>
352355 ) : showingLimits ? (
353 <UsageLimitsSection selectedEnvironmentIds={selectedEnvironmentIds} />
356 <UsageLimitsSection selectedEnvironmentIds={selectedEnvironmentIds} now={limitsNow} />
354357 ) : isPending ? (
355358 <UsageSkeleton />
356359 ) : (