diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 218a7f21feb1..b07f65d739e2 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -182,7 +182,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, @@ -202,18 +202,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")} @@ -598,9 +605,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 ab114fbbd6f2..8cca10b9e70c 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx @@ -2,12 +2,16 @@ 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 { page } from "vite-plus/test/browser"; 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 +67,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(page.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 8ec1c7a02afc..e98880ce6645 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -189,8 +189,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} {choices.length > 0 && redeem ? ( @@ -498,10 +501,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 ( @@ -521,7 +526,7 @@ function PoolBar({ color={normalizeProviderAccentColor(member.account.accentColor) ?? color} now={now} index={position + 1} - showAccountName={pool.columns.length > 1} + showAccountName={providerAccountCount > 1} /> ) : pool.kind === "monthly" && member.account.limits.zeroSpendCap ? (
reset.restoresPercent > 0); + const nextReset = pool.resets[0]; return (
@@ -567,17 +574,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} @@ -608,6 +621,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]), @@ -1352,18 +1352,49 @@ describe("pooled account columns", () => { { ...weekly, usedPercent: 80 }, ]), account("c", [weekly]), + { + ...account("capped", []), + limits: { checkedAt: "2026-09-03T11:00:00.000Z", windows: [], zeroSpendCap: true }, + }, + account("monthly", [{ ...window, kind: "monthly" }]), ], now, ); expect(keys(pool!)).toEqual([ - ["a", "b", null], - [null, "b", "c"], + ["a", "b"], + ["b", "c"], + [null, "monthly"], + ]); + expect(pool!.windows[2]!.columns.map(({ account }) => account.key)).toEqual([ + "capped", + "monthly", ]); 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 6fe6c70a89a4..e65d6c3fb337 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -693,9 +693,14 @@ 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: accounts.flatMap((account) => { + const member = memberByAccount.get(account.key); + return member + ? [member] + : first.kind === "monthly" && account.limits.zeroSpendCap + ? [{ account, window: null }] + : []; + }), usedPercent: Math.round(usedPercent), remainingPercent: Math.round(100 - usedPercent), pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed),