diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 0fd35faf7528..30c20090261f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -131,7 +131,9 @@ "@pierre/trees": "1.0.0-beta.4", "@types/react": "~19.2.0", "@types/react-dom": "~19.2.3", + "@types/react-test-renderer": "19.1.0", "babel-preset-expo": "~57.0.9", + "react-test-renderer": "19.2.3", "tailwindcss": "^4.0.0", "typescript": "catalog:" }, diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 23d9ac3b9a04..25c0683f5ddb 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -208,7 +208,7 @@ export function UsageLimitsSection({ ? presentations : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); const pools = collectLimitPools(collectLimitAccounts(selected), now); - const notices = collectLimitNotices(selected); + const notices = collectLimitNotices(selected, now); const colors = useProviderColors(); return ( diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index e7afe114a566..03cc6e89f101 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -25,6 +25,7 @@ import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { useProviderColors } from "./usageProviders"; +import { useUsageLimitsRefresh } from "./useUsageLimitsRefresh"; const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; @@ -279,12 +280,16 @@ 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, + enabled = true, +) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); const [now, setNow] = useState(() => Date.now()); + useUsageLimitsRefresh(enabled, selectedEnvironmentIds, setNow); const [refreshing, setRefreshing] = useState(false); const [failedEnvironments, setFailedEnvironments] = useState< readonly { environmentId: EnvironmentId; label: string }[] diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 04d9a4bee6cf..e9215eb5d772 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -94,7 +94,7 @@ export function UsageRouteScreen() { window, selectedEnvironmentIds, ); - const limits = useRefreshLimits(selectedEnvironmentIds); + const limits = useRefreshLimits(selectedEnvironmentIds, tab === "limits"); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), diff --git a/apps/mobile/src/features/usage/useUsageLimitsRefresh.test.tsx b/apps/mobile/src/features/usage/useUsageLimitsRefresh.test.tsx new file mode 100644 index 000000000000..b712bfc87d8b --- /dev/null +++ b/apps/mobile/src/features/usage/useUsageLimitsRefresh.test.tsx @@ -0,0 +1,105 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + focused: true, + appState: "background", + listeners: new Set<() => void>(), + refresh: vi.fn(async () => undefined), +})); +vi.mock("react-native", () => ({ + AppState: { + get currentState() { + return state.appState; + }, + addEventListener: (_: string, listener: () => void) => { + state.listeners.add(listener); + return { remove: () => state.listeners.delete(listener) }; + }, + }, +})); +vi.mock("@react-navigation/native", () => ({ useIsFocused: () => state.focused })); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => presentations })); +vi.mock("../../state/presentation", () => ({ + environmentPresentations: { presentationsAtom: null }, +})); +vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refresh })); + +import { useUsageLimitsRefresh } from "./useUsageLimitsRefresh"; + +const presentations = new Map([ + [ + EnvironmentId.make("remote"), + { + connection: { phase: "connected" }, + entry: { target: { label: "K12" } }, + serverConfig: { + usageLimitSources: [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy", + label: "Hub", + checkedAt: "2026-09-13T10:00:00Z", + accounts: [], + }, + ], + }, + }, + ], +]); +function Screen({ enabled = true }) { + useUsageLimitsRefresh(enabled, null, () => {}); + return null; +} +let renderer: ReactTestRenderer; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-13T12:00:00Z")); + state.focused = true; + state.appState = "background"; + state.refresh.mockClear(); +}); +afterEach(async () => { + await act(() => renderer?.unmount()); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); +it("refreshes on foreground resume, stops when leaving the screen, and recovers on return", async () => { + await act(() => { + renderer = create(); + }); + expect(vi.getTimerCount()).toBe(0); + expect(state.refresh).not.toHaveBeenCalled(); + await act(async () => { + state.appState = "active"; + for (const listener of state.listeners) listener(); + }); + expect(state.refresh).toHaveBeenCalledTimes(1); + expect(state.refresh).toHaveBeenCalledWith({ environmentId: "remote", input: {} }); + await act(() => { + state.focused = false; + renderer.update(); + }); + expect(vi.getTimerCount()).toBe(0); + await act(async () => { + await vi.advanceTimersByTimeAsync(2 * 60 * 60_000); + }); + expect(state.refresh).toHaveBeenCalledTimes(1); + await act(() => { + state.focused = true; + renderer.update(); + }); + expect(state.refresh).toHaveBeenCalledTimes(2); +}); +it("does not refresh the cost/tokens tab", async () => { + state.appState = "active"; + await act(() => { + renderer = create(); + }); + expect(state.refresh).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/apps/mobile/src/features/usage/useUsageLimitsRefresh.ts b/apps/mobile/src/features/usage/useUsageLimitsRefresh.ts new file mode 100644 index 000000000000..7bbee47a9a42 --- /dev/null +++ b/apps/mobile/src/features/usage/useUsageLimitsRefresh.ts @@ -0,0 +1,61 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createUsageLimitsRefresher } from "@t3tools/client-runtime/state/usage"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { USAGE_LIMITS_MAX_AGE_MS } from "@t3tools/shared/usageLimits"; +import { useEffect, useEffectEvent, useMemo } from "react"; +import { AppState } from "react-native"; +import { useIsFocused } from "@react-navigation/native"; + +import { environmentPresentations } from "../../state/presentation"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; + +export function useUsageLimitsRefresh( + enabled: boolean, + selectedEnvironmentIds: ReadonlySet | null, + onChecked: (now: number) => void, +) { + const focused = useIsFocused(); + const active = enabled && focused; + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const refresh = useMemo( + () => + createUsageLimitsRefresher((environmentId) => refreshProviders({ environmentId, input: {} })), + [refreshProviders], + ); + const update = useEffectEvent(() => { + if (!active || AppState.currentState !== "active") return; + const selected = new Map( + [...presentations].filter( + ([id, presentation]) => + presentation.connection.phase === "connected" && + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(id)), + ), + ); + onChecked(Date.now()); + void refresh(selected, Date.now()); + }); + useEffect(() => { + update(); + // Recheck after config delivery, reconnect, or environment selection. + // eslint-disable-next-line react/exhaustive-effect-dependencies + }, [active, presentations, selectedEnvironmentIds]); + useEffect(() => { + if (!active) return; + let timer: ReturnType | undefined; + const wake = () => { + clearInterval(timer); + update(); + if (AppState.currentState === "active") timer = setInterval(update, USAGE_LIMITS_MAX_AGE_MS); + }; + wake(); + const subscription = AppState.addEventListener("change", wake); + return () => { + clearInterval(timer); + subscription.remove(); + }; + }, [active]); +} diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts new file mode 100644 index 000000000000..7836e24c3de3 --- /dev/null +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "@effect/vitest"; +import { UsageLimitSourceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import * as UsageLimitSources from "./UsageLimitSources.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +function fixture() { + let reads = 0; + let failing = false; + const http = HttpClient.make((request) => + Effect.sync(() => { + if (request.url.endsWith("/auth-files")) { + reads++; + return HttpClientResponse.fromWeb( + request, + Response.json( + { files: [{ id: "test", auth_index: "test", provider: "claude" }] }, + { status: failing ? 503 : 200 }, + ), + ); + } + return HttpClientResponse.fromWeb( + request, + Response.json({ + status_code: 200, + body: encodeJson({ + five_hour: { utilization: reads === 1 ? 44 : 51, resets_at: null }, + }), + }), + ); + }), + ); + const layer = UsageLimitSources.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient, http), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ServerSettingsService.layerTest({ + usageLimitSources: { + [UsageLimitSourceId.make("hub")]: { + kind: "cliproxy", + url: "http://hub.test", + managementKey: "test", + enabled: true, + }, + }, + }), + ), + ), + ); + return { + layer, + reads: () => reads, + fail: () => { + failing = true; + }, + }; +} + +const populated = (sources: UsageLimitSources.UsageLimitSources["Service"]) => + sources.streamChanges.pipe( + Stream.filter((snapshots) => snapshots.length > 0), + Stream.take(1), + Stream.runCollect, + ); + +describe("UsageLimitSources freshness", () => { + it.effect( + "stays idle without demand, then revalidates a stale subscription and coalesces reconnecting clients", + () => { + const test = fixture(); + return Effect.gen(function* () { + const sources = yield* UsageLimitSources.UsageLimitSources; + yield* populated(sources); + expect(test.reads()).toBe(1); + yield* TestClock.adjust("2 hours"); + expect(test.reads()).toBe(1); + expect((yield* sources.current)[0]?.accounts[0]?.usageLimits.windows[0]?.usedPercent).toBe( + 44, + ); + const refreshed = sources.streamChanges.pipe( + Stream.filter( + (snapshots) => snapshots[0]?.accounts[0]?.usageLimits.windows[0]?.usedPercent === 51, + ), + Stream.take(1), + Stream.runCollect, + ); + const clients = yield* Effect.all([refreshed, refreshed], { concurrency: "unbounded" }); + expect(clients).toHaveLength(2); + expect(test.reads()).toBe(2); + yield* populated(sources); + expect(test.reads()).toBe(2); + }).pipe(Effect.provide(test.layer)); + }, + ); + + it.effect( + "publishes failures to an existing subscriber and bounds failed reconnect retries", + () => { + const test = fixture(); + return Effect.gen(function* () { + const sources = yield* UsageLimitSources.UsageLimitSources; + yield* populated(sources); + yield* TestClock.adjust("2 hours"); + test.fail(); + const failure = yield* sources.streamChanges.pipe( + Stream.filter((snapshots) => snapshots[0]?.error !== undefined), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + const snapshots = yield* Fiber.join(failure); + expect(snapshots[0]?.[0]?.error).toBe("The hub could not list accounts."); + expect(snapshots[0]?.[0]?.accounts).toEqual([]); + expect(test.reads()).toBe(2); + yield* populated(sources); + expect(test.reads()).toBe(2); + }).pipe(Effect.provide(test.layer)); + }, + ); +}); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 957a6ab3321e..622659825bfe 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -3,7 +3,8 @@ * on, today a CLIProxyAPI hub pooling several subscription accounts. * * Each configured `settings.usageLimitSources` entry is polled on the - * provider health-check interval and on every settings change, then + * provider health-check interval, on settings changes, and when a client + * subscribes to an expired snapshot, then * published as one snapshot per source over `subscribeServerConfig`. A source * that fails keeps its row with `error` set so the user can see it is * configured but unreachable. Nothing is persisted: like provider status, @@ -22,6 +23,8 @@ import { type UsageLimitSourceSnapshot, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { USAGE_LIMITS_MAX_AGE_MS } from "@t3tools/shared/usageLimits"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -100,7 +103,8 @@ export const make = Effect.gen(function* () { // must not publish after the change's own refresh and resurrect a removed // source. Callers queue behind the in-flight run and see current settings. const refreshLock = yield* Semaphore.make(1); - const refresh = Effect.gen(function* () { + let lastRefresh = -Infinity; + const readSources = Effect.gen(function* () { const settings = yield* settingsService.getSettings.pipe( Effect.orElseSucceed((): ServerSettings | null => null), ); @@ -113,6 +117,15 @@ export const make = Effect.gen(function* () { { concurrency: 4 }, ); yield* publish(snapshots); + lastRefresh = yield* Clock.currentTimeMillis; + }); + const refresh = readSources.pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true })); + // Reconnecting clients need a fresh read even when background work is paused. + // Check inside the lock so simultaneous subscriptions share the same read. + const refreshIfStale = Effect.gen(function* () { + if ((yield* Clock.currentTimeMillis) - lastRefresh >= USAGE_LIMITS_MAX_AGE_MS) { + yield* readSources; + } }).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true })); // Shares the refresh lock so a stale in-flight read cannot overwrite a redemption. @@ -172,6 +185,7 @@ export const make = Effect.gen(function* () { return Stream.unwrap( Effect.gen(function* () { const subscription = yield* PubSub.subscribe(changes); + yield* refreshIfStale.pipe(Effect.forkScoped); const snapshot = yield* Ref.get(stateRef); return Stream.concat(Stream.make(snapshot), Stream.fromSubscription(subscription)).pipe( Stream.changes, diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 6d30d49a7e4a..db06678e499a 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -316,8 +316,7 @@ export function ResetCredits({ /** * Subscription quota across every connected environment's providers and hubs, - * pooled per provider. The page advances `now` on explicit refresh rather than - * ticking: a live clock would repaint the page for no decision-changing gain. + * pooled per provider. The visible page advances `now` when checking freshness. */ export function UsageLimitsSection({ selectedEnvironmentIds, diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 1317cef89839..e0aa0ce2af33 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -543,7 +543,7 @@ export function UsageLimitsPooled({ readonly now: number; }) { const pools = collectLimitPools(collectLimitAccounts(presentations), now); - const notices = collectLimitNotices(presentations); + const notices = collectLimitNotices(presentations, now); return (
{pools.length === 0 && notices.length === 0 ? ( diff --git a/apps/web/src/components/usage/UsagePage.refresh.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.test.tsx index d6d300ce35b0..9991c0a69a02 100644 --- a/apps/web/src/components/usage/UsagePage.refresh.test.tsx +++ b/apps/web/src/components/usage/UsagePage.refresh.test.tsx @@ -85,6 +85,8 @@ import { UsagePage } from "./UsagePage"; let renderer: ReactTestRenderer; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("document", Object.assign(new EventTarget(), { visibilityState: "visible" })); + vi.stubGlobal("window", new EventTarget()); vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z")); state.refreshProviders.mockClear(); state.presentations = new Map([ @@ -175,5 +177,21 @@ 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).toHaveBeenCalledWith({ environmentId: "test", input: {} }); +}); + +it("does no hidden work and refreshes stale limits after resume", async () => { + Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true }); + vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T14:00:00Z")); + await act(() => { + renderer = create(); + }); expect(state.refreshProviders).not.toHaveBeenCalled(); + await act(async () => { + Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true }); + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + await act(async () => window.dispatchEvent(new Event("focus"))); + expect(state.refreshProviders).toHaveBeenCalledTimes(1); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 2da7414d9337..d020d1b7e5c7 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -60,6 +60,7 @@ import { import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; +import { useUsageLimitsRefresh } from "./useUsageLimitsRefresh"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; @@ -109,6 +110,7 @@ export function UsagePage() { const [breakdown, setBreakdown] = useState<"model" | "time">("model"); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); + useUsageLimitsRefresh(showingLimits, selectedEnvironmentIds, setLimitsNow); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( diff --git a/apps/web/src/components/usage/useUsageLimitsRefresh.ts b/apps/web/src/components/usage/useUsageLimitsRefresh.ts new file mode 100644 index 000000000000..53a783fd72ff --- /dev/null +++ b/apps/web/src/components/usage/useUsageLimitsRefresh.ts @@ -0,0 +1,62 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createUsageLimitsRefresher } from "@t3tools/client-runtime/state/usage"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { USAGE_LIMITS_MAX_AGE_MS } from "@t3tools/shared/usageLimits"; +import { useEffect, useEffectEvent, useMemo } from "react"; + +import { environmentPresentations } from "../../state/presentation"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; + +export function useUsageLimitsRefresh( + enabled: boolean, + selectedEnvironmentIds: ReadonlySet | null, + onChecked: (now: number) => void, +) { + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const refresh = useMemo( + () => + createUsageLimitsRefresher((environmentId) => refreshProviders({ environmentId, input: {} })), + [refreshProviders], + ); + const update = useEffectEvent(() => { + if (!enabled || document.visibilityState !== "visible") return; + const selected = new Map( + [...presentations].filter( + ([id, presentation]) => + presentation.connection.phase === "connected" && + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(id)), + ), + ); + onChecked(Date.now()); + void refresh(selected, Date.now()); + }); + useEffect(() => { + update(); + // Recheck after config delivery, reconnect, or environment selection. + // eslint-disable-next-line react/exhaustive-effect-dependencies + }, [enabled, presentations, selectedEnvironmentIds]); + useEffect(() => { + if (!enabled) return; + let timer: ReturnType | undefined; + const wake = () => { + clearInterval(timer); + update(); + if (document.visibilityState === "visible") + timer = setInterval(update, USAGE_LIMITS_MAX_AGE_MS); + }; + wake(); + document.addEventListener("visibilitychange", wake); + window.addEventListener("focus", wake); + window.addEventListener("online", wake); + return () => { + clearInterval(timer); + document.removeEventListener("visibilitychange", wake); + window.removeEventListener("focus", wake); + window.removeEventListener("online", wake); + }; + }, [enabled]); +} diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 29f029c9d863..031bb0cee5bc 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId, UsageDay, + UsageLimitSourceId, USAGE_CONTRACT_VERSION, type UsageSummary, } from "@t3tools/contracts"; @@ -10,7 +11,7 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; import type { EnvironmentPresentation } from "../connection/presentation.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; -import { refreshUsage } from "./usage.ts"; +import { createUsageLimitsRefresher, refreshUsage } from "./usage.ts"; const input = { sinceDay: UsageDay.make("2026-09-05"), @@ -182,3 +183,62 @@ describe("manual usage refresh", () => { unmount(); }); }); + +describe("visible subscription limits", () => { + const now = Date.parse("2026-09-13T12:00:00Z"); + const old = new Date(now - 2 * 60 * 60_000).toISOString(); + const presentation = (checkedAt: string) => ({ + entry: { target: { label: "Test" } }, + serverConfig: { + usageLimitSources: [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Hub", + checkedAt, + accounts: [], + }, + ], + }, + }); + + it("refreshes stale environments independently and leaves fresh snapshots alone", async () => { + const a = EnvironmentId.make("a"), + b = EnvironmentId.make("b"); + const calls: EnvironmentId[] = []; + const refresh = createUsageLimitsRefresher(async (id) => { + calls.push(id); + }); + await refresh( + new Map([ + [a, presentation(old)], + [b, presentation(new Date(now).toISOString())], + ]), + now, + ); + expect(calls).toEqual([a]); + await refresh(new Map([[b, presentation(old)]]), now); + expect(calls).toEqual([a, b]); + }); + + it("coalesces reconnect and resume during an outstanding request, then retries after failure", async () => { + const id = EnvironmentId.make("remote"); + const done = Promise.withResolvers(); + let calls = 0; + const refresh = createUsageLimitsRefresher(() => { + calls++; + return done.promise; + }); + const views = new Map([[id, presentation(old)]]); + const first = refresh(views, now); + const reconnect = refresh(views, now + 1); + await Promise.resolve(); + expect(calls).toBe(1); + done.reject(new Error("disconnected")); + await Promise.all([first, reconnect]); + await refresh(views, now + 59_999); + expect(calls).toBe(1); + await refresh(views, now + 60_000); + expect(calls).toBe(2); + }); +}); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 10a565a0c24f..5326bd812b9e 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,4 +1,9 @@ import type { EnvironmentId, UsageSummaryInput } from "@t3tools/contracts"; +import { + USAGE_LIMITS_MAX_AGE_MS, + usageLimitsAreStale, + type LimitPresentations, +} from "@t3tools/shared/usageLimits"; import * as Schema from "effect/Schema"; import type { AtomRegistry } from "effect/unstable/reactivity"; @@ -9,6 +14,39 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); +/** Only visible consumers call this; bound retries and coalesce overlapping wakeups per environment. */ +export function createUsageLimitsRefresher(refresh: (id: EnvironmentId) => Promise) { + const attempted = new Map(); + const pending = new Map>(); + return async (presentations: LimitPresentations, now: number) => { + await Promise.allSettled( + [...presentations].map(([id, presentation]) => { + const config = presentation.serverConfig; + const timestamps = [ + ...(config?.usageLimitSources ?? []).map((source) => source.checkedAt), + ...(config?.providers ?? []).flatMap((provider) => + provider.enabled && + provider.usageLimits?.unavailable?.reason !== "unsupported" && + provider.usageLimits + ? [provider.usageLimits.checkedAt] + : [], + ), + ]; + if (!timestamps.some((checkedAt) => usageLimitsAreStale(checkedAt, now))) return; + const running = pending.get(id); + if (running) return running; + if (now - (attempted.get(id) ?? -Infinity) < USAGE_LIMITS_MAX_AGE_MS) return; + attempted.set(id, now); + const request = Promise.resolve() + .then(() => refresh(id)) + .finally(() => pending.delete(id)); + pending.set(id, request); + return request; + }), + ); + }; +} + /** Refresh pricing, then await each selected environment's rescan while it remains connected. */ export async function refreshUsage({ registry, diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 0ef53b1e7f55..679678cac4f7 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -740,6 +740,93 @@ describe("collectLimitNotices", () => { }); expect(collectLimitNotices(one)[0]).toBe("Laptop · Claude Max: Could not read limits."); }); + it("labels old hub snapshots and per-account failures without exposing account identity", () => { + const view = new Map([ + [ + EnvironmentId.make("remote"), + { + ...laptop, + serverConfig: { + usageLimitSources: [ + { + ...hub, + accounts: [ + { + id: "private-account", + email: "private@example.com", + driver: claude, + usageLimits: { + checkedAt, + windows: [], + unavailable: { reason: "probeFailed" as const }, + }, + }, + ], + }, + ], + }, + }, + ], + ]); + expect(collectLimitNotices(view, Date.parse(checkedAt) + 120_000)).toEqual([ + "hub: Could not read limits for 1 account.", + "hub: Usage is out of date.", + ]); + }); + + it.each([undefined, { reason: "unsupported" as const }])( + "does not report unsupported or empty hub accounts as failed reads", + (unavailable) => { + const view = new Map([ + [ + EnvironmentId.make("remote"), + { + ...laptop, + serverConfig: { + usageLimitSources: [ + { + ...hub, + accounts: [ + { + id: "api-key-account", + driver: claude, + usageLimits: { + checkedAt, + windows: [], + ...(unavailable ? { unavailable } : {}), + }, + }, + ], + }, + ], + }, + }, + ], + ]); + expect(collectLimitNotices(view, Date.parse(checkedAt))).toEqual([]); + }, + ); + + it.each([undefined, "The hub could not list accounts."])( + "marks stale empty snapshots, including failed reads, as out of date", + (error) => { + const view = new Map([ + [ + EnvironmentId.make("remote"), + { + ...laptop, + serverConfig: { usageLimitSources: [{ ...hub, ...(error ? { error } : {}) }] }, + }, + ], + ]); + const notice = `hub: ${error ?? "No accounts reported."}`; + expect(collectLimitNotices(view, Date.parse(checkedAt))).toEqual([notice]); + expect(collectLimitNotices(view, Date.parse(checkedAt) + 120_000)).toEqual([ + notice, + "hub: Usage is out of date.", + ]); + }, + ); }); describe("/usage-limits", () => { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 17ba1feab5d6..4bbbf45cc045 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -24,6 +24,13 @@ const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; +export const USAGE_LIMITS_MAX_AGE_MS = MINUTE; + +export function usageLimitsAreStale(checkedAt: string, now: number): boolean { + const checked = Date.parse(checkedAt); + return !Number.isFinite(checked) || now - checked >= USAGE_LIMITS_MAX_AGE_MS; +} + /** * Providers that belong on the Limits view: enabled, installed, and one whose * driver reports subscription usage at all. A driver with no notion of usage @@ -220,7 +227,10 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl * are left out; there is nothing for the user to act on. The environment * is named only when more than one is connected. */ -export function collectLimitNotices(presentations: LimitPresentations): readonly string[] { +export function collectLimitNotices( + presentations: LimitPresentations, + now?: number, +): readonly string[] { const label = (environmentLabel: string, subject: string) => presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject; const notices: string[] = []; @@ -233,12 +243,31 @@ export function collectLimitNotices(presentations: LimitPresentations): readonly const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null; const name = provider.displayName?.trim() || String(provider.driver); if (notice) notices.push(`${label(environmentLabel, name)}: ${notice}`); + else if ( + now !== undefined && + provider.usageLimits && + usageLimitsAreStale(provider.usageLimits.checkedAt, now) + ) { + notices.push(`${label(environmentLabel, name)}: Usage is out of date.`); + } } for (const source of presentation.serverConfig?.usageLimitSources ?? []) { if (source.error) { notices.push(`${label(environmentLabel, source.label)}: ${source.error}`); } else if (source.accounts.length === 0) { notices.push(`${label(environmentLabel, source.label)}: No accounts reported.`); + } else { + const failures = source.accounts.filter( + (account) => account.usageLimits.unavailable?.reason === "probeFailed", + ); + if (failures.length > 0) { + notices.push( + `${label(environmentLabel, source.label)}: Could not read limits for ${failures.length} ${failures.length === 1 ? "account" : "accounts"}.`, + ); + } + } + if (now !== undefined && usageLimitsAreStale(source.checkedAt, now)) { + notices.push(`${label(environmentLabel, source.label)}: Usage is out of date.`); } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8dbaf396924..adc811e703b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -483,9 +483,15 @@ importers: '@types/react-dom': specifier: ~19.2.3 version: 19.2.3(@types/react@19.2.16) + '@types/react-test-renderer': + specifier: 19.1.0 + version: 19.1.0 babel-preset-expo: specifier: ~57.0.9 version: 57.0.9(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@57.0.15)(expo@57.0.18)(react-refresh@0.14.2) + react-test-renderer: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) tailwindcss: specifier: 4.3.3 version: 4.3.3 @@ -9854,6 +9860,11 @@ packages: '@types/react': optional: true + react-test-renderer@19.2.3: + resolution: {integrity: sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==} + peerDependencies: + react: ^19.2.3 + react-test-renderer@19.2.6: resolution: {integrity: sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw==} peerDependencies: @@ -21203,6 +21214,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + react-test-renderer@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + react-is: 19.2.7 + scheduler: 0.27.0 + react-test-renderer@19.2.6(react@19.2.6): dependencies: react: 19.2.6