From 3705f9bafc2e185fb86b482573dca7a830d9d84f Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Mon, 13 Jul 2026 16:02:05 -0700 Subject: [PATCH] add message keyboard nav and jump picker --- .../features/command/keyboard-shortcuts.ts | 24 + .../sessions/components/ConversationView.tsx | 111 +++- .../components/chat-thread/ChatThread.tsx | 160 +++++- .../chat-thread/MessageJumpPicker.tsx | 510 ++++++++++++++++++ .../components/chat-thread/threadHotkeys.ts | 17 + .../components/session-update/UserMessage.tsx | 4 +- 6 files changed, 819 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/chat-thread/MessageJumpPicker.tsx create mode 100644 packages/ui/src/features/sessions/components/chat-thread/threadHotkeys.ts diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index a384557310..967c454e2d 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -27,6 +27,9 @@ export const SHORTCUTS = { SPACE_UP: "mod+up", SPACE_DOWN: "mod+down", FIND_IN_CONVERSATION: "mod+f", + MESSAGE_PREV: "alt+up", + MESSAGE_NEXT: "alt+down", + MESSAGE_JUMP: "mod+j", BLUR: "escape", SUBMIT_BLUR: "mod+enter", SWITCH_MESSAGING_MODE: "mod+s", @@ -203,6 +206,27 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ category: "panels", context: "Task detail", }, + { + id: "message-prev", + keys: SHORTCUTS.MESSAGE_PREV, + description: "Previous message", + category: "panels", + context: "Task detail", + }, + { + id: "message-next", + keys: SHORTCUTS.MESSAGE_NEXT, + description: "Next message", + category: "panels", + context: "Task detail", + }, + { + id: "message-jump", + keys: SHORTCUTS.MESSAGE_JUMP, + description: "Jump to message", + category: "panels", + context: "Task detail", + }, { id: "paste-as-file", keys: SHORTCUTS.PASTE_AS_FILE, diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 59a446014f..c3f39d0f9c 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -10,11 +10,14 @@ import { } from "@posthog/quill"; import type { AcpMessage } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; import type { ConversationItem, TurnContext, } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar"; +import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker"; +import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/chat-thread/threadHotkeys"; import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems"; @@ -60,6 +63,7 @@ import { } from "@posthog/ui/shell/diffWorkerHost"; import { Box, Flex, Text } from "@radix-ui/themes"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; export interface ConversationViewProps { events: AcpMessage[]; @@ -220,6 +224,90 @@ export function ConversationView({ listRef: searchListRef, }); + const [jumpPickerOpen, setJumpPickerOpen] = useState(false); + const [keyboardFocusedMessageId, setKeyboardFocusedMessageId] = useState< + string | null + >(null); + + const userMessages = useMemo(() => { + const result: Array<{ id: string; index: number }> = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type === "user_message") { + result.push({ id: item.id, index: i }); + } + } + return result; + }, [items]); + + // Grouped rows != items, so scroll by the row the message landed in (same + // mapping search uses), falling back to the raw item index. + const scrollToUserMessage = useCallback((id: string, itemIndex: number) => { + const rowIndex = itemIdToRowIndexRef.current.get(id) ?? itemIndex; + listRef.current?.scrollToIndex(rowIndex); + }, []); + + const handleNavigateMessage = useCallback( + (direction: -1 | 1) => { + if (userMessages.length === 0) return; + + const currentIndex = keyboardFocusedMessageId + ? userMessages.findIndex( + (message) => message.id === keyboardFocusedMessageId, + ) + : -1; + + const nextIndex = + currentIndex === -1 + ? direction > 0 + ? 0 + : userMessages.length - 1 + : Math.max( + 0, + Math.min(userMessages.length - 1, currentIndex + direction), + ); + + const nextMessage = userMessages[nextIndex]; + if (!nextMessage) return; + + setKeyboardFocusedMessageId(nextMessage.id); + scrollToUserMessage(nextMessage.id, nextMessage.index); + }, + [keyboardFocusedMessageId, userMessages, scrollToUserMessage], + ); + + useHotkeys( + SHORTCUTS.MESSAGE_JUMP, + () => setJumpPickerOpen((prev) => !prev), + THREAD_HOTKEY_OPTIONS, + ); + + useHotkeys( + SHORTCUTS.MESSAGE_PREV, + () => handleNavigateMessage(-1), + THREAD_HOTKEY_OPTIONS, + ); + + useHotkeys( + SHORTCUTS.MESSAGE_NEXT, + () => handleNavigateMessage(1), + THREAD_HOTKEY_OPTIONS, + ); + + const clearKeyboardFocus = useCallback(() => { + setKeyboardFocusedMessageId(null); + }, []); + + const handleJumpToMessage = useCallback( + (id: string) => { + const message = userMessages.find((entry) => entry.id === id); + if (!message) return; + setKeyboardFocusedMessageId(id); + scrollToUserMessage(id, message.index); + }, + [userMessages, scrollToUserMessage], + ); + const handleScrollStateChange = useCallback((isAtBottom: boolean) => { isAtBottomRef.current = isAtBottom; setShowScrollButton(!isAtBottom); @@ -254,6 +342,7 @@ export function ConversationView({ timestamp={item.timestamp} animate={!initialItemIds.has(item.id)} taskId={taskId} + keyboardFocused={item.id === keyboardFocusedMessageId} sourceUrl={ slackThreadUrl && item.id === firstUserMessageId ? slackThreadUrl @@ -287,7 +376,14 @@ export function ConversationView({ return ; } }, - [repoPath, taskId, slackThreadUrl, firstUserMessageId, initialItemIds], + [ + repoPath, + taskId, + slackThreadUrl, + firstUserMessageId, + initialItemIds, + keyboardFocusedMessageId, + ], ); const getRowKey = useCallback((row: ThreadRow) => row.id, []); @@ -359,7 +455,11 @@ export function ConversationView({ poolOptions={diffsPoolOptions} highlighterOptions={DIFFS_HIGHLIGHTER_OPTIONS} > -
+
)} + + ref={listRef} diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index ef56e04229..6e82d78d3d 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -30,6 +30,7 @@ import { useChatMessageScrollerVisibility, } from "@posthog/quill"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; import { useSmoothedText } from "@posthog/ui/features/editor/components/useSmoothedText"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore"; @@ -40,10 +41,12 @@ import { } from "@posthog/ui/features/sessions/components/chat-thread/ChatMarkdown"; import { ChatThreadFooter } from "@posthog/ui/features/sessions/components/chat-thread/ChatThreadFooter"; import { ChatThreadChromeProvider } from "@posthog/ui/features/sessions/components/chat-thread/chatThreadChrome"; +import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker"; import { ToolGroup, type ToolGroupItem, } from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup"; +import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/chat-thread/threadHotkeys"; import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems"; @@ -88,6 +91,8 @@ import { useRef, useState, } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + import type { ConversationViewProps } from "../ConversationView"; /** A row is either a parsed conversation item or a synthesized group of tool calls. */ @@ -261,10 +266,12 @@ function UserBubble({ content, timestamp, attachments = [], + keyboardFocused = false, }: { content: string; timestamp?: number; attachments?: UserMessageAttachment[]; + keyboardFocused?: boolean; }) { const bluebirdEnabled = useFeatureFlag( PROJECT_BLUEBIRD_FLAG, @@ -363,7 +370,14 @@ function UserBubble({ )} )} - +
ReactNode; isTrailing?: boolean; + keyboardFocused?: boolean; }) { if (item.type === "tool_group") { const context = item.tools[0]?.turnContext; @@ -638,6 +654,7 @@ function ThreadItemBody({ content={item.content} timestamp={item.timestamp} attachments={item.attachments} + keyboardFocused={keyboardFocused} /> ); } @@ -669,9 +686,11 @@ function completedTurnTimestamp(turn: AgentTurn): number | undefined { const ThreadRow = memo(function ThreadRow({ item, renderItem, + keyboardFocused, }: { item: TurnRow; renderItem: (item: ConversationItem) => ReactNode; + keyboardFocused?: boolean; }) { if (item.type === "agent_turn") { return ( @@ -711,7 +730,11 @@ const ThreadRow = memo(function ThreadRow({ className="mx-auto w-full px-2.5 py-1 empty:hidden" style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }} > - + ); }); @@ -782,18 +805,122 @@ function ThreadAutoFollow({ items }: { items: ConversationItem[] }) { return