Skip to content

Commit d942c02

Browse files
feat(sessions): keyboard navigation and jump-to-message picker (#3407)
Co-authored-by: Basit Balogun <basitbalogun10@gmail.com>
1 parent e024cb4 commit d942c02

6 files changed

Lines changed: 819 additions & 7 deletions

File tree

packages/ui/src/features/command/keyboard-shortcuts.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export const SHORTCUTS = {
2727
SPACE_UP: "mod+up",
2828
SPACE_DOWN: "mod+down",
2929
FIND_IN_CONVERSATION: "mod+f",
30+
MESSAGE_PREV: "alt+up",
31+
MESSAGE_NEXT: "alt+down",
32+
MESSAGE_JUMP: "mod+j",
3033
BLUR: "escape",
3134
SUBMIT_BLUR: "mod+enter",
3235
SWITCH_MESSAGING_MODE: "mod+s",
@@ -203,6 +206,27 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
203206
category: "panels",
204207
context: "Task detail",
205208
},
209+
{
210+
id: "message-prev",
211+
keys: SHORTCUTS.MESSAGE_PREV,
212+
description: "Previous message",
213+
category: "panels",
214+
context: "Task detail",
215+
},
216+
{
217+
id: "message-next",
218+
keys: SHORTCUTS.MESSAGE_NEXT,
219+
description: "Next message",
220+
category: "panels",
221+
context: "Task detail",
222+
},
223+
{
224+
id: "message-jump",
225+
keys: SHORTCUTS.MESSAGE_JUMP,
226+
description: "Jump to message",
227+
category: "panels",
228+
context: "Task detail",
229+
},
206230
{
207231
id: "paste-as-file",
208232
keys: SHORTCUTS.PASTE_AS_FILE,

packages/ui/src/features/sessions/components/ConversationView.tsx

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import {
1010
} from "@posthog/quill";
1111
import type { AcpMessage } from "@posthog/shared";
1212
import type { Task } from "@posthog/shared/domain-types";
13+
import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts";
1314
import type {
1415
ConversationItem,
1516
TurnContext,
1617
} from "@posthog/ui/features/sessions/components/buildConversationItems";
1718
import { 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";
1821
import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage";
1922
import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult";
2023
import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems";
@@ -60,6 +63,7 @@ import {
6063
} from "@posthog/ui/shell/diffWorkerHost";
6164
import { Box, Flex, Text } from "@radix-ui/themes";
6265
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
66+
import { useHotkeys } from "react-hotkeys-hook";
6367

6468
export 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

Comments
 (0)