diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.test.ts b/apps/mobile/src/features/usage/UsageLimitsSection.test.ts new file mode 100644 index 000000000000..f8d769355f77 --- /dev/null +++ b/apps/mobile/src/features/usage/UsageLimitsSection.test.ts @@ -0,0 +1,117 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + values: [] as unknown[], + cursor: 0, + presentations: new Map(), + refreshProviders: vi.fn(), + autoRefresh: async () => {}, + refreshingRef: { current: false }, +})); +vi.mock("react", () => ({ + useState: (initial: unknown) => { + const index = state.cursor++; + if (!(index in state.values)) { + state.values[index] = typeof initial === "function" ? initial() : initial; + } + return [ + state.values[index], + (next: unknown) => { + state.values[index] = typeof next === "function" ? next(state.values[index]) : next; + }, + ]; + }, + useRef: () => state.refreshingRef, + useEffect: () => {}, + useEffectEvent: (callback: () => Promise) => { + state.autoRefresh = callback; + return callback; + }, +})); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); +vi.mock("react-native", () => ({ Alert: {}, Pressable: "button", View: "div" })); +vi.mock("../../components/AppText", () => ({ AppText: "span" })); +vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null })); +vi.mock("./usageProviders", () => ({ useProviderColors: () => ({}) })); +vi.mock("../../state/presentation", () => ({ + environmentPresentations: { presentationsAtom: null }, +})); +vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders })); + +import { useRefreshLimits } from "./UsageLimitsSection"; +import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; + +beforeEach(() => { + state.values = []; + state.cursor = 0; + state.refreshingRef.current = false; + state.refreshProviders.mockReset(); +}); + +it("keeps a newer environment failure when an older refresh finishes", async () => { + const a = EnvironmentId.make("mobile-limits-a"); + const b = EnvironmentId.make("mobile-limits-b"); + const pending = Promise.withResolvers<{ _tag: string }>(); + const read = () => { + state.cursor = 0; + return useRefreshLimits(); + }; + const presentation = (label: string) => ({ + connection: { phase: "connected" }, + entry: { target: { label } }, + }); + state.presentations = new Map([[a, presentation("A")]]); + state.refreshProviders.mockImplementation(({ environmentId }) => + environmentId === a ? pending.promise : Promise.resolve({ _tag: "Failure" }), + ); + const first = read().refresh(); + state.presentations = new Map([ + [a, presentation("A")], + [b, presentation("B")], + ]); + read(); + await state.autoRefresh(); + expect(read().failedLabels).toEqual(["B"]); + pending.resolve({ _tag: "Success" }); + await first; + expect(read().failedLabels).toEqual(["B"]); +}); + +it("does not let an older multi-environment batch clear a newer failure for the same environment", async () => { + const a = EnvironmentId.make("mobile-limits-race-a"); + const b = EnvironmentId.make("mobile-limits-race-b"); + const aFirst = Promise.withResolvers<{ _tag: string }>(); + const bFirst = Promise.withResolvers<{ _tag: string }>(); + const read = (selected: ReadonlySet | null = null) => { + state.cursor = 0; + return useRefreshLimits(selected); + }; + const presentation = (label: string) => ({ + connection: { phase: "connected" }, + entry: { target: { label } }, + }); + state.presentations = new Map([ + [a, presentation("A")], + [b, presentation("B")], + ]); + let aCalls = 0; + state.refreshProviders.mockImplementation(({ environmentId }) => + environmentId === b + ? bFirst.promise + : ++aCalls === 1 + ? aFirst.promise + : Promise.resolve({ _tag: "Failure" }), + ); + read(); + const older = state.autoRefresh(); + aFirst.resolve({ _tag: "Success" }); + await refreshUsageLimits(a, () => aFirst.promise); + const selected = new Set([a]); + await read(selected).refresh(); + expect(read(selected).failedLabels).toEqual(["A"]); + bFirst.resolve({ _tag: "Success" }); + await older; + expect(read(selected).failedLabels).toEqual(["A"]); +}); diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index e7afe114a566..5383602869bb 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -16,7 +16,8 @@ import { paceOf, remainingPercent, } from "@t3tools/shared/usageLimits"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; +import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; import { Alert, Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; @@ -279,47 +280,79 @@ export function ResetCredits(props: { * Environments whose probe failed are named, since their rows keep showing * the previous quota with nothing else to say so. */ -export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet | null = null) { +export function useRefreshLimits( + selectedEnvironmentIds: ReadonlySet | null = null, + active = false, +) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); const [now, setNow] = useState(() => Date.now()); const [refreshing, setRefreshing] = useState(false); + const refreshingRef = useRef(false); const [failedEnvironments, setFailedEnvironments] = useState< readonly { environmentId: EnvironmentId; label: string }[] >([]); - // Always toggles `refreshing`, even with nothing to probe: Android's - // RefreshControl keeps its spinner up until it sees true then false. - const refresh = async () => { + const refresh = async (automatic = false) => { const connected = [...presentations].filter( ([environmentId, presentation]) => presentation.connection.phase === "connected" && (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), ); - setRefreshing(true); try { - const results = await Promise.all( - connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })), - ); - setFailedEnvironments( - connected - .filter((_, index) => results[index]?._tag === "Failure") - .map(([environmentId, presentation]) => ({ + await Promise.all( + connected.map(async ([environmentId, presentation]) => { + const result = await refreshUsageLimits( environmentId, - label: presentation.entry.target.label, - })), + () => refreshProviders({ environmentId, input: {} }), + automatic, + ); + if (result === undefined) return; + setFailedEnvironments((previous) => [ + ...previous.filter((failed) => failed.environmentId !== environmentId), + ...(result._tag === "Failure" + ? [{ environmentId, label: presentation.entry.target.label }] + : []), + ]); + }), ); } finally { setNow(Date.now()); + } + }; + // Always toggles `refreshing`, even with nothing to probe: Android's + // RefreshControl keeps its spinner up until it sees true then false. + const refreshManually = async () => { + if (refreshingRef.current) return; + refreshingRef.current = true; + setRefreshing(true); + try { + await refresh(); + } finally { + refreshingRef.current = false; setRefreshing(false); } }; + const connectedLimitsEnvironments = [...presentations] + .filter( + ([environmentId, presentation]) => + presentation.connection.phase === "connected" && + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), + ) + .map(([environmentId]) => environmentId) + .sort() + .join(","); + const autoRefreshLimits = useEffectEvent(() => refresh(true)); + useEffect(() => { + if (active && connectedLimitsEnvironments) void autoRefreshLimits(); + }, [active, connectedLimitsEnvironments]); + const failedLabels = failedEnvironments .filter( ({ environmentId }) => selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), ) .map(({ label }) => label); - return { now, refreshing, failedLabels, refresh }; + return { now, refreshing, failedLabels, refresh: refreshManually }; } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 59910c295547..6f00300f1acc 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,5 +1,5 @@ import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; -import { type RouteProp, useNavigation, useRoute } from "@react-navigation/native"; +import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; import { isCompatibleUsageContractVersion, isModelCostUnknown, @@ -95,7 +95,8 @@ export function UsageRouteScreen() { window, selectedEnvironmentIds, ); - const limits = useRefreshLimits(selectedEnvironmentIds); + const isFocused = useIsFocused(); + const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), diff --git a/apps/web/src/components/usage/UsagePage.refresh.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.test.tsx index d6d300ce35b0..f8958ab33d32 100644 --- a/apps/web/src/components/usage/UsagePage.refresh.test.tsx +++ b/apps/web/src/components/usage/UsagePage.refresh.test.tsx @@ -1,12 +1,13 @@ import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { mergeUsage } from "@t3tools/shared/usageMerge"; -import { act } from "react"; +import { StrictMode, 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), + metric: "limits", })); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); vi.mock("../../state/presentation", () => ({ @@ -43,7 +44,7 @@ vi.mock("../../state/usage", () => ({ }), })); vi.mock("./usagePagePreferences", () => ({ - readUsagePagePreferences: () => ({ metric: "limits", windowDays: 30 }), + readUsagePagePreferences: () => ({ metric: state.metric, windowDays: 30 }), saveUsagePagePreferences: vi.fn(), })); vi.mock("../ui/button", () => ({ Button: "button" })); @@ -83,13 +84,16 @@ vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ lab import { UsagePage } from "./UsagePage"; let renderer: ReactTestRenderer; +let environmentNumber = 0; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z")); + environmentNumber += 1; + state.metric = "limits"; state.refreshProviders.mockClear(); state.presentations = new Map([ [ - EnvironmentId.make("test"), + EnvironmentId.make(`test-${environmentNumber}`), { entry: { target: { label: "Test" } }, connection: { phase: "connected" }, @@ -150,7 +154,10 @@ it.each([0, 1])( .at(buttonIndex)! .props.onClick(); }); - expect(state.refreshProviders).toHaveBeenCalledWith({ environmentId: "test", input: {} }); + expect(state.refreshProviders).toHaveBeenCalledWith({ + environmentId: `test-${environmentNumber}`, + input: {}, + }); expect( JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), ).toContain("in 1h 30m"); @@ -175,5 +182,91 @@ it("uses the current time when returning to limits from tokens", async () => { expect( JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), ).toContain("in 1h 0m"); + expect(state.refreshProviders).toHaveBeenCalledTimes(2); +}); + +it("refreshes once on opening Limits and suppresses rapid returns and remounts", async () => { + state.metric = "tokens"; + await act(() => { + renderer = create( + + + , + ); + }); expect(state.refreshProviders).not.toHaveBeenCalled(); + const selectMetric = (metric: string) => + renderer.root + .findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]! + .props.onValueChange([metric]); + await act(() => selectMetric("limits")); + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + await act(() => selectMetric("tokens")); + await act(() => selectMetric("limits")); + await act(() => renderer.unmount()); + state.metric = "limits"; + await act(() => { + renderer = create( + + + , + ); + }); + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + await act(() => selectMetric("tokens")); + vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:05:00Z")); + await act(() => selectMetric("limits")); + expect(state.refreshProviders).toHaveBeenCalledTimes(2); +}); + +it("waits for connection and refreshes new environments during a slow refresh", async () => { + const [id, presentation] = [...state.presentations][0]!; + state.presentations = new Map([[id, { ...presentation, connection: { phase: "disconnected" } }]]); + await act(() => { + renderer = create(); + }); + expect(state.refreshProviders).not.toHaveBeenCalled(); + let finishRefresh!: () => void; + state.refreshProviders.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRefresh = () => resolve(undefined); + }), + ); + state.presentations = new Map([[id, presentation]]); + await act(() => renderer.update()); + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + const nextId = EnvironmentId.make(`${id}-next`); + state.presentations = new Map([...state.presentations, [nextId, presentation]]); + await act(() => renderer.update()); + expect(state.refreshProviders).toHaveBeenCalledTimes(2); + expect(state.refreshProviders).toHaveBeenLastCalledWith({ environmentId: nextId, input: {} }); + await act(() => finishRefresh()); +}); + +it("keeps manual refresh busy until the already-running automatic check settles", async () => { + let finishRefresh!: () => void; + const pending = new Promise((resolve) => { + finishRefresh = () => resolve(undefined); + }); + state.refreshProviders.mockImplementationOnce(() => pending); + await act(() => { + renderer = create(); + }); + const button = () => + renderer.root.findAll( + (node) => node.type === "button" && node.props["aria-label"] === "Refresh limits", + )[0]!; + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + await act(() => button().props.onClick()); + try { + expect(button().props["aria-busy"]).toBe(true); + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + } finally { + await act(async () => { + finishRefresh(); + await pending; + }); + } + expect(button().props["aria-busy"]).toBe(false); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 2da7414d9337..95fee0e4b52a 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -11,7 +11,8 @@ import { CircleDashedIcon, SlidersHorizontalIcon, } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; import { isCompatibleUsageContractVersion, @@ -165,21 +166,31 @@ export function UsagePage() { setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); }; + const refreshLimits = async (automatic = false) => { + try { + await Promise.all( + Array.from(presentations, ([environmentId, presentation]) => { + if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; + if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { + return refreshUsageLimits( + environmentId, + () => refreshProviders({ environmentId, input: {} }), + automatic, + ); + } + }), + ); + } finally { + setLimitsNow(Date.now()); + } + }; const refreshWindow = () => { if (refreshingRef.current) return; if (showingLimits) { refreshingRef.current = true; setIsRefreshing(true); - void Promise.all( - Array.from(presentations, ([environmentId, presentation]) => { - if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; - if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { - return refreshProviders({ environmentId, input: {} }); - } - }), - ).finally(() => { - setLimitsNow(Date.now()); + void refreshLimits().finally(() => { refreshingRef.current = false; setIsRefreshing(false); }); @@ -201,6 +212,23 @@ export function UsagePage() { setIsRefreshing(false); }); }; + const connectedLimitsEnvironments = [...presentations] + .filter( + ([environmentId, presentation]) => + presentation.connection.phase === "connected" && + presentation.serverConfig !== null && + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), + ) + .map(([environmentId]) => environmentId) + .sort() + .join(","); + const autoRefreshLimits = useEffectEvent(() => { + void refreshLimits(true); + }); + useEffect(() => { + if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits(); + }, [showingLimits, connectedLimitsEnvironments]); + const windowLabel = isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` diff --git a/docs/user/usage.md b/docs/user/usage.md index 9b0f449ee757..dbef802509b3 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -60,7 +60,9 @@ the bar show each account's quota, countdown, and credits. Tap a row to open its The same account signed in on more than one environment, or reported by a hub as well, counts once. Filter with the environment dropdown to see what a single machine has. -If a window looks stale, refresh Limits to re-check every provider and hub. +Opening Limits checks the selected connected environments automatically. Each client waits at +least five minutes between automatic checks of an environment, including after a failed check. +If a window still looks stale, refresh Limits to re-check every provider and hub. Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the current model's limits without leaving the conversation. The result opens above the composer and diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 29f029c9d863..98eb46e13ca8 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -6,11 +6,11 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentPresentation } from "../connection/presentation.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; -import { refreshUsage } from "./usage.ts"; +import { refreshUsage, refreshUsageLimits } from "./usage.ts"; const input = { sinceDay: UsageDay.make("2026-09-05"), @@ -182,3 +182,46 @@ describe("manual usage refresh", () => { unmount(); }); }); + +describe("limits refresh cooldown", () => { + it("joins manual calls and gates automatic refreshes after success or failure", async () => { + const clock = vi.spyOn(Date, "now").mockReturnValue(1_000); + try { + for (const fails of [false, true]) { + const id = EnvironmentId.make(`limits-${fails}`); + const pending = Promise.withResolvers(); + const refresh = vi.fn(() => pending.promise); + const first = refreshUsageLimits(id, refresh, true); + await refreshUsageLimits(id, refresh, true); + const manual = refreshUsageLimits(id, refresh); + const settled = vi.fn(); + void manual.then(settled, settled); + expect(settled).not.toHaveBeenCalled(); + expect(refresh).toHaveBeenCalledTimes(1); + if (fails) { + const firstFailure = expect(first).rejects.toThrow("unavailable"); + const manualFailure = expect(manual).rejects.toThrow("unavailable"); + pending.reject(new Error("unavailable")); + await Promise.all([firstFailure, manualFailure]); + } else { + pending.resolve("quota"); + expect(await first).toBe("quota"); + expect(await manual).toBe("quota"); + } + expect(settled).toHaveBeenCalledTimes(1); + const next = vi.fn(async () => undefined); + clock.mockReturnValue(300_999); + await refreshUsageLimits(id, next, true); + expect(next).not.toHaveBeenCalled(); + clock.mockReturnValue(301_000); + await refreshUsageLimits(id, next, true); + expect(next).toHaveBeenCalledTimes(1); + await refreshUsageLimits(id, next); + expect(next).toHaveBeenCalledTimes(2); + clock.mockReturnValue(1_000); + } + } finally { + clock.mockRestore(); + } + }); +}); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 10a565a0c24f..8a2b1a44a951 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -9,6 +9,33 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); +const limitsRefreshAfter = new Map(); +const limitsRefreshes = new Map>(); + +export async function refreshUsageLimits( + environmentId: EnvironmentId, + refresh: () => Promise, + automatic = false, +): Promise { + const pending = limitsRefreshes.get(environmentId); + if (pending !== undefined) { + // Manual refresh waits for the current check; automatic refresh does not repeat it. + return automatic ? undefined : ((await pending) as A); + } + const refreshAfter = limitsRefreshAfter.get(environmentId) ?? 0; + // @effect-diagnostics-next-line globalDate:off + if (automatic && Date.now() < refreshAfter) return; + const current = Promise.resolve() + .then(refresh) + .finally(() => { + limitsRefreshes.delete(environmentId); + // @effect-diagnostics-next-line globalDate:off + limitsRefreshAfter.set(environmentId, Date.now() + 5 * 60_000); + }); + limitsRefreshes.set(environmentId, current); + return await current; +} + /** Refresh pricing, then await each selected environment's rescan while it remains connected. */ export async function refreshUsage({ registry,