);
}
/**
- * 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,
diff --git a/docs/user/usage.md b/docs/user/usage.md
index 4be084ea299e..fba493156dc2 100644
--- a/docs/user/usage.md
+++ b/docs/user/usage.md
@@ -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
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
diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts
index 48eb87ce0c2d..b814e66da459 100644
--- a/packages/shared/src/usageLimits.test.ts
+++ b/packages/shared/src/usageLimits.test.ts
@@ -9,6 +9,7 @@ import {
import { describe, expect, it } from "vite-plus/test";
import {
+ type LimitAccount,
isUsageLimitsCommand,
collectProviderUsageLimits,
sameUsageLimitCommandCoverage,
@@ -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[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");
diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts
index 784b1ada3e31..5c32cc0343b7 100644
--- a/packages/shared/src/usageLimits.ts
+++ b/packages/shared/src/usageLimits.ts
@@ -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;
@@ -389,10 +394,10 @@ const WINDOW_KIND_ORDER: Record = {
* 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[],
@@ -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;
+ };
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) };
});
@@ -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
@@ -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 },
+ ),
usedPercent: Math.round(usedPercent),
remainingPercent: Math.round(100 - usedPercent),
pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed),