From 973233abedb3113334b2dd537077a61235e2e4bb Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Sun, 1 Nov 2026 23:51:12 -0500 Subject: [PATCH 1/9] fix(web): pool hub accounts for a proxied instance's thread limits An instance that sends requests through a CLIProxyAPI hub reads no limits of its own. The hub picks an account per request, so the thread spends the pool rather than any one account. Resolve the card's limits to the instance's own when readable, else the pooled windows of its driver's hub accounts, built with the Limits page's own pooling. --- .../chat/ThreadDetailsUsage.logic.test.ts | 184 ++++++++++++++++++ .../chat/ThreadDetailsUsage.logic.ts | 79 ++++++++ 2 files changed, 263 insertions(+) create mode 100644 apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts create mode 100644 apps/web/src/components/chat/ThreadDetailsUsage.logic.ts diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts new file mode 100644 index 0000000000000..74d18e4b30b27 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -0,0 +1,184 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageLimits, + type UsageLimitSourceSnapshot, + UsageLimitSourceId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; + +const now = Date.parse("2026-10-30T12:00:00.000Z"); +const checkedAt = "2026-10-30T11:59:00.000Z"; +const claude = ProviderDriverKind.make("claudeAgent"); +const codex = ProviderDriverKind.make("codex"); +const instanceId = ProviderInstanceId.make("claudeAgent"); + +function limitsAt(usedPercent: number, at = checkedAt): ServerProviderUsageLimits { + return { + checkedAt: at, + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent, + windowDurationMins: 300, + resetsAt: "2026-10-30T13:17:00.000Z", + }, + ], + }; +} + +const unsupported: ServerProviderUsageLimits = { + checkedAt, + windows: [], + unavailable: { reason: "unsupported" }, +}; + +function claudeInstance(usageLimits: ServerProviderUsageLimits): ServerProvider { + return { + instanceId, + driver: claude, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", type: "customEndpoint", label: "Custom API endpoint" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + usageLimits, + }; +} + +interface HubAccountInput { + readonly id: string; + readonly email?: string; + readonly driver?: ProviderDriverKind; + readonly limits: ServerProviderUsageLimits; +} + +function hub( + label: string, + accounts: readonly HubAccountInput[], + error?: string, +): UsageLimitSourceSnapshot { + return { + id: UsageLimitSourceId.make(label), + kind: "cliproxy", + label, + checkedAt, + accounts: accounts.map((account) => ({ + id: account.id, + driver: account.driver ?? claude, + ...(account.email ? { email: account.email } : {}), + usageLimits: account.limits, + })), + ...(error ? { error } : {}), + }; +} + +/** What the card shows, reduced to the rows a user reads. */ +function shown(own: ServerProviderUsageLimits, sources: readonly UsageLimitSourceSnapshot[]) { + const resolved = resolveThreadDetailsLimits(instanceId, [claudeInstance(own)], sources, now); + if (resolved?.kind !== "hub") return resolved; + return { + kind: resolved.kind, + hubs: resolved.hubLabels, + rows: resolved.windows.map((window) => ({ + label: window.label, + left: window.remainingPercent, + accounts: window.members.length, + })), + }; +} + +describe("resolveThreadDetailsLimits", () => { + it("shows nothing without a selected, known instance", () => { + expect(resolveThreadDetailsLimits(null, [claudeInstance(unsupported)], [], now)).toBeNull(); + expect( + resolveThreadDetailsLimits(ProviderInstanceId.make("gone"), [], [hub("Hub", [])], now), + ).toBeNull(); + }); + + it("keeps an instance's own readable limits over any hub", () => { + const own = limitsAt(40); + expect(shown(own, [hub("Hub", [{ id: "a.json", limits: limitsAt(90) }])])).toEqual({ + kind: "instance", + limits: own, + }); + }); + + it.each([ + { + name: "one hub account", + sources: [hub("Hub", [{ id: "a.json", email: "a@example.com", limits: limitsAt(19) }])], + hubs: ["Hub"], + rows: [{ label: "Session", left: 81, accounts: 1 }], + }, + { + name: "several accounts, pooled like Usage → Limits", + sources: [ + hub("Hub", [ + { id: "a.json", email: "a@example.com", limits: limitsAt(20) }, + { id: "b.json", email: "b@example.com", limits: limitsAt(60) }, + ]), + ], + hubs: ["Hub"], + rows: [{ label: "Session", left: 60, accounts: 2 }], + }, + { + name: "one login reported by two hubs, counted once at its freshest reading", + sources: [ + hub("Old", [{ id: "a.json", email: "a@example.com", limits: limitsAt(90) }]), + hub("New", [ + { + id: "a.json", + email: "a@example.com", + limits: limitsAt(30, "2026-10-30T11:59:30.000Z"), + }, + ]), + ], + hubs: ["Old", "New"], + rows: [{ label: "Session", left: 70, accounts: 1 }], + }, + { + name: "accounts across hubs, skipping a hub that failed to read", + sources: [ + hub("Home", [{ id: "a.json", email: "a@example.com", limits: limitsAt(20) }]), + hub("Work", [{ id: "b.json", email: "b@example.com", limits: limitsAt(40) }]), + hub("Down", [], "The hub answered with HTTP 404."), + ], + hubs: ["Home", "Work"], + rows: [{ label: "Session", left: 70, accounts: 2 }], + }, + { + name: "only this driver's readable accounts", + sources: [ + hub("Hub", [ + { id: "a.json", email: "a@example.com", limits: limitsAt(50) }, + { id: "c.json", email: "c@example.com", driver: codex, limits: limitsAt(0) }, + { id: "d.json", email: "d@example.com", limits: unsupported }, + ]), + ], + hubs: ["Hub"], + rows: [{ label: "Session", left: 50, accounts: 1 }], + }, + ])("pools $name when the instance reads no limits", ({ sources, hubs, rows }) => { + expect(shown(unsupported, sources)).toEqual({ kind: "hub", hubs, rows }); + }); + + it("keeps the instance's notice when no hub has a readable account for its driver", () => { + const sources = [ + hub("Hub", [ + { id: "c.json", driver: codex, limits: limitsAt(10) }, + { id: "d.json", limits: unsupported }, + ]), + ]; + expect(shown(unsupported, sources)).toEqual({ kind: "instance", limits: unsupported }); + }); +}); diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts new file mode 100644 index 0000000000000..8c6e05bf684b7 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -0,0 +1,79 @@ +import { + EnvironmentId, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageLimits, + type UsageLimitSourceSnapshots, +} from "@t3tools/contracts"; +import { + collectLimitAccounts, + collectLimitPools, + collectProviderUsageLimits, + displayLimitWindows, + type LimitPoolWindow, + limitsNotice, +} from "@t3tools/shared/usageLimits"; + +export type ThreadDetailsLimits = + | { readonly kind: "instance"; readonly limits: ServerProviderUsageLimits } + | { + readonly kind: "hub"; + readonly windows: readonly LimitPoolWindow[]; + readonly hubLabels: readonly string[]; + }; + +// collectLimitAccounts keys its input by environment; the card reads one, so any id serves. +const CARD_ENVIRONMENT = EnvironmentId.make("thread-details"); + +/** + * The limits the thread details card shows for one provider instance: its own, or, when it reads + * none (as when it sends requests through a CLIProxyAPI hub), the pool of its driver's hub accounts. + * The hub picks an account per request, so a thread spends the pool rather than any one account. + */ +export function resolveThreadDetailsLimits( + providerInstanceId: ProviderInstanceId | null, + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, + now: number, +): ThreadDetailsLimits | null { + const provider = providers.find((entry) => entry.instanceId === providerInstanceId); + if (!provider) return null; + const report = collectProviderUsageLimits(provider.instanceId, providers, sources, now); + // Only this instance's data: the report keeps one instance per login, and any other entry may be + // a different login or workspace, so fall back to the instance's own snapshot. + const own = + report?.accounts.find((entry) => entry.instanceId === provider.instanceId)?.limits ?? + provider.usageLimits; + if (own && limitsNotice(own) === null) return { kind: "instance", limits: own }; + return hubPool(provider.driver, sources, now) ?? (own ? { kind: "instance", limits: own } : null); +} + +function hubPool( + driver: ServerProvider["driver"], + sources: UsageLimitSourceSnapshots, + now: number, +): ThreadDetailsLimits | null { + // No native providers: a login outside the hubs is not one the hub can route a request to. + const presentations = new Map([ + [ + CARD_ENVIRONMENT, + { + entry: { target: { label: "" } }, + serverConfig: { providers: [], usageLimitSources: sources }, + }, + ], + ]); + const accounts = collectLimitAccounts(presentations).filter( + (account) => account.driver === driver, + ); + const [pool] = collectLimitPools(accounts, now); + if (!pool) return null; + const hubLabels = sources + .filter((source) => + source.accounts.some( + (account) => account.driver === driver && limitsNotice(account.usageLimits) === null, + ), + ) + .map((source) => source.label); + return { kind: "hub", windows: displayLimitWindows(pool), hubLabels }; +} From 43d2d11165a3721a32ab65169c9da7077d9f48b9 Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Sun, 1 Nov 2026 23:51:23 -0500 Subject: [PATCH 2/9] fix(web): thread details card shows a hub's pooled limits Rows read the pool's room left; the tooltip names the account count, the next reset and what it restores, the pace, and the hub. --- .../components/chat/ThreadDetailsUsage.tsx | 110 ++++++++++++++---- 1 file changed, 88 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx index 0d6c0129484ed..90ff97fcf84f5 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.tsx +++ b/apps/web/src/components/chat/ThreadDetailsUsage.tsx @@ -1,13 +1,15 @@ import type { ProviderInstanceId, ServerProvider, + ServerProviderUsageLimits, ServerProviderUsageWindow, UsageLimitSourceSnapshots, } from "@t3tools/contracts"; import { - collectProviderUsageLimits, formatDuration, + formatResetsIn, type LimitPace, + type LimitPoolWindow, limitsNotice, paceOf, remainingPercent, @@ -23,6 +25,7 @@ import { formatUpcomingTimestamp } from "../../timestampFormat"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { formatContextWindowCompactionMessage } from "./compaction.logic"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; +import { resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; const PACE_LABEL: Record = { ahead: "Ahead of pace", @@ -39,7 +42,10 @@ export interface ThreadDetailsUsageInput { type UsageTone = "error" | "warning" | null; -/** The selected provider's limit windows. Each row reads as room left. */ +/** + * The selected provider's limit windows, or its hub accounts' pooled windows when it reads none. + * Each row reads as room left. + */ export function ThreadDetailsUsage({ providerInstanceId, providers, @@ -47,17 +53,8 @@ export function ThreadDetailsUsage({ }: Omit) { const nowMinute = useNowMinute(); const now = Date.parse(`${nowMinute}:00Z`); - const report = - providerInstanceId !== null - ? collectProviderUsageLimits(providerInstanceId, providers, sources, now) - : null; - // Only this instance's data: the report keeps one instance per login, and any other entry may be - // a different login or workspace, so fall back to the instance's own snapshot. - const limits = - report?.accounts.find((entry) => entry.instanceId === providerInstanceId)?.limits ?? - providers.find((provider) => provider.instanceId === providerInstanceId)?.usageLimits; - if (!limits) return null; - const notice = limitsNotice(limits); + const resolved = resolveThreadDetailsLimits(providerInstanceId, providers, sources, now); + if (!resolved) return null; return (
- {notice ? ( - {notice}} - /> + {resolved.kind === "hub" ? ( + resolved.windows.map((window) => ( + + )) ) : ( - limits.windows.map((window) => ) + )}
); } +function InstanceRows({ + limits, + now, +}: { + readonly limits: ServerProviderUsageLimits; + readonly now: number; +}) { + const notice = limitsNotice(limits); + if (notice) { + return ( + {notice}} + /> + ); + } + return limits.windows.map((window) => ); +} + /** The thread's context window. The bar fills as context is used; the tick marks auto-compaction. */ export function ThreadDetailsContext({ usage }: { readonly usage: ContextWindowSnapshot }) { const maxTokens = usage.maxTokens != null && usage.maxTokens > 0 ? usage.maxTokens : null; @@ -174,6 +189,57 @@ function LimitRow({ ); } +function PoolRow({ + window, + hubLabels, + now, +}: { + readonly window: LimitPoolWindow; + readonly hubLabels: readonly string[]; + readonly now: number; +}) { + const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); + const left = window.remainingPercent; + const accounts = window.members.length; + const next = window.resets.find((reset) => reset.at > now); + const nextResetsAt = next?.member.window.resetsAt; + const resetsIn = next ? formatResetsIn(next.member.window, now) : null; + return ( + + + {window.label}: {left}% left + {accounts > 1 ? ` across ${accounts} accounts` : ""} + + {next && nextResetsAt && resetsIn ? ( + + {poolResetLabel(resetsIn, accounts > 1 ? next.restoresPercent : null)} ·{" "} + {formatUpcomingTimestamp(nextResetsAt, timestampFormat, now)} + + ) : null} + {window.pace ? ( + {PACE_LABEL[window.pace]} + ) : null} + From {hubLabels.join(", ")} + + } + /> + ); +} + +/** "Resets in 5m" for one account; "+12% in 5m" when several share the pool. */ +function poolResetLabel(resetsIn: string, restoresPercent: number | null): string { + if (restoresPercent === null) return resetsIn.replace("resets in", "Resets in"); + const when = resetsIn.replace("resets in", "in").replace("Resets momentarily", "momentarily"); + return `+${restoresPercent}% ${when}`; +} + /** One label, hairline, and value row; the whole row opens the details tooltip. */ function UsageRow({ label, From 71c21d50d9f948bb3b8659b20e07741f1c66e0ae Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Sun, 1 Nov 2026 23:51:33 -0500 Subject: [PATCH 3/9] docs: thread details card pools a hub's accounts --- docs/user/usage.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/user/usage.md b/docs/user/usage.md index d7e0dad901bef..908f14f3fac75 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -146,7 +146,9 @@ anything. The command is offered only for providers that appear under **Usage To keep them in view on web and desktop, turn on **Usage limits** in **Settings → Appearance → Thread details**, or right-click the thread details card. The bottom of the thread details card then shows how much is left in each limit window of the current provider account. Some windows, such as a weekly Opus limit, count only one model. Hover a -row to see when it resets and your pace. Turn on **Context window** to also show the thread's +row to see when it resets and your pace. A provider that sends requests through a CLIProxyAPI hub +reports no limits of its own, so the card shows the hub's accounts for that provider pooled, as +**Usage → Limits** does. Turn on **Context window** to also show the thread's context use against the model's limit. A tick on the bar marks where the provider compacts automatically, when it reports that point. From 45d194cb51bf7941216a725a7dee0f7d52745169 Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Wed, 4 Nov 2026 09:16:07 -0500 Subject: [PATCH 4/9] fix(web): thread details card waits for an instance's first reading Before the instance's first limits probe, usageLimits is undefined. The card showed the hub pool in that gap, so a directly signed-in instance flashed another account's pool at startup. main shows nothing there; keep that. --- .../web/src/components/chat/ThreadDetailsUsage.logic.test.ts | 5 ++++- apps/web/src/components/chat/ThreadDetailsUsage.logic.ts | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index 74d18e4b30b27..81a5f81fa6929 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -98,11 +98,14 @@ function shown(own: ServerProviderUsageLimits, sources: readonly UsageLimitSourc } describe("resolveThreadDetailsLimits", () => { - it("shows nothing without a selected, known instance", () => { + it("shows nothing without a selected, known instance, or before its first reading", () => { expect(resolveThreadDetailsLimits(null, [claudeInstance(unsupported)], [], now)).toBeNull(); expect( resolveThreadDetailsLimits(ProviderInstanceId.make("gone"), [], [hub("Hub", [])], now), ).toBeNull(); + const { usageLimits: _unread, ...unread } = claudeInstance(unsupported); + const readableHub = hub("Hub", [{ id: "a.json", limits: limitsAt(20) }]); + expect(resolveThreadDetailsLimits(instanceId, [unread], [readableHub], now)).toBeNull(); }); it("keeps an instance's own readable limits over any hub", () => { diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 8c6e05bf684b7..513a0241631f6 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -44,8 +44,9 @@ export function resolveThreadDetailsLimits( const own = report?.accounts.find((entry) => entry.instanceId === provider.instanceId)?.limits ?? provider.usageLimits; - if (own && limitsNotice(own) === null) return { kind: "instance", limits: own }; - return hubPool(provider.driver, sources, now) ?? (own ? { kind: "instance", limits: own } : null); + if (!own) return null; + if (limitsNotice(own) === null) return { kind: "instance", limits: own }; + return hubPool(provider.driver, sources, now) ?? { kind: "instance", limits: own }; } function hubPool( From 2639658c248a81082bafdfa8b3246f8f958b6dad Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Wed, 4 Nov 2026 11:08:33 -0500 Subject: [PATCH 5/9] fix(web): pooled limits name the next refill that restores quota The card's reset line took the soonest future reset even when it restored 0%, so a pool with an unused account read "+0% in 10m" and hid the next real refill. Usage -> Limits skips those resets; the card now does the same for a pool and keeps the plain countdown for one account. PoolRow also drops under the complexity limit: the reset line is its own component and the remaining-percent tone is one helper shared with LimitRow instead of two copies of a nested ternary. --- .../chat/ThreadDetailsUsage.logic.test.ts | 30 ++++++++++++- .../chat/ThreadDetailsUsage.logic.ts | 8 ++++ .../components/chat/ThreadDetailsUsage.tsx | 45 +++++++++++++------ 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index 81a5f81fa6929..9deb00aa44414 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -8,7 +8,7 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; +import { nextPoolReset, resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; const now = Date.parse("2026-10-30T12:00:00.000Z"); const checkedAt = "2026-10-30T11:59:00.000Z"; @@ -32,6 +32,11 @@ function limitsAt(usedPercent: number, at = checkedAt): ServerProviderUsageLimit }; } +function limitsResettingAt(usedPercent: number, resetsAt: string): ServerProviderUsageLimits { + const [window] = limitsAt(usedPercent).windows; + return { checkedAt, windows: [{ ...window!, resetsAt }] }; +} + const unsupported: ServerProviderUsageLimits = { checkedAt, windows: [], @@ -175,6 +180,29 @@ describe("resolveThreadDetailsLimits", () => { expect(shown(unsupported, sources)).toEqual({ kind: "hub", hubs, rows }); }); + it("names the next reset that refills quota, skipping an unused account's 0% reset", () => { + const soon = "2026-10-30T12:10:00.000Z"; + const later = "2026-10-30T14:00:00.000Z"; + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [ + hub("Hub", [ + { id: "a.json", email: "a@example.com", limits: limitsResettingAt(0, soon) }, + { id: "b.json", email: "b@example.com", limits: limitsResettingAt(80, later) }, + ]), + ], + now, + ); + if (resolved?.kind !== "hub") throw new Error("expected a hub pool"); + const [window] = resolved.windows; + expect(window!.resets.map((reset) => reset.restoresPercent)).toEqual([0, 40]); + expect(nextPoolReset(window!, now)).toMatchObject({ + at: Date.parse(later), + restoresPercent: 40, + }); + }); + it("keeps the instance's notice when no hub has a readable account for its driver", () => { const sources = [ hub("Hub", [ diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 513a0241631f6..0832adc57be39 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -49,6 +49,14 @@ export function resolveThreadDetailsLimits( return hubPool(provider.driver, sources, now) ?? { kind: "instance", limits: own }; } +/** The reset the card names: the soonest for one account, the soonest that refills quota for a pool. */ +export function nextPoolReset(window: LimitPoolWindow, now: number) { + const pooled = window.members.length > 1; + return ( + window.resets.find((reset) => reset.at > now && (!pooled || reset.restoresPercent > 0)) ?? null + ); +} + function hubPool( driver: ServerProvider["driver"], sources: UsageLimitSourceSnapshots, diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx index 90ff97fcf84f5..94ca837bca2e9 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.tsx +++ b/apps/web/src/components/chat/ThreadDetailsUsage.tsx @@ -25,7 +25,7 @@ import { formatUpcomingTimestamp } from "../../timestampFormat"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { formatContextWindowCompactionMessage } from "./compaction.logic"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; -import { resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; +import { nextPoolReset, resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; const PACE_LABEL: Record = { ahead: "Ahead of pace", @@ -42,6 +42,12 @@ export interface ThreadDetailsUsageInput { type UsageTone = "error" | "warning" | null; +function limitTone(left: number): UsageTone { + if (left <= 10) return "error"; + if (left <= 20) return "warning"; + return null; +} + /** * The selected provider's limit windows, or its hub accounts' pooled windows when it reads none. * Each row reads as room left. @@ -168,7 +174,7 @@ function LimitRow({ settings.timestampFormat); const left = window.remainingPercent; const accounts = window.members.length; - const next = window.resets.find((reset) => reset.at > now); - const nextResetsAt = next?.member.window.resetsAt; - const resetsIn = next ? formatResetsIn(next.member.window, now) : null; return ( 1 ? ` across ${accounts} accounts` : ""} - {next && nextResetsAt && resetsIn ? ( - - {poolResetLabel(resetsIn, accounts > 1 ? next.restoresPercent : null)} ·{" "} - {formatUpcomingTimestamp(nextResetsAt, timestampFormat, now)} - - ) : null} + {window.pace ? ( {PACE_LABEL[window.pace]} ) : null} @@ -233,6 +230,28 @@ function PoolRow({ ); } +function PoolResetLine({ + window, + now, +}: { + readonly window: LimitPoolWindow; + readonly now: number; +}) { + const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); + const next = nextPoolReset(window, now); + if (!next) return null; + const resetsAt = next.member.window.resetsAt; + const resetsIn = formatResetsIn(next.member.window, now); + if (!resetsAt || !resetsIn) return null; + const restores = window.members.length > 1 ? next.restoresPercent : null; + return ( + + {poolResetLabel(resetsIn, restores)} ·{" "} + {formatUpcomingTimestamp(resetsAt, timestampFormat, now)} + + ); +} + /** "Resets in 5m" for one account; "+12% in 5m" when several share the pool. */ function poolResetLabel(resetsIn: string, restoresPercent: number | null): string { if (restoresPercent === null) return resetsIn.replace("resets in", "Resets in"); From 3e1ac45d8a6d260e1d4641ff5bca1a55afa61da5 Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Wed, 4 Nov 2026 11:11:00 -0500 Subject: [PATCH 6/9] fix(web): thread details pool merges signed-in accounts like Limits The card pooled hub accounts only, so a login that is also signed in natively kept the hub's older reading while Usage -> Limits showed the fresher one. The pool now takes the driver's signed-in and hub accounts together, deduped by login with the freshest reading, as Limits does. The card still falls back to the pool only when a hub has a readable account for the driver, so signed-in accounts alone never form one. --- .../chat/ThreadDetailsUsage.logic.test.ts | 46 +++++++++++++++++-- .../chat/ThreadDetailsUsage.logic.ts | 10 ++-- docs/user/usage.md | 8 ++-- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index 9deb00aa44414..9d82d53820300 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -60,6 +60,15 @@ function claudeInstance(usageLimits: ServerProviderUsageLimits): ServerProvider }; } +function signedIn(id: string, email: string, usageLimits: ServerProviderUsageLimits) { + const base = claudeInstance(usageLimits); + return { + ...base, + instanceId: ProviderInstanceId.make(id), + auth: { ...base.auth, email }, + } satisfies ServerProvider; +} + interface HubAccountInput { readonly id: string; readonly email?: string; @@ -88,8 +97,17 @@ function hub( } /** What the card shows, reduced to the rows a user reads. */ -function shown(own: ServerProviderUsageLimits, sources: readonly UsageLimitSourceSnapshot[]) { - const resolved = resolveThreadDetailsLimits(instanceId, [claudeInstance(own)], sources, now); +function shown( + own: ServerProviderUsageLimits, + sources: readonly UsageLimitSourceSnapshot[], + natives: readonly ServerProvider[] = [], +) { + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(own), ...natives], + sources, + now, + ); if (resolved?.kind !== "hub") return resolved; return { kind: resolved.kind, @@ -176,8 +194,22 @@ describe("resolveThreadDetailsLimits", () => { hubs: ["Hub"], rows: [{ label: "Session", left: 50, accounts: 1 }], }, - ])("pools $name when the instance reads no limits", ({ sources, hubs, rows }) => { - expect(shown(unsupported, sources)).toEqual({ kind: "hub", hubs, rows }); + { + name: "a login signed in natively and on a hub, counted once at its freshest reading", + sources: [hub("Hub", [{ id: "a.json", email: "a@example.com", limits: limitsAt(90) }])], + natives: [signedIn("claude-a", "a@example.com", limitsAt(30, "2026-10-30T11:59:30.000Z"))], + hubs: ["Hub"], + rows: [{ label: "Session", left: 70, accounts: 1 }], + }, + { + name: "a signed-in account no hub holds, alongside the hub's", + sources: [hub("Hub", [{ id: "a.json", email: "a@example.com", limits: limitsAt(20) }])], + natives: [signedIn("claude-b", "b@example.com", limitsAt(60))], + hubs: ["Hub"], + rows: [{ label: "Session", left: 60, accounts: 2 }], + }, + ])("pools $name when the instance reads no limits", ({ sources, natives, hubs, rows }) => { + expect(shown(unsupported, sources, natives)).toEqual({ kind: "hub", hubs, rows }); }); it("names the next reset that refills quota, skipping an unused account's 0% reset", () => { @@ -210,6 +242,10 @@ describe("resolveThreadDetailsLimits", () => { { id: "d.json", limits: unsupported }, ]), ]; - expect(shown(unsupported, sources)).toEqual({ kind: "instance", limits: unsupported }); + const natives = [signedIn("claude-b", "b@example.com", limitsAt(60))]; + expect(shown(unsupported, sources, natives)).toEqual({ + kind: "instance", + limits: unsupported, + }); }); }); diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 0832adc57be39..8af3f053cf22a 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -27,7 +27,7 @@ const CARD_ENVIRONMENT = EnvironmentId.make("thread-details"); /** * The limits the thread details card shows for one provider instance: its own, or, when it reads - * none (as when it sends requests through a CLIProxyAPI hub), the pool of its driver's hub accounts. + * none (as when it sends requests through a CLIProxyAPI hub), the pool of its driver's accounts. * The hub picks an account per request, so a thread spends the pool rather than any one account. */ export function resolveThreadDetailsLimits( @@ -46,7 +46,7 @@ export function resolveThreadDetailsLimits( provider.usageLimits; if (!own) return null; if (limitsNotice(own) === null) return { kind: "instance", limits: own }; - return hubPool(provider.driver, sources, now) ?? { kind: "instance", limits: own }; + return hubPool(provider.driver, providers, sources, now) ?? { kind: "instance", limits: own }; } /** The reset the card names: the soonest for one account, the soonest that refills quota for a pool. */ @@ -59,16 +59,16 @@ export function nextPoolReset(window: LimitPoolWindow, now: number) { function hubPool( driver: ServerProvider["driver"], + providers: readonly ServerProvider[], sources: UsageLimitSourceSnapshots, now: number, ): ThreadDetailsLimits | null { - // No native providers: a login outside the hubs is not one the hub can route a request to. const presentations = new Map([ [ CARD_ENVIRONMENT, { entry: { target: { label: "" } }, - serverConfig: { providers: [], usageLimitSources: sources }, + serverConfig: { providers, usageLimitSources: sources }, }, ], ]); @@ -76,7 +76,6 @@ function hubPool( (account) => account.driver === driver, ); const [pool] = collectLimitPools(accounts, now); - if (!pool) return null; const hubLabels = sources .filter((source) => source.accounts.some( @@ -84,5 +83,6 @@ function hubPool( ), ) .map((source) => source.label); + if (!pool || hubLabels.length === 0) return null; return { kind: "hub", windows: displayLimitWindows(pool), hubLabels }; } diff --git a/docs/user/usage.md b/docs/user/usage.md index 908f14f3fac75..ae715e123b6be 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -147,10 +147,10 @@ To keep them in view on web and desktop, turn on **Usage limits** in **Settings The bottom of the thread details card then shows how much is left in each limit window of the current provider account. Some windows, such as a weekly Opus limit, count only one model. Hover a row to see when it resets and your pace. A provider that sends requests through a CLIProxyAPI hub -reports no limits of its own, so the card shows the hub's accounts for that provider pooled, as -**Usage → Limits** does. Turn on **Context window** to also show the thread's -context use against the model's limit. A tick on the bar marks where the provider compacts -automatically, when it reports that point. +reports no limits of its own, so the card shows that provider's accounts pooled, as +**Usage → Limits** does: the hub's accounts and any signed-in ones. Turn on **Context window** to +also show the thread's context use against the model's limit. A tick on the bar marks where the +provider compacts automatically, when it reports that point. OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in the environment. T3 cannot report limits for external OpenCode servers because their credentials From c222155a0bf9820f465a0f9f17d163532347580a Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Wed, 4 Nov 2026 11:17:44 -0500 Subject: [PATCH 7/9] fix(web): pooled tooltip names where each window's readings come from The "From " line listed every hub with a readable account, so once signed-in accounts joined the pool it credited their quota to the hubs and labelled windows the hubs never reported. The line is now built per window from its members: the hubs holding its hub-only accounts, then "Signed-in accounts" when a signed-in instance holds one. The resolver comment no longer claims a thread spends the whole pool. --- .../chat/ThreadDetailsUsage.logic.test.ts | 29 +++++++---------- .../chat/ThreadDetailsUsage.logic.ts | 32 ++++++++++--------- .../components/chat/ThreadDetailsUsage.tsx | 23 +++++-------- 3 files changed, 36 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index 9d82d53820300..6087d44a283f3 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -8,7 +8,7 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { nextPoolReset, resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; +import { nextPoolReset, poolSources, resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; const now = Date.parse("2026-10-30T12:00:00.000Z"); const checkedAt = "2026-10-30T11:59:00.000Z"; @@ -111,11 +111,11 @@ function shown( if (resolved?.kind !== "hub") return resolved; return { kind: resolved.kind, - hubs: resolved.hubLabels, rows: resolved.windows.map((window) => ({ label: window.label, left: window.remainingPercent, accounts: window.members.length, + from: poolSources(window), })), }; } @@ -143,8 +143,7 @@ describe("resolveThreadDetailsLimits", () => { { name: "one hub account", sources: [hub("Hub", [{ id: "a.json", email: "a@example.com", limits: limitsAt(19) }])], - hubs: ["Hub"], - rows: [{ label: "Session", left: 81, accounts: 1 }], + rows: [{ label: "Session", left: 81, accounts: 1, from: ["Hub"] }], }, { name: "several accounts, pooled like Usage → Limits", @@ -154,8 +153,7 @@ describe("resolveThreadDetailsLimits", () => { { id: "b.json", email: "b@example.com", limits: limitsAt(60) }, ]), ], - hubs: ["Hub"], - rows: [{ label: "Session", left: 60, accounts: 2 }], + rows: [{ label: "Session", left: 60, accounts: 2, from: ["Hub"] }], }, { name: "one login reported by two hubs, counted once at its freshest reading", @@ -169,8 +167,7 @@ describe("resolveThreadDetailsLimits", () => { }, ]), ], - hubs: ["Old", "New"], - rows: [{ label: "Session", left: 70, accounts: 1 }], + rows: [{ label: "Session", left: 70, accounts: 1, from: ["Old"] }], }, { name: "accounts across hubs, skipping a hub that failed to read", @@ -179,8 +176,7 @@ describe("resolveThreadDetailsLimits", () => { hub("Work", [{ id: "b.json", email: "b@example.com", limits: limitsAt(40) }]), hub("Down", [], "The hub answered with HTTP 404."), ], - hubs: ["Home", "Work"], - rows: [{ label: "Session", left: 70, accounts: 2 }], + rows: [{ label: "Session", left: 70, accounts: 2, from: ["Home", "Work"] }], }, { name: "only this driver's readable accounts", @@ -191,25 +187,22 @@ describe("resolveThreadDetailsLimits", () => { { id: "d.json", email: "d@example.com", limits: unsupported }, ]), ], - hubs: ["Hub"], - rows: [{ label: "Session", left: 50, accounts: 1 }], + rows: [{ label: "Session", left: 50, accounts: 1, from: ["Hub"] }], }, { name: "a login signed in natively and on a hub, counted once at its freshest reading", sources: [hub("Hub", [{ id: "a.json", email: "a@example.com", limits: limitsAt(90) }])], natives: [signedIn("claude-a", "a@example.com", limitsAt(30, "2026-10-30T11:59:30.000Z"))], - hubs: ["Hub"], - rows: [{ label: "Session", left: 70, accounts: 1 }], + rows: [{ label: "Session", left: 70, accounts: 1, from: ["Signed-in accounts"] }], }, { name: "a signed-in account no hub holds, alongside the hub's", sources: [hub("Hub", [{ id: "a.json", email: "a@example.com", limits: limitsAt(20) }])], natives: [signedIn("claude-b", "b@example.com", limitsAt(60))], - hubs: ["Hub"], - rows: [{ label: "Session", left: 60, accounts: 2 }], + rows: [{ label: "Session", left: 60, accounts: 2, from: ["Hub", "Signed-in accounts"] }], }, - ])("pools $name when the instance reads no limits", ({ sources, natives, hubs, rows }) => { - expect(shown(unsupported, sources, natives)).toEqual({ kind: "hub", hubs, rows }); + ])("pools $name when the instance reads no limits", ({ sources, natives, rows }) => { + expect(shown(unsupported, sources, natives)).toEqual({ kind: "hub", rows }); }); it("names the next reset that refills quota, skipping an unused account's 0% reset", () => { diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 8af3f053cf22a..31cb456b0ae4f 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -16,11 +16,7 @@ import { export type ThreadDetailsLimits = | { readonly kind: "instance"; readonly limits: ServerProviderUsageLimits } - | { - readonly kind: "hub"; - readonly windows: readonly LimitPoolWindow[]; - readonly hubLabels: readonly string[]; - }; + | { readonly kind: "hub"; readonly windows: readonly LimitPoolWindow[] }; // collectLimitAccounts keys its input by environment; the card reads one, so any id serves. const CARD_ENVIRONMENT = EnvironmentId.make("thread-details"); @@ -28,7 +24,6 @@ const CARD_ENVIRONMENT = EnvironmentId.make("thread-details"); /** * The limits the thread details card shows for one provider instance: its own, or, when it reads * none (as when it sends requests through a CLIProxyAPI hub), the pool of its driver's accounts. - * The hub picks an account per request, so a thread spends the pool rather than any one account. */ export function resolveThreadDetailsLimits( providerInstanceId: ProviderInstanceId | null, @@ -76,13 +71,20 @@ function hubPool( (account) => account.driver === driver, ); const [pool] = collectLimitPools(accounts, now); - const hubLabels = sources - .filter((source) => - source.accounts.some( - (account) => account.driver === driver && limitsNotice(account.usageLimits) === null, - ), - ) - .map((source) => source.label); - if (!pool || hubLabels.length === 0) return null; - return { kind: "hub", windows: displayLimitWindows(pool), hubLabels }; + const hubReadsDriver = sources.some((source) => + source.accounts.some( + (account) => account.driver === driver && limitsNotice(account.usageLimits) === null, + ), + ); + if (!pool || !hubReadsDriver) return null; + return { kind: "hub", windows: displayLimitWindows(pool) }; +} + +/** Where a pooled window's readings come from: the hubs holding its hub-only accounts, then signed-in accounts. */ +export function poolSources(window: LimitPoolWindow): string[] { + const hubs = window.members.flatMap(({ account }) => + account.environments.length === 0 && account.sourceLabel ? [account.sourceLabel] : [], + ); + const signedIn = window.members.some(({ account }) => account.environments.length > 0); + return [...new Set(hubs), ...(signedIn ? ["Signed-in accounts"] : [])]; } diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx index 94ca837bca2e9..75ac3d48d9413 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.tsx +++ b/apps/web/src/components/chat/ThreadDetailsUsage.tsx @@ -25,7 +25,7 @@ import { formatUpcomingTimestamp } from "../../timestampFormat"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { formatContextWindowCompactionMessage } from "./compaction.logic"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; -import { nextPoolReset, resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; +import { nextPoolReset, poolSources, resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; const PACE_LABEL: Record = { ahead: "Ahead of pace", @@ -49,7 +49,7 @@ function limitTone(left: number): UsageTone { } /** - * The selected provider's limit windows, or its hub accounts' pooled windows when it reads none. + * The selected provider's limit windows, or its driver's pooled accounts when it reads none. * Each row reads as room left. */ export function ThreadDetailsUsage({ @@ -70,9 +70,7 @@ export function ThreadDetailsUsage({ >
{resolved.kind === "hub" ? ( - resolved.windows.map((window) => ( - - )) + resolved.windows.map((window) => ) ) : ( )} @@ -195,16 +193,9 @@ function LimitRow({ ); } -function PoolRow({ - window, - hubLabels, - now, -}: { - readonly window: LimitPoolWindow; - readonly hubLabels: readonly string[]; - readonly now: number; -}) { +function PoolRow({ window, now }: { readonly window: LimitPoolWindow; readonly now: number }) { const left = window.remainingPercent; + const from = poolSources(window); const accounts = window.members.length; return ( {PACE_LABEL[window.pace]} ) : null} - From {hubLabels.join(", ")} + {from.length > 0 ? ( + From {from.join(", ")} + ) : null} } /> From 299325d47509099285d3f870e69b60046ace961e Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Wed, 4 Nov 2026 11:29:08 -0500 Subject: [PATCH 8/9] refactor(web): thread details pool reads plainly and is named for what it holds The pooled reset line formatted its wording by string-replacing another formatter's English copy; it now builds it from the duration. The union tag and helper said hub though the pool also holds signed-in accounts. The driver pool checks the cheap hub gate before building accounts. --- .../chat/ThreadDetailsUsage.logic.test.ts | 6 +++--- .../chat/ThreadDetailsUsage.logic.ts | 21 ++++++++++--------- .../components/chat/ThreadDetailsUsage.tsx | 21 +++++++------------ 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index 6087d44a283f3..3ccfb8d8af062 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -108,7 +108,7 @@ function shown( sources, now, ); - if (resolved?.kind !== "hub") return resolved; + if (resolved?.kind !== "pool") return resolved; return { kind: resolved.kind, rows: resolved.windows.map((window) => ({ @@ -202,7 +202,7 @@ describe("resolveThreadDetailsLimits", () => { rows: [{ label: "Session", left: 60, accounts: 2, from: ["Hub", "Signed-in accounts"] }], }, ])("pools $name when the instance reads no limits", ({ sources, natives, rows }) => { - expect(shown(unsupported, sources, natives)).toEqual({ kind: "hub", rows }); + expect(shown(unsupported, sources, natives)).toEqual({ kind: "pool", rows }); }); it("names the next reset that refills quota, skipping an unused account's 0% reset", () => { @@ -219,7 +219,7 @@ describe("resolveThreadDetailsLimits", () => { ], now, ); - if (resolved?.kind !== "hub") throw new Error("expected a hub pool"); + if (resolved?.kind !== "pool") throw new Error("expected a pool"); const [window] = resolved.windows; expect(window!.resets.map((reset) => reset.restoresPercent)).toEqual([0, 40]); expect(nextPoolReset(window!, now)).toMatchObject({ diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 31cb456b0ae4f..67ad40711201e 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -16,7 +16,7 @@ import { export type ThreadDetailsLimits = | { readonly kind: "instance"; readonly limits: ServerProviderUsageLimits } - | { readonly kind: "hub"; readonly windows: readonly LimitPoolWindow[] }; + | { readonly kind: "pool"; readonly windows: readonly LimitPoolWindow[] }; // collectLimitAccounts keys its input by environment; the card reads one, so any id serves. const CARD_ENVIRONMENT = EnvironmentId.make("thread-details"); @@ -41,7 +41,7 @@ export function resolveThreadDetailsLimits( provider.usageLimits; if (!own) return null; if (limitsNotice(own) === null) return { kind: "instance", limits: own }; - return hubPool(provider.driver, providers, sources, now) ?? { kind: "instance", limits: own }; + return driverPool(provider.driver, providers, sources, now) ?? { kind: "instance", limits: own }; } /** The reset the card names: the soonest for one account, the soonest that refills quota for a pool. */ @@ -52,12 +52,18 @@ export function nextPoolReset(window: LimitPoolWindow, now: number) { ); } -function hubPool( +function driverPool( driver: ServerProvider["driver"], providers: readonly ServerProvider[], sources: UsageLimitSourceSnapshots, now: number, ): ThreadDetailsLimits | null { + const hubReadsDriver = sources.some((source) => + source.accounts.some( + (account) => account.driver === driver && limitsNotice(account.usageLimits) === null, + ), + ); + if (!hubReadsDriver) return null; const presentations = new Map([ [ CARD_ENVIRONMENT, @@ -71,13 +77,8 @@ function hubPool( (account) => account.driver === driver, ); const [pool] = collectLimitPools(accounts, now); - const hubReadsDriver = sources.some((source) => - source.accounts.some( - (account) => account.driver === driver && limitsNotice(account.usageLimits) === null, - ), - ); - if (!pool || !hubReadsDriver) return null; - return { kind: "hub", windows: displayLimitWindows(pool) }; + if (!pool) return null; + return { kind: "pool", windows: displayLimitWindows(pool) }; } /** Where a pooled window's readings come from: the hubs holding its hub-only accounts, then signed-in accounts. */ diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx index 75ac3d48d9413..1c1a80cfd2197 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.tsx +++ b/apps/web/src/components/chat/ThreadDetailsUsage.tsx @@ -7,7 +7,6 @@ import type { } from "@t3tools/contracts"; import { formatDuration, - formatResetsIn, type LimitPace, type LimitPoolWindow, limitsNotice, @@ -27,6 +26,8 @@ import { formatContextWindowCompactionMessage } from "./compaction.logic"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { nextPoolReset, poolSources, resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; +const MINUTE_MS = 60_000; + const PACE_LABEL: Record = { ahead: "Ahead of pace", on: "On pace", @@ -69,7 +70,7 @@ export function ThreadDetailsUsage({ showHeading={false} >
- {resolved.kind === "hub" ? ( + {resolved.kind === "pool" ? ( resolved.windows.map((window) => ) ) : ( @@ -234,24 +235,18 @@ function PoolResetLine({ const next = nextPoolReset(window, now); if (!next) return null; const resetsAt = next.member.window.resetsAt; - const resetsIn = formatResetsIn(next.member.window, now); - if (!resetsAt || !resetsIn) return null; + if (!resetsAt) return null; + const remaining = next.at - now; + const when = remaining < MINUTE_MS ? "momentarily" : `in ${formatDuration(remaining)}`; const restores = window.members.length > 1 ? next.restoresPercent : null; + const label = restores === null ? `Resets ${when}` : `+${restores}% ${when}`; return ( - {poolResetLabel(resetsIn, restores)} ·{" "} - {formatUpcomingTimestamp(resetsAt, timestampFormat, now)} + {label} · {formatUpcomingTimestamp(resetsAt, timestampFormat, now)} ); } -/** "Resets in 5m" for one account; "+12% in 5m" when several share the pool. */ -function poolResetLabel(resetsIn: string, restoresPercent: number | null): string { - if (restoresPercent === null) return resetsIn.replace("resets in", "Resets in"); - const when = resetsIn.replace("resets in", "in").replace("Resets momentarily", "momentarily"); - return `+${restoresPercent}% ${when}`; -} - /** One label, hairline, and value row; the whole row opens the details tooltip. */ function UsageRow({ label, From 0184bdc4efa74a862c466b15ec74f3e9646f125f Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Wed, 4 Nov 2026 11:29:19 -0500 Subject: [PATCH 9/9] docs(usage): thread details pool is this environment's accounts Usage -> Limits pools every connected environment; the card pools the thread's own, since a thread cannot spend another machine's accounts. --- docs/user/usage.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/user/usage.md b/docs/user/usage.md index ae715e123b6be..ab53b913b80e0 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -147,10 +147,11 @@ To keep them in view on web and desktop, turn on **Usage limits** in **Settings The bottom of the thread details card then shows how much is left in each limit window of the current provider account. Some windows, such as a weekly Opus limit, count only one model. Hover a row to see when it resets and your pace. A provider that sends requests through a CLIProxyAPI hub -reports no limits of its own, so the card shows that provider's accounts pooled, as -**Usage → Limits** does: the hub's accounts and any signed-in ones. Turn on **Context window** to -also show the thread's context use against the model's limit. A tick on the bar marks where the -provider compacts automatically, when it reports that point. +reports no limits of its own, so the card shows that provider's accounts pooled, the hub's and +any signed-in ones, from this environment only. **Usage → Limits** pools every connected +environment. Turn on **Context window** to also show the thread's context use against the +model's limit. A tick on the bar marks where the provider compacts automatically, when it reports +that point. OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in the environment. T3 cannot report limits for external OpenCode servers because their credentials