authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-08 00:40:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 00:40:35-07:00
log1f14d6d10afbcc99ec255b23544ee25c08dea321
treeafcde3abda400231749e02f8e59a633877276440
parent7d9aaf6a7eb22d5e85b0e3b21686cc437fe68576
signature Signed by PGP key B5690EEEBB952194

fix(usage): keep account columns aligned across limit rows (#10690)


5 files changed, 165 insertions(+), 52 deletions(-)

apps/mobile/src/features/usage/UsageLimitsPooled.tsx+26-22
...@@ -116,30 +116,34 @@ function PoolWindowCard({...@@ -116,30 +116,34 @@ function PoolWindowCard({
116 </Text>116 </Text>
117 ) : null}117 ) : null}
118 <View className="flex-row gap-1">118 <View className="flex-row gap-1">
119 {pool.members.map(({ account, window }, index) => (119 {pool.columns.map(({ account, window }, index) => {
120 <Pressable120 if (!window) return <View key={account.key} className="h-7 min-w-0 flex-1" />;
121 key={account.key}121 return (
122 accessibilityRole="button"122 <Pressable
123 accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`}123 key={account.key}
124 accessibilityHint="Show account details"124 accessibilityRole="button"
125 onPress={() => openAccount(account)}125 accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`}
126 className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle"126 accessibilityHint="Show account details"
127 >127 onPress={() => openAccount(account)}
128 <AccountSegment128 className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle"
129 remaining={remainingPercent(window)}129 >
130 color={color}130 <AccountSegment
131 pending={Boolean(window.resetsAt)}131 remaining={remainingPercent(window)}
132 />132 color={color}
133 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">133 pending={Boolean(window.resetsAt)}
134 <Text className="text-xs font-t3-medium tabular-nums text-foreground">134 />
135 {index + 1}135 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
136 </Text>136 <Text className="text-xs font-t3-medium tabular-nums text-foreground">
137 </View>137 {index + 1}
138 </Pressable>138 </Text>
139 ))}139 </View>
140 </Pressable>
141 );
142 })}
140 </View>143 </View>
141 <View>144 <View>
142 {pool.members.map(({ account, window }, index) => {145 {pool.columns.map(({ account, window }, index) => {
146 if (!window) return null;
143 const credits = account.limits.resetCredits?.availableCount ?? 0;147 const credits = account.limits.resetCredits?.availableCount ?? 0;
144 const resetsIn = formatResetsIn(window, now);148 const resetsIn = formatResetsIn(window, now);
145 return (149 return (
apps/web/src/components/usage/UsageLimitsPooled.tsx+16-15
...@@ -449,28 +449,29 @@ function PoolBar({...@@ -449,28 +449,29 @@ function PoolBar({
449 <div className="@container/pool min-w-0">449 <div className="@container/pool min-w-0">
450 <div450 <div
451 className="grid gap-x-1 gap-y-1"451 className="grid gap-x-1 gap-y-1"
452 style={{ gridTemplateColumns: `repeat(${pool.members.length}, minmax(0, 1fr))` }}452 style={{ gridTemplateColumns: `repeat(${pool.columns.length}, minmax(0, 1fr))` }}
453 >453 >
454 {pool.members.map(({ account, window }, position) => (454 {pool.columns.map((member, position) =>
455 <PoolSegment455 member.window ? (
456 key={account.key}456 <PoolSegment
457 account={account}457 key={member.account.key}
458 window={window}458 account={member.account}
459 reset={restores.get(account.key)}459 window={member.window}
460 color={color}460 reset={restores.get(member.account.key)}
461 now={now}461 color={color}
462 index={position + 1}462 now={now}
463 />463 index={position + 1}
464 ))}464 />
465 ) : null,
466 )}
465 </div>467 </div>
466 </div>468 </div>
467 );469 );
468}470}
469471
470/**472/**
471 * Big pooled number and the segment bar. The bar is sorted by reset, so who473 * Big pooled number and the segment bar. Accounts keep the same column across
472 * refills next is its left edge; the exact time and share restored live in474 * windows; each segment's popover shows its own reset time and share restored.
473 * each segment's popover rather than a list restating the bar.
474 */475 */
475function PoolWindowCard({476function PoolWindowCard({
476 pool,477 pool,
docs/user/usage.md+4-2
...@@ -42,8 +42,10 @@ the dialog....@@ -42,8 +42,10 @@ the dialog.
42**Usage → Limits** pools every subscription account it can see per provider, so with several Codex42**Usage → Limits** pools every subscription account it can see per provider, so with several Codex
43or Claude accounts across your environments and hubs you read one number per window rather than a43or Claude accounts across your environments and hubs you read one number per window rather than a
44list. Each window card shows how much of the pool is left and a bar with one segment per account,44list. Each window card shows how much of the pool is left and a bar with one segment per account,
45ordered by which resets soonest; when the provider reports reset times, the card also says when45kept in the same column across windows. Accounts are ordered by their 5-hour reset, soonest
46the next reset lands and how much it hands back. The hatched46first, or by the first available window when no account reports a 5-hour limit. A gap means the
47account does not report that window. When the provider reports reset times, the card also says
48when the next reset lands and how much it hands back. The hatched
47part of a segment is what that reset restores. Tap a segment or account row for the account's plan,49part of a segment is what that reset restores. Tap a segment or account row for the account's plan,
48where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked50where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked
49reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below51reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below
packages/shared/src/usageLimits.test.ts+93-1
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9import { describe, expect, it } from "vite-plus/test";9import { describe, expect, it } from "vite-plus/test";
1010
11import {11import {
12 type LimitAccount,
12 isUsageLimitsCommand,13 isUsageLimitsCommand,
13 collectProviderUsageLimits,14 collectProviderUsageLimits,
14 sameUsageLimitCommandCoverage,15 sameUsageLimitCommandCoverage,
...@@ -777,12 +778,103 @@ describe("pools", () => {...@@ -777,12 +778,103 @@ describe("pools", () => {
777 ["weekly", 1],778 ["weekly", 1],
778 ["monthly", 1],779 ["monthly", 1],
779 ]);780 ]);
780 // Segments read left to right as "who refills next", matching the reset list.781 // Session resets determine the account order for every row.
781 expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]);782 expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]);
782 expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]);783 expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]);
783 });784 });
784});785});
785786
787describe("pooled account columns", () => {
788 const weekly = {
789 ...window,
790 id: "seven_day",
791 kind: "weekly",
792 label: "Weekly",
793 windowDurationMins: 7 * 24 * 60,
794 } as const;
795 const account = (key: string, windows: LimitAccount["limits"]["windows"]): LimitAccount => ({
796 key,
797 driver: ProviderDriverKind.make("claudeAgent"),
798 displayName: key,
799 email: undefined,
800 plan: undefined,
801 accentColor: undefined,
802 environments: [],
803 sourceLabel: "Hub",
804 redeem: null,
805 limits: { checkedAt: "2026-09-03T11:00:00.000Z", windows },
806 });
807 const keys = (pool: ReturnType<typeof collectLimitPools>[number]) =>
808 pool.windows.map((row) =>
809 row.columns.map((member) => (member.window ? member.account.key : null)),
810 );
811
812 it("keeps session columns across rows with opposite reset and usage orders", () => {
813 const accounts = [
814 account("a", [
815 { ...weekly, usedPercent: 80, resetsAt: "2026-09-05T12:00:00.000Z" },
816 { ...window, usedPercent: 10, resetsAt: "2026-09-03T15:00:00.000Z" },
817 ]),
818 account("b", [
819 { ...weekly, usedPercent: 20, resetsAt: "2026-09-06T12:00:00.000Z" },
820 { ...window, usedPercent: 90, resetsAt: "2026-09-03T13:00:00.000Z" },
821 ]),
822 ];
823 const [pool] = collectLimitPools(accounts, now);
824 expect(pool!.accounts.map((account) => account.key)).toEqual(["b", "a"]);
825 expect(keys(pool!)).toEqual([
826 ["b", "a"],
827 ["b", "a"],
828 ]);
829 expect(pool!.windows[1]!.resets.map((reset) => reset.member.account.key)).toEqual(["a", "b"]);
830 expect(pool!.windows[1]!.remainingPercent).toBe(50);
831 expect(keys(collectLimitPools(accounts.toReversed(), now)[0]!)).toEqual(keys(pool!));
832 });
833
834 it("preserves gaps without counting missing windows toward pooled quota", () => {
835 const [pool] = collectLimitPools(
836 [
837 account("a", [window]),
838 account("b", [
839 { ...window, resetsAt: "2026-09-03T15:00:00.000Z" },
840 { ...weekly, usedPercent: 80 },
841 ]),
842 account("c", [weekly]),
843 ],
844 now,
845 );
846 expect(keys(pool!)).toEqual([
847 ["a", "b", null],
848 [null, "b", "c"],
849 ]);
850 expect(pool!.windows[1]!.members.map((member) => member.account.key)).toEqual(["b", "c"]);
851 expect(pool!.windows[1]!.remainingPercent).toBe(40);
852 expect(pool!.windows[1]!.resets.map((reset) => reset.restoresPercent)).toEqual([40, 20]);
853 });
854
855 it("falls back to weekly resets when no account reports a session", () => {
856 const [pool] = collectLimitPools(
857 [
858 account("a", [{ ...weekly, resetsAt: "2026-09-06T12:00:00.000Z" }]),
859 account("b", [{ ...weekly, resetsAt: "2026-09-05T12:00:00.000Z" }]),
860 ],
861 now,
862 );
863 expect(keys(pool!)).toEqual([["b", "a"]]);
864 });
865
866 it("sorts unknown resets last and breaks ties consistently", () => {
867 const accounts = [
868 account("z", [{ ...window, resetsAt: undefined }]),
869 account("b", [window]),
870 account("a", [window]),
871 account("y", [{ ...window, resetsAt: "invalid" }]),
872 ];
873 expect(keys(collectLimitPools(accounts, now)[0]!)).toEqual([["a", "b", "y", "z"]]);
874 expect(keys(collectLimitPools(accounts.toReversed(), now)[0]!)).toEqual([["a", "b", "y", "z"]]);
875 });
876});
877
786describe("collectLimitNotices", () => {878describe("collectLimitNotices", () => {
787 const checkedAt = "2026-09-03T11:00:00.000Z";879 const checkedAt = "2026-09-03T11:00:00.000Z";
788 const claude = ProviderDriverKind.make("claudeAgent");880 const claude = ProviderDriverKind.make("claudeAgent");
packages/shared/src/usageLimits.ts+26-12
...@@ -357,6 +357,11 @@ export interface LimitPoolWindow {...@@ -357,6 +357,11 @@ export interface LimitPoolWindow {
357 readonly kind: ServerProviderUsageWindow["kind"];357 readonly kind: ServerProviderUsageWindow["kind"];
358 readonly label: string;358 readonly label: string;
359 readonly members: readonly LimitPoolMember[];359 readonly members: readonly LimitPoolMember[];
360 /** Fixed account positions across rows; a null window leaves a gap. */
361 readonly columns: ReadonlyArray<{
362 readonly account: LimitAccount;
363 readonly window: ServerProviderUsageWindow | null;
364 }>;
360 readonly remainingPercent: number;365 readonly remainingPercent: number;
361 readonly usedPercent: number;366 readonly usedPercent: number;
362 readonly pace: LimitPace | null;367 readonly pace: LimitPace | null;
...@@ -389,10 +394,10 @@ const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = {...@@ -389,10 +394,10 @@ const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = {
389 * a month on Free/Go), and a monthly allowance must not average into a394 * a month on Free/Go), and a monthly allowance must not average into a
390 * five-hour pool. Pools order by kind, then first appearance.395 * five-hour pool. Pools order by kind, then first appearance.
391 *396 *
392 * `accounts` is the table order: instances the user can act on (native,397 * Accounts and columns share the session reset order, soonest first. When
393 * named) before hub-only accounts, each group alphabetical. Each window's398 * no account reports a session window, use the first window by kind instead.
394 * `members` sort by reset instead, soonest first, so a bar reads left to399 * Missing reset times sort last, with account names and keys breaking ties.
395 * right as "who refills next" and matches the reset list under it.400 * Each window's reset list still follows its own clock.
396 */401 */
397export function collectLimitPools(402export function collectLimitPools(
398 accounts: readonly LimitAccount[],403 accounts: readonly LimitAccount[],
...@@ -405,10 +410,20 @@ export function collectLimitPools(...@@ -405,10 +410,20 @@ export function collectLimitPools(
405 else byDriver.set(account.driver, [account]);410 else byDriver.set(account.driver, [account]);
406 }411 }
407 return [...byDriver].map(([driver, members]) => {412 return [...byDriver].map(([driver, members]) => {
413 const orderWindow = members
414 .flatMap((account) => account.limits.windows)
415 .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0];
416 const orderReset = (account: LimitAccount) => {
417 const window = account.limits.windows.find(
418 (window) => window.kind === orderWindow?.kind && window.id === orderWindow.id,
419 );
420 return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY;
421 };
408 const sorted = [...members].sort(422 const sorted = [...members].sort(
409 (left, right) =>423 (left, right) =>
410 Number(left.redeem === null) - Number(right.redeem === null) ||424 orderReset(left) - orderReset(right) ||
411 accountSortName(left).localeCompare(accountSortName(right)),425 accountSortName(left).localeCompare(accountSortName(right)) ||
426 left.key.localeCompare(right.key),
412 );427 );
413 return { driver, accounts: sorted, windows: poolWindows(sorted, now) };428 return { driver, accounts: sorted, windows: poolWindows(sorted, now) };
414 });429 });
...@@ -428,12 +443,8 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L...@@ -428,12 +443,8 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
428 else byKey.set(key, [{ account, window }]);443 else byKey.set(key, [{ account, window }]);
429 }444 }
430 }445 }
431 const pools = [...byKey.values()].map((unordered): LimitPoolWindow => {446 const pools = [...byKey.values()].map((members): LimitPoolWindow => {
432 const members = [...unordered].sort(447 const memberByAccount = new Map(members.map((member) => [member.account.key, member]));
433 (left, right) =>
434 (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) -
435 (resetMillis(right.window) ?? Number.POSITIVE_INFINITY),
436 );
437 const first = members[0]!.window;448 const first = members[0]!.window;
438 const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length;449 const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length;
439 // Pace compares spend against the clock, so it is judged only over the450 // 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...@@ -465,6 +476,9 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
465 kind: first.kind,476 kind: first.kind,
466 label: first.label,477 label: first.label,
467 members,478 members,
479 columns: accounts.map(
480 (account) => memberByAccount.get(account.key) ?? { account, window: null },
481 ),
468 usedPercent: Math.round(usedPercent),482 usedPercent: Math.round(usedPercent),
469 remainingPercent: Math.round(100 - usedPercent),483 remainingPercent: Math.round(100 - usedPercent),
470 pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed),484 pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed),