mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-19 07:04:21 +01:00
Co-authored-by: Nahiyan Khan <nahiyan@squareup.com> Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Lily Delalande <119957291+lily-de@users.noreply.github.com> Co-authored-by: Spence <spencrmartin@gmail.com> Co-authored-by: spencrmartin <spencermartin@squareup.com> Co-authored-by: Judson Stephenson <Jud@users.noreply.github.com> Co-authored-by: Max Novich <mnovich@squareup.com> Co-authored-by: Best Codes <106822363+The-Best-Codes@users.noreply.github.com> Co-authored-by: caroline-a-mckenzie <cmckenzie@squareup.com> Co-authored-by: Michael Neale <michael.neale@gmail.com>
275 lines
9.3 KiB
TypeScript
275 lines
9.3 KiB
TypeScript
/**
|
|
* 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 { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { Message } from '../types/message';
|
|
import GooseMessage from './GooseMessage';
|
|
import UserMessage from './UserMessage';
|
|
import { ContextHandler } from './context_management/ContextHandler';
|
|
import { useChatContextManager } from './context_management/ChatContextManager';
|
|
import { NotificationEvent } from '../hooks/useMessageStream';
|
|
import LoadingGoose from './LoadingGoose';
|
|
|
|
interface ProgressiveMessageListProps {
|
|
messages: Message[];
|
|
chat?: { id: string; messageHistoryIndex: number }; // Make optional for session history
|
|
toolCallNotifications?: Map<string, NotificationEvent[]>; // Make optional
|
|
append?: (value: string) => void; // Make optional
|
|
appendMessage?: (message: Message) => void; // Make optional
|
|
isUserMessage: (message: Message) => boolean;
|
|
onScrollToBottom?: () => void;
|
|
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
|
|
}
|
|
|
|
export default function ProgressiveMessageList({
|
|
messages,
|
|
chat,
|
|
toolCallNotifications = new Map(),
|
|
append = () => {},
|
|
appendMessage = () => {},
|
|
isUserMessage,
|
|
onScrollToBottom,
|
|
batchSize = 15, // Render 15 messages per batch (reduced for better UX)
|
|
batchDelay = 30, // 30ms delay between batches (faster)
|
|
showLoadingThreshold = 30, // Only show progressive loading for 30+ messages (lower threshold)
|
|
renderMessage, // Custom render function
|
|
isStreamingMessage = false, // Whether messages are currently being streamed
|
|
}: ProgressiveMessageListProps) {
|
|
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);
|
|
|
|
// Try to use context manager, but don't require it for session history
|
|
let hasContextHandlerContent: ((message: Message) => boolean) | undefined;
|
|
let getContextHandlerType:
|
|
| ((message: Message) => 'contextLengthExceeded' | 'summarizationRequested')
|
|
| undefined;
|
|
|
|
try {
|
|
const contextManager = useChatContextManager();
|
|
hasContextHandlerContent = contextManager.hasContextHandlerContent;
|
|
getContextHandlerType = contextManager.getContextHandlerType;
|
|
} catch (error) {
|
|
// Context manager not available (e.g., in session history view)
|
|
// This is fine, we'll just skip context handler functionality
|
|
hasContextHandlerContent = undefined;
|
|
getContextHandlerType = undefined;
|
|
}
|
|
|
|
// Simple progressive loading - start immediately when component mounts if needed
|
|
useEffect(() => {
|
|
if (messages.length <= showLoadingThreshold) {
|
|
setRenderedCount(messages.length);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Large list - start progressive loading
|
|
const loadNextBatch = () => {
|
|
setRenderedCount((current) => {
|
|
const nextCount = Math.min(current + batchSize, messages.length);
|
|
|
|
if (nextCount >= messages.length) {
|
|
setIsLoading(false);
|
|
// Trigger scroll to bottom
|
|
window.setTimeout(() => {
|
|
onScrollToBottom?.();
|
|
}, 100);
|
|
} else {
|
|
// Schedule next batch
|
|
timeoutRef.current = window.setTimeout(loadNextBatch, batchDelay);
|
|
}
|
|
|
|
return nextCount;
|
|
});
|
|
};
|
|
|
|
// 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,
|
|
onScrollToBottom,
|
|
renderedCount,
|
|
]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
if (timeoutRef.current) {
|
|
window.clearTimeout(timeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Force complete rendering when search is active
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const isMac = window.electron.platform === 'darwin';
|
|
const isSearchShortcut = (isMac ? e.metaKey : e.ctrlKey) && e.key === 'f';
|
|
|
|
if (isSearchShortcut && isLoading) {
|
|
// Immediately render all messages when search is triggered
|
|
setRenderedCount(messages.length);
|
|
setIsLoading(false);
|
|
if (timeoutRef.current) {
|
|
window.clearTimeout(timeoutRef.current);
|
|
timeoutRef.current = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [isLoading, messages.length]);
|
|
|
|
// Render messages up to the current rendered count
|
|
const renderMessages = useCallback(() => {
|
|
const messagesToRender = messages.slice(0, renderedCount);
|
|
|
|
const renderedMessages = messagesToRender
|
|
.map((message, index) => {
|
|
// Use custom render function if provided
|
|
if (renderMessage) {
|
|
return renderMessage(message, index);
|
|
}
|
|
|
|
// Default rendering logic (for BaseChat)
|
|
if (!chat) {
|
|
console.warn(
|
|
'ProgressiveMessageList: chat prop is required when not using custom renderMessage'
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const isUser = isUserMessage(message);
|
|
|
|
const result = (
|
|
<div
|
|
key={message.id && `${message.id}-${message.content.length}`}
|
|
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} ${isUser ? 'user' : 'assistant'}`}
|
|
data-testid="message-container"
|
|
>
|
|
{isUser ? (
|
|
<>
|
|
{hasContextHandlerContent && hasContextHandlerContent(message) ? (
|
|
<ContextHandler
|
|
messages={messages}
|
|
messageId={message.id ?? message.created.toString()}
|
|
chatId={chat.id}
|
|
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
|
|
contextType={getContextHandlerType!(message)}
|
|
onSummaryComplete={() => {
|
|
window.setTimeout(() => onScrollToBottom?.(), 100);
|
|
}}
|
|
/>
|
|
) : (
|
|
<UserMessage message={message} />
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{hasContextHandlerContent && hasContextHandlerContent(message) ? (
|
|
<ContextHandler
|
|
messages={messages}
|
|
messageId={message.id ?? message.created.toString()}
|
|
chatId={chat.id}
|
|
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
|
|
contextType={getContextHandlerType!(message)}
|
|
onSummaryComplete={() => {
|
|
window.setTimeout(() => onScrollToBottom?.(), 100);
|
|
}}
|
|
/>
|
|
) : (
|
|
<GooseMessage
|
|
messageHistoryIndex={chat.messageHistoryIndex}
|
|
message={message}
|
|
messages={messages}
|
|
append={append}
|
|
appendMessage={appendMessage}
|
|
toolCallNotifications={toolCallNotifications}
|
|
isStreaming={
|
|
isStreamingMessage &&
|
|
!isUser &&
|
|
index === messagesToRender.length - 1 &&
|
|
message.role === 'assistant'
|
|
}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return result;
|
|
})
|
|
.filter(Boolean); // Filter out null values
|
|
|
|
return renderedMessages;
|
|
}, [
|
|
messages,
|
|
renderedCount,
|
|
renderMessage,
|
|
isUserMessage,
|
|
hasContextHandlerContent,
|
|
getContextHandlerType,
|
|
chat,
|
|
append,
|
|
appendMessage,
|
|
toolCallNotifications,
|
|
onScrollToBottom,
|
|
isStreamingMessage,
|
|
]);
|
|
|
|
return (
|
|
<>
|
|
{renderMessages()}
|
|
|
|
{/* Loading indicator when progressively rendering */}
|
|
{isLoading && (
|
|
<div className="flex flex-col items-center justify-center py-8">
|
|
<LoadingGoose message={`Loading messages... (${renderedCount}/${messages.length})`} />
|
|
<div className="text-xs text-text-muted mt-2">
|
|
Press Cmd/Ctrl+F to load all messages immediately for search
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|