Skip to content
Merged
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
8 changes: 4 additions & 4 deletions apps/web/src/components/usage/UsageLimits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,17 +316,17 @@ export function ResetCredits({

/**
* Subscription quota across every connected environment's providers and hubs,
* pooled per provider. Countdowns anchor to render time rather than ticking: a
* live clock would repaint the page every minute for no decision-changing gain.
* pooled per provider. The page advances `now` on explicit refresh rather than

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: now also advances when Limits is re-selected from Tokens or Cost (selectMetric), not only on refresh. Worth a few words here, since this comment is now the only place the anchoring rule for the section is written down.

* ticking: a live clock would repaint the page for no decision-changing gain.
*/
export function UsageLimitsSection({
selectedEnvironmentIds,
now,
}: {
readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
readonly now: number;
}) {
const presentations = useAtomValue(environmentPresentations.presentationsAtom);
// Anchored once per mount on purpose: countdowns must not tick (see above).
const [now] = useState(() => Date.now());
const selected =
selectedEnvironmentIds === null
? presentations
Expand Down
179 changes: 179 additions & 0 deletions apps/web/src/components/usage/UsagePage.refresh.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
import { mergeUsage } from "@t3tools/shared/usageMerge";
import { act } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";

const state = vi.hoisted(() => ({
presentations: new Map(),
refreshProviders: vi.fn(async () => undefined),
}));
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations }));
vi.mock("../../state/presentation", () => ({
environmentPresentations: { presentationsAtom: null },
}));
vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } }));
vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders }));
vi.mock("../../env", () => ({ isElectron: false }));
vi.mock("../../hooks/useSettings", () => ({ usePrimarySettings: () => "24h" }));
vi.mock("../../state/usage", () => ({
useUsage: () => ({
merged: mergeUsage([], USAGE_CONTRACT_VERSION),
environments: [
{
environmentId: EnvironmentId.make("test"),
label: "Test",
isPending: false,
error: null,
summary: null,
},
],
selectedEnvironments: [
{
environmentId: EnvironmentId.make("test"),
label: "Test",
isPending: false,
error: null,
summary: null,
},
],
isPending: false,
isPartial: false,
refresh: async () => undefined,
}),
}));
vi.mock("./usagePagePreferences", () => ({
readUsagePagePreferences: () => ({ metric: "limits", windowDays: 30 }),
saveUsagePagePreferences: vi.fn(),
}));
vi.mock("../ui/button", () => ({ Button: "button" }));
vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" }));
vi.mock("../ui/select", () => ({
Select: "select",
SelectItem: "option",
SelectPopup: "div",
SelectTrigger: "div",
SelectValue: "span",
}));
vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" }));
vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" }));
vi.mock("../ui/tooltip", () => ({ Tooltip: "div", TooltipPopup: "div", TooltipTrigger: "div" }));
vi.mock("../ui/popover", () => ({ Popover: "div", PopoverPopup: "div", PopoverTrigger: "div" }));
vi.mock("../ui/menu", () => ({
Menu: "div",
MenuCheckboxItem: "div",
MenuItem: "div",
MenuPopup: "div",
MenuSeparator: "hr",
MenuTrigger: "div",
}));
vi.mock("../WorkspaceBreadcrumb", () => ({
WorkspaceBreadcrumb: "div",
WorkspaceBreadcrumbItem: "div",
WorkspaceBreadcrumbSeparator: "span",
}));
vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" }));
vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" }));
vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" }));
vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null }));
vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null }));
vi.mock("../settings/RedactedSensitiveText", () => ({ RedactedSensitiveText: "span" }));
vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ label: "Codex" }) }));

import { UsagePage } from "./UsagePage";

let renderer: ReactTestRenderer;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z"));
state.refreshProviders.mockClear();
state.presentations = new Map([
[
EnvironmentId.make("test"),
{
entry: { target: { label: "Test" } },
connection: { phase: "connected" },
serverConfig: {
providers: [
{
instanceId: ProviderInstanceId.make("codex"),
driver: "codex",
enabled: true,
installed: true,
version: null,
status: "ready",
auth: { status: "authenticated" },
checkedAt: "2026-09-11T12:00:00Z",
models: [],
slashCommands: [],
skills: [],
usageLimits: {
checkedAt: "2026-09-11T12:00:00Z",
windows: [
{
id: "five_hour",
kind: "session",
label: "Session",
usedPercent: 40,
windowDurationMins: 300,
resetsAt: "2026-09-11T14:00:00Z",
},
],
},
},
],
},
},
],
]);
});
afterEach(async () => {
await act(() => renderer?.unmount());
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it.each([0, 1])(
"refreshes the visible limits countdown with refresh button %i without switching tabs, even when quota is unchanged",
async (buttonIndex) => {
await act(() => {
renderer = create(<UsagePage />);
});
expect(
JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this JSON.stringify with the props-stripping replacer appears four times in the file. A small renderedText(renderer) helper would let the assertions read as expect(renderedText(renderer)).toContain("in 1h 30m").

).toContain("in 2h 0m");
vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:30:00Z"));
await act(async () => {
renderer.root
.findAllByProps({ "aria-label": "Refresh limits" })
.filter((node) => node.type === "button")
.at(buttonIndex)!
.props.onClick();
});
expect(state.refreshProviders).toHaveBeenCalledWith({ environmentId: "test", input: {} });
expect(
JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
).toContain("in 1h 30m");
expect(
JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
).not.toContain("in 2h 0m");
},
);

it("uses the current time when returning to limits from tokens", async () => {
await act(() => {
renderer = create(<UsagePage />);
});
const selectMetric = (metric: string) => {
renderer.root
.findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]!
.props.onValueChange([metric]);
};
await act(() => selectMetric("tokens"));
vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T13:00:00Z"));
await act(() => selectMetric("limits"));
expect(
JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
).toContain("in 1h 0m");
expect(state.refreshProviders).not.toHaveBeenCalled();
});
5 changes: 4 additions & 1 deletion apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export function UsagePage() {
const metric = preferences.metric;
const showingLimits = metric === "limits";
const [isRefreshing, setIsRefreshing] = useState(false);
const [limitsNow, setLimitsNow] = useState(() => Date.now());
const refreshingRef = useRef(false);
const [breakdown, setBreakdown] = useState<"model" | "time">("model");
const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
Expand Down Expand Up @@ -159,6 +160,7 @@ export function UsagePage() {
});
};
const selectMetric = (nextMetric: UsageMetric) => {
if (nextMetric === "limits") setLimitsNow(Date.now());
const nextPreferences = { metric: nextMetric, windowDays };
setPreferences(nextPreferences);
saveUsagePagePreferences(nextPreferences);
Expand All @@ -177,6 +179,7 @@ export function UsagePage() {
}
}),
).finally(() => {
setLimitsNow(Date.now());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This covers the explicit refresh, but the server also patches usageLimits between probes: ProviderUsageLimitsIngestion applies Codex's per-turn rate-limit events through applyUsageLimits, and they reach this page over the config stream without passing through refreshWindow. With Limits open while a turn runs in another thread, usedPercent and possibly resetsAt update live but now stays at the last click, so the countdown and pace hairline drift in exactly the way the PR description calls out. This was true before the PR and mobile has the same gap, so not blocking.

If you want to close it in the same change, a smaller model might be to re-anchor whenever the newest usageLimits.checkedAt across the displayed snapshots changes, with the mount-time anchor as the baseline. Every path that changes the data (either button, another client's refresh, ingestion) then moves the clock with it, and the two setLimitsNow call sites go away. Also fine as a follow-up if you'd rather keep this PR to the reported repro.

refreshingRef.current = false;
setIsRefreshing(false);
});
Expand Down Expand Up @@ -350,7 +353,7 @@ export function UsagePage() {
: `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}
</p>
) : showingLimits ? (
<UsageLimitsSection selectedEnvironmentIds={selectedEnvironmentIds} />
<UsageLimitsSection selectedEnvironmentIds={selectedEnvironmentIds} now={limitsNow} />
) : isPending ? (
<UsageSkeleton />
) : (
Expand Down
Loading