diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 0aa29ff052..fbb0b71ebb 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -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'); diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 28cde204e5..633eb170b3 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -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 ( -
- {isUser ? ( - <> - {hasContextHandlerContent(message) ? ( - { - // Auto-scroll to bottom when summary is complete - setTimeout(() => { - if (scrollRef.current?.scrollToBottom) { - scrollRef.current.scrollToBottom(); - } - }, 100); - }} - /> - ) : ( - - )} - - ) : ( - <> - {hasContextHandlerContent(message) ? ( - { - // Auto-scroll to bottom when summary is complete - setTimeout(() => { - if (scrollRef.current?.scrollToBottom) { - scrollRef.current.scrollToBottom(); - } - }, 100); - }} - /> - ) : ( - { - const updatedMessages = [...messages, newMessage]; - setMessages(updatedMessages); - }} - toolCallNotifications={toolCallNotifications} - /> - )} - - )} -
- ); - }); - }; + // Callback to handle scroll to bottom from ProgressiveMessageList + const handleScrollToBottom = useCallback(() => { + setTimeout(() => { + if (scrollRef.current?.scrollToBottom) { + scrollRef.current.scrollToBottom(); + } + }, 100); + }, []); return (
@@ -443,10 +378,34 @@ function BaseChatContent({ <> {disableSearch ? ( // Render messages without SearchView wrapper when search is disabled - renderMessages() + { + const updatedMessages = [...messages, newMessage]; + setMessages(updatedMessages); + }} + isUserMessage={isUserMessage} + onScrollToBottom={handleScrollToBottom} + /> ) : ( // Render messages with SearchView wrapper when search is enabled - {renderMessages()} + + { + const updatedMessages = [...messages, newMessage]; + setMessages(updatedMessages); + }} + isUserMessage={isUserMessage} + onScrollToBottom={handleScrollToBottom} + /> + )} {error && ( diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx new file mode 100644 index 0000000000..9749b715af --- /dev/null +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -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; // 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(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 = ( +
+ {isUser ? ( + <> + {hasContextHandlerContent && hasContextHandlerContent(message) ? ( + { + window.setTimeout(() => onScrollToBottom?.(), 100); + }} + /> + ) : ( + + )} + + ) : ( + <> + {hasContextHandlerContent && hasContextHandlerContent(message) ? ( + { + window.setTimeout(() => onScrollToBottom?.(), 100); + }} + /> + ) : ( + + )} + + )} +
+ ); + + 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 && ( +
+ +
+ Press Cmd/Ctrl+F to load all messages immediately for search +
+
+ )} + + ); +} diff --git a/ui/desktop/src/components/sessions/SessionHistoryView.tsx b/ui/desktop/src/components/sessions/SessionHistoryView.tsx index 88f91ae3f5..23d764743d 100644 --- a/ui/desktop/src/components/sessions/SessionHistoryView.tsx +++ b/ui/desktop/src/components/sessions/SessionHistoryView.tsx @@ -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 (
@@ -90,35 +130,30 @@ const SessionMessages: React.FC<{ Try Again
- ) : messages?.length > 0 ? ( -
- {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' ? ( - - ) : ( - {}} - appendMessage={() => {}} - toolCallNotifications={new Map()} - /> - ); - }) - .filter(Boolean)} -
+ ) : filteredMessages?.length > 0 ? ( + +
+ + {}} // 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 + /> + +
+
) : (
diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index 924a1723ca..6e805604af 100644 --- a/ui/desktop/src/hooks/useRecipeManager.ts +++ b/ui/desktop/src/hooks/useRecipeManager.ts @@ -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]);