Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
},
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<View className="gap-6">
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/usage/UsageLimitsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<EnvironmentId> | null = null) {
export function useRefreshLimits(
selectedEnvironmentIds: ReadonlySet<EnvironmentId> | 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 }[]
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
105 changes: 105 additions & 0 deletions apps/mobile/src/features/usage/useUsageLimitsRefresh.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Screen />);
});
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(<Screen />);
});
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(<Screen />);
});
expect(state.refresh).toHaveBeenCalledTimes(2);
});
it("does not refresh the cost/tokens tab", async () => {
state.appState = "active";
await act(() => {
renderer = create(<Screen enabled={false} />);
});
expect(state.refresh).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
61 changes: 61 additions & 0 deletions apps/mobile/src/features/usage/useUsageLimitsRefresh.ts
Original file line number Diff line number Diff line change
@@ -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<EnvironmentId> | 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<typeof setInterval> | 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]);
}
133 changes: 133 additions & 0 deletions apps/server/src/usage/UsageLimitSources.test.ts
Original file line number Diff line number Diff line change
@@ -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));
},
);
});
Loading
Loading