diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 19e7e7e03c6e3..dd663e5fb7f23 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -186,7 +186,7 @@ function PoolWindowCard({ const inlineLabels = wide && segmentWidth >= 96; const inlineReset = wide && segmentWidth >= 150; const inlineName = wide && segmentWidth >= 220; - const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); + const nextReset = pool.resets[0]; const openAccount = (account: LimitAccount) => navigation.navigate("SettingsUsageAccount", { accountKey: account.key, @@ -204,18 +204,25 @@ function PoolWindowCard({ {pool.remainingPercent}% - left + + {pool.members.length > 1 ? "average left" : "left"} + + {pool.members.length > 1 ? ( + + Across {pool.members.length} accounts + + ) : null} {pool.pace ? ( {PACE_LABEL[pool.pace]} ) : null} {description ? {description} : null} - {nextRefill ? ( + {nextReset ? ( - ↻ +{nextRefill.restoresPercent}%{" "} - {formatResetsIn(nextRefill.member.window, now) + Next account reset{" "} + {formatResetsIn(nextReset.member.window, now) ?.replace("resets in ", "in ") .replace("resets now", "now")} @@ -602,9 +609,11 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { Even pace: {evenPace}% left by now ) : null} - {reset && reset.restoresPercent > 0 ? ( + {reset && reset.member.refillPercent > 0 ? ( - Restores {reset.restoresPercent}% of the pool + After reset:{" "} + {Math.round(Math.min(100, remainingPercent(window) + reset.member.refillPercent))} + % left on this account ) : null} diff --git a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx index ab114fbbd6f21..a60f0a85025d8 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx @@ -2,12 +2,15 @@ import { EnvironmentId, ProviderDriverKind, ProviderInstanceId, + UsageLimitSourceId, type ServerProvider, } from "@t3tools/contracts"; -import { CHATGPT_USAGE_URL } from "@t3tools/shared/usageLimits"; +import { type LimitPresentations, CHATGPT_USAGE_URL } from "@t3tools/shared/usageLimits"; import { describe, expect, it } from "vite-plus/test"; import { render } from "../../../test/render"; +import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; +import "../../index.css"; import { UsageLimitsPooled } from "./UsageLimitsPooled"; const checkedAt = "2026-10-25T16:00:00.000Z"; @@ -63,3 +66,93 @@ describe("UsageLimitsPooled ChatGPT usage card", () => { .toBeVisible(); }); }); + +it("keeps the account label and reset summary on a one-account row of a multi-account provider", async () => { + const now = Date.parse("2026-10-28T01:00:00.000Z"); + const checkedAt = new Date(now).toISOString(); + const driver = ProviderDriverKind.make("codex"); + const presentations: LimitPresentations = new Map([ + [ + EnvironmentId.make("demo"), + { + entry: { target: { label: "Demo" } }, + serverConfig: { + providers: [], + usageLimitSources: [ + { + id: UsageLimitSourceId.make("demo"), + kind: "cliproxy", + label: "Demo", + checkedAt, + accounts: [ + { + id: "session-only", + driver, + usageLimits: { + checkedAt, + windows: [ + { + id: "primary", + kind: "session", + label: "Session", + usedPercent: 0, + resetsAt: "2026-10-28T03:55:00.000Z", + }, + ], + }, + }, + { + id: "later-session", + driver, + usageLimits: { + checkedAt, + windows: [ + { + id: "primary", + kind: "session", + label: "Session", + usedPercent: 0.6, + resetsAt: "2026-10-28T05:00:00.000Z", + }, + ], + }, + }, + { + id: "monthly-only", + driver, + usageLimits: { + checkedAt, + windows: [ + { + id: "primary", + kind: "monthly", + label: "Monthly", + usedPercent: 2, + resetsAt: "2026-11-26T18:00:00.000Z", + }, + ], + }, + }, + ], + }, + ], + }, + }, + ], + ]); + const container = document.createElement("div"); + container.style.width = "1000px"; + await using screen = await render( + + + , + { container }, + ); + await expect.element(screen.getByText("monthly-only", { exact: true }).first()).toBeVisible(); + await expect.element(screen.getByText("Next account reset in 29d 17h")).toBeVisible(); + await expect.element(screen.getByText("Next account reset in 2h 55m")).toBeVisible(); + await expect.element(screen.getByText("average left", { exact: true })).toBeVisible(); + await expect.element(screen.getByText("Across 2 accounts", { exact: true })).toBeVisible(); + await screen.getByRole("button", { name: /^later-session: 99% left/ }).click(); + await expect.element(screen.getByText("100% left on this account")).toBeVisible(); +}); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index e55f5d3857f39..9ce8641ad2bc8 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -191,8 +191,11 @@ function SegmentPopover({ ) : null} {timeLeft !== null ? {timeLeft}% left by now : null} - {reset && reset.restoresPercent > 0 ? ( - +{reset.restoresPercent}% of pool + {reset && reset.member.refillPercent > 0 ? ( + + {Math.round(Math.min(100, remaining + reset.member.refillPercent))}% left on this + account + ) : null} {credits && redeem ? ( @@ -495,10 +498,12 @@ function PoolBar({ pool, color, now, + providerAccountCount, }: { readonly pool: LimitPoolWindow; readonly color: string; readonly now: number; + readonly providerAccountCount: number; }) { const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset])); return ( @@ -518,7 +523,7 @@ function PoolBar({ color={color} now={now} index={position + 1} - showAccountName={pool.columns.length > 1} + showAccountName={providerAccountCount > 1} /> ) : null, )} @@ -531,16 +536,18 @@ function PoolWindowCard({ pool, color, now, + providerAccountCount, label, description, }: { readonly pool: LimitPoolWindow; readonly color: string; readonly now: number; + readonly providerAccountCount: number; readonly label?: string | undefined; readonly description?: string | undefined; }) { - const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); + const nextReset = pool.resets[0]; return (
@@ -550,17 +557,23 @@ function PoolWindowCard({ {pool.remainingPercent}% - left + {pool.members.length > 1 ? "average left" : "left"} {pool.pace ? : null} - {nextRefill && pool.columns.length > 1 ? ( + {pool.members.length > 1 ? ( + + Across {pool.members.length} accounts + + ) : null} + {nextReset && providerAccountCount > 1 ? ( - ↻ +{nextRefill.restoresPercent}% + Next account reset{" "} + {formatResetsIn(nextReset.member.window, now)?.replace("resets ", "")} ) : null}
- + {description ? (

{description}

) : null} @@ -590,6 +603,7 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu { }); }); - it("preserves gaps without counting missing windows toward pooled quota", () => { + it("fills each row with only accounts reporting that window", () => { const [pool] = collectLimitPools( [ account("a", [window]), @@ -962,14 +962,35 @@ describe("pooled account columns", () => { now, ); expect(keys(pool!)).toEqual([ - ["a", "b", null], - [null, "b", "c"], + ["a", "b"], + ["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("gives two Codex session accounts equal slots and a monthly-only account its own full row", () => { + const codex = (key: string, windows: LimitAccount["limits"]["windows"]) => ({ + ...account(key, windows), + driver: ProviderDriverKind.make("codex"), + }); + const [pool] = collectLimitPools( + [ + codex("a", [window, { ...weekly, usedPercent: 90 }]), + codex("b", [window, { ...weekly, usedPercent: 100 }]), + codex("monthly", [{ ...window, kind: "monthly", usedPercent: 2 }]), + ], + now, + ); + expect(keys(pool!)).toEqual([["a", "b"], ["a", "b"], ["monthly"]]); + expect(pool!.windows[1]!.remainingPercent).toBe(5); + expect(pool!.windows[1]!.members.map((member) => 100 - member.window.usedPercent)).toEqual([ + 10, 0, + ]); + expect(pool!.windows[2]!.remainingPercent).toBe(98); + }); + it("falls back to weekly resets when no account reports a session", () => { const [pool] = collectLimitPools( [ diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 3dca207a2efb7..deb28f802d155 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -550,7 +550,6 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L } } const pools = [...byKey].map(([key, members]): LimitPoolWindow => { - const memberByAccount = new Map(members.map((member) => [member.account.key, member])); const first = members[0]!.window; const id = members[0]!.account.driver === "codex" ? key : first.id; const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; @@ -581,9 +580,7 @@ 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 }, - ), + columns: members, usedPercent: Math.round(usedPercent), remainingPercent: Math.round(100 - usedPercent), pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed),