@@ -34,6 +34,8 @@ import type { CollapseMode } from "@posthog/ui/features/sessions/components/new-
3434import { createIncrementalThreadGrouper } from "@posthog/ui/features/sessions/components/new-thread/incrementalThreadGrouping" ;
3535import { ToolCallGroupChip } from "@posthog/ui/features/sessions/components/new-thread/ToolCallGroupChip" ;
3636import { SessionFooter } from "@posthog/ui/features/sessions/components/SessionFooter" ;
37+ import { MessageScrollbarRail } from "@posthog/ui/features/sessions/components/scrollbar-rail/MessageScrollbarRail" ;
38+ import { useMessageRailMarkers } from "@posthog/ui/features/sessions/components/scrollbar-rail/useMessageRailMarkers" ;
3739import {
3840 type RenderItem ,
3941 SessionUpdateView ,
@@ -73,8 +75,8 @@ import {
7375 useCallback ,
7476 useEffect ,
7577 useMemo ,
78+ useReducer ,
7679 useRef ,
77- useState ,
7880} from "react" ;
7981import { useHotkeys } from "react-hotkeys-hook" ;
8082
@@ -106,6 +108,57 @@ export interface ConversationViewProps {
106108 promptRecallRef ?: RefObject < PromptRecallHandler | null > ;
107109}
108110
111+ interface ConversationViewState {
112+ showScrollButton : boolean ;
113+ jumpPickerOpen : boolean ;
114+ keyboardFocusedMessageId : string | null ;
115+ scrollElements : {
116+ scrollEl : HTMLElement | null ;
117+ contentEl : HTMLElement | null ;
118+ } ;
119+ }
120+
121+ type ConversationViewAction =
122+ | { type : "set-show-scroll-button" ; value : boolean }
123+ | { type : "set-jump-picker-open" ; value : boolean }
124+ | { type : "set-keyboard-focused-message" ; value : string | null }
125+ | {
126+ type : "set-scroll-elements" ;
127+ value : ConversationViewState [ "scrollElements" ] ;
128+ } ;
129+
130+ const INITIAL_CONVERSATION_VIEW_STATE : ConversationViewState = {
131+ showScrollButton : false ,
132+ jumpPickerOpen : false ,
133+ keyboardFocusedMessageId : null ,
134+ scrollElements : { scrollEl : null , contentEl : null } ,
135+ } ;
136+
137+ function conversationViewReducer (
138+ state : ConversationViewState ,
139+ action : ConversationViewAction ,
140+ ) : ConversationViewState {
141+ switch ( action . type ) {
142+ case "set-show-scroll-button" :
143+ return state . showScrollButton === action . value
144+ ? state
145+ : { ...state , showScrollButton : action . value } ;
146+ case "set-jump-picker-open" :
147+ return state . jumpPickerOpen === action . value
148+ ? state
149+ : { ...state , jumpPickerOpen : action . value } ;
150+ case "set-keyboard-focused-message" :
151+ return state . keyboardFocusedMessageId === action . value
152+ ? state
153+ : { ...state , keyboardFocusedMessageId : action . value } ;
154+ case "set-scroll-elements" :
155+ return state . scrollElements . scrollEl === action . value . scrollEl &&
156+ state . scrollElements . contentEl === action . value . contentEl
157+ ? state
158+ : { ...state , scrollElements : action . value } ;
159+ }
160+ }
161+
109162export function ConversationView ( {
110163 events,
111164 isPromptPending,
@@ -134,7 +187,10 @@ export function ConversationView({
134187
135188 const listRef = useRef < VirtualizedListHandle > ( null ) ;
136189 const isAtBottomRef = useRef ( true ) ;
137- const [ showScrollButton , setShowScrollButton ] = useState ( false ) ;
190+ const [ viewState , dispatchViewState ] = useReducer (
191+ conversationViewReducer ,
192+ INITIAL_CONVERSATION_VIEW_STATE ,
193+ ) ;
138194 const debugLogsCloudRuns = useSettingsStore ( ( s ) => s . debugLogsCloudRuns ) ;
139195 const showDebugLogs = debugLogsCloudRuns ;
140196
@@ -169,14 +225,16 @@ export function ConversationView({
169225 }
170226 const firstUserMessageId = firstUserMessageIdRef . current ;
171227
172- const [ initialItemIds ] = useState (
173- ( ) =>
174- new Set (
175- conversationItems
176- . filter ( ( i ) => i . type === "user_message" )
177- . map ( ( i ) => i . id ) ,
178- ) ,
179- ) ;
228+ const initialItemIdsRef = useRef < Set < string > | null > ( null ) ;
229+ if ( initialItemIdsRef . current === null ) {
230+ initialItemIdsRef . current = new Set < string > ( ) ;
231+ for ( const item of conversationItems ) {
232+ if ( item . type === "user_message" ) {
233+ initialItemIdsRef . current . add ( item . id ) ;
234+ }
235+ }
236+ }
237+ const initialItemIds = initialItemIdsRef . current ;
180238
181239 const pendingPermissions = usePendingPermissionsForTask ( taskId ?? "" ) ;
182240 const pendingPermissionsCount = pendingPermissions . size ;
@@ -235,6 +293,10 @@ export function ConversationView({
235293 id != null ? itemIdToRowIndexRef . current . get ( id ) : undefined ;
236294 listRef . current ?. scrollToIndex ( rowIdx ?? index ) ;
237295 } ,
296+ // Search doesn't read scroll/content geometry, so these forward to the real
297+ // handle purely to satisfy the (now-extended) VirtualizedListHandle shape.
298+ getScrollElement : ( ) => listRef . current ?. getScrollElement ( ) ?? null ,
299+ getContentElement : ( ) => listRef . current ?. getContentElement ( ) ?? null ,
238300 } ) ;
239301
240302 const search = useConversationSearch ( {
@@ -243,10 +305,8 @@ export function ConversationView({
243305 listRef : searchListRef ,
244306 } ) ;
245307
246- const [ jumpPickerOpen , setJumpPickerOpen ] = useState ( false ) ;
247- const [ keyboardFocusedMessageId , setKeyboardFocusedMessageId ] = useState <
248- string | null
249- > ( null ) ;
308+ const { jumpPickerOpen, keyboardFocusedMessageId, scrollElements } =
309+ viewState ;
250310
251311 const userMessages = useMemo ( ( ) => {
252312 const result : Array < { id : string ; index : number ; content : string } > = [ ] ;
@@ -292,15 +352,22 @@ export function ConversationView({
292352 if ( ! nextMessage ) return ;
293353
294354 useSettingsStore . getState ( ) . markHintLearned ( PROMPT_RECALL_HINT_KEY ) ;
295- setKeyboardFocusedMessageId ( nextMessage . id ) ;
355+ dispatchViewState ( {
356+ type : "set-keyboard-focused-message" ,
357+ value : nextMessage . id ,
358+ } ) ;
296359 scrollToUserMessage ( nextMessage . id , nextMessage . index ) ;
297360 } ,
298361 [ keyboardFocusedMessageId , userMessages , scrollToUserMessage ] ,
299362 ) ;
300363
301364 useHotkeys (
302365 SHORTCUTS . MESSAGE_JUMP ,
303- ( ) => setJumpPickerOpen ( ( prev ) => ! prev ) ,
366+ ( ) =>
367+ dispatchViewState ( {
368+ type : "set-jump-picker-open" ,
369+ value : ! jumpPickerOpen ,
370+ } ) ,
304371 THREAD_HOTKEY_OPTIONS ,
305372 ) ;
306373
@@ -317,34 +384,73 @@ export function ConversationView({
317384 ) ;
318385
319386 const clearKeyboardFocus = useCallback ( ( ) => {
320- setKeyboardFocusedMessageId ( null ) ;
387+ dispatchViewState ( { type : "set-keyboard-focused-message" , value : null } ) ;
321388 } , [ ] ) ;
322389
323390 const handleJumpToMessage = useCallback (
324391 ( id : string ) => {
325392 const message = userMessages . find ( ( entry ) => entry . id === id ) ;
326393 if ( ! message ) return ;
327- setKeyboardFocusedMessageId ( id ) ;
394+ dispatchViewState ( { type : "set-keyboard-focused-message" , value : id } ) ;
328395 scrollToUserMessage ( id , message . index ) ;
329396 } ,
330397 [ userMessages , scrollToUserMessage ] ,
331398 ) ;
332399
400+ // The scrollbar marker rail needs the VirtualizedList's scroll + content
401+ // elements. Resolve them from the list ref callback so the rail renders as
402+ // soon as the imperative handle is attached, without mount-time state setup.
403+ const setListRef = useCallback ( ( handle : VirtualizedListHandle | null ) => {
404+ listRef . current = handle ;
405+ dispatchViewState ( {
406+ type : "set-scroll-elements" ,
407+ value : {
408+ scrollEl : handle ?. getScrollElement ( ) ?? null ,
409+ contentEl : handle ?. getContentElement ( ) ?? null ,
410+ } ,
411+ } ) ;
412+ } , [ ] ) ;
413+
414+ const railUserMessages = useMemo (
415+ ( ) =>
416+ userMessages . map ( ( m ) => ( {
417+ id : m . id ,
418+ content : m . content ,
419+ index : m . index ,
420+ } ) ) ,
421+ [ userMessages ] ,
422+ ) ;
423+ const railMarkers = useMessageRailMarkers ( {
424+ contentEl : scrollElements . contentEl ,
425+ scrollEl : scrollElements . scrollEl ,
426+ userMessages : railUserMessages ,
427+ onJump : ( id ) => {
428+ const message = userMessages . find ( ( entry ) => entry . id === id ) ;
429+ if ( ! message ) return ;
430+ dispatchViewState ( { type : "set-keyboard-focused-message" , value : id } ) ;
431+ scrollToUserMessage ( id , message . index ) ;
432+ } ,
433+ activeId : keyboardFocusedMessageId ,
434+ } ) ;
435+
333436 const handleScrollStateChange = useCallback ( ( isAtBottom : boolean ) => {
334437 isAtBottomRef . current = isAtBottom ;
335- setShowScrollButton ( ! isAtBottom ) ;
438+ dispatchViewState ( {
439+ type : "set-show-scroll-button" ,
440+ value : ! isAtBottom ,
441+ } ) ;
336442 } , [ ] ) ;
337443
338444 const scrollToBottom = useCallback ( ( ) => {
339445 listRef . current ?. scrollToBottom ( ) ;
340- setShowScrollButton ( false ) ;
446+ dispatchViewState ( { type : "set-show-scroll-button" , value : false } ) ;
341447 } , [ ] ) ;
342448
343449 useEffect ( ( ) => {
344450 const handleVisibilityChange = ( ) => {
345451 if ( ! document . hidden && isAtBottomRef . current ) {
346452 listRef . current ?. scrollToBottom ( ) ;
347- setShowScrollButton ( false ) ;
453+ dispatchViewState ( { type : "set-show-scroll-button" , value : false } ) ;
348454 }
349455 } ;
350456
@@ -501,14 +607,16 @@ export function ConversationView({
501607
502608 < MessageJumpPicker
503609 open = { jumpPickerOpen }
504- onOpenChange = { setJumpPickerOpen }
610+ onOpenChange = { ( open ) =>
611+ dispatchViewState ( { type : "set-jump-picker-open" , value : open } )
612+ }
505613 items = { items }
506614 onJumpToMessage = { handleJumpToMessage }
507615 />
508616
509617 < SessionTaskIdProvider taskId = { taskId } >
510618 < VirtualizedList < ThreadRow >
511- ref = { listRef }
619+ ref = { setListRef }
512620 items = { threadRows }
513621 getItemKey = { getRowKey }
514622 renderItem = { renderRow }
@@ -521,7 +629,8 @@ export function ConversationView({
521629 scrollX = { scrollX }
522630 />
523631 </ SessionTaskIdProvider >
524- { showScrollButton && (
632+ < MessageScrollbarRail markers = { railMarkers } />
633+ { viewState . showScrollButton && (
525634 < Box className = "absolute right-6 bottom-4 z-10" >
526635 < Tooltip >
527636 < TooltipTrigger
0 commit comments