From bfd5836321355fc35db32cbcea3995ff50eea92f Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Tue, 15 Sep 2026 14:36:11 +0200 Subject: [PATCH 1/6] feat(web): opt-in usage limits meter in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users cannot see how much of the session window is left without leaving the thread: /usage-limits and Usage → Limits are both a step away, and the turn that hits the limit is the one just sent. Adds an off-by-default setting (Settings → General → Usage limits meter) that draws one small bar plus the percent left for the selected provider's session window, falling back to the window with the least left. Clicking it opens the same panel /usage-limits does; hover is a plain tooltip. A window whose reset time has passed without a fresh reading renders as unknown, never as full quota. The block trails the footer controls and is the first to overflow in the resting strip, without raising the overflow menu on its own. Window selection and expiry live in packages/shared with unit tests; nothing new crosses the wire. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/ChatView.tsx | 15 +++ apps/web/src/components/chat/ChatComposer.tsx | 65 ++++++++++--- .../src/components/chat/UsageLimitsMeter.tsx | 94 +++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 32 +++++++ .../src/components/settings/settingsSearch.ts | 6 ++ docs/user/usage.md | 3 + packages/contracts/src/settings.test.ts | 12 +++ packages/contracts/src/settings.ts | 4 + packages/shared/src/usageLimits.test.ts | 66 +++++++++++++ packages/shared/src/usageLimits.ts | 33 +++++++ 10 files changed, 316 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/components/chat/UsageLimitsMeter.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 708809712917..ed93b1854718 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9645,6 +9645,21 @@ export default function ChatView(props: ChatViewProps) { ? openUsageLimits : undefined } + // The meter reads the selected instance's own + // snapshot; the panel also merges hub-reported accounts. + usageLimits={activeProviderStatus?.usageLimits} + usageLimitsProviderLabel={ + activeProviderStatus?.displayName?.trim() || + (activeProviderStatus ? String(activeProviderStatus.driver) : "") + } + // Unlike the slash command, the meter is a button: + // it opens the panel outright, so a draft carrying + // attachments or contexts is no reason to refuse. + onOpenUsageLimits={ + usageLimitsOffered && usageLimitsKey !== null + ? openUsageLimits + : undefined + } environmentUnavailable={activeEnvironmentUnavailableState} activePendingApproval={activePendingApproval} pendingApprovals={pendingApprovals} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f7d3ac540ccc..e1bdfcadeab4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -29,6 +29,7 @@ import type { RuntimeMode, ScopedThreadRef, ServerProvider, + ServerProviderUsageLimits, ThreadId, SnapShotSource, } from "@t3tools/contracts"; @@ -48,7 +49,7 @@ import { } from "@t3tools/client-runtime/text-paste"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; -import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; +import { USAGE_LIMITS_COMMAND, usageLimitsMeterWindow } from "@t3tools/shared/usageLimits"; import { Fragment, memo, @@ -922,6 +923,7 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: import { Button } from "../ui/button"; import { Select, SelectItem, SelectPopup, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { UsageLimitsMeter } from "./UsageLimitsMeter"; import { toastManager } from "../ui/toast"; import { FileIcon, @@ -1310,6 +1312,11 @@ export interface ChatComposerProps { bannerItems: readonly ComposerBannerStackItem[]; /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */ onUsageLimitsCommand?: (() => void) | undefined; + /** Subscription usage for the selected provider, for the opt-in footer meter. */ + usageLimits?: ServerProviderUsageLimits | undefined; + usageLimitsProviderLabel: string; + /** Opens the same panel /usage-limits does; undefined when there is nothing to show. */ + onOpenUsageLimits?: (() => void) | undefined; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; @@ -4837,11 +4844,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0; + // The block only exists when there is a reading to draw, so an unsupported + // provider leaves no separator hanging in the footer. + const showUsageLimitsMeter = + settings.usageLimitsMeterEnabled && usageLimitsMeterWindow(props.usageLimits) !== null; const composerControlsCompact = !composerControlsInStrip && isComposerFooterCompact; + // Which blocks the footer would render, in order, so each block's own + // `hidden` prop and the overflow menu read the same slice. + const restingBlockIds = [ + ...(providerTraitsPicker ? ["traits"] : []), + "mode", + ...(showUsageLimitsMeter ? ["usage-limits"] : []), + ]; + const hiddenIds = restingBlockIds.slice(restingBlockIds.length - restingHiddenBlockCount); const restingProviderTraitsPicker = renderProviderTraitsPicker({ ...providerTraitsPickerInput, size: "xs", - hidden: composerControlsHidden || restingHiddenBlockCount > 1, + hidden: composerControlsHidden || hiddenIds.includes("traits"), }); const restingBlockDefs = [ ...(providerTraitsPicker @@ -4865,16 +4884,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode={interactionMode} runtimeMode={runtimeMode} size={composerControlsInStrip ? "xs" : "sm"} - hidden={composerControlsHidden || restingHiddenBlockCount > 0} + hidden={composerControlsHidden || hiddenIds.includes("mode")} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} /> ), }, + // Trailing, so the least load-bearing control is the first into overflow. + // It has no menu entry there: the meter is a glance, not an action. + ...(showUsageLimitsMeter + ? [ + { + id: "usage-limits", + content: ( + <> + + + + ), + }, + ] + : []), ]; - const hiddenRestingBlockIds = restingBlockDefs - .slice(restingBlockDefs.length - restingHiddenBlockCount) - .map((def) => def.id); + // The meter has no menu entry: it is a glance, not an action. Hiding only + // the meter must not raise an overflow trigger holding nothing new. + const overflowIds = hiddenIds.filter((id) => id !== "usage-limits"); const composerControls = showProviderUnavailable ? (