mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
✨ 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:
parent
393742016e
commit
6757c65a54
9 changed files with 457 additions and 214 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue