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, }; 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..e8e1cbba9fbe --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUsageLimits.tsx @@ -0,0 +1,230 @@ +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 } 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 limitsForProvider( + config: ServerConfig, + provider: ServerProvider, +): ServerProviderUsageLimits | null { + if ( + provider.usageLimits !== undefined && + provider.usageLimits.unavailable === undefined && + provider.usageLimits.windows.length > 0 + ) { + return provider.usageLimits; + } + const sourceAccount = sourceAccountForProvider(config, provider); + if ( + sourceAccount?.usageLimits.unavailable === undefined && + sourceAccount?.usageLimits.windows.length + ) { + return sourceAccount.usageLimits; + } + return null; +} + +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 environmentId = + routeTarget?.kind === "server" + ? routeTarget.threadRef.environmentId + : (draftSession?.environmentId ?? null); + const instanceId = + composerDraft?.activeProvider ?? + activeThread?.session?.providerInstanceId ?? + activeThread?.modelSelection.instanceId ?? + null; + const config = environmentId === null ? undefined : serverConfigs.get(environmentId); + const provider = config?.providers.find((candidate) => candidate.instanceId === instanceId); + 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); + 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..b12990f9c964 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -18,6 +18,12 @@ 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. 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 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..651113fe13ba 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(false))), 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),