11import { forwardRef , useCallback , useEffect , useImperativeHandle , useMemo , useRef , useState } from "react"
22import { useDeepCompareEffect , useEvent , useMount } from "react-use"
33import debounce from "debounce"
4- import { Virtuoso , type VirtuosoHandle } from "react-virtuoso"
4+ import { Virtuoso , type VirtuosoHandle , type ListRange } from "react-virtuoso"
55import removeMd from "remove-markdown"
66import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
77import useSound from "use-sound"
@@ -189,6 +189,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
189189 const userRespondedRef = useRef < boolean > ( false )
190190 const [ currentFollowUpTs , setCurrentFollowUpTs ] = useState < number | null > ( null )
191191
192+ // Pagination state - Initialize to show last 20 messages
193+ const [ visibleRange , setVisibleRange ] = useState ( ( ) => {
194+ // Initialize with a default range that will be updated when messages load
195+ return { start : 0 , end : 20 }
196+ } )
197+ const [ isLoadingTop , setIsLoadingTop ] = useState ( false )
198+ const [ isLoadingBottom , setIsLoadingBottom ] = useState ( false )
199+
200+ // Buffer configuration
201+ const VISIBLE_MESSAGE_COUNT = 20
202+ const BUFFER_SIZE = 20
203+ const LOAD_THRESHOLD = 5 // Load more when within 5 messages of edge
204+
192205 const clineAskRef = useRef ( clineAsk )
193206 useEffect ( ( ) => {
194207 clineAskRef . current = clineAsk
@@ -452,6 +465,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
452465 retryCountRef . current . clear ( )
453466 } , [ task ?. ts ] )
454467
468+ // Separate effect to handle initial pagination setup when task changes
469+ useEffect ( ( ) => {
470+ if ( task ) {
471+ // Reset pagination state when task changes
472+ // The actual range will be set when messages are processed
473+ setVisibleRange ( { start : 0 , end : VISIBLE_MESSAGE_COUNT } )
474+ }
475+ } , [ task ?. ts , task , VISIBLE_MESSAGE_COUNT ] )
476+
455477 useEffect ( ( ) => {
456478 if ( isHidden ) {
457479 everVisibleMessagesTsRef . current . clear ( )
@@ -1306,6 +1328,214 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
13061328 return result
13071329 } , [ isCondensing , visibleMessages ] )
13081330
1331+ // Update visible range when groupedMessages changes
1332+ useEffect ( ( ) => {
1333+ if ( groupedMessages . length > 0 ) {
1334+ setVisibleRange ( ( prev ) => {
1335+ const totalMessages = groupedMessages . length
1336+
1337+ // If this looks like initial load (we're at default range and have many messages)
1338+ if ( prev . start === 0 && prev . end === VISIBLE_MESSAGE_COUNT && totalMessages > VISIBLE_MESSAGE_COUNT ) {
1339+ // Start at the bottom of the conversation
1340+ return {
1341+ start : Math . max ( 0 , totalMessages - VISIBLE_MESSAGE_COUNT ) ,
1342+ end : totalMessages ,
1343+ }
1344+ }
1345+
1346+ // If we're already at the end and new messages arrived, adjust to include them
1347+ if ( prev . end === totalMessages - 1 || ( isAtBottom && totalMessages > prev . end ) ) {
1348+ return {
1349+ start : Math . max ( 0 , totalMessages - VISIBLE_MESSAGE_COUNT ) ,
1350+ end : totalMessages ,
1351+ }
1352+ }
1353+
1354+ // Otherwise keep current range
1355+ return prev
1356+ } )
1357+ }
1358+ } , [ groupedMessages . length , isAtBottom ] )
1359+
1360+ // Message windowing logic
1361+ const windowedMessages = useMemo ( ( ) => {
1362+ const totalWindowSize = VISIBLE_MESSAGE_COUNT + BUFFER_SIZE * 2 // 60 messages total
1363+
1364+ // Handle small conversations (less than total window size)
1365+ if ( groupedMessages . length <= totalWindowSize ) {
1366+ return {
1367+ messages : groupedMessages ,
1368+ startIndex : 0 ,
1369+ endIndex : groupedMessages . length ,
1370+ totalCount : groupedMessages . length ,
1371+ startPadding : 0 ,
1372+ endPadding : 0 ,
1373+ }
1374+ }
1375+
1376+ // Calculate the window with buffers
1377+ const bufferStart = Math . max ( 0 , visibleRange . start - BUFFER_SIZE )
1378+ const bufferEnd = Math . min ( groupedMessages . length , visibleRange . end + BUFFER_SIZE )
1379+
1380+ // Slice the messages array
1381+ const windowed = groupedMessages . slice ( bufferStart , bufferEnd )
1382+
1383+ // Add placeholder items for virtualization
1384+ const startPadding = bufferStart
1385+ const endPadding = Math . max ( 0 , groupedMessages . length - bufferEnd )
1386+
1387+ return {
1388+ messages : windowed ,
1389+ startIndex : bufferStart ,
1390+ endIndex : bufferEnd ,
1391+ totalCount : groupedMessages . length ,
1392+ startPadding,
1393+ endPadding,
1394+ }
1395+ } , [ groupedMessages , visibleRange ] )
1396+
1397+ // Loading functions
1398+ const loadMoreMessagesTop = useCallback ( ( ) => {
1399+ setIsLoadingTop ( true )
1400+
1401+ // Simulate async loading with setTimeout (in real implementation, this would be instant)
1402+ setTimeout ( ( ) => {
1403+ setVisibleRange ( ( prev ) => ( {
1404+ start : Math . max ( 0 , prev . start - VISIBLE_MESSAGE_COUNT ) ,
1405+ end : prev . end ,
1406+ } ) )
1407+ setIsLoadingTop ( false )
1408+ } , 100 )
1409+ } , [ ] )
1410+
1411+ const loadMoreMessagesBottom = useCallback ( ( ) => {
1412+ setIsLoadingBottom ( true )
1413+
1414+ setTimeout ( ( ) => {
1415+ setVisibleRange ( ( prev ) => ( {
1416+ start : prev . start ,
1417+ end : Math . min ( groupedMessages . length , prev . end + VISIBLE_MESSAGE_COUNT ) ,
1418+ } ) )
1419+ setIsLoadingBottom ( false )
1420+ } , 100 )
1421+ } , [ groupedMessages . length ] )
1422+
1423+ // Debounced range change handler to prevent excessive updates
1424+ const debouncedRangeChanged = useMemo (
1425+ ( ) =>
1426+ debounce ( ( range : ListRange ) => {
1427+ const { startIndex, endIndex } = range
1428+
1429+ // Check if we need to load more messages at the top
1430+ if ( startIndex <= LOAD_THRESHOLD && visibleRange . start > 0 && ! isLoadingTop ) {
1431+ loadMoreMessagesTop ( )
1432+ }
1433+
1434+ // Check if we need to load more messages at the bottom
1435+ if (
1436+ endIndex >= windowedMessages . messages . length - LOAD_THRESHOLD &&
1437+ visibleRange . end < groupedMessages . length &&
1438+ ! isLoadingBottom
1439+ ) {
1440+ loadMoreMessagesBottom ( )
1441+ }
1442+ } , 100 ) ,
1443+ [
1444+ visibleRange ,
1445+ groupedMessages . length ,
1446+ isLoadingTop ,
1447+ isLoadingBottom ,
1448+ windowedMessages . messages . length ,
1449+ loadMoreMessagesTop ,
1450+ loadMoreMessagesBottom ,
1451+ ] ,
1452+ )
1453+
1454+ // Scroll position tracking
1455+ const handleRangeChanged = useCallback (
1456+ ( range : ListRange ) => {
1457+ // Call the debounced function for loading more messages
1458+ debouncedRangeChanged ( range )
1459+ } ,
1460+ [ debouncedRangeChanged ] ,
1461+ )
1462+
1463+ // Cleanup debounced function on unmount
1464+ useEffect ( ( ) => {
1465+ return ( ) => {
1466+ if ( debouncedRangeChanged && typeof ( debouncedRangeChanged as any ) . cancel === "function" ) {
1467+ ; ( debouncedRangeChanged as any ) . cancel ( )
1468+ }
1469+ }
1470+ } , [ debouncedRangeChanged ] )
1471+
1472+ // TEMPORARY DEBUGGING: Memory usage monitoring
1473+ useEffect ( ( ) => {
1474+ // Only run in browsers that support performance.memory (Chrome/Edge)
1475+ if ( ! ( "memory" in performance ) ) {
1476+ console . log ( "[ChatView Memory Monitor] performance.memory API not available in this browser" )
1477+ return
1478+ }
1479+
1480+ const logMemoryUsage = ( ) => {
1481+ const now = new Date ( )
1482+ const timestamp = now . toTimeString ( ) . split ( " " ) [ 0 ] // HH:MM:SS format
1483+
1484+ // Get memory info
1485+ const memoryInfo = ( performance as any ) . memory
1486+ const heapUsedMB = ( memoryInfo . usedJSHeapSize / 1048576 ) . toFixed ( 1 )
1487+
1488+ // Get message counts
1489+ const messagesInDOM = windowedMessages . messages . length
1490+ const totalMessages = groupedMessages . length
1491+
1492+ // Get visible range info
1493+ const visibleStart = windowedMessages . startIndex
1494+ const visibleEnd = windowedMessages . endIndex
1495+ const bufferStart = Math . max ( 0 , visibleRange . start - BUFFER_SIZE )
1496+ const bufferEnd = Math . min ( groupedMessages . length , visibleRange . end + BUFFER_SIZE )
1497+
1498+ // Check if pagination is active
1499+ const isPaginationActive = groupedMessages . length > VISIBLE_MESSAGE_COUNT
1500+
1501+ // Format and log the information
1502+ console . log (
1503+ `[ChatView Memory Monitor - ${ timestamp } ]\n` +
1504+ `- Heap Used: ${ heapUsedMB } MB\n` +
1505+ `- Messages in DOM: ${ messagesInDOM } / ${ totalMessages } total\n` +
1506+ `- Visible Range: ${ visibleStart } -${ visibleEnd } (buffer: ${ bufferStart } -${ bufferEnd } )\n` +
1507+ `- Pagination Active: ${ isPaginationActive } ` ,
1508+ )
1509+ }
1510+
1511+ // Log immediately
1512+ logMemoryUsage ( )
1513+
1514+ // Set up interval to log every 5 seconds
1515+ const intervalId = setInterval ( logMemoryUsage , 5000 )
1516+
1517+ // Cleanup on unmount
1518+ return ( ) => {
1519+ clearInterval ( intervalId )
1520+ }
1521+ } , [
1522+ windowedMessages . messages . length ,
1523+ windowedMessages . startIndex ,
1524+ windowedMessages . endIndex ,
1525+ groupedMessages . length ,
1526+ visibleRange ,
1527+ BUFFER_SIZE ,
1528+ VISIBLE_MESSAGE_COUNT ,
1529+ ] )
1530+ // END TEMPORARY DEBUGGING
1531+
1532+ // Loading indicator component
1533+ const LoadingIndicator = ( ) => (
1534+ < div className = "flex justify-center items-center py-4" >
1535+ < div className = "animate-spin rounded-full h-6 w-6 border-b-2 border-vscode-progressBar-background" > </ div >
1536+ </ div >
1537+ )
1538+
13091539 // scrolling
13101540
13111541 const scrollToBottomSmooth = useMemo (
@@ -1471,12 +1701,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
14711701
14721702 const itemContent = useCallback (
14731703 ( index : number , messageOrGroup : ClineMessage | ClineMessage [ ] ) => {
1704+ // Get the actual index in the original array
1705+ const actualIndex = windowedMessages . startIndex + index
1706+
14741707 // browser session group
14751708 if ( Array . isArray ( messageOrGroup ) ) {
14761709 return (
14771710 < BrowserSessionRow
14781711 messages = { messageOrGroup }
1479- isLast = { index === groupedMessages . length - 1 }
1712+ isLast = { actualIndex === groupedMessages . length - 1 }
14801713 lastModifiedMessage = { modifiedMessages . at ( - 1 ) }
14811714 onHeightChange = { handleRowHeightChange }
14821715 isStreaming = { isStreaming }
@@ -1499,7 +1732,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
14991732 isExpanded = { expandedRows [ messageOrGroup . ts ] || false }
15001733 onToggleExpand = { toggleRowExpansion } // This was already stabilized
15011734 lastModifiedMessage = { modifiedMessages . at ( - 1 ) } // Original direct access
1502- isLast = { index === groupedMessages . length - 1 } // Original direct access
1735+ isLast = { actualIndex === groupedMessages . length - 1 } // Use actual index
15031736 onHeightChange = { handleRowHeightChange }
15041737 isStreaming = { isStreaming }
15051738 onSuggestionClick = { handleSuggestionClickInRow } // This was already stabilized
@@ -1528,6 +1761,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
15281761 )
15291762 } ,
15301763 [
1764+ windowedMessages . startIndex ,
15311765 expandedRows ,
15321766 toggleRowExpansion ,
15331767 modifiedMessages ,
@@ -1842,21 +2076,27 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
18422076 < div className = "grow flex" ref = { scrollContainerRef } >
18432077 < Virtuoso
18442078 ref = { virtuosoRef }
1845- key = { task . ts } // trick to make sure virtuoso re-renders when task changes, and we use initialTopMostItemIndex to start at the bottom
2079+ key = { task . ts }
18462080 className = "scrollable grow overflow-y-scroll mb-1"
1847- // increasing top by 3_000 to prevent jumping around when user collapses a row
1848- increaseViewportBy = { { top : 3_000 , bottom : Number . MAX_SAFE_INTEGER } } // hack to make sure the last message is always rendered to get truly perfect scroll to bottom animation when new messages are added (Number.MAX_SAFE_INTEGER is safe for arithmetic operations, which is all virtuoso uses this value for in src/sizeRangeSystem.ts)
1849- data = { groupedMessages } // messages is the raw format returned by extension, modifiedMessages is the manipulated structure that combines certain messages of related type, and visibleMessages is the filtered structure that removes messages that should not be rendered
2081+ data = { windowedMessages . messages }
18502082 itemContent = { itemContent }
2083+ rangeChanged = { handleRangeChanged }
2084+ overscan = { 5 } // Render 5 extra items outside viewport
2085+ components = { {
2086+ Header : ( ) => ( isLoadingTop ? < LoadingIndicator /> : null ) ,
2087+ Footer : ( ) => ( isLoadingBottom ? < LoadingIndicator /> : null ) ,
2088+ } }
18512089 atBottomStateChange = { ( isAtBottom ) => {
18522090 setIsAtBottom ( isAtBottom )
18532091 if ( isAtBottom ) {
18542092 disableAutoScrollRef . current = false
18552093 }
18562094 setShowScrollToBottom ( disableAutoScrollRef . current && ! isAtBottom )
18572095 } }
1858- atBottomThreshold = { 10 } // anything lower causes issues with followOutput
1859- initialTopMostItemIndex = { groupedMessages . length - 1 }
2096+ atBottomThreshold = { 10 }
2097+ initialTopMostItemIndex = {
2098+ windowedMessages . messages . length > 0 ? windowedMessages . messages . length - 1 : 0
2099+ }
18602100 />
18612101 </ div >
18622102 < div className = { `flex-initial min-h-0 ${ ! areButtonsVisible ? "mb-1" : "" } ` } >
0 commit comments