diff --git a/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx b/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx index 0a975ca73f..74dd039280 100644 --- a/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx +++ b/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + segments().forEach((segment) => expect(segment.className).not.toContain('opacity-40')); }); it('leaves the estimate path unsegmented, with no swatches on its rows', async () => { diff --git a/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx index 02b5983836..7a445e37fa 100644 --- a/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx +++ b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx @@ -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; /** 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 ( -
+
onHoverChange?.(id) : undefined} + onPointerLeave={id != null ? () => onHoverChange?.(null) : undefined} + > {segment != null && } {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" /> )} - {label} + {label} {formatTokens(value)} {percent != null && ( -