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({
116116 </Text>
117117 ) : null}
118118 <View className="flex-row gap-1">
119 {pool.members.map(({ account, window }, index) => (
120 <Pressable
121 key={account.key}
122 accessibilityRole="button"
123 accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`}
124 accessibilityHint="Show account details"
125 onPress={() => openAccount(account)}
126 className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle"
127 >
128 <AccountSegment
129 remaining={remainingPercent(window)}
130 color={color}
131 pending={Boolean(window.resetsAt)}
132 />
133 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
134 <Text className="text-xs font-t3-medium tabular-nums text-foreground">
135 {index + 1}
136 </Text>
137 </View>
138 </Pressable>
139 ))}
119 {pool.columns.map(({ account, window }, index) => {
120 if (!window) return <View key={account.key} className="h-7 min-w-0 flex-1" />;
121 return (
122 <Pressable
123 key={account.key}
124 accessibilityRole="button"
125 accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`}
126 accessibilityHint="Show account details"
127 onPress={() => openAccount(account)}
128 className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle"
129 >
130 <AccountSegment
131 remaining={remainingPercent(window)}
132 color={color}
133 pending={Boolean(window.resetsAt)}
134 />
135 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
136 <Text className="text-xs font-t3-medium tabular-nums text-foreground">
137 {index + 1}
138 </Text>
139 </View>
140 </Pressable>
141 );
142 })}
140143 </View>
141144 <View>
142 {pool.members.map(({ account, window }, index) => {
145 {pool.columns.map(({ account, window }, index) => {
146 if (!window) return null;
143147 const credits = account.limits.resetCredits?.availableCount ?? 0;
144148 const resetsIn = formatResetsIn(window, now);
145149 return (
apps/web/src/components/usage/UsageLimitsPooled.tsx+16-15
......@@ -449,28 +449,29 @@ function PoolBar({
449449 <div className="@container/pool min-w-0">
450450 <div
451451 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))` }}
453453 >
454 {pool.members.map(({ account, window }, position) => (
455 <PoolSegment
456 key={account.key}
457 account={account}
458 window={window}
459 reset={restores.get(account.key)}
460 color={color}
461 now={now}
462 index={position + 1}
463 />
464 ))}
454 {pool.columns.map((member, position) =>
455 member.window ? (
456 <PoolSegment
457 key={member.account.key}
458 account={member.account}
459 window={member.window}
460 reset={restores.get(member.account.key)}
461 color={color}
462 now={now}
463 index={position + 1}
464 />
465 ) : null,
466 )}
465467 </div>
466468 </div>
467469 );
468470}
469471
470472/**
471 * Big pooled number and the segment bar. The bar is sorted by reset, so who
472 * refills next is its left edge; the exact time and share restored live in
473 * each segment's popover rather than a list restating the bar.
473 * Big pooled number and the segment bar. Accounts keep the same column across
474 * windows; each segment's popover shows its own reset time and share restored.
474475 */
475476function PoolWindowCard({
476477 pool,
docs/user/usage.md+4-2
......@@ -42,8 +42,10 @@ the dialog.
4242**Usage → Limits** pools every subscription account it can see per provider, so with several Codex
4343or Claude accounts across your environments and hubs you read one number per window rather than a
4444list. 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 when
46the next reset lands and how much it hands back. The hatched
45kept in the same column across windows. Accounts are ordered by their 5-hour reset, soonest
46first, 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
4749part of a segment is what that reset restores. Tap a segment or account row for the account's plan,
4850where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked
4951reset 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 {
99import { describe, expect, it } from "vite-plus/test";
1010
1111import {
12 type LimitAccount,
1213 isUsageLimitsCommand,
1314 collectProviderUsageLimits,
1415 sameUsageLimitCommandCoverage,
......@@ -777,12 +778,103 @@ describe("pools", () => {
777778 ["weekly", 1],
778779 ["monthly", 1],
779780 ]);
780 // Segments read left to right as "who refills next", matching the reset list.
781 // Session resets determine the account order for every row.
781782 expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]);
782783 expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]);
783784 });
784785});
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
786878describe("collectLimitNotices", () => {
787879 const checkedAt = "2026-09-03T11:00:00.000Z";
788880 const claude = ProviderDriverKind.make("claudeAgent");
packages/shared/src/usageLimits.ts+26-12
......@@ -357,6 +357,11 @@ export interface LimitPoolWindow {
357357 readonly kind: ServerProviderUsageWindow["kind"];
358358 readonly label: string;
359359 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 }>;
360365 readonly remainingPercent: number;
361366 readonly usedPercent: number;
362367 readonly pace: LimitPace | null;
......@@ -389,10 +394,10 @@ const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = {
389394 * a month on Free/Go), and a monthly allowance must not average into a
390395 * five-hour pool. Pools order by kind, then first appearance.
391396 *
392 * `accounts` is the table order: instances the user can act on (native,
393 * named) before hub-only accounts, each group alphabetical. Each window's
394 * `members` sort by reset instead, soonest first, so a bar reads left to
395 * right as "who refills next" and matches the reset list under it.
397 * Accounts and columns share the session reset order, soonest first. When
398 * no account reports a session window, use the first window by kind instead.
399 * Missing reset times sort last, with account names and keys breaking ties.
400 * Each window's reset list still follows its own clock.
396401 */
397402export function collectLimitPools(
398403 accounts: readonly LimitAccount[],
......@@ -405,10 +410,20 @@ export function collectLimitPools(
405410 else byDriver.set(account.driver, [account]);
406411 }
407412 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 };
408422 const sorted = [...members].sort(
409423 (left, right) =>
410 Number(left.redeem === null) - Number(right.redeem === null) ||
411 accountSortName(left).localeCompare(accountSortName(right)),
424 orderReset(left) - orderReset(right) ||
425 accountSortName(left).localeCompare(accountSortName(right)) ||
426 left.key.localeCompare(right.key),
412427 );
413428 return { driver, accounts: sorted, windows: poolWindows(sorted, now) };
414429 });
......@@ -428,12 +443,8 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
428443 else byKey.set(key, [{ account, window }]);
429444 }
430445 }
431 const pools = [...byKey.values()].map((unordered): LimitPoolWindow => {
432 const members = [...unordered].sort(
433 (left, right) =>
434 (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) -
435 (resetMillis(right.window) ?? Number.POSITIVE_INFINITY),
436 );
446 const pools = [...byKey.values()].map((members): LimitPoolWindow => {
447 const memberByAccount = new Map(members.map((member) => [member.account.key, member]));
437448 const first = members[0]!.window;
438449 const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length;
439450 // 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
465476 kind: first.kind,
466477 label: first.label,
467478 members,
479 columns: accounts.map(
480 (account) => memberByAccount.get(account.key) ?? { account, window: null },
481 ),
468482 usedPercent: Math.round(usedPercent),
469483 remainingPercent: Math.round(100 - usedPercent),
470484 pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed),