improve long chat rendering performance (#11583)

This commit is contained in:
Lifei Zhou
2026-08-26 10:50:44 +00:00
committed by GitHub
parent 40602ba7b8
commit 9a0c1aec09
9 changed files with 898 additions and 340 deletions
+10 -4
View File
@@ -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 && (
<div className={hasStartedUsingRecipe ? 'mb-6' : ''}>
<RecipeActivities
append={(text: string) => 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({
<SearchView>
<ProgressiveMessageList
messages={messages}
chat={{ sessionId }}
sessionId={sessionId}
toolCallNotifications={toolCallNotifications}
append={(text: string) => handleSubmit({ msg: text, images: [] })}
isUserMessage={(m: Message) => m.role === 'user'}
append={appendToChat}
isUserMessage={isUserMessage}
isStreamingMessage={chatState !== ChatState.Idle}
onRenderingComplete={handleRenderingComplete}
onMessageUpdate={onMessageUpdate}
+39 -74
View File
@@ -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<string, NotificationEvent[]>;
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 (
<div className="goose-message flex w-[90%] justify-start min-w-0">
<div className="flex flex-col w-full min-w-0">
@@ -147,7 +111,7 @@ function GooseMessage({
<div className="flex flex-col group">
{displayText.trim() && (
<div ref={contentRef} className="agent-message-bubble w-full">
<MarkdownContent content={displayText} />
<MarkdownContent content={markdownText} />
</div>
)}
@@ -185,23 +149,24 @@ function GooseMessage({
<div className={cn(displayText && 'mt-2')}>
<div className="relative flex flex-col w-full group">
<div className="flex flex-col gap-3">
{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 (
<div className="goose-message-tool" key={toolRequest.id}>
<ToolCallWithResponse
sessionId={sessionId}
isCancelledMessage={false}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
notifications={toolCallNotifications.get(toolRequest.id)}
toolResponse={toolState.response}
notifications={toolNotifications[toolIndex]}
isStreamingMessage={isStreaming}
isPendingApproval={isPending}
isPendingApproval={toolState.isPending}
append={append}
confirmationContent={confirmationContent}
confirmationContent={toolState.confirmation}
isApprovalClicked={isApprovalClicked}
/>
</div>
@@ -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]);
@@ -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<string, number>());
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 <div>{id}</div>;
},
}));
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 <div>{id}</div>;
},
}));
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(
<ProgressiveMessageList
messages={messages}
sessionId="test-session"
append={append}
isUserMessage={isUserMessage}
/>,
{ 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(
<ProgressiveMessageList
messages={cloneMessages(messages)}
sessionId="test-session"
append={append}
isUserMessage={isUserMessage}
/>
);
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(
<ProgressiveMessageList
messages={updatedMessages}
sessionId="test-session"
append={append}
isUserMessage={isUserMessage}
/>
);
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(
<ProgressiveMessageList
messages={updatedMessages}
sessionId="test-session"
append={append}
isUserMessage={isUserMessage}
/>
);
expect(renderCounts.get('tool-request')).toBe(2);
expect(renderCounts.get('unrelated')).toBe(1);
});
it('preserves the message update callback', () => {
const onMessageUpdate = vi.fn();
render(
<ProgressiveMessageList
messages={[message('user-1', 'user', [{ type: 'text', text: 'Original' }])]}
sessionId="test-session"
append={append}
isUserMessage={isUserMessage}
onMessageUpdate={onMessageUpdate}
/>,
{ 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(
<StrictMode>
<IntlTestWrapper>
<ProgressiveMessageList
messages={messages}
sessionId="test-session"
append={append}
isUserMessage={isUserMessage}
batchSize={2}
batchDelay={20}
showLoadingThreshold={0}
onRenderingComplete={onRenderingComplete}
/>
</IntlTestWrapper>
</StrictMode>
);
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);
});
});
@@ -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<ChatType, 'sessionId'>;
toolCallNotifications?: Map<string, NotificationEvent[]>; // 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<string, NotificationEvent[]>();
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 <CreditsExhaustedNotification notification={notification} />;
case 'inlineMessage':
return <SystemNotificationInline notification={notification} />;
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<string, unknown>
) => Promise<boolean>;
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 (
<div
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} assistant`}
data-testid="message-container"
>
{renderSystemNotification(notification)}
</div>
);
}
const hasOnlyToolResponses = message.content.every((content) => content.type === 'toolResponse');
return (
<Fragment>
{modelChangeMessage && (
<SystemNotificationInline
notification={{
msg: modelChangeMessage,
notificationType: 'inlineMessage',
}}
/>
)}
<div
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} ${isUser ? 'user' : 'assistant'} ${rowContext.isInToolCallChain ? 'in-chain' : ''}`}
data-testid="message-container"
>
{isUser ? (
!hasOnlyToolResponses && (
<UserMessage message={message} onMessageUpdate={onMessageUpdate} />
)
) : (
<GooseMessage
sessionId={sessionId}
message={message}
hideTimestamp={rowContext.hideTimestamp}
toolStates={rowContext.toolStates}
toolNotifications={toolNotifications}
toolConfirmationShownInline={rowContext.toolConfirmationShownInline}
append={append}
isStreaming={isStreaming}
submitElicitationResponse={submitElicitationResponse}
/>
)}
</div>
</Fragment>
);
}
const MessageRow = memo(MessageRowComponent, isEqual);
interface ProgressiveMessageListProps {
messages: Message[];
sessionId: string;
toolCallNotifications?: Map<string, NotificationEvent[]>;
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<string, unknown>
@@ -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<number | null>(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<string | null>(null);
const isLoading = renderedCount < messages.length;
const renderModelChangeDisclosure = useCallback(
(previousModel: string, currentModel: string) => (
<SystemNotificationInline
notification={{
msg: intl.formatMessage(i18n.modelChanged, {
previousModel: getModelDisplayName(previousModel),
currentModel: getModelDisplayName(currentModel),
}),
notificationType: 'inlineMessage',
}}
/>
),
[intl]
);
const getSystemNotification = (message: Message): SystemNotificationContent | undefined => {
return getCreditsExhaustedNotification(message) ?? getInlineSystemNotification(message);
};
const renderSystemNotification = (notification: SystemNotificationContent) => {
switch (notification.notificationType) {
case 'creditsExhausted':
return <CreditsExhaustedNotification notification={notification} />;
case 'inlineMessage':
return <SystemNotificationInline notification={notification} />;
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 (
<div
key={`notification-${message.id ?? `msg-${index}-${message.created}`}`}
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} assistant`}
data-testid="message-container"
>
{renderSystemNotification(notification)}
</div>
);
}
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 (
<Fragment key={messageKey}>
{showModelChangeDisclosure &&
currentResolvedModel &&
previousResolvedModel &&
renderModelChangeDisclosure(previousResolvedModel, currentResolvedModel)}
<div
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} ${isUser ? 'user' : 'assistant'} ${messageIsInChain ? 'in-chain' : ''}`}
data-testid="message-container"
>
{isUser ? (
!hasOnlyToolResponses(message) && (
<UserMessage message={message} onMessageUpdate={onMessageUpdate} />
)
) : (
<GooseMessage
sessionId={chat.sessionId}
message={message}
messages={messages}
append={append}
toolCallNotifications={toolCallNotifications}
isStreaming={
isStreamingMessage &&
!isUser &&
index === messagesToRender.length - 1 &&
message.role === 'assistant'
}
submitElicitationResponse={submitElicitationResponse}
/>
)}
</div>
</Fragment>
);
})
.filter(Boolean);
}, [
messages,
renderedCount,
renderMessage,
isUserMessage,
chat,
append,
toolCallNotifications,
isStreamingMessage,
onMessageUpdate,
toolCallChains,
submitElicitationResponse,
getPreviousResolvedModel,
getResolvedModel,
renderModelChangeDisclosure,
]);
return (
<MessageRow
key={messageKey}
append={append}
index={index}
isStreaming={
isStreamingMessage &&
!isUser &&
index === messagesToRender.length - 1 &&
message.role === 'assistant'
}
isUser={isUser}
message={message}
modelChangeMessage={modelChangeMessage}
onMessageUpdate={onMessageUpdate}
rowContext={rowContext}
sessionId={sessionId}
submitElicitationResponse={submitElicitationResponse}
toolNotifications={toolNotifications}
/>
);
})
.filter(Boolean);
return (
<>
{renderMessages()}
{messageRows}
{/* Loading indicator when progressively rendering */}
{isLoading && (
<div className="flex flex-col items-center justify-center py-8">
<LoadingGoose
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import type { Message, MessageContent } from '../types/message';
import { deriveMessageRowContexts } from './messageRowContext';
const visibleMetadata: Message['metadata'] = { agentVisible: true, userVisible: true };
function message(
id: string,
role: Message['role'],
content: MessageContent[],
resolvedModel?: string
): Message {
return {
id,
role,
created: 1,
content,
metadata: {
...visibleMetadata,
...(resolvedModel
? {
inference: {
provider: 'test',
requestedModel: resolvedModel,
resolvedModel,
},
}
: {}),
},
};
}
function toolRequest(id: string): MessageContent {
return {
type: 'toolRequest',
id,
toolCall: {
status: 'success',
value: { name: 'test_tool', arguments: {} },
},
};
}
function toolResponse(id: string, value: string): MessageContent {
return {
type: 'toolResponse',
id,
toolResult: {
status: 'success',
value: { content: [{ type: 'text', text: value }], isError: false },
},
};
}
function toolConfirmation(id: string): MessageContent {
return {
type: 'actionRequired',
data: {
actionType: 'toolConfirmation',
id,
toolName: 'test_tool',
arguments: {},
},
};
}
describe('deriveMessageRowContexts', () => {
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');
});
});
@@ -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<number>();
const hiddenTimestampIndices = new Set<number>();
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<string>();
const firstConfirmationByRequestId = new Map<string, ToolConfirmationData>();
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<string, ToolResponseMessageContent>();
const latestResponseMessageIndexByRequestId = new Map<string, number>();
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)
),
};
});
}
@@ -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);
});
});
@@ -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<number | null>(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;
}