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..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"; @@ -134,7 +135,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 +513,72 @@ const ThreadRow = memo(function ThreadRow({ ); }); +/** + * 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 ThreadAutoFollow({ items }: { items: ConversationItem[] }) { + const { scrollToEnd } = useChatMessageScroller(); + const { end } = useChatMessageScrollerScrollable(); + 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); + 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]); + + 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