Skip to content
17 changes: 17 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/ChatView.tsx:10094

The composer usage meter stays hidden when quota exists only in a matching usageLimitSources account, even though the Limits panel displays that quota. usageLimits is sourced exclusively from activeProviderStatus.usageLimits, while collectProviderUsageLimits can 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:
In file @apps/web/src/components/ChatView.tsx around line 10094:

The composer usage meter stays hidden when quota exists only in a matching `usageLimitSources` account, even though the Limits panel displays that quota. `usageLimits` is sourced exclusively from `activeProviderStatus.usageLimits`, while `collectProviderUsageLimits` can obtain limits from source accounts; pass the collected limits to the meter so CLI-proxy/hub-reported quota is available here too.

}
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
environmentUnavailable={activeEnvironmentUnavailableState}
activePendingApproval={activePendingApproval}
pendingApprovals={pendingApprovals}
Expand Down
79 changes: 64 additions & 15 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
RuntimeMode,
ScopedThreadRef,
ServerProvider,
ServerProviderUsageLimits,
ThreadId,
SnapShotSource,
} from "@t3tools/contracts";
Expand All @@ -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,
Expand Down Expand Up @@ -188,6 +189,7 @@ import { replaceComposerContextReferences } from "@t3tools/shared/composerContex
import {
getRestingComposerImagePreviewCounts,
resolveRestingComposerControlsLayout,
resolveRestingHiddenBlockIds,
shouldAnimateComposerRestingTransition,
shouldUseCompactComposerPrimaryActions,
shouldUseCompactComposerFooter,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand All @@ -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;
Expand All @@ -1027,6 +1036,7 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null, useContro
setLayout((current) => {
const next = resolveRestingComposerControlsLayout({
...measurement,
menulessTrailingCount: menulessTrailingCountRef.current,
hostWidth,
previous: current,
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium chat/ChatComposer.tsx:2687

In the expanded footer, expandedControlsLayout does not reserve space for the menuless usage-limits block, so a narrow composer can hide mode and show an overflow menu even when the picker and mode control fit after the meter is hidden. Pass the same showUsageLimitsMeter count to the expanded layout resolver.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ChatComposer.tsx around line 2687:

In the expanded footer, `expandedControlsLayout` does not reserve space for the menuless `usage-limits` block, so a narrow composer can hide `mode` and show an overflow menu even when the picker and mode control fit after the meter is hidden. Pass the same `showUsageLimitsMeter` count to the expanded layout resolver.

const pendingPrimaryAction = useMemo(
() =>
Expand Down Expand Up @@ -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
Expand All @@ -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
Comment thread
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");
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const composerControls = showProviderUnavailable ? (
<ComposerControl
type="button"
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/components/chat/UsageLimitsMeter.tsx
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>
);
}
Loading
Loading