1- import React from 'react' ;
1+ import React , { useState , useRef , useEffect , useCallback , memo } from 'react' ;
22import classnames from 'classnames' ;
33import StyledWrapper from './StyledWrapper' ;
44import { IconExclamationCircle , IconChevronRight , IconInfoCircle , IconChevronDown , IconArrowUpRight , IconArrowDownLeft } from '@tabler/icons' ;
55import CodeEditor from 'components/CodeEditor/index' ;
66import { useTheme } from 'providers/Theme' ;
7- import { useState } from 'react' ;
87import { useSelector } from 'react-redux' ;
9- import { useRef } from 'react' ;
10- import { useEffect } from 'react' ;
8+ import { Virtuoso } from 'react-virtuoso' ;
119
1210const getContentMeta = ( content ) => {
1311 if ( typeof content === 'object' ) {
@@ -61,8 +59,7 @@ const TypeIcon = ({ type }) => {
6159 } [ type ] ;
6260} ;
6361
64- const WSMessageItem = ( { message, inFocus } ) => {
65- const [ isOpen , setIsOpen ] = useState ( false ) ;
62+ const WSMessageItem = memo ( ( { message, isOpen, onToggle } ) => {
6663 const [ showHex , setShowHex ] = useState ( false ) ;
6764 const preferences = useSelector ( ( state ) => state . app . preferences ) ;
6865 const { displayedTheme } = useTheme ( ) ;
@@ -82,21 +79,23 @@ const WSMessageItem = ({ message, inFocus }) => {
8279 const dateDiff = Date . now ( ) - new Date ( message . timestamp ) . getTime ( ) ;
8380 if ( dateDiff < 1000 * 10 ) {
8481 setIsNew ( true ) ;
85- setTimeout ( ( ) => {
82+ const timer = setTimeout ( ( ) => {
8683 notified . current = true ;
8784 setIsNew ( false ) ;
8885 } , 2500 ) ;
86+ return ( ) => clearTimeout ( timer ) ;
8987 }
90- } , [ message ] ) ;
88+ } , [ message . timestamp ] ) ;
9189
9290 const canOpenMessage = ! isInfo && ! isError ;
9391
92+ const handleToggle = ( ) => {
93+ if ( ! canOpenMessage ) return ;
94+ onToggle ?. ( message . timestamp ) ;
95+ } ;
96+
9497 return (
9598 < div
96- ref = { ( node ) => {
97- if ( ! node ) return ;
98- if ( inFocus ) node . scrollIntoView ( ) ;
99- } }
10099 className = { classnames ( 'ws-message flex flex-col p-2' , {
101100 'ws-incoming' : isIncoming ,
102101 'ws-outgoing' : isOutgoing ,
@@ -111,10 +110,7 @@ const WSMessageItem = ({ message, inFocus }) => {
111110 'cursor-pointer' : canOpenMessage ,
112111 'cursor-not-allowed' : ! canOpenMessage
113112 } ) }
114- onClick = { ( e ) => {
115- if ( ! canOpenMessage ) return ;
116- setIsOpen ( ! isOpen ) ;
117- } }
113+ onClick = { handleToggle }
118114 >
119115 < div className = "flex min-w-0 shrink" >
120116 < span className = "message-type-icon" >
@@ -176,23 +172,87 @@ const WSMessageItem = ({ message, inFocus }) => {
176172 ) }
177173 </ div >
178174 ) ;
179- } ;
175+ } ) ;
176+
177+ const WSMessagesList = ( { messages = [ ] } ) => {
178+ const virtuosoRef = useRef ( null ) ;
179+ const [ scrollerElement , setScrollerElement ] = useState ( null ) ;
180+ const [ openMessages , setOpenMessages ] = useState ( new Set ( ) ) ;
181+ const userScrolledAwayRef = useRef ( false ) ;
182+
183+ // Toggle message open/closed state by timestamp
184+ const handleMessageToggle = useCallback ( ( timestamp ) => {
185+ setOpenMessages ( ( prev ) => {
186+ const next = new Set ( prev ) ;
187+ if ( next . has ( timestamp ) ) {
188+ next . delete ( timestamp ) ;
189+ } else {
190+ next . add ( timestamp ) ;
191+ }
192+ return next ;
193+ } ) ;
194+ } , [ ] ) ;
195+
196+ useEffect ( ( ) => {
197+ if ( ! scrollerElement ) return ;
198+
199+ const handleWheel = ( e ) => {
200+ // deltaY < 0 means scrolling up
201+ if ( e . deltaY < 0 ) {
202+ userScrolledAwayRef . current = true ;
203+ }
204+ } ;
205+
206+ scrollerElement . addEventListener ( 'wheel' , handleWheel , { passive : true } ) ;
207+
208+ return ( ) => {
209+ scrollerElement . removeEventListener ( 'wheel' , handleWheel ) ;
210+ } ;
211+ } , [ scrollerElement ] ) ;
212+
213+ const handleAtBottomStateChange = useCallback ( ( atBottom ) => {
214+ if ( atBottom ) {
215+ // User scrolled back to bottom, re-enable auto-scroll
216+ userScrolledAwayRef . current = false ;
217+ }
218+ } , [ ] ) ;
219+
220+ const followOutput = useCallback ( ( isAtBottom ) => {
221+ // Don't auto-scroll if user has scrolled away or has messages open
222+ if ( userScrolledAwayRef . current || openMessages . size > 0 ) {
223+ return false ;
224+ }
225+ if ( isAtBottom ) {
226+ return 'smooth' ;
227+ }
228+ return false ;
229+ } , [ openMessages . size ] ) ;
230+
231+ const renderItem = useCallback ( ( _ , msg ) => {
232+ const isOpen = openMessages . has ( msg . timestamp ) ;
233+ return < WSMessageItem message = { msg } isOpen = { isOpen } onToggle = { handleMessageToggle } /> ;
234+ } , [ openMessages , handleMessageToggle ] ) ;
235+
236+ const computeItemKey = useCallback ( ( _ , msg ) => {
237+ return msg . seq ?? msg . timestamp ;
238+ } , [ ] ) ;
180239
181- const WSMessagesList = ( { order = - 1 , messages = [ ] } ) => {
182240 if ( ! messages . length ) {
183241 return < StyledWrapper > < div className = "empty-state" > No messages yet.</ div > </ StyledWrapper > ;
184242 }
185243
186- // sort based on order, seq was newly added and might be missing in some cases and when missing,
187- // the timestamp will be used instead
188- const ordered = messages . toSorted ( ( x , y ) => ( ( x . seq ?? x . timestamp ) - ( y . seq ?? y . timestamp ) ) * ( - order ) ) ;
189-
190244 return (
191245 < StyledWrapper className = "ws-messages-list flex flex-col" >
192- { ordered . map ( ( msg , idx , src ) => {
193- const inFocus = order === - 1 ? src . length - 1 === idx : idx === 0 ;
194- return < WSMessageItem key = { msg . seq ? msg . seq : msg . timestamp } inFocus = { inFocus } id = { idx } message = { msg } /> ;
195- } ) }
246+ < Virtuoso
247+ ref = { virtuosoRef }
248+ scrollerRef = { setScrollerElement }
249+ data = { messages }
250+ itemContent = { renderItem }
251+ computeItemKey = { computeItemKey }
252+ followOutput = { followOutput }
253+ initialTopMostItemIndex = { messages . length - 1 }
254+ atBottomStateChange = { handleAtBottomStateChange }
255+ />
196256 </ StyledWrapper >
197257 ) ;
198258} ;
0 commit comments