feat: Context Gauge Hover Reveal and Breakdown Motion Polish (#15038)

Show the context breakdown on hover instead of click, shrink the gauge,
open and close the popover with a scale-and-fade transition, ease the
collapsible with decelerating open and accelerating close curves, render
the Messages segment solid, and pair legend row hover with a dimmed
meter via a new highlightId prop on SegmentedMeter.
This commit is contained in:
Marco Beretta 2026-08-21 17:34:36 +02:00 committed by GitHub
parent 393742016e
commit 6757c65a54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 457 additions and 214 deletions

View file

@ -2,7 +2,7 @@ import '@testing-library/jest-dom/extend-expect';
import { Provider } from 'jotai';
import userEvent from '@testing-library/user-event';
import { Constants, Tools } from 'librechat-data-provider';
import { render, screen, within } from '@testing-library/react';
import { render, screen, within, fireEvent } from '@testing-library/react';
import type { TokenUsageView } from '~/hooks/Chat/useTokenUsage';
import Breakdown from './Breakdown';
@ -138,7 +138,7 @@ describe('TokenUsage Breakdown', () => {
(segment) => /(?:bg-series-\d(?:\/25)?)/.exec(segment.className)?.[0] ?? 'none',
),
).toEqual([
'bg-series-1/25', // messages
'bg-series-1', // messages
'bg-series-2', // system prompt
'bg-series-3', // system tools
'bg-series-3', // system tools, deferred
@ -178,7 +178,7 @@ describe('TokenUsage Breakdown', () => {
expect(deferred.getAttribute('style')).toContain('repeating-linear-gradient');
});
it('marks Messages as the one translucent, edged segment', async () => {
it('renders the Messages segment solid, like every other series', async () => {
renderBreakdown({ view: snapshotView });
await userEvent.click(toggle());
@ -186,8 +186,80 @@ describe('TokenUsage Breakdown', () => {
'com_ui_context_messages',
).parentElement?.firstElementChild as HTMLElement;
expect(messages).toHaveClass('bg-series-1/25');
expect(messages).toHaveClass('ring-series-1');
expect(messages).toHaveClass('bg-series-1');
expect(messages.className).not.toContain('ring-series-1');
});
it('dims the other bar segments while a legend row is hovered', async () => {
renderBreakdown({ view: snapshotView });
await userEvent.click(toggle());
const segments = Array.from(screen.getByRole('progressbar').children) as HTMLElement[];
const rowFor = (label: string) =>
within(screen.getByTestId('context-breakdown'))
.getByText(label)
.closest('div') as HTMLElement;
fireEvent.pointerEnter(rowFor('com_ui_context_messages'));
expect(segments[0].className).not.toContain('opacity-40');
segments.slice(1).forEach((segment) => expect(segment).toHaveClass('opacity-40'));
fireEvent.pointerLeave(rowFor('com_ui_context_messages'));
segments.forEach((segment) => expect(segment.className).not.toContain('opacity-40'));
});
it('recedes every segment when the free track row is hovered', async () => {
renderBreakdown({ view: snapshotView });
await userEvent.click(toggle());
const segments = Array.from(screen.getByRole('progressbar').children) as HTMLElement[];
const rowFor = (label: string) =>
within(screen.getByTestId('context-breakdown'))
.getByText(label)
.closest('div') as HTMLElement;
fireEvent.pointerEnter(rowFor('com_ui_context_free'));
segments.forEach((segment) => expect(segment).toHaveClass('opacity-40'));
});
it('drops the dimming when the hovered row disappears from a live update', async () => {
const noSubagents = JSON.parse(JSON.stringify(snapshotView)) as TokenUsageView;
delete noSubagents.snapshot?.breakdown.toolTokenCounts?.[Constants.SUBAGENT];
const { rerender } = renderBreakdown({ view: snapshotView });
await userEvent.click(toggle());
const segments = () => Array.from(screen.getByRole('progressbar').children) as HTMLElement[];
const rowFor = (label: string) =>
within(screen.getByTestId('context-breakdown'))
.getByText(label)
.closest('div') as HTMLElement;
fireEvent.pointerEnter(rowFor('com_ui_context_subagents'));
segments().forEach((segment, index) =>
expect(segment.classList.contains('opacity-40')).toBe(index !== 7),
);
rerender(
<Provider>
<Breakdown view={noSubagents} showCost={false} />
</Provider>,
);
segments().forEach((segment) => expect(segment.className).not.toContain('opacity-40'));
/** The hover state is cleared, not masked: the row reappearing on a later
* live update must not bring the stale dimming back. */
rerender(
<Provider>
<Breakdown view={snapshotView} showCost={false} />
</Provider>,
);
segments().forEach((segment) => expect(segment.className).not.toContain('opacity-40'));
});
it('leaves the estimate path unsegmented, with no swatches on its rows', async () => {

View file

@ -1,3 +1,4 @@
import { useState } from 'react';
import { useAtom } from 'jotai';
import { ChevronDown, ExternalLink } from 'lucide-react';
import {
@ -15,10 +16,6 @@ import { groupToolTokens, formatTokens, formatCost } from '~/utils';
import { contextBreakdownExpandedAtom } from '~/store/usage';
import { useLocalize } from '~/hooks';
/** Row text lifts to primary ink while the row is hovered or holds focus. */
const HOVER_INK =
'transition-colors group-hover:text-text-primary group-focus-within:text-text-primary';
interface RowProps {
label: string;
value: number;
@ -27,12 +24,19 @@ interface RowProps {
segment?: Pick<MeterSegment, 'slot' | 'hatched' | 'outlined'>;
/** The free-space remainder, keyed to the bare track rather than a series */
track?: boolean;
/** Meter segment this row keys; hovering it highlights its slice of the bar */
id?: string;
onHoverChange?: (id: string | null) => void;
}
function Row({ label, value, max, segment, track }: RowProps) {
function Row({ label, value, max, segment, track, id, onHoverChange }: RowProps) {
const percent = max != null && max > 0 ? Math.min((value / max) * 100, 100) : null;
return (
<div className="group flex items-center justify-between gap-4 text-sm">
<div
className="flex items-center justify-between gap-4 text-sm"
onPointerEnter={id != null ? () => onHoverChange?.(id) : undefined}
onPointerLeave={id != null ? () => onHoverChange?.(null) : undefined}
>
<span className="flex min-w-0 items-center gap-2">
{segment != null && <MeterSwatch segment={segment} />}
{track === true && (
@ -41,12 +45,12 @@ function Row({ label, value, max, segment, track }: RowProps) {
className="size-2 flex-none rounded-sm bg-surface-tertiary ring-1 ring-inset ring-border-medium"
/>
)}
<span className={`text-text-secondary ${HOVER_INK}`}>{label}</span>
<span className="text-text-secondary">{label}</span>
</span>
<span className="font-medium text-text-primary">
{formatTokens(value)}
{percent != null && (
<span className={`ml-1 text-xs text-text-secondary ${HOVER_INK}`} aria-hidden="true">
<span className="ml-1 text-xs text-text-secondary" aria-hidden="true">
({Math.round(percent)}%)
</span>
)}
@ -70,6 +74,7 @@ export default function Breakdown({
}: BreakdownProps) {
const localize = useLocalize();
const [expanded, setExpanded] = useAtom(contextBreakdownExpandedAtom);
const [hoveredSegment, setHoveredSegment] = useState<string | null>(null);
const { usedTokens, maxTokens, percent, snapshot, snapshotActive, branchUsage, hasUsage } = view;
/** Show the all-branches total only when it (a) exceeds the active branch
* epsilon guards against float summation order surfacing a spurious row in an
@ -108,7 +113,6 @@ export default function Breakdown({
label: localize('com_ui_context_messages'),
value: messageTokens,
slot: 1,
outlined: true,
},
{ id: 'system', label: localize('com_ui_context_system'), value: systemTokens, slot: 2 },
...(groups == null
@ -166,11 +170,27 @@ export default function Breakdown({
/** The estimate path knows the total but not the composition, so it keeps a
* single unsegmented fill and its rows carry no swatches. */
const meterSegments: MeterSegment[] =
segments.length > 0 ? segments : [{ id: 'used', value: usedTokens, slot: 1, outlined: true }];
segments.length > 0 ? segments : [{ id: 'used', value: usedTokens, slot: 1 }];
/** A hovered row can vanish mid-hover (live snapshot update, or the view
* falling back to the estimate path, whose rows carry no hover pairing);
* without its pointerleave a stale id would dim every segment. Clear the
* state rather than masking it, so a stale highlight cannot return if the
* row reappears with the pointer elsewhere. Render-phase reset: React
* re-renders immediately and never commits the stale highlight. */
const hoverIsValid = (id: string): boolean =>
id === 'free'
? breakdown != null && freeTokens != null
: meterSegments.some((segment) => segment.id === id && segment.value > 0);
if (hoveredSegment != null && !hoverIsValid(hoveredSegment)) {
setHoveredSegment(null);
}
const activeSegment =
hoveredSegment != null && hoverIsValid(hoveredSegment) ? hoveredSegment : null;
return (
<div className="w-72" role="region" aria-label={localize('com_ui_context_usage')}>
<Collapsible open={expanded} onOpenChange={setExpanded} className="space-y-3">
<Collapsible open={expanded} onOpenChange={setExpanded}>
<CollapsibleTrigger
className="group flex w-full items-center justify-between gap-2 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
data-testid="context-breakdown-toggle"
@ -179,21 +199,21 @@ export default function Breakdown({
{localize('com_ui_context_window')}
</span>
<span className="flex items-center gap-1 whitespace-nowrap text-xs font-medium text-text-secondary">
<span className={HOVER_INK}>
{maxTokens != null
? `${formatTokens(usedTokens)} / ${formatTokens(maxTokens)} (${Math.round(percent)}%)`
: formatTokens(usedTokens)}
</span>
{maxTokens != null
? `${formatTokens(usedTokens)} / ${formatTokens(maxTokens)} (${Math.round(percent)}%)`
: formatTokens(usedTokens)}
<ChevronDown
aria-hidden="true"
className="size-3.5 shrink-0 text-text-tertiary transition-transform duration-200 group-data-[state=open]:rotate-180 motion-reduce:transition-none"
className="ease-[cubic-bezier(0,0,0.2,1)] size-3.5 shrink-0 text-text-tertiary transition-transform duration-300 group-data-[state=open]:rotate-180 motion-reduce:transition-none"
/>
</span>
</CollapsibleTrigger>
<SegmentedMeter
className="mt-3"
segments={maxTokens != null ? meterSegments : []}
max={maxTokens ?? 1}
highlightId={activeSegment}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
@ -201,131 +221,149 @@ export default function Breakdown({
aria-label={localize('com_ui_context_usage')}
/>
<CollapsibleContent className="space-y-3 overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none">
<div
className="space-y-1.5"
data-testid={breakdown ? 'context-breakdown' : 'context-estimate'}
>
{breakdown ? (
<>
{segments.map(
({ id, label, value, ...segment }) =>
value > 0 && (
<Row key={id} label={label} value={value} max={maxTokens} segment={segment} />
),
)}
{freeTokens != null && (
<Row
label={localize('com_ui_context_free')}
value={freeTokens}
max={maxTokens}
track
/>
)}
</>
) : (
<>
{view.branchTotals.summaryBaseline > 0 && (
<Row
label={localize('com_ui_context_summary')}
value={view.branchTotals.summaryBaseline}
max={maxTokens}
/>
)}
{view.messagesPruned ? (
/** Over-window: the per-category split no longer describes what's
* sent, so show the pruned message total (incl. in-flight). */
<Row
label={localize('com_ui_context_messages')}
value={view.messageTokens + view.liveTokens}
max={maxTokens}
/>
) : (
<>
<Row label={localize('com_ui_input')} value={view.branchTotals.input} />
{/* The gap to the meter lives INSIDE the animated element (mt-3 below),
so it collapses with the height. On the parent it would be a margin
outside the animation, and unmounting the content would drop it in a
single 12px jump after the height reached zero. */}
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none">
<div className="mt-3 space-y-3">
<div
className="space-y-1.5"
data-testid={breakdown ? 'context-breakdown' : 'context-estimate'}
>
{breakdown ? (
<>
{segments.map(
({ id, label, value, ...segment }) =>
value > 0 && (
<Row
key={id}
label={label}
value={value}
max={maxTokens}
segment={segment}
id={id}
onHoverChange={setHoveredSegment}
/>
),
)}
{freeTokens != null && (
<Row
label={localize('com_ui_output')}
value={view.branchTotals.output + view.liveTokens}
label={localize('com_ui_context_free')}
value={freeTokens}
max={maxTokens}
track
id="free"
onHoverChange={setHoveredSegment}
/>
{view.estimatedTokens > 0 && (
)}
</>
) : (
<>
{view.branchTotals.summaryBaseline > 0 && (
<Row
label={localize('com_ui_context_summary')}
value={view.branchTotals.summaryBaseline}
max={maxTokens}
/>
)}
{view.messagesPruned ? (
/** Over-window: the per-category split no longer describes what's
* sent, so show the pruned message total (incl. in-flight). */
<Row
label={localize('com_ui_context_messages')}
value={view.messageTokens + view.liveTokens}
max={maxTokens}
/>
) : (
<>
<Row label={localize('com_ui_input')} value={view.branchTotals.input} />
<Row
label={localize('com_ui_context_estimated')}
value={view.estimatedTokens}
label={localize('com_ui_output')}
value={view.branchTotals.output + view.liveTokens}
/>
)}
</>
)}
{view.overheadTokens > 0 && (
<Row label={localize('com_ui_context_system')} value={view.overheadTokens} />
)}
{maxTokens == null && (
<p className="text-xs text-text-secondary">
{localize('com_ui_context_unknown')}
{view.estimatedTokens > 0 && (
<Row
label={localize('com_ui_context_estimated')}
value={view.estimatedTokens}
/>
)}
</>
)}
{view.overheadTokens > 0 && (
<Row label={localize('com_ui_context_system')} value={view.overheadTokens} />
)}
{maxTokens == null && (
<p className="text-xs text-text-secondary">
{localize('com_ui_context_unknown')}
</p>
)}
<p className="text-xs italic text-text-secondary">
{localize('com_ui_estimated')}
</p>
)}
<p className="text-xs italic text-text-secondary">{localize('com_ui_estimated')}</p>
</>
)}
</div>
{hasUsage && (
<>
<div className="border-t border-border-light" role="separator" />
<div className="space-y-1.5" data-testid="token-usage-totals">
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-tertiary">
{localize('com_ui_context_totals')}
</h3>
<Row label={localize('com_ui_input')} value={branchUsage.input} />
<Row label={localize('com_ui_output')} value={branchUsage.output} />
{branchUsage.cacheRead > 0 && (
<Row label={localize('com_ui_cache_read')} value={branchUsage.cacheRead} />
)}
{branchUsage.cacheWrite > 0 && (
<Row label={localize('com_ui_cache_write')} value={branchUsage.cacheWrite} />
)}
</div>
</>
)}
{showCost && hasUsage && branchUsage.costKnown && (
<>
<div className="border-t border-border-light" role="separator" />
<div className="space-y-1.5" data-testid="token-usage-cost">
<div className="flex items-center justify-between text-sm">
<span className="text-text-secondary">
{showTotal
? localize('com_ui_context_cost_branch')
: localize('com_ui_context_cost')}
</span>
<span className="font-medium text-text-primary">
{formatCost(view.branchCost, currency)}
</span>
</div>
{showTotal && (
<div className="flex items-center justify-between text-xs">
<span className="text-text-secondary">
{localize('com_ui_context_cost_total')}
</span>
<span className="text-text-secondary">
{formatCost(view.totalCost, currency)}
</span>
</div>
)}
</div>
</>
)}
{langfuseSessionUrl && (
<>
<div className="border-t border-border-light" role="separator" />
<Button asChild variant="link" className="h-auto w-full justify-between gap-2 p-0">
<a href={langfuseSessionUrl} target="_blank" rel="noopener noreferrer">
<span>{localize('com_ui_langfuse_view_session')}</span>
<ExternalLink className="size-4 shrink-0" aria-hidden="true" />
</a>
</Button>
</>
)}
</div>
{hasUsage && (
<>
<div className="border-t border-border-light" role="separator" />
<div className="space-y-1.5" data-testid="token-usage-totals">
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-tertiary">
{localize('com_ui_context_totals')}
</h3>
<Row label={localize('com_ui_input')} value={branchUsage.input} />
<Row label={localize('com_ui_output')} value={branchUsage.output} />
{branchUsage.cacheRead > 0 && (
<Row label={localize('com_ui_cache_read')} value={branchUsage.cacheRead} />
)}
{branchUsage.cacheWrite > 0 && (
<Row label={localize('com_ui_cache_write')} value={branchUsage.cacheWrite} />
)}
</div>
</>
)}
{showCost && hasUsage && branchUsage.costKnown && (
<>
<div className="border-t border-border-light" role="separator" />
<div className="space-y-1.5" data-testid="token-usage-cost">
<div className="group flex items-center justify-between text-sm">
<span className={`text-text-secondary ${HOVER_INK}`}>
{showTotal
? localize('com_ui_context_cost_branch')
: localize('com_ui_context_cost')}
</span>
<span className="font-medium text-text-primary">
{formatCost(view.branchCost, currency)}
</span>
</div>
{showTotal && (
<div className="group flex items-center justify-between text-xs">
<span className={`text-text-secondary ${HOVER_INK}`}>
{localize('com_ui_context_cost_total')}
</span>
<span className={`text-text-secondary ${HOVER_INK}`}>
{formatCost(view.totalCost, currency)}
</span>
</div>
)}
</div>
</>
)}
{langfuseSessionUrl && (
<>
<div className="border-t border-border-light" role="separator" />
<Button asChild variant="link" className="h-auto w-full justify-between gap-2 p-0">
<a href={langfuseSessionUrl} target="_blank" rel="noopener noreferrer">
<span>{localize('com_ui_langfuse_view_session')}</span>
<ExternalLink className="size-4 shrink-0" aria-hidden="true" />
</a>
</Button>
</>
)}
</CollapsibleContent>
</Collapsible>
</div>

View file

@ -1,7 +1,7 @@
import { cn } from '~/utils';
const SIZE = 28;
const STROKE_WIDTH = 3.5;
const SIZE = 20;
const STROKE_WIDTH = 2.5;
const RADIUS = (SIZE - STROKE_WIDTH) / 2;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;

View file

@ -1,12 +1,11 @@
import { memo, useRef } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { TooltipAnchor } from '@librechat/client';
import { Constants } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import type { CurrencyConfig } from '~/utils';
import { useGetLangfuseSessionLinkQuery, useGetStartupConfig } from '~/data-provider';
import { formatTokens, formatCost, cn } from '~/utils';
import useTokenUsage from '~/hooks/Chat/useTokenUsage';
import { formatTokens, cn } from '~/utils';
import { useLocalize } from '~/hooks';
import Breakdown from './Breakdown';
import Gauge from './Gauge';
@ -17,6 +16,11 @@ interface TokenUsageProps {
isSubmitting: boolean;
}
/** Hover pacing: a brief intent delay so sweeping past the gauge doesn't pop
* the card open, and a grace period for the pointer to travel into it. */
const SHOW_DELAY_MS = 100;
const HIDE_DELAY_MS = 150;
function TokenUsageIndicator({
index,
conversation,
@ -35,6 +39,72 @@ function TokenUsageIndicator({
const popoverOpen = Ariakit.useStoreState(popover, 'open');
const disclosureRef = useRef<HTMLButtonElement>(null);
const conversationId = conversation?.conversationId ?? '';
const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/**
* Ariakit only restores focus to the trigger on hide when it took focus on
* show, so keep `autoFocusOnShow` on for click/keyboard opens (Escape returns
* focus to the gauge) and off for hover so it never pulls focus off the
* composer mid-typing.
*/
const [focusOnShow, setFocusOnShow] = useState(true);
/** A click pins an open popover: hover no longer holds it, so the pointer can
* leave without it closing. Cleared when the popover closes by any path
* (Escape, outside click, a second click). */
const pinnedRef = useRef(false);
/** The pin state as of the pointerdown that precedes a mouse click. The
* pointerdown may hide the popover (hideOnInteractOutside does not exempt
* the disclosure's inner elements), which clears `pinnedRef` via the close
* effect before the click handler runs; the click must decide from this
* snapshot instead. */
const pinAtPointerDownRef = useRef(false);
const cancelTimers = useCallback(() => {
if (showTimerRef.current != null) {
clearTimeout(showTimerRef.current);
showTimerRef.current = null;
}
if (hideTimerRef.current != null) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
}, []);
const openByPointer = useCallback(() => {
if (pinnedRef.current) {
return;
}
cancelTimers();
if (popover.getState().open) {
return;
}
showTimerRef.current = setTimeout(() => {
showTimerRef.current = null;
setFocusOnShow(false);
popover.show();
}, SHOW_DELAY_MS);
}, [cancelTimers, popover]);
const scheduleHide = useCallback(() => {
if (pinnedRef.current) {
return;
}
cancelTimers();
hideTimerRef.current = setTimeout(() => {
hideTimerRef.current = null;
popover.hide();
}, HIDE_DELAY_MS);
}, [cancelTimers, popover]);
useEffect(() => {
if (!popoverOpen) {
pinnedRef.current = false;
}
}, [popoverOpen]);
/** Pending hover work must not outlive its target: cancel it on unmount and
* when the branch changes under the pointer, so a delayed show cannot open
* the popover for a conversation the user has navigated away from. */
useEffect(() => cancelTimers, [cancelTimers, conversationId]);
const canResolveLangfuseSession =
langfuseConnectionAccess &&
popoverOpen &&
@ -62,63 +132,93 @@ function TokenUsageIndicator({
})
: localize('com_ui_context_usage_label_unknown', { 0: formatTokens(view.usedTokens) });
const snapshotSummary = hasMax
? localize('com_ui_context_usage_snapshot', {
0: formatTokens(view.usedTokens),
1: formatTokens(view.maxTokens ?? 0),
2: String(Math.round(view.percent)),
})
: localize('com_ui_context_usage_snapshot_unknown', { 0: formatTokens(view.usedTokens) });
const snapshot =
showCost && view.hasUsage && view.branchUsage.costKnown
? `${snapshotSummary} · ${formatCost(view.branchCost, currency)}`
: snapshotSummary;
return (
<>
<TooltipAnchor
description={snapshot}
side="top"
render={
<Ariakit.PopoverDisclosure
ref={disclosureRef}
store={popover}
type="button"
data-testid="token-usage"
aria-label={ariaLabel}
aria-haspopup="dialog"
className={cn(
'flex size-theme-control items-center justify-center rounded-theme-control-round transition-colors',
'hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary',
'duration-300 animate-in fade-in zoom-in-95',
)}
>
<span
role="meter"
aria-valuemin={0}
aria-valuemax={hasMax ? view.maxTokens : undefined}
aria-valuenow={view.usedTokens}
aria-label={localize('com_ui_context_usage')}
className="flex items-center justify-center"
>
<Gauge percent={view.percent} indeterminate={!hasMax} />
</span>
</Ariakit.PopoverDisclosure>
}
/>
{/* Focus the labelled dialog on open so screen readers enter and announce
the breakdown, and so focus stays contained instead of falling back to
the body (which the composer's global focus logic would steal). The
visible ring is suppressed via focus:outline-none, and finalFocus
returns focus to the gauge trigger on close. */}
{/* Hover shows the breakdown; the disclosure keeps click / Enter / Space
working for touch and keyboard users. Taps also emit pointer
enter/leave, so the hover timers are gated to hover-capable pointers
or a tap would hide the popover it just opened. */}
<Ariakit.PopoverDisclosure
ref={disclosureRef}
store={popover}
type="button"
data-testid="token-usage"
aria-label={ariaLabel}
aria-haspopup="dialog"
onPointerDown={() => {
pinAtPointerDownRef.current = pinnedRef.current;
}}
onPointerEnter={(e) => {
if (e.pointerType !== 'touch') {
openByPointer();
}
}}
onPointerLeave={(e) => {
if (e.pointerType !== 'touch') {
scheduleHide();
}
}}
onClick={(e) => {
cancelTimers();
e.preventDefault();
/** Mouse clicks (detail > 0) decide from the pointerdown snapshot:
* the pointerdown may have hidden the popover and cleared the live
* pin before this handler ran. Keyboard clicks carry no pointerdown,
* so the live pin state is the truth there. */
const wasPinned = e.detail > 0 ? pinAtPointerDownRef.current : pinnedRef.current;
if (wasPinned) {
pinnedRef.current = false;
popover.hide();
return;
}
if (!popover.getState().open) {
setFocusOnShow(true);
}
pinnedRef.current = true;
popover.show();
}}
className={cn(
'flex size-theme-control items-center justify-center rounded-theme-control-round transition-colors',
'hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary',
'duration-300 animate-in fade-in zoom-in-95',
)}
>
<span
role="meter"
aria-valuemin={0}
aria-valuemax={hasMax ? view.maxTokens : undefined}
aria-valuenow={view.usedTokens}
aria-label={localize('com_ui_context_usage')}
className="flex items-center justify-center"
>
<Gauge percent={view.percent} indeterminate={!hasMax} />
</span>
</Ariakit.PopoverDisclosure>
{/* Focus the labelled dialog on keyboard/click open so screen readers
enter and announce the breakdown, and so focus stays contained instead
of falling back to the body (which the composer's global focus logic
would steal). The visible ring is suppressed via focus:outline-none,
and finalFocus returns focus to the gauge trigger on close. */}
<Ariakit.Popover
store={popover}
gutter={8}
portal
unmountOnHide
autoFocusOnShow={focusOnShow}
finalFocus={disclosureRef}
aria-label={localize('com_ui_context_usage')}
className="z-[200] rounded-xl border border-border-medium bg-surface-secondary p-3 shadow-lg focus:outline-none"
onPointerEnter={cancelTimers}
onPointerLeave={(e) => {
if (e.pointerType !== 'touch') {
scheduleHide();
}
}}
className={cn(
'z-[200] rounded-xl border border-border-medium bg-surface-secondary p-3 shadow-lg focus:outline-none',
'origin-bottom translate-y-1 scale-95 opacity-0 transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none',
'data-[enter]:translate-y-0 data-[enter]:scale-100 data-[enter]:opacity-100',
'data-[leave]:translate-y-1 data-[leave]:scale-95 data-[leave]:opacity-0',
)}
>
<Breakdown
view={view}

View file

@ -1056,8 +1056,6 @@
"com_ui_context_usage": "Context usage",
"com_ui_context_usage_label": "Context window: {{0}} of {{1}} tokens used ({{2}}%)",
"com_ui_context_usage_label_unknown": "Context usage: {{0}} tokens used",
"com_ui_context_usage_snapshot": "Context {{0}} / {{1}} ({{2}}%)",
"com_ui_context_usage_snapshot_unknown": "Context {{0}}",
"com_ui_context_window": "Context window",
"com_ui_continue": "Continue",
"com_ui_continue_chat": "Continue this chat",

View file

@ -36,14 +36,17 @@ module.exports = {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
/** Radix Collapsible exposes its own height variable, not the accordion one. */
/** Radix Collapsible exposes its own height variable, not the accordion one.
* The fade rides along so the rows dissolve instead of squashing. Opening
* decelerates into place; closing accelerates away, because a decelerating
* close stalls over its final pixels before the unmount. */
'collapsible-down': {
from: { height: 0 },
to: { height: 'var(--radix-collapsible-content-height)' },
from: { height: 0, opacity: 0 },
to: { height: 'var(--radix-collapsible-content-height)', opacity: 1 },
},
'collapsible-up': {
from: { height: 'var(--radix-collapsible-content-height)' },
to: { height: 0 },
from: { height: 'var(--radix-collapsible-content-height)', opacity: 1 },
to: { height: 0, opacity: 0 },
},
'slide-in-right': {
'0%': { transform: 'translateX(100%)' },
@ -84,8 +87,8 @@ module.exports = {
'fade-in': 'fadeIn 0.5s ease-out forwards',
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'collapsible-down': 'collapsible-down 0.2s ease-out',
'collapsible-up': 'collapsible-up 0.2s ease-out',
'collapsible-down': 'collapsible-down 0.3s cubic-bezier(0, 0, 0.2, 1)',
'collapsible-up': 'collapsible-up 0.2s cubic-bezier(0.4, 0, 1, 1)',
'slide-in-right': 'slide-in-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
'slide-in-left': 'slide-in-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
'slide-out-left': 'slide-out-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',

View file

@ -30,8 +30,8 @@ async function expandBreakdown(popover: Locator) {
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
}
/** Opens the gauge breakdown popover (click, not hover), expands the detail,
* and returns its region. */
/** Opens the gauge breakdown popover (a click, which also pins it), expands
* the detail, and returns its region. */
async function openBreakdown(page: Page) {
await expectGaugeAboveZero(page);
await gauge(page).click();
@ -250,7 +250,7 @@ test.describe('context usage gauge', () => {
await expect(popover.getByTestId('token-usage-totals')).toBeVisible({ timeout: 10000 });
});
test('hides on a new chat, then reveals snapshot on hover and breakdown on click', async ({
test('hides on a new chat, then reveals the breakdown on hover and pins it on click', async ({
page,
}) => {
test.setTimeout(120000);
@ -263,17 +263,30 @@ test.describe('context usage gauge', () => {
await sendAndAwaitReply(page, 'hello');
await expectGaugeAboveZero(page);
/** Hover surfaces the compact snapshot tooltip — not the full breakdown. */
/** Hover opens the full breakdown after the intent delay; the compact
* tooltip is gone, and the popover carries no tooltip role. */
await gauge(page).hover();
const tooltip = page.getByRole('tooltip');
await expect(tooltip).toBeVisible({ timeout: 10000 });
await expect(tooltip).toContainText('Context');
await expect(page.getByRole('region', { name: 'Context usage' })).toHaveCount(0);
/** Click opens the breakdown popover; Escape (focus-away) closes it. */
await gauge(page).click();
const popover = page.getByRole('region', { name: 'Context usage' });
await expect(popover).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('tooltip')).toHaveCount(0);
/** Moving the pointer away closes it: hover is the only thing holding it. */
await messagesView(page).hover({ position: { x: 5, y: 5 } });
await expect(popover).toBeHidden({ timeout: 10000 });
/** Click pins the breakdown: the pointer can leave without it closing. */
await gauge(page).hover();
await expect(popover).toBeVisible({ timeout: 10000 });
await gauge(page).click();
await messagesView(page).hover({ position: { x: 5, y: 5 } });
await page.waitForTimeout(500);
await expect(popover).toBeVisible();
await gauge(page).click();
await expect(popover).toBeHidden({ timeout: 10000 });
/** A click-opened popover is pinned too; Escape (focus-away) closes it. */
await gauge(page).click();
await expect(popover).toBeVisible({ timeout: 10000 });
await expect(popover.getByText('Context window')).toBeVisible();
await page.keyboard.press('Escape');
await expect(popover).toBeHidden({ timeout: 10000 });

View file

@ -91,6 +91,21 @@ describe('SegmentedMeter', () => {
expect(children()[1].className).not.toContain('ring-series');
});
it('recedes every segment but the highlighted one', () => {
renderMeter({ highlightId: 'a' });
expect(children()[0].className).not.toContain('opacity-40');
children()
.slice(1)
.forEach((child) => expect(child).toHaveClass('opacity-40'));
});
it('recedes all segments when none matches the highlight', () => {
renderMeter({ highlightId: 'nonexistent' });
children().forEach((child) => expect(child).toHaveClass('opacity-40'));
});
it('hatches without changing the slot, and reads the stripe from the theme', () => {
renderMeter();

View file

@ -87,6 +87,9 @@ export interface SegmentedMeterProps extends React.ComponentPropsWithoutRef<'div
segments: MeterSegment[];
/** Denominator for every segment width; the shortfall renders as free track */
max: number;
/** Segment id the caller considers active; every other segment recedes.
* Pairs a legend row with its slice of the bar on hover. */
highlightId?: string | null;
}
/** Surface gap between touching fills, and the floor that keeps a present
@ -112,7 +115,7 @@ const SEGMENT_MIN = 2;
export const SegmentedMeter: React.ForwardRefExoticComponent<
SegmentedMeterProps & React.RefAttributes<HTMLDivElement>
> = React.forwardRef<HTMLDivElement, SegmentedMeterProps>(
({ segments, max, className, ...props }, ref) => {
({ segments, max, highlightId, className, ...props }, ref) => {
const rendered = segments.filter((segment) => segment.value > 0);
const gapBudget = Math.max(rendered.length - 1, 0) * SEGMENT_GAP;
const fractions = rendered.map((segment) => Math.min(segment.value / max, 1));
@ -141,8 +144,9 @@ export const SegmentedMeter: React.ForwardRefExoticComponent<
key={segment.id}
aria-hidden="true"
className={cn(
'h-full transition-[width] duration-300 motion-reduce:transition-none',
'h-full transition-[width,opacity] duration-300 motion-reduce:transition-none',
seriesSwatchClass(segment),
highlightId != null && segment.id !== highlightId && 'opacity-40',
)}
style={{
width: `calc(${fraction} * 100% - ${gapShare.toFixed(3)}px)`,