Skip to content

Commit e7d8d13

Browse files
pauldambraposthog[bot]claude
authored
Add user-message marker rail to conversation scrollbar (#3631)
Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com> Co-authored-by: pauldambra <984817+pauldambra@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5599f91 commit e7d8d13

9 files changed

Lines changed: 931 additions & 24 deletions

File tree

apps/code/snapshots.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,14 @@ snapshots:
332332
hash: v1.k4693efd2.602ee87d6d7a745d4b09d9e49bd12a70e52299b78ca97a48b5ca73a01532d801.F_1XKDzCxzMm_opEt9FvzczBb_2n-vyvOyGFXHGJmTk
333333
features-sessions-conversationview--with-pending-prompt--light:
334334
hash: v1.k4693efd2.1636dfc3ee0dd9d28065166f2fff0193f2d499692c74749d3afa7585aae898b5.YxTPa4CsqI2YuGLUdck8340ITZQrpna_C6z-SgII4Ws
335+
features-sessions-scrollbarrail--pure--dark:
336+
hash: v1.k4693efd2.6baec21b1942848a3a376d2b90ca106b2c1b94de74382b4cd05673695d2649ee.UDDPtLDf1J2E3d3Oiy9is77Nm5xRSIfB7q5z0uUZRSQ
337+
features-sessions-scrollbarrail--pure--light:
338+
hash: v1.k4693efd2.33f02943fdbb1ea9774881f8909b580ab03ba02749614c533af1bf51dd817dca.ieP2bhDz0U_PTB4M6Zplvit0XlSTFiUn083GYBFNuC0
339+
features-sessions-scrollbarrail--scrollable-conversation--dark:
340+
hash: v1.k4693efd2.203b62b52d6365d6ec222f7529c35b3f46b17cd07ca792a8ce2de594af4d589b.3dfpnVM8yxor1PNO5aPk0pxn5THZjK4otA6mbz3XhtA
341+
features-sessions-scrollbarrail--scrollable-conversation--light:
342+
hash: v1.k4693efd2.fa7ef9cbdb09aef6108efcd3606cee32d889b3ad3b8f8be1ac637aed4e80cc72.0XcIKzM-sZHVG9hTZkAHdnBIPSrBiKmjq56QRQ6nasc
335343
features-sessions-toolcallblock--create-new-file--dark:
336344
hash: v1.k4693efd2.8847f96037682460aba47f0e0f5113037918235fc98596933f2d1d06fd6f8f92.SHetexoVOtozsZGZQMcVbmpBlbk7M5D5Q0R_TJvPnyc
337345
features-sessions-toolcallblock--create-new-file--light:

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

Lines changed: 133 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import type { CollapseMode } from "@posthog/ui/features/sessions/components/new-
3434
import { createIncrementalThreadGrouper } from "@posthog/ui/features/sessions/components/new-thread/incrementalThreadGrouping";
3535
import { ToolCallGroupChip } from "@posthog/ui/features/sessions/components/new-thread/ToolCallGroupChip";
3636
import { 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";
3739
import {
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";
7981
import { 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+
109162
export 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

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ interface VirtualizedListProps<T> {
3535
export interface VirtualizedListHandle {
3636
scrollToBottom: () => void;
3737
scrollToIndex: (index: number) => void;
38+
/** The scrolling element (the `overflow-y-auto` viewport). Exposed so a
39+
* scrollbar marker rail can read `scrollTop`/`scrollHeight` and refresh on
40+
* scroll. `null` until the list mounts. */
41+
getScrollElement: () => HTMLDivElement | null;
42+
/** The inner content element whose height == the virtual total size. Rows
43+
* (and their `data-conversation-item-id` stamps) live inside this, so a
44+
* marker rail measures row offsets against it. `null` until the list mounts. */
45+
getContentElement: () => HTMLDivElement | null;
3846
}
3947

4048
const AT_BOTTOM_THRESHOLD = 50;
@@ -63,6 +71,7 @@ function VirtualizedListInner<T>(
6371
ref: React.ForwardedRef<VirtualizedListHandle>,
6472
) {
6573
const parentRef = useRef<HTMLDivElement>(null);
74+
const contentRef = useRef<HTMLDivElement>(null);
6675
const footerRef = useRef<HTMLDivElement>(null);
6776
const initializedRef = useRef(false);
6877
const isAtBottomRef = useRef(true);
@@ -156,6 +165,8 @@ function VirtualizedListInner<T>(
156165
isAtBottomRef.current = false;
157166
virtualizer.scrollToIndex(index, { align: "center" });
158167
},
168+
getScrollElement: () => parentRef.current,
169+
getContentElement: () => contentRef.current,
159170
}),
160171
[virtualizer, settleAtEnd],
161172
);
@@ -258,6 +269,7 @@ function VirtualizedListInner<T>(
258269
style={{ scrollbarGutter: "stable" }}
259270
>
260271
<div
272+
ref={contentRef}
261273
style={{
262274
height: totalSize,
263275
position: "relative",

0 commit comments

Comments
 (0)