From d5dfb4ebdec7303a15f98f8df25bf8a37c7f0d69 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Tue, 21 Jul 2026 14:59:58 +0100 Subject: [PATCH 01/15] feat(ui): add user-message marker rail to conversation scrollbar Add a marker rail in the scrollbar gutter of both conversation views (the legacy ConversationView and the new ChatThread). Each user message gets a darker marker positioned at its scroll offset; clicking jumps to the message and hovering shows its first few words as a tooltip. The native browser scrollbar can't be colored per-message or given click handlers/tooltip points, so this is a thin absolutely-positioned rail beside the scroll area instead. - New scrollbar-rail/ module: MessageScrollbarRail (presentation) + useMessageRailMarkers (measures rendered user-message rows and computes fractional positions; interpolates unmeasured/virtualized rows between measured neighbours). - Legacy ConversationView: VirtualizedList exposes its scroll + content elements via the imperative handle; the rail mounts in the container over the scrollbar gutter. - New ChatThread: a ThreadScrollbarRail locates the quill scroller viewport + content via a hidden probe (same pattern as StickyHeaderOverlay) and mounts the rail inside ChatMessageScroller. Generated-By: PostHog Code Task-Id: a950f289-b4c3-472e-b375-0799e0cbf8a4 --- .../sessions/components/ConversationView.tsx | 55 +++++ .../sessions/components/VirtualizedList.tsx | 12 + .../components/chat-thread/ChatThread.tsx | 103 ++++++++ .../MessageScrollbarRail.test.tsx | 89 +++++++ .../scrollbar-rail/MessageScrollbarRail.tsx | 81 ++++++ .../scrollbar-rail/messageRailTypes.ts | 40 +++ .../scrollbar-rail/useMessageRailMarkers.ts | 230 ++++++++++++++++++ 7 files changed, 610 insertions(+) create mode 100644 packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.test.tsx create mode 100644 packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.tsx create mode 100644 packages/ui/src/features/sessions/components/scrollbar-rail/messageRailTypes.ts create mode 100644 packages/ui/src/features/sessions/components/scrollbar-rail/useMessageRailMarkers.ts diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 5a049e930a..ac6679fd4d 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -23,6 +23,8 @@ import { 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 { usePromptRecallSource } from "@posthog/ui/features/sessions/components/chat-thread/usePromptRecallSource"; +import { MessageScrollbarRail } from "@posthog/ui/features/sessions/components/scrollbar-rail/MessageScrollbarRail"; +import { useMessageRailMarkers } from "@posthog/ui/features/sessions/components/scrollbar-rail/useMessageRailMarkers"; 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"; @@ -235,6 +237,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({ @@ -330,6 +336,54 @@ export function ConversationView({ [userMessages, scrollToUserMessage], ); + // The scrollbar marker rail needs the VirtualizedList's scroll + content + // elements. They're only available after mount, so resolve them into state from + // the imperative handle; the rail re-renders once they're populated. The list's + // internal refs are stable for its lifetime, so a mount-time resolve (with a + // rAF retry for the first paint where the ref isn't attached yet) is enough. + const [scrollElements, setScrollElements] = useState<{ + scrollEl: HTMLElement | null; + contentEl: HTMLElement | null; + }>({ scrollEl: null, contentEl: null }); + useEffect(() => { + const sync = () => { + const handle = listRef.current; + if (!handle) return; + const scrollEl = handle.getScrollElement(); + const contentEl = handle.getContentElement(); + setScrollElements((prev) => + prev.scrollEl === scrollEl && prev.contentEl === contentEl + ? prev + : { scrollEl, contentEl }, + ); + }; + sync(); + const raf = requestAnimationFrame(sync); + return () => cancelAnimationFrame(raf); + }, []); + + 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; + setKeyboardFocusedMessageId(id); + scrollToUserMessage(id, message.index); + }, + activeId: keyboardFocusedMessageId, + }); + const handleScrollStateChange = useCallback((isAtBottom: boolean) => { isAtBottomRef.current = isAtBottom; setShowScrollButton(!isAtBottom); @@ -521,6 +575,7 @@ export function ConversationView({ scrollX={scrollX} /> + {showScrollButton && ( diff --git a/packages/ui/src/features/sessions/components/VirtualizedList.tsx b/packages/ui/src/features/sessions/components/VirtualizedList.tsx index ef5ae0e174..1e95107336 100644 --- a/packages/ui/src/features/sessions/components/VirtualizedList.tsx +++ b/packages/ui/src/features/sessions/components/VirtualizedList.tsx @@ -35,6 +35,14 @@ interface VirtualizedListProps { 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" }} >
+
-
- {/* The scroll viewport. `scrollbar-gutter: stable` reserves the gutter the - rail sits over, matching the real ConversationView. */} +
+ {/* The scroll viewport. `min-h-0` lets the flex child shrink to the + viewport instead of growing with content, so the rail (`h-full`) + matches the *visible* height — the real ConversationView pins its + list with `absolute inset-0` for the same reason. `scrollbar-gutter: + stable` reserves the gutter the rail sits over. */}
Date: Tue, 21 Jul 2026 18:01:38 +0100 Subject: [PATCH 06/15] fix(ui): bound scrollbar rail story viewport Generated-By: PostHog Code Task-Id: 9a68b219-d8a9-45af-ae6c-3f9b1b19e7b0 --- .../scrollbar-rail/MessageScrollbarRail.stories.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx b/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx index df48edab7a..cf85a3bd75 100644 --- a/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx +++ b/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx @@ -167,14 +167,14 @@ function ScrollableConversationDemo() {
- {/* The scroll viewport. `min-h-0` lets the flex child shrink to the - viewport instead of growing with content, so the rail (`h-full`) - matches the *visible* height — the real ConversationView pins its - list with `absolute inset-0` for the same reason. `scrollbar-gutter: + {/* Pin the scroll viewport to this bounded flex child, matching the real + ConversationView. Keeping the viewport out of normal flow prevents + the long mock transcript from making the rail content-height and + spreading most markers below the captured scene. `scrollbar-gutter: stable` reserves the gutter the rail sits over. */}
From d6a42b419479bfda62b787626f477f12cc1314fc Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:49:33 +0000 Subject: [PATCH 07/15] chore(visual): update storybook baselines 4 updated Run: 925dc45b-9dab-4332-8c13-77e829b57a3f Co-authored-by: pauldambra <984817+pauldambra@users.noreply.github.com> --- apps/code/snapshots.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index 3147f9a2f7..b61e19a359 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.65a8a9751978b1225ebcd43d2cf98fc9af4e08b360505f6341134b3b6f16bec1.A7iVr2e4YkBBM1Vh14gOW_miV7zmG8vny5n5FoM3MOI + features-sessions-scrollbarrail--pure--light: + hash: v1.k4693efd2.c08d498202dfe03b7eb796a373b695f2e213d48917e4440ad607f4ce6af02e2c.DWl3zrJX1AJEVdlcP2K0zl9wTzT-JbQ87NustHlt8KI + features-sessions-scrollbarrail--scrollable-conversation--dark: + hash: v1.k4693efd2.d063bb77a125bde7221b2ff96cc616aedf99128726bdfe8ecaf83d7631d49b99.kgO_IIZwLE5meW1Ju38huy1xcH-AyfF9t6M8ypLOIzg + features-sessions-scrollbarrail--scrollable-conversation--light: + hash: v1.k4693efd2.bef742a0769f0c76217537ba3e7b1e4fec91488b7749c39f80eddf690b37af8f.Fk7y9JBeORTnQJAlL0CAEntiC9gJC-6ThIMY_P5GIIo features-sessions-toolcallblock--create-new-file--dark: hash: v1.k4693efd2.8847f96037682460aba47f0e0f5113037918235fc98596933f2d1d06fd6f8f92.SHetexoVOtozsZGZQMcVbmpBlbk7M5D5Q0R_TJvPnyc features-sessions-toolcallblock--create-new-file--light: From a68a9554839681ae73e6b4f7cc9f00b2a06f5f2e Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Tue, 21 Jul 2026 23:04:43 +0100 Subject: [PATCH 08/15] fix(ui): show human-only conversation rail markers Generated-By: PostHog Code Task-Id: 9a68b219-d8a9-45af-ae6c-3f9b1b19e7b0 --- .../MessageScrollbarRail.stories.tsx | 47 ++++++------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx b/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx index cf85a3bd75..67f42b40f6 100644 --- a/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx +++ b/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx @@ -51,33 +51,18 @@ export const Pure: Story = { }), marker({ id: "m2", - topPct: 0.23, + topPct: 0.52, label: "Add a marker to the scrollbar for my messages", - }), - marker({ - id: "m3", - topPct: 0.48, - label: "Why did the build break on CI?", active: true, }), - marker({ - id: "m4", - topPct: 0.71, - label: "Can you color the scrollbar based on conversation data?", - }), - marker({ - id: "m5", - topPct: 0.93, - label: "Ship it", - }), ]} /> {/* Caption so the rail (8px, far right) isn't the only thing on screen. */}

- Markers sit in the scrollbar gutter on the right. Hover one to see - the first few words; click to jump. The accent-colored marker is - active. + Each human message has one marker in the scrollbar gutter. Hover a + marker to preview the message; click to jump. The accent-colored + marker is active.

@@ -90,26 +75,22 @@ export const Pure: Story = { interface TranscriptEntry { id: string; prompt: string; - reply: string; + reply: string[]; } function buildTranscript(): TranscriptEntry[] { const prompts = [ "How do I set up the dev environment for this repo?", "Add a darker marker to the scrollbar where my messages are", - "Why did the build break after the last merge?", - "Can you color the scrollbar based on data in the conversation?", - "Show the first few words of each message as a tooltip", - "Walk me through the DI boot sequence", - "What does the host boundary check enforce?", - "Refactor the conversation items pipeline", - "Generate a changelog entry for the rail", - "Ship it once tests are green", ]; return prompts.map((prompt, i) => ({ id: `user-${i}`, prompt, - reply: `Here's a thorough answer to "${prompt}". `.repeat(40), + reply: Array.from( + { length: 10 }, + (_, paragraph) => + `Response paragraph ${paragraph + 1}. Here's a thorough answer to "${prompt}" with enough detail to make the conversation scroll naturally.`, + ), })); } @@ -190,9 +171,11 @@ function ScrollableConversationDemo() { > {entry.prompt}
-

- {entry.reply} -

+
+ {entry.reply.map((paragraph) => ( +

{paragraph}

+ ))} +
))}
From c735eb251dde066396cfcc6ae40903f6ebc52297 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Tue, 21 Jul 2026 23:23:48 +0100 Subject: [PATCH 09/15] fix(ui): measure rail markers from human messages only Generated-By: PostHog Code Task-Id: 9a68b219-d8a9-45af-ae6c-3f9b1b19e7b0 --- .../components/scrollbar-rail/MessageScrollbarRail.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx b/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx index 67f42b40f6..7fa67038d8 100644 --- a/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx +++ b/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx @@ -162,10 +162,10 @@ function ScrollableConversationDemo() { {transcript.map((entry) => (
From 40fe711ca945c848492bc1655e14860d173538ec Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Tue, 21 Jul 2026 23:46:25 +0100 Subject: [PATCH 10/15] style(ui): biome-format MessageScrollbarRail story Collapse a JSX element onto one line so `biome ci` passes formatting. Co-Authored-By: Claude Opus 4.8 --- .../scrollbar-rail/MessageScrollbarRail.stories.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx b/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx index 7fa67038d8..904b4c5c83 100644 --- a/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx +++ b/packages/ui/src/features/sessions/components/scrollbar-rail/MessageScrollbarRail.stories.tsx @@ -160,10 +160,7 @@ function ScrollableConversationDemo() { >
{transcript.map((entry) => ( -
+
Date: Wed, 22 Jul 2026 11:16:22 +0000 Subject: [PATCH 11/15] chore(visual): update storybook baselines 4 updated Run: 5c72beed-851f-4266-9d48-8750405a5f9c Co-authored-by: pauldambra <984817+pauldambra@users.noreply.github.com> --- apps/code/snapshots.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index b61e19a359..40ce40c1b2 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -333,13 +333,13 @@ snapshots: features-sessions-conversationview--with-pending-prompt--light: hash: v1.k4693efd2.1636dfc3ee0dd9d28065166f2fff0193f2d499692c74749d3afa7585aae898b5.YxTPa4CsqI2YuGLUdck8340ITZQrpna_C6z-SgII4Ws features-sessions-scrollbarrail--pure--dark: - hash: v1.k4693efd2.65a8a9751978b1225ebcd43d2cf98fc9af4e08b360505f6341134b3b6f16bec1.A7iVr2e4YkBBM1Vh14gOW_miV7zmG8vny5n5FoM3MOI + hash: v1.k4693efd2.6baec21b1942848a3a376d2b90ca106b2c1b94de74382b4cd05673695d2649ee.UDDPtLDf1J2E3d3Oiy9is77Nm5xRSIfB7q5z0uUZRSQ features-sessions-scrollbarrail--pure--light: - hash: v1.k4693efd2.c08d498202dfe03b7eb796a373b695f2e213d48917e4440ad607f4ce6af02e2c.DWl3zrJX1AJEVdlcP2K0zl9wTzT-JbQ87NustHlt8KI + hash: v1.k4693efd2.33f02943fdbb1ea9774881f8909b580ab03ba02749614c533af1bf51dd817dca.ieP2bhDz0U_PTB4M6Zplvit0XlSTFiUn083GYBFNuC0 features-sessions-scrollbarrail--scrollable-conversation--dark: - hash: v1.k4693efd2.d063bb77a125bde7221b2ff96cc616aedf99128726bdfe8ecaf83d7631d49b99.kgO_IIZwLE5meW1Ju38huy1xcH-AyfF9t6M8ypLOIzg + hash: v1.k4693efd2.203b62b52d6365d6ec222f7529c35b3f46b17cd07ca792a8ce2de594af4d589b.3dfpnVM8yxor1PNO5aPk0pxn5THZjK4otA6mbz3XhtA features-sessions-scrollbarrail--scrollable-conversation--light: - hash: v1.k4693efd2.bef742a0769f0c76217537ba3e7b1e4fec91488b7749c39f80eddf690b37af8f.Fk7y9JBeORTnQJAlL0CAEntiC9gJC-6ThIMY_P5GIIo + 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: From 39e9a76786aece15045b655b349ee2d305015502 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Wed, 22 Jul 2026 12:22:14 +0100 Subject: [PATCH 12/15] fix(ui): address conversation rail React Doctor findings Generated-By: PostHog Code Task-Id: 106723a6-379c-4fdb-bde2-78568beacdfd --- .../sessions/components/ConversationView.tsx | 143 ++++++++++++------ .../scrollbar-rail/MessageScrollbarRail.tsx | 19 ++- 2 files changed, 105 insertions(+), 57 deletions(-) diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index b37108ccb7..cb5dcea1a4 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -75,8 +75,8 @@ import { useCallback, useEffect, useMemo, + useReducer, useRef, - useState, } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -108,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, @@ -136,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; @@ -171,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; @@ -249,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 }> = []; @@ -298,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], @@ -306,7 +358,11 @@ export function ConversationView({ useHotkeys( SHORTCUTS.MESSAGE_JUMP, - () => setJumpPickerOpen((prev) => !prev), + () => + dispatchViewState({ + type: "set-jump-picker-open", + value: !jumpPickerOpen, + }), THREAD_HOTKEY_OPTIONS, ); @@ -323,43 +379,31 @@ 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. They're only available after mount, so resolve them into state from - // the imperative handle; the rail re-renders once they're populated. The list's - // internal refs are stable for its lifetime, so a mount-time resolve (with a - // rAF retry for the first paint where the ref isn't attached yet) is enough. - const [scrollElements, setScrollElements] = useState<{ - scrollEl: HTMLElement | null; - contentEl: HTMLElement | null; - }>({ scrollEl: null, contentEl: null }); - useEffect(() => { - const sync = () => { - const handle = listRef.current; - if (!handle) return; - const scrollEl = handle.getScrollElement(); - const contentEl = handle.getContentElement(); - setScrollElements((prev) => - prev.scrollEl === scrollEl && prev.contentEl === contentEl - ? prev - : { scrollEl, contentEl }, - ); - }; - sync(); - const raf = requestAnimationFrame(sync); - return () => cancelAnimationFrame(raf); + // 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( @@ -378,7 +422,7 @@ export function ConversationView({ onJump: (id) => { 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); }, activeId: keyboardFocusedMessageId, @@ -386,19 +430,22 @@ export function ConversationView({ 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 }); } }; @@ -555,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} @@ -576,7 +625,7 @@ export function ConversationView({ /> - {showScrollButton && ( + {viewState.showScrollButton && ( + + Jump to message: {marker.label} + Date: Wed, 22 Jul 2026 12:24:56 +0100 Subject: [PATCH 13/15] fix(ui): space estimated message rail markers Generated-By: PostHog Code Task-Id: 106723a6-379c-4fdb-bde2-78568beacdfd --- .../components/scrollbar-rail/useMessageRailMarkers.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/sessions/components/scrollbar-rail/useMessageRailMarkers.ts b/packages/ui/src/features/sessions/components/scrollbar-rail/useMessageRailMarkers.ts index e6930301c4..2f29ebec47 100644 --- a/packages/ui/src/features/sessions/components/scrollbar-rail/useMessageRailMarkers.ts +++ b/packages/ui/src/features/sessions/components/scrollbar-rail/useMessageRailMarkers.ts @@ -221,10 +221,16 @@ function interpolateOffset( return { top, height: ESTIMATE }; } if (prev) { - return { top: prev.top + prev.height + ESTIMATE, height: ESTIMATE }; + return { + top: prev.top + prev.height + (index - prevIdx) * ESTIMATE, + height: ESTIMATE, + }; } if (next) { - return { top: Math.max(0, next.top - ESTIMATE), height: ESTIMATE }; + return { + top: Math.max(0, next.top - (nextIdx - index) * ESTIMATE), + height: ESTIMATE, + }; } return { top: index * ESTIMATE, height: ESTIMATE }; } From c76c868ac49d824f86b28b7da304c926436d30b0 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Wed, 22 Jul 2026 12:27:38 +0100 Subject: [PATCH 14/15] chore(ci): restore React Doctor workflow Generated-By: PostHog Code Task-Id: 106723a6-379c-4fdb-bde2-78568beacdfd --- .github/scripts/react-doctor-comment.mjs | 140 +++++++++++++++++++++++ .github/workflows/react-doctor.yml | 120 +++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 .github/scripts/react-doctor-comment.mjs create mode 100644 .github/workflows/react-doctor.yml diff --git a/.github/scripts/react-doctor-comment.mjs b/.github/scripts/react-doctor-comment.mjs new file mode 100644 index 0000000000..bde71abc66 --- /dev/null +++ b/.github/scripts/react-doctor-comment.mjs @@ -0,0 +1,140 @@ +import fs from "node:fs"; + +const [reportPath, outPath] = process.argv.slice(2); +const MARKER = ""; +const slug = process.env.GITHUB_REPOSITORY; +const server = (process.env.GITHUB_SERVER_URL || "https://github.com").replace( + /\/$/, + "", +); +const head = ( + process.env.REACT_DOCTOR_HEAD_SHA || + process.env.GITHUB_SHA || + "" +).trim(); + +const MAX_LISTED = 50; +const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`; +const inline = (value) => + String(value ?? "") + .replace(/\s+/g, " ") + .replace(/[`<>]/g, "") + .trim(); +const byFileThenLine = (a, b) => + a.filePath === b.filePath + ? a.line - b.line + : a.filePath < b.filePath + ? -1 + : 1; +const encodedPath = (file) => + String(file ?? "") + .split("/") + .map((segment) => + encodeURIComponent(segment).replace(/[()]/g, (c) => + c === "(" ? "%28" : "%29", + ), + ) + .join("/"); +const fileLink = (file, line) => + slug && head + ? `[\`${inline(file)}:${line}\`](${server}/${slug}/blob/${head}/${encodedPath(file)}#L${line})` + : `\`${inline(file)}:${line}\``; + +let report; +try { + report = JSON.parse(fs.readFileSync(reportPath, "utf8")); +} catch { + report = null; +} + +const lines = [MARKER, ""]; + +if (!report) { + lines.push("**React Doctor** could not produce a report."); +} else if (!report.ok) { + lines.push( + "**React Doctor** could not complete this scan.", + "", + `> ${inline(report.error?.message) || "scan failed"}`, + ); +} else { + const summary = report.summary ?? {}; + const total = summary.totalDiagnosticCount ?? 0; + const diagnostics = (report.projects ?? []).flatMap( + (project) => project.diagnostics ?? [], + ); + + if (total === 0) { + lines.push("**React Doctor** found no issues in the changed files. 🎉"); + } else { + const severity = []; + if (summary.errorCount) severity.push(plural(summary.errorCount, "error")); + if (summary.warningCount) + severity.push(plural(summary.warningCount, "warning")); + const severityTail = severity.length ? ` · ${severity.join(" & ")}` : ""; + lines.push( + `**React Doctor** found **${plural(total, "issue")}** in ${plural(summary.affectedFileCount ?? 0, "file")}${severityTail}.`, + ); + + const errors = diagnostics + .filter((d) => d.severity === "error") + .sort(byFileThenLine); + if (errors.length) { + lines.push("", "**Errors**", ""); + for (const error of errors.slice(0, MAX_LISTED)) { + const title = inline(error.title); + lines.push( + `- ❌ ${fileLink(error.filePath, error.line)}${title ? ` ${title}` : ""} \`${inline(error.rule)}\``, + ); + } + if (errors.length > MAX_LISTED) + lines.push( + "", + `${plural(errors.length - MAX_LISTED, "more error")} not shown.`, + ); + } + + const warnings = diagnostics + .filter((d) => d.severity === "warning") + .sort(byFileThenLine); + if (warnings.length) { + lines.push( + "", + `
${plural(warnings.length, "warning")}`, + "", + ); + let currentFile = null; + for (const warning of warnings.slice(0, MAX_LISTED)) { + if (warning.filePath !== currentFile) { + if (currentFile !== null) lines.push(""); + lines.push(`**\`${inline(warning.filePath)}\`**`); + currentFile = warning.filePath; + } + const title = inline(warning.title); + lines.push( + `- ⚠️ ${fileLink(warning.filePath, warning.line)}${title ? ` ${title}` : ""} \`${inline(warning.rule)}\``, + ); + } + if (warnings.length > MAX_LISTED) + lines.push( + "", + `${plural(warnings.length - MAX_LISTED, "more warning")} not shown.`, + ); + lines.push("", "
"); + } + } +} + +const commit = head ? ` for commit \`${head.slice(0, 7)}\`` : ""; +lines.push( + "", + `Reviewed by [React Doctor](https://react.doctor)${commit}.`, +); + +fs.writeFileSync( + outPath, + `${lines + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd()}\n`, +); diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml new file mode 100644 index 0000000000..d75b7260ec --- /dev/null +++ b/.github/workflows/react-doctor.yml @@ -0,0 +1,120 @@ +name: React Doctor + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + # Workflow-level paths only work because react-doctor is not a required + # check. If it ever becomes required, move the skip into a gate job like + # test.yml, or path-skipped PRs will wait on it forever. + paths: + - "**/*.ts" + - "**/*.tsx" + - "**/*.jsx" + - ".github/workflows/react-doctor.yml" + - ".github/scripts/react-doctor-comment.mjs" + +permissions: + contents: read + pull-requests: write + +concurrency: + group: react-doctor-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + react-doctor: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + package-manager-cache: false + + - id: scan + name: Run react-doctor on changed files + shell: bash + env: + NO_COLOR: "1" + REACT_DOCTOR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # -e omitted: exit codes are captured and inspected explicitly. + set -uo pipefail + CHANGED="${RUNNER_TEMP}/react-doctor-changed-files.txt" + if ! git diff --name-only --diff-filter=ACMR "${REACT_DOCTOR_BASE_SHA}...${HEAD_SHA}" > "$CHANGED"; then + echo "Could not diff ${REACT_DOCTOR_BASE_SHA}...${HEAD_SHA}; failing rather than skipping the scan." >&2 + echo "exit-code=1" >> "$GITHUB_OUTPUT" + exit 1 + fi + if [ ! -s "$CHANGED" ]; then + echo "No changed files; nothing for react-doctor to scan." + echo "exit-code=0" >> "$GITHUB_OUTPUT" + exit 0 + fi + REPORT="${RUNNER_TEMP}/react-doctor-report.json" + status=0 + npx --yes react-doctor@0.5.4 . --blocking error --changed-files-from "$CHANGED" --json --json-compact --no-telemetry > "$REPORT" || status=$? + echo "exit-code=$status" >> "$GITHUB_OUTPUT" + echo "report=$REPORT" >> "$GITHUB_OUTPUT" + if [ "$status" -ne 0 ]; then + echo "react-doctor exited with status ${status}; report follows." >&2 + cat "$REPORT" >&2 || true + fi + + - name: Upsert sticky PR comment + if: ${{ always() && steps.scan.outputs.report != '' }} + continue-on-error: true + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + REACT_DOCTOR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPORT: ${{ steps.scan.outputs.report }} + run: | + set -uo pipefail + BODY="${RUNNER_TEMP}/react-doctor-comment.md" + node "${GITHUB_WORKSPACE}/.github/scripts/react-doctor-comment.mjs" "$REPORT" "$BODY" + cat "$BODY" >> "$GITHUB_STEP_SUMMARY" + jq -Rs '{body: .}' "$BODY" > "${RUNNER_TEMP}/react-doctor-payload.json" + marker="" + existing=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \ + --jq ".[] | select(.body | startswith(\"${marker}\")) | .id" | head -n1 || true) + if [ -n "$existing" ]; then + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${existing}" --input "${RUNNER_TEMP}/react-doctor-payload.json" >/dev/null + else + gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --input "${RUNNER_TEMP}/react-doctor-payload.json" >/dev/null + fi + + - name: Enforce blocking findings + if: ${{ always() }} + shell: bash + env: + STATUS: ${{ steps.scan.outputs.exit-code }} + REPORT: ${{ steps.scan.outputs.report }} + run: | + # -e omitted: exit codes are captured and inspected explicitly. + set -uo pipefail + if [ "${STATUS:-1}" = "0" ]; then + exit 0 + fi + if [ -n "${REPORT:-}" ]; then + if ! jq -e . "$REPORT" >/dev/null 2>&1; then + echo "::warning title=React Doctor::Scanner exited ${STATUS:-1} without a valid JSON report; not blocking the PR on a scanner failure." + exit 0 + fi + if jq -e '.ok == false' "$REPORT" >/dev/null 2>&1; then + # Strip newlines so the message cannot end the ::warning command early. + message=$(jq -r '.error.message // "unknown error"' "$REPORT" | tr -d '\n') + echo "::warning title=React Doctor::Scanner crashed (${message}); not blocking the PR on a scanner failure." + exit 0 + fi + fi + exit "${STATUS:-1}" From 8d826377e4bb17c7374474e4fa6afd5cb04553a0 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Wed, 22 Jul 2026 12:30:19 +0100 Subject: [PATCH 15/15] fix(ui): measure rail rows in one DOM pass, coalesce per frame Address qa/codex P2: measure() ran one querySelector per user message on every scroll event, which in long threads is hundreds/thousands of synchronous DOM lookups per frame. Collect rendered rows with a single querySelectorAll pass indexed by id, and coalesce observer/scroll-driven measures to at most one per animation frame. Co-Authored-By: Claude Opus 4.8 --- .../scrollbar-rail/useMessageRailMarkers.ts | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/features/sessions/components/scrollbar-rail/useMessageRailMarkers.ts b/packages/ui/src/features/sessions/components/scrollbar-rail/useMessageRailMarkers.ts index 2f29ebec47..73993f6bda 100644 --- a/packages/ui/src/features/sessions/components/scrollbar-rail/useMessageRailMarkers.ts +++ b/packages/ui/src/features/sessions/components/scrollbar-rail/useMessageRailMarkers.ts @@ -94,10 +94,18 @@ export function useMessageRailMarkers({ // both move together). `offsetTop` can't be used directly because virtual // list rows live in a `transform`-ed wrapper that becomes the offset parent. const contentTop = content.getBoundingClientRect().top; + // Collect all rendered rows in a single DOM pass and index them by id, + // rather than one `querySelector` per message: in the long threads this + // rail targets, a per-message query on every scroll frame is hundreds or + // thousands of synchronous DOM lookups. `querySelectorAll` walks the + // (virtualized, bounded) rendered set once. + const rowById = new Map(); + for (const row of content.querySelectorAll(`[${attr}]`)) { + const id = row.getAttribute(attr); + if (id != null) rowById.set(id, row); + } for (const entry of userMessagesRef.current) { - const row = content.querySelector( - `[${attr}="${CSS.escape(entry.id)}"]`, - ) as HTMLElement | null; + const row = rowById.get(entry.id); if (!row) continue; const rect = row.getBoundingClientRect(); const top = rect.top - contentTop; @@ -111,24 +119,41 @@ export function useMessageRailMarkers({ if (changed) bump(); }, [bump]); + // Coalesce observer/scroll-driven measures to at most one per animation + // frame — a burst of scroll or mutation events within a frame only needs a + // single re-measure. Cancelled on unmount via the ref below. + const frameRef = useRef(null); + const scheduleMeasure = useCallback(() => { + if (frameRef.current != null) return; + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + measure(); + }); + }, [measure]); + // Refresh on scroll (positions change as the thumb moves) + on DOM mutation // (rows entering/leaving the virtualization window) + on content resize. useEffect(() => { if (!contentEl) return; + // Measure synchronously on mount so markers paint on the first frame; later + // event-driven measures are frame-coalesced through `scheduleMeasure`. measure(); - const mutation = new MutationObserver(() => measure()); + const mutation = new MutationObserver(scheduleMeasure); mutation.observe(contentEl, { childList: true, subtree: true }); - const resize = new ResizeObserver(() => measure()); + const resize = new ResizeObserver(scheduleMeasure); resize.observe(contentEl); - const onScroll = () => measure(); const scroll = scrollEl ?? contentEl; - scroll.addEventListener("scroll", onScroll, { passive: true }); + scroll.addEventListener("scroll", scheduleMeasure, { passive: true }); return () => { mutation.disconnect(); resize.disconnect(); - scroll.removeEventListener("scroll", onScroll); + scroll.removeEventListener("scroll", scheduleMeasure); + if (frameRef.current != null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } }; - }, [contentEl, scrollEl, measure]); + }, [contentEl, scrollEl, measure, scheduleMeasure]); // Drop stale ids (removed messages) so markers don't linger after compaction. useEffect(() => {