From eae015c2dddd67b82560876f07ab7aca77a70365 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 30 Jul 2026 17:54:37 -0400 Subject: [PATCH 1/3] fix(chat-thread): stop the thread yanking back to an earlier prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scroller engine treats a row marked `scrollAnchor` as one it owns the scroll position for: on every content change it pins the first anchor it has not pinned before to the top of the viewport. Anchors present at mount are never recorded as pinned, so the first row swap after load scrolls the thread to the very first prompt, the next swap to the second, and so on — and a scroll-to-bottom lasts only until the next swap moves you one prompt down. Rows are no longer anchors, and the current turn (minimap highlight + the virtualization handoff anchor) is measured from the viewport instead, reusing the same `computeStickyAnchor` the windowed body already uses. The scroll-to-bottom button now also re-arms auto-follow, so it holds the bottom while a reply is still streaming rows in below. Generated-By: PostHog Code Task-Id: ae1e23ab-61e6-4a59-bc42-77667af0baab --- .../components/chat-thread/ChatThread.tsx | 164 +++++++++++++++--- .../components/chat-thread/MessageMinimap.tsx | 22 +-- 2 files changed, 148 insertions(+), 38 deletions(-) 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 46ae7513f0..477688d868 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -35,7 +35,6 @@ import { TooltipTrigger, useChatMessageScroller, useChatMessageScrollerScrollable, - useChatMessageScrollerVisibility, } from "@posthog/quill"; import type { AcpMessage, AgentConversationEvent } from "@posthog/shared"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; @@ -67,10 +66,12 @@ import { type AgentTurn, CHAT_THREAD_VIRTUALIZATION_THRESHOLD, completedTurnTimestamp, + computeStickyAnchor, countFlatRows, type FlatThreadRow, flattenTurnRows, SCROLL_PREVIOUS_ITEM_PEEK, + type StickyAnchorEntry, type ThreadItem, type ThreadScrollResume, type TurnRow, @@ -623,7 +624,13 @@ function ThreadItemBody({ * non-virtualized thread stays cheap. The pinned header is the separate overlay, not the rows. * * An {@link AgentTurn} renders as a single muted card wrapping its items with tight spacing; a user - * message stays a standalone anchored row. + * message stays a standalone row. + * + * No row is a `scrollAnchor`: the engine treats an anchored row as one it owns the scroll position + * for and pins every anchor it has not pinned before to the top of the viewport on the next content + * change — so a single row swap anywhere in a settled thread scrolls it back to an earlier prompt, + * one prompt per swap. {@link ThreadTurnAnchor} tracks the current turn without handing the engine + * that control. */ const ThreadRow = memo(function ThreadRow({ item, @@ -675,7 +682,7 @@ const ThreadRow = memo(function ThreadRow({ return ( @@ -688,6 +695,17 @@ const ThreadRow = memo(function ThreadRow({ ); }); +/** The scroll viewport owning `node`, found from any element rendered inside the scroller. */ +function findScrollerViewport(node: Element | null): HTMLElement | null { + return ( + node + ?.closest('[data-slot="chat-message-scroller"]') + ?.querySelector( + '[data-slot="chat-message-scroller-viewport"]', + ) ?? null + ); +} + /** * Keeps the view pinned to the bottom from prompt submit until the user scrolls away. * @@ -701,9 +719,17 @@ const ThreadRow = memo(function ThreadRow({ * commit that leaves content below the fold re-issues `scrollToEnd` to recapture follow. * * User scroll intent (wheel, touch, pointer, keys — same signals the engine listens to) disarms - * the pin; the next submit or the scroll-to-bottom button re-engages following. + * the pin; the next submit or the scroll-to-bottom button re-engages following. Arming is owned by + * the caller so the button can re-engage it — reaching the bottom once is worthless while a reply is + * still streaming rows in below. */ -function ThreadAutoFollow({ items }: { items: ConversationItem[] }) { +function ThreadAutoFollow({ + items, + armedRef, +}: { + items: ConversationItem[]; + armedRef: RefObject; +}) { const { scrollToEnd } = useChatMessageScroller(); const { end } = useChatMessageScrollerScrollable(); const lastItem = items.at(-1); @@ -713,7 +739,6 @@ function ThreadAutoFollow({ items }: { items: ConversationItem[] }) { [items], ); const prevCountRef = useRef(userMessageCount); - const armedRef = useRef(false); const probeRef = useRef(null); useLayoutEffect(() => { @@ -723,12 +748,10 @@ function ThreadAutoFollow({ items }: { items: ConversationItem[] }) { if (lastItem?.type !== "user_message") return; armedRef.current = true; scrollToEnd({ behavior: "auto" }); - }, [userMessageCount, lastItem, scrollToEnd]); + }, [userMessageCount, lastItem, scrollToEnd, armedRef]); useEffect(() => { - const viewport = probeRef.current - ?.closest('[data-slot="chat-message-scroller"]') - ?.querySelector('[data-slot="chat-message-scroller-viewport"]'); + const viewport = findScrollerViewport(probeRef.current); if (!viewport) return; const disarm = () => { armedRef.current = false; @@ -742,7 +765,7 @@ function ThreadAutoFollow({ items }: { items: ConversationItem[] }) { viewport.removeEventListener(event, disarm); } }; - }, []); + }, [armedRef]); // biome-ignore lint/correctness/useExhaustiveDependencies: re-check on every streamed change — `end` alone doesn't re-notify while it stays true across commits. useEffect(() => { @@ -871,12 +894,11 @@ function ThreadKeyboardNav({ } /** - * Keeps {@link ThreadScrollResume} current while the non-virtualized body is mounted, so the - * windowed body can pick up where this one left off if the thread crosses the threshold - * mid-session. Both values come from engine state the scroller already tracks — at-bottom from - * `scrollable.end` (true while content extends below the fold), and the anchored user message from - * the same visibility state the sticky header reads — so nothing here listens to scroll. Writes go - * to a ref: the recorder must never make the thread re-render. + * Records at-bottom into {@link ThreadScrollResume} while the non-virtualized body is mounted, so the + * windowed body can pick up where this one left off if the thread crosses the threshold mid-session. + * It reads engine state the scroller already tracks (`scrollable.end` is true while content extends + * below the fold), so nothing here listens to scroll; {@link ThreadTurnAnchor} fills in the anchor + * half. Writes go to a ref: the recorder must never make the thread re-render. */ function ThreadScrollStateRecorder({ stateRef, @@ -884,15 +906,104 @@ function ThreadScrollStateRecorder({ stateRef: RefObject; }) { const { end } = useChatMessageScrollerScrollable(); - const { currentAnchorId } = useChatMessageScrollerVisibility(); useEffect(() => { - stateRef.current = { atBottom: !end, anchorId: currentAnchorId ?? null }; - }, [end, currentAnchorId, stateRef]); + stateRef.current = { ...stateRef.current, atBottom: !end }; + }, [end, stateRef]); return null; } +/** + * Tracks the user turn the reader is parked on — the last prompt whose top has passed the peek line, + * matching how the windowed body derives its own sticky anchor — and renders the minimap with it. + * + * The engine reports this for rows marked `scrollAnchor`, but marking a row also hands the engine the + * scroll position for it (see {@link ThreadRow}), so the anchor is measured here instead. Only this + * component re-renders as the reader moves; the rows never do. + */ +function ThreadTurnAnchor({ + items, + resumeStateRef, +}: { + items: ConversationItem[]; + resumeStateRef: RefObject; +}) { + const [anchorId, setAnchorId] = useState(null); + const probeRef = useRef(null); + const measureRef = useRef<(() => void) | null>(null); + const promptIds = useMemo( + () => + new Set( + items + .filter((item) => item.type === "user_message") + .map((item) => item.id), + ), + [items], + ); + const promptIdsRef = useRef(promptIds); + useEffect(() => { + promptIdsRef.current = promptIds; + }, [promptIds]); + + useEffect(() => { + const viewport = findScrollerViewport(probeRef.current); + if (!viewport) return; + let frame = 0; + const measure = () => { + frame = 0; + const viewportTop = viewport.getBoundingClientRect().top; + const { scrollTop } = viewport; + const entries: StickyAnchorEntry[] = []; + for (const row of viewport.querySelectorAll( + "[data-message-id]", + )) { + const id = row.dataset.messageId; + if (!id || !promptIdsRef.current.has(id)) continue; + const rect = row.getBoundingClientRect(); + // A collapsed row (`empty:hidden`) measures as a zero rect at the origin, which would read + // as a row sitting above the peek line. + if (rect.height === 0) continue; + entries.push({ + id, + start: rect.top - viewportTop + scrollTop, + end: rect.bottom - viewportTop + scrollTop, + }); + } + const { anchorId: current } = computeStickyAnchor( + entries, + scrollTop, + SCROLL_PREVIOUS_ITEM_PEEK, + ); + setAnchorId(current); + resumeStateRef.current = { ...resumeStateRef.current, anchorId: current }; + }; + const schedule = () => { + if (frame === 0) frame = requestAnimationFrame(measure); + }; + measureRef.current = schedule; + measure(); + viewport.addEventListener("scroll", schedule, { passive: true }); + return () => { + measureRef.current = null; + cancelAnimationFrame(frame); + viewport.removeEventListener("scroll", schedule); + }; + }, [resumeStateRef]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: appended rows move every row below them, so the anchor is stale until it is re-measured. + useEffect(() => { + measureRef.current?.(); + }, [items]); + + return ( + <> +