improve long chat rendering performance (#11583)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user