diff --git a/src/ui/components/scroll-container.tsx b/src/ui/components/scroll-container.tsx new file mode 100644 index 0000000..b73b238 --- /dev/null +++ b/src/ui/components/scroll-container.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { Box, Text } from 'ink'; + +interface ScrollContainerProps { + children: React.ReactNode; + autoScroll?: boolean; + maxHeight?: number; +} + +export const ScrollContainer: React.FC = ({ + children, + autoScroll = true, + maxHeight = 25 +}) => { + const [scrollPosition, setScrollPosition] = useState(0); + const [isAtBottom, setIsAtBottom] = useState(true); + const contentRef = useRef(null); + const lastChildCountRef = useRef(0); + const isStreamingRef = useRef(false); + + // Auto-scroll to bottom when new content is added + useEffect(() => { + const childCount = React.Children.count(children); + + // Check if we're streaming (new content being added) + if (childCount > lastChildCountRef.current) { + isStreamingRef.current = true; + + if (autoScroll && isAtBottom) { + // Keep scrolled to bottom during streaming + const newScrollPosition = Math.max(0, childCount - maxHeight); + setScrollPosition(newScrollPosition); + setIsAtBottom(newScrollPosition + maxHeight >= childCount); + } + + lastChildCountRef.current = childCount; + + // Reset streaming flag after a short delay + setTimeout(() => { + isStreamingRef.current = false; + }, 100); + } + }, [children, autoScroll, isAtBottom, maxHeight]); + + // Calculate visible content based on scroll position + const visibleChildren = React.useMemo(() => { + const childArray = React.Children.toArray(children); + const totalChildren = childArray.length; + + if (totalChildren <= maxHeight) { + return childArray; + } + + const startIndex = Math.max(0, Math.min(scrollPosition, totalChildren - maxHeight)); + const endIndex = Math.min(totalChildren, startIndex + maxHeight); + + return childArray.slice(startIndex, endIndex); + }, [children, scrollPosition, maxHeight]); + + // Show scroll indicators + const totalChildren = React.Children.count(children); + const actualScrollPosition = totalChildren <= maxHeight ? 0 : Math.max(0, Math.min(scrollPosition, totalChildren - maxHeight)); + const canScrollUp = actualScrollPosition > 0; + const canScrollDown = actualScrollPosition + maxHeight < totalChildren; + + return ( + + {/* Scroll up indicator - more subtle like Claude */} + {canScrollUp && ( + + + + ↑ {actualScrollPosition} messages above + + + + )} + + {/* Content area with smooth transitions */} + + {visibleChildren} + + + {/* Scroll down indicator - more subtle like Claude */} + {canScrollDown && ( + + + + ↓ {totalChildren - (actualScrollPosition + maxHeight)} more messages below + + + + )} + + ); +}; \ No newline at end of file diff --git a/src/ui/components/streaming-chat.tsx b/src/ui/components/streaming-chat.tsx index 3aa62c5..4d71b37 100644 --- a/src/ui/components/streaming-chat.tsx +++ b/src/ui/components/streaming-chat.tsx @@ -7,6 +7,7 @@ import ToolCallBox from './tool-call-box'; import { ConfirmationService, ConfirmationOptions } from '../../utils/confirmation-service'; import ConfirmationDialog from './confirmation-dialog'; import { SettingsMenu } from './settings-menu'; +import { ScrollContainer } from './scroll-container'; import { logger } from '../../utils/logger'; import { MarkdownRenderer } from '../utils/markdown-renderer'; @@ -37,6 +38,13 @@ export default function StreamingChat({ agent, onProviderSwitch, onTokenCountCha const isMountedRef = useRef(true); const contentBufferRef = useRef(''); const contentUpdateTimeoutRef = useRef(null); + const lastRenderTimeRef = useRef(0); + const scrollPositionRef = useRef(0); + + // Constants for performance optimization + const MAX_VISIBLE_MESSAGES = 50; // Limit visible messages to prevent performance issues + const CONTENT_UPDATE_DELAY = 100; // Increased delay to reduce blinking + const MIN_RENDER_INTERVAL = 16; // ~60fps limit for renders const confirmationService = ConfirmationService.getInstance(); @@ -276,8 +284,11 @@ The chat history is automatically saved and will persist between sessions.`, clearTimeout(contentUpdateTimeoutRef.current); } - // Debounce content updates (50ms delay) - contentUpdateTimeoutRef.current = setTimeout(() => { + // Throttle content updates with frame rate limiting + const now = Date.now(); + if (now - lastRenderTimeRef.current >= MIN_RENDER_INTERVAL) { + // Update immediately if enough time has passed + lastRenderTimeRef.current = now; if (isMountedRef.current) { setMessages(prev => prev.map((msg, idx) => @@ -287,7 +298,21 @@ The chat history is automatically saved and will persist between sessions.`, ) ); } - }, 50); + } else { + // Debounce content updates with increased delay + contentUpdateTimeoutRef.current = setTimeout(() => { + if (isMountedRef.current) { + lastRenderTimeRef.current = Date.now(); + setMessages(prev => + prev.map((msg, idx) => + idx === prev.length - 1 && msg.isStreaming + ? { ...msg, content: contentBufferRef.current } + : msg + ) + ); + } + }, CONTENT_UPDATE_DELAY); + } } } break; @@ -386,7 +411,7 @@ The chat history is automatically saved and will persist between sessions.`, switch (message.type) { case 'user': return ( - + {message.content} @@ -394,7 +419,7 @@ The chat history is automatically saved and will persist between sessions.`, case 'assistant': return ( - + {message.content && ( @@ -406,14 +431,14 @@ The chat history is automatically saved and will persist between sessions.`, case 'tool_calls': return ( - + {message.toolCalls?.map((toolCall, tcIndex) => { // Find corresponding result from chat history const result = (toolCall as any).result; return ( @@ -427,6 +452,41 @@ The chat history is automatically saved and will persist between sessions.`, } }, []); + // Memoize visible messages to prevent unnecessary re-renders + const visibleMessages = useMemo(() => { + const totalMessages = messages.length; + if (totalMessages <= MAX_VISIBLE_MESSAGES) { + return messages; + } + + // Keep the most recent messages and show a truncation indicator + const truncatedMessages = messages.slice(-MAX_VISIBLE_MESSAGES); + const truncationMessage: ChatMessage = { + type: 'assistant', + content: `... (${totalMessages - MAX_VISIBLE_MESSAGES} earlier messages hidden for performance)`, + timestamp: new Date(0), // Use epoch time for consistent sorting + }; + + return [truncationMessage, ...truncatedMessages]; + }, [messages]); + + // Stable message rendering with React.memo optimization + const MemoizedMessage = React.memo(({ message, index }: { message: ChatMessage; index: number }) => { + return renderMessage(message, index); + }, (prevProps, nextProps) => { + // Only re-render if the message content actually changed + const prevMsg = prevProps.message; + const nextMsg = nextProps.message; + + return ( + prevMsg.content === nextMsg.content && + prevMsg.isStreaming === nextMsg.isStreaming && + prevMsg.type === nextMsg.type && + prevMsg.timestamp.getTime() === nextMsg.timestamp.getTime() && + JSON.stringify(prevMsg.toolCalls) === JSON.stringify(nextMsg.toolCalls) + ); + }); + // If settings menu is active, show it instead of the chat if (showSettings) { return ( @@ -461,9 +521,13 @@ The chat history is automatically saved and will persist between sessions.`, Press 's' to stop operation, ESC to cancel, Ctrl+P to switch provider/model, 'exit' or Ctrl+C to quit - {/* Messages */} - - {messages.map(renderMessage)} + {/* Messages with scroll container */} + + + {visibleMessages.map((message, index) => ( + + ))} + {/* Input */}