diff --git a/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx b/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx index cf5eff2c97..0a975ca73f 100644 --- a/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx +++ b/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx @@ -1,5 +1,8 @@ import '@testing-library/jest-dom/extend-expect'; -import { render, screen } from '@testing-library/react'; +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 type { TokenUsageView } from '~/hooks/Chat/useTokenUsage'; import Breakdown from './Breakdown'; @@ -37,22 +40,199 @@ const view = { messagesPruned: false, } as TokenUsageView; +/** A snapshot-backed branch: messages, instructions, and one tool per group, + * with a deferred entry on both the system and MCP families. */ +const snapshotView = { + ...view, + usedTokens: 1000, + maxTokens: 2000, + percent: 50, + isEstimate: false, + snapshotActive: true, + effectiveInstructionTokens: 0, + snapshot: { + anchorMessageId: 'message-1', + effectiveInstructionTokens: 400, + breakdown: { + maxContextTokens: 2000, + instructionTokens: 400, + systemMessageTokens: 100, + dynamicInstructionTokens: 20, + toolSchemaTokens: 280, + summaryTokens: 50, + toolCount: 5, + messageCount: 4, + messageTokens: 550, + availableForMessages: 1600, + toolTokenCounts: { + web_search: 90, + deferred_tool: 30, + [`server${Constants.mcp_delimiter}tool`]: 80, + [`server${Constants.mcp_delimiter}lazy`]: 40, + [Tools.skill]: 25, + [Constants.SUBAGENT]: 15, + }, + deferredToolNames: ['deferred_tool', `server${Constants.mcp_delimiter}lazy`], + }, + }, +} as unknown as TokenUsageView; + +const renderBreakdown = (props: Partial> = {}) => + render( + + + , + ); + +const toggle = () => screen.getByTestId('context-breakdown-toggle'); + +beforeEach(() => { + localStorage.clear(); +}); + describe('TokenUsage Breakdown', () => { - it('renders the Langfuse session as an external link when available', () => { + describe('collapse', () => { + it('opens showing only the gauge, with the detail behind the disclosure', () => { + renderBreakdown(); + + expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + expect(screen.queryByTestId('token-usage-totals')).not.toBeInTheDocument(); + }); + + it('reveals the detail once expanded', async () => { + renderBreakdown(); + + await userEvent.click(toggle()); + + expect(toggle()).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByTestId('token-usage-totals')).toBeInTheDocument(); + }); + + it('persists the expanded choice across mounts', async () => { + const { unmount } = renderBreakdown(); + await userEvent.click(toggle()); + unmount(); + + renderBreakdown(); + + expect(toggle()).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByTestId('token-usage-totals')).toBeInTheDocument(); + }); + + it('keeps the gauge readout visible while collapsed', () => { + renderBreakdown({ view: snapshotView }); + + expect(within(toggle()).getByText('1K / 2K (50%)')).toBeInTheDocument(); + }); + }); + + describe('segments', () => { + it('stacks the segments in slot order, deferred beside its parent', () => { + renderBreakdown({ view: snapshotView }); + + const segments = Array.from(screen.getByRole('progressbar').children) as HTMLElement[]; + + expect( + segments.map( + (segment) => /(?:bg-series-\d(?:\/25)?)/.exec(segment.className)?.[0] ?? 'none', + ), + ).toEqual([ + 'bg-series-1/25', // messages + 'bg-series-2', // system prompt + 'bg-series-3', // system tools + 'bg-series-3', // system tools, deferred + 'bg-series-4', // mcp tools + 'bg-series-4', // mcp tools, deferred + 'bg-series-5', // skills + 'bg-series-6', // subagents + 'bg-series-7', // summary + ]); + }); + + it('drops a category that contributes nothing', () => { + const noSkills = JSON.parse(JSON.stringify(snapshotView)) as TokenUsageView; + delete noSkills.snapshot?.breakdown.toolTokenCounts?.[Tools.skill]; + + renderBreakdown({ view: noSkills }); + + expect(screen.getByRole('progressbar').children).toHaveLength(8); + expect(screen.getByRole('progressbar').querySelector('.bg-series-5')).toBeNull(); + }); + + it('gives the deferred rows their family slot and a hatch', async () => { + renderBreakdown({ view: snapshotView }); + await userEvent.click(toggle()); + + const breakdown = screen.getByTestId('context-breakdown'); + const rowFor = (label: string) => + within(breakdown).getByText(label).parentElement as HTMLElement; + + const system = rowFor('com_ui_context_tools_system').firstElementChild as HTMLElement; + const deferred = rowFor('com_ui_context_tools_system_deferred') + .firstElementChild as HTMLElement; + + expect(system).toHaveClass('bg-series-3'); + expect(deferred).toHaveClass('bg-series-3'); + expect(system.getAttribute('style') ?? '').not.toContain('repeating-linear-gradient'); + expect(deferred.getAttribute('style')).toContain('repeating-linear-gradient'); + }); + + it('marks Messages as the one translucent, edged segment', async () => { + renderBreakdown({ view: snapshotView }); + await userEvent.click(toggle()); + + const messages = within(screen.getByTestId('context-breakdown')).getByText( + 'com_ui_context_messages', + ).parentElement?.firstElementChild as HTMLElement; + + expect(messages).toHaveClass('bg-series-1/25'); + expect(messages).toHaveClass('ring-series-1'); + }); + + it('leaves the estimate path unsegmented, with no swatches on its rows', async () => { + renderBreakdown(); + await userEvent.click(toggle()); + + const estimate = screen.getByTestId('context-estimate'); + + expect(estimate).toBeInTheDocument(); + expect(estimate.querySelector('[class*="bg-series-"]')).toBeNull(); + }); + }); + + describe('totals', () => { + it('labels the usage section so the numbers are not read as context', async () => { + renderBreakdown(); + await userEvent.click(toggle()); + + expect( + within(screen.getByTestId('token-usage-totals')).getByRole('heading', { + name: 'com_ui_context_totals', + }), + ).toBeInTheDocument(); + }); + }); + + describe('langfuse', () => { const url = 'https://cloud.langfuse.com/project/project-1/sessions/conversation-1'; - render(); + it('renders the Langfuse session as an external link when available', async () => { + renderBreakdown({ langfuseSessionUrl: url }); + await userEvent.click(toggle()); - expect(screen.getByRole('link', { name: 'com_ui_langfuse_view_session' })).toHaveAttribute( - 'href', - url, - ); - expect(screen.getByRole('link')).toHaveAttribute('target', '_blank'); - }); + expect(screen.getByRole('link', { name: 'com_ui_langfuse_view_session' })).toHaveAttribute( + 'href', + url, + ); + expect(screen.getByRole('link')).toHaveAttribute('target', '_blank'); + }); - it('omits the Langfuse session link when no traced message is available', () => { - render(); + it('omits the Langfuse session link when no traced message is available', async () => { + renderBreakdown(); + await userEvent.click(toggle()); - expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); }); }); diff --git a/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx index ec9a7ee462..02b5983836 100644 --- a/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx +++ b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx @@ -1,25 +1,52 @@ -import { Button } from '@librechat/client'; -import { ExternalLink } from 'lucide-react'; +import { useAtom } from 'jotai'; +import { ChevronDown, ExternalLink } from 'lucide-react'; +import { + Button, + Collapsible, + MeterSwatch, + SegmentedMeter, + CollapsibleContent, + CollapsibleTrigger, +} from '@librechat/client'; +import type { MeterSegment } from '@librechat/client'; import type { TokenUsageView } from '~/hooks/Chat/useTokenUsage'; import type { CurrencyConfig } from '~/utils'; 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; max?: number; + /** Present only when the row corresponds to real estate in the meter */ + segment?: Pick; + /** The free-space remainder, keyed to the bare track rather than a series */ + track?: boolean; } -function Row({ label, value, max }: RowProps) { +function Row({ label, value, max, segment, track }: RowProps) { const percent = max != null && max > 0 ? Math.min((value / max) * 100, 100) : null; return ( -
- {label} +
+ + {segment != null && } + {track === true && ( + {formatTokens(value)} {percent != null && ( -