From e9ee7921531f0f1947cac3074c6972c201ef081a 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 e4e819e5152b2e3bbff172519a49f75223aeb8e7 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 f3ee87e1b7f7b..2278b86f8a7c8 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 "./ContextWindowMeter.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 337e0bc5728f679b12da5a89fa734ac0da6c26ff 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 d25bdba81f00a..06a356239f5b3 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -150,7 +150,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 ccbe8f4ca203ff7ffb0c776c5675663656bcefa0 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 1831099236e4ecf4caaf7ff4067210d90c6240ac 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 2278b86f8a7c8..9aefbcc41741c 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 "./ContextWindowMeter.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 c8099964e253bf791fe27bfc8037b69ae43de914 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 06a356239f5b3..ff2b5c0482683 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -151,10 +151,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 b36389c669710ef498aa52690bd9f0bcaa87ef33 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 9aefbcc41741c..100ce5f5e922e 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 "./ContextWindowMeter.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 7e24a3ff7645ad7eed6e9108fe1f346677a24c4b 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 100ce5f5e922e..f376870afa29a 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 "./ContextWindowMeter.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 228a7b55087334090c33c912a5f889998e26b9fa 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 ff2b5c0482683..5dadf3fde08c5 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -151,10 +151,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