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..88c8ca3efd 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"; @@ -623,7 +622,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 +680,7 @@ const ThreadRow = memo(function ThreadRow({ return ( @@ -688,6 +693,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 +717,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 +737,6 @@ function ThreadAutoFollow({ items }: { items: ConversationItem[] }) { [items], ); const prevCountRef = useRef(userMessageCount); - const armedRef = useRef(false); const probeRef = useRef(null); useLayoutEffect(() => { @@ -723,12 +746,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 +763,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 +892,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 +904,102 @@ 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, + * the same rule {@link computeStickyAnchor} applies to the windowed body's measured rows — 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(() => { + const ids = new Set(); + for (const item of items) { + if (item.type === "user_message") ids.add(item.id); + } + return ids; + }, [items]); + const promptIdsRef = useRef(promptIds); + useEffect(() => { + promptIdsRef.current = promptIds; + }, [promptIds]); + + useEffect(() => { + const viewport = findScrollerViewport(probeRef.current); + const rows = viewport?.querySelector( + '[data-slot="chat-message-scroller-content"]', + ); + if (!viewport || !rows) return; + let frame = 0; + const measure = () => { + frame = 0; + const line = + viewport.getBoundingClientRect().top + SCROLL_PREVIOUS_ITEM_PEEK; + let current: string | null = null; + // Rows are content's direct children (walking the subtree would visit every node inside every + // turn card) and sit in ascending vertical order, so scanning back from the newest one finds + // the anchor without measuring the thread above it — at the live tail that is the first read. + for (let i = rows.children.length - 1; i >= 0; i--) { + const row = rows.children[i]; + if (!(row instanceof HTMLElement)) continue; + 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 line. + if (rect.height === 0) continue; + if (rect.top > line) continue; + current = id; + break; + } + 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 ( + <> +