@@ -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" ;
@@ -513,31 +514,69 @@ const ThreadRow = memo(function ThreadRow({
513514} ) ;
514515
515516/**
516- * Scrolls to the end when the user sends a prompt, regardless of current position. The engine only
517- * auto-follows in `following-bottom` mode, which it re-enters solely within 8px of the exact bottom
518- * — with the thread's bottom padding you're rarely that close, so without this a submit can leave
519- * the new prompt (and the streaming response) below the fold. `scrollToEnd` also puts the engine
520- * back into `following-bottom`, so the response streams with the view following.
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.
521530 */
522- function ScrollToEndOnSubmit ( { items } : { items : ConversationItem [ ] } ) {
531+ function ThreadAutoFollow ( { items } : { items : ConversationItem [ ] } ) {
523532 const { scrollToEnd } = useChatMessageScroller ( ) ;
533+ const { end } = useChatMessageScrollerScrollable ( ) ;
524534 const lastItem = items . at ( - 1 ) ;
525535 const userMessageCount = useMemo (
526536 ( ) =>
527537 items . reduce ( ( n , item ) => ( item . type === "user_message" ? n + 1 : n ) , 0 ) ,
528538 [ items ] ,
529539 ) ;
530540 const prevCountRef = useRef ( userMessageCount ) ;
541+ const armedRef = useRef ( false ) ;
542+ const probeRef = useRef < HTMLSpanElement > ( null ) ;
531543
532544 useLayoutEffect ( ( ) => {
533545 const previous = prevCountRef . current ;
534546 prevCountRef . current = userMessageCount ;
535547 if ( previous === 0 || userMessageCount <= previous ) return ;
536548 if ( lastItem ?. type !== "user_message" ) return ;
549+ armedRef . current = true ;
537550 scrollToEnd ( { behavior : "auto" } ) ;
538551 } , [ userMessageCount , lastItem , scrollToEnd ] ) ;
539552
540- return null ;
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" /> ;
541580}
542581
543582/** The scroll body, under the Provider so the overlay + scroll-button hooks can read engine state. */
@@ -566,7 +605,7 @@ function ThreadScrollBody({
566605 return (
567606 < ChatMessageScroller className = "group/thread" >
568607 < StickyHeaderOverlay items = { items } />
569- < ScrollToEndOnSubmit items = { items } />
608+ < ThreadAutoFollow items = { items } />
570609 < ChatMessageScrollerViewport >
571610 < ChatMessageScrollerContent className = "py-4 pb-8" density = "default" >
572611 { keyedRows . map ( ( { item, key } ) => (
0 commit comments