Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/ui/components/scroll-container.tsx
Original file line number Diff line number Diff line change
@@ -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<ScrollContainerProps> = ({
children,
autoScroll = true,
maxHeight = 25
}) => {
const [scrollPosition, setScrollPosition] = useState(0);
const [isAtBottom, setIsAtBottom] = useState(true);
const contentRef = useRef<any>(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 (
<Box flexDirection="column" width="100%">
{/* Scroll up indicator - more subtle like Claude */}
{canScrollUp && (
<Box justifyContent="center" marginBottom={1}>
<Box paddingX={1}>
<Text color="gray" dimColor>
↑ {actualScrollPosition} messages above
</Text>
</Box>
</Box>
)}

{/* Content area with smooth transitions */}
<Box flexDirection="column" ref={contentRef}>
{visibleChildren}
</Box>

{/* Scroll down indicator - more subtle like Claude */}
{canScrollDown && (
<Box justifyContent="center" marginTop={1}>
<Box paddingX={1}>
<Text color="gray" dimColor>
↓ {totalChildren - (actualScrollPosition + maxHeight)} more messages below
</Text>
</Box>
</Box>
)}
</Box>
);
};
84 changes: 74 additions & 10 deletions src/ui/components/streaming-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -37,6 +38,13 @@ export default function StreamingChat({ agent, onProviderSwitch, onTokenCountCha
const isMountedRef = useRef(true);
const contentBufferRef = useRef('');
const contentUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(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();

Expand Down Expand Up @@ -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) =>
Expand All @@ -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;
Expand Down Expand Up @@ -386,15 +411,15 @@ The chat history is automatically saved and will persist between sessions.`,
switch (message.type) {
case 'user':
return (
<Box key={index} marginBottom={1}>
<Box key={`user-${index}-${message.timestamp.getTime()}`} marginBottom={1}>
<Text color="blue">❯ </Text>
<Text>{message.content}</Text>
</Box>
);

case 'assistant':
return (
<Box key={index} flexDirection="column" marginBottom={1}>
<Box key={`assistant-${index}-${message.timestamp.getTime()}`} flexDirection="column" marginBottom={1}>
{message.content && (
<Box marginLeft={2}>
<MarkdownRenderer content={message.content} />
Expand All @@ -406,14 +431,14 @@ The chat history is automatically saved and will persist between sessions.`,

case 'tool_calls':
return (
<Box key={index} flexDirection="column" marginBottom={1} width="100%">
<Box key={`tools-${index}-${message.timestamp.getTime()}`} flexDirection="column" marginBottom={1} width="100%">
{message.toolCalls?.map((toolCall, tcIndex) => {
// Find corresponding result from chat history
const result = (toolCall as any).result;

return (
<ToolCallBox
key={`${index}-${tcIndex}`}
key={`${index}-${tcIndex}-${toolCall.id}`}
toolCall={toolCall}
result={result}
/>
Expand All @@ -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 (
Expand Down Expand Up @@ -461,9 +521,13 @@ The chat history is automatically saved and will persist between sessions.`,
<Text dimColor>Press 's' to stop operation, ESC to cancel, Ctrl+P to switch provider/model, 'exit' or Ctrl+C to quit</Text>
</Box>

{/* Messages */}
<Box flexDirection="column" marginBottom={1} width="100%">
{messages.map(renderMessage)}
{/* Messages with scroll container */}
<Box flexDirection="column" marginBottom={1} width="100%" flexGrow={1}>
<ScrollContainer autoScroll={true} maxHeight={25}>
{visibleMessages.map((message, index) => (
<MemoizedMessage key={`msg-${index}-${message.timestamp.getTime()}`} message={message} index={index} />
))}
</ScrollContainer>
</Box>

{/* Input */}
Expand Down
Loading