| 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,7 +16,8 @@ import { |
| 16 | paceOf, | 16 | paceOf, |
| 17 | remainingPercent, | 17 | remainingPercent, |
| 18 | } from "@t3tools/shared/usageLimits"; | 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 | import { Alert, Pressable, View } from "react-native"; | 21 | import { Alert, Pressable, View } from "react-native"; |
| 21 | 22 | ||
| 22 | import { AppText as Text } from "../../components/AppText"; | 23 | import { 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 showing | 280 | * 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 | */ |
| 282 | export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null) { | 283 | export 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's | 297 | 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 = failedEnvironments | 351 | 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 @@ |
| 1 | import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; | 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 | import { | 3 | import { |
| 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"); | ||
| 99 | 100 | ||
| 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 @@ |
| 1 | import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; | 1 | import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; |
| 2 | import { mergeUsage } from "@t3tools/shared/usageMerge"; | 2 | import { mergeUsage } from "@t3tools/shared/usageMerge"; |
| 3 | import { act } from "react"; | 3 | import { StrictMode, act } from "react"; |
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; | 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 6 | 6 | ||
| 7 | const state = vi.hoisted(() => ({ | 7 | const 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 | })); |
| 11 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); | 12 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); |
| 12 | vi.mock("../../state/presentation", () => ({ | 13 | vi.mock("../../state/presentation", () => ({ |
| ... | @@ -43,7 +44,7 @@ vi.mock("../../state/usage", () => ({ | ... | @@ -43,7 +44,7 @@ vi.mock("../../state/usage", () => ({ |
| 43 | }), | 44 | }), |
| 44 | })); | 45 | })); |
| 45 | vi.mock("./usagePagePreferences", () => ({ | 46 | vi.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 | })); |
| 49 | vi.mock("../ui/button", () => ({ Button: "button" })); | 50 | vi.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 |
| 83 | import { UsagePage } from "./UsagePage"; | 84 | import { UsagePage } from "./UsagePage"; |
| 84 | 85 | ||
| 85 | let renderer: ReactTestRenderer; | 86 | let renderer: ReactTestRenderer; |
| 87 | let environmentNumber = 0; | ||
| 86 | beforeEach(() => { | 88 | beforeEach(() => { |
| 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 | |||
| 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 | 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 | |||
| 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,7 +11,8 @@ import { |
| 11 | CircleDashedIcon, | 11 | CircleDashedIcon, |
| 12 | SlidersHorizontalIcon, | 12 | SlidersHorizontalIcon, |
| 13 | } from "lucide-react"; | 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 | import { | 17 | import { |
| 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; |
| 170 | 189 | ||
| 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 !== undefined | 233 | 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 |
| 60 | The same account signed in on more than one environment, or reported by a hub as well, counts once. | 60 | The same account signed in on more than one environment, or reported by a hub as well, counts once. |
| 61 | Filter with the environment dropdown to see what a single machine has. | 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 | Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the | 67 | Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the |
| 66 | current model's limits without leaving the conversation. The result opens above the composer and | 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,11 +6,11 @@ import { |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | 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 | import type { EnvironmentPresentation } from "../connection/presentation.ts"; | 11 | import type { EnvironmentPresentation } from "../connection/presentation.ts"; |
| 12 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; | 12 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; |
| 13 | import { refreshUsage } from "./usage.ts"; | 13 | import { refreshUsage, refreshUsageLimits } from "./usage.ts"; |
| 14 | 14 | ||
| 15 | const input = { | 15 | const 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 | |||
| 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,6 +9,33 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; |
| 9 | 9 | ||
| 10 | const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); | 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 | /** 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. */ |
| 13 | export async function refreshUsage({ | 40 | export async function refreshUsage({ |
| 14 | registry, | 41 | registry, |