| author | |
| committer | |
| log | 87a12b53fdff7e2e0318af3edea54005557cea56 |
| tree | 7b474403dbec666f87da5f3528f101774ac56285 |
| parent | f4600d77dd7c2fa9f10e8f4500882e427fcc7e26 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | const 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 | })); | |
| 12 | vi.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 | })); | |
| 32 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); | |
| 33 | vi.mock("react-native", () => ({ Alert: {}, Pressable: "button", View: "div" })); | |
| 34 | vi.mock("../../components/AppText", () => ({ AppText: "span" })); | |
| 35 | vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null })); | |
| 36 | vi.mock("./usageProviders", () => ({ useProviderColors: () => ({}) })); | |
| 37 | vi.mock("../../state/presentation", () => ({ | |
| 38 | environmentPresentations: { presentationsAtom: null }, | |
| 39 | })); | |
| 40 | vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } })); | |
| 41 | vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders })); | |
| 42 | ||
| 43 | import { useRefreshLimits } from "./UsageLimitsSection"; | |
| 44 | import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; | |
| 45 | ||
| 46 | beforeEach(() => { | |
| 47 | state.values = []; | |
| 48 | state.cursor = 0; | |
| 49 | state.refreshingRef.current = false; | |
| 50 | state.refreshProviders.mockReset(); | |
| 51 | }); | |
| 52 | ||
| 53 | it("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 | ||
| 82 | it("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 | 16 | paceOf, |
| 17 | 17 | remainingPercent, |
| 18 | 18 | } from "@t3tools/shared/usageLimits"; |
| 19 | import { type ReactNode, useState } from "react"; | |
| 19 | import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; | |
| 20 | import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; | |
| 20 | 21 | import { Alert, Pressable, View } from "react-native"; |
| 21 | 22 | |
| 22 | 23 | import { AppText as Text } from "../../components/AppText"; |
| ... | ... | @@ -279,47 +280,79 @@ export function ResetCredits(props: { |
| 279 | 280 | * Environments whose probe failed are named, since their rows keep showing |
| 280 | 281 | * the previous quota with nothing else to say so. |
| 281 | 282 | */ |
| 282 | export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null) { | |
| 283 | export function useRefreshLimits( | |
| 284 | selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null, | |
| 285 | active = false, | |
| 286 | ) { | |
| 283 | 287 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 284 | 288 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { |
| 285 | 289 | reportFailure: false, |
| 286 | 290 | }); |
| 287 | 291 | const [now, setNow] = useState(() => Date.now()); |
| 288 | 292 | const [refreshing, setRefreshing] = useState(false); |
| 293 | const refreshingRef = useRef(false); | |
| 289 | 294 | const [failedEnvironments, setFailedEnvironments] = useState< |
| 290 | 295 | readonly { environmentId: EnvironmentId; label: string }[] |
| 291 | 296 | >([]); |
| 292 | // Always toggles `refreshing`, even with nothing to probe: Android's | |
| 293 | // RefreshControl keeps its spinner up until it sees true then false. | |
| 294 | const refresh = async () => { | |
| 297 | const refresh = async (automatic = false) => { | |
| 295 | 298 | const connected = [...presentations].filter( |
| 296 | 299 | ([environmentId, presentation]) => |
| 297 | 300 | presentation.connection.phase === "connected" && |
| 298 | 301 | (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), |
| 299 | 302 | ); |
| 300 | setRefreshing(true); | |
| 301 | 303 | try { |
| 302 | const results = await Promise.all( | |
| 303 | connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })), | |
| 304 | ); | |
| 305 | setFailedEnvironments( | |
| 306 | connected | |
| 307 | .filter((_, index) => results[index]?._tag === "Failure") | |
| 308 | .map(([environmentId, presentation]) => ({ | |
| 304 | await Promise.all( | |
| 305 | connected.map(async ([environmentId, presentation]) => { | |
| 306 | const result = await refreshUsageLimits( | |
| 309 | 307 | environmentId, |
| 310 | label: presentation.entry.target.label, | |
| 311 | })), | |
| 308 | () => refreshProviders({ environmentId, input: {} }), | |
| 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 | 320 | } finally { |
| 314 | 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 | 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 | 351 | const failedLabels = failedEnvironments |
| 319 | 352 | .filter( |
| 320 | 353 | ({ environmentId }) => |
| 321 | 354 | selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), |
| 322 | 355 | ) |
| 323 | 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 | 1 | import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; |
| 2 | import { type RouteProp, useNavigation, useRoute } from "@react-navigation/native"; | |
| 2 | import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; | |
| 3 | 3 | import { |
| 4 | 4 | isCompatibleUsageContractVersion, |
| 5 | 5 | isModelCostUnknown, |
| ... | ... | @@ -95,7 +95,8 @@ export function UsageRouteScreen() { |
| 95 | 95 | window, |
| 96 | 96 | selectedEnvironmentIds, |
| 97 | 97 | ); |
| 98 | const limits = useRefreshLimits(selectedEnvironmentIds); | |
| 98 | const isFocused = useIsFocused(); | |
| 99 | const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); | |
| 99 | 100 | |
| 100 | 101 | const days = useMemo( |
| 101 | 102 | () => enumerateDays(window.sinceDay, window.untilDay), |
apps/web/src/components/usage/UsagePage.refresh.test.tsx+97-4| ... | ... | @@ -1,12 +1,13 @@ |
| 1 | 1 | import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; |
| 2 | 2 | import { mergeUsage } from "@t3tools/shared/usageMerge"; |
| 3 | import { act } from "react"; | |
| 3 | import { StrictMode, act } from "react"; | |
| 4 | 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 5 | 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 6 | 6 | |
| 7 | 7 | const state = vi.hoisted(() => ({ |
| 8 | 8 | presentations: new Map(), |
| 9 | 9 | refreshProviders: vi.fn(async () => undefined), |
| 10 | metric: "limits", | |
| 10 | 11 | })); |
| 11 | 12 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); |
| 12 | 13 | vi.mock("../../state/presentation", () => ({ |
| ... | ... | @@ -43,7 +44,7 @@ vi.mock("../../state/usage", () => ({ |
| 43 | 44 | }), |
| 44 | 45 | })); |
| 45 | 46 | vi.mock("./usagePagePreferences", () => ({ |
| 46 | readUsagePagePreferences: () => ({ metric: "limits", windowDays: 30 }), | |
| 47 | readUsagePagePreferences: () => ({ metric: state.metric, windowDays: 30 }), | |
| 47 | 48 | saveUsagePagePreferences: vi.fn(), |
| 48 | 49 | })); |
| 49 | 50 | vi.mock("../ui/button", () => ({ Button: "button" })); |
| ... | ... | @@ -83,13 +84,16 @@ vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ lab |
| 83 | 84 | import { UsagePage } from "./UsagePage"; |
| 84 | 85 | |
| 85 | 86 | let renderer: ReactTestRenderer; |
| 87 | let environmentNumber = 0; | |
| 86 | 88 | beforeEach(() => { |
| 87 | 89 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 88 | 90 | vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z")); |
| 91 | environmentNumber += 1; | |
| 92 | state.metric = "limits"; | |
| 89 | 93 | state.refreshProviders.mockClear(); |
| 90 | 94 | state.presentations = new Map([ |
| 91 | 95 | [ |
| 92 | EnvironmentId.make("test"), | |
| 96 | EnvironmentId.make(`test-${environmentNumber}`), | |
| 93 | 97 | { |
| 94 | 98 | entry: { target: { label: "Test" } }, |
| 95 | 99 | connection: { phase: "connected" }, |
| ... | ... | @@ -150,7 +154,10 @@ it.each([0, 1])( |
| 150 | 154 | .at(buttonIndex)! |
| 151 | 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 | 161 | expect( |
| 155 | 162 | JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), |
| 156 | 163 | ).toContain("in 1h 30m"); |
| ... | ... | @@ -175,5 +182,91 @@ it("uses the current time when returning to limits from tokens", async () => { |
| 175 | 182 | expect( |
| 176 | 183 | JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), |
| 177 | 184 | ).toContain("in 1h 0m"); |
| 185 | expect(state.refreshProviders).toHaveBeenCalledTimes(2); | |
| 186 | }); | |
| 187 | ||
| 188 | it("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 | 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 | ||
| 222 | it("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 | ||
| 247 | it("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 | 11 | CircleDashedIcon, |
| 12 | 12 | SlidersHorizontalIcon, |
| 13 | 13 | } from "lucide-react"; |
| 14 | import { useMemo, useRef, useState } from "react"; | |
| 14 | import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; | |
| 15 | import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; | |
| 15 | 16 | |
| 16 | 17 | import { |
| 17 | 18 | isCompatibleUsageContractVersion, |
| ... | ... | @@ -165,21 +166,31 @@ export function UsagePage() { |
| 165 | 166 | setPreferences(nextPreferences); |
| 166 | 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 | 187 | const refreshWindow = () => { |
| 169 | 188 | if (refreshingRef.current) return; |
| 170 | 189 | |
| 171 | 190 | if (showingLimits) { |
| 172 | 191 | refreshingRef.current = true; |
| 173 | 192 | setIsRefreshing(true); |
| 174 | void Promise.all( | |
| 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()); | |
| 193 | void refreshLimits().finally(() => { | |
| 183 | 194 | refreshingRef.current = false; |
| 184 | 195 | setIsRefreshing(false); |
| 185 | 196 | }); |
| ... | ... | @@ -201,6 +212,23 @@ export function UsagePage() { |
| 201 | 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 | 232 | const windowLabel = |
| 205 | 233 | isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined |
| 206 | 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 | 60 | The same account signed in on more than one environment, or reported by a hub as well, counts once. |
| 61 | 61 | Filter with the environment dropdown to see what a single machine has. |
| 62 | 62 | |
| 63 | If a window looks stale, refresh Limits to re-check every provider and hub. | |
| 63 | Opening Limits checks the selected connected environments automatically. Each client waits at | |
| 64 | least five minutes between automatic checks of an environment, including after a failed check. | |
| 65 | If a window still looks stale, refresh Limits to re-check every provider and hub. | |
| 64 | 66 | |
| 65 | 67 | Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the |
| 66 | 68 | current 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 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import * as Effect from "effect/Effect"; |
| 8 | 8 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 9 | import { afterEach, describe, expect, it } from "vite-plus/test"; | |
| 9 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 10 | 10 | |
| 11 | 11 | import type { EnvironmentPresentation } from "../connection/presentation.ts"; |
| 12 | 12 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; |
| 13 | import { refreshUsage } from "./usage.ts"; | |
| 13 | import { refreshUsage, refreshUsageLimits } from "./usage.ts"; | |
| 14 | 14 | |
| 15 | 15 | const input = { |
| 16 | 16 | sinceDay: UsageDay.make("2026-09-05"), |
| ... | ... | @@ -182,3 +182,46 @@ describe("manual usage refresh", () => { |
| 182 | 182 | unmount(); |
| 183 | 183 | }); |
| 184 | 184 | }); |
| 185 | ||
| 186 | describe("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 | 9 | |
| 10 | 10 | const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); |
| 11 | 11 | |
| 12 | const limitsRefreshAfter = new Map<EnvironmentId, number>(); | |
| 13 | const limitsRefreshes = new Map<EnvironmentId, Promise<unknown>>(); | |
| 14 | ||
| 15 | export 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 | 39 | /** Refresh pricing, then await each selected environment's rescan while it remains connected. */ |
| 13 | 40 | export async function refreshUsage({ |
| 14 | 41 | registry, |