diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index dd62f4952d1f..7c20f802477d 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -116,30 +116,34 @@ function PoolWindowCard({ ) : null} - {pool.members.map(({ account, window }, index) => ( - openAccount(account)} - className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" - > - - - - {index + 1} - - - - ))} + {pool.columns.map(({ account, window }, index) => { + if (!window) return ; + return ( + openAccount(account)} + className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" + > + + + + {index + 1} + + + + ); + })} - {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 ( diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 0c17779daa3c..37654140c282 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -449,28 +449,29 @@ function PoolBar({
- {pool.members.map(({ account, window }, position) => ( - - ))} + {pool.columns.map((member, position) => + member.window ? ( + + ) : null, + )}
); } /** - * 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),