diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index db13c8f21c80..c2d07e2c935f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10033,6 +10033,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 d168c6db4e5f..14fc6a6898fe 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -31,6 +31,7 @@ import type { RuntimeMode, ScopedThreadRef, ServerProvider, + ServerProviderUsageLimits, ThreadId, SnapShotSource, } from "@t3tools/contracts"; @@ -51,7 +52,7 @@ import { import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; 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 { memo, type ComponentProps, @@ -188,6 +189,7 @@ import { replaceComposerContextReferences } from "@t3tools/shared/composerContex import { getRestingComposerImagePreviewCounts, resolveRestingComposerControlsLayout, + resolveRestingHiddenBlockIds, shouldAnimateComposerRestingTransition, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, @@ -931,6 +933,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, @@ -997,7 +1000,11 @@ const extendReplacementRangeForTrailingSpace = ( return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; }; -function useRestingComposerControlsLayout(host: HTMLDivElement | null, useControlsAsHost = false) { +function useRestingComposerControlsLayout( + host: HTMLDivElement | null, + useControlsAsHost = false, + menulessTrailingCount = 0, +) { const [controls, setControls] = useState(null); const controlsRef = useRef(null); const attachControls = useCallback((element: HTMLDivElement | null) => { @@ -1006,6 +1013,8 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null, useContro }, []); const hostRef = useRef(host); hostRef.current = host; + const menulessTrailingCountRef = useRef(menulessTrailingCount); + menulessTrailingCountRef.current = menulessTrailingCount; const [layout, setLayout] = useState<{ hiddenCount: number; iconOnlyCount?: number; @@ -1027,6 +1036,7 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null, useContro setLayout((current) => { const next = resolveRestingComposerControlsLayout({ ...measurement, + menulessTrailingCount: menulessTrailingCountRef.current, hostWidth, previous: current, }); @@ -1356,6 +1366,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; @@ -2657,13 +2672,18 @@ 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, attachControls: attachRestingComposerControls, hiddenBlockCount: restingControlsHiddenBlockCount, iconOnlyBlockCount: restingControlsIconOnlyBlockCount, controlsVisible: restingControlsVisible, - } = useRestingComposerControlsLayout(restingControlsHost); + } = useRestingComposerControlsLayout(restingControlsHost, false, showUsageLimitsMeter ? 1 : 0); const expandedControlsLayout = useRestingComposerControlsLayout(null, true); const pendingPrimaryAction = useMemo( () => @@ -4955,10 +4975,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const iconOnlyBlockCount = composerControlsInStrip ? restingControlsIconOnlyBlockCount : expandedControlsLayout.iconOnlyBlockCount; + // 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: composerControlsInStrip ? "xs" : "sm", - hidden: composerControlsHidden || restingHiddenBlockCount > 1, + hidden: composerControlsHidden || hiddenIds.includes("traits"), }); const restingBlockDefs = [ ...(providerTraitsPicker @@ -4982,16 +5011,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 ? ( {restingBlockDefs.map((def, index) => { - const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount; + const hidden = hiddenIds.includes(def.id); return (