diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 70880971291..4574d92b918 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9645,6 +9645,23 @@ 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={ + usageLimitsOffered ? activeProviderStatus?.usageLimits : undefined + } + 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 f7d3ac540cc..d8bd9b8021f 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, @@ -185,6 +186,7 @@ import { COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, getRestingComposerImagePreviewCounts, resolveRestingComposerControlsLayout, + resolveRestingHiddenBlockIds, shouldAnimateComposerRestingTransition, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, @@ -922,6 +924,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, @@ -985,10 +988,12 @@ const extendReplacementRangeForTrailingSpace = ( return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; }; -function useRestingComposerControlsLayout(host: HTMLDivElement | null) { +function useRestingComposerControlsLayout(host: HTMLDivElement | null, menulessTrailingCount = 0) { const controlsRef = useRef(null); const hostRef = useRef(host); hostRef.current = host; + const menulessTrailingCountRef = useRef(menulessTrailingCount); + menulessTrailingCountRef.current = menulessTrailingCount; const [layout, setLayout] = useState({ hiddenCount: 0, visible: true }); const measure = useCallback(() => { @@ -1005,6 +1010,7 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) { setLayout((current) => { const next = resolveRestingComposerControlsLayout({ ...measurement, + menulessTrailingCount: menulessTrailingCountRef.current, hostWidth, previous: current, }); @@ -1310,6 +1316,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; @@ -2587,11 +2598,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerOwned: true, } satisfies Parameters[0]; const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput); + // The block only exists when there is a reading to draw, so an unsupported + // provider leaves no separator hanging in the footer. It is also the one + // trailing block with no overflow menu entry, which the layout needs to know. + const showUsageLimitsMeter = + settings.usageLimitsMeterEnabled && usageLimitsMeterWindow(props.usageLimits) !== null; const { controlsRef: restingComposerControlsRef, hiddenBlockCount: restingControlsHiddenBlockCount, controlsVisible: restingControlsVisible, - } = useRestingComposerControlsLayout(restingControlsHost); + } = useRestingComposerControlsLayout(restingControlsHost, showUsageLimitsMeter ? 1 : 0); const pendingPrimaryAction = useMemo( () => activePendingProgress @@ -4838,10 +4854,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0; const composerControlsCompact = !composerControlsInStrip && isComposerFooterCompact; + // Which blocks the footer would render, in order. The wrapper that takes a + // block out of flow, the block's own `hidden` prop, and the overflow menu + // all read the one hidden list, so they cannot disagree. + const restingBlockIds = [ + ...(providerTraitsPicker ? ["traits"] : []), + "mode", + ...(showUsageLimitsMeter ? ["usage-limits"] : []), + ]; + const hiddenIds = resolveRestingHiddenBlockIds(restingBlockIds, restingHiddenBlockCount); const restingProviderTraitsPicker = renderProviderTraitsPicker({ ...providerTraitsPickerInput, size: "xs", - hidden: composerControlsHidden || restingHiddenBlockCount > 1, + hidden: composerControlsHidden || hiddenIds.includes("traits"), }); const restingBlockDefs = [ ...(providerTraitsPicker @@ -4865,16 +4890,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 ? (