From 4f4030abf572b3859d68c3e9f3a82967c6b1731e Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Thu, 2 Jul 2026 11:01:09 +0100 Subject: [PATCH 1/4] fix(thread): stop ChatThread snapping to top on prompt submit Stable row keys keep the optimistic user bubble's DOM element across the echoed-prompt swap and a lone tool marker's element across tool-group collapse, so quill's message scroller never sees a count-neutral childList mutation (its re-anchor fallback scrolls to the first never-handled anchor, i.e. the first user message in the conversation). New prompts now anchor to the viewport top explicitly via scrollToMessage, since the engine's implicit anchor path can't fire while the footer is a scroller-content child. Generated-By: PostHog Code Task-Id: c30ae5a8-6f18-4a44-882e-a1b29ea84154 --- .../components/chat-thread/ChatThread.tsx | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 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 37a4c5fad7..a40d84eccd 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -134,7 +134,7 @@ function groupToolRuns(items: ConversationItem[]): ThreadItem[] { const flush = () => { if (toolCount >= 2) { const tools = buffer.filter(isToolCallItem); - out.push({ type: "tool_group", id: `tool-group-${tools[0].id}`, tools }); + out.push({ type: "tool_group", id: tools[0].id, tools }); } else { out.push(...buffer); } @@ -512,6 +512,27 @@ const ThreadRow = memo(function ThreadRow({ ); }); +function AnchorNewPrompt({ items }: { items: ConversationItem[] }) { + const { scrollToMessage } = useChatMessageScroller(); + const lastItem = items.at(-1); + const userMessageCount = useMemo( + () => + items.reduce((n, item) => (item.type === "user_message" ? n + 1 : n), 0), + [items], + ); + const prevCountRef = useRef(userMessageCount); + + useLayoutEffect(() => { + const previous = prevCountRef.current; + prevCountRef.current = userMessageCount; + if (previous === 0 || userMessageCount <= previous) return; + if (lastItem?.type !== "user_message") return; + scrollToMessage(lastItem.id, { align: "start" }); + }, [userMessageCount, lastItem, scrollToMessage]); + + return null; +} + /** The scroll body, under the Provider so the overlay + scroll-button hooks can read engine state. */ function ThreadScrollBody({ items, @@ -525,15 +546,24 @@ function ThreadScrollBody({ /** Status row (duration / context usage) pinned as the last item in the thread. */ footer?: ReactNode; }) { + const keyedRows = useMemo(() => { + let userTurn = 0; + return rows.map((item) => ({ + item, + key: item.type === "user_message" ? `user-turn-${userTurn++}` : item.id, + })); + }, [rows]); + // `group/thread` so the footer's hover-reveal (opacity-50 → 100 on group-hover) tracks the thread, // mirroring the legacy ConversationView container. return ( + - {rows.map((item) => ( - + {keyedRows.map(({ item, key }) => ( + ))} {footer && (
Date: Thu, 2 Jul 2026 11:18:18 +0100 Subject: [PATCH 2/4] fix(thread): drop anchor-to-top, keep native stick-to-bottom scrolling Generated-By: PostHog Code Task-Id: c30ae5a8-6f18-4a44-882e-a1b29ea84154 --- .../components/chat-thread/ChatThread.tsx | 22 ------------------- 1 file changed, 22 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 a40d84eccd..a7187ec0a6 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -512,27 +512,6 @@ const ThreadRow = memo(function ThreadRow({ ); }); -function AnchorNewPrompt({ items }: { items: ConversationItem[] }) { - const { scrollToMessage } = useChatMessageScroller(); - const lastItem = items.at(-1); - const userMessageCount = useMemo( - () => - items.reduce((n, item) => (item.type === "user_message" ? n + 1 : n), 0), - [items], - ); - const prevCountRef = useRef(userMessageCount); - - useLayoutEffect(() => { - const previous = prevCountRef.current; - prevCountRef.current = userMessageCount; - if (previous === 0 || userMessageCount <= previous) return; - if (lastItem?.type !== "user_message") return; - scrollToMessage(lastItem.id, { align: "start" }); - }, [userMessageCount, lastItem, scrollToMessage]); - - return null; -} - /** The scroll body, under the Provider so the overlay + scroll-button hooks can read engine state. */ function ThreadScrollBody({ items, @@ -559,7 +538,6 @@ function ThreadScrollBody({ return ( - {keyedRows.map(({ item, key }) => ( From ab7c0470df33ddd53467eee393c25b0b0e6b302f Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Thu, 2 Jul 2026 11:28:54 +0100 Subject: [PATCH 3/4] fix(thread): reliably follow the bottom on submit and streaming replies The scroller only auto-follows in following-bottom mode, which it re-enters solely within 8px of the exact bottom. With the thread's bottom padding the view is rarely that close, and any stray trackpad wheel drops out of follow mode permanently. Scroll to end on every user submit (which also re-engages follow mode for the streaming reply) and widen the edge threshold to 100px so near-bottom counts as at-bottom for recapture. Generated-By: PostHog Code Task-Id: c30ae5a8-6f18-4a44-882e-a1b29ea84154 --- .../components/chat-thread/ChatThread.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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 a7187ec0a6..d96f92d9aa 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -512,6 +512,34 @@ const ThreadRow = memo(function ThreadRow({ ); }); +/** + * Scrolls to the end when the user sends a prompt, regardless of current position. The engine only + * auto-follows in `following-bottom` mode, which it re-enters solely within 8px of the exact bottom + * — with the thread's bottom padding you're rarely that close, so without this a submit can leave + * the new prompt (and the streaming response) below the fold. `scrollToEnd` also puts the engine + * back into `following-bottom`, so the response streams with the view following. + */ +function ScrollToEndOnSubmit({ items }: { items: ConversationItem[] }) { + const { scrollToEnd } = useChatMessageScroller(); + const lastItem = items.at(-1); + const userMessageCount = useMemo( + () => + items.reduce((n, item) => (item.type === "user_message" ? n + 1 : n), 0), + [items], + ); + const prevCountRef = useRef(userMessageCount); + + useLayoutEffect(() => { + const previous = prevCountRef.current; + prevCountRef.current = userMessageCount; + if (previous === 0 || userMessageCount <= previous) return; + if (lastItem?.type !== "user_message") return; + scrollToEnd({ behavior: "auto" }); + }, [userMessageCount, lastItem, scrollToEnd]); + + return null; +} + /** The scroll body, under the Provider so the overlay + scroll-button hooks can read engine state. */ function ThreadScrollBody({ items, @@ -538,6 +566,7 @@ function ThreadScrollBody({ return ( + {keyedRows.map(({ item, key }) => ( @@ -700,6 +729,11 @@ export function ChatThread({ Date: Thu, 2 Jul 2026 11:39:46 +0100 Subject: [PATCH 4/4] fix(thread): recapture bottom-follow when large streamed blocks outrun it The engine guards each autoscroll with a 180ms grace window; a big streamed block can jank past it, so the engine sees content below the fold while not autoscrolling and demotes itself to free-scrolling mid-reply. Arm a pin on submit that re-issues scrollToEnd whenever content slips below the fold, disarmed by user scroll intent. Generated-By: PostHog Code Task-Id: c30ae5a8-6f18-4a44-882e-a1b29ea84154 --- .../components/chat-thread/ChatThread.tsx | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 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 d96f92d9aa..f4db936a3e 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -19,6 +19,7 @@ import { ChatMessageScrollerViewport, cn, useChatMessageScroller, + useChatMessageScrollerScrollable, useChatMessageScrollerVisibility, } from "@posthog/quill"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; @@ -513,14 +514,23 @@ const ThreadRow = memo(function ThreadRow({ }); /** - * Scrolls to the end when the user sends a prompt, regardless of current position. The engine only - * auto-follows in `following-bottom` mode, which it re-enters solely within 8px of the exact bottom - * — with the thread's bottom padding you're rarely that close, so without this a submit can leave - * the new prompt (and the streaming response) below the fold. `scrollToEnd` also puts the engine - * back into `following-bottom`, so the response streams with the view following. + * Keeps the view pinned to the bottom from prompt submit until the user scrolls away. + * + * The engine's own follow mode isn't enough on its own: + * - It only re-engages within `scrollEdgeThreshold` of the exact bottom, so a submit from anywhere + * higher would leave the new prompt (and the reply) below the fold. Scrolling to the end on + * submit also flips the engine back into `following-bottom`. + * - Each engine autoscroll is guarded by a 180ms grace window; a large streamed block (heavy + * markdown render) can jank past it, making the engine observe "content below the fold while not + * autoscrolling" and silently demote itself to `free-scrolling` mid-reply. While armed, any + * 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. */ -function ScrollToEndOnSubmit({ items }: { items: ConversationItem[] }) { +function ThreadAutoFollow({ items }: { items: ConversationItem[] }) { const { scrollToEnd } = useChatMessageScroller(); + const { end } = useChatMessageScrollerScrollable(); const lastItem = items.at(-1); const userMessageCount = useMemo( () => @@ -528,16 +538,45 @@ function ScrollToEndOnSubmit({ items }: { items: ConversationItem[] }) { [items], ); const prevCountRef = useRef(userMessageCount); + const armedRef = useRef(false); + const probeRef = useRef(null); useLayoutEffect(() => { const previous = prevCountRef.current; prevCountRef.current = userMessageCount; if (previous === 0 || userMessageCount <= previous) return; if (lastItem?.type !== "user_message") return; + armedRef.current = true; scrollToEnd({ behavior: "auto" }); }, [userMessageCount, lastItem, scrollToEnd]); - return null; + useEffect(() => { + const viewport = probeRef.current + ?.closest('[data-slot="chat-message-scroller"]') + ?.querySelector('[data-slot="chat-message-scroller-viewport"]'); + if (!viewport) return; + const disarm = () => { + armedRef.current = false; + }; + const events = ["wheel", "touchmove", "pointerdown", "keydown"] as const; + for (const event of events) { + viewport.addEventListener(event, disarm, { passive: true }); + } + return () => { + for (const event of events) { + viewport.removeEventListener(event, disarm); + } + }; + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: re-check on every streamed change — `end` alone doesn't re-notify while it stays true across commits. + useEffect(() => { + if (armedRef.current && end) { + scrollToEnd({ behavior: "auto" }); + } + }, [items, end, scrollToEnd]); + + return