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
48 changes: 26 additions & 22 deletions apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,30 +116,34 @@ function PoolWindowCard({
</Text>
) : null}
<View className="flex-row gap-1">
{pool.members.map(({ account, window }, index) => (
<Pressable
key={account.key}
accessibilityRole="button"
accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`}
accessibilityHint="Show account details"
onPress={() => openAccount(account)}
className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle"
>
<AccountSegment
remaining={remainingPercent(window)}
color={color}
pending={Boolean(window.resetsAt)}
/>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<Text className="text-xs font-t3-medium tabular-nums text-foreground">
{index + 1}
</Text>
</View>
</Pressable>
))}
{pool.columns.map(({ account, window }, index) => {
if (!window) return <View key={account.key} className="h-7 min-w-0 flex-1" />;
return (
<Pressable
key={account.key}
accessibilityRole="button"
accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`}
accessibilityHint="Show account details"
onPress={() => openAccount(account)}
className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle"
>
<AccountSegment
remaining={remainingPercent(window)}
color={color}
pending={Boolean(window.resetsAt)}
/>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<Text className="text-xs font-t3-medium tabular-nums text-foreground">
{index + 1}
</Text>
</View>
</Pressable>
);
})}
</View>
<View>
{pool.members.map(({ account, window }, index) => {
{pool.columns.map(({ account, window }, index) => {
if (!window) return null;
const credits = account.limits.resetCredits?.availableCount ?? 0;
const resetsIn = formatResetsIn(window, now);
return (
Expand Down
31 changes: 16 additions & 15 deletions apps/web/src/components/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,28 +449,29 @@ function PoolBar({
<div className="@container/pool min-w-0">
<div
className="grid gap-x-1 gap-y-1"
style={{ gridTemplateColumns: `repeat(${pool.members.length}, minmax(0, 1fr))` }}
style={{ gridTemplateColumns: `repeat(${pool.columns.length}, minmax(0, 1fr))` }}
>
{pool.members.map(({ account, window }, position) => (
<PoolSegment
key={account.key}
account={account}
window={window}
reset={restores.get(account.key)}
color={color}
now={now}
index={position + 1}
/>
))}
{pool.columns.map((member, position) =>
member.window ? (
<PoolSegment
key={member.account.key}
account={member.account}
window={member.window}
reset={restores.get(member.account.key)}
color={color}
now={now}
index={position + 1}
/>
) : null,
)}
</div>
</div>
);
}

/**
* Big pooled number and the segment bar. The bar is sorted by reset, so who
* refills next is its left edge; the exact time and share restored live in
* each segment's popover rather than a list restating the bar.
* Big pooled number and the segment bar. Accounts keep the same column across
* windows; each segment's popover shows its own reset time and share restored.
*/
function PoolWindowCard({
pool,
Expand Down
6 changes: 4 additions & 2 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ the dialog.
**Usage → Limits** pools every subscription account it can see per provider, so with several Codex
or Claude accounts across your 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,
ordered by which resets soonest; when the provider reports reset times, the card also says when
the next reset lands and how much it hands back. The hatched
kept in the same column across windows. Accounts are ordered by their 5-hour reset, soonest
first, or by the first available window 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 accounts with banked
reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below
Expand Down
94 changes: 93 additions & 1 deletion packages/shared/src/usageLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { describe, expect, it } from "vite-plus/test";

import {
type LimitAccount,
isUsageLimitsCommand,
collectProviderUsageLimits,
sameUsageLimitCommandCoverage,
Expand Down Expand Up @@ -777,12 +778,103 @@ describe("pools", () => {
["weekly", 1],
["monthly", 1],
]);
// Segments read left to right as "who refills next", matching the reset list.
// Session resets determine the account order for every row.
expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]);
expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]);
});
});

describe("pooled account columns", () => {
const weekly = {
...window,
id: "seven_day",
kind: "weekly",
label: "Weekly",
windowDurationMins: 7 * 24 * 60,
} as const;
const account = (key: string, windows: LimitAccount["limits"]["windows"]): LimitAccount => ({
key,
driver: ProviderDriverKind.make("claudeAgent"),
displayName: key,
email: undefined,
plan: undefined,
accentColor: undefined,
environments: [],
sourceLabel: "Hub",
redeem: null,
limits: { checkedAt: "2026-09-03T11:00:00.000Z", windows },
});
const keys = (pool: ReturnType<typeof collectLimitPools>[number]) =>
pool.windows.map((row) =>
row.columns.map((member) => (member.window ? member.account.key : null)),
);

it("keeps session columns across rows with opposite reset and usage orders", () => {
const accounts = [
account("a", [
{ ...weekly, usedPercent: 80, resetsAt: "2026-09-05T12:00:00.000Z" },
{ ...window, usedPercent: 10, resetsAt: "2026-09-03T15:00:00.000Z" },
]),
account("b", [
{ ...weekly, usedPercent: 20, resetsAt: "2026-09-06T12:00:00.000Z" },
{ ...window, usedPercent: 90, resetsAt: "2026-09-03T13:00:00.000Z" },
]),
];
const [pool] = collectLimitPools(accounts, now);
expect(pool!.accounts.map((account) => account.key)).toEqual(["b", "a"]);
expect(keys(pool!)).toEqual([
["b", "a"],
["b", "a"],
]);
expect(pool!.windows[1]!.resets.map((reset) => reset.member.account.key)).toEqual(["a", "b"]);
expect(pool!.windows[1]!.remainingPercent).toBe(50);
expect(keys(collectLimitPools(accounts.toReversed(), now)[0]!)).toEqual(keys(pool!));
});

it("preserves gaps without counting missing windows toward pooled quota", () => {
const [pool] = collectLimitPools(
[
account("a", [window]),
account("b", [
{ ...window, resetsAt: "2026-09-03T15:00:00.000Z" },
{ ...weekly, usedPercent: 80 },
]),
account("c", [weekly]),
],
now,
);
expect(keys(pool!)).toEqual([
["a", "b", null],
[null, "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("falls back to weekly resets when no account reports a session", () => {
const [pool] = collectLimitPools(
[
account("a", [{ ...weekly, resetsAt: "2026-09-06T12:00:00.000Z" }]),
account("b", [{ ...weekly, resetsAt: "2026-09-05T12:00:00.000Z" }]),
],
now,
);
expect(keys(pool!)).toEqual([["b", "a"]]);
});

it("sorts unknown resets last and breaks ties consistently", () => {
const accounts = [
account("z", [{ ...window, resetsAt: undefined }]),
account("b", [window]),
account("a", [window]),
account("y", [{ ...window, resetsAt: "invalid" }]),
];
expect(keys(collectLimitPools(accounts, now)[0]!)).toEqual([["a", "b", "y", "z"]]);
expect(keys(collectLimitPools(accounts.toReversed(), now)[0]!)).toEqual([["a", "b", "y", "z"]]);
});
});

describe("collectLimitNotices", () => {
const checkedAt = "2026-09-03T11:00:00.000Z";
const claude = ProviderDriverKind.make("claudeAgent");
Expand Down
38 changes: 26 additions & 12 deletions packages/shared/src/usageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,11 @@ export interface LimitPoolWindow {
readonly kind: ServerProviderUsageWindow["kind"];
readonly label: string;
readonly members: readonly LimitPoolMember[];
/** Fixed account positions across rows; a null window leaves a gap. */
readonly columns: ReadonlyArray<{
readonly account: LimitAccount;
readonly window: ServerProviderUsageWindow | null;
}>;
readonly remainingPercent: number;
readonly usedPercent: number;
readonly pace: LimitPace | null;
Expand Down Expand Up @@ -389,10 +394,10 @@ const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = {
* a month on Free/Go), and a monthly allowance must not average into a
* five-hour pool. Pools order by kind, then first appearance.
*
* `accounts` is the table order: instances the user can act on (native,
* named) before hub-only accounts, each group alphabetical. Each window's
* `members` sort by reset instead, soonest first, so a bar reads left to
* right as "who refills next" and matches the reset list under it.
* Accounts and columns share the session reset order, soonest first. When
* no account reports a session window, use the first window by kind instead.
* Missing reset times sort last, with account names and keys breaking ties.
* Each window's reset list still follows its own clock.
*/
export function collectLimitPools(
accounts: readonly LimitAccount[],
Expand All @@ -405,10 +410,20 @@ export function collectLimitPools(
else byDriver.set(account.driver, [account]);
}
return [...byDriver].map(([driver, members]) => {
const orderWindow = members
.flatMap((account) => account.limits.windows)
.sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0];
const orderReset = (account: LimitAccount) => {
const window = account.limits.windows.find(
(window) => window.kind === orderWindow?.kind && window.id === orderWindow.id,
);
return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
const sorted = [...members].sort(
(left, right) =>
Number(left.redeem === null) - Number(right.redeem === null) ||
accountSortName(left).localeCompare(accountSortName(right)),
orderReset(left) - orderReset(right) ||
accountSortName(left).localeCompare(accountSortName(right)) ||
left.key.localeCompare(right.key),
);
return { driver, accounts: sorted, windows: poolWindows(sorted, now) };
});
Expand All @@ -428,12 +443,8 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
else byKey.set(key, [{ account, window }]);
}
}
const pools = [...byKey.values()].map((unordered): LimitPoolWindow => {
const members = [...unordered].sort(
(left, right) =>
(resetMillis(left.window) ?? Number.POSITIVE_INFINITY) -
(resetMillis(right.window) ?? Number.POSITIVE_INFINITY),
);
const pools = [...byKey.values()].map((members): LimitPoolWindow => {
const memberByAccount = new Map(members.map((member) => [member.account.key, member]));
const first = members[0]!.window;
const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length;
// Pace compares spend against the clock, so it is judged only over the
Expand Down Expand Up @@ -465,6 +476,9 @@ 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 },
Comment thread
juliusmarminge marked this conversation as resolved.
),
usedPercent: Math.round(usedPercent),
remainingPercent: Math.round(100 - usedPercent),
pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed),
Expand Down
Loading