| author | |
| committer | |
| log | 1f14d6d10afbcc99ec255b23544ee25c08dea321 |
| tree | afcde3abda400231749e02f8e59a633877276440 |
| parent | 7d9aaf6a7eb22d5e85b0e3b21686cc437fe68576 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | <Pressable | 120 | 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 | <AccountSegment | 128 | 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 | <div | 450 | <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 | <PoolSegment | 455 | 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 | } |
| 469 | 471 | ||
| 470 | /** | 472 | /** |
| 471 | * Big pooled number and the segment bar. The bar is sorted by reset, so who | 473 | * 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 in | 474 | * 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 | */ |
| 475 | function PoolWindowCard({ | 476 | function 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 Codex | 42 | **Usage → Limits** pools every subscription account it can see per provider, so with several Codex |
| 43 | or Claude accounts across your environments and hubs you read one number per window rather than a | 43 | or Claude accounts across your environments and hubs you read one number per window rather than a |
| 44 | list. Each window card shows how much of the pool is left and a bar with one segment per account, | 44 | list. Each window card shows how much of the pool is left and a bar with one segment per account, |
| 45 | ordered by which resets soonest; when the provider reports reset times, the card also says when | 45 | kept in the same column across windows. Accounts are ordered by their 5-hour reset, soonest |
| 46 | the next reset lands and how much it hands back. The hatched | 46 | first, or by the first available window when no account reports a 5-hour limit. A gap means the |
| 47 | account does not report that window. When the provider reports reset times, the card also says | ||
| 48 | when the next reset lands and how much it hands back. The hatched | ||
| 47 | part of a segment is what that reset restores. Tap a segment or account row for the account's plan, | 49 | part of a segment is what that reset restores. Tap a segment or account row for the account's plan, |
| 48 | where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked | 50 | where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked |
| 49 | reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below | 51 | reset 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 { |
| 9 | import { describe, expect, it } from "vite-plus/test"; | 9 | import { describe, expect, it } from "vite-plus/test"; |
| 10 | 10 | ||
| 11 | import { | 11 | import { |
| 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 | }); |
| 785 | 786 | ||
| 787 | describe("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 | |||
| 786 | describe("collectLimitNotices", () => { | 878 | describe("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 a | 394 | * 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's | 398 | * 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 to | 399 | * 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 | */ |
| 397 | export function collectLimitPools( | 402 | export 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 the | 450 | // 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), |