(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;
+}