perf: Major performance overhaul with virtual scrolling and context splitting
Phase 1 - Virtual Scrolling: - Add @tanstack/react-virtual for efficient message list rendering - Only render visible messages instead of entire history - Fix auto-scroll using native scrollTop instead of unreliable virtualizer Phase 2 - Context Optimization: - Split monolithic SessionContext into 4 specialized contexts - MessagesContext, SessionsContext, SettingsContext, UIContext - Prevents unnecessary re-renders across unrelated components Phase 3 - Compression & Cleanup: - Enable Brotli compression (~23% smaller than gzip) - Switch to fholzer/nginx-brotli:v1.28.0 image - Add automatic upload cleanup for idle sessions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, useMemo, memo, useCallback, lazy, Suspense } from 'react';
|
||||
import { useEffect, useRef, useState, useMemo, memo, useCallback, lazy, Suspense, useLayoutEffect } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import {
|
||||
User, Bot, Terminal, CheckCircle, AlertCircle, Info,
|
||||
@@ -177,7 +178,6 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o
|
||||
const { hosts } = useHosts();
|
||||
const hostConfig = hosts[hostId] || null;
|
||||
const containerRef = useRef(null);
|
||||
const messagesEndRef = useRef(null);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const [newMessageCount, setNewMessageCount] = useState(0);
|
||||
const prevMessageCount = useRef(messages.length);
|
||||
@@ -203,30 +203,6 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o
|
||||
}
|
||||
}, [checkIfAtBottom]);
|
||||
|
||||
// Auto-scroll on new messages or content updates
|
||||
useEffect(() => {
|
||||
if (!userScrolledAway.current && containerRef.current) {
|
||||
// Use scrollTop directly for more reliable scrolling
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
} else if (userScrolledAway.current) {
|
||||
const newCount = messages.length - prevMessageCount.current;
|
||||
if (newCount > 0) {
|
||||
setNewMessageCount(prev => prev + newCount);
|
||||
}
|
||||
}
|
||||
prevMessageCount.current = messages.length;
|
||||
}, [messages]); // Watch entire messages array for content updates
|
||||
|
||||
// Scroll to bottom function
|
||||
const scrollToBottom = useCallback(() => {
|
||||
userScrolledAway.current = false;
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
setShowScrollButton(false);
|
||||
setNewMessageCount(0);
|
||||
}, []);
|
||||
|
||||
// Cache for incremental updates - avoid full rebuild on streaming content changes
|
||||
const processedCacheRef = useRef({ messages: [], result: [], toolResultMap: new Map() });
|
||||
|
||||
@@ -295,6 +271,82 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o
|
||||
return result;
|
||||
}, [messages]);
|
||||
|
||||
// Virtualizer for efficient rendering of large message lists
|
||||
// Estimate height based on message type
|
||||
const estimateSize = useCallback((index) => {
|
||||
const msg = processedMessages[index];
|
||||
if (!msg) return 100;
|
||||
switch (msg.type) {
|
||||
case 'user': return 80;
|
||||
case 'assistant': return 200;
|
||||
case 'tool_use': return 150;
|
||||
case 'tool_result': return 120;
|
||||
case 'system': return 40;
|
||||
case 'error': return 80;
|
||||
default: return 100;
|
||||
}
|
||||
}, [processedMessages]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: processedMessages.length + (isProcessing ? 1 : 0), // +1 for processing indicator
|
||||
getScrollElement: () => containerRef.current,
|
||||
estimateSize,
|
||||
overscan: 5, // Render 5 extra items above/below viewport
|
||||
measureElement: (element) => {
|
||||
// Measure actual element height for dynamic sizing
|
||||
return element?.getBoundingClientRect().height ?? estimateSize(0);
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive or content changes (if not scrolled away)
|
||||
useLayoutEffect(() => {
|
||||
// Skip if user manually scrolled away
|
||||
if (userScrolledAway.current) {
|
||||
const newCount = messages.length - prevMessageCount.current;
|
||||
if (newCount > 0) {
|
||||
setNewMessageCount(prev => prev + newCount);
|
||||
}
|
||||
prevMessageCount.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use native scroll for reliability - virtualizer.scrollToIndex can be unreliable
|
||||
if (containerRef.current && processedMessages.length > 0) {
|
||||
// Small delay to let DOM update after content change
|
||||
requestAnimationFrame(() => {
|
||||
if (containerRef.current && !userScrolledAway.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
prevMessageCount.current = messages.length;
|
||||
}, [messages, processedMessages.length, isProcessing]);
|
||||
|
||||
// Also scroll on streaming content updates (last message content change)
|
||||
const lastMessageContent = messages[messages.length - 1]?.content;
|
||||
useLayoutEffect(() => {
|
||||
if (!userScrolledAway.current && containerRef.current && lastMessageContent) {
|
||||
requestAnimationFrame(() => {
|
||||
if (containerRef.current && !userScrolledAway.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [lastMessageContent]);
|
||||
|
||||
// Scroll to bottom function - use native scroll for reliability
|
||||
const scrollToBottomVirtual = useCallback(() => {
|
||||
userScrolledAway.current = false;
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTo({
|
||||
top: containerRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
setShowScrollButton(false);
|
||||
setNewMessageCount(0);
|
||||
}, []);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-8">
|
||||
@@ -310,36 +362,67 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o
|
||||
);
|
||||
}
|
||||
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-full overflow-y-auto p-4 space-y-4"
|
||||
className="h-full overflow-y-auto"
|
||||
>
|
||||
{processedMessages.map((message, index) => (
|
||||
<Message key={`${message.type}-${message._originalIndex}-${message.timestamp || index}-${hostConfig?.avatar || 'no-avatar'}`} message={message} onSendMessage={onSendMessage} hostConfig={hostConfig} />
|
||||
))}
|
||||
{/* Virtual list container */}
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{items.map((virtualItem) => {
|
||||
const isProcessingIndicator = virtualItem.index === processedMessages.length;
|
||||
|
||||
{/* Processing indicator */}
|
||||
{isProcessing && (
|
||||
<div className="flex items-center gap-3 text-dark-400">
|
||||
<Bot className="w-6 h-6" />
|
||||
<div className="flex gap-1">
|
||||
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
|
||||
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
|
||||
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
className="px-4 py-2"
|
||||
>
|
||||
{isProcessingIndicator ? (
|
||||
// Processing indicator
|
||||
<div className="flex items-center gap-3 text-dark-400">
|
||||
<Bot className="w-6 h-6" />
|
||||
<div className="flex gap-1">
|
||||
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
|
||||
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
|
||||
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Message
|
||||
message={processedMessages[virtualItem.index]}
|
||||
onSendMessage={onSendMessage}
|
||||
hostConfig={hostConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating scroll-to-bottom button */}
|
||||
{showScrollButton && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
onClick={scrollToBottomVirtual}
|
||||
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-full shadow-lg transition-all duration-200 hover:scale-105 z-10"
|
||||
>
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
|
||||
Reference in New Issue
Block a user