@@ -19,6 +19,7 @@ import {
1919 ChatMessageScrollerViewport ,
2020 cn ,
2121 useChatMessageScroller ,
22+ useChatMessageScrollerScrollable ,
2223 useChatMessageScrollerVisibility ,
2324} from "@posthog/quill" ;
2425import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared" ;
@@ -134,7 +135,7 @@ function groupToolRuns(items: ConversationItem[]): ThreadItem[] {
134135 const flush = ( ) => {
135136 if ( toolCount >= 2 ) {
136137 const tools = buffer . filter ( isToolCallItem ) ;
137- out . push ( { type : "tool_group" , id : `tool-group- ${ tools [ 0 ] . id } ` , tools } ) ;
138+ out . push ( { type : "tool_group" , id : tools [ 0 ] . id , tools } ) ;
138139 } else {
139140 out . push ( ...buffer ) ;
140141 }
@@ -512,6 +513,72 @@ const ThreadRow = memo(function ThreadRow({
512513 ) ;
513514} ) ;
514515
516+ /**
517+ * Keeps the view pinned to the bottom from prompt submit until the user scrolls away.
518+ *
519+ * The engine's own follow mode isn't enough on its own:
520+ * - It only re-engages within `scrollEdgeThreshold` of the exact bottom, so a submit from anywhere
521+ * higher would leave the new prompt (and the reply) below the fold. Scrolling to the end on
522+ * submit also flips the engine back into `following-bottom`.
523+ * - Each engine autoscroll is guarded by a 180ms grace window; a large streamed block (heavy
524+ * markdown render) can jank past it, making the engine observe "content below the fold while not
525+ * autoscrolling" and silently demote itself to `free-scrolling` mid-reply. While armed, any
526+ * commit that leaves content below the fold re-issues `scrollToEnd` to recapture follow.
527+ *
528+ * User scroll intent (wheel, touch, pointer, keys — same signals the engine listens to) disarms
529+ * the pin; the next submit or the scroll-to-bottom button re-engages following.
530+ */
531+ function ThreadAutoFollow ( { items } : { items : ConversationItem [ ] } ) {
532+ const { scrollToEnd } = useChatMessageScroller ( ) ;
533+ const { end } = useChatMessageScrollerScrollable ( ) ;
534+ const lastItem = items . at ( - 1 ) ;
535+ const userMessageCount = useMemo (
536+ ( ) =>
537+ items . reduce ( ( n , item ) => ( item . type === "user_message" ? n + 1 : n ) , 0 ) ,
538+ [ items ] ,
539+ ) ;
540+ const prevCountRef = useRef ( userMessageCount ) ;
541+ const armedRef = useRef ( false ) ;
542+ const probeRef = useRef < HTMLSpanElement > ( null ) ;
543+
544+ useLayoutEffect ( ( ) => {
545+ const previous = prevCountRef . current ;
546+ prevCountRef . current = userMessageCount ;
547+ if ( previous === 0 || userMessageCount <= previous ) return ;
548+ if ( lastItem ?. type !== "user_message" ) return ;
549+ armedRef . current = true ;
550+ scrollToEnd ( { behavior : "auto" } ) ;
551+ } , [ userMessageCount , lastItem , scrollToEnd ] ) ;
552+
553+ useEffect ( ( ) => {
554+ const viewport = probeRef . current
555+ ?. closest ( '[data-slot="chat-message-scroller"]' )
556+ ?. querySelector ( '[data-slot="chat-message-scroller-viewport"]' ) ;
557+ if ( ! viewport ) return ;
558+ const disarm = ( ) => {
559+ armedRef . current = false ;
560+ } ;
561+ const events = [ "wheel" , "touchmove" , "pointerdown" , "keydown" ] as const ;
562+ for ( const event of events ) {
563+ viewport . addEventListener ( event , disarm , { passive : true } ) ;
564+ }
565+ return ( ) => {
566+ for ( const event of events ) {
567+ viewport . removeEventListener ( event , disarm ) ;
568+ }
569+ } ;
570+ } , [ ] ) ;
571+
572+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-check on every streamed change — `end` alone doesn't re-notify while it stays true across commits.
573+ useEffect ( ( ) => {
574+ if ( armedRef . current && end ) {
575+ scrollToEnd ( { behavior : "auto" } ) ;
576+ }
577+ } , [ items , end , scrollToEnd ] ) ;
578+
579+ return < span ref = { probeRef } className = "hidden" aria-hidden = "true" /> ;
580+ }
581+
515582/** The scroll body, under the Provider so the overlay + scroll-button hooks can read engine state. */
516583function ThreadScrollBody ( {
517584 items,
@@ -525,15 +592,24 @@ function ThreadScrollBody({
525592 /** Status row (duration / context usage) pinned as the last item in the thread. */
526593 footer ?: ReactNode ;
527594} ) {
595+ const keyedRows = useMemo ( ( ) => {
596+ let userTurn = 0 ;
597+ return rows . map ( ( item ) => ( {
598+ item,
599+ key : item . type === "user_message" ? `user-turn-${ userTurn ++ } ` : item . id ,
600+ } ) ) ;
601+ } , [ rows ] ) ;
602+
528603 // `group/thread` so the footer's hover-reveal (opacity-50 → 100 on group-hover) tracks the thread,
529604 // mirroring the legacy ConversationView container.
530605 return (
531606 < ChatMessageScroller className = "group/thread" >
532607 < StickyHeaderOverlay items = { items } />
608+ < ThreadAutoFollow items = { items } />
533609 < ChatMessageScrollerViewport >
534610 < ChatMessageScrollerContent className = "py-4 pb-8" density = "default" >
535- { rows . map ( ( item ) => (
536- < ThreadRow key = { item . id } item = { item } renderItem = { renderItem } />
611+ { keyedRows . map ( ( { item, key } ) => (
612+ < ThreadRow key = { key } item = { item } renderItem = { renderItem } />
537613 ) ) }
538614 { footer && (
539615 < div
@@ -692,6 +768,11 @@ export function ChatThread({
692768 < ChatMessageScrollerProvider
693769 autoScroll
694770 defaultScrollPosition = "end"
771+ // Default is 8px: with the thread's bottom padding you're rarely that close, so
772+ // auto-follow ("following-bottom") would disengage on any stray trackpad wheel and
773+ // never re-engage. Within this band the engine recaptures follow on the next content
774+ // change; deliberate upward flicks travel past it and stay free-scrolling.
775+ scrollEdgeThreshold = { 100 }
695776 scrollPreviousItemPeek = { 64 }
696777 >
697778 < ThreadScrollBody
0 commit comments