Skip to content
Open
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
23 changes: 16 additions & 7 deletions apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ function PoolWindowCard({
const inlineLabels = wide && segmentWidth >= 96;
const inlineReset = wide && segmentWidth >= 150;
const inlineName = wide && segmentWidth >= 220;
const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
const nextReset = pool.resets[0];
const openAccount = (account: LimitAccount) =>
navigation.navigate("SettingsUsageAccount", {
accountKey: account.key,
Expand All @@ -204,18 +204,25 @@ function PoolWindowCard({
<Text className="text-3xl font-t3-bold tabular-nums text-foreground">
{pool.remainingPercent}%
</Text>
<Text className="text-sm text-foreground-muted">left</Text>
<Text className="text-sm text-foreground-muted">
{pool.members.length > 1 ? "average left" : "left"}
</Text>
</View>
{pool.members.length > 1 ? (
<Text className="text-xs text-foreground-muted">
Across {pool.members.length} accounts
</Text>
) : null}
</View>
{pool.pace ? (
<Text className="text-xs text-foreground-tertiary">{PACE_LABEL[pool.pace]}</Text>
) : null}
</View>
{description ? <Text className="text-xs text-foreground-muted">{description}</Text> : null}
{nextRefill ? (
{nextReset ? (
<Text className="text-xs tabular-nums text-foreground-muted">
↻ +{nextRefill.restoresPercent}%{" "}
{formatResetsIn(nextRefill.member.window, now)
Next account reset{" "}
{formatResetsIn(nextReset.member.window, now)
?.replace("resets in ", "in ")
.replace("resets now", "now")}
</Text>
Expand Down Expand Up @@ -602,9 +609,11 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
Even pace: {evenPace}% left by now
</Text>
) : null}
{reset && reset.restoresPercent > 0 ? (
{reset && reset.member.refillPercent > 0 ? (
<Text className="text-sm text-foreground-muted">
Restores {reset.restoresPercent}% of the pool
After reset:{" "}
{Math.round(Math.min(100, remainingPercent(window) + reset.member.refillPercent))}
% left on this account
</Text>
) : null}
</View>
Expand Down
95 changes: 94 additions & 1 deletion apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import {
EnvironmentId,
ProviderDriverKind,
ProviderInstanceId,
UsageLimitSourceId,
type ServerProvider,
} from "@t3tools/contracts";
import { CHATGPT_USAGE_URL } from "@t3tools/shared/usageLimits";
import { type LimitPresentations, CHATGPT_USAGE_URL } from "@t3tools/shared/usageLimits";
import { describe, expect, it } from "vite-plus/test";

import { render } from "../../../test/render";
import { AppAtomRegistryProvider } from "../../rpc/atomRegistry";
import "../../index.css";
import { UsageLimitsPooled } from "./UsageLimitsPooled";

const checkedAt = "2026-10-25T16:00:00.000Z";
Expand Down Expand Up @@ -63,3 +66,93 @@ describe("UsageLimitsPooled ChatGPT usage card", () => {
.toBeVisible();
});
});

it("keeps the account label and reset summary on a one-account row of a multi-account provider", async () => {
const now = Date.parse("2026-10-28T01:00:00.000Z");
const checkedAt = new Date(now).toISOString();
const driver = ProviderDriverKind.make("codex");
const presentations: LimitPresentations = new Map([
[
EnvironmentId.make("demo"),
{
entry: { target: { label: "Demo" } },
serverConfig: {
providers: [],
usageLimitSources: [
{
id: UsageLimitSourceId.make("demo"),
kind: "cliproxy",
label: "Demo",
checkedAt,
accounts: [
{
id: "session-only",
driver,
usageLimits: {
checkedAt,
windows: [
{
id: "primary",
kind: "session",
label: "Session",
usedPercent: 0,
resetsAt: "2026-10-28T03:55:00.000Z",
},
],
},
},
{
id: "later-session",
driver,
usageLimits: {
checkedAt,
windows: [
{
id: "primary",
kind: "session",
label: "Session",
usedPercent: 0.6,
resetsAt: "2026-10-28T05:00:00.000Z",
},
],
},
},
{
id: "monthly-only",
driver,
usageLimits: {
checkedAt,
windows: [
{
id: "primary",
kind: "monthly",
label: "Monthly",
usedPercent: 2,
resetsAt: "2026-11-26T18:00:00.000Z",
},
],
},
},
],
},
],
},
},
],
]);
const container = document.createElement("div");
container.style.width = "1000px";
await using screen = await render(
<AppAtomRegistryProvider>
<UsageLimitsPooled presentations={presentations} now={now} />
</AppAtomRegistryProvider>,
{ container },
);
await expect.element(screen.getByText("monthly-only", { exact: true }).first()).toBeVisible();
await expect.element(screen.getByText("Next account reset in 29d 17h")).toBeVisible();
await expect.element(screen.getByText("Next account reset in 2h 55m")).toBeVisible();
await expect.element(screen.getByText("average left", { exact: true })).toBeVisible();
await expect.element(screen.getByText("Across 2 accounts", { exact: true })).toBeVisible();
await screen.getByRole("button", { name: /^later-session: 99% left/ }).click();
await expect.element(screen.getByText("100% left on this account")).toBeVisible();
});
30 changes: 22 additions & 8 deletions apps/web/src/components/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,11 @@ function SegmentPopover({
</Row>
) : null}
{timeLeft !== null ? <Row label="Even pace">{timeLeft}% left by now</Row> : null}
{reset && reset.restoresPercent > 0 ? (
<Row label="Restores">+{reset.restoresPercent}% of pool</Row>
{reset && reset.member.refillPercent > 0 ? (
<Row label="After reset">
{Math.round(Math.min(100, remaining + reset.member.refillPercent))}% left on this
account
</Row>
) : null}
</div>
{credits && redeem ? (
Expand Down Expand Up @@ -495,10 +498,12 @@ function PoolBar({
pool,
color,
now,
providerAccountCount,
}: {
readonly pool: LimitPoolWindow;
readonly color: string;
readonly now: number;
readonly providerAccountCount: number;
}) {
const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset]));
return (
Expand All @@ -518,7 +523,7 @@ function PoolBar({
color={color}
now={now}
index={position + 1}
showAccountName={pool.columns.length > 1}
showAccountName={providerAccountCount > 1}
/>
) : null,
)}
Expand All @@ -531,16 +536,18 @@ function PoolWindowCard({
pool,
color,
now,
providerAccountCount,
label,
description,
}: {
readonly pool: LimitPoolWindow;
readonly color: string;
readonly now: number;
readonly providerAccountCount: number;
readonly label?: string | undefined;
readonly description?: string | undefined;
}) {
const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
const nextReset = pool.resets[0];
return (
<div className="grid items-center gap-x-6 gap-y-3 rounded-lg border border-border/60 p-4 md:grid-cols-[11rem_minmax(0,1fr)]">
<div className="flex flex-col gap-1">
Expand All @@ -550,17 +557,23 @@ function PoolWindowCard({
{pool.remainingPercent}%
</span>
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<span>left</span>
<span>{pool.members.length > 1 ? "average left" : "left"}</span>
{pool.pace ? <PaceIcon pace={pool.pace} /> : null}
</span>
</span>
{nextRefill && pool.columns.length > 1 ? (
{pool.members.length > 1 ? (
<span className="text-xs text-muted-foreground">
Across {pool.members.length} accounts
</span>
) : null}
{nextReset && providerAccountCount > 1 ? (
<span className="text-xs font-medium text-foreground tabular-nums">
↻ +{nextRefill.restoresPercent}%
Next account reset{" "}
{formatResetsIn(nextReset.member.window, now)?.replace("resets ", "")}
</span>
) : null}
</div>
<PoolBar pool={pool} color={color} now={now} />
<PoolBar pool={pool} color={color} now={now} providerAccountCount={providerAccountCount} />
{description ? (
<p className="text-xs text-muted-foreground md:col-span-2">{description}</p>
) : null}
Expand Down Expand Up @@ -590,6 +603,7 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu
<PoolWindowCard
key={`${window.kind}:${window.id}`}
pool={window}
providerAccountCount={pool.accounts.length}
color={color}
now={now}
label={details?.label}
Expand Down
10 changes: 6 additions & 4 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,13 @@ the dialog.
**Usage → Limits** pools every subscription account it can see per provider, so with several Codex
or Claude accounts across your enabled environments and hubs you read one number per window rather than a
list. Each window card shows how much of the pool is left and a bar with one segment per account,
kept in the same column across windows. Accounts are ordered by their next 5-hour refill, soonest
sharing each row equally among accounts that report that window. The headline is the average
percentage left across those accounts; each segment shows that account's percentage.
Accounts are ordered by their next 5-hour refill, soonest
first. For an account with no weekly quota left, this is when its session becomes usable again.
The first available window is used when no account reports a 5-hour limit. A gap means the
account does not report that window. When the provider reports reset times, the card also says
when the next reset lands and how much it hands back. The hatched
The first available window is used when no account reports a 5-hour limit. Accounts that do not
report a window take no space in that row. When the provider reports reset times, the card also says
when the next account reset lands. The hatched
part of a segment is what that reset restores. Tap a segment or account row for the account's plan,
where it is signed in, and its reset time. On web, you can hover too. Codex and Claude accounts
with banked reset credits show a ticket count and the **Use reset** action in the account details.
Expand Down
27 changes: 24 additions & 3 deletions packages/shared/src/usageLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,7 @@ describe("pooled account columns", () => {
});
});

it("preserves gaps without counting missing windows toward pooled quota", () => {
it("fills each row with only accounts reporting that window", () => {
const [pool] = collectLimitPools(
[
account("a", [window]),
Expand All @@ -962,14 +962,35 @@ describe("pooled account columns", () => {
now,
);
expect(keys(pool!)).toEqual([
["a", "b", null],
[null, "b", "c"],
["a", "b"],
["b", "c"],
]);
expect(pool!.windows[1]!.members.map((member) => member.account.key)).toEqual(["b", "c"]);
expect(pool!.windows[1]!.remainingPercent).toBe(40);
expect(pool!.windows[1]!.resets.map((reset) => reset.restoresPercent)).toEqual([40, 20]);
});

it("gives two Codex session accounts equal slots and a monthly-only account its own full row", () => {
const codex = (key: string, windows: LimitAccount["limits"]["windows"]) => ({
...account(key, windows),
driver: ProviderDriverKind.make("codex"),
});
const [pool] = collectLimitPools(
[
codex("a", [window, { ...weekly, usedPercent: 90 }]),
codex("b", [window, { ...weekly, usedPercent: 100 }]),
codex("monthly", [{ ...window, kind: "monthly", usedPercent: 2 }]),
],
now,
);
expect(keys(pool!)).toEqual([["a", "b"], ["a", "b"], ["monthly"]]);
expect(pool!.windows[1]!.remainingPercent).toBe(5);
expect(pool!.windows[1]!.members.map((member) => 100 - member.window.usedPercent)).toEqual([
10, 0,
]);
expect(pool!.windows[2]!.remainingPercent).toBe(98);
});

it("falls back to weekly resets when no account reports a session", () => {
const [pool] = collectLimitPools(
[
Expand Down
5 changes: 1 addition & 4 deletions packages/shared/src/usageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,6 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
}
}
const pools = [...byKey].map(([key, members]): LimitPoolWindow => {
const memberByAccount = new Map(members.map((member) => [member.account.key, member]));
const first = members[0]!.window;
const id = members[0]!.account.driver === "codex" ? key : first.id;
const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length;
Expand Down Expand Up @@ -581,9 +580,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
kind: first.kind,
label: first.label,
members,
columns: accounts.map(
(account) => memberByAccount.get(account.key) ?? { account, window: null },
),
columns: members,
usedPercent: Math.round(usedPercent),
remainingPercent: Math.round(100 - usedPercent),
pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed),
Expand Down
Loading