From 9a0c1aec094b15f46f049b74b45ae2ff9179e2d2 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 26 Aug 2026 10:50:44 +0000 Subject: [PATCH] improve long chat rendering performance (#11583) --- ui/desktop/src/components/BaseChat.tsx | 14 +- ui/desktop/src/components/GooseMessage.tsx | 113 ++--- ui/desktop/src/components/MarkdownContent.tsx | 9 +- .../ProgressiveMessageList.test.tsx | 256 ++++++++++ .../src/components/ProgressiveMessageList.tsx | 462 ++++++++---------- .../src/components/messageRowContext.test.ts | 138 ++++++ .../src/components/messageRowContext.ts | 110 +++++ .../hooks/useThrottledStreamingText.test.ts | 81 +++ .../src/hooks/useThrottledStreamingText.ts | 55 +++ 9 files changed, 898 insertions(+), 340 deletions(-) create mode 100644 ui/desktop/src/components/ProgressiveMessageList.test.tsx create mode 100644 ui/desktop/src/components/messageRowContext.test.ts create mode 100644 ui/desktop/src/components/messageRowContext.ts create mode 100644 ui/desktop/src/hooks/useThrottledStreamingText.test.ts create mode 100644 ui/desktop/src/hooks/useThrottledStreamingText.ts diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 4073d4dfc..500d7e3c1 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -56,6 +56,8 @@ const i18n = defineMessages({ }, }); +const isUserMessage = (message: Message) => message.role === 'user'; + interface BaseChatProps { setChat: (chat: ChatType) => void; onMessageSubmit?: (message: string) => void; @@ -122,6 +124,10 @@ export default function BaseChat({ sessionId, onStreamFinish, }); + const appendToChat = useCallback( + (text: string) => handleSubmit({ msg: text, images: [] }), + [handleSubmit] + ); const handleWorkingDirChange = useCallback( async (newDir: string) => { @@ -455,7 +461,7 @@ export default function BaseChat({ {recipe && (
handleSubmit({ msg: text, images: [] })} + append={appendToChat} activities={Array.isArray(recipe.activities) ? recipe.activities : null} title={recipe.title} parameterValues={session?.user_recipe_values || {}} @@ -468,10 +474,10 @@ export default function BaseChat({ handleSubmit({ msg: text, images: [] })} - isUserMessage={(m: Message) => m.role === 'user'} + append={appendToChat} + isUserMessage={isUserMessage} isStreamingMessage={chatState !== ChatState.Idle} onRenderingComplete={handleRenderingComplete} onMessageUpdate={onMessageUpdate} diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index 238734e22..0d382bf73 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -9,28 +9,32 @@ import { getTextAndImageContent, getThinkingContent, getToolRequests, - getToolResponses, getToolConfirmationContent, getElicitationContent, - getPendingToolConfirmationIds, - getAnyToolConfirmationData, - ToolConfirmationData, - NotificationEvent, type Message, + type NotificationEvent, } from '../types/message'; import ToolCallConfirmation from './ToolCallConfirmation'; import ElicitationRequest from './ElicitationRequest'; import MessageCopyLink from './MessageCopyLink'; import MessageUsageStats from './MessageUsageStats'; import { cn } from '../utils'; -import { identifyConsecutiveToolCalls, shouldHideTimestamp } from '../utils/toolCallChaining'; +import type { ToolRenderState } from './messageRowContext'; +import { + STREAMING_RENDER_COOLDOWN_MS, + useThrottledStreamingText, +} from '../hooks/useThrottledStreamingText'; + +const MAX_STREAMING_MARKDOWN_LENGTH = 16_000; +const LARGE_STREAMING_RENDER_COOLDOWN_MS = 250; interface GooseMessageProps { sessionId: string; message: Message; - messages: Message[]; - metadata?: string[]; - toolCallNotifications: Map; + hideTimestamp: boolean; + toolStates: readonly ToolRenderState[]; + toolNotifications: readonly (NotificationEvent[] | undefined)[]; + toolConfirmationShownInline: boolean; append: (value: string) => void; isStreaming: boolean; submitElicitationResponse?: ( @@ -42,8 +46,10 @@ interface GooseMessageProps { function GooseMessage({ sessionId, message, - messages, - toolCallNotifications, + hideTimestamp, + toolStates, + toolNotifications, + toolConfirmationShownInline, append, isStreaming, submitElicitationResponse, @@ -60,26 +66,19 @@ function GooseMessage({ const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]); const toolRequests = getToolRequests(message); - const messageIndex = messages.findIndex((msg) => msg.id === message.id); + const shouldThrottleStreamingText = + isStreaming && displayText.length > 0 && toolRequests.length === 0 && imagePaths.length === 0; + const streamingRenderCooldownMs = + displayText.length > MAX_STREAMING_MARKDOWN_LENGTH + ? LARGE_STREAMING_RENDER_COOLDOWN_MS + : STREAMING_RENDER_COOLDOWN_MS; + const markdownText = useThrottledStreamingText( + displayText, + shouldThrottleStreamingText, + streamingRenderCooldownMs + ); const toolConfirmationContent = getToolConfirmationContent(message); const elicitationContent = getElicitationContent(message); - - const findConfirmationForToolAcrossMessages = ( - toolRequestId: string - ): ToolConfirmationData | undefined => { - for (const msg of messages) { - const confirmationData = getAnyToolConfirmationData(msg); - if (confirmationData && confirmationData.id === toolRequestId) { - return confirmationData; - } - } - return undefined; - }; - const toolCallChains = useMemo(() => identifyConsecutiveToolCalls(messages), [messages]); - const hideTimestamp = useMemo( - () => shouldHideTimestamp(messageIndex, toolCallChains), - [messageIndex, toolCallChains] - ); const hasToolConfirmation = toolConfirmationContent !== undefined; const hasElicitation = elicitationContent !== undefined; const outputTokenLimitNotice = isOutputTokenLimitFallback @@ -93,41 +92,6 @@ function GooseMessage({ }) : undefined; - const toolConfirmationShownInline = useMemo(() => { - if (!toolConfirmationContent) return false; - const confirmationData = getAnyToolConfirmationData(message); - if (!confirmationData) return false; - - for (const msg of messages) { - const requests = getToolRequests(msg); - if (requests.some((req) => req.id === confirmationData.id)) { - return true; - } - } - return false; - }, [toolConfirmationContent, message, messages]); - - const toolResponsesMap = useMemo(() => { - const responseMap = new Map(); - - if (messageIndex !== undefined && messageIndex >= 0) { - for (let i = messageIndex + 1; i < messages.length; i++) { - const responses = getToolResponses(messages[i]); - - for (const response of responses) { - const matchingRequest = toolRequests.find((req) => req.id === response.id); - if (matchingRequest) { - responseMap.set(response.id, response); - } - } - } - } - - return responseMap; - }, [messages, messageIndex, toolRequests]); - - const pendingConfirmationIds = getPendingToolConfirmationIds(messages); - return (
@@ -147,7 +111,7 @@ function GooseMessage({
{displayText.trim() && (
- +
)} @@ -185,23 +149,24 @@ function GooseMessage({
- {toolRequests.map((toolRequest) => { - const hasResponse = toolResponsesMap.has(toolRequest.id); - const isPending = pendingConfirmationIds.has(toolRequest.id); - const confirmationContent = findConfirmationForToolAcrossMessages(toolRequest.id); - const isApprovalClicked = confirmationContent && !isPending && hasResponse; + {toolRequests.map((toolRequest, toolIndex) => { + const toolState = toolStates[toolIndex]; + const hasResponse = toolState.response !== undefined; + const isApprovalClicked = Boolean( + toolState.confirmation && !toolState.isPending && hasResponse + ); return (
diff --git a/ui/desktop/src/components/MarkdownContent.tsx b/ui/desktop/src/components/MarkdownContent.tsx index af6cd18ff..91afe79a7 100644 --- a/ui/desktop/src/components/MarkdownContent.tsx +++ b/ui/desktop/src/components/MarkdownContent.tsx @@ -182,15 +182,12 @@ const MarkdownContent = memo(function MarkdownContent({ className = '', }: MarkdownContentProps) { const intl = useIntl(); - const [processedContent, setProcessedContent] = useState(content); - - useEffect(() => { + const processedContent = useMemo(() => { try { - const processed = wrapHTMLInCodeBlock(content); - setProcessedContent(processed); + return wrapHTMLInCodeBlock(content); } catch (error) { console.error('Error processing content:', error); - setProcessedContent(content); + return content; } }, [content]); diff --git a/ui/desktop/src/components/ProgressiveMessageList.test.tsx b/ui/desktop/src/components/ProgressiveMessageList.test.tsx new file mode 100644 index 000000000..d30005d93 --- /dev/null +++ b/ui/desktop/src/components/ProgressiveMessageList.test.tsx @@ -0,0 +1,256 @@ +import { StrictMode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import type { ImageData, Message, MessageContent } from '../types/message'; +import { IntlTestWrapper } from '../i18n/test-utils'; +import ProgressiveMessageList from './ProgressiveMessageList'; + +const renderCounts = vi.hoisted(() => new Map()); +const messageUpdateCallbacks = vi.hoisted( + () => + new Map< + string, + | (( + messageId: string, + newContent: string, + editType: 'fork' | 'edit', + retainedImages: ImageData[] + ) => void) + | undefined + >() +); + +vi.mock('./GooseMessage', () => ({ + default: ({ message }: { message: Message }) => { + const id = message.id ?? 'missing-id'; + renderCounts.set(id, (renderCounts.get(id) ?? 0) + 1); + return
{id}
; + }, +})); + +vi.mock('./UserMessage', () => ({ + default: ({ + message, + onMessageUpdate, + }: { + message: Message; + onMessageUpdate?: ( + messageId: string, + newContent: string, + editType: 'fork' | 'edit', + retainedImages: ImageData[] + ) => void; + }) => { + const id = message.id ?? 'missing-id'; + renderCounts.set(id, (renderCounts.get(id) ?? 0) + 1); + messageUpdateCallbacks.set(id, onMessageUpdate); + return
{id}
; + }, +})); + +const visibleMetadata: Message['metadata'] = { agentVisible: true, userVisible: true }; +const append = vi.fn(); +const isUserMessage = (message: Message) => message.role === 'user'; + +function message(id: string, role: Message['role'], content: MessageContent[]): Message { + return { id, role, created: 1, content, metadata: visibleMetadata }; +} + +function cloneMessages(messages: Message[]): Message[] { + return messages.map((item) => ({ + ...item, + content: item.content.map((content) => ({ ...content })), + metadata: { ...item.metadata }, + })); +} + +function toolRequest(id: string): MessageContent { + return { + type: 'toolRequest', + id, + toolCall: { + status: 'success', + value: { name: 'test_tool', arguments: {} }, + }, + }; +} + +function toolResponse(id: string): MessageContent { + return { + type: 'toolResponse', + id, + toolResult: { + status: 'success', + value: { content: [{ type: 'text', text: 'complete' }], isError: false }, + }, + }; +} + +function renderList(messages: Message[]) { + return render( + , + { wrapper: IntlTestWrapper } + ); +} + +describe('ProgressiveMessageList render isolation', () => { + beforeEach(() => { + renderCounts.clear(); + messageUpdateCallbacks.clear(); + append.mockClear(); + }); + + it('does not rerender historical rows from cloned equivalent messages', () => { + const messages = [ + message('assistant-1', 'assistant', [{ type: 'text', text: 'First' }]), + message('user-1', 'user', [{ type: 'text', text: 'Continue' }]), + message('assistant-2', 'assistant', [{ type: 'text', text: 'Streaming' }]), + ]; + const { rerender } = renderList(messages); + + rerender( + + ); + + expect(renderCounts).toEqual( + new Map([ + ['assistant-1', 1], + ['user-1', 1], + ['assistant-2', 1], + ]) + ); + + const updatedMessages = cloneMessages(messages); + updatedMessages[2].content = [{ type: 'text', text: 'Streaming update' }]; + rerender( + + ); + + expect(renderCounts).toEqual( + new Map([ + ['assistant-1', 1], + ['user-1', 1], + ['assistant-2', 2], + ]) + ); + }); + + it('rerenders the matching request row when a tool response arrives', () => { + const messages = [ + message('tool-request', 'assistant', [toolRequest('tool-1')]), + message('unrelated', 'assistant', [{ type: 'text', text: 'Unrelated' }]), + message('tool-response', 'user', []), + ]; + const { rerender } = renderList(messages); + const updatedMessages = cloneMessages(messages); + updatedMessages[2].content = [toolResponse('tool-1')]; + + rerender( + + ); + + expect(renderCounts.get('tool-request')).toBe(2); + expect(renderCounts.get('unrelated')).toBe(1); + }); + + it('preserves the message update callback', () => { + const onMessageUpdate = vi.fn(); + render( + , + { wrapper: IntlTestWrapper } + ); + const retainedImages: ImageData[] = [{ data: 'image', mimeType: 'image/png' }]; + + messageUpdateCallbacks.get('user-1')?.('user-1', 'Updated', 'fork', retainedImages); + + expect(onMessageUpdate).toHaveBeenCalledWith('user-1', 'Updated', 'fork', retainedImages); + }); +}); + +describe('ProgressiveMessageList batching', () => { + const messages = Array.from({ length: 10 }, (_, index) => + message(`assistant-${index}`, 'assistant', [{ type: 'text', text: `Message ${index}` }]) + ); + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function renderBatchedList(onRenderingComplete = vi.fn()) { + render( + + + + + + ); + return onRenderingComplete; + } + + it('renders exactly one batch per delay in StrictMode', () => { + renderBatchedList(); + + expect(screen.queryByText('assistant-1')).not.toBeNull(); + expect(screen.queryByText('assistant-2')).toBeNull(); + + act(() => vi.advanceTimersByTime(20)); + expect(screen.queryByText('assistant-3')).not.toBeNull(); + expect(screen.queryByText('assistant-4')).toBeNull(); + + act(() => vi.advanceTimersByTime(20)); + expect(screen.queryByText('assistant-5')).not.toBeNull(); + expect(screen.queryByText('assistant-6')).toBeNull(); + }); + + it('reports completion once after the final batch', () => { + const onRenderingComplete = renderBatchedList(); + + for (let batch = 0; batch < 4; batch++) { + act(() => vi.advanceTimersByTime(20)); + } + expect(screen.queryByText('assistant-9')).not.toBeNull(); + expect(onRenderingComplete).not.toHaveBeenCalled(); + + act(() => vi.advanceTimersByTime(50)); + expect(onRenderingComplete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index b7762ecd9..c193def14 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -1,20 +1,5 @@ -/** - * ProgressiveMessageList Component - * - * A performance-optimized message list that renders messages progressively - * to prevent UI blocking when loading long chat sessions. This component - * renders messages in batches with a loading indicator, maintaining full - * compatibility with the search functionality. - * - * Key Features: - * - Progressive rendering in configurable batches - * - Loading indicator during batch processing - * - Maintains search functionality compatibility - * - Smooth user experience with responsive UI - * - Configurable batch size and delay - */ - -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Fragment, memo, useEffect, useMemo, useRef, useState } from 'react'; +import { isEqual } from 'lodash'; import { defineMessages, useIntl } from '../i18n'; import GooseMessage from './GooseMessage'; import UserMessage from './UserMessage'; @@ -33,9 +18,8 @@ import type { SystemNotificationContent, } from '../types/message'; import LoadingGoose from './LoadingGoose'; -import { ChatType } from '../types/chat'; -import { identifyConsecutiveToolCalls, isInChain } from '../utils/toolCallChaining'; import { getModelDisplayName } from './settings/models/predefinedModelsUtils'; +import { deriveMessageRowContexts, type MessageRowContext } from './messageRowContext'; const i18n = defineMessages({ loadingMessages: { @@ -52,25 +36,135 @@ const i18n = defineMessages({ }, }); -interface ProgressiveMessageListProps { - messages: Message[]; - chat: Pick; - toolCallNotifications?: Map; // Make optional - append?: (value: string) => void; // Make optional - isUserMessage: (message: Message) => boolean; - batchSize?: number; - batchDelay?: number; - showLoadingThreshold?: number; // Only show loading if more than X messages - // Custom render function for messages - renderMessage?: (message: Message, index: number) => React.ReactNode | null; - isStreamingMessage?: boolean; // Whether messages are currently being streamed +const emptyToolCallNotifications = new Map(); +const emptyAppend = () => {}; + +function getResolvedModel(message: Message): string | null { + if (message.role !== 'assistant' || !message.metadata.userVisible) return null; + return message.metadata.inference?.resolvedModel ?? null; +} + +function getSystemNotification(message: Message): SystemNotificationContent | undefined { + return getCreditsExhaustedNotification(message) ?? getInlineSystemNotification(message); +} + +function renderSystemNotification(notification: SystemNotificationContent) { + switch (notification.notificationType) { + case 'creditsExhausted': + return ; + case 'inlineMessage': + return ; + default: + return null; + } +} + +interface MessageRowProps { + append: (value: string) => void; + index: number; + isStreaming: boolean; + isUser: boolean; + message: Message; + modelChangeMessage: string | null; onMessageUpdate?: ( messageId: string, newContent: string, editType: 'fork' | 'edit', retainedImages: ImageData[] ) => void; - onRenderingComplete?: () => void; // Callback when all messages are rendered + rowContext: MessageRowContext; + sessionId: string; + submitElicitationResponse?: ( + elicitationId: string, + userData: Record + ) => Promise; + toolNotifications: readonly (NotificationEvent[] | undefined)[]; +} + +function MessageRowComponent({ + append, + index, + isStreaming, + isUser, + message, + modelChangeMessage, + onMessageUpdate, + rowContext, + sessionId, + submitElicitationResponse, + toolNotifications, +}: MessageRowProps) { + const notification = getSystemNotification(message); + + if (notification) { + return ( +
+ {renderSystemNotification(notification)} +
+ ); + } + + const hasOnlyToolResponses = message.content.every((content) => content.type === 'toolResponse'); + + return ( + + {modelChangeMessage && ( + + )} +
+ {isUser ? ( + !hasOnlyToolResponses && ( + + ) + ) : ( + + )} +
+
+ ); +} + +const MessageRow = memo(MessageRowComponent, isEqual); + +interface ProgressiveMessageListProps { + messages: Message[]; + sessionId: string; + toolCallNotifications?: Map; + append?: (value: string) => void; + isUserMessage: (message: Message) => boolean; + batchSize?: number; + batchDelay?: number; + showLoadingThreshold?: number; + renderMessage?: (message: Message, index: number) => React.ReactNode | null; + isStreamingMessage?: boolean; + onMessageUpdate?: ( + messageId: string, + newContent: string, + editType: 'fork' | 'edit', + retainedImages: ImageData[] + ) => void; + onRenderingComplete?: () => void; submitElicitationResponse?: ( elicitationId: string, userData: Record @@ -79,158 +173,64 @@ interface ProgressiveMessageListProps { export default function ProgressiveMessageList({ messages, - chat, - toolCallNotifications = new Map(), - append = () => {}, + sessionId, + toolCallNotifications = emptyToolCallNotifications, + append = emptyAppend, isUserMessage, - batchSize = 20, + batchSize = 5, batchDelay = 20, showLoadingThreshold = 50, - renderMessage, // Custom render function - isStreamingMessage = false, // Whether messages are currently being streamed + renderMessage, + isStreamingMessage = false, onMessageUpdate, onRenderingComplete, submitElicitationResponse, }: ProgressiveMessageListProps) { const intl = useIntl(); - const [renderedCount, setRenderedCount] = useState(() => { - // Initialize with either all messages (if small) or first batch (if large) - return messages.length <= showLoadingThreshold - ? messages.length - : Math.min(batchSize, messages.length); - }); - const [isLoading, setIsLoading] = useState(() => messages.length > showLoadingThreshold); - const timeoutRef = useRef(null); - const mountedRef = useRef(true); - const hasOnlyToolResponses = (message: Message) => - message.content.every((c) => c.type === 'toolResponse'); - - const getResolvedModel = useCallback((message: Message): string | null => { - if (message.role !== 'assistant' || !message.metadata.userVisible) return null; - return message.metadata.inference?.resolvedModel ?? null; - }, []); - - const getPreviousResolvedModel = useCallback( - (index: number): string | null => { - for (let i = index - 1; i >= 0; i--) { - const model = getResolvedModel(messages[i]); - if (model) return model; - } - return null; - }, - [getResolvedModel, messages] + const [renderedCount, setRenderedCount] = useState(() => + messages.length <= showLoadingThreshold ? messages.length : Math.min(batchSize, messages.length) ); + const completedMessageKeyRef = useRef(null); + const isLoading = renderedCount < messages.length; - const renderModelChangeDisclosure = useCallback( - (previousModel: string, currentModel: string) => ( - - ), - [intl] - ); - - const getSystemNotification = (message: Message): SystemNotificationContent | undefined => { - return getCreditsExhaustedNotification(message) ?? getInlineSystemNotification(message); - }; - - const renderSystemNotification = (notification: SystemNotificationContent) => { - switch (notification.notificationType) { - case 'creditsExhausted': - return ; - case 'inlineMessage': - return ; - default: - return null; - } - }; - - // Simple progressive loading - start immediately when component mounts if needed useEffect(() => { if (messages.length <= showLoadingThreshold) { setRenderedCount(messages.length); - setIsLoading(false); - // For small lists, call completion callback immediately - if (onRenderingComplete) { - setTimeout(() => onRenderingComplete(), 50); - } return; } - // Large list - start progressive loading - const loadNextBatch = () => { - setRenderedCount((current) => { - const nextCount = Math.min(current + batchSize, messages.length); + if (!isLoading) return; - if (nextCount >= messages.length) { - setIsLoading(false); - // Call the completion callback after a brief delay to ensure DOM is updated - if (onRenderingComplete) { - setTimeout(() => onRenderingComplete(), 50); - } - } else { - // Schedule next batch - timeoutRef.current = window.setTimeout(loadNextBatch, batchDelay); - } + const timeout = window.setTimeout(() => { + setRenderedCount((current) => Math.min(current + batchSize, messages.length)); + }, batchDelay); - return nextCount; - }); - }; + return () => window.clearTimeout(timeout); + }, [batchDelay, batchSize, isLoading, messages.length, renderedCount, showLoadingThreshold]); - // Start loading after a short delay - timeoutRef.current = window.setTimeout(loadNextBatch, batchDelay); - - return () => { - if (timeoutRef.current) { - window.clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - }; - }, [ - messages.length, - batchSize, - batchDelay, - showLoadingThreshold, - renderedCount, - onRenderingComplete, - ]); - - // Cleanup on unmount useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - if (timeoutRef.current) { - window.clearTimeout(timeoutRef.current); - } - }; - }, []); + if (isLoading) return; + + const completedMessageKey = `${sessionId}:${messages.length}`; + if (completedMessageKeyRef.current === completedMessageKey) return; + + const timeout = window.setTimeout(() => { + completedMessageKeyRef.current = completedMessageKey; + onRenderingComplete?.(); + }, 50); + + return () => window.clearTimeout(timeout); + }, [isLoading, messages.length, onRenderingComplete, sessionId]); - // Force complete rendering when search is active useEffect(() => { - // Only add listener if we're actually loading - if (!isLoading) { - return; - } + if (!isLoading) return; - const handleKeyDown = (e: KeyboardEvent) => { + const handleKeyDown = (event: KeyboardEvent) => { const isMac = window.electron.platform === 'darwin'; - const isSearchShortcut = (isMac ? e.metaKey : e.ctrlKey) && e.key === 'f'; + const isSearchShortcut = (isMac ? event.metaKey : event.ctrlKey) && event.key === 'f'; if (isSearchShortcut) { - // Immediately render all messages when search is triggered setRenderedCount(messages.length); - setIsLoading(false); - if (timeoutRef.current) { - window.clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } } }; @@ -238,111 +238,61 @@ export default function ProgressiveMessageList({ return () => window.removeEventListener('keydown', handleKeyDown); }, [isLoading, messages.length]); - // Detect tool call chains - const toolCallChains = useMemo(() => identifyConsecutiveToolCalls(messages), [messages]); + const rowContexts = useMemo(() => deriveMessageRowContexts(messages), [messages]); + const messagesToRender = messages.slice(0, renderedCount); + const messageRows = messagesToRender + .map((message, index) => { + if (!message.metadata.userVisible) return null; + if (renderMessage) return renderMessage(message, index); - // Render messages up to the current rendered count - const renderMessages = useCallback(() => { - const messagesToRender = messages.slice(0, renderedCount); - return messagesToRender - .map((message, index) => { - if (!message.metadata.userVisible) { - return null; - } - if (renderMessage) { - return renderMessage(message, index); - } + const isUser = isUserMessage(message); + const messageIdentifier = message.id ?? `msg-${index}-${message.created}`; + const messageKey = getSystemNotification(message) + ? `notification-${messageIdentifier}` + : messageIdentifier; + const rowContext = rowContexts[index]; + const currentResolvedModel = getResolvedModel(message); + const modelChangeMessage = + currentResolvedModel && + rowContext.previousResolvedModel && + currentResolvedModel !== rowContext.previousResolvedModel + ? intl.formatMessage(i18n.modelChanged, { + previousModel: getModelDisplayName(rowContext.previousResolvedModel), + currentModel: getModelDisplayName(currentResolvedModel), + }) + : null; + const toolNotifications = rowContext.toolStates.map((toolState) => + toolCallNotifications.get(toolState.requestId) + ); - // Default rendering logic (for BaseChat) - if (!chat) { - console.warn( - 'ProgressiveMessageList: chat prop is required when not using custom renderMessage' - ); - return null; - } - - const notification = getSystemNotification(message); - if (notification) { - return ( -
- {renderSystemNotification(notification)} -
- ); - } - - const isUser = isUserMessage(message); - const messageIsInChain = isInChain(index, toolCallChains); - const currentResolvedModel = getResolvedModel(message); - const previousResolvedModel = currentResolvedModel ? getPreviousResolvedModel(index) : null; - const showModelChangeDisclosure = Boolean( - currentResolvedModel && - previousResolvedModel && - currentResolvedModel !== previousResolvedModel - ); - - const messageKey = message.id ?? `msg-${index}-${message.created}`; - - return ( - - {showModelChangeDisclosure && - currentResolvedModel && - previousResolvedModel && - renderModelChangeDisclosure(previousResolvedModel, currentResolvedModel)} -
- {isUser ? ( - !hasOnlyToolResponses(message) && ( - - ) - ) : ( - - )} -
-
- ); - }) - .filter(Boolean); - }, [ - messages, - renderedCount, - renderMessage, - isUserMessage, - chat, - append, - toolCallNotifications, - isStreamingMessage, - onMessageUpdate, - toolCallChains, - submitElicitationResponse, - getPreviousResolvedModel, - getResolvedModel, - renderModelChangeDisclosure, - ]); + return ( + + ); + }) + .filter(Boolean); return ( <> - {renderMessages()} + {messageRows} - {/* Loading indicator when progressively rendering */} {isLoading && (
{ + it('uses the last matching response after the request', () => { + const messages = [ + message('response-before', 'user', [toolResponse('tool-1', 'before')]), + message('request', 'assistant', [toolRequest('tool-1')]), + message('response-after-1', 'user', [toolResponse('tool-1', 'after-1')]), + message('response-after-2', 'user', [toolResponse('tool-1', 'after-2')]), + ]; + + const contexts = deriveMessageRowContexts(messages); + + expect(contexts[1].toolStates).toHaveLength(1); + expect(contexts[1].toolStates[0].response).toEqual(toolResponse('tool-1', 'after-2')); + }); + + it('derives confirmation and pending state for each tool request', () => { + const messages = [ + message('requests', 'assistant', [toolRequest('tool-1'), toolRequest('tool-2')]), + message('confirmation-1', 'user', [toolConfirmation('tool-1')]), + message('confirmation-2', 'user', [toolConfirmation('tool-2')]), + message('response', 'user', [toolResponse('tool-1', 'complete')]), + ]; + + const contexts = deriveMessageRowContexts(messages); + + expect(contexts[0].toolStates).toMatchObject([ + { + requestId: 'tool-1', + confirmation: { id: 'tool-1', toolName: 'test_tool', arguments: {} }, + isPending: false, + }, + { + requestId: 'tool-2', + confirmation: { id: 'tool-2', toolName: 'test_tool', arguments: {} }, + isPending: true, + }, + ]); + expect(contexts[1].toolConfirmationShownInline).toBe(true); + expect(contexts[2].toolConfirmationShownInline).toBe(true); + }); + + it('preserves tool-call chain and timestamp behavior', () => { + const messages = [ + message('tool-1', 'assistant', [ + { type: 'text', text: 'Starting tools.' }, + toolRequest('tool-1'), + ]), + message('tool-2', 'assistant', [toolRequest('tool-2')]), + message('done', 'assistant', [{ type: 'text', text: 'Done.' }]), + ]; + + const contexts = deriveMessageRowContexts(messages); + + expect(contexts[0]).toMatchObject({ isInToolCallChain: true, hideTimestamp: true }); + expect(contexts[1]).toMatchObject({ isInToolCallChain: true, hideTimestamp: false }); + expect(contexts[2]).toMatchObject({ isInToolCallChain: false, hideTimestamp: false }); + }); + + it('tracks the previous resolved model for model disclosures', () => { + const messages = [ + message('model-a', 'assistant', [{ type: 'text', text: 'A' }], 'model-a'), + message('user', 'user', [{ type: 'text', text: 'Continue' }]), + message('model-b', 'assistant', [{ type: 'text', text: 'B' }], 'model-b'), + ]; + + const contexts = deriveMessageRowContexts(messages); + + expect(contexts[0].previousResolvedModel).toBeNull(); + expect(contexts[1].previousResolvedModel).toBeNull(); + expect(contexts[2].previousResolvedModel).toBe('model-a'); + }); +}); diff --git a/ui/desktop/src/components/messageRowContext.ts b/ui/desktop/src/components/messageRowContext.ts new file mode 100644 index 000000000..81a5638cf --- /dev/null +++ b/ui/desktop/src/components/messageRowContext.ts @@ -0,0 +1,110 @@ +import { + getAnyToolConfirmationData, + getPendingToolConfirmationIds, + getToolConfirmationContent, + getToolRequests, + getToolResponses, + type Message, + type ToolConfirmationData, + type ToolResponseMessageContent, +} from '../types/message'; +import { identifyConsecutiveToolCalls } from '../utils/toolCallChaining'; + +export interface ToolRenderState { + requestId: string; + response: ToolResponseMessageContent | undefined; + confirmation: ToolConfirmationData | undefined; + isPending: boolean; +} + +export interface MessageRowContext { + hideTimestamp: boolean; + isInToolCallChain: boolean; + previousResolvedModel: string | null; + toolStates: readonly ToolRenderState[]; + toolConfirmationShownInline: boolean; +} + +function resolvedModel(message: Message): string | null { + if (message.role !== 'assistant' || !message.metadata.userVisible) return null; + return message.metadata.inference?.resolvedModel ?? null; +} + +export function deriveMessageRowContexts(messages: Message[]): MessageRowContext[] { + const toolCallChains = identifyConsecutiveToolCalls(messages); + const chainedMessageIndices = new Set(); + const hiddenTimestampIndices = new Set(); + + for (const chain of toolCallChains) { + for (const messageIndex of chain) { + chainedMessageIndices.add(messageIndex); + } + for (const messageIndex of chain.slice(0, -1)) { + hiddenTimestampIndices.add(messageIndex); + } + } + + const toolRequestIds = new Set(); + const firstConfirmationByRequestId = new Map(); + + for (const message of messages) { + for (const request of getToolRequests(message)) { + toolRequestIds.add(request.id); + } + + const confirmation = getAnyToolConfirmationData(message); + if (confirmation && !firstConfirmationByRequestId.has(confirmation.id)) { + firstConfirmationByRequestId.set(confirmation.id, confirmation); + } + } + + const pendingConfirmationIds = getPendingToolConfirmationIds(messages); + const toolStatesByMessageIndex: ToolRenderState[][] = Array.from( + { length: messages.length }, + () => [] + ); + const latestResponseByRequestId = new Map(); + const latestResponseMessageIndexByRequestId = new Map(); + + for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex--) { + const message = messages[messageIndex]; + toolStatesByMessageIndex[messageIndex] = getToolRequests(message).map((request) => ({ + requestId: request.id, + response: latestResponseByRequestId.get(request.id), + confirmation: firstConfirmationByRequestId.get(request.id), + isPending: pendingConfirmationIds.has(request.id), + })); + + for (const response of getToolResponses(message)) { + const existingResponseMessageIndex = latestResponseMessageIndexByRequestId.get(response.id); + if ( + existingResponseMessageIndex === undefined || + existingResponseMessageIndex === messageIndex + ) { + latestResponseByRequestId.set(response.id, response); + latestResponseMessageIndexByRequestId.set(response.id, messageIndex); + } + } + } + + let previousResolvedModel: string | null = null; + + return messages.map((message, messageIndex) => { + const currentResolvedModel = resolvedModel(message); + const rowPreviousResolvedModel = currentResolvedModel ? previousResolvedModel : null; + if (currentResolvedModel) previousResolvedModel = currentResolvedModel; + + const toolConfirmation = getToolConfirmationContent(message); + const confirmationData = getAnyToolConfirmationData(message); + + return { + hideTimestamp: hiddenTimestampIndices.has(messageIndex), + isInToolCallChain: chainedMessageIndices.has(messageIndex), + previousResolvedModel: rowPreviousResolvedModel, + toolStates: toolStatesByMessageIndex[messageIndex], + toolConfirmationShownInline: Boolean( + toolConfirmation && confirmationData && toolRequestIds.has(confirmationData.id) + ), + }; + }); +} diff --git a/ui/desktop/src/hooks/useThrottledStreamingText.test.ts b/ui/desktop/src/hooks/useThrottledStreamingText.test.ts new file mode 100644 index 000000000..ce94684a1 --- /dev/null +++ b/ui/desktop/src/hooks/useThrottledStreamingText.test.ts @@ -0,0 +1,81 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + STREAMING_RENDER_COOLDOWN_MS, + useThrottledStreamingText, +} from './useThrottledStreamingText'; + +describe('useThrottledStreamingText', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('coalesces updates received during the cooldown', () => { + const { result, rerender } = renderHook( + ({ content }) => useThrottledStreamingText(content, true), + { initialProps: { content: 'first' } } + ); + + rerender({ content: 'first second' }); + rerender({ content: 'first second third' }); + + expect(result.current).toBe('first'); + + act(() => vi.advanceTimersByTime(STREAMING_RENDER_COOLDOWN_MS)); + + expect(result.current).toBe('first second third'); + }); + + it('publishes immediately when the previous cooldown has elapsed', () => { + const { result, rerender } = renderHook( + ({ content }) => useThrottledStreamingText(content, true), + { initialProps: { content: 'first' } } + ); + + act(() => vi.advanceTimersByTime(STREAMING_RENDER_COOLDOWN_MS)); + rerender({ content: 'first second' }); + + expect(result.current).toBe('first second'); + }); + + it('uses a longer cooldown when requested', () => { + const longerCooldownMs = 250; + const { result, rerender } = renderHook( + ({ content }) => useThrottledStreamingText(content, true, longerCooldownMs), + { initialProps: { content: 'first' } } + ); + + rerender({ content: 'first second' }); + act(() => vi.advanceTimersByTime(STREAMING_RENDER_COOLDOWN_MS)); + expect(result.current).toBe('first'); + + act(() => vi.advanceTimersByTime(longerCooldownMs - STREAMING_RENDER_COOLDOWN_MS)); + expect(result.current).toBe('first second'); + }); + + it('returns the latest content immediately when throttling is disabled', () => { + const { result, rerender } = renderHook( + ({ content, enabled }) => useThrottledStreamingText(content, enabled), + { initialProps: { content: 'first', enabled: true } } + ); + + rerender({ content: 'first second', enabled: true }); + expect(result.current).toBe('first'); + + rerender({ content: 'first second', enabled: false }); + expect(result.current).toBe('first second'); + expect(vi.getTimerCount()).toBe(0); + }); + + it('cancels the cooldown on unmount', () => { + const { unmount } = renderHook(() => useThrottledStreamingText('first', true)); + + expect(vi.getTimerCount()).toBe(1); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/ui/desktop/src/hooks/useThrottledStreamingText.ts b/ui/desktop/src/hooks/useThrottledStreamingText.ts new file mode 100644 index 000000000..91bb6b808 --- /dev/null +++ b/ui/desktop/src/hooks/useThrottledStreamingText.ts @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from 'react'; + +export const STREAMING_RENDER_COOLDOWN_MS = 50; + +export function useThrottledStreamingText( + content: string, + enabled: boolean, + cooldownMs = STREAMING_RENDER_COOLDOWN_MS +): string { + const [renderedText, setRenderedText] = useState(content); + const renderedTextRef = useRef(content); + const latestTextRef = useRef(content); + const cooldownTimerRef = useRef(null); + + latestTextRef.current = content; + + useEffect(() => { + if (!enabled) { + if (cooldownTimerRef.current !== null) { + window.clearTimeout(cooldownTimerRef.current); + cooldownTimerRef.current = null; + } + renderedTextRef.current = content; + setRenderedText(content); + return; + } + + if (cooldownTimerRef.current === null && content !== renderedTextRef.current) { + renderedTextRef.current = content; + setRenderedText(content); + } + }, [content, enabled]); + + useEffect(() => { + if (!enabled) return; + + cooldownTimerRef.current = window.setTimeout(() => { + cooldownTimerRef.current = null; + const latestText = latestTextRef.current; + if (latestText !== renderedTextRef.current) { + renderedTextRef.current = latestText; + setRenderedText(latestText); + } + }, cooldownMs); + + return () => { + if (cooldownTimerRef.current !== null) { + window.clearTimeout(cooldownTimerRef.current); + cooldownTimerRef.current = null; + } + }; + }, [cooldownMs, enabled, renderedText]); + + return enabled ? renderedText : content; +}