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]);