@@ -244,6 +244,7 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
244244import { ExpandedImageDialog } from "./chat/ExpandedImageDialog" ;
245245import { PullRequestThreadDialog } from "./PullRequestThreadDialog" ;
246246import { MessagesTimeline } from "./chat/MessagesTimeline" ;
247+ import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic" ;
247248import { ChatHeader } from "./chat/ChatHeader" ;
248249import { PanelLayoutControls , RightPanelMaximizeControl } from "./chat/PanelLayoutControls" ;
249250import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview" ;
@@ -3567,6 +3568,10 @@ function ChatViewContent(props: ChatViewProps) {
35673568 new Debouncer ( ( ) => setShowScrollToBottom ( true ) , { wait : 150 } ) ,
35683569 ) ;
35693570 const timelineScrollModeRef = useRef < TimelineScrollMode > ( "following-end" ) ;
3571+ // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd
3572+ // re-pins on its own (independent of the refs), so the timeline needs a
3573+ // render-visible flag to switch it off once the user scrolls away.
3574+ const [ timelineLiveFollowEnabled , setTimelineLiveFollowEnabled ] = useState ( true ) ;
35703575 const pendingTimelineAnchorRef = useRef < MessageId | null > ( null ) ;
35713576 const positionedTimelineAnchorRef = useRef < MessageId | null > ( null ) ;
35723577 const settledTimelineAnchorRef = useRef < MessageId | null > ( null ) ;
@@ -3583,6 +3588,7 @@ function ChatViewContent(props: ChatViewProps) {
35833588 anchorUserScrollGenerationRef . current += 1 ;
35843589 timelineScrollModeRef . current = "free-scrolling" ;
35853590 liveFollowUserScrollGenerationRef . current = null ;
3591+ setTimelineLiveFollowEnabled ( false ) ;
35863592 pendingTimelineAnchorRef . current = null ;
35873593 positionedTimelineAnchorRef . current = null ;
35883594 settledTimelineAnchorRef . current = null ;
@@ -3654,6 +3660,7 @@ function ChatViewContent(props: ChatViewProps) {
36543660 isAtEndRef . current = true ;
36553661 timelineScrollModeRef . current = "following-end" ;
36563662 liveFollowUserScrollGenerationRef . current = anchorUserScrollGenerationRef . current ;
3663+ setTimelineLiveFollowEnabled ( true ) ;
36573664 pendingTimelineAnchorRef . current = null ;
36583665 activeTimelineAnchorIndexRef . current = null ;
36593666 showScrollDebouncer . current . cancel ( ) ;
@@ -3662,37 +3669,120 @@ function ChatViewContent(props: ChatViewProps) {
36623669 } , [ ] ) ;
36633670 useEffect ( ( ) => {
36643671 let removeListeners : ( ( ) => void ) | null = null ;
3665- const frame = requestAnimationFrame ( ( ) => {
3666- const scrollNode = legendListRef . current ?. getScrollableNode ( ) ;
3667- if ( ! scrollNode ) {
3668- return ;
3669- }
3670- const handleManualNavigation = ( ) => {
3671- cancelTimelineLiveFollowForUserNavigationRef . current ( ) ;
3672- } ;
3673- scrollNode . addEventListener ( "wheel" , handleManualNavigation , {
3674- passive : true ,
3675- } ) ;
3676- scrollNode . addEventListener ( "touchmove" , handleManualNavigation , {
3677- passive : true ,
3678- } ) ;
3679- scrollNode . addEventListener ( "pointerdown" , handleManualNavigation , {
3680- passive : true ,
3672+ let frame : number | null = null ;
3673+ const attach = ( remainingAttempts : number ) => {
3674+ frame = requestAnimationFrame ( ( ) => {
3675+ frame = null ;
3676+ const scrollNode = legendListRef . current ?. getScrollableNode ( ) ;
3677+ if ( ! scrollNode ) {
3678+ // The list may not have mounted on the first frame after a thread
3679+ // switch — without a retry the opt-out listeners never attach and
3680+ // live-follow becomes impossible to escape for the whole thread.
3681+ if ( remainingAttempts > 0 ) {
3682+ attach ( remainingAttempts - 1 ) ;
3683+ }
3684+ return ;
3685+ }
3686+ const handleManualNavigation = ( ) => {
3687+ cancelTimelineLiveFollowForUserNavigationRef . current ( ) ;
3688+ } ;
3689+ // The gestures below must only break follow when they can actually
3690+ // move the viewport away from the live edge. Follow now gates
3691+ // LegendList's maintainScrollAtEnd, so a spurious break while pinned
3692+ // at the end produces no scroll event, never re-arms, and streaming
3693+ // silently stops following. Underflowing content can't scroll at all,
3694+ // so nothing there should break follow.
3695+ const contentScrollsUp = ( ) => timelineRealContentOverflowsViewport ( ) ;
3696+ // The follow re-arm band, not the strict flag: streaming growth makes
3697+ // isAtEnd flicker false for a frame before the follow scroll catches
3698+ // up, and a gesture landing in that window while still pinned would
3699+ // otherwise break follow with no scroll event left to re-arm it.
3700+ const viewportIsAwayFromEnd = ( ) =>
3701+ resolveTimelineIsAtEnd ( legendListRef . current ?. getState ( ) , composerOverlayHeight ) ===
3702+ false ;
3703+ // Only an upward wheel is a navigation intent; wheeling down while
3704+ // following either does nothing (at the end) or moves toward it.
3705+ const handleWheel = ( event : WheelEvent ) => {
3706+ if ( event . deltaY < 0 && contentScrollsUp ( ) ) {
3707+ handleManualNavigation ( ) ;
3708+ }
3709+ } ;
3710+ // Touch direction isn't observable here (touchmove fires on any
3711+ // finger motion, scrolling or not), so break only once the drag has
3712+ // actually carried the viewport out of the end band — an upward flick
3713+ // gets there within its first few events and later touchmoves break.
3714+ const handleTouchMove = ( ) => {
3715+ if ( viewportIsAwayFromEnd ( ) ) {
3716+ handleManualNavigation ( ) ;
3717+ }
3718+ } ;
3719+ // Scrollbar drags produce no wheel/touch events; they are the only
3720+ // pointerdowns whose target is the scroll node itself rather than a
3721+ // message row. Content clicks break follow only away from the end
3722+ // (reading or selecting up there must hold position); clicking near
3723+ // the live edge keeps following.
3724+ const handlePointerDown = ( event : PointerEvent ) => {
3725+ if ( event . target === scrollNode ) {
3726+ if ( contentScrollsUp ( ) ) {
3727+ handleManualNavigation ( ) ;
3728+ }
3729+ return ;
3730+ }
3731+ if ( viewportIsAwayFromEnd ( ) ) {
3732+ handleManualNavigation ( ) ;
3733+ }
3734+ } ;
3735+ // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and
3736+ // pointer events entirely; without this the timeline yanks back to
3737+ // the end on the next stream chunk.
3738+ const handleKeyDown = ( event : KeyboardEvent ) => {
3739+ switch ( event . key ) {
3740+ case "PageUp" :
3741+ case "Home" :
3742+ case "ArrowUp" :
3743+ if ( contentScrollsUp ( ) ) {
3744+ handleManualNavigation ( ) ;
3745+ }
3746+ break ;
3747+ default :
3748+ break ;
3749+ }
3750+ } ;
3751+ scrollNode . addEventListener ( "wheel" , handleWheel , {
3752+ passive : true ,
3753+ } ) ;
3754+ scrollNode . addEventListener ( "touchmove" , handleTouchMove , {
3755+ passive : true ,
3756+ } ) ;
3757+ scrollNode . addEventListener ( "pointerdown" , handlePointerDown , {
3758+ passive : true ,
3759+ } ) ;
3760+ scrollNode . addEventListener ( "keydown" , handleKeyDown ) ;
3761+ removeListeners = ( ) => {
3762+ scrollNode . removeEventListener ( "wheel" , handleWheel ) ;
3763+ scrollNode . removeEventListener ( "touchmove" , handleTouchMove ) ;
3764+ scrollNode . removeEventListener ( "pointerdown" , handlePointerDown ) ;
3765+ scrollNode . removeEventListener ( "keydown" , handleKeyDown ) ;
3766+ } ;
36813767 } ) ;
3682- removeListeners = ( ) => {
3683- scrollNode . removeEventListener ( "wheel" , handleManualNavigation ) ;
3684- scrollNode . removeEventListener ( "touchmove" , handleManualNavigation ) ;
3685- scrollNode . removeEventListener ( "pointerdown" , handleManualNavigation ) ;
3686- } ;
3687- } ) ;
3768+ } ;
3769+ attach ( 12 ) ;
36883770
36893771 return ( ) => {
3690- cancelAnimationFrame ( frame ) ;
3772+ if ( frame !== null ) {
3773+ cancelAnimationFrame ( frame ) ;
3774+ }
36913775 removeListeners ?.( ) ;
36923776 } ;
3693- } , [ activeThread ?. id ] ) ;
3777+ } , [ activeThread ?. id , composerOverlayHeight , timelineRealContentOverflowsViewport ] ) ;
36943778
36953779 const onTimelineAnchorReady = useCallback ( ( messageId : MessageId , anchorIndex : number ) => {
3780+ // Anchored-end space can be remeasured when the turn completes. Once the
3781+ // user has scrolled away (or returned to ordinary end-following), that
3782+ // remeasurement must not restart the send-time anchor positioning.
3783+ if ( timelineScrollModeRef . current !== "anchoring-new-turn" ) {
3784+ return ;
3785+ }
36963786 if ( pendingTimelineAnchorRef . current === messageId ) {
36973787 pendingTimelineAnchorRef . current = null ;
36983788 }
@@ -3798,6 +3888,7 @@ function ChatViewContent(props: ChatViewProps) {
37983888 if ( isAtEnd ) {
37993889 timelineScrollModeRef . current = "following-end" ;
38003890 liveFollowUserScrollGenerationRef . current = anchorUserScrollGenerationRef . current ;
3891+ setTimelineLiveFollowEnabled ( true ) ;
38013892 showScrollDebouncer . current . cancel ( ) ;
38023893 setShowScrollToBottom ( false ) ;
38033894 } else {
@@ -3878,6 +3969,7 @@ function ChatViewContent(props: ChatViewProps) {
38783969 isAtEndRef . current = true ;
38793970 timelineScrollModeRef . current = "following-end" ;
38803971 liveFollowUserScrollGenerationRef . current = anchorUserScrollGenerationRef . current ;
3972+ setTimelineLiveFollowEnabled ( true ) ;
38813973 pendingTimelineAnchorRef . current = null ;
38823974 positionedTimelineAnchorRef . current = null ;
38833975 settledTimelineAnchorRef . current = null ;
@@ -4945,6 +5037,7 @@ function ChatViewContent(props: ChatViewProps) {
49455037 isAtEndRef . current = true ;
49465038 timelineScrollModeRef . current = "anchoring-new-turn" ;
49475039 liveFollowUserScrollGenerationRef . current = anchorUserScrollGenerationRef . current ;
5040+ setTimelineLiveFollowEnabled ( true ) ;
49485041 pendingTimelineAnchorRef . current = messageIdForSend ;
49495042 activeTimelineAnchorIndexRef . current = null ;
49505043 showScrollDebouncer . current . cancel ( ) ;
@@ -5389,6 +5482,7 @@ function ChatViewContent(props: ChatViewProps) {
53895482 isAtEndRef . current = true ;
53905483 timelineScrollModeRef . current = "anchoring-new-turn" ;
53915484 liveFollowUserScrollGenerationRef . current = anchorUserScrollGenerationRef . current ;
5485+ setTimelineLiveFollowEnabled ( true ) ;
53925486 pendingTimelineAnchorRef . current = messageIdForSend ;
53935487 activeTimelineAnchorIndexRef . current = null ;
53945488 showScrollDebouncer . current . cancel ( ) ;
@@ -6055,6 +6149,7 @@ function ChatViewContent(props: ChatViewProps) {
60556149 onAnchorReady = { onTimelineAnchorReady }
60566150 onAnchorSizeChanged = { onTimelineAnchorSizeChanged }
60576151 contentInsetEndAdjustment = { composerOverlayHeight }
6152+ liveFollowEnabled = { timelineLiveFollowEnabled }
60586153 onIsAtEndChange = { onIsAtEndChange }
60596154 onManualNavigation = { cancelTimelineLiveFollowForUserNavigation }
60606155 hideEmptyPlaceholder = { isDraftHeroState || threadDetailLoading }
0 commit comments