@@ -10,11 +10,14 @@ import {
1010} from "@posthog/quill" ;
1111import type { AcpMessage } from "@posthog/shared" ;
1212import type { Task } from "@posthog/shared/domain-types" ;
13+ import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts" ;
1314import type {
1415 ConversationItem ,
1516 TurnContext ,
1617} from "@posthog/ui/features/sessions/components/buildConversationItems" ;
1718import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar" ;
19+ import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker" ;
20+ import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/chat-thread/threadHotkeys" ;
1821import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage" ;
1922import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult" ;
2023import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems" ;
@@ -60,6 +63,7 @@ import {
6063} from "@posthog/ui/shell/diffWorkerHost" ;
6164import { Box , Flex , Text } from "@radix-ui/themes" ;
6265import { memo , useCallback , useEffect , useMemo , useRef , useState } from "react" ;
66+ import { useHotkeys } from "react-hotkeys-hook" ;
6367
6468export interface ConversationViewProps {
6569 events : AcpMessage [ ] ;
@@ -220,6 +224,90 @@ export function ConversationView({
220224 listRef : searchListRef ,
221225 } ) ;
222226
227+ const [ jumpPickerOpen , setJumpPickerOpen ] = useState ( false ) ;
228+ const [ keyboardFocusedMessageId , setKeyboardFocusedMessageId ] = useState <
229+ string | null
230+ > ( null ) ;
231+
232+ const userMessages = useMemo ( ( ) => {
233+ const result : Array < { id : string ; index : number } > = [ ] ;
234+ for ( let i = 0 ; i < items . length ; i ++ ) {
235+ const item = items [ i ] ;
236+ if ( item . type === "user_message" ) {
237+ result . push ( { id : item . id , index : i } ) ;
238+ }
239+ }
240+ return result ;
241+ } , [ items ] ) ;
242+
243+ // Grouped rows != items, so scroll by the row the message landed in (same
244+ // mapping search uses), falling back to the raw item index.
245+ const scrollToUserMessage = useCallback ( ( id : string , itemIndex : number ) => {
246+ const rowIndex = itemIdToRowIndexRef . current . get ( id ) ?? itemIndex ;
247+ listRef . current ?. scrollToIndex ( rowIndex ) ;
248+ } , [ ] ) ;
249+
250+ const handleNavigateMessage = useCallback (
251+ ( direction : - 1 | 1 ) => {
252+ if ( userMessages . length === 0 ) return ;
253+
254+ const currentIndex = keyboardFocusedMessageId
255+ ? userMessages . findIndex (
256+ ( message ) => message . id === keyboardFocusedMessageId ,
257+ )
258+ : - 1 ;
259+
260+ const nextIndex =
261+ currentIndex === - 1
262+ ? direction > 0
263+ ? 0
264+ : userMessages . length - 1
265+ : Math . max (
266+ 0 ,
267+ Math . min ( userMessages . length - 1 , currentIndex + direction ) ,
268+ ) ;
269+
270+ const nextMessage = userMessages [ nextIndex ] ;
271+ if ( ! nextMessage ) return ;
272+
273+ setKeyboardFocusedMessageId ( nextMessage . id ) ;
274+ scrollToUserMessage ( nextMessage . id , nextMessage . index ) ;
275+ } ,
276+ [ keyboardFocusedMessageId , userMessages , scrollToUserMessage ] ,
277+ ) ;
278+
279+ useHotkeys (
280+ SHORTCUTS . MESSAGE_JUMP ,
281+ ( ) => setJumpPickerOpen ( ( prev ) => ! prev ) ,
282+ THREAD_HOTKEY_OPTIONS ,
283+ ) ;
284+
285+ useHotkeys (
286+ SHORTCUTS . MESSAGE_PREV ,
287+ ( ) => handleNavigateMessage ( - 1 ) ,
288+ THREAD_HOTKEY_OPTIONS ,
289+ ) ;
290+
291+ useHotkeys (
292+ SHORTCUTS . MESSAGE_NEXT ,
293+ ( ) => handleNavigateMessage ( 1 ) ,
294+ THREAD_HOTKEY_OPTIONS ,
295+ ) ;
296+
297+ const clearKeyboardFocus = useCallback ( ( ) => {
298+ setKeyboardFocusedMessageId ( null ) ;
299+ } , [ ] ) ;
300+
301+ const handleJumpToMessage = useCallback (
302+ ( id : string ) => {
303+ const message = userMessages . find ( ( entry ) => entry . id === id ) ;
304+ if ( ! message ) return ;
305+ setKeyboardFocusedMessageId ( id ) ;
306+ scrollToUserMessage ( id , message . index ) ;
307+ } ,
308+ [ userMessages , scrollToUserMessage ] ,
309+ ) ;
310+
223311 const handleScrollStateChange = useCallback ( ( isAtBottom : boolean ) => {
224312 isAtBottomRef . current = isAtBottom ;
225313 setShowScrollButton ( ! isAtBottom ) ;
@@ -254,6 +342,7 @@ export function ConversationView({
254342 timestamp = { item . timestamp }
255343 animate = { ! initialItemIds . has ( item . id ) }
256344 taskId = { taskId }
345+ keyboardFocused = { item . id === keyboardFocusedMessageId }
257346 sourceUrl = {
258347 slackThreadUrl && item . id === firstUserMessageId
259348 ? slackThreadUrl
@@ -287,7 +376,14 @@ export function ConversationView({
287376 return < UserShellExecuteView item = { item } /> ;
288377 }
289378 } ,
290- [ repoPath , taskId , slackThreadUrl , firstUserMessageId , initialItemIds ] ,
379+ [
380+ repoPath ,
381+ taskId ,
382+ slackThreadUrl ,
383+ firstUserMessageId ,
384+ initialItemIds ,
385+ keyboardFocusedMessageId ,
386+ ] ,
291387 ) ;
292388
293389 const getRowKey = useCallback ( ( row : ThreadRow ) => row . id , [ ] ) ;
@@ -359,7 +455,11 @@ export function ConversationView({
359455 poolOptions = { diffsPoolOptions }
360456 highlighterOptions = { DIFFS_HIGHLIGHTER_OPTIONS }
361457 >
362- < div ref = { containerRef } className = "group/thread relative flex-1" >
458+ < div
459+ ref = { containerRef }
460+ className = "group/thread relative flex-1"
461+ onPointerDownCapture = { clearKeyboardFocus }
462+ >
363463 < div
364464 id = "fullscreen-portal"
365465 className = "pointer-events-none absolute inset-0 z-20"
@@ -377,6 +477,13 @@ export function ConversationView({
377477 />
378478 ) }
379479
480+ < MessageJumpPicker
481+ open = { jumpPickerOpen }
482+ onOpenChange = { setJumpPickerOpen }
483+ items = { items }
484+ onJumpToMessage = { handleJumpToMessage }
485+ />
486+
380487 < SessionTaskIdProvider taskId = { taskId } >
381488 < VirtualizedList < ThreadRow >
382489 ref = { listRef }
0 commit comments