Skip to content

Commit 1bb265b

Browse files
committed
perf: implement virtual scrolling for message lists
- Replace static rendering with @tanstack/react-virtual - Optimize rendering for long conversation histories - Maintain smooth auto-scroll behavior - Integrate with enhanced message system from upstream Significantly improves performance when handling extensive agent execution outputs and long Claude sessions.
1 parent 4334045 commit 1bb265b

2 files changed

Lines changed: 372 additions & 162 deletions

File tree

src/components/AgentExecution.tsx

Lines changed: 77 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { StreamMessage } from "./StreamMessage";
2525
import { ExecutionControlBar } from "./ExecutionControlBar";
2626
import { ErrorBoundary } from "./ErrorBoundary";
2727
import { enhanceMessages, type EnhancedMessage } from "@/types/enhanced-messages";
28+
import { useVirtualizer } from "@tanstack/react-virtual";
2829

2930
interface AgentExecutionProps {
3031
/**
@@ -94,6 +95,21 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
9495
const unlistenRefs = useRef<UnlistenFn[]>([]);
9596
const elapsedTimeIntervalRef = useRef<NodeJS.Timeout | null>(null);
9697

98+
// Virtualizers for efficient, smooth scrolling of potentially very long outputs
99+
const rowVirtualizer = useVirtualizer({
100+
count: enhancedMessages.length,
101+
getScrollElement: () => scrollContainerRef.current,
102+
estimateSize: () => 150, // fallback estimate; dynamically measured afterwards
103+
overscan: 5,
104+
});
105+
106+
const fullscreenRowVirtualizer = useVirtualizer({
107+
count: enhancedMessages.length,
108+
getScrollElement: () => fullscreenScrollRef.current,
109+
estimateSize: () => 150,
110+
overscan: 5,
111+
});
112+
97113
useEffect(() => {
98114
// Clean up listeners on unmount
99115
return () => {
@@ -116,17 +132,19 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
116132
};
117133

118134
useEffect(() => {
119-
// Only auto-scroll if user hasn't manually scrolled OR if they're at the bottom
135+
if (enhancedMessages.length === 0) return;
136+
137+
// Auto-scroll only if the user has not manually scrolled OR they are still at the bottom
120138
const shouldAutoScroll = !hasUserScrolled || isAtBottom();
121-
139+
122140
if (shouldAutoScroll) {
123-
const endRef = isFullscreenModalOpen ? fullscreenMessagesEndRef.current : messagesEndRef.current;
124-
if (endRef) {
125-
endRef.scrollIntoView({ behavior: "smooth" });
141+
if (isFullscreenModalOpen) {
142+
fullscreenRowVirtualizer.scrollToIndex(enhancedMessages.length - 1, { align: "end", behavior: "smooth" });
143+
} else {
144+
rowVirtualizer.scrollToIndex(enhancedMessages.length - 1, { align: "end", behavior: "smooth" });
126145
}
127146
}
128-
}, [messages, hasUserScrolled, isFullscreenModalOpen]);
129-
147+
}, [enhancedMessages.length, hasUserScrolled, isFullscreenModalOpen, rowVirtualizer, fullscreenRowVirtualizer]);
130148

131149
// Update elapsed time while running
132150
useEffect(() => {
@@ -621,21 +639,32 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
621639
</div>
622640
)}
623641

624-
<AnimatePresence>
625-
{enhancedMessages.map((message, index) => (
626-
<motion.div
627-
key={index}
628-
initial={{ opacity: 0, y: 10 }}
629-
animate={{ opacity: 1, y: 0 }}
630-
transition={{ duration: 0.2 }}
631-
className="mb-4"
632-
>
633-
<ErrorBoundary>
634-
<StreamMessage message={message} streamMessages={enhancedMessages} />
635-
</ErrorBoundary>
636-
</motion.div>
637-
))}
638-
</AnimatePresence>
642+
<div
643+
className="relative w-full max-w-5xl mx-auto"
644+
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
645+
>
646+
<AnimatePresence>
647+
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
648+
const message = enhancedMessages[virtualItem.index];
649+
return (
650+
<motion.div
651+
key={virtualItem.key}
652+
data-index={virtualItem.index}
653+
ref={(el) => el && rowVirtualizer.measureElement(el)}
654+
initial={{ opacity: 0, y: 10 }}
655+
animate={{ opacity: 1, y: 0 }}
656+
transition={{ duration: 0.2 }}
657+
className="absolute inset-x-4 pb-4"
658+
style={{ top: virtualItem.start }}
659+
>
660+
<ErrorBoundary>
661+
<StreamMessage message={message} streamMessages={enhancedMessages} />
662+
</ErrorBoundary>
663+
</motion.div>
664+
);
665+
})}
666+
</AnimatePresence>
667+
</div>
639668

640669
<div ref={messagesEndRef} />
641670
</div>
@@ -751,21 +780,32 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
751780
</div>
752781
)}
753782

754-
<AnimatePresence>
755-
{enhancedMessages.map((message, index) => (
756-
<motion.div
757-
key={index}
758-
initial={{ opacity: 0, y: 10 }}
759-
animate={{ opacity: 1, y: 0 }}
760-
transition={{ duration: 0.2 }}
761-
className="mb-4"
762-
>
763-
<ErrorBoundary>
764-
<StreamMessage message={message} streamMessages={enhancedMessages} />
765-
</ErrorBoundary>
766-
</motion.div>
767-
))}
768-
</AnimatePresence>
783+
<div
784+
className="relative w-full max-w-5xl mx-auto"
785+
style={{ height: `${fullscreenRowVirtualizer.getTotalSize()}px` }}
786+
>
787+
<AnimatePresence>
788+
{fullscreenRowVirtualizer.getVirtualItems().map((virtualItem) => {
789+
const message = enhancedMessages[virtualItem.index];
790+
return (
791+
<motion.div
792+
key={virtualItem.key}
793+
data-index={virtualItem.index}
794+
ref={(el) => el && fullscreenRowVirtualizer.measureElement(el)}
795+
initial={{ opacity: 0, y: 10 }}
796+
animate={{ opacity: 1, y: 0 }}
797+
transition={{ duration: 0.2 }}
798+
className="absolute inset-x-4 pb-4"
799+
style={{ top: virtualItem.start }}
800+
>
801+
<ErrorBoundary>
802+
<StreamMessage message={message} streamMessages={enhancedMessages} />
803+
</ErrorBoundary>
804+
</motion.div>
805+
);
806+
})}
807+
</AnimatePresence>
808+
</div>
769809

770810
<div ref={fullscreenMessagesEndRef} />
771811
</div>

0 commit comments

Comments
 (0)