-
Notifications
You must be signed in to change notification settings - Fork 6.5k
feat(web): opt-in usage limits meter in the composer #11890
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a94d0fa
3c28e1e
7628d42
342c70d
b3cd3f1
ba1ff39
10c6e05
3424c0a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<HTMLDivElement | null>(null); | ||
| const controlsRef = useRef<HTMLDivElement>(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<typeof renderProviderTraitsPicker>[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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium In the expanded footer, 🤖 Copy this AI Prompt to have your agent fix this: |
||
| 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 | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| ? [ | ||
| { | ||
| id: "usage-limits", | ||
| content: ( | ||
| <> | ||
| <ComposerControlSeparator size={composerControlsInStrip ? "xs" : "sm"} /> | ||
| <UsageLimitsMeter | ||
| limits={props.usageLimits} | ||
| providerLabel={props.usageLimitsProviderLabel} | ||
| size={composerControlsInStrip ? "xs" : "sm"} | ||
| onOpen={props.onOpenUsageLimits} | ||
| /> | ||
| </> | ||
| ), | ||
| }, | ||
| ] | ||
| : []), | ||
| ]; | ||
| 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"); | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| const composerControls = showProviderUnavailable ? ( | ||
| <ComposerControl | ||
| type="button" | ||
|
|
@@ -5102,7 +5151,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | |
|
|
||
| <> | ||
| {restingBlockDefs.map((def, index) => { | ||
| const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount; | ||
| const hidden = hiddenIds.includes(def.id); | ||
| return ( | ||
| <div | ||
| key={def.id} | ||
|
|
@@ -5125,21 +5174,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | |
| })} | ||
| <div | ||
| data-resting-controls-overflow | ||
| aria-hidden={hiddenRestingBlockIds.length === 0 || undefined} | ||
| inert={hiddenRestingBlockIds.length === 0 || undefined} | ||
| aria-hidden={overflowIds.length === 0 || undefined} | ||
| inert={overflowIds.length === 0 || undefined} | ||
| className={cn( | ||
| "min-w-0 shrink-0", | ||
| hiddenRestingBlockIds.length === 0 && "pointer-events-none invisible absolute", | ||
| overflowIds.length === 0 && "pointer-events-none invisible absolute", | ||
| )} | ||
| > | ||
| <CompactComposerControlsMenu | ||
| interactionMode={interactionMode} | ||
| runtimeMode={runtimeMode} | ||
| size={composerControlsInStrip ? "xs" : "sm"} | ||
| hidden={composerControlsHidden || hiddenRestingBlockIds.length === 0} | ||
| showInteractionModeToggle={planModeUiEnabled && hiddenRestingBlockIds.includes("mode")} | ||
| hidden={composerControlsHidden || overflowIds.length === 0} | ||
| showInteractionModeToggle={planModeUiEnabled && overflowIds.includes("mode")} | ||
| traitsMenuContent={ | ||
| hiddenRestingBlockIds.includes("traits") ? providerTraitsMenuContent : undefined | ||
| overflowIds.includes("traits") ? providerTraitsMenuContent : undefined | ||
| } | ||
| onToggleInteractionMode={toggleInteractionMode} | ||
| onRuntimeModeChange={handleRuntimeModeChange} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import type { ServerProviderUsageLimits } from "@t3tools/contracts"; | ||
| import { | ||
| formatResetsIn, | ||
| remainingPercent as windowRemainingPercent, | ||
| usageLimitsMeterWindow, | ||
| windowExpired, | ||
| } from "@t3tools/shared/usageLimits"; | ||
|
|
||
| import { useNowMinute } from "~/hooks/useNowMinute"; | ||
| import { ComposerControl, type ComposerControlSize } from "./ComposerControl"; | ||
| import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
|
|
||
| /** | ||
| * How much of the selected provider's subscription is left, in the width of a | ||
| * word. Opt-in, and a shortcut into the existing `/usage-limits` panel rather | ||
| * than a surface of its own: the meter answers "can I keep going", the panel | ||
| * answers everything else. | ||
| * | ||
| * A reading whose window has already rolled over shows as unknown. The quota | ||
| * it reports belongs to a window that no longer exists, so drawing it as a | ||
| * full bar would promise headroom nobody has measured. | ||
| */ | ||
| export function UsageLimitsMeter(props: { | ||
| limits: ServerProviderUsageLimits | undefined; | ||
| providerLabel: string; | ||
| size: ComposerControlSize; | ||
| onOpen: (() => void) | undefined; | ||
| }) { | ||
| const { limits, providerLabel, size, onOpen } = props; | ||
| // The app's shared minute clock, not a timer of our own: the countdown and | ||
| // the rollover into "expired" stay honest, and the meter repaints at most | ||
| // once a minute instead of continuously. | ||
| const now = Date.parse(`${useNowMinute()}:00.000Z`); | ||
| const window = usageLimitsMeterWindow(limits); | ||
| if (!window) return null; | ||
|
|
||
| const remainingPercent = windowRemainingPercent(window); | ||
| const stale = windowExpired(window, now); | ||
| const fillColor = | ||
| remainingPercent <= 5 | ||
| ? "var(--color-error)" | ||
| : remainingPercent <= 20 | ||
| ? "var(--color-warning)" | ||
| : "color-mix(in oklab, var(--color-muted-foreground) 72%, transparent)"; | ||
| const resets = stale ? null : formatResetsIn(window, now); | ||
| const label = stale | ||
| ? `Usage limits: ${window.label} reading expired` | ||
| : `Usage limits: ${remainingPercent}% of ${window.label} left`; | ||
|
|
||
| return ( | ||
| <Tooltip> | ||
| <TooltipTrigger | ||
| render={ | ||
| <ComposerControl | ||
| size={size} | ||
| type="button" | ||
| aria-label={label} | ||
| className="shrink-0 gap-1.5 whitespace-nowrap" | ||
| aria-disabled={onOpen === undefined || undefined} | ||
| // Footer controls never take the caret from the editor. | ||
| onPointerDown={(event) => event.preventDefault()} | ||
| onClick={onOpen} | ||
| /> | ||
| } | ||
| > | ||
| <span | ||
| className="h-1 w-14 shrink-0 overflow-hidden rounded-full bg-muted-foreground/25" | ||
| role="progressbar" | ||
| aria-valuemin={0} | ||
| aria-valuemax={100} | ||
| {...(stale ? {} : { "aria-valuenow": remainingPercent })} | ||
| > | ||
| {stale ? null : ( | ||
| <span | ||
| className="block h-full rounded-full" | ||
| style={{ width: `${remainingPercent}%`, backgroundColor: fillColor }} | ||
| /> | ||
| )} | ||
| </span> | ||
| <span aria-hidden="true" className="tabular-nums"> | ||
| {stale ? "—" : `${remainingPercent}%`} | ||
| </span> | ||
| </TooltipTrigger> | ||
| <TooltipPopup side="top"> | ||
| {stale | ||
| ? `${providerLabel} · ${window.label}: reading expired, send a message to refresh` | ||
| : `${providerLabel} · ${window.label}: ${remainingPercent}% left${resets ? ` · ${resets}` : ""}`} | ||
| </TooltipPopup> | ||
| </Tooltip> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
components/ChatView.tsx:10094The composer usage meter stays hidden when quota exists only in a matching
usageLimitSourcesaccount, even though the Limits panel displays that quota.usageLimitsis sourced exclusively fromactiveProviderStatus.usageLimits, whilecollectProviderUsageLimitscan obtain limits from source accounts; pass the collected limits to the meter so CLI-proxy/hub-reported quota is available here too.🤖 Copy this AI Prompt to have your agent fix this: