From 1bb265beef9a1ef125bd373e41b59060291381ce Mon Sep 17 00:00:00 2001 From: Mufeed VH Date: Mon, 23 Jun 2025 00:30:31 +0530 Subject: [PATCH] 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. --- src/components/AgentExecution.tsx | 114 +++++--- src/components/ClaudeCodeSession.tsx | 420 +++++++++++++++++++-------- 2 files changed, 372 insertions(+), 162 deletions(-) diff --git a/src/components/AgentExecution.tsx b/src/components/AgentExecution.tsx index e057640..115fb42 100644 --- a/src/components/AgentExecution.tsx +++ b/src/components/AgentExecution.tsx @@ -25,6 +25,7 @@ import { StreamMessage } from "./StreamMessage"; import { ExecutionControlBar } from "./ExecutionControlBar"; import { ErrorBoundary } from "./ErrorBoundary"; import { enhanceMessages, type EnhancedMessage } from "@/types/enhanced-messages"; +import { useVirtualizer } from "@tanstack/react-virtual"; interface AgentExecutionProps { /** @@ -94,6 +95,21 @@ export const AgentExecution: React.FC = ({ const unlistenRefs = useRef([]); const elapsedTimeIntervalRef = useRef(null); + // Virtualizers for efficient, smooth scrolling of potentially very long outputs + const rowVirtualizer = useVirtualizer({ + count: enhancedMessages.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => 150, // fallback estimate; dynamically measured afterwards + overscan: 5, + }); + + const fullscreenRowVirtualizer = useVirtualizer({ + count: enhancedMessages.length, + getScrollElement: () => fullscreenScrollRef.current, + estimateSize: () => 150, + overscan: 5, + }); + useEffect(() => { // Clean up listeners on unmount return () => { @@ -116,17 +132,19 @@ export const AgentExecution: React.FC = ({ }; useEffect(() => { - // Only auto-scroll if user hasn't manually scrolled OR if they're at the bottom + if (enhancedMessages.length === 0) return; + + // Auto-scroll only if the user has not manually scrolled OR they are still at the bottom const shouldAutoScroll = !hasUserScrolled || isAtBottom(); - + if (shouldAutoScroll) { - const endRef = isFullscreenModalOpen ? fullscreenMessagesEndRef.current : messagesEndRef.current; - if (endRef) { - endRef.scrollIntoView({ behavior: "smooth" }); + if (isFullscreenModalOpen) { + fullscreenRowVirtualizer.scrollToIndex(enhancedMessages.length - 1, { align: "end", behavior: "smooth" }); + } else { + rowVirtualizer.scrollToIndex(enhancedMessages.length - 1, { align: "end", behavior: "smooth" }); } } - }, [messages, hasUserScrolled, isFullscreenModalOpen]); - + }, [enhancedMessages.length, hasUserScrolled, isFullscreenModalOpen, rowVirtualizer, fullscreenRowVirtualizer]); // Update elapsed time while running useEffect(() => { @@ -621,21 +639,32 @@ export const AgentExecution: React.FC = ({ )} - - {enhancedMessages.map((message, index) => ( - - - - - - ))} - +
+ + {rowVirtualizer.getVirtualItems().map((virtualItem) => { + const message = enhancedMessages[virtualItem.index]; + return ( + el && rowVirtualizer.measureElement(el)} + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.2 }} + className="absolute inset-x-4 pb-4" + style={{ top: virtualItem.start }} + > + + + + + ); + })} + +
@@ -751,21 +780,32 @@ export const AgentExecution: React.FC = ({ )} - - {enhancedMessages.map((message, index) => ( - - - - - - ))} - +
+ + {fullscreenRowVirtualizer.getVirtualItems().map((virtualItem) => { + const message = enhancedMessages[virtualItem.index]; + return ( + el && fullscreenRowVirtualizer.measureElement(el)} + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.2 }} + className="absolute inset-x-4 pb-4" + style={{ top: virtualItem.start }} + > + + + + + ); + })} + +
diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index a81cb52..b5956ba 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -8,7 +8,8 @@ import { Copy, ChevronDown, GitBranch, - Settings + Settings, + Globe } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -19,14 +20,19 @@ import { cn } from "@/lib/utils"; import { open } from "@tauri-apps/plugin-dialog"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { StreamMessage } from "./StreamMessage"; -import { FloatingPromptInput } from "./FloatingPromptInput"; +import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput"; import { ErrorBoundary } from "./ErrorBoundary"; import { TokenCounter } from "./TokenCounter"; import { TimelineNavigator } from "./TimelineNavigator"; import { CheckpointSettings } from "./CheckpointSettings"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { SplitPane } from "@/components/ui/split-pane"; +import { WebviewPreview } from "./WebviewPreview"; +import { PreviewPromptDialog } from "./PreviewPromptDialog"; import type { ClaudeStreamMessage } from "./AgentExecution"; import { enhanceMessages, type EnhancedMessage } from "@/types/enhanced-messages"; +import { useVirtualizer } from "@tanstack/react-virtual"; interface ClaudeCodeSessionProps { /** @@ -67,7 +73,6 @@ export const ClaudeCodeSession: React.FC = ({ const [rawJsonlOutput, setRawJsonlOutput] = useState([]); const [copyPopoverOpen, setCopyPopoverOpen] = useState(false); const [isFirstPrompt, setIsFirstPrompt] = useState(!session); - const [currentModel, setCurrentModel] = useState<"sonnet" | "opus">("sonnet"); const [totalTokens, setTotalTokens] = useState(0); const [extractedSessionInfo, setExtractedSessionInfo] = useState<{ sessionId: string; @@ -80,9 +85,18 @@ export const ClaudeCodeSession: React.FC = ({ const [forkCheckpointId, setForkCheckpointId] = useState(null); const [forkSessionName, setForkSessionName] = useState(""); - const messagesEndRef = useRef(null); + // New state for preview feature + const [showPreview, setShowPreview] = useState(false); + const [previewUrl, setPreviewUrl] = useState(""); + const [detectedUrl, setDetectedUrl] = useState(""); + const [showPreviewPrompt, setShowPreviewPrompt] = useState(false); + const [splitPosition, setSplitPosition] = useState(50); + const [isPreviewMaximized, setIsPreviewMaximized] = useState(false); + + const parentRef = useRef(null); const unlistenRefs = useRef([]); const hasActiveSessionRef = useRef(false); + const floatingPromptRef = useRef(null); // Get effective session info (from prop or extracted) - use useMemo to ensure it updates const effectiveSession = useMemo(() => { @@ -98,6 +112,13 @@ export const ClaudeCodeSession: React.FC = ({ return null; }, [session, extractedSessionInfo, projectPath]); + const rowVirtualizer = useVirtualizer({ + count: enhancedMessages.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 150, // Estimate, will be dynamically measured + overscan: 5, + }); + // Debug logging useEffect(() => { console.log('[ClaudeCodeSession] State update:', { @@ -125,8 +146,10 @@ export const ClaudeCodeSession: React.FC = ({ // Auto-scroll to bottom when new messages arrive useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [enhancedMessages]); + if (enhancedMessages.length > 0) { + rowVirtualizer.scrollToIndex(enhancedMessages.length - 1, { align: 'end', behavior: 'smooth' }); + } + }, [enhancedMessages.length, rowVirtualizer]); // Calculate total tokens from messages useEffect(() => { @@ -195,7 +218,6 @@ export const ClaudeCodeSession: React.FC = ({ try { setIsLoading(true); setError(null); - setCurrentModel(model); hasActiveSessionRef.current = true; // Add the user message immediately to the UI @@ -453,6 +475,51 @@ export const ClaudeCodeSession: React.FC = ({ } }; + // Handle URL detection from terminal output + const handleLinkDetected = (url: string) => { + if (!showPreview && !showPreviewPrompt) { + setDetectedUrl(url); + setShowPreviewPrompt(true); + } + }; + + const handleOpenPreview = () => { + setPreviewUrl(detectedUrl); + setShowPreview(true); + setShowPreviewPrompt(false); + }; + + const handleClosePreview = () => { + setShowPreview(false); + setIsPreviewMaximized(false); + // Keep the previewUrl so it can be restored when reopening + }; + + const handlePreviewScreenshot = async (imagePath: string) => { + console.log("Screenshot captured:", imagePath); + + // Add the screenshot to the floating prompt input + if (floatingPromptRef.current) { + floatingPromptRef.current.addImage(imagePath); + + // Show a subtle animation/feedback that the image was added + // You could add a toast notification here if desired + } + }; + + const handlePreviewUrlChange = (url: string) => { + console.log('[ClaudeCodeSession] Preview URL changed to:', url); + setPreviewUrl(url); + }; + + const handleTogglePreviewMaximize = () => { + setIsPreviewMaximized(!isPreviewMaximized); + // Reset split position when toggling maximize + if (isPreviewMaximized) { + setSplitPosition(50); + } + }; + // Clean up listeners on component unmount useEffect(() => { return () => { @@ -466,9 +533,132 @@ export const ClaudeCodeSession: React.FC = ({ }; }, []); + const messagesList = ( +
+
+ + {rowVirtualizer.getVirtualItems().map((virtualItem) => { + const message = enhancedMessages[virtualItem.index]; + return ( + el && rowVirtualizer.measureElement(el)} + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -20 }} + transition={{ duration: 0.3 }} + className="absolute inset-x-4 pb-4" + style={{ + top: virtualItem.start, + }} + > + + + ); + })} + +
+ + {/* Loading and Error indicators positioned relative to the scroll container */} +
+ {isLoading && ( + + + + )} + + {error && ( + + {error} + + )} +
+
+ ); + + const projectPathInput = !session && ( + + +
+ setProjectPath(e.target.value)} + placeholder="/path/to/your/project" + className="flex-1" + disabled={isLoading} + /> + +
+
+ ); + + // If preview is maximized, render only the WebviewPreview in full screen + if (showPreview && isPreviewMaximized) { + return ( + + + + + + ); + } + return (
-
+
{/* Header */} = ({ )} + {/* Preview Button */} + + + + + + + {showPreview + ? "Close the preview pane" + : "Open a browser preview to test your web applications" + } + + + + {enhancedMessages.length > 0 && ( = ({
} open={copyPopoverOpen} onOpenChange={setCopyPopoverOpen} - align="end" /> )} + +
- {/* Timeline Navigator */} - {showTimeline && effectiveSession && ( -
-
- -
-
- )} - - {/* Project Path Selection (only for new sessions) */} - {!session && ( -
- {/* Error display */} - {error && ( - - {error} - - )} - - {/* Project Path */} -
- -
- setProjectPath(e.target.value)} - placeholder="Select or enter project path" - disabled={hasActiveSessionRef.current} - className="flex-1" + {/* Main Content Area */} +
+ {showPreview ? ( + // Split pane layout when preview is active + + {projectPathInput} + {messagesList} +
+ } + right={ + - -
-
-
- )} - - {/* Messages Display */} -
- {enhancedMessages.length === 0 && !isLoading && ( -
- -

Ready to Start

-

- {session - ? "Send a message to continue this conversation" - : "Select a project path and send your first prompt" - } -

+ } + initialSplit={splitPosition} + onSplitChange={setSplitPosition} + minLeftWidth={400} + minRightWidth={400} + className="h-full" + /> + ) : ( + // Original layout when no preview +
+ {projectPathInput} + {messagesList}
)} - - {isLoading && enhancedMessages.length === 0 && ( -
-
- - - {session ? "Loading session history..." : "Initializing Claude Code..."} - -
-
- )} - - - {enhancedMessages.map((message, index) => ( - - - - - - ))} - - - {/* Show loading indicator when processing, even if there are messages */} - {isLoading && enhancedMessages.length > 0 && ( -
- - Processing... -
- )} - -
+ + {/* Floating Prompt Input - Always visible */} + + + + + {/* Timeline */} + {showTimeline && effectiveSession && ( + + )}
- {/* Floating Prompt Input */} - setShowPreviewPrompt(false)} /> - - {/* Token Counter */} - {/* Fork Dialog */}