diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index 3147f9a2f7..40ce40c1b2 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -332,6 +332,14 @@ snapshots: hash: v1.k4693efd2.602ee87d6d7a745d4b09d9e49bd12a70e52299b78ca97a48b5ca73a01532d801.F_1XKDzCxzMm_opEt9FvzczBb_2n-vyvOyGFXHGJmTk features-sessions-conversationview--with-pending-prompt--light: hash: v1.k4693efd2.1636dfc3ee0dd9d28065166f2fff0193f2d499692c74749d3afa7585aae898b5.YxTPa4CsqI2YuGLUdck8340ITZQrpna_C6z-SgII4Ws + features-sessions-scrollbarrail--pure--dark: + hash: v1.k4693efd2.6baec21b1942848a3a376d2b90ca106b2c1b94de74382b4cd05673695d2649ee.UDDPtLDf1J2E3d3Oiy9is77Nm5xRSIfB7q5z0uUZRSQ + features-sessions-scrollbarrail--pure--light: + hash: v1.k4693efd2.33f02943fdbb1ea9774881f8909b580ab03ba02749614c533af1bf51dd817dca.ieP2bhDz0U_PTB4M6Zplvit0XlSTFiUn083GYBFNuC0 + features-sessions-scrollbarrail--scrollable-conversation--dark: + hash: v1.k4693efd2.203b62b52d6365d6ec222f7529c35b3f46b17cd07ca792a8ce2de594af4d589b.3dfpnVM8yxor1PNO5aPk0pxn5THZjK4otA6mbz3XhtA + features-sessions-scrollbarrail--scrollable-conversation--light: + hash: v1.k4693efd2.fa7ef9cbdb09aef6108efcd3606cee32d889b3ad3b8f8be1ac637aed4e80cc72.0XcIKzM-sZHVG9hTZkAHdnBIPSrBiKmjq56QRQ6nasc features-sessions-toolcallblock--create-new-file--dark: hash: v1.k4693efd2.8847f96037682460aba47f0e0f5113037918235fc98596933f2d1d06fd6f8f92.SHetexoVOtozsZGZQMcVbmpBlbk7M5D5Q0R_TJvPnyc features-sessions-toolcallblock--create-new-file--light: diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 5a049e930a..cb5dcea1a4 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -34,6 +34,8 @@ import type { CollapseMode } from "@posthog/ui/features/sessions/components/new- import { createIncrementalThreadGrouper } from "@posthog/ui/features/sessions/components/new-thread/incrementalThreadGrouping"; import { ToolCallGroupChip } from "@posthog/ui/features/sessions/components/new-thread/ToolCallGroupChip"; import { SessionFooter } from "@posthog/ui/features/sessions/components/SessionFooter"; +import { MessageScrollbarRail } from "@posthog/ui/features/sessions/components/scrollbar-rail/MessageScrollbarRail"; +import { useMessageRailMarkers } from "@posthog/ui/features/sessions/components/scrollbar-rail/useMessageRailMarkers"; import { type RenderItem, SessionUpdateView, @@ -73,8 +75,8 @@ import { useCallback, useEffect, useMemo, + useReducer, useRef, - useState, } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -106,6 +108,57 @@ export interface ConversationViewProps { promptRecallRef?: RefObject; } +interface ConversationViewState { + showScrollButton: boolean; + jumpPickerOpen: boolean; + keyboardFocusedMessageId: string | null; + scrollElements: { + scrollEl: HTMLElement | null; + contentEl: HTMLElement | null; + }; +} + +type ConversationViewAction = + | { type: "set-show-scroll-button"; value: boolean } + | { type: "set-jump-picker-open"; value: boolean } + | { type: "set-keyboard-focused-message"; value: string | null } + | { + type: "set-scroll-elements"; + value: ConversationViewState["scrollElements"]; + }; + +const INITIAL_CONVERSATION_VIEW_STATE: ConversationViewState = { + showScrollButton: false, + jumpPickerOpen: false, + keyboardFocusedMessageId: null, + scrollElements: { scrollEl: null, contentEl: null }, +}; + +function conversationViewReducer( + state: ConversationViewState, + action: ConversationViewAction, +): ConversationViewState { + switch (action.type) { + case "set-show-scroll-button": + return state.showScrollButton === action.value + ? state + : { ...state, showScrollButton: action.value }; + case "set-jump-picker-open": + return state.jumpPickerOpen === action.value + ? state + : { ...state, jumpPickerOpen: action.value }; + case "set-keyboard-focused-message": + return state.keyboardFocusedMessageId === action.value + ? state + : { ...state, keyboardFocusedMessageId: action.value }; + case "set-scroll-elements": + return state.scrollElements.scrollEl === action.value.scrollEl && + state.scrollElements.contentEl === action.value.contentEl + ? state + : { ...state, scrollElements: action.value }; + } +} + export function ConversationView({ events, isPromptPending, @@ -134,7 +187,10 @@ export function ConversationView({ const listRef = useRef(null); const isAtBottomRef = useRef(true); - const [showScrollButton, setShowScrollButton] = useState(false); + const [viewState, dispatchViewState] = useReducer( + conversationViewReducer, + INITIAL_CONVERSATION_VIEW_STATE, + ); const debugLogsCloudRuns = useSettingsStore((s) => s.debugLogsCloudRuns); const showDebugLogs = debugLogsCloudRuns; @@ -169,14 +225,11 @@ export function ConversationView({ } const firstUserMessageId = firstUserMessageIdRef.current; - const [initialItemIds] = useState( - () => - new Set( - conversationItems - .filter((i) => i.type === "user_message") - .map((i) => i.id), - ), + const initialItemIdsRef = useRef | null>(null); + initialItemIdsRef.current ??= new Set( + conversationItems.filter((i) => i.type === "user_message").map((i) => i.id), ); + const initialItemIds = initialItemIdsRef.current; const pendingPermissions = usePendingPermissionsForTask(taskId ?? ""); const pendingPermissionsCount = pendingPermissions.size; @@ -235,6 +288,10 @@ export function ConversationView({ id != null ? itemIdToRowIndexRef.current.get(id) : undefined; listRef.current?.scrollToIndex(rowIdx ?? index); }, + // Search doesn't read scroll/content geometry, so these forward to the real + // handle purely to satisfy the (now-extended) VirtualizedListHandle shape. + getScrollElement: () => listRef.current?.getScrollElement() ?? null, + getContentElement: () => listRef.current?.getContentElement() ?? null, }); const search = useConversationSearch({ @@ -243,10 +300,8 @@ export function ConversationView({ listRef: searchListRef, }); - const [jumpPickerOpen, setJumpPickerOpen] = useState(false); - const [keyboardFocusedMessageId, setKeyboardFocusedMessageId] = useState< - string | null - >(null); + const { jumpPickerOpen, keyboardFocusedMessageId, scrollElements } = + viewState; const userMessages = useMemo(() => { const result: Array<{ id: string; index: number; content: string }> = []; @@ -292,7 +347,10 @@ export function ConversationView({ if (!nextMessage) return; useSettingsStore.getState().markHintLearned(PROMPT_RECALL_HINT_KEY); - setKeyboardFocusedMessageId(nextMessage.id); + dispatchViewState({ + type: "set-keyboard-focused-message", + value: nextMessage.id, + }); scrollToUserMessage(nextMessage.id, nextMessage.index); }, [keyboardFocusedMessageId, userMessages, scrollToUserMessage], @@ -300,7 +358,11 @@ export function ConversationView({ useHotkeys( SHORTCUTS.MESSAGE_JUMP, - () => setJumpPickerOpen((prev) => !prev), + () => + dispatchViewState({ + type: "set-jump-picker-open", + value: !jumpPickerOpen, + }), THREAD_HOTKEY_OPTIONS, ); @@ -317,34 +379,73 @@ export function ConversationView({ ); const clearKeyboardFocus = useCallback(() => { - setKeyboardFocusedMessageId(null); + dispatchViewState({ type: "set-keyboard-focused-message", value: null }); }, []); const handleJumpToMessage = useCallback( (id: string) => { const message = userMessages.find((entry) => entry.id === id); if (!message) return; - setKeyboardFocusedMessageId(id); + dispatchViewState({ type: "set-keyboard-focused-message", value: id }); scrollToUserMessage(id, message.index); }, [userMessages, scrollToUserMessage], ); + // The scrollbar marker rail needs the VirtualizedList's scroll + content + // elements. Resolve them from the list ref callback so the rail renders as + // soon as the imperative handle is attached, without mount-time state setup. + const setListRef = useCallback((handle: VirtualizedListHandle | null) => { + listRef.current = handle; + dispatchViewState({ + type: "set-scroll-elements", + value: { + scrollEl: handle?.getScrollElement() ?? null, + contentEl: handle?.getContentElement() ?? null, + }, + }); + }, []); + + const railUserMessages = useMemo( + () => + userMessages.map((m) => ({ + id: m.id, + content: m.content, + index: m.index, + })), + [userMessages], + ); + const railMarkers = useMessageRailMarkers({ + contentEl: scrollElements.contentEl, + scrollEl: scrollElements.scrollEl, + userMessages: railUserMessages, + onJump: (id) => { + const message = userMessages.find((entry) => entry.id === id); + if (!message) return; + dispatchViewState({ type: "set-keyboard-focused-message", value: id }); + scrollToUserMessage(id, message.index); + }, + activeId: keyboardFocusedMessageId, + }); + const handleScrollStateChange = useCallback((isAtBottom: boolean) => { isAtBottomRef.current = isAtBottom; - setShowScrollButton(!isAtBottom); + dispatchViewState({ + type: "set-show-scroll-button", + value: !isAtBottom, + }); }, []); const scrollToBottom = useCallback(() => { listRef.current?.scrollToBottom(); - setShowScrollButton(false); + dispatchViewState({ type: "set-show-scroll-button", value: false }); }, []); useEffect(() => { const handleVisibilityChange = () => { if (!document.hidden && isAtBottomRef.current) { listRef.current?.scrollToBottom(); - setShowScrollButton(false); + dispatchViewState({ type: "set-show-scroll-button", value: false }); } }; @@ -501,14 +602,16 @@ export function ConversationView({ + dispatchViewState({ type: "set-jump-picker-open", value: open }) + } items={items} onJumpToMessage={handleJumpToMessage} /> - ref={listRef} + ref={setListRef} items={threadRows} getItemKey={getRowKey} renderItem={renderRow} @@ -521,7 +624,8 @@ export function ConversationView({ scrollX={scrollX} /> - {showScrollButton && ( + + {viewState.showScrollButton && ( { export interface VirtualizedListHandle { scrollToBottom: () => void; scrollToIndex: (index: number) => void; + /** The scrolling element (the `overflow-y-auto` viewport). Exposed so a + * scrollbar marker rail can read `scrollTop`/`scrollHeight` and refresh on + * scroll. `null` until the list mounts. */ + getScrollElement: () => HTMLDivElement | null; + /** The inner content element whose height == the virtual total size. Rows + * (and their `data-conversation-item-id` stamps) live inside this, so a + * marker rail measures row offsets against it. `null` until the list mounts. */ + getContentElement: () => HTMLDivElement | null; } const AT_BOTTOM_THRESHOLD = 50; @@ -63,6 +71,7 @@ function VirtualizedListInner( ref: React.ForwardedRef, ) { const parentRef = useRef(null); + const contentRef = useRef(null); const footerRef = useRef(null); const initializedRef = useRef(false); const isAtBottomRef = useRef(true); @@ -156,6 +165,8 @@ function VirtualizedListInner( isAtBottomRef.current = false; virtualizer.scrollToIndex(index, { align: "center" }); }, + getScrollElement: () => parentRef.current, + getContentElement: () => contentRef.current, }), [virtualizer, settleAtEnd], ); @@ -258,6 +269,7 @@ function VirtualizedListInner( style={{ scrollbarGutter: "stable" }} >
+