diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 1fe93eb470..d09b9e9e86 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1,7 +1,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react" import { useDeepCompareEffect, useEvent, useMount } from "react-use" import debounce from "debounce" -import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" +import { Virtuoso } from "react-virtuoso" import removeMd from "remove-markdown" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import useSound from "use-sound" @@ -58,6 +58,9 @@ import QueuedMessages from "./QueuedMessages" import { getLatestTodo } from "@roo/todo" import { QueuedMessage } from "@roo-code/types" +// Import the new virtualization utilities +import { useOptimizedVirtualization } from "./virtualization" + export interface ChatViewProps { isHidden: boolean showAnnouncement: boolean @@ -167,13 +170,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction(undefined) const [secondaryButtonText, setSecondaryButtonText] = useState(undefined) const [didClickCancel, setDidClickCancel] = useState(false) - const virtuosoRef = useRef(null) - const [expandedRows, setExpandedRows] = useState>({}) - const prevExpandedRowsRef = useRef>() const scrollContainerRef = useRef(null) - const disableAutoScrollRef = useRef(false) - const [showScrollToBottom, setShowScrollToBottom] = useState(false) - const [isAtBottom, setIsAtBottom] = useState(false) const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) const [showCheckpointWarning, setShowCheckpointWarning] = useState(false) @@ -189,6 +186,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction(false) const [currentFollowUpTs, setCurrentFollowUpTs] = useState(null) + // Map the optimized state to the existing state variables for backward compatibility + const [expandedRows, setExpandedRows] = useState>({}) + const prevExpandedRowsRef = useRef>() + const disableAutoScrollRef = useRef(false) + const [showScrollToBottom, setShowScrollToBottom] = useState(false) + const clineAskRef = useRef(clineAsk) useEffect(() => { clineAskRef.current = clineAsk @@ -434,6 +437,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + // Clear expanded rows for new task setExpandedRows({}) everVisibleMessagesTsRef.current.clear() // Clear for new task setCurrentFollowUpTs(null) // Clear follow-up answered state for new task @@ -480,6 +484,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { // Checking clineAsk isn't enough since messages effect may be called // again for a tool for example, set clineAsk to its value, and if the @@ -521,6 +526,102 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + console.warn(`[VIRTUALIZATION] ChatView performance issue: ${metric} = ${value}`) + }, + }) + + console.log("[VIRTUALIZATION] Virtualization hook initialized:", { + messagesCount: modifiedMessages.length, + isStreaming, + isHidden, + viewportConfig, + timestamp: new Date().toISOString(), + }) + + // Sync expanded rows with state manager + useEffect(() => { + if (stateManager) { + const newExpandedRows: Record = {} + modifiedMessages.forEach((msg) => { + if (stateManager.isExpanded(msg.ts)) { + newExpandedRows[msg.ts] = true + } + }) + setExpandedRows(newExpandedRows) + + console.log("[VIRTUALIZATION] Synced expanded rows with state manager:", { + expandedCount: Object.keys(newExpandedRows).length, + visibleRange, + timestamp: new Date().toISOString(), + }) + } + }, [modifiedMessages, stateManager, visibleRange]) + + // Sync scroll button visibility + useEffect(() => { + setShowScrollToBottom(shouldShowScrollButton || (disableAutoScrollRef.current && !isAtBottom)) + }, [shouldShowScrollButton, isAtBottom]) + + // Clear state manager for new task + useEffect(() => { + if (stateManager) { + stateManager.clear() + } + if (scrollManager) { + scrollManager.reset() + } + }, [task?.ts, stateManager, scrollManager]) + + // Handle performance monitoring + useEffect(() => { + if (performanceMonitor) { + if (isHidden) { + performanceMonitor.stopMonitoring() + } else { + performanceMonitor.startMonitoring() + } + } + }, [isHidden, performanceMonitor]) + + // Update scroll manager when user expands rows + useEffect(() => { + const prev = prevExpandedRowsRef.current + let wasAnyRowExpandedByUser = false + if (prev) { + // Check if any row transitioned from false/undefined to true + for (const [tsKey, isExpanded] of Object.entries(expandedRows)) { + const ts = Number(tsKey) + if (isExpanded && !(prev[ts] ?? false)) { + wasAnyRowExpandedByUser = true + break + } + } + } + + if (wasAnyRowExpandedByUser && scrollManager) { + scrollManager.forceUserScrolling() + } + }, [expandedRows, scrollManager]) + const markFollowUpAsAnswered = useCallback(() => { const lastFollowUpMessage = messagesRef.current.findLast((msg) => msg.ask === "followup") if (lastFollowUpMessage) { @@ -549,6 +650,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + handleChatReset() + if (scrollManager) { + scrollManager.resetUserScrolling() + } + }, [handleChatReset, scrollManager]) + /** * Handles sending messages to the extension * @param text - The message text to send @@ -607,7 +716,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { @@ -842,7 +951,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction - debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 10, { - immediate: true, - }), - [], + debounce( + () => { + console.log("[VIRTUALIZATION] Smooth scroll to bottom triggered") + optimizedScrollToBottom("smooth") + }, + 10, + { + immediate: true, + }, + ), + [optimizedScrollToBottom], ) useEffect(() => { @@ -1325,17 +1440,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - virtuosoRef.current?.scrollTo({ - top: Number.MAX_SAFE_INTEGER, - behavior: "auto", // Instant causes crash. - }) - }, []) + console.log("[VIRTUALIZATION] Auto scroll to bottom triggered") + optimizedScrollToBottom("auto") + }, [optimizedScrollToBottom]) const handleSetExpandedRow = useCallback( (ts: number, expand?: boolean) => { - setExpandedRows((prev) => ({ ...prev, [ts]: expand === undefined ? !prev[ts] : expand })) + if (stateManager) { + const newExpanded = expand === undefined ? !stateManager.isExpanded(ts) : expand + stateManager.setState(ts, { isExpanded: newExpanded }) + setExpandedRows((prev) => ({ ...prev, [ts]: newExpanded })) + } else { + // Fallback to local state if stateManager not ready + setExpandedRows((prev) => ({ ...prev, [ts]: expand === undefined ? !prev[ts] : expand })) + } }, - [setExpandedRows], // setExpandedRows is stable + [stateManager], ) // Scroll when user toggles certain rows. @@ -1363,7 +1483,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction { let timer: NodeJS.Timeout | undefined - if (!disableAutoScrollRef.current) { + if ( + !disableAutoScrollRef.current && + scrollManager && + stateManager && + scrollManager.shouldAutoScroll(stateManager.hasExpandedMessages()) + ) { timer = setTimeout(() => scrollToBottomSmooth(), 50) } return () => { @@ -1371,18 +1496,24 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const wheelEvent = event as WheelEvent - - if (wheelEvent.deltaY && wheelEvent.deltaY < 0) { - if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) { - // User scrolled up - disableAutoScrollRef.current = true + }, [groupedMessages.length, scrollToBottomSmooth, scrollManager, stateManager]) + + const handleWheel = useCallback( + (event: Event) => { + const wheelEvent = event as WheelEvent + + if (wheelEvent.deltaY && wheelEvent.deltaY < 0) { + if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) { + // User scrolled up + disableAutoScrollRef.current = true + if (scrollManager) { + scrollManager.forceUserScrolling() + } + } } - } - }, []) + }, + [scrollManager], + ) useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance @@ -1471,6 +1602,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + console.log("[VIRTUALIZATION] Rendering item at index:", { + index, + isGroup: Array.isArray(messageOrGroup), + messageTs: Array.isArray(messageOrGroup) ? messageOrGroup[0]?.ts : messageOrGroup.ts, + timestamp: new Date().toISOString(), + }) + // browser session group if (Array.isArray(messageOrGroup)) { return ( @@ -1844,16 +1982,36 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - setIsAtBottom(isAtBottom) - if (isAtBottom) { + onScroll={(e) => { + const target = e.currentTarget as HTMLElement + const scrollState = { + scrollTop: target.scrollTop, + scrollHeight: target.scrollHeight, + viewportHeight: target.clientHeight, + } + + console.log("[VIRTUALIZATION] Virtuoso scroll event:", { + ...scrollState, + distanceFromBottom: + scrollState.scrollHeight - scrollState.scrollTop - scrollState.viewportHeight, + timestamp: new Date().toISOString(), + }) + + handleVirtuosoScroll(target.scrollTop) + handleScrollStateChange(scrollState) + }} + rangeChanged={handleRangeChange} + atBottomStateChange={(atBottom) => { + if (atBottom) { disableAutoScrollRef.current = false + if (scrollManager) { + scrollManager.resetUserScrolling() + } } - setShowScrollToBottom(disableAutoScrollRef.current && !isAtBottom) }} atBottomThreshold={10} // anything lower causes issues with followOutput initialTopMostItemIndex={groupedMessages.length - 1} diff --git a/webview-ui/src/components/chat/__tests__/ChatView.virtualization.comprehensive.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.virtualization.comprehensive.spec.tsx new file mode 100644 index 0000000000..a7ff828d91 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatView.virtualization.comprehensive.spec.tsx @@ -0,0 +1,569 @@ +// Comprehensive virtualization tests for ChatView +// npx vitest run src/components/chat/__tests__/ChatView.virtualization.comprehensive.spec.tsx + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import React from "react" +import { render, waitFor, fireEvent } from "@testing-library/react" + +// Define types +interface MockMessage { + id: string + text: string + timestamp: number + expanded?: boolean +} + +interface VirtualizationState { + expandedMessages: Set + scrollPosition: number + isUserScrolling: boolean +} + +// Create global state for the mock +const mockState: VirtualizationState = { + expandedMessages: new Set(), + scrollPosition: 0, + isUserScrolling: false, +} + +// Mock react-virtuoso with more realistic behavior +vi.mock("react-virtuoso", () => { + return { + Virtuoso: function MockVirtuoso({ + data, + itemContent, + onScroll, + _scrollSeekConfiguration, + _overscan, + _increaseViewportBy, + _alignToBottom, + _followOutput, + _initialTopMostItemIndex, + rangeChanged, + isScrolling, + atBottomStateChange, + }: any) { + const [scrollTop, setScrollTop] = React.useState(0) + const itemHeight = 50 // Mock item height + const clientHeight = 600 // Viewport height + const scrollHeight = data.length * itemHeight + + // Calculate visible range based on scroll position + const startIndex = Math.floor(scrollTop / itemHeight) + const visibleCount = Math.ceil(clientHeight / itemHeight) + const endIndex = Math.min(startIndex + visibleCount + 2, data.length - 1) // +2 for overscan + + // Simulate range change callback + React.useEffect(() => { + if (rangeChanged && data.length > 0) { + rangeChanged({ startIndex, endIndex }) + } + }, [startIndex, endIndex, rangeChanged, data.length]) + + // Simulate scroll state + React.useEffect(() => { + if (isScrolling) { + isScrolling(false) + } + }, [isScrolling]) + + // Simulate at bottom state + React.useEffect(() => { + if (atBottomStateChange) { + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 10 + atBottomStateChange(isAtBottom) + } + }, [scrollTop, atBottomStateChange, scrollHeight]) + + // For initial render, always show first items + const actualStartIndex = data.length > 0 ? Math.min(startIndex, data.length - 1) : 0 + const actualEndIndex = data.length > 0 ? Math.min(endIndex, data.length - 1) : -1 + + // Ensure we render visible items + const itemsToRender = + actualEndIndex >= actualStartIndex ? data.slice(actualStartIndex, actualEndIndex + 1) : [] + + return ( +
{ + const newScrollTop = (e.target as HTMLElement).scrollTop + setScrollTop(newScrollTop) + if (onScroll) { + onScroll(e) + } + }}> +
+ {itemsToRender.map((item: any, index: number) => ( +
+ {itemContent(actualStartIndex + index, item)} +
+ ))} +
+
+ ) + }, + } +}) + +// Mock virtualization hook +const mockVirtualizationHook = vi.fn(({ _messages }: any) => ({ + virtuosoRef: { current: null }, + viewportConfig: { + top: 500, + bottom: 1000, + overscan: { main: 200, reverse: 200 }, + }, + stateManager: { + isExpanded: (messageTs: number) => mockState.expandedMessages.has(String(messageTs)), + setState: (messageTs: number, stateUpdate: any) => { + const id = String(messageTs) + if (stateUpdate.isExpanded !== undefined) { + if (stateUpdate.isExpanded) { + mockState.expandedMessages.add(id) + } else { + mockState.expandedMessages.delete(id) + } + } + }, + clear: () => mockState.expandedMessages.clear(), + hasExpandedMessages: () => mockState.expandedMessages.size > 0, + pinMessage: vi.fn(), + cleanup: vi.fn(), + }, + scrollManager: { + shouldAutoScroll: () => !mockState.isUserScrolling, + resetUserScrolling: () => { + mockState.isUserScrolling = false + }, + forceUserScrolling: () => { + mockState.isUserScrolling = true + }, + reset: () => { + mockState.isUserScrolling = false + mockState.scrollPosition = 0 + }, + }, + performanceMonitor: { + startMonitoring: vi.fn(), + stopMonitoring: vi.fn(), + getMetrics: () => ({ + renderTime: 50, + scrollFPS: 60, + memoryUsage: 100, + }), + }, + handleScroll: vi.fn((scrollTop: number) => { + mockState.scrollPosition = scrollTop + mockState.isUserScrolling = true + }), + handleRangeChange: vi.fn(), + handleScrollStateChange: vi.fn((_state: any) => {}), + scrollToBottom: vi.fn((_behavior?: ScrollBehavior) => {}), + isAtBottom: mockState.scrollPosition === 0, // Simplified + showScrollToBottom: mockState.isUserScrolling, + visibleRange: { startIndex: 0, endIndex: 10 }, +})) + +vi.mock("../virtualization", () => ({ + useOptimizedVirtualization: mockVirtualizationHook, +})) + +// Import Virtuoso from the mock +import { Virtuoso } from "react-virtuoso" + +// Test component that simulates ChatView virtualization +const VirtualizedChatView = ({ messages }: { messages: MockMessage[] }) => { + const virtualization = mockVirtualizationHook({ messages }) + const [localMessages, setLocalMessages] = React.useState(messages) + + React.useEffect(() => { + setLocalMessages(messages) + }, [messages]) + + const handleToggleExpand = (messageId: string) => { + // Extract the index from the message ID + const messageIndex = parseInt(messageId.replace("msg-", "")) + // Get the actual timestamp from the message + const messageTs = localMessages[messageIndex]?.timestamp + if (messageTs) { + const isExpanded = virtualization.stateManager.isExpanded(messageTs) + virtualization.stateManager.setState(messageTs, { isExpanded: !isExpanded }) + // Force re-render + setLocalMessages([...localMessages]) + } + } + + return ( +
+ ( +
+
{message.text}
+ +
+ )} + onScroll={(e) => virtualization.handleScroll((e.target as HTMLElement).scrollTop)} + rangeChanged={virtualization.handleRangeChange} + isScrolling={(_isScrolling: boolean) => + virtualization.handleScrollStateChange({ + scrollTop: 0, + scrollHeight: 1000, + viewportHeight: 600, + }) + } + alignToBottom={true} + followOutput={virtualization.scrollManager.shouldAutoScroll()} + /> + {virtualization.showScrollToBottom && ( + + )} +
+ ) +} + +// Helper to generate messages +const generateMessages = (count: number): MockMessage[] => { + return Array.from({ length: count }, (_, i) => ({ + id: `msg-${i}`, + text: `Message ${i}`, + timestamp: Date.now() - (count - i) * 1000, + })) +} + +describe("ChatView Virtualization - Comprehensive Tests", () => { + beforeEach(() => { + vi.clearAllMocks() + // Reset mock state + mockState.expandedMessages.clear() + mockState.scrollPosition = 0 + mockState.isUserScrolling = false + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("1. Large Message List Handling (1000+ messages)", () => { + it("should efficiently render 1000 messages", async () => { + const messages = generateMessages(1000) + const { container, getByTestId } = render() + + await waitFor(() => { + expect(getByTestId("chat-view")).toBeInTheDocument() + }) + + // Check that not all messages are rendered at once + const renderedItems = container.querySelectorAll('[data-testid^="virtuoso-item-"]') + expect(renderedItems.length).toBeLessThan(50) // Much less than 1000 + expect(renderedItems.length).toBeGreaterThan(0) + }) + + it("should handle 5000 messages without performance degradation", async () => { + const messages = generateMessages(5000) + const startTime = performance.now() + + const { getByTestId } = render() + + await waitFor(() => { + expect(getByTestId("chat-view")).toBeInTheDocument() + }) + + const renderTime = performance.now() - startTime + expect(renderTime).toBeLessThan(500) // Should render quickly + }) + + it("should update efficiently when messages are added", async () => { + const { rerender, container } = render() + + // Add more messages + const updatedMessages = generateMessages(200) + rerender() + + await waitFor(() => { + const items = container.querySelectorAll('[data-testid^="virtuoso-item-"]') + expect(items.length).toBeGreaterThan(0) + }) + }) + }) + + describe("2. Scrolling Behavior", () => { + it("should handle scroll events", async () => { + const messages = generateMessages(100) + const { getByTestId } = render() + + const container = getByTestId("virtuoso-container") + + // Simulate scroll + fireEvent.scroll(container, { target: { scrollTop: 500 } }) + + // Check that scroll handler was called + await waitFor(() => { + expect(mockVirtualizationHook).toHaveBeenCalled() + const result = mockVirtualizationHook.mock.results[0].value + expect(result.handleScroll).toHaveBeenCalled() + }) + }) + + it("should show scroll to bottom button when user scrolls up", async () => { + const messages = generateMessages(100) + const { getByTestId, queryByTestId, rerender } = render() + + // Initially no scroll button + expect(queryByTestId("scroll-to-bottom")).not.toBeInTheDocument() + + // Simulate user scroll + const container = getByTestId("virtuoso-container") + fireEvent.scroll(container, { target: { scrollTop: 500 } }) + + // Update mock state to show button + mockState.isUserScrolling = true + + // Force re-render to show button + rerender() + + await waitFor(() => { + expect(queryByTestId("scroll-to-bottom")).toBeInTheDocument() + }) + }) + + it("should auto-scroll when new messages arrive", async () => { + const { rerender } = render() + + const hook = mockVirtualizationHook.mock.results[0].value + expect(hook.scrollManager.shouldAutoScroll()).toBe(true) + + // Add new message + rerender() + + // Should still auto-scroll + expect(hook.scrollManager.shouldAutoScroll()).toBe(true) + }) + }) + + describe("3. State Persistence", () => { + it("should maintain expanded state across renders", async () => { + const messages = generateMessages(10) + const { getByTestId, rerender } = render() + + // Wait for initial render + await waitFor(() => { + expect(getByTestId("message-msg-0")).toBeInTheDocument() + }) + + // Expand a message that's visible + const expandButton = getByTestId("expand-msg-0") + fireEvent.click(expandButton) + + // Wait for state update + await waitFor(() => { + const messageTs = messages[0].timestamp + expect(mockState.expandedMessages.has(String(messageTs))).toBe(true) + }) + + // Re-render with same messages + rerender() + + // State should persist + const messageTs = messages[0].timestamp + expect(mockState.expandedMessages.has(String(messageTs))).toBe(true) + }) + + it("should clear expanded state when requested", () => { + // Render component to initialize the hook + render() + + // Get the hook result + const hook = mockVirtualizationHook.mock.results[0].value + + // Set some expanded states + const ts1 = Date.now() - 1000 + const ts2 = Date.now() - 2000 + hook.stateManager.setState(ts1, { isExpanded: true }) + hook.stateManager.setState(ts2, { isExpanded: true }) + + expect(hook.stateManager.hasExpandedMessages()).toBe(true) + + // Clear all + hook.stateManager.clear() + + expect(hook.stateManager.hasExpandedMessages()).toBe(false) + expect(hook.stateManager.isExpanded(ts1)).toBe(false) + expect(hook.stateManager.isExpanded(ts2)).toBe(false) + }) + + it("should maintain scroll position during updates", async () => { + const messages = generateMessages(100) + const { getByTestId, rerender } = render() + + const container = getByTestId("virtuoso-container") + + // Scroll to middle + fireEvent.scroll(container, { target: { scrollTop: 2500 } }) + + // Update messages + rerender() + + // Scroll position should be maintained (user was scrolling) + const hook = mockVirtualizationHook.mock.results[0].value + expect(hook.scrollManager.shouldAutoScroll()).toBe(false) + }) + }) + + describe("4. Performance Monitoring", () => { + it("should track performance metrics", () => { + // Render component to initialize the hook + render() + + // Get the hook result + const hook = mockVirtualizationHook.mock.results[0].value + + // Start monitoring + hook.performanceMonitor.startMonitoring() + expect(hook.performanceMonitor.startMonitoring).toHaveBeenCalled() + + // Get metrics + const metrics = hook.performanceMonitor.getMetrics() + expect(metrics).toEqual({ + renderTime: 50, + scrollFPS: 60, + memoryUsage: 100, + }) + + // Stop monitoring + hook.performanceMonitor.stopMonitoring() + expect(hook.performanceMonitor.stopMonitoring).toHaveBeenCalled() + }) + + it("should handle rapid message additions efficiently", async () => { + let messages = generateMessages(100) + const { rerender } = render() + + const startTime = performance.now() + + // Rapidly add messages + for (let i = 0; i < 10; i++) { + messages = [...messages, ...generateMessages(10)] + rerender() + } + + const totalTime = performance.now() - startTime + expect(totalTime).toBeLessThan(1000) // Should handle rapid updates quickly + }) + }) + + describe("5. Viewport Configuration", () => { + it("should use optimized viewport settings", () => { + // Render component to initialize the hook + render() + + // Get the hook result + const hook = mockVirtualizationHook.mock.results[0].value + + expect(hook.viewportConfig).toEqual({ + top: 500, + bottom: 1000, + overscan: { main: 200, reverse: 200 }, + }) + }) + + it("should handle visible range changes", async () => { + const messages = generateMessages(100) + render() + + const hook = mockVirtualizationHook.mock.results[0].value + + // Simulate range change + hook.handleRangeChange({ startIndex: 10, endIndex: 20 }) + + expect(hook.handleRangeChange).toHaveBeenCalledWith({ + startIndex: 10, + endIndex: 20, + }) + }) + }) + + describe("6. Edge Cases", () => { + it("should handle empty message list", () => { + const { container } = render() + + const items = container.querySelectorAll('[data-testid^="virtuoso-item-"]') + expect(items.length).toBe(0) + }) + + it("should handle single message", () => { + const { getByTestId } = render() + + expect(getByTestId("message-msg-0")).toBeInTheDocument() + }) + + it("should handle message updates", async () => { + const messages = generateMessages(10) + const { getByTestId, rerender } = render() + + // Wait for initial render + await waitFor(() => { + expect(getByTestId("message-msg-0")).toBeInTheDocument() + }) + + // Update a visible message + const updatedMessages = [...messages] + updatedMessages[0] = { ...updatedMessages[0], text: "Updated Message 0" } + + rerender() + + await waitFor(() => { + expect(getByTestId("message-msg-0")).toHaveTextContent("Updated Message 0") + }) + }) + }) + + describe("7. Memory Management", () => { + it("should not leak memory with large datasets", () => { + const messages = generateMessages(10000) + const { unmount } = render() + + // Component should unmount cleanly + expect(() => unmount()).not.toThrow() + }) + + it("should clean up state on unmount", () => { + const { unmount } = render() + + const hook = mockVirtualizationHook.mock.results[0].value + + // Set some state + const ts = Date.now() - 1000 + hook.stateManager.setState(ts, { isExpanded: true }) + hook.scrollManager.forceUserScrolling() + + unmount() + + // State should be cleaned up + hook.scrollManager.reset() + hook.stateManager.clear() + + expect(hook.stateManager.hasExpandedMessages()).toBe(false) + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.virtualization.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.virtualization.spec.tsx new file mode 100644 index 0000000000..0e06e52780 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatView.virtualization.spec.tsx @@ -0,0 +1,500 @@ +// npx vitest run src/components/chat/__tests__/ChatView.virtualization.spec.tsx + +import React from "react" +import { render, waitFor, act } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" + +import ChatView, { ChatViewProps } from "../ChatView" + +// Define minimal types needed for testing +interface ClineMessage { + type: "say" | "ask" + say?: string + ask?: string + ts: number + text?: string + partial?: boolean +} + +interface ExtensionState { + version: string + clineMessages: ClineMessage[] + taskHistory: any[] + shouldShowAnnouncement: boolean + allowedCommands: string[] + alwaysAllowExecute: boolean + [key: string]: any +} + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock use-sound hook +const mockPlayFunction = vi.fn() +vi.mock("use-sound", () => ({ + default: vi.fn(() => [mockPlayFunction]), +})) + +// Mock window.AUDIO_BASE_URI +Object.defineProperty(window, "AUDIO_BASE_URI", { + writable: true, + value: "http://localhost/audio", +}) + +// Mock the virtualization hook to return a simpler implementation +vi.mock("../virtualization", () => ({ + useOptimizedVirtualization: vi.fn(({ messages: _messages }) => ({ + virtuosoRef: { current: null }, + viewportConfig: { top: 500, bottom: 1000 }, + stateManager: { + isExpanded: () => false, + setState: vi.fn(), + clear: vi.fn(), + hasExpandedMessages: () => false, + }, + scrollManager: { + shouldAutoScroll: () => true, + resetUserScrolling: vi.fn(), + forceUserScrolling: vi.fn(), + reset: vi.fn(), + }, + performanceMonitor: { + startMonitoring: vi.fn(), + stopMonitoring: vi.fn(), + }, + handleScroll: vi.fn(), + handleRangeChange: vi.fn(), + handleScrollStateChange: vi.fn(), + scrollToBottom: vi.fn(), + isAtBottom: true, + showScrollToBottom: false, + visibleRange: { startIndex: 0, endIndex: 10 }, + })), +})) + +// Mock components that use ESM dependencies +vi.mock("../BrowserSessionRow", () => ({ + default: function MockBrowserSessionRow({ messages: _messages }: { messages: ClineMessage[] }) { + return
{_messages.length} browser actions
+ }, +})) + +vi.mock("../ChatRow", () => ({ + default: function MockChatRow({ message }: { message: ClineMessage }) { + return ( +
+ {message.text || message.say || message.ask || `Message ${message.ts}`} +
+ ) + }, +})) + +// Mock Virtuoso to render items directly +vi.mock("react-virtuoso", () => ({ + Virtuoso: function MockVirtuoso({ data, itemContent }: any) { + // Only render first 10 items to avoid memory issues + const itemsToRender = data?.slice(0, 10) || [] + return ( +
+ {itemsToRender.map((item: any, index: number) => ( +
+ {itemContent(index, item)} +
+ ))} +
+ ) + }, + VirtuosoHandle: {}, +})) + +vi.mock("../AutoApproveMenu", () => ({ + default: () => null, +})) + +vi.mock("../../common/VersionIndicator", () => ({ + default: () => null, +})) + +vi.mock("../Announcement", () => ({ + default: () => null, +})) + +vi.mock("@src/components/welcome/RooCloudCTA", () => ({ + default: () => null, +})) + +vi.mock("../QueuedMessages", () => ({ + default: () => null, +})) + +vi.mock("@src/components/welcome/RooTips", () => ({ + default: () => null, +})) + +vi.mock("@src/components/welcome/RooHero", () => ({ + default: () => null, +})) + +vi.mock("../common/TelemetryBanner", () => ({ + default: () => null, +})) + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, + Trans: ({ i18nKey }: { i18nKey: string }) => <>{i18nKey}, +})) + +// Mock ChatTextArea +vi.mock("../ChatTextArea", () => ({ + default: React.forwardRef(function MockChatTextArea(_props: any, ref: any) { + React.useImperativeHandle(ref, () => ({ + focus: vi.fn(), + })) + return
+ }), +})) + +// Mock VSCode components +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeButton: function MockVSCodeButton({ children, onClick }: any) { + return + }, + VSCodeTextField: function MockVSCodeTextField() { + return + }, + VSCodeLink: function MockVSCodeLink({ children }: any) { + return {children} + }, +})) + +// Helper to generate mock messages +function generateMockMessages(count: number): ClineMessage[] { + return Array.from({ length: count }, (_, i) => ({ + type: i % 2 === 0 ? "say" : "ask", + say: i % 2 === 0 ? "text" : undefined, + ask: i % 2 === 1 ? "tool" : undefined, + ts: Date.now() - (count - i) * 1000, + text: + i % 2 === 1 && i % 10 === 1 ? JSON.stringify({ tool: "test-tool", params: { index: i } }) : `Message ${i}`, + partial: false, + })) +} + +// Mock window.postMessage to trigger state hydration +const mockPostMessage = (state: Partial) => { + window.postMessage( + { + type: "state", + state: { + version: "1.0.0", + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + ...state, + }, + }, + "*", + ) +} + +const defaultProps: ChatViewProps = { + isHidden: false, + showAnnouncement: false, + hideAnnouncement: () => {}, +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, +}) + +const renderChatView = (props: Partial = {}) => { + return render( + + + + + , + ) +} + +describe("ChatView - Virtualization Tests", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("Basic Virtualization", () => { + it("should render ChatView with virtualization enabled", async () => { + const { container } = renderChatView() + + // Generate a small set of messages + const messages = generateMockMessages(20) + + act(() => { + mockPostMessage({ + clineMessages: messages, + }) + }) + + // Wait for render + await waitFor(() => { + expect(container.querySelector('[data-testid="chat-view"]')).toBeInTheDocument() + }) + + // Verify virtuoso container is rendered + expect(container.querySelector('[data-testid="virtuoso-container"]')).toBeInTheDocument() + }) + + it("should render visible messages", async () => { + const { container } = renderChatView() + + const messages = generateMockMessages(30) + + act(() => { + mockPostMessage({ + clineMessages: messages, + }) + }) + + // Wait for messages to render + await waitFor(() => { + const items = container.querySelectorAll('[data-testid="virtuoso-item"]') + expect(items.length).toBeGreaterThan(0) + }) + + // Verify messages are rendered + const messageElements = container.querySelectorAll('[data-testid^="message-"]') + expect(messageElements.length).toBeGreaterThan(0) + expect(messageElements[0].textContent).toContain("Message 0") + }) + }) + + describe("Large Message Lists", () => { + it("should handle 100+ messages efficiently", async () => { + const { container } = renderChatView() + + // Generate messages but only render first 10 (mocked) + const messages = generateMockMessages(100) + + act(() => { + mockPostMessage({ + clineMessages: messages, + }) + }) + + // Should render without crashing + await waitFor(() => { + expect(container.querySelector('[data-testid="chat-view"]')).toBeInTheDocument() + }) + + // Verify virtuoso is handling the messages + const virtuosoContainer = container.querySelector('[data-testid="virtuoso-container"]') + expect(virtuosoContainer).toBeInTheDocument() + + // Only first 10 should be rendered (due to our mock) + const items = container.querySelectorAll('[data-testid="virtuoso-item"]') + expect(items.length).toBe(10) + }) + + it("should handle message updates", async () => { + const { container } = renderChatView() + + // Initial messages + const messages = generateMockMessages(20) + + act(() => { + mockPostMessage({ + clineMessages: messages, + }) + }) + + await waitFor(() => { + expect(container.querySelector('[data-testid="virtuoso-container"]')).toBeInTheDocument() + }) + + // Add new message + const newMessages = [ + ...messages, + { + type: "say" as const, + say: "text", + ts: Date.now(), + text: "New message", + partial: false, + }, + ] + + act(() => { + mockPostMessage({ + clineMessages: newMessages, + }) + }) + + // Should handle the update + await waitFor(() => { + const items = container.querySelectorAll('[data-testid="virtuoso-item"]') + expect(items.length).toBeGreaterThan(0) + }) + }) + }) + + describe("Scrolling Behavior", () => { + it("should auto-scroll to bottom for new messages", async () => { + const { container } = renderChatView() + + const messages = generateMockMessages(50) + + act(() => { + mockPostMessage({ + clineMessages: messages, + }) + }) + + await waitFor(() => { + expect(container.querySelector('[data-testid="virtuoso-container"]')).toBeInTheDocument() + }) + + // Verify scroll manager is configured for auto-scroll + const { useOptimizedVirtualization } = await import("../virtualization") + const mockCall = vi.mocked(useOptimizedVirtualization).mock.calls[0] + expect(mockCall).toBeDefined() + + // The mock returns shouldAutoScroll as true + const result = vi.mocked(useOptimizedVirtualization).mock.results[0] + expect(result.value.scrollManager.shouldAutoScroll()).toBe(true) + }) + }) + + describe("Performance", () => { + it("should render initial messages quickly", async () => { + const startTime = performance.now() + const { container } = renderChatView() + + const messages = generateMockMessages(100) + + act(() => { + mockPostMessage({ + clineMessages: messages, + }) + }) + + await waitFor(() => { + expect(container.querySelector('[data-testid="virtuoso-container"]')).toBeInTheDocument() + }) + + const endTime = performance.now() + const renderTime = endTime - startTime + + // Should render quickly (under 1 second) + expect(renderTime).toBeLessThan(1000) + }) + }) + + describe("State Management", () => { + it("should handle expanded state", async () => { + const { container } = renderChatView() + + const messages = generateMockMessages(20) + + act(() => { + mockPostMessage({ + clineMessages: messages, + }) + }) + + await waitFor(() => { + expect(container.querySelector('[data-testid="virtuoso-container"]')).toBeInTheDocument() + }) + + // Verify state manager is available + const { useOptimizedVirtualization } = await import("../virtualization") + const result = vi.mocked(useOptimizedVirtualization).mock.results[0] + expect(result.value.stateManager).toBeDefined() + expect(result.value.stateManager.isExpanded).toBeDefined() + }) + }) + + describe("Edge Cases", () => { + it("should handle empty message list", async () => { + const { container } = renderChatView() + + act(() => { + mockPostMessage({ + clineMessages: [], + }) + }) + + await waitFor(() => { + expect(container.querySelector('[data-testid="chat-view"]')).toBeInTheDocument() + }) + + // Virtuoso should still be rendered + expect(container.querySelector('[data-testid="virtuoso-container"]')).toBeInTheDocument() + + // But no items + const items = container.querySelectorAll('[data-testid="virtuoso-item"]') + expect(items.length).toBe(0) + }) + + it("should handle rapid message additions", async () => { + const { container } = renderChatView() + + let messages = generateMockMessages(10) + + act(() => { + mockPostMessage({ + clineMessages: messages, + }) + }) + + await waitFor(() => { + expect(container.querySelector('[data-testid="virtuoso-container"]')).toBeInTheDocument() + }) + + // Rapidly add messages + for (let i = 0; i < 5; i++) { + messages = [ + ...messages, + { + type: "say" as const, + say: "text", + ts: Date.now() + i, + text: `Rapid message ${i}`, + partial: false, + }, + ] + + act(() => { + mockPostMessage({ + clineMessages: messages, + }) + }) + } + + // Should handle all updates + await waitFor(() => { + const items = container.querySelectorAll('[data-testid="virtuoso-item"]') + expect(items.length).toBe(10) // Still capped at 10 by our mock + }) + }) + }) +}) diff --git a/webview-ui/src/components/chat/hooks/useOptimizedVirtualization.ts b/webview-ui/src/components/chat/hooks/useOptimizedVirtualization.ts new file mode 100644 index 0000000000..1285ab1646 --- /dev/null +++ b/webview-ui/src/components/chat/hooks/useOptimizedVirtualization.ts @@ -0,0 +1,337 @@ +import { useRef, useMemo, useCallback, useEffect, useState } from "react" +import { VirtuosoHandle } from "react-virtuoso" +import { ClineMessage } from "@roo-code/types" +import { MessageStateManager } from "../utils/MessageStateManager" +import { AutoScrollManager } from "../utils/AutoScrollManager" +import { + VIRTUALIZATION_CONFIG, + detectDevicePerformance, + getViewportConfigForDevice, + ViewportConfig, +} from "../utils/virtualizationConfig" +import { + createOptimizedMessageGroups, + MessageGroup, + getVisibleMessageIndices, + optimizeGroups, +} from "../utils/messageGrouping" +import { PerformanceMonitor } from "../utils/performanceMonitor" + +/** + * Hook options + */ +export interface UseOptimizedVirtualizationOptions { + messages: ClineMessage[] + isStreaming: boolean + isHidden: boolean + onPerformanceIssue?: (metric: string, value: number) => void + customConfig?: Partial +} + +/** + * Hook return type + */ +export interface UseOptimizedVirtualizationReturn { + virtuosoRef: React.RefObject + viewportConfig: ViewportConfig + messageGroups: MessageGroup[] + stateManager: MessageStateManager + scrollManager: AutoScrollManager + performanceMonitor: PerformanceMonitor + handleScroll: (scrollTop: number) => void + handleRangeChange: (range: { startIndex: number; endIndex: number }) => void + handleScrollStateChange: (state: { scrollTop: number; scrollHeight: number; viewportHeight: number }) => void + scrollToBottom: (behavior?: ScrollBehavior) => void + isAtBottom: boolean + showScrollToBottom: boolean + visibleRange: { startIndex: number; endIndex: number } +} + +/** + * Custom hook for optimized ChatView virtualization + */ +export function useOptimizedVirtualization({ + messages, + isStreaming, + isHidden, + onPerformanceIssue, + customConfig, +}: UseOptimizedVirtualizationOptions): UseOptimizedVirtualizationReturn { + // Core refs + const virtuosoRef = useRef(null) + const stateManagerRef = useRef() + const scrollManagerRef = useRef() + const performanceMonitorRef = useRef() + + // State + const [visibleRange, setVisibleRange] = useState({ startIndex: 0, endIndex: 50 }) + const [isAtBottom, setIsAtBottom] = useState(true) + const [showScrollToBottom, setShowScrollToBottom] = useState(false) + const [scrollState, setScrollState] = useState({ + scrollTop: 0, + scrollHeight: 0, + viewportHeight: 0, + }) + + // Merge custom config + const config = useMemo( + () => ({ + ...VIRTUALIZATION_CONFIG, + ...customConfig, + }), + [customConfig], + ) + + // Initialize managers (only once) + if (!stateManagerRef.current) { + stateManagerRef.current = new MessageStateManager(config.stateCache.maxSize, config.stateCache.ttl) + } + + if (!scrollManagerRef.current) { + scrollManagerRef.current = new AutoScrollManager(config.autoScroll.threshold) + } + + if (!performanceMonitorRef.current) { + // Use higher memory limit for chat apps (256MB instead of default 100MB) + const performanceThresholds = { + maxMemoryUsage: 256 * 1024 * 1024, // 256MB - reasonable for modern chat apps + } + performanceMonitorRef.current = new PerformanceMonitor(performanceThresholds, onPerformanceIssue) + } + + const stateManager = stateManagerRef.current + const scrollManager = scrollManagerRef.current + const performanceMonitor = performanceMonitorRef.current + + // Determine viewport configuration based on device and state + const viewportConfig = useMemo(() => { + const devicePerf = detectDevicePerformance() + const hasExpanded = stateManager.hasExpandedMessages() + + console.log("[VIRTUALIZATION] Viewport config calculation:", { + devicePerf, + hasExpanded, + isStreaming, + timestamp: new Date().toISOString(), + }) + + // Streaming takes priority + if (isStreaming) { + console.log("[VIRTUALIZATION] Using streaming viewport config:", config.viewport.streaming) + return config.viewport.streaming + } + + // Expanded messages need more buffer + if (hasExpanded) { + console.log("[VIRTUALIZATION] Using expanded viewport config:", config.viewport.expanded) + return config.viewport.expanded + } + + // Use device-specific config + const deviceConfig = getViewportConfigForDevice(devicePerf) + console.log("[VIRTUALIZATION] Using device-specific viewport config:", deviceConfig) + return deviceConfig + }, [isStreaming, stateManager, config]) + + // Create optimized message groups + const messageGroups = useMemo(() => { + return performanceMonitor.measureRender("createMessageGroups", () => { + const groups = createOptimizedMessageGroups(messages, visibleRange) + return optimizeGroups(groups) + }) + }, [messages, visibleRange, performanceMonitor]) + + // Handle scroll events + const handleScroll = useCallback( + (scrollTop: number) => { + // Use stored scroll state + const { scrollHeight, viewportHeight } = scrollState + + console.log("[VIRTUALIZATION] Scroll event:", { + scrollTop, + scrollHeight, + viewportHeight, + distanceFromBottom: scrollHeight - scrollTop - viewportHeight, + timestamp: new Date().toISOString(), + }) + + scrollManager.handleScroll(scrollTop, scrollHeight, viewportHeight) + + // Update performance metrics + performanceMonitor.updateScrollFPS() + + // Update UI state + const atBottom = scrollManager.isAtBottom(scrollTop, scrollHeight, viewportHeight) + setIsAtBottom(atBottom) + setShowScrollToBottom(!atBottom && scrollManager.getState().isUserScrolling) + + console.log("[VIRTUALIZATION] Scroll state updated:", { + isAtBottom: atBottom, + showScrollToBottom: !atBottom && scrollManager.getState().isUserScrolling, + userScrolling: scrollManager.getState().isUserScrolling, + }) + }, + [scrollState, scrollManager, performanceMonitor], + ) + + // Handle visible range changes + const handleRangeChange = useCallback( + (range: { startIndex: number; endIndex: number }) => { + console.log("[VIRTUALIZATION] Visible range changed:", { + startIndex: range.startIndex, + endIndex: range.endIndex, + totalGroups: messageGroups.length, + timestamp: new Date().toISOString(), + }) + + setVisibleRange(range) + + // Update performance metrics + const messageIndices = getVisibleMessageIndices(messageGroups, range) + const visibleMessageCount = messageIndices.endIndex - messageIndices.startIndex + 1 + performanceMonitor.updateMessageCounts(messages.length, visibleMessageCount) + + console.log("[VIRTUALIZATION] Performance metrics updated:", { + totalMessages: messages.length, + visibleMessages: visibleMessageCount, + messageIndices, + }) + + // Pin important messages in visible range + const visibleGroups = messageGroups.slice(range.startIndex, range.endIndex + 1) + let pinnedCount = 0 + visibleGroups.forEach((group) => { + group.messages.forEach((msg) => { + // Pin error messages and active tools + if (msg.ask === "api_req_failed" || msg.say === "error" || (msg.ask === "tool" && !msg.partial)) { + stateManager.pinMessage(msg.ts) + pinnedCount++ + } + }) + }) + + if (pinnedCount > 0) { + console.log("[VIRTUALIZATION] Pinned messages in visible range:", pinnedCount) + } + + // Cleanup old states periodically + if (Math.random() < 0.1) { + // 10% chance on each range change + console.log("[VIRTUALIZATION] Running state cleanup") + stateManager.cleanup() + } + }, + [messages, messageGroups, stateManager, performanceMonitor], + ) + + // Scroll to bottom function + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = "smooth") => { + const { scrollTop, scrollHeight, viewportHeight } = scrollState + const distance = scrollHeight - scrollTop - viewportHeight + + // Calculate behavior but don't use it since we're using the passed behavior + scrollManager.getScrollBehavior(scrollTop, scrollHeight, config.autoScroll.smoothScrollMaxDistance) + + virtuosoRef.current?.scrollTo({ + top: Number.MAX_SAFE_INTEGER, + behavior: distance > config.autoScroll.smoothScrollMaxDistance ? "auto" : behavior, + }) + + scrollManager.resetUserScrolling() + }, + [config, scrollState, scrollManager], + ) + + // Auto-scroll effect + useEffect(() => { + if (!isHidden && scrollManager.shouldAutoScroll(stateManager.hasExpandedMessages())) { + const timeoutId = setTimeout(() => { + scrollToBottom("smooth") + }, config.autoScroll.debounceDelay) + + return () => clearTimeout(timeoutId) + } + }, [messages.length, isHidden, scrollToBottom, config, scrollManager, stateManager]) + + // Performance monitoring + useEffect(() => { + if (!isHidden) { + performanceMonitor.startMonitoring() + + // Update metrics periodically with optimized frequency + const intervalId = setInterval(() => { + // Only update memory usage every other cycle to reduce overhead + const shouldUpdateMemory = Date.now() % 2 === 0 + if (shouldUpdateMemory) { + performanceMonitor.updateMemoryUsage() + } + + // DOM node count is less critical, update less frequently + if (Date.now() % 3 === 0) { + performanceMonitor.updateDOMNodeCount() + } + + // Log metrics in development + const report = performanceMonitor.getReport() + console.log("[VIRTUALIZATION] Performance report:", { + metrics: { + ...report.metrics, + memoryUsageMB: (report.metrics.memoryUsage / 1024 / 1024).toFixed(2), + }, + issues: report.issues, + timestamp: new Date().toISOString(), + }) + + if (report.issues.length > 0) { + console.warn("[VIRTUALIZATION] Performance issues detected:", report.issues) + } + }, 5000) + + return () => { + clearInterval(intervalId) + performanceMonitor.stopMonitoring() + } + } + }, [isHidden, performanceMonitor]) + + // Cleanup on unmount or when hidden + useEffect(() => { + if (isHidden) { + stateManager.cleanup() + performanceMonitor.reset() + } + }, [isHidden, stateManager, performanceMonitor]) + + // Cleanup on unmount + useEffect(() => { + return () => { + scrollManager.dispose() + performanceMonitor.dispose() + } + }, [scrollManager, performanceMonitor]) + + // Handle scroll state updates from Virtuoso + const handleScrollStateChange = useCallback( + (newState: { scrollTop: number; scrollHeight: number; viewportHeight: number }) => { + setScrollState(newState) + }, + [], + ) + + return { + virtuosoRef, + viewportConfig, + messageGroups, + stateManager, + scrollManager, + performanceMonitor, + handleScroll, + handleRangeChange, + handleScrollStateChange, + scrollToBottom, + isAtBottom, + showScrollToBottom, + visibleRange, + } +} diff --git a/webview-ui/src/components/chat/utils/AutoScrollManager.ts b/webview-ui/src/components/chat/utils/AutoScrollManager.ts new file mode 100644 index 0000000000..c69ef77200 --- /dev/null +++ b/webview-ui/src/components/chat/utils/AutoScrollManager.ts @@ -0,0 +1,260 @@ +/** + * Manages auto-scroll behavior for the chat view + * Detects user intent and provides smooth scrolling experience + */ +export class AutoScrollManager { + private isUserScrolling: boolean = false + private lastScrollTop: number = 0 + private lastScrollTime: number = 0 + private scrollVelocity: number = 0 + private scrollTimeout: NodeJS.Timeout | null = null + private atBottomThreshold: number + private isScrolling: boolean = false + + // Performance tracking + private scrollEventCount: number = 0 + private lastFPSCheck: number = Date.now() + + constructor(threshold: number = 50) { + this.atBottomThreshold = threshold + } + + /** + * Handle scroll events and detect user intent + */ + handleScroll(scrollTop: number, scrollHeight: number, clientHeight: number, timestamp: number = Date.now()): void { + const deltaScroll = scrollTop - this.lastScrollTop + const deltaTime = timestamp - this.lastScrollTime + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + + // Calculate scroll velocity for smooth scroll detection + if (deltaTime > 0) { + this.scrollVelocity = Math.abs(deltaScroll) / deltaTime + } + + // Detect user scrolling + const isScrollingUp = deltaScroll < -5 // Small threshold to avoid noise + const significantScroll = Math.abs(deltaScroll) > 10 + + console.log("[VIRTUALIZATION] AutoScrollManager.handleScroll:", { + scrollTop, + scrollHeight, + clientHeight, + deltaScroll, + deltaTime, + distanceFromBottom, + scrollVelocity: this.scrollVelocity, + isScrollingUp, + significantScroll, + timestamp: new Date().toISOString(), + }) + + if (isScrollingUp && distanceFromBottom > this.atBottomThreshold) { + console.log("[VIRTUALIZATION] User scrolling detected: scrolling up") + this.isUserScrolling = true + this.isScrolling = true + } else if (significantScroll && !this.isNearBottom(scrollTop, scrollHeight, clientHeight)) { + console.log("[VIRTUALIZATION] User scrolling detected: significant scroll") + this.isUserScrolling = true + this.isScrolling = true + } + + // Reset user scrolling flag if they scroll to bottom + if (distanceFromBottom <= this.atBottomThreshold) { + if (this.isUserScrolling) { + console.log("[VIRTUALIZATION] User scrolling reset: reached bottom") + } + this.isUserScrolling = false + } + + // Update state + this.lastScrollTop = scrollTop + this.lastScrollTime = timestamp + this.scrollEventCount++ + + // Clear existing timeout + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout) + } + + // Set timeout to detect end of scrolling + this.scrollTimeout = setTimeout(() => { + console.log("[VIRTUALIZATION] Scrolling ended") + this.isScrolling = false + this.scrollVelocity = 0 + this.scrollTimeout = null + }, 150) + } + + /** + * Check if should auto-scroll to bottom + */ + shouldAutoScroll(hasExpandedMessages: boolean = false): boolean { + // Don't auto-scroll if: + // 1. User is manually scrolling + // 2. There are expanded messages (user might be reading) + // 3. Currently in a scroll animation + const shouldScroll = !this.isUserScrolling && !hasExpandedMessages && !this.isScrolling + + console.log("[VIRTUALIZATION] AutoScrollManager.shouldAutoScroll:", { + shouldScroll, + isUserScrolling: this.isUserScrolling, + hasExpandedMessages, + isScrolling: this.isScrolling, + timestamp: new Date().toISOString(), + }) + + return shouldScroll + } + + /** + * Check if scroll position is near bottom + */ + isNearBottom(scrollTop: number, scrollHeight: number, clientHeight: number): boolean { + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + return distanceFromBottom <= this.atBottomThreshold + } + + /** + * Check if currently at the very bottom + */ + isAtBottom(scrollTop: number, scrollHeight: number, clientHeight: number): boolean { + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + return distanceFromBottom <= 1 // 1px tolerance for rounding + } + + /** + * Reset user scrolling flag + */ + resetUserScrolling(): void { + console.log("[VIRTUALIZATION] User scrolling reset manually") + this.isUserScrolling = false + } + + /** + * Force user scrolling state (e.g., when user expands a message) + */ + forceUserScrolling(): void { + console.log("[VIRTUALIZATION] User scrolling forced") + this.isUserScrolling = true + } + + /** + * Get current scroll velocity + */ + getScrollVelocity(): number { + return this.scrollVelocity + } + + /** + * Check if currently scrolling + */ + isCurrentlyScrolling(): boolean { + return this.isScrolling + } + + /** + * Calculate optimal scroll behavior based on distance + */ + getScrollBehavior(currentTop: number, targetTop: number, maxSmoothDistance: number = 5000): ScrollBehavior { + const distance = Math.abs(targetTop - currentTop) + const behavior = distance > maxSmoothDistance ? "auto" : "smooth" + + console.log("[VIRTUALIZATION] Scroll behavior calculated:", { + currentTop, + targetTop, + distance, + maxSmoothDistance, + behavior, + timestamp: new Date().toISOString(), + }) + + // Use instant scroll for large jumps to avoid janky animation + if (distance > maxSmoothDistance) { + return "auto" + } + + // Use smooth scroll for smaller distances + return "smooth" + } + + /** + * Get scroll performance metrics + */ + getScrollMetrics(): { + fps: number + isUserScrolling: boolean + velocity: number + isScrolling: boolean + } { + const now = Date.now() + const timeDelta = now - this.lastFPSCheck + const fps = timeDelta > 0 ? (this.scrollEventCount * 1000) / timeDelta : 60 + + // Reset counters + if (timeDelta > 1000) { + this.scrollEventCount = 0 + this.lastFPSCheck = now + } + + return { + fps: Math.min(60, Math.round(fps)), + isUserScrolling: this.isUserScrolling, + velocity: this.scrollVelocity, + isScrolling: this.isScrolling, + } + } + + /** + * Update threshold for bottom detection + */ + setBottomThreshold(threshold: number): void { + this.atBottomThreshold = threshold + } + + /** + * Get current state for debugging + */ + getState(): { + isUserScrolling: boolean + lastScrollTop: number + scrollVelocity: number + isScrolling: boolean + atBottomThreshold: number + } { + return { + isUserScrolling: this.isUserScrolling, + lastScrollTop: this.lastScrollTop, + scrollVelocity: this.scrollVelocity, + isScrolling: this.isScrolling, + atBottomThreshold: this.atBottomThreshold, + } + } + + /** + * Reset all state + */ + reset(): void { + this.isUserScrolling = false + this.lastScrollTop = 0 + this.lastScrollTime = 0 + this.scrollVelocity = 0 + this.isScrolling = false + this.scrollEventCount = 0 + + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout) + this.scrollTimeout = null + } + } + + /** + * Cleanup resources + */ + dispose(): void { + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout) + this.scrollTimeout = null + } + } +} diff --git a/webview-ui/src/components/chat/utils/MessageStateManager.ts b/webview-ui/src/components/chat/utils/MessageStateManager.ts new file mode 100644 index 0000000000..239652c9cd --- /dev/null +++ b/webview-ui/src/components/chat/utils/MessageStateManager.ts @@ -0,0 +1,289 @@ +import { LRUCache } from "lru-cache" + +/** + * Represents the state of a message in the chat view + */ +export interface MessageState { + isExpanded: boolean + lastInteraction: number + isPinned: boolean + height?: number // Cached height for better virtualization +} + +/** + * Manages message states efficiently using LRU cache + * Handles expanded/collapsed states and pinned messages + */ +export class MessageStateManager { + private states: LRUCache + private pinnedMessages: Set + private expandedCount: number = 0 + + constructor(maxSize: number = 100, ttl: number = 30 * 60 * 1000) { + this.states = new LRUCache({ + max: maxSize, + ttl: ttl, + updateAgeOnGet: true, + updateAgeOnHas: true, + dispose: (value, key) => { + // Update expanded count when items are evicted + if (value.isExpanded) { + this.expandedCount = Math.max(0, this.expandedCount - 1) + } + console.log("[VIRTUALIZATION] MessageStateManager state evicted:", { + messageTs: key, + wasExpanded: value.isExpanded, + expandedCount: this.expandedCount, + timestamp: new Date().toISOString(), + }) + }, + }) + this.pinnedMessages = new Set() + } + + /** + * Get the state of a message + */ + getState(messageTs: number): MessageState | undefined { + const state = this.states.get(messageTs) + if (state) { + console.log("[VIRTUALIZATION] MessageStateManager cache hit:", { + messageTs, + state, + timestamp: new Date().toISOString(), + }) + } else { + console.log("[VIRTUALIZATION] MessageStateManager cache miss:", { + messageTs, + timestamp: new Date().toISOString(), + }) + } + return state + } + + /** + * Check if a message is expanded + */ + isExpanded(messageTs: number): boolean { + return this.states.get(messageTs)?.isExpanded ?? false + } + + /** + * Set the state of a message + */ + setState(messageTs: number, state: Partial): void { + const existing = this.states.get(messageTs) + const wasExpanded = existing?.isExpanded ?? false + + const newState: MessageState = { + isExpanded: state.isExpanded ?? existing?.isExpanded ?? false, + lastInteraction: Date.now(), + isPinned: state.isPinned ?? existing?.isPinned ?? false, + height: state.height ?? existing?.height, + } + + // Update expanded count + if (!wasExpanded && newState.isExpanded) { + this.expandedCount++ + } else if (wasExpanded && !newState.isExpanded) { + this.expandedCount = Math.max(0, this.expandedCount - 1) + } + + console.log("[VIRTUALIZATION] MessageStateManager.setState:", { + messageTs, + previousState: existing, + newState, + expandedCount: this.expandedCount, + cacheSize: this.states.size, + timestamp: new Date().toISOString(), + }) + + this.states.set(messageTs, newState) + } + + /** + * Toggle the expanded state of a message + */ + toggleExpanded(messageTs: number): boolean { + const current = this.isExpanded(messageTs) + this.setState(messageTs, { isExpanded: !current }) + return !current + } + + /** + * Pin a message to prevent it from being evicted + */ + pinMessage(messageTs: number): void { + this.pinnedMessages.add(messageTs) + this.setState(messageTs, { isPinned: true }) + + // Ensure pinned messages don't get evicted + const state = this.states.get(messageTs) + if (state) { + // Re-set to refresh TTL + this.states.set(messageTs, state) + } + } + + /** + * Unpin a message + */ + unpinMessage(messageTs: number): void { + this.pinnedMessages.delete(messageTs) + this.setState(messageTs, { isPinned: false }) + } + + /** + * Check if a message is pinned + */ + isPinned(messageTs: number): boolean { + return this.pinnedMessages.has(messageTs) + } + + /** + * Get all expanded messages in a range + */ + getExpandedInRange(messages: Array<{ ts: number }>): Set { + const expanded = new Set() + for (const msg of messages) { + if (this.isExpanded(msg.ts)) { + expanded.add(msg.ts) + } + } + return expanded + } + + /** + * Get the count of expanded messages + */ + getExpandedCount(): number { + return this.expandedCount + } + + /** + * Check if any messages are expanded + */ + hasExpandedMessages(): boolean { + return this.expandedCount > 0 + } + + /** + * Set the cached height for a message + */ + setCachedHeight(messageTs: number, height: number): void { + const state = this.getState(messageTs) + if (state) { + this.setState(messageTs, { height }) + } else { + this.setState(messageTs, { height, isExpanded: false }) + } + } + + /** + * Get the cached height for a message + */ + getCachedHeight(messageTs: number): number | undefined { + return this.getState(messageTs)?.height + } + + /** + * Clear all states except pinned messages + */ + clear(): void { + const pinnedStates = new Map() + + // Save pinned message states + this.pinnedMessages.forEach((ts) => { + const state = this.states.get(ts) + if (state) { + pinnedStates.set(ts, state) + } + }) + + const previousSize = this.states.size + const previousExpandedCount = this.expandedCount + + // Clear all states + this.states.clear() + this.expandedCount = 0 + + // Restore pinned messages + pinnedStates.forEach((state, ts) => { + this.states.set(ts, state) + if (state.isExpanded) { + this.expandedCount++ + } + }) + + console.log("[VIRTUALIZATION] MessageStateManager cleared:", { + previousSize, + previousExpandedCount, + newSize: this.states.size, + newExpandedCount: this.expandedCount, + pinnedCount: this.pinnedMessages.size, + timestamp: new Date().toISOString(), + }) + } + + /** + * Cleanup old states (called automatically by LRU cache) + */ + cleanup(): void { + const beforeSize = this.states.size + this.states.purgeStale() + const afterSize = this.states.size + + if (beforeSize !== afterSize) { + console.log("[VIRTUALIZATION] MessageStateManager cleanup:", { + beforeSize, + afterSize, + removed: beforeSize - afterSize, + timestamp: new Date().toISOString(), + }) + } + } + + /** + * Get statistics about the state manager + */ + getStats(): { + totalStates: number + expandedCount: number + pinnedCount: number + cacheSize: number + } { + return { + totalStates: this.states.size, + expandedCount: this.expandedCount, + pinnedCount: this.pinnedMessages.size, + cacheSize: this.states.size, + } + } + + /** + * Export all states (for debugging or persistence) + */ + exportStates(): Array<[number, MessageState]> { + const states: Array<[number, MessageState]> = [] + this.states.forEach((value, key) => { + states.push([key, value]) + }) + return states + } + + /** + * Import states (for restoration) + */ + importStates(states: Array<[number, MessageState]>): void { + this.clear() + for (const [ts, state] of states) { + this.states.set(ts, state) + if (state.isPinned) { + this.pinnedMessages.add(ts) + } + if (state.isExpanded) { + this.expandedCount++ + } + } + } +} diff --git a/webview-ui/src/components/chat/utils/__tests__/AutoScrollManager.test.ts b/webview-ui/src/components/chat/utils/__tests__/AutoScrollManager.test.ts new file mode 100644 index 0000000000..032f0200fd --- /dev/null +++ b/webview-ui/src/components/chat/utils/__tests__/AutoScrollManager.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import { AutoScrollManager } from "../AutoScrollManager" + +describe("AutoScrollManager", () => { + let manager: AutoScrollManager + + beforeEach(() => { + manager = new AutoScrollManager(50) // 50px threshold + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + manager.dispose() + }) + + describe("scroll detection", () => { + it("should detect user scrolling up", () => { + // Initial state at bottom + manager.handleScroll(950, 1000, 50) + expect(manager.getState().isUserScrolling).toBe(false) + + // Scroll up significantly + manager.handleScroll(800, 1000, 50) + expect(manager.getState().isUserScrolling).toBe(true) + }) + + it("should reset user scrolling when returning to bottom", () => { + // Scroll up + manager.handleScroll(800, 1000, 50) + expect(manager.getState().isUserScrolling).toBe(true) + + // Return to bottom (within threshold) + manager.handleScroll(960, 1000, 50) + expect(manager.getState().isUserScrolling).toBe(false) + }) + + it("should not detect small scroll movements as user scrolling", () => { + manager.handleScroll(945, 1000, 50) + manager.handleScroll(940, 1000, 50) // Small movement + expect(manager.getState().isUserScrolling).toBe(false) + }) + }) + + describe("auto-scroll decisions", () => { + it("should allow auto-scroll when not user scrolling", () => { + expect(manager.shouldAutoScroll()).toBe(true) + }) + + it("should prevent auto-scroll when user is scrolling", () => { + manager.forceUserScrolling() + expect(manager.shouldAutoScroll()).toBe(false) + }) + + it("should prevent auto-scroll when there are expanded messages", () => { + expect(manager.shouldAutoScroll(true)).toBe(false) + }) + + it("should prevent auto-scroll during active scrolling", () => { + manager.handleScroll(100, 1000, 50) + expect(manager.getState().isScrolling).toBe(true) + expect(manager.shouldAutoScroll()).toBe(false) + + // After timeout, scrolling should stop + vi.advanceTimersByTime(200) + expect(manager.getState().isScrolling).toBe(false) + }) + }) + + describe("position checks", () => { + it("should correctly identify near bottom position", () => { + expect(manager.isNearBottom(960, 1000, 50)).toBe(true) // 40px from bottom + expect(manager.isNearBottom(940, 1000, 50)).toBe(false) // 60px from bottom + }) + + it("should correctly identify at bottom position", () => { + expect(manager.isAtBottom(999, 1000, 50)).toBe(true) // 1px from bottom + expect(manager.isAtBottom(995, 1000, 50)).toBe(false) // 5px from bottom + }) + }) + + describe("scroll velocity", () => { + it("should calculate scroll velocity", () => { + const now = Date.now() + manager.handleScroll(100, 1000, 50, now) + manager.handleScroll(200, 1000, 50, now + 100) // 100px in 100ms + + expect(manager.getScrollVelocity()).toBeCloseTo(1.0) // 1px/ms + }) + + it("should reset velocity after scrolling stops", () => { + manager.handleScroll(100, 1000, 50) + manager.handleScroll(200, 1000, 50) + expect(manager.getScrollVelocity()).toBeGreaterThan(0) + + vi.advanceTimersByTime(200) + expect(manager.getScrollVelocity()).toBe(0) + }) + }) + + describe("scroll behavior optimization", () => { + it("should recommend instant scroll for large distances", () => { + expect(manager.getScrollBehavior(0, 10000, 5000)).toBe("auto") + }) + + it("should recommend smooth scroll for small distances", () => { + expect(manager.getScrollBehavior(0, 1000, 5000)).toBe("smooth") + }) + }) + + describe("performance metrics", () => { + it("should track scroll metrics", () => { + // Simulate scroll events + const now = Date.now() + for (let i = 0; i < 10; i++) { + manager.handleScroll(i * 10, 1000, 50, now + i * 16) // ~60fps + } + + const metrics = manager.getScrollMetrics() + expect(metrics.fps).toBeGreaterThan(0) + expect(metrics.isUserScrolling).toBe(false) + expect(metrics.velocity).toBeGreaterThan(0) + expect(metrics.isScrolling).toBe(true) + }) + }) + + describe("threshold management", () => { + it("should update bottom threshold", () => { + expect(manager.getState().atBottomThreshold).toBe(50) + + manager.setBottomThreshold(100) + expect(manager.getState().atBottomThreshold).toBe(100) + + // Check with new threshold + expect(manager.isNearBottom(920, 1000, 50)).toBe(true) // 80px from bottom, within 100px + }) + }) + + describe("state management", () => { + it("should reset all state", () => { + manager.forceUserScrolling() + manager.handleScroll(500, 1000, 50) + + manager.reset() + + const state = manager.getState() + expect(state.isUserScrolling).toBe(false) + expect(state.lastScrollTop).toBe(0) + expect(state.scrollVelocity).toBe(0) + expect(state.isScrolling).toBe(false) + }) + + it("should clean up timers on dispose", () => { + manager.handleScroll(100, 1000, 50) + + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout") + manager.dispose() + + expect(clearTimeoutSpy).toHaveBeenCalled() + }) + }) +}) diff --git a/webview-ui/src/components/chat/utils/__tests__/MessageStateManager.test.ts b/webview-ui/src/components/chat/utils/__tests__/MessageStateManager.test.ts new file mode 100644 index 0000000000..5ac635b4f3 --- /dev/null +++ b/webview-ui/src/components/chat/utils/__tests__/MessageStateManager.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { MessageStateManager } from "../MessageStateManager" + +describe("MessageStateManager", () => { + let manager: MessageStateManager + + beforeEach(() => { + manager = new MessageStateManager(5, 1000) // Small cache for testing + }) + + describe("basic state management", () => { + it("should set and get message state", () => { + const ts = 12345 + manager.setState(ts, { isExpanded: true }) + + const state = manager.getState(ts) + expect(state).toBeDefined() + expect(state?.isExpanded).toBe(true) + expect(state?.isPinned).toBe(false) + }) + + it("should return undefined for non-existent state", () => { + expect(manager.getState(99999)).toBeUndefined() + }) + + it("should check if message is expanded", () => { + const ts = 12345 + expect(manager.isExpanded(ts)).toBe(false) + + manager.setState(ts, { isExpanded: true }) + expect(manager.isExpanded(ts)).toBe(true) + }) + + it("should toggle expanded state", () => { + const ts = 12345 + expect(manager.toggleExpanded(ts)).toBe(true) + expect(manager.isExpanded(ts)).toBe(true) + + expect(manager.toggleExpanded(ts)).toBe(false) + expect(manager.isExpanded(ts)).toBe(false) + }) + }) + + describe("expanded count tracking", () => { + it("should track expanded count correctly", () => { + expect(manager.getExpandedCount()).toBe(0) + expect(manager.hasExpandedMessages()).toBe(false) + + manager.setState(1, { isExpanded: true }) + expect(manager.getExpandedCount()).toBe(1) + expect(manager.hasExpandedMessages()).toBe(true) + + manager.setState(2, { isExpanded: true }) + expect(manager.getExpandedCount()).toBe(2) + + manager.setState(1, { isExpanded: false }) + expect(manager.getExpandedCount()).toBe(1) + }) + + it("should handle expanded count when cache evicts items", () => { + // Fill cache to capacity + for (let i = 1; i <= 5; i++) { + manager.setState(i, { isExpanded: true }) + } + expect(manager.getExpandedCount()).toBe(5) + + // Add one more, should evict the oldest + manager.setState(6, { isExpanded: true }) + expect(manager.getExpandedCount()).toBe(5) // Should still be 5 due to eviction + }) + }) + + describe("pinned messages", () => { + it("should pin and unpin messages", () => { + const ts = 12345 + expect(manager.isPinned(ts)).toBe(false) + + manager.pinMessage(ts) + expect(manager.isPinned(ts)).toBe(true) + expect(manager.getState(ts)?.isPinned).toBe(true) + + manager.unpinMessage(ts) + expect(manager.isPinned(ts)).toBe(false) + expect(manager.getState(ts)?.isPinned).toBe(false) + }) + + it("should preserve pinned messages during clear", () => { + manager.pinMessage(1) + manager.setState(2, { isExpanded: true }) + manager.setState(3, { isExpanded: true }) + + manager.clear() + + expect(manager.isPinned(1)).toBe(true) + expect(manager.getState(1)).toBeDefined() + expect(manager.getState(2)).toBeUndefined() + expect(manager.getState(3)).toBeUndefined() + }) + }) + + describe("height caching", () => { + it("should cache message heights", () => { + const ts = 12345 + manager.setCachedHeight(ts, 200) + + expect(manager.getCachedHeight(ts)).toBe(200) + expect(manager.getState(ts)?.height).toBe(200) + }) + + it("should preserve height when updating other properties", () => { + const ts = 12345 + manager.setCachedHeight(ts, 200) + manager.setState(ts, { isExpanded: true }) + + expect(manager.getCachedHeight(ts)).toBe(200) + expect(manager.isExpanded(ts)).toBe(true) + }) + }) + + describe("range operations", () => { + it("should get expanded messages in range", () => { + manager.setState(1, { isExpanded: true }) + manager.setState(2, { isExpanded: false }) + manager.setState(3, { isExpanded: true }) + manager.setState(4, { isExpanded: false }) + + const messages = [{ ts: 1 }, { ts: 2 }, { ts: 3 }, { ts: 4 }] + const expanded = manager.getExpandedInRange(messages) + + expect(expanded.size).toBe(2) + expect(expanded.has(1)).toBe(true) + expect(expanded.has(3)).toBe(true) + }) + }) + + describe("statistics", () => { + it("should provide accurate stats", () => { + manager.setState(1, { isExpanded: true }) + manager.setState(2, { isExpanded: false }) + manager.pinMessage(1) + manager.pinMessage(3) + + const stats = manager.getStats() + expect(stats.totalStates).toBe(3) // 1, 2, and 3 (pinned creates state) + expect(stats.expandedCount).toBe(1) + expect(stats.pinnedCount).toBe(2) + expect(stats.cacheSize).toBe(3) + }) + }) + + describe("import/export", () => { + it("should export and import states", () => { + manager.setState(1, { isExpanded: true }) + manager.setState(2, { isExpanded: false }) + manager.pinMessage(1) + + const exported = manager.exportStates() + expect(exported.length).toBe(2) + + // Create new manager and import + const newManager = new MessageStateManager() + newManager.importStates(exported) + + expect(newManager.isExpanded(1)).toBe(true) + expect(newManager.isExpanded(2)).toBe(false) + expect(newManager.isPinned(1)).toBe(true) + expect(newManager.getExpandedCount()).toBe(1) + }) + }) +}) diff --git a/webview-ui/src/components/chat/utils/__tests__/virtualizationConfig.test.ts b/webview-ui/src/components/chat/utils/__tests__/virtualizationConfig.test.ts new file mode 100644 index 0000000000..a0c46901aa --- /dev/null +++ b/webview-ui/src/components/chat/utils/__tests__/virtualizationConfig.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { VIRTUALIZATION_CONFIG, detectDevicePerformance, getViewportConfigForDevice } from "../virtualizationConfig" + +describe("virtualizationConfig", () => { + describe("VIRTUALIZATION_CONFIG", () => { + it("should have correct default viewport configurations", () => { + expect(VIRTUALIZATION_CONFIG.viewport.default).toEqual({ top: 500, bottom: 1000 }) + expect(VIRTUALIZATION_CONFIG.viewport.streaming).toEqual({ top: 500, bottom: 3000 }) + expect(VIRTUALIZATION_CONFIG.viewport.expanded).toEqual({ top: 2000, bottom: 2000 }) + expect(VIRTUALIZATION_CONFIG.viewport.minimal).toEqual({ top: 200, bottom: 500 }) + }) + + it("should have correct performance thresholds", () => { + expect(VIRTUALIZATION_CONFIG.performance.maxMessagesInDOM).toBe(500) + expect(VIRTUALIZATION_CONFIG.performance.cleanupThreshold).toBe(1000) + expect(VIRTUALIZATION_CONFIG.performance.minCleanupInterval).toBe(5000) + }) + + it("should have correct auto-scroll configuration", () => { + expect(VIRTUALIZATION_CONFIG.autoScroll.threshold).toBe(50) + expect(VIRTUALIZATION_CONFIG.autoScroll.smoothScrollMaxDistance).toBe(5000) + expect(VIRTUALIZATION_CONFIG.autoScroll.debounceDelay).toBe(100) + }) + + it("should have correct state cache configuration", () => { + expect(VIRTUALIZATION_CONFIG.stateCache.maxSize).toBe(100) + expect(VIRTUALIZATION_CONFIG.stateCache.ttl).toBe(30 * 60 * 1000) + }) + }) + + describe("detectDevicePerformance", () => { + let originalNavigator: any + + beforeEach(() => { + originalNavigator = global.navigator + // @ts-expect-error - mocking navigator + global.navigator = { + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/91.0.4472.124", + } + }) + + afterEach(() => { + global.navigator = originalNavigator + }) + + it("should detect high performance device with large heap size", () => { + // @ts-expect-error - mocking navigator memory + global.navigator.memory = { + jsHeapSizeLimit: 3 * 1024 * 1024 * 1024, // 3GB + } + + expect(detectDevicePerformance()).toBe("high") + }) + + it("should detect medium performance device with medium heap size", () => { + // @ts-expect-error - mocking navigator memory + global.navigator.memory = { + jsHeapSizeLimit: 1.5 * 1024 * 1024 * 1024, // 1.5GB + } + + expect(detectDevicePerformance()).toBe("medium") + }) + + it("should detect high performance device with many CPU cores", () => { + // @ts-expect-error - mocking navigator hardwareConcurrency + global.navigator.hardwareConcurrency = 8 + + expect(detectDevicePerformance()).toBe("high") + }) + + it("should detect medium performance device with moderate CPU cores", () => { + // @ts-expect-error - mocking navigator hardwareConcurrency + global.navigator.hardwareConcurrency = 4 + + expect(detectDevicePerformance()).toBe("medium") + }) + + it("should detect high performance device with high device memory", () => { + // @ts-expect-error - mocking navigator deviceMemory + global.navigator.deviceMemory = 8 + + expect(detectDevicePerformance()).toBe("high") + }) + + it("should detect low performance on mobile devices", () => { + // @ts-expect-error - mocking navigator userAgent + global.navigator.userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15" + // @ts-expect-error - mocking navigator connection + global.navigator.connection = { effectiveType: "4g" } + + expect(detectDevicePerformance()).toBe("low") + }) + + it("should default to low performance when no hints available", () => { + // @ts-expect-error - mocking navigator + global.navigator = { + userAgent: "Unknown Browser", + } + + expect(detectDevicePerformance()).toBe("low") + }) + }) + + describe("getViewportConfigForDevice", () => { + it("should return correct config for high performance", () => { + const config = getViewportConfigForDevice("high") + expect(config).toEqual({ top: 1000, bottom: 2000 }) + }) + + it("should return correct config for medium performance", () => { + const config = getViewportConfigForDevice("medium") + expect(config).toEqual(VIRTUALIZATION_CONFIG.viewport.default) + }) + + it("should return correct config for low performance", () => { + const config = getViewportConfigForDevice("low") + expect(config).toEqual(VIRTUALIZATION_CONFIG.viewport.minimal) + }) + }) +}) diff --git a/webview-ui/src/components/chat/utils/messageGrouping.ts b/webview-ui/src/components/chat/utils/messageGrouping.ts new file mode 100644 index 0000000000..20f21d17f3 --- /dev/null +++ b/webview-ui/src/components/chat/utils/messageGrouping.ts @@ -0,0 +1,325 @@ +import { ClineMessage } from "@roo-code/types" +import { ClineSayBrowserAction } from "@roo/ExtensionMessage" + +/** + * Represents a group of messages for virtualized rendering + */ +export interface MessageGroup { + type: "single" | "browser-session" + messages: ClineMessage[] + startIndex: number + endIndex: number + estimatedHeight?: number + collapsed?: boolean + sessionId?: string +} + +/** + * Configuration for message grouping + */ +export interface GroupingConfig { + maxGroupSize?: number + collapseThreshold?: number + visibleBuffer?: number +} + +const DEFAULT_CONFIG: GroupingConfig = { + maxGroupSize: 50, + collapseThreshold: 10, + visibleBuffer: 50, +} + +/** + * Creates optimized message groups for virtualization + * Groups browser sessions and provides placeholders for off-screen content + */ +export function createOptimizedMessageGroups( + messages: ClineMessage[], + visibleRange?: { startIndex: number; endIndex: number }, + config: GroupingConfig = DEFAULT_CONFIG, +): MessageGroup[] { + const groups: MessageGroup[] = [] + let currentBrowserSession: ClineMessage[] = [] + let sessionStartIndex = -1 + let isInBrowserSession = false + let sessionId = "" + + const { visibleBuffer = 50 } = config + + messages.forEach((message, index) => { + // Determine if we should process this message based on visible range + const shouldProcess = + !visibleRange || + (index >= Math.max(0, visibleRange.startIndex - visibleBuffer) && + index <= visibleRange.endIndex + visibleBuffer) + + // Handle browser session start + if (message.ask === "browser_action_launch") { + // End previous session if any + if (currentBrowserSession.length > 0) { + groups.push( + createBrowserSessionGroup( + currentBrowserSession, + sessionStartIndex, + index - 1, + sessionId, + shouldProcess, + ), + ) + } + + // Start new session + isInBrowserSession = true + sessionStartIndex = index + sessionId = `browser-session-${message.ts}` + currentBrowserSession = [message] + } + // Continue browser session + else if (isInBrowserSession && isBrowserSessionMessage(message)) { + currentBrowserSession.push(message) + + // Check for session end + if (message.say === "browser_action") { + try { + const action = JSON.parse(message.text || "{}") as ClineSayBrowserAction + if (action.action === "close") { + groups.push( + createBrowserSessionGroup( + currentBrowserSession, + sessionStartIndex, + index, + sessionId, + shouldProcess, + ), + ) + currentBrowserSession = [] + isInBrowserSession = false + sessionId = "" + } + } catch (_e) { + // Invalid JSON, continue session + } + } + } + // Regular message or end of browser session + else { + // End browser session if active + if (currentBrowserSession.length > 0) { + groups.push( + createBrowserSessionGroup( + currentBrowserSession, + sessionStartIndex, + index - 1, + sessionId, + shouldProcess, + ), + ) + currentBrowserSession = [] + isInBrowserSession = false + sessionId = "" + } + + // Add single message + if (shouldProcess || isImportantMessage(message)) { + groups.push({ + type: "single", + messages: [message], + startIndex: index, + endIndex: index, + estimatedHeight: estimateMessageHeight(message), + }) + } + } + }) + + // Handle remaining browser session + if (currentBrowserSession.length > 0) { + groups.push( + createBrowserSessionGroup(currentBrowserSession, sessionStartIndex, messages.length - 1, sessionId, true), + ) + } + + return groups +} + +/** + * Creates a browser session group with optimization + */ +function createBrowserSessionGroup( + messages: ClineMessage[], + startIndex: number, + endIndex: number, + sessionId: string, + shouldRenderFull: boolean, +): MessageGroup { + const group: MessageGroup = { + type: "browser-session", + messages: shouldRenderFull ? messages : messages.slice(0, 3), // Show preview when off-screen + startIndex, + endIndex, + sessionId, + collapsed: !shouldRenderFull && messages.length > 10, + estimatedHeight: estimateBrowserSessionHeight(messages, !shouldRenderFull), + } + + return group +} + +/** + * Check if a message is part of a browser session + */ +function isBrowserSessionMessage(message: ClineMessage): boolean { + if (message.type === "ask") { + return ["browser_action_launch"].includes(message.ask || "") + } + + if (message.type === "say") { + return ["api_req_started", "text", "browser_action", "browser_action_result"].includes(message.say || "") + } + + return false +} + +/** + * Check if a message is important and should always be rendered + */ +function isImportantMessage(message: ClineMessage): boolean { + // Always render error messages + if (message.say === "error" || message.ask === "api_req_failed") { + return true + } + + // Always render active tool requests + if (message.ask === "tool" && !message.partial) { + return true + } + + // Always render completion results + if (message.ask === "completion_result" || message.say === "completion_result") { + return true + } + + return false +} + +/** + * Estimate the height of a single message + */ +function estimateMessageHeight(message: ClineMessage): number { + const BASE_HEIGHT = 60 // Base height for message chrome + const CHAR_HEIGHT_FACTOR = 0.15 // Approximate height per character + const IMAGE_HEIGHT = 200 // Height per image + const CODE_BLOCK_EXTRA = 40 // Extra height for code blocks + + let height = BASE_HEIGHT + + // Add text height + if (message.text) { + const textLength = message.text.length + height += textLength * CHAR_HEIGHT_FACTOR + + // Check for code blocks + const codeBlockCount = (message.text.match(/```/g) || []).length / 2 + height += codeBlockCount * CODE_BLOCK_EXTRA + } + + // Add image heights + if (message.images && message.images.length > 0) { + height += message.images.length * IMAGE_HEIGHT + } + + // Add extra height for certain message types + if (message.ask === "tool" || message.say === "api_req_started") { + height += 40 // Tool messages have extra UI + } + + return Math.round(height) +} + +/** + * Estimate the height of a browser session + */ +function estimateBrowserSessionHeight(messages: ClineMessage[], collapsed: boolean): number { + if (collapsed) { + return 120 // Collapsed session shows summary + } + + // Sum individual message heights + return messages.reduce((total, msg) => total + estimateMessageHeight(msg), 0) + 40 // Extra for session chrome +} + +/** + * Get visible message indices from groups + */ +export function getVisibleMessageIndices( + groups: MessageGroup[], + visibleGroupRange: { startIndex: number; endIndex: number }, +): { startIndex: number; endIndex: number } { + if (groups.length === 0) { + return { startIndex: 0, endIndex: 0 } + } + + const startGroup = groups[Math.max(0, visibleGroupRange.startIndex)] + const endGroup = groups[Math.min(groups.length - 1, visibleGroupRange.endIndex)] + + return { + startIndex: startGroup?.startIndex || 0, + endIndex: endGroup?.endIndex || 0, + } +} + +/** + * Calculate total estimated height of message groups + */ +export function calculateTotalHeight(groups: MessageGroup[]): number { + return groups.reduce((total, group) => total + (group.estimatedHeight || 100), 0) +} + +/** + * Find group containing a specific message timestamp + */ +export function findGroupByMessageTs(groups: MessageGroup[], messageTs: number): MessageGroup | undefined { + return groups.find((group) => group.messages.some((msg) => msg.ts === messageTs)) +} + +/** + * Optimize groups by merging small adjacent single-message groups + */ +export function optimizeGroups(groups: MessageGroup[], maxMergeSize: number = 5): MessageGroup[] { + const optimized: MessageGroup[] = [] + let currentMerge: MessageGroup | null = null + + for (const group of groups) { + if ( + group.type === "single" && + currentMerge && + currentMerge.messages.length < maxMergeSize && + group.startIndex === currentMerge.endIndex + 1 + ) { + // Merge into current group + currentMerge.messages.push(...group.messages) + currentMerge.endIndex = group.endIndex + currentMerge.estimatedHeight = (currentMerge.estimatedHeight || 0) + (group.estimatedHeight || 0) + } else { + // Save current merge if any + if (currentMerge) { + optimized.push(currentMerge) + } + + // Start new merge or add non-mergeable group + if (group.type === "single") { + currentMerge = { ...group } + } else { + optimized.push(group) + currentMerge = null + } + } + } + + // Don't forget the last merge + if (currentMerge) { + optimized.push(currentMerge) + } + + return optimized +} diff --git a/webview-ui/src/components/chat/utils/performanceMonitor.ts b/webview-ui/src/components/chat/utils/performanceMonitor.ts new file mode 100644 index 0000000000..c6597268f8 --- /dev/null +++ b/webview-ui/src/components/chat/utils/performanceMonitor.ts @@ -0,0 +1,356 @@ +/** + * Performance metrics for ChatView virtualization + */ +export interface PerformanceMetrics { + renderTime: number[] + scrollFPS: number + memoryUsage: number + messageCount: number + visibleMessageCount: number + domNodeCount: number + lastMeasurement: number +} + +/** + * Performance thresholds for monitoring + */ +export interface PerformanceThresholds { + maxRenderTime: number + minScrollFPS: number + maxMemoryUsage: number + maxDOMNodes: number +} + +const DEFAULT_THRESHOLDS: PerformanceThresholds = { + maxRenderTime: 16.67, // 60 FPS target + minScrollFPS: 30, + maxMemoryUsage: 256 * 1024 * 1024, // 256MB - more reasonable for modern web apps + maxDOMNodes: 5000, +} + +/** + * Monitors and tracks performance metrics for ChatView + */ +export class PerformanceMonitor { + private metrics: PerformanceMetrics = { + renderTime: [], + scrollFPS: 60, + memoryUsage: 0, + messageCount: 0, + visibleMessageCount: 0, + domNodeCount: 0, + lastMeasurement: Date.now(), + } + + private frameCount = 0 + private lastFrameTime = performance.now() + private rafId: number | null = null + private isMonitoring = false + private thresholds: PerformanceThresholds + private performanceObserver: PerformanceObserver | null = null + + // Callbacks for threshold violations + private onThresholdViolation?: (metric: string, value: number, threshold: number) => void + + constructor( + thresholds: Partial = {}, + onThresholdViolation?: (metric: string, value: number, threshold: number) => void, + ) { + this.thresholds = { ...DEFAULT_THRESHOLDS, ...thresholds } + this.onThresholdViolation = onThresholdViolation + + // Set up performance observer if available + if (typeof PerformanceObserver !== "undefined") { + try { + this.performanceObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.entryType === "measure" && entry.name.startsWith("chat-")) { + this.recordRenderTime(entry.duration) + } + } + }) + this.performanceObserver.observe({ entryTypes: ["measure"] }) + } catch (_e) { + console.warn("PerformanceObserver not available:", _e) + } + } + } + + /** + * Start monitoring performance + */ + startMonitoring(): void { + if (this.isMonitoring) return + + this.isMonitoring = true + this.measureFPS() + } + + /** + * Stop monitoring performance + */ + stopMonitoring(): void { + this.isMonitoring = false + + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId) + this.rafId = null + } + } + + /** + * Measure render performance + */ + measureRender(label: string, callback: () => T): T { + const startMark = `${label}-start` + const endMark = `${label}-end` + + performance.mark(startMark) + const result = callback() + performance.mark(endMark) + + try { + performance.measure(label, startMark, endMark) + const measure = performance.getEntriesByName(label, "measure")[0] + if (measure) { + this.recordRenderTime(measure.duration) + } + } catch (_e) { + // Fallback for browsers that don't support performance.measure + const start = performance.getEntriesByName(startMark, "mark")[0]?.startTime || 0 + const end = performance.getEntriesByName(endMark, "mark")[0]?.startTime || 0 + this.recordRenderTime(end - start) + } + + // Clean up marks + performance.clearMarks(startMark) + performance.clearMarks(endMark) + performance.clearMeasures(label) + + return result + } + + /** + * Record a render time measurement + */ + private recordRenderTime(duration: number): void { + this.metrics.renderTime.push(duration) + + // Keep only last 100 measurements + if (this.metrics.renderTime.length > 100) { + this.metrics.renderTime.shift() + } + + // Check threshold + if (duration > this.thresholds.maxRenderTime) { + this.violateThreshold("renderTime", duration, this.thresholds.maxRenderTime) + } + } + + /** + * Measure FPS using requestAnimationFrame + */ + private measureFPS = (): void => { + if (!this.isMonitoring) return + + const now = performance.now() + const delta = now - this.lastFrameTime + + if (delta >= 1000) { + this.metrics.scrollFPS = Math.round((this.frameCount * 1000) / delta) + this.frameCount = 0 + this.lastFrameTime = now + + // Check threshold + if (this.metrics.scrollFPS < this.thresholds.minScrollFPS) { + this.violateThreshold("scrollFPS", this.metrics.scrollFPS, this.thresholds.minScrollFPS) + } + } + + this.frameCount++ + this.rafId = requestAnimationFrame(this.measureFPS) + } + + /** + * Update scroll FPS (called from scroll handler) + */ + updateScrollFPS(): void { + // This is called from scroll events to track scroll performance + this.frameCount++ + } + + /** + * Update memory usage + */ + updateMemoryUsage(): void { + if ("memory" in performance) { + const memory = (performance as any).memory + this.metrics.memoryUsage = memory.usedJSHeapSize || 0 + + // Check threshold + if (this.metrics.memoryUsage > this.thresholds.maxMemoryUsage) { + this.violateThreshold("memoryUsage", this.metrics.memoryUsage, this.thresholds.maxMemoryUsage) + } + } + } + + /** + * Update message counts + */ + updateMessageCounts(total: number, visible: number): void { + this.metrics.messageCount = total + this.metrics.visibleMessageCount = visible + } + + /** + * Update DOM node count + */ + updateDOMNodeCount(): void { + // Use a more efficient method to count DOM nodes + // Only count nodes within the chat container to reduce overhead + const chatContainer = document.querySelector('[data-testid="chat-messages-container"]') || document.body + this.metrics.domNodeCount = chatContainer.getElementsByTagName("*").length + + // Check threshold + if (this.metrics.domNodeCount > this.thresholds.maxDOMNodes) { + this.violateThreshold("domNodeCount", this.metrics.domNodeCount, this.thresholds.maxDOMNodes) + } + } + + /** + * Get current metrics + */ + getMetrics(): PerformanceMetrics { + return { ...this.metrics, lastMeasurement: Date.now() } + } + + /** + * Get average render time + */ + getAverageRenderTime(): number { + if (this.metrics.renderTime.length === 0) return 0 + const sum = this.metrics.renderTime.reduce((a, b) => a + b, 0) + return sum / this.metrics.renderTime.length + } + + /** + * Get performance score (0-100) + */ + getPerformanceScore(): number { + const renderScore = Math.max(0, 100 - (this.getAverageRenderTime() / this.thresholds.maxRenderTime) * 50) + const fpsScore = Math.min(100, (this.metrics.scrollFPS / 60) * 100) + const memoryScore = Math.max(0, 100 - (this.metrics.memoryUsage / this.thresholds.maxMemoryUsage) * 50) + const domScore = Math.max(0, 100 - (this.metrics.domNodeCount / this.thresholds.maxDOMNodes) * 50) + + return Math.round((renderScore + fpsScore + memoryScore + domScore) / 4) + } + + /** + * Log current metrics to console + */ + logMetrics(): void { + const score = this.getPerformanceScore() + const avgRenderTime = this.getAverageRenderTime() + + console.log("ChatView Performance Metrics:", { + score: `${score}/100`, + avgRenderTime: `${avgRenderTime.toFixed(2)}ms`, + scrollFPS: this.metrics.scrollFPS, + memoryUsage: `${(this.metrics.memoryUsage / 1024 / 1024).toFixed(2)}MB`, + efficiency: `${this.metrics.visibleMessageCount}/${this.metrics.messageCount} messages rendered`, + domNodes: this.metrics.domNodeCount, + }) + } + + /** + * Get performance report + */ + getReport(): { + score: number + metrics: PerformanceMetrics + averageRenderTime: number + issues: string[] + } { + const issues: string[] = [] + const avgRenderTime = this.getAverageRenderTime() + + if (avgRenderTime > this.thresholds.maxRenderTime) { + issues.push( + `Render time (${avgRenderTime.toFixed(2)}ms) exceeds target (${this.thresholds.maxRenderTime}ms)`, + ) + } + + if (this.metrics.scrollFPS < this.thresholds.minScrollFPS) { + issues.push(`Scroll FPS (${this.metrics.scrollFPS}) below minimum (${this.thresholds.minScrollFPS})`) + } + + if (this.metrics.memoryUsage > this.thresholds.maxMemoryUsage) { + issues.push( + `Memory usage (${(this.metrics.memoryUsage / 1024 / 1024).toFixed(2)}MB) exceeds limit (${(this.thresholds.maxMemoryUsage / 1024 / 1024).toFixed(0)}MB)`, + ) + } + + if (this.metrics.domNodeCount > this.thresholds.maxDOMNodes) { + issues.push(`DOM nodes (${this.metrics.domNodeCount}) exceeds limit (${this.thresholds.maxDOMNodes})`) + } + + return { + score: this.getPerformanceScore(), + metrics: this.getMetrics(), + averageRenderTime: avgRenderTime, + issues, + } + } + + /** + * Handle threshold violation + */ + private violateThreshold(metric: string, value: number, threshold: number): void { + if (this.onThresholdViolation) { + this.onThresholdViolation(metric, value, threshold) + } + } + + /** + * Reset all metrics + */ + reset(): void { + this.metrics = { + renderTime: [], + scrollFPS: 60, + memoryUsage: 0, + messageCount: 0, + visibleMessageCount: 0, + domNodeCount: 0, + lastMeasurement: Date.now(), + } + this.frameCount = 0 + this.lastFrameTime = performance.now() + } + + /** + * Cleanup resources + */ + dispose(): void { + this.stopMonitoring() + + if (this.performanceObserver) { + this.performanceObserver.disconnect() + this.performanceObserver = null + } + } +} + +/** + * Create a singleton performance monitor instance + */ +let globalMonitor: PerformanceMonitor | null = null + +export function getGlobalPerformanceMonitor(): PerformanceMonitor { + if (!globalMonitor) { + globalMonitor = new PerformanceMonitor({}, (metric, value, threshold) => { + console.warn(`Performance threshold violated: ${metric} = ${value} (threshold: ${threshold})`) + }) + } + return globalMonitor +} diff --git a/webview-ui/src/components/chat/utils/virtualizationConfig.ts b/webview-ui/src/components/chat/utils/virtualizationConfig.ts new file mode 100644 index 0000000000..c4ffbb5f0c --- /dev/null +++ b/webview-ui/src/components/chat/utils/virtualizationConfig.ts @@ -0,0 +1,124 @@ +/** + * Configuration for ChatView virtualization optimization + */ + +export interface ViewportConfig { + top: number + bottom: number +} + +export interface VirtualizationConfig { + viewport: { + default: ViewportConfig + streaming: ViewportConfig + expanded: ViewportConfig + minimal: ViewportConfig + } + performance: { + maxMessagesInDOM: number + cleanupThreshold: number + minCleanupInterval: number + } + autoScroll: { + threshold: number + smoothScrollMaxDistance: number + debounceDelay: number + } + stateCache: { + maxSize: number + ttl: number + } +} + +export const VIRTUALIZATION_CONFIG: VirtualizationConfig = { + // Base viewport extensions + viewport: { + default: { top: 500, bottom: 1000 }, + streaming: { top: 500, bottom: 3000 }, + expanded: { top: 2000, bottom: 2000 }, + minimal: { top: 200, bottom: 500 }, + }, + + // Performance thresholds + performance: { + maxMessagesInDOM: 500, + cleanupThreshold: 1000, + minCleanupInterval: 5000, // 5 seconds + }, + + // Auto-scroll configuration + autoScroll: { + threshold: 50, // pixels from bottom to consider "at bottom" + smoothScrollMaxDistance: 5000, // use instant scroll for larger jumps + debounceDelay: 100, + }, + + // State preservation + stateCache: { + maxSize: 100, + ttl: 30 * 60 * 1000, // 30 minutes + }, +} + +export type DevicePerformance = "high" | "medium" | "low" + +/** + * Detects device performance capabilities to optimize virtualization + */ +export function detectDevicePerformance(): DevicePerformance { + // Check for performance hints from browser + if ("memory" in navigator) { + const memory = (navigator as any).memory + if (memory?.jsHeapSizeLimit) { + const heapLimit = memory.jsHeapSizeLimit + if (heapLimit > 2 * 1024 * 1024 * 1024) return "high" // > 2GB + if (heapLimit > 1 * 1024 * 1024 * 1024) return "medium" // > 1GB + } + } + + // Check for hardware concurrency (CPU cores) + if ("hardwareConcurrency" in navigator) { + const cores = navigator.hardwareConcurrency + if (cores >= 8) return "high" + if (cores >= 4) return "medium" + } + + // Check for device memory hint (Chrome only) + if ("deviceMemory" in navigator) { + const deviceMemory = (navigator as any).deviceMemory + if (deviceMemory >= 8) return "high" + if (deviceMemory >= 4) return "medium" + } + + // Check connection type for mobile detection + if ("connection" in navigator) { + const connection = (navigator as any).connection + if (connection?.effectiveType === "4g" && !isMobileDevice()) { + return "medium" + } + } + + // Default to low for safety + return "low" +} + +/** + * Simple mobile device detection + */ +function isMobileDevice(): boolean { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) +} + +/** + * Get viewport configuration based on device performance + */ +export function getViewportConfigForDevice(performance: DevicePerformance): ViewportConfig { + switch (performance) { + case "high": + return { top: 1000, bottom: 2000 } + case "medium": + return VIRTUALIZATION_CONFIG.viewport.default + case "low": + return VIRTUALIZATION_CONFIG.viewport.minimal + } +} diff --git a/webview-ui/src/components/chat/virtualization/index.ts b/webview-ui/src/components/chat/virtualization/index.ts new file mode 100644 index 0000000000..0aa5176366 --- /dev/null +++ b/webview-ui/src/components/chat/virtualization/index.ts @@ -0,0 +1,101 @@ +/** + * ChatView Virtualization Utilities + * + * This module provides optimized virtualization for handling very long conversations + * efficiently. It includes: + * - LRU cache-based state management for expanded/collapsed messages + * - Intelligent auto-scroll behavior with user intent detection + * - Real-time performance monitoring + * - Device-aware optimizations + * - Progressive loading for browser session groups + */ + +// Re-export all virtualization utilities from their respective modules +export { MessageStateManager } from "../utils/MessageStateManager" +export type { MessageState } from "../utils/MessageStateManager" + +export { AutoScrollManager } from "../utils/AutoScrollManager" + +export { PerformanceMonitor, getGlobalPerformanceMonitor } from "../utils/performanceMonitor" +export type { PerformanceMetrics, PerformanceThresholds } from "../utils/performanceMonitor" + +export { + VIRTUALIZATION_CONFIG, + detectDevicePerformance, + getViewportConfigForDevice, +} from "../utils/virtualizationConfig" +export type { ViewportConfig, VirtualizationConfig, DevicePerformance } from "../utils/virtualizationConfig" + +export { + createOptimizedMessageGroups, + getVisibleMessageIndices, + calculateTotalHeight, + findGroupByMessageTs, + optimizeGroups, +} from "../utils/messageGrouping" +export type { MessageGroup, GroupingConfig } from "../utils/messageGrouping" + +export { useOptimizedVirtualization } from "../hooks/useOptimizedVirtualization" +export type { + UseOptimizedVirtualizationOptions, + UseOptimizedVirtualizationReturn, +} from "../hooks/useOptimizedVirtualization" + +/** + * Quick start guide for using the virtualization system: + * + * 1. Import the hook in your ChatView component: + * ```typescript + * import { useOptimizedVirtualization } from './virtualization' + * ``` + * + * 2. Replace existing virtualization setup: + * ```typescript + * const { + * virtuosoRef, + * viewportConfig, + * messageGroups, + * stateManager, + * scrollManager, + * handleScroll, + * handleRangeChange, + * scrollToBottom + * } = useOptimizedVirtualization({ + * messages: groupedMessages, + * isStreaming, + * isHidden + * }) + * ``` + * + * 3. Update Virtuoso configuration: + * ```typescript + * handleScroll(e.currentTarget.scrollTop)} + * rangeChanged={handleRangeChange} + * // ... other props + * /> + * ``` + * + * 4. Use stateManager for expanded/collapsed states: + * ```typescript + * const isExpanded = stateManager.isExpanded(messageTs) + * const toggleExpanded = () => stateManager.toggleExpanded(messageTs) + * ``` + */ + +/** + * Default configuration recommendations: + * + * - Buffer sizes: 500px top, 1000px bottom (adjustable based on device) + * - LRU cache: 250 items max (configurable) + * - Auto-scroll threshold: 50px from bottom + * - Smooth scroll max distance: 5000px (instant scroll for larger jumps) + * + * The system automatically adjusts based on: + * - Device performance (high/medium/low) + * - Current state (streaming, expanded messages) + * - User interaction patterns + */