added progressive loading to speed up resuming large chat session loading and added search view to session history

This commit is contained in:
Zane Staggs
2025-07-15 11:07:10 -07:00
parent 6e5a8b6fe4
commit cdc825cf45
5 changed files with 381 additions and 124 deletions
+1 -1
View File
@@ -752,7 +752,7 @@ export default function App() {
}
initAttemptedRef.current = true;
console.log(`Initializing app with settings v2`);
console.log(`Initializing app`);
const urlParams = new URLSearchParams(window.location.search);
const viewType = urlParams.get('view');
+36 -77
View File
@@ -42,22 +42,20 @@
* while remaining flexible enough to support different UI contexts (Hub vs Pair).
*/
import React, { useEffect, useContext, createContext, useRef } from 'react';
import React, { useEffect, useContext, createContext, useRef, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import GooseMessage from './GooseMessage';
import UserMessage from './UserMessage';
import { SearchView } from './conversation/SearchView';
import { AgentHeader } from './AgentHeader';
import LayingEggLoader from './LayingEggLoader';
import LoadingGoose from './LoadingGoose';
import Splash from './Splash';
import PopularChatTopics from './PopularChatTopics';
import ProgressiveMessageList from './ProgressiveMessageList';
import { SessionSummaryModal } from './context_management/SessionSummaryModal';
import {
ChatContextManagerProvider,
useChatContextManager,
} from './context_management/ChatContextManager';
import { ContextHandler } from './context_management/ContextHandler';
import { type View, ViewOptions } from '../App';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import ChatInput from './ChatInput';
@@ -137,8 +135,6 @@ function BaseChatContent({
resetMessagesWithSummary,
closeSummaryModal,
updateSummary,
hasContextHandlerContent,
getContextHandlerType,
} = useChatContextManager();
// Use shared chat engine
@@ -298,75 +294,14 @@ function BaseChatContent({
}
append(text);
};
const renderMessages = () => {
return filteredMessages.map((message, index) => {
const isUser = isUserMessage(message);
return (
<div
key={message.id || index}
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} ${isUser ? 'user' : 'assistant'}`}
data-testid="message-container"
>
{isUser ? (
<>
{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={() => {
// Auto-scroll to bottom when summary is complete
setTimeout(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 100);
}}
/>
) : (
<UserMessage message={message} />
)}
</>
) : (
<>
{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={() => {
// Auto-scroll to bottom when summary is complete
setTimeout(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 100);
}}
/>
) : (
<GooseMessage
messageHistoryIndex={chat?.messageHistoryIndex}
message={message}
messages={messages}
append={append}
appendMessage={(newMessage) => {
const updatedMessages = [...messages, newMessage];
setMessages(updatedMessages);
}}
toolCallNotifications={toolCallNotifications}
/>
)}
</>
)}
</div>
);
});
};
// Callback to handle scroll to bottom from ProgressiveMessageList
const handleScrollToBottom = useCallback(() => {
setTimeout(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 100);
}, []);
return (
<div className="h-full flex flex-col min-h-0">
@@ -443,10 +378,34 @@ function BaseChatContent({
<>
{disableSearch ? (
// Render messages without SearchView wrapper when search is disabled
renderMessages()
<ProgressiveMessageList
messages={filteredMessages}
chat={chat}
toolCallNotifications={toolCallNotifications}
append={append}
appendMessage={(newMessage) => {
const updatedMessages = [...messages, newMessage];
setMessages(updatedMessages);
}}
isUserMessage={isUserMessage}
onScrollToBottom={handleScrollToBottom}
/>
) : (
// Render messages with SearchView wrapper when search is enabled
<SearchView>{renderMessages()}</SearchView>
<SearchView>
<ProgressiveMessageList
messages={filteredMessages}
chat={chat}
toolCallNotifications={toolCallNotifications}
append={append}
appendMessage={(newMessage) => {
const updatedMessages = [...messages, newMessage];
setMessages(updatedMessages);
}}
isUserMessage={isUserMessage}
onScrollToBottom={handleScrollToBottom}
/>
</SearchView>
)}
{error && (
@@ -0,0 +1,277 @@
/**
* 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;
}
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
}: 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(() => {
console.log(
`ProgressiveMessageList: ${messages.length} messages, threshold: ${showLoadingThreshold}, renderedCount: ${renderedCount}`
);
if (messages.length <= showLoadingThreshold) {
// Small list - render all immediately
console.log('ProgressiveMessageList: Small list, rendering all immediately');
setRenderedCount(messages.length);
setIsLoading(false);
return;
}
console.log('ProgressiveMessageList: Large list, starting progressive loading');
// Large list - start progressive loading
const loadNextBatch = () => {
setRenderedCount((current) => {
const nextCount = Math.min(current + batchSize, messages.length);
console.log(
`ProgressiveMessageList: Loading batch, now showing ${nextCount}/${messages.length}`
);
if (nextCount >= messages.length) {
console.log('ProgressiveMessageList: All messages loaded');
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 || index}
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}
/>
)}
</>
)}
</div>
);
return result;
})
.filter(Boolean); // Filter out null values
return renderedMessages;
}, [
messages,
renderedCount,
renderMessage,
isUserMessage,
hasContextHandlerContent,
getContextHandlerType,
chat,
append,
appendMessage,
toolCallNotifications,
onScrollToBottom,
]);
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>
)}
</>
);
}
@@ -28,10 +28,47 @@ import {
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import UserMessage from '../UserMessage';
import GooseMessage from '../GooseMessage';
import ProgressiveMessageList from '../ProgressiveMessageList';
import { SearchView } from '../conversation/SearchView';
import { ChatContextManagerProvider } from '../context_management/ChatContextManager';
import { Message } from '../../types/message';
// Helper function to determine if a message is a user message (same as useChatEngine)
const isUserMessage = (message: Message): boolean => {
if (message.role === 'assistant') {
return false;
}
if (message.content.every((c) => c.type === 'toolConfirmationRequest')) {
return false;
}
return true;
};
// Filter messages for display (same logic as useChatEngine)
const filterMessagesForDisplay = (messages: Message[]): Message[] => {
return messages.filter((message) => {
// Only filter out when display is explicitly false
if (message.display === false) return false;
// Keep all assistant messages and user messages that aren't just tool responses
if (message.role === 'assistant') return true;
// For user messages, check if they're only tool responses
if (message.role === 'user') {
const hasOnlyToolResponses = message.content.every((c) => c.type === 'toolResponse');
const hasTextContent = message.content.some((c) => c.type === 'text');
const hasToolConfirmation = message.content.every(
(c) => c.type === 'toolConfirmationRequest'
);
// Keep the message if it has text content or tool confirmation or is not just tool responses
return hasTextContent || !hasOnlyToolResponses || hasToolConfirmation;
}
return true;
});
};
interface SessionHistoryViewProps {
session: SessionDetails;
isLoading: boolean;
@@ -64,13 +101,16 @@ const SessionHeader: React.FC<{
);
};
// New component for displaying session messages with chat styling
// Session messages component that uses the same rendering as BaseChat
const SessionMessages: React.FC<{
messages: Message[];
isLoading: boolean;
error: string | null;
onRetry: () => void;
}> = ({ messages, isLoading, error, onRetry }) => {
// Filter messages for display (same as BaseChat)
const filteredMessages = filterMessagesForDisplay(messages);
return (
<ScrollArea className="h-full w-full">
<div className="pb-24 pt-8">
@@ -90,35 +130,30 @@ const SessionMessages: React.FC<{
Try Again
</Button>
</div>
) : messages?.length > 0 ? (
<div className="max-w-4xl mx-auto w-full">
{messages
.map((message) => {
// Skip pure tool response messages for cleaner display
const isOnlyToolResponse =
message.content.length > 0 &&
message.content.every((c) => c.type === 'toolResponse');
if (isOnlyToolResponse) {
return null;
}
return message.role === 'user' ? (
<UserMessage key={message.id} message={message} />
) : (
<GooseMessage
key={message.id}
messageHistoryIndex={messages.length}
message={message}
messages={messages}
append={() => {}}
appendMessage={() => {}}
toolCallNotifications={new Map()}
/>
);
})
.filter(Boolean)}
</div>
) : filteredMessages?.length > 0 ? (
<ChatContextManagerProvider>
<div className="max-w-4xl mx-auto w-full">
<SearchView>
<ProgressiveMessageList
messages={filteredMessages}
chat={{
id: 'session-preview',
messageHistoryIndex: filteredMessages.length,
}}
toolCallNotifications={new Map()}
append={() => {}} // Read-only for session history
appendMessage={(newMessage) => {
// Read-only - do nothing
console.log('appendMessage called in read-only session history:', newMessage);
}}
isUserMessage={isUserMessage} // Use the same function as BaseChat
batchSize={15} // Same as BaseChat default
batchDelay={30} // Same as BaseChat default
showLoadingThreshold={30} // Same as BaseChat default
/>
</SearchView>
</div>
</ChatContextManagerProvider>
) : (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<MessageSquareText className="w-12 h-12 mb-4" />
-14
View File
@@ -27,33 +27,19 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
// First check if we have a persisted recipe in chat context
// We need to distinguish between null (explicitly cleared) and undefined (not set)
if (chatContext?.chat.recipeConfig !== undefined) {
console.log('useRecipeManager: Using recipe from chat context:', {
recipeConfig: chatContext.chat.recipeConfig,
title: chatContext.chat.recipeConfig?.title,
});
return chatContext.chat.recipeConfig; // This could be null or a Recipe
}
// Then check if recipe config is passed via navigation state
if (locationState?.recipeConfig) {
console.log('useRecipeManager: Using recipe from location state:', {
recipeConfig: locationState.recipeConfig,
title: locationState.recipeConfig.title,
});
return locationState.recipeConfig as Recipe;
}
// Fallback to app config (from deeplinks)
const appRecipeConfig = window.appConfig.get('recipeConfig') as Recipe | null;
if (appRecipeConfig) {
console.log('useRecipeManager: Using recipe from app config:', {
recipeConfig: appRecipeConfig,
title: appRecipeConfig.title,
});
return appRecipeConfig;
}
console.log('useRecipeManager: No recipe config found');
return null;
}, [chatContext, locationState]);