diff --git a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx index 6a94c079c8..d36bca7fc8 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx @@ -42,10 +42,10 @@ interface SubagentCallProps { } const TICKER_MAX_LINES = 3; -/** Trailing-edge throttle window for the live preview. Tuned down from - * the original 1.2s so the ticker feels snappy when the container is - * already full and frames are scrolling. */ -const TICKER_THROTTLE_MS = 800; +/** Trailing-edge refresh window for the live preview once the ticker has + * enough text to fill the row. Keeps long streaming lines from repainting + * every token while still letting the collapsed subagent UI feel responsive. */ +export const SUBAGENT_TICKER_THROTTLE_MS = 400; /** Below this live-buffer length we skip throttling entirely. Without * this the user would see "Reasoning: I" for ~1s while the model * streams the rest of the sentence — the pass-through lets early @@ -243,7 +243,7 @@ export default function SubagentCall({ const displayedTickerLines = useThrottledValue( tickerLines, - TICKER_THROTTLE_MS, + SUBAGENT_TICKER_THROTTLE_MS, shouldThrottleTicker, ); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx index 47a9aa12cb..38a82682a2 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { RecoilRoot, useRecoilCallback } from 'recoil'; -import { render, screen, act, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, act, fireEvent, waitFor, within } from '@testing-library/react'; import type { SubagentUpdateEvent } from 'librechat-data-provider'; import type { SubagentContentPart, @@ -16,7 +16,7 @@ import { initSubagentTickerState, } from '~/utils/subagentContent'; import { subagentProgressByToolCallId } from '~/store/subagents'; -import SubagentCall from '../SubagentCall'; +import SubagentCall, { SUBAGENT_TICKER_THROTTLE_MS } from '../SubagentCall'; jest.mock('~/hooks', () => ({ useLocalize: @@ -83,18 +83,22 @@ jest.mock('../Attachment', () => ({ ), })); -jest.mock('@librechat/client', () => ({ - OGDialog: ({ children }: { children: React.ReactNode }) => <>{children}, - OGDialogContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - OGDialogTitle: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - OGDialogDescription: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})); +jest.mock( + '@librechat/client', + () => ({ + OGDialog: ({ children }: { children: React.ReactNode }) => <>{children}, + OGDialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + OGDialogTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + OGDialogDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }), + { virtual: true }, +); jest.mock('lucide-react', () => ({ // eslint-disable-next-line i18next/no-literal-string @@ -131,6 +135,10 @@ jest.mock('~/utils', () => ({ cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), })); +afterEach(() => { + jest.useRealTimers(); +}); + /** The dialog wraps single parts in `Container` and grouped tool_calls in * `ToolCallGroup`. Stub both as transparent wrappers so the tests still * assert on the leaf renderers (Text/Reasoning/ToolCall) without pulling @@ -229,10 +237,13 @@ function renderWithState(args: { /> , ); - act(() => { - setter.current?.(args.progress ?? null); - }); - return rendered; + const setProgress = (next: SubagentProgress | null) => { + act(() => { + setter.current?.(next); + }); + }; + setProgress(args.progress ?? null); + return { ...rendered, setProgress }; } describe('SubagentCall — status resolution', () => { @@ -443,6 +454,52 @@ describe('SubagentCall — ticker', () => { /** Only one "Writing:" label, not three — deltas collapse into one live line. */ expect(screen.getAllByText('Writing:')).toHaveLength(1); }); + + it('refreshes long live previews after the subagent ticker throttle window', () => { + jest.useFakeTimers(); + const firstPreview = 'First live preview '.repeat(8).trim(); + const secondPreview = 'Second live preview '.repeat(8).trim(); + const eventForText = (text: string): SubagentUpdateEvent => ({ + runId: 'p', + subagentRunId: 'run_a', + subagentType: 'self', + subagentAgentId: 'child', + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text }] } }, + timestamp: '', + }); + const progressForText = (text: string): SubagentProgress => + progressFromEvents({ + subagentRunId: 'run_a', + subagentType: 'self', + status: 'message_delta', + events: [eventForText(text)], + }); + + const { setProgress } = renderWithState({ + toolCallId: 'call_throttled_writing', + initialProgress: 0.3, + isSubmitting: true, + progress: progressForText(firstPreview), + }); + const ticker = within(screen.getByRole('button', { name: 'Running agent' })); + + expect(ticker.getByText(firstPreview)).toBeInTheDocument(); + setProgress(progressForText(secondPreview)); + expect(ticker.getByText(firstPreview)).toBeInTheDocument(); + expect(ticker.queryByText(secondPreview)).not.toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(SUBAGENT_TICKER_THROTTLE_MS - 1); + }); + expect(ticker.getByText(firstPreview)).toBeInTheDocument(); + expect(ticker.queryByText(secondPreview)).not.toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(ticker.getByText(secondPreview)).toBeInTheDocument(); + }); }); describe('SubagentCall — dialog content', () => {