authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-16 01:46:42+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 15:46:42-07:00
log87a12b53fdff7e2e0318af3edea54005557cea56
tree7b474403dbec666f87da5f3528f101774ac56285
parentf4600d77dd7c2fa9f10e8f4500882e427fcc7e26
signature Signed by PGP key B5690EEEBB952194

fix(usage): refresh limits when the tab opens (#11928)

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

8 files changed, 379 insertions(+), 35 deletions(-)

apps/mobile/src/features/usage/UsageLimitsSection.test.ts created+117
...@@ -0,0 +1,117 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { beforeEach, expect, it, vi } from "vite-plus/test";
3
4const state = vi.hoisted(() => ({
5 values: [] as unknown[],
6 cursor: 0,
7 presentations: new Map(),
8 refreshProviders: vi.fn(),
9 autoRefresh: async () => {},
10 refreshingRef: { current: false },
11}));
12vi.mock("react", () => ({
13 useState: (initial: unknown) => {
14 const index = state.cursor++;
15 if (!(index in state.values)) {
16 state.values[index] = typeof initial === "function" ? initial() : initial;
17 }
18 return [
19 state.values[index],
20 (next: unknown) => {
21 state.values[index] = typeof next === "function" ? next(state.values[index]) : next;
22 },
23 ];
24 },
25 useRef: () => state.refreshingRef,
26 useEffect: () => {},
27 useEffectEvent: (callback: () => Promise<void>) => {
28 state.autoRefresh = callback;
29 return callback;
30 },
31}));
32vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations }));
33vi.mock("react-native", () => ({ Alert: {}, Pressable: "button", View: "div" }));
34vi.mock("../../components/AppText", () => ({ AppText: "span" }));
35vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null }));
36vi.mock("./usageProviders", () => ({ useProviderColors: () => ({}) }));
37vi.mock("../../state/presentation", () => ({
38 environmentPresentations: { presentationsAtom: null },
39}));
40vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } }));
41vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders }));
42
43import { useRefreshLimits } from "./UsageLimitsSection";
44import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";
45
46beforeEach(() => {
47 state.values = [];
48 state.cursor = 0;
49 state.refreshingRef.current = false;
50 state.refreshProviders.mockReset();
51});
52
53it("keeps a newer environment failure when an older refresh finishes", async () => {
54 const a = EnvironmentId.make("mobile-limits-a");
55 const b = EnvironmentId.make("mobile-limits-b");
56 const pending = Promise.withResolvers<{ _tag: string }>();
57 const read = () => {
58 state.cursor = 0;
59 return useRefreshLimits();
60 };
61 const presentation = (label: string) => ({
62 connection: { phase: "connected" },
63 entry: { target: { label } },
64 });
65 state.presentations = new Map([[a, presentation("A")]]);
66 state.refreshProviders.mockImplementation(({ environmentId }) =>
67 environmentId === a ? pending.promise : Promise.resolve({ _tag: "Failure" }),
68 );
69 const first = read().refresh();
70 state.presentations = new Map([
71 [a, presentation("A")],
72 [b, presentation("B")],
73 ]);
74 read();
75 await state.autoRefresh();
76 expect(read().failedLabels).toEqual(["B"]);
77 pending.resolve({ _tag: "Success" });
78 await first;
79 expect(read().failedLabels).toEqual(["B"]);
80});
81
82it("does not let an older multi-environment batch clear a newer failure for the same environment", async () => {
83 const a = EnvironmentId.make("mobile-limits-race-a");
84 const b = EnvironmentId.make("mobile-limits-race-b");
85 const aFirst = Promise.withResolvers<{ _tag: string }>();
86 const bFirst = Promise.withResolvers<{ _tag: string }>();
87 const read = (selected: ReadonlySet<EnvironmentId> | null = null) => {
88 state.cursor = 0;
89 return useRefreshLimits(selected);
90 };
91 const presentation = (label: string) => ({
92 connection: { phase: "connected" },
93 entry: { target: { label } },
94 });
95 state.presentations = new Map([
96 [a, presentation("A")],
97 [b, presentation("B")],
98 ]);
99 let aCalls = 0;
100 state.refreshProviders.mockImplementation(({ environmentId }) =>
101 environmentId === b
102 ? bFirst.promise
103 : ++aCalls === 1
104 ? aFirst.promise
105 : Promise.resolve({ _tag: "Failure" }),
106 );
107 read();
108 const older = state.autoRefresh();
109 aFirst.resolve({ _tag: "Success" });
110 await refreshUsageLimits(a, () => aFirst.promise);
111 const selected = new Set([a]);
112 await read(selected).refresh();
113 expect(read(selected).failedLabels).toEqual(["A"]);
114 bFirst.resolve({ _tag: "Success" });
115 await older;
116 expect(read(selected).failedLabels).toEqual(["A"]);
117});
apps/mobile/src/features/usage/UsageLimitsSection.tsx+49-16
...@@ -16,7 +16,8 @@ import {...@@ -16,7 +16,8 @@ import {
16 paceOf,16 paceOf,
17 remainingPercent,17 remainingPercent,
18} from "@t3tools/shared/usageLimits";18} from "@t3tools/shared/usageLimits";
19import { type ReactNode, useState } from "react";19import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
20import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";
20import { Alert, Pressable, View } from "react-native";21import { Alert, Pressable, View } from "react-native";
2122
22import { AppText as Text } from "../../components/AppText";23import { AppText as Text } from "../../components/AppText";
...@@ -279,47 +280,79 @@ export function ResetCredits(props: {...@@ -279,47 +280,79 @@ export function ResetCredits(props: {
279 * Environments whose probe failed are named, since their rows keep showing280 * Environments whose probe failed are named, since their rows keep showing
280 * the previous quota with nothing else to say so.281 * the previous quota with nothing else to say so.
281 */282 */
282export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null) {283export function useRefreshLimits(
284 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
285 active = false,
286) {
283 const presentations = useAtomValue(environmentPresentations.presentationsAtom);287 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
284 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {288 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
285 reportFailure: false,289 reportFailure: false,
286 });290 });
287 const [now, setNow] = useState(() => Date.now());291 const [now, setNow] = useState(() => Date.now());
288 const [refreshing, setRefreshing] = useState(false);292 const [refreshing, setRefreshing] = useState(false);
293 const refreshingRef = useRef(false);
289 const [failedEnvironments, setFailedEnvironments] = useState<294 const [failedEnvironments, setFailedEnvironments] = useState<
290 readonly { environmentId: EnvironmentId; label: string }[]295 readonly { environmentId: EnvironmentId; label: string }[]
291 >([]);296 >([]);
292 // Always toggles `refreshing`, even with nothing to probe: Android's297 const refresh = async (automatic = false) => {
293 // RefreshControl keeps its spinner up until it sees true then false.
294 const refresh = async () => {
295 const connected = [...presentations].filter(298 const connected = [...presentations].filter(
296 ([environmentId, presentation]) =>299 ([environmentId, presentation]) =>
297 presentation.connection.phase === "connected" &&300 presentation.connection.phase === "connected" &&
298 (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)),301 (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)),
299 );302 );
300 setRefreshing(true);
301 try {303 try {
302 const results = await Promise.all(304 await Promise.all(
303 connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })),305 connected.map(async ([environmentId, presentation]) => {
304 );306 const result = await refreshUsageLimits(
305 setFailedEnvironments(
306 connected
307 .filter((_, index) => results[index]?._tag === "Failure")
308 .map(([environmentId, presentation]) => ({
309 environmentId,307 environmentId,
310 label: presentation.entry.target.label,308 () => refreshProviders({ environmentId, input: {} }),
311 })),309 automatic,
310 );
311 if (result === undefined) return;
312 setFailedEnvironments((previous) => [
313 ...previous.filter((failed) => failed.environmentId !== environmentId),
314 ...(result._tag === "Failure"
315 ? [{ environmentId, label: presentation.entry.target.label }]
316 : []),
317 ]);
318 }),
312 );319 );
313 } finally {320 } finally {
314 setNow(Date.now());321 setNow(Date.now());
322 }
323 };
324 // Always toggles `refreshing`, even with nothing to probe: Android's
325 // RefreshControl keeps its spinner up until it sees true then false.
326 const refreshManually = async () => {
327 if (refreshingRef.current) return;
328 refreshingRef.current = true;
329 setRefreshing(true);
330 try {
331 await refresh();
332 } finally {
333 refreshingRef.current = false;
315 setRefreshing(false);334 setRefreshing(false);
316 }335 }
317 };336 };
337 const connectedLimitsEnvironments = [...presentations]
338 .filter(
339 ([environmentId, presentation]) =>
340 presentation.connection.phase === "connected" &&
341 (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)),
342 )
343 .map(([environmentId]) => environmentId)
344 .sort()
345 .join(",");
346 const autoRefreshLimits = useEffectEvent(() => refresh(true));
347 useEffect(() => {
348 if (active && connectedLimitsEnvironments) void autoRefreshLimits();
349 }, [active, connectedLimitsEnvironments]);
350
318 const failedLabels = failedEnvironments351 const failedLabels = failedEnvironments
319 .filter(352 .filter(
320 ({ environmentId }) =>353 ({ environmentId }) =>
321 selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId),354 selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId),
322 )355 )
323 .map(({ label }) => label);356 .map(({ label }) => label);
324 return { now, refreshing, failedLabels, refresh };357 return { now, refreshing, failedLabels, refresh: refreshManually };
325}358}
apps/mobile/src/features/usage/UsageRouteScreen.tsx+3-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";1import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
2import { type RouteProp, useNavigation, useRoute } from "@react-navigation/native";2import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";
3import {3import {
4 isCompatibleUsageContractVersion,4 isCompatibleUsageContractVersion,
5 isModelCostUnknown,5 isModelCostUnknown,
...@@ -95,7 +95,8 @@ export function UsageRouteScreen() {...@@ -95,7 +95,8 @@ export function UsageRouteScreen() {
95 window,95 window,
96 selectedEnvironmentIds,96 selectedEnvironmentIds,
97 );97 );
98 const limits = useRefreshLimits(selectedEnvironmentIds);98 const isFocused = useIsFocused();
99 const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits");
99100
100 const days = useMemo(101 const days = useMemo(
101 () => enumerateDays(window.sinceDay, window.untilDay),102 () => enumerateDays(window.sinceDay, window.untilDay),
apps/web/src/components/usage/UsagePage.refresh.test.tsx+97-4
...@@ -1,12 +1,13 @@...@@ -1,12 +1,13 @@
1import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";1import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
2import { mergeUsage } from "@t3tools/shared/usageMerge";2import { mergeUsage } from "@t3tools/shared/usageMerge";
3import { act } from "react";3import { StrictMode, act } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
66
7const state = vi.hoisted(() => ({7const state = vi.hoisted(() => ({
8 presentations: new Map(),8 presentations: new Map(),
9 refreshProviders: vi.fn(async () => undefined),9 refreshProviders: vi.fn(async () => undefined),
10 metric: "limits",
10}));11}));
11vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations }));12vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations }));
12vi.mock("../../state/presentation", () => ({13vi.mock("../../state/presentation", () => ({
...@@ -43,7 +44,7 @@ vi.mock("../../state/usage", () => ({...@@ -43,7 +44,7 @@ vi.mock("../../state/usage", () => ({
43 }),44 }),
44}));45}));
45vi.mock("./usagePagePreferences", () => ({46vi.mock("./usagePagePreferences", () => ({
46 readUsagePagePreferences: () => ({ metric: "limits", windowDays: 30 }),47 readUsagePagePreferences: () => ({ metric: state.metric, windowDays: 30 }),
47 saveUsagePagePreferences: vi.fn(),48 saveUsagePagePreferences: vi.fn(),
48}));49}));
49vi.mock("../ui/button", () => ({ Button: "button" }));50vi.mock("../ui/button", () => ({ Button: "button" }));
...@@ -83,13 +84,16 @@ vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ lab...@@ -83,13 +84,16 @@ vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ lab
83import { UsagePage } from "./UsagePage";84import { UsagePage } from "./UsagePage";
8485
85let renderer: ReactTestRenderer;86let renderer: ReactTestRenderer;
87let environmentNumber = 0;
86beforeEach(() => {88beforeEach(() => {
87 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);89 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
88 vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z"));90 vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z"));
91 environmentNumber += 1;
92 state.metric = "limits";
89 state.refreshProviders.mockClear();93 state.refreshProviders.mockClear();
90 state.presentations = new Map([94 state.presentations = new Map([
91 [95 [
92 EnvironmentId.make("test"),96 EnvironmentId.make(`test-${environmentNumber}`),
93 {97 {
94 entry: { target: { label: "Test" } },98 entry: { target: { label: "Test" } },
95 connection: { phase: "connected" },99 connection: { phase: "connected" },
...@@ -150,7 +154,10 @@ it.each([0, 1])(...@@ -150,7 +154,10 @@ it.each([0, 1])(
150 .at(buttonIndex)!154 .at(buttonIndex)!
151 .props.onClick();155 .props.onClick();
152 });156 });
153 expect(state.refreshProviders).toHaveBeenCalledWith({ environmentId: "test", input: {} });157 expect(state.refreshProviders).toHaveBeenCalledWith({
158 environmentId: `test-${environmentNumber}`,
159 input: {},
160 });
154 expect(161 expect(
155 JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),162 JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
156 ).toContain("in 1h 30m");163 ).toContain("in 1h 30m");
...@@ -175,5 +182,91 @@ it("uses the current time when returning to limits from tokens", async () => {...@@ -175,5 +182,91 @@ it("uses the current time when returning to limits from tokens", async () => {
175 expect(182 expect(
176 JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),183 JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
177 ).toContain("in 1h 0m");184 ).toContain("in 1h 0m");
185 expect(state.refreshProviders).toHaveBeenCalledTimes(2);
186});
187
188it("refreshes once on opening Limits and suppresses rapid returns and remounts", async () => {
189 state.metric = "tokens";
190 await act(() => {
191 renderer = create(
192 <StrictMode>
193 <UsagePage />
194 </StrictMode>,
195 );
196 });
178 expect(state.refreshProviders).not.toHaveBeenCalled();197 expect(state.refreshProviders).not.toHaveBeenCalled();
198 const selectMetric = (metric: string) =>
199 renderer.root
200 .findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]!
201 .props.onValueChange([metric]);
202 await act(() => selectMetric("limits"));
203 expect(state.refreshProviders).toHaveBeenCalledTimes(1);
204 await act(() => selectMetric("tokens"));
205 await act(() => selectMetric("limits"));
206 await act(() => renderer.unmount());
207 state.metric = "limits";
208 await act(() => {
209 renderer = create(
210 <StrictMode>
211 <UsagePage />
212 </StrictMode>,
213 );
214 });
215 expect(state.refreshProviders).toHaveBeenCalledTimes(1);
216 await act(() => selectMetric("tokens"));
217 vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:05:00Z"));
218 await act(() => selectMetric("limits"));
219 expect(state.refreshProviders).toHaveBeenCalledTimes(2);
220});
221
222it("waits for connection and refreshes new environments during a slow refresh", async () => {
223 const [id, presentation] = [...state.presentations][0]!;
224 state.presentations = new Map([[id, { ...presentation, connection: { phase: "disconnected" } }]]);
225 await act(() => {
226 renderer = create(<UsagePage />);
227 });
228 expect(state.refreshProviders).not.toHaveBeenCalled();
229 let finishRefresh!: () => void;
230 state.refreshProviders.mockImplementationOnce(
231 () =>
232 new Promise<undefined>((resolve) => {
233 finishRefresh = () => resolve(undefined);
234 }),
235 );
236 state.presentations = new Map([[id, presentation]]);
237 await act(() => renderer.update(<UsagePage />));
238 expect(state.refreshProviders).toHaveBeenCalledTimes(1);
239 const nextId = EnvironmentId.make(`${id}-next`);
240 state.presentations = new Map([...state.presentations, [nextId, presentation]]);
241 await act(() => renderer.update(<UsagePage />));
242 expect(state.refreshProviders).toHaveBeenCalledTimes(2);
243 expect(state.refreshProviders).toHaveBeenLastCalledWith({ environmentId: nextId, input: {} });
244 await act(() => finishRefresh());
245});
246
247it("keeps manual refresh busy until the already-running automatic check settles", async () => {
248 let finishRefresh!: () => void;
249 const pending = new Promise<undefined>((resolve) => {
250 finishRefresh = () => resolve(undefined);
251 });
252 state.refreshProviders.mockImplementationOnce(() => pending);
253 await act(() => {
254 renderer = create(<UsagePage />);
255 });
256 const button = () =>
257 renderer.root.findAll(
258 (node) => node.type === "button" && node.props["aria-label"] === "Refresh limits",
259 )[0]!;
260 expect(state.refreshProviders).toHaveBeenCalledTimes(1);
261 await act(() => button().props.onClick());
262 try {
263 expect(button().props["aria-busy"]).toBe(true);
264 expect(state.refreshProviders).toHaveBeenCalledTimes(1);
265 } finally {
266 await act(async () => {
267 finishRefresh();
268 await pending;
269 });
270 }
271 expect(button().props["aria-busy"]).toBe(false);
179});272});
apps/web/src/components/usage/UsagePage.tsx+38-10
...@@ -11,7 +11,8 @@ import {...@@ -11,7 +11,8 @@ import {
11 CircleDashedIcon,11 CircleDashedIcon,
12 SlidersHorizontalIcon,12 SlidersHorizontalIcon,
13} from "lucide-react";13} from "lucide-react";
14import { useMemo, useRef, useState } from "react";14import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
15import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";
1516
16import {17import {
17 isCompatibleUsageContractVersion,18 isCompatibleUsageContractVersion,
...@@ -165,21 +166,31 @@ export function UsagePage() {...@@ -165,21 +166,31 @@ export function UsagePage() {
165 setPreferences(nextPreferences);166 setPreferences(nextPreferences);
166 saveUsagePagePreferences(nextPreferences);167 saveUsagePagePreferences(nextPreferences);
167 };168 };
169 const refreshLimits = async (automatic = false) => {
170 try {
171 await Promise.all(
172 Array.from(presentations, ([environmentId, presentation]) => {
173 if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return;
174 if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) {
175 return refreshUsageLimits(
176 environmentId,
177 () => refreshProviders({ environmentId, input: {} }),
178 automatic,
179 );
180 }
181 }),
182 );
183 } finally {
184 setLimitsNow(Date.now());
185 }
186 };
168 const refreshWindow = () => {187 const refreshWindow = () => {
169 if (refreshingRef.current) return;188 if (refreshingRef.current) return;
170189
171 if (showingLimits) {190 if (showingLimits) {
172 refreshingRef.current = true;191 refreshingRef.current = true;
173 setIsRefreshing(true);192 setIsRefreshing(true);
174 void Promise.all(193 void refreshLimits().finally(() => {
175 Array.from(presentations, ([environmentId, presentation]) => {
176 if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return;
177 if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) {
178 return refreshProviders({ environmentId, input: {} });
179 }
180 }),
181 ).finally(() => {
182 setLimitsNow(Date.now());
183 refreshingRef.current = false;194 refreshingRef.current = false;
184 setIsRefreshing(false);195 setIsRefreshing(false);
185 });196 });
...@@ -201,6 +212,23 @@ export function UsagePage() {...@@ -201,6 +212,23 @@ export function UsagePage() {
201 setIsRefreshing(false);212 setIsRefreshing(false);
202 });213 });
203 };214 };
215 const connectedLimitsEnvironments = [...presentations]
216 .filter(
217 ([environmentId, presentation]) =>
218 presentation.connection.phase === "connected" &&
219 presentation.serverConfig !== null &&
220 (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)),
221 )
222 .map(([environmentId]) => environmentId)
223 .sort()
224 .join(",");
225 const autoRefreshLimits = useEffectEvent(() => {
226 void refreshLimits(true);
227 });
228 useEffect(() => {
229 if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits();
230 }, [showingLimits, connectedLimitsEnvironments]);
231
204 const windowLabel =232 const windowLabel =
205 isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined233 isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
206 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`234 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
docs/user/usage.md+3-1
...@@ -60,7 +60,9 @@ the bar show each account's quota, countdown, and credits. Tap a row to open its...@@ -60,7 +60,9 @@ the bar show each account's quota, countdown, and credits. Tap a row to open its
60The same account signed in on more than one environment, or reported by a hub as well, counts once.60The same account signed in on more than one environment, or reported by a hub as well, counts once.
61Filter with the environment dropdown to see what a single machine has.61Filter with the environment dropdown to see what a single machine has.
6262
63If a window looks stale, refresh Limits to re-check every provider and hub.63Opening Limits checks the selected connected environments automatically. Each client waits at
64least five minutes between automatic checks of an environment, including after a failed check.
65If a window still looks stale, refresh Limits to re-check every provider and hub.
6466
65Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the67Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the
66current model's limits without leaving the conversation. The result opens above the composer and68current model's limits without leaving the conversation. The result opens above the composer and
packages/client-runtime/src/state/usage.test.ts+45-2
...@@ -6,11 +6,11 @@ import {...@@ -6,11 +6,11 @@ import {
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";8import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
9import { afterEach, describe, expect, it } from "vite-plus/test";9import { afterEach, describe, expect, it, vi } from "vite-plus/test";
1010
11import type { EnvironmentPresentation } from "../connection/presentation.ts";11import type { EnvironmentPresentation } from "../connection/presentation.ts";
12import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";12import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";
13import { refreshUsage } from "./usage.ts";13import { refreshUsage, refreshUsageLimits } from "./usage.ts";
1414
15const input = {15const input = {
16 sinceDay: UsageDay.make("2026-09-05"),16 sinceDay: UsageDay.make("2026-09-05"),
...@@ -182,3 +182,46 @@ describe("manual usage refresh", () => {...@@ -182,3 +182,46 @@ describe("manual usage refresh", () => {
182 unmount();182 unmount();
183 });183 });
184});184});
185
186describe("limits refresh cooldown", () => {
187 it("joins manual calls and gates automatic refreshes after success or failure", async () => {
188 const clock = vi.spyOn(Date, "now").mockReturnValue(1_000);
189 try {
190 for (const fails of [false, true]) {
191 const id = EnvironmentId.make(`limits-${fails}`);
192 const pending = Promise.withResolvers<string>();
193 const refresh = vi.fn(() => pending.promise);
194 const first = refreshUsageLimits(id, refresh, true);
195 await refreshUsageLimits(id, refresh, true);
196 const manual = refreshUsageLimits(id, refresh);
197 const settled = vi.fn();
198 void manual.then(settled, settled);
199 expect(settled).not.toHaveBeenCalled();
200 expect(refresh).toHaveBeenCalledTimes(1);
201 if (fails) {
202 const firstFailure = expect(first).rejects.toThrow("unavailable");
203 const manualFailure = expect(manual).rejects.toThrow("unavailable");
204 pending.reject(new Error("unavailable"));
205 await Promise.all([firstFailure, manualFailure]);
206 } else {
207 pending.resolve("quota");
208 expect(await first).toBe("quota");
209 expect(await manual).toBe("quota");
210 }
211 expect(settled).toHaveBeenCalledTimes(1);
212 const next = vi.fn(async () => undefined);
213 clock.mockReturnValue(300_999);
214 await refreshUsageLimits(id, next, true);
215 expect(next).not.toHaveBeenCalled();
216 clock.mockReturnValue(301_000);
217 await refreshUsageLimits(id, next, true);
218 expect(next).toHaveBeenCalledTimes(1);
219 await refreshUsageLimits(id, next);
220 expect(next).toHaveBeenCalledTimes(2);
221 clock.mockReturnValue(1_000);
222 }
223 } finally {
224 clock.mockRestore();
225 }
226 });
227});
packages/client-runtime/src/state/usage.ts+27
...@@ -9,6 +9,33 @@ import type { createServerEnvironmentAtoms } from "./server.ts";...@@ -9,6 +9,33 @@ import type { createServerEnvironmentAtoms } from "./server.ts";
99
10const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError);10const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError);
1111
12const limitsRefreshAfter = new Map<EnvironmentId, number>();
13const limitsRefreshes = new Map<EnvironmentId, Promise<unknown>>();
14
15export async function refreshUsageLimits<A>(
16 environmentId: EnvironmentId,
17 refresh: () => Promise<A>,
18 automatic = false,
19): Promise<A | undefined> {
20 const pending = limitsRefreshes.get(environmentId);
21 if (pending !== undefined) {
22 // Manual refresh waits for the current check; automatic refresh does not repeat it.
23 return automatic ? undefined : ((await pending) as A);
24 }
25 const refreshAfter = limitsRefreshAfter.get(environmentId) ?? 0;
26 // @effect-diagnostics-next-line globalDate:off
27 if (automatic && Date.now() < refreshAfter) return;
28 const current = Promise.resolve()
29 .then(refresh)
30 .finally(() => {
31 limitsRefreshes.delete(environmentId);
32 // @effect-diagnostics-next-line globalDate:off
33 limitsRefreshAfter.set(environmentId, Date.now() + 5 * 60_000);
34 });
35 limitsRefreshes.set(environmentId, current);
36 return await current;
37}
38
12/** Refresh pricing, then await each selected environment's rescan while it remains connected. */39/** Refresh pricing, then await each selected environment's rescan while it remains connected. */
13export async function refreshUsage({40export async function refreshUsage({
14 registry,41 registry,