diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 85497bba..f074969d 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -112,15 +112,55 @@ function BaseChatContent({ const location = useLocation(); const scrollRef = useRef(null); - // Get disableAnimation from location state const disableAnimation = location.state?.disableAnimation || false; - - // Track if user has started using the current recipe const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState(null); - const { isCompacting, handleManualCompaction } = useContextManager(); + // Timeout ref for debouncing auto-scroll + const autoScrollTimeoutRef = useRef(null); + // Track if user was following when agent started responding + const wasFollowingRef = useRef(true); + + const isNearBottom = React.useCallback(() => { + if (!scrollRef.current) return false; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const viewport = scrollRef.current as any; + if (!viewport.viewportRef?.current) return false; + + const viewportElement = viewport.viewportRef.current; + const { scrollHeight, scrollTop, clientHeight } = viewportElement; + const scrollBottom = scrollTop + clientHeight; + const distanceFromBottom = scrollHeight - scrollBottom; + + return distanceFromBottom <= 100; + }, []); + + // Function to auto-scroll if user was following when agent started + const conditionalAutoScroll = React.useCallback(() => { + // Clear any existing timeout + if (autoScrollTimeoutRef.current) { + clearTimeout(autoScrollTimeoutRef.current); + } + + // Debounce the auto-scroll to prevent jumpy behavior and prevent multiple rapid scrolls + autoScrollTimeoutRef.current = window.setTimeout(() => { + // Only auto-scroll if user was following when the agent started responding + if (wasFollowingRef.current && scrollRef.current) { + scrollRef.current.scrollToBottom(); + } + }, 150); + }, []); + + useEffect(() => { + return () => { + if (autoScrollTimeoutRef.current) { + clearTimeout(autoScrollTimeoutRef.current); + } + }; + }, []); + // Use shared chat engine const { messages, @@ -148,10 +188,14 @@ function BaseChatContent({ chat, setChat, onMessageStreamFinish: () => { + conditionalAutoScroll(); + // Call the original callback if provided onMessageStreamFinish?.(); }, onMessageSent: () => { + wasFollowingRef.current = isNearBottom(); + // Mark that user has started using the recipe if (recipeConfig) { setHasStartedUsingRecipe(true); @@ -229,7 +273,14 @@ function BaseChatContent({ 'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2) ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Empty dependency array means this runs once on mount + }, []); + + // Auto-scroll when messages are loaded (for session resuming) + const handleRenderingComplete = React.useCallback(() => { + if (scrollRef.current?.scrollToBottom) { + scrollRef.current.scrollToBottom(); + } + }, []); // Handle submit const handleSubmit = (e: React.FormEvent) => { @@ -361,6 +412,7 @@ function BaseChatContent({ isUserMessage={isUserMessage} isStreamingMessage={chatState !== ChatState.Idle} onMessageUpdate={onMessageUpdate} + onRenderingComplete={handleRenderingComplete} /> ) : ( // Render messages with SearchView wrapper when search is enabled @@ -377,6 +429,7 @@ function BaseChatContent({ isUserMessage={isUserMessage} isStreamingMessage={chatState !== ChatState.Idle} onMessageUpdate={onMessageUpdate} + onRenderingComplete={handleRenderingComplete} /> )} diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 2d6ed51a..0b4e87f6 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -38,6 +38,7 @@ interface ProgressiveMessageListProps { renderMessage?: (message: Message, index: number) => React.ReactNode | null; isStreamingMessage?: boolean; // Whether messages are currently being streamed onMessageUpdate?: (messageId: string, newContent: string) => void; + onRenderingComplete?: () => void; // Callback when all messages are rendered } export default function ProgressiveMessageList({ @@ -53,6 +54,7 @@ export default function ProgressiveMessageList({ renderMessage, // Custom render function isStreamingMessage = false, // Whether messages are currently being streamed onMessageUpdate, + onRenderingComplete, }: ProgressiveMessageListProps) { const [renderedCount, setRenderedCount] = useState(() => { // Initialize with either all messages (if small) or first batch (if large) @@ -83,6 +85,10 @@ export default function ProgressiveMessageList({ if (messages.length <= showLoadingThreshold) { setRenderedCount(messages.length); setIsLoading(false); + // For small lists, call completion callback immediately + if (onRenderingComplete) { + setTimeout(() => onRenderingComplete(), 50); + } return; } @@ -93,6 +99,10 @@ export default function ProgressiveMessageList({ 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); @@ -111,7 +121,14 @@ export default function ProgressiveMessageList({ timeoutRef.current = null; } }; - }, [messages.length, batchSize, batchDelay, showLoadingThreshold, renderedCount]); + }, [ + messages.length, + batchSize, + batchDelay, + showLoadingThreshold, + renderedCount, + onRenderingComplete, + ]); // Cleanup on unmount useEffect(() => {