From 1146b7e59d171b9dd668c9e66390fd459ea6e8e3 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 4 Sep 2026 16:22:42 +0530 Subject: [PATCH 1/4] feat(web): show provider usage limits in sidebar Adds a sidebar indicator showing the active thread's provider usage limits with a hover panel for all windows, plus a settings toggle to hide it. --- .../src/components/sidebar/SidebarChrome.tsx | 2 + .../components/sidebar/SidebarUsageLimits.tsx | 230 ++++++++++++++++++ apps/web/src/components/usage/UsageLimits.tsx | 26 +- docs/user/usage.md | 5 + packages/contracts/src/settings.ts | 2 + 5 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/sidebar/SidebarUsageLimits.tsx diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 4f115a751422..7aebd81de81c 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -32,6 +32,7 @@ import { import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; +import { SidebarUsageLimits } from "./SidebarUsageLimits"; import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; export const SidebarChromeHeader = memo(function SidebarChromeHeader({ @@ -226,6 +227,7 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { + ); diff --git a/apps/web/src/components/sidebar/SidebarUsageLimits.tsx b/apps/web/src/components/sidebar/SidebarUsageLimits.tsx new file mode 100644 index 000000000000..471b3b078c6a --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUsageLimits.tsx @@ -0,0 +1,230 @@ +import type { + ProviderDriverKind, + ServerConfig, + ServerProvider, + ServerProviderUsageWindow, + UsageLimitSourceAccount, + UsageProviderKind, +} from "@t3tools/contracts"; +import { formatResetsIn, providerLimitsLabel } from "@t3tools/shared/usageLimits"; +import { useParams } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; + +import { useComposerDraftStore } from "../../composerDraftStore"; +import { useClientSettings } from "../../hooks/useSettings"; +import { useServerConfigs, useThread } from "../../state/entities"; +import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "../../threadRoutes"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { PROVIDER_PRESENTATION } from "../usage/usageProviders"; + +function compactDuration(minutes: number | undefined): string | null { + if (minutes === undefined) return null; + if (minutes % 1_440 === 0) return `${minutes / 1_440}d`; + if (minutes % 60 === 0) return `${minutes / 60}h`; + return `${minutes}m`; +} + +function remainingPercent(window: ServerProviderUsageWindow): number { + return Math.max(0, Math.min(100, 100 - window.usedPercent)); +} + +function compareWindowDuration( + left: ServerProviderUsageWindow, + right: ServerProviderUsageWindow, +): number { + return ( + (left.windowDurationMins ?? Number.POSITIVE_INFINITY) - + (right.windowDurationMins ?? Number.POSITIVE_INFINITY) + ); +} + +function providerColor(driver: ProviderDriverKind): string { + const kind: UsageProviderKind | null = + driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : null; + return kind === null ? "var(--sidebar-foreground)" : PROVIDER_PRESENTATION[kind].color; +} + +function normalizedEmail(value: string | undefined): string | null { + const email = value?.trim().toLowerCase(); + return email ? email : null; +} + +function sourceAccountForProvider( + config: ServerConfig, + provider: ServerProvider, +): UsageLimitSourceAccount | null { + const accounts = (config.usageLimitSources ?? []).flatMap((source) => source.accounts); + const sameDriver = accounts.filter((account) => account.driver === provider.driver); + const providerEmail = normalizedEmail(provider.auth.email); + if (providerEmail !== null) { + return sameDriver.find((account) => normalizedEmail(account.email) === providerEmail) ?? null; + } + return sameDriver.length === 1 ? (sameDriver[0] ?? null) : null; +} + +function windowsForProvider( + config: ServerConfig, + provider: ServerProvider, +): ReadonlyArray { + if ( + provider.usageLimits !== undefined && + provider.usageLimits.unavailable === undefined && + provider.usageLimits.windows.length > 0 + ) { + return provider.usageLimits.windows; + } + const sourceAccount = sourceAccountForProvider(config, provider); + if ( + sourceAccount?.usageLimits.unavailable === undefined && + sourceAccount?.usageLimits.windows.length + ) { + return sourceAccount.usageLimits.windows; + } + return []; +} + +function windowLabel(window: ServerProviderUsageWindow): string { + const duration = compactDuration(window.windowDurationMins); + return duration === null ? window.label : `${window.label} · ${duration}`; +} + +function UsageWindowBar({ + window, + color, + now, +}: { + readonly window: ServerProviderUsageWindow; + readonly color: string; + readonly now: number; +}) { + const remaining = remainingPercent(window); + const reset = formatResetsIn(window, now); + return ( +
+
+ + {windowLabel(window)} + + + {Math.round(remaining)}% left + +
+
+
+
+ {reset ? ( + + {reset} + + ) : null} +
+ ); +} + +export function SidebarUsageLimits() { + const enabled = useClientSettings((settings) => settings.sidebarUsageLimitsEnabled); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const draftSession = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + const routeThreadRef = useMemo( + () => resolveActiveThreadRouteRef(routeTarget, draftSession), + [draftSession, routeTarget], + ); + const activeThread = useThread(routeThreadRef); + const composerDraft = useComposerDraftStore((store) => { + if (routeTarget?.kind === "draft") return store.getComposerDraft(routeTarget.draftId); + if (routeTarget?.kind === "server") return store.getComposerDraft(routeTarget.threadRef); + return null; + }); + const serverConfigs = useServerConfigs(); + const [now] = useState(() => Date.now()); + const environmentId = + routeTarget?.kind === "server" + ? routeTarget.threadRef.environmentId + : (draftSession?.environmentId ?? null); + const instanceId = + composerDraft?.activeProvider ?? + activeThread?.session?.providerInstanceId ?? + activeThread?.modelSelection.instanceId ?? + null; + if (!enabled) return null; + if (environmentId === null || instanceId === null) return null; + const config = serverConfigs.get(environmentId); + const provider = config?.providers.find((candidate) => candidate.instanceId === instanceId); + if (!config || !provider) return null; + const windows = windowsForProvider(config, provider).toSorted(compareWindowDuration); + const shortestWindow = windows[0]; + if (!shortestWindow) return null; + const color = providerColor(provider.driver); + const providerLabel = providerLimitsLabel(provider, (driver) => getDriverOption(driver)?.label); + const shortestRemaining = remainingPercent(shortestWindow); + const shortestDuration = compactDuration(shortestWindow.windowDurationMins); + const summary = `${providerLabel} ${shortestWindow.label}: ${Math.round(shortestRemaining)}% left`; + + return ( +
+
+
+ + + {providerLabel} + {shortestDuration ? ` · ${shortestDuration}` : ""} + + + {Math.round(shortestRemaining)}% left + +
+
+
+
+
+
+
+
+ + + {providerLabel} limits + +
+
+ {windows.map((window) => ( + + ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index b1a57582bc9f..e2910d005c31 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -24,7 +24,11 @@ import { import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; import { Fragment, useState } from "react"; -import { usePrimarySettings } from "../../hooks/useSettings"; +import { + useClientSettings, + usePrimarySettings, + useUpdateClientSettings, +} from "../../hooks/useSettings"; import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -42,6 +46,7 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PROVIDER_PRESENTATION } from "./usageProviders"; @@ -397,6 +402,22 @@ const SOURCE_KIND_LABEL: Record = { cliproxy: "CLI Proxy", }; +function SidebarLimitsToggle() { + const checked = useClientSettings((settings) => settings.sidebarUsageLimitsEnabled); + const updateSettings = useUpdateClientSettings(); + return ( + + ); +} + type LimitsSource = ReturnType[number]; /** Read-only accounts pooled by a configured usage source. */ @@ -435,6 +456,9 @@ export function UsageLimitsSection() { return (
+
+ +
{groups.length === 0 && sources.length === 0 ? (

No provider on a connected environment reports subscription limits. diff --git a/docs/user/usage.md b/docs/user/usage.md index fceedc3c2560..7fd2da6890e6 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -18,6 +18,11 @@ provider health-check interval and update live while a turn runs. API-key accoun subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy via `ANTHROPIC_AUTH_TOKEN`, since the CLI then treats itself as an API-key client. +When an open chat's provider reports subscription limits, the bottom of the sidebar shows the +remaining quota for its shortest window, such as the five-hour session. Hover or focus the indicator +to see every window for that provider without leaving the chat. Turn **Show in sidebar** off at the +top of the Limits view to hide the indicator; the Usage button remains in the sidebar. + If you pool accounts behind a CLIProxyAPI hub, open **Settings → Providers → Usage providers** and choose **Add hub**. Select the device that should connect to the hub; its accounts appear on the Limits view. Remove hubs from the same settings section. Each limits row shows its provider diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fef3b2992f07..3db3a94fb625 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -331,6 +331,7 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + sidebarUsageLimitsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -1201,6 +1202,7 @@ export const ClientSettingsPatch = Schema.Struct({ proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), + sidebarUsageLimitsEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), From 1341e3a770e25d60e9373f388f71743e68046cf0 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 4 Sep 2026 16:46:13 +0530 Subject: [PATCH 2/4] fix(web): default sidebar usage limits indicator to hidden --- docs/user/usage.md | 5 +++-- packages/contracts/src/settings.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/user/usage.md b/docs/user/usage.md index 7fd2da6890e6..b12990f9c964 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -20,8 +20,9 @@ via `ANTHROPIC_AUTH_TOKEN`, since the CLI then treats itself as an API-key clien When an open chat's provider reports subscription limits, the bottom of the sidebar shows the remaining quota for its shortest window, such as the five-hour session. Hover or focus the indicator -to see every window for that provider without leaving the chat. Turn **Show in sidebar** off at the -top of the Limits view to hide the indicator; the Usage button remains in the sidebar. +to see every window for that provider without leaving the chat. The indicator is hidden by default; +turn **Show in sidebar** on at the top of the Limits view to display it. The Usage button remains in +the sidebar. If you pool accounts behind a CLIProxyAPI hub, open **Settings → Providers → Usage providers** and choose **Add hub**. Select the device that should connect to the hub; its accounts appear on diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3db3a94fb625..651113fe13ba 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -331,7 +331,7 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - sidebarUsageLimitsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + sidebarUsageLimitsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), From 9ca974745faf94c97a2e00e9fb83f8aff601a98f Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 4 Sep 2026 17:11:37 +0530 Subject: [PATCH 3/4] fix(web): resolve sidebar usage limits window against checked-at time --- .../components/sidebar/SidebarUsageLimits.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUsageLimits.tsx b/apps/web/src/components/sidebar/SidebarUsageLimits.tsx index 471b3b078c6a..e8e1cbba9fbe 100644 --- a/apps/web/src/components/sidebar/SidebarUsageLimits.tsx +++ b/apps/web/src/components/sidebar/SidebarUsageLimits.tsx @@ -2,13 +2,14 @@ import type { ProviderDriverKind, ServerConfig, ServerProvider, + ServerProviderUsageLimits, ServerProviderUsageWindow, UsageLimitSourceAccount, UsageProviderKind, } from "@t3tools/contracts"; import { formatResetsIn, providerLimitsLabel } from "@t3tools/shared/usageLimits"; import { useParams } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; import { useClientSettings } from "../../hooks/useSettings"; @@ -63,25 +64,25 @@ function sourceAccountForProvider( return sameDriver.length === 1 ? (sameDriver[0] ?? null) : null; } -function windowsForProvider( +function limitsForProvider( config: ServerConfig, provider: ServerProvider, -): ReadonlyArray { +): ServerProviderUsageLimits | null { if ( provider.usageLimits !== undefined && provider.usageLimits.unavailable === undefined && provider.usageLimits.windows.length > 0 ) { - return provider.usageLimits.windows; + return provider.usageLimits; } const sourceAccount = sourceAccountForProvider(config, provider); if ( sourceAccount?.usageLimits.unavailable === undefined && sourceAccount?.usageLimits.windows.length ) { - return sourceAccount.usageLimits.windows; + return sourceAccount.usageLimits; } - return []; + return null; } function windowLabel(window: ServerProviderUsageWindow): string { @@ -145,7 +146,6 @@ export function SidebarUsageLimits() { return null; }); const serverConfigs = useServerConfigs(); - const [now] = useState(() => Date.now()); const environmentId = routeTarget?.kind === "server" ? routeTarget.threadRef.environmentId @@ -155,12 +155,12 @@ export function SidebarUsageLimits() { activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId ?? null; - if (!enabled) return null; - if (environmentId === null || instanceId === null) return null; - const config = serverConfigs.get(environmentId); + const config = environmentId === null ? undefined : serverConfigs.get(environmentId); const provider = config?.providers.find((candidate) => candidate.instanceId === instanceId); - if (!config || !provider) return null; - const windows = windowsForProvider(config, provider).toSorted(compareWindowDuration); + const limits = enabled && config && provider ? limitsForProvider(config, provider) : null; + if (!limits || !provider) return null; + const now = Date.parse(limits.checkedAt); + const windows = limits.windows.toSorted(compareWindowDuration); const shortestWindow = windows[0]; if (!shortestWindow) return null; const color = providerColor(provider.driver); From f18044dfd71f3d0213962d71b6c827ec8835a7f9 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 4 Sep 2026 17:30:01 +0530 Subject: [PATCH 4/4] test(desktop): add sidebarUsageLimitsEnabled to client settings fixture --- apps/desktop/src/settings/DesktopClientSettings.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 28cce3cfb507..f2418144a754 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -58,6 +58,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, legacySidebarEnabled: false, + sidebarUsageLimitsEnabled: true, timestampFormat: "24-hour", wordWrap: true, };