From 63fa9e264dca1c925ce3e906ecde94e078e462e3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 20:23:24 -0700 Subject: [PATCH 1/2] fix: stop streaming updates from yanking the thread scroll position On both web and mobile, scrolling up while a thread streamed would snap back to the bottom on the next chunk. Mobile had no user-scroll latch at all (LegendList's maintainScrollAtEnd re-pinned on pure geometry); web re-armed live-follow within half a viewport of the end and let the library's own maintain re-pin regardless of the app's follow state. Follow now breaks when the user scrolls away and re-arms only when the viewport actually returns to the bottom, on send, or on thread switch. Co-Authored-By: Claude Fable 5 --- .../src/features/threads/ThreadFeed.tsx | 71 ++++++++- apps/web/src/components/ChatView.tsx | 137 +++++++++++++++--- .../components/chat/MessagesTimeline.logic.ts | 32 +++- .../components/chat/MessagesTimeline.test.tsx | 33 ++++- .../src/components/chat/MessagesTimeline.tsx | 14 +- 5 files changed, 252 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index fd8ffb270cb..28df94b529b 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1335,6 +1335,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const [viewportHeight, setViewportHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); + // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed + // whenever the viewport drifts back inside its geometric threshold, which + // yanked users off history they were reading every time a stream chunk grew + // a row. Follow breaks when the user scrolls up and away, and re-arms only + // when the list actually returns to the end (or on send / thread switch). + const [endFollowEnabled, setEndFollowEnabled] = useState(true); + const endFollowEnabledRef = useRef(true); + // A "user scroll session" spans from drag start through the end of its + // momentum; only motion inside a session can break follow, so MVCP + // compensations and programmatic scrolls never strand a follower. + const userScrollSessionRef = useRef(false); + const setEndFollow = useCallback((enabled: boolean) => { + if (endFollowEnabledRef.current === enabled) { + return; + } + endFollowEnabledRef.current = enabled; + setEndFollowEnabled(enabled); + }, []); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1454,9 +1472,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; nearListEnd.value = contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height; + + // Latch bookkeeping. LegendList recomputes its inset-aware end distance + // before invoking this handler, so getState() is current. Returning to + // the end re-arms follow no matter who scrolled (the user, or our own + // scroll-to-end); moving away breaks it only during a user-initiated + // scroll session, so MVCP compensations and programmatic repositioning + // can never strand a follower. + const listState = props.listRef.current?.getState(); + if (listState) { + if (listState.isWithinMaintainScrollAtEndThreshold) { + setEndFollow(true); + } else if (userScrollSessionRef.current) { + setEndFollow(false); + } + } }, - [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd], + [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd, props.listRef, setEndFollow], ); + const handleScrollBeginDrag = useCallback(() => { + userScrollSessionRef.current = true; + }, []); + // The session must survive past finger-lift so momentum that carries the + // user away from the end still breaks follow; a drag released with no + // momentum ends its session at the release itself, otherwise at momentum + // end. Leaving a session open would let a later animated maintain-scroll + // read as user motion and break follow spuriously. + const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent) => { + const velocity = event.nativeEvent.velocity?.y ?? 0; + if (Math.abs(velocity) < 0.05) { + userScrollSessionRef.current = false; + } + }, []); + const handleMomentumScrollEnd = useCallback(() => { + userScrollSessionRef.current = false; + }, []); // Gated variant of the 180ms feed layout slide. Instant while browsing // history: maintainVisibleContentPosition compensates the scroll offset in @@ -1496,6 +1546,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reportHeaderMaterialVisibility(false); }, [props.threadId, reportHeaderMaterialVisibility]); + // A thread switch opens pinned to the end; a send explicitly returns to the + // live edge (ThreadDetailScreen scrolls the new message into place). Both + // re-arm follow regardless of where the user had scrolled before. + useEffect(() => { + userScrollSessionRef.current = false; + setEndFollow(true); + }, [props.threadId, setEndFollow]); + useEffect(() => { + if (props.anchorMessageId !== null) { + userScrollSessionRef.current = false; + setEndFollow(true); + } + }, [props.anchorMessageId, setEndFollow]); + const expandedWorkGroupIds = useMemo(() => { const ids = new Set(); for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) { @@ -1847,7 +1911,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // anchor scrolls also lets it correct a scroll that landed on a // stale end target once the anchor row finishes measuring. maintainScrollAtEnd={ - disclosureToggleSettling + disclosureToggleSettling || !endFollowEnabled ? false : { animated: true, @@ -1896,6 +1960,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { alignItemsAtEnd initialScrollAtEnd onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollEnd={handleMomentumScrollEnd} scrollEventThrottle={16} ListHeaderComponent={ <> diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f17e7021c44..b4c6f9a5c43 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -244,6 +244,7 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -3567,6 +3568,10 @@ function ChatViewContent(props: ChatViewProps) { new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); const timelineScrollModeRef = useRef("following-end"); + // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd + // re-pins on its own (independent of the refs), so the timeline needs a + // render-visible flag to switch it off once the user scrolls away. + const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); @@ -3583,6 +3588,7 @@ function ChatViewContent(props: ChatViewProps) { anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; + setTimelineLiveFollowEnabled(false); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; @@ -3654,6 +3660,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -3662,35 +3669,112 @@ function ChatViewContent(props: ChatViewProps) { }, []); useEffect(() => { let removeListeners: (() => void) | null = null; - const frame = requestAnimationFrame(() => { - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - scrollNode.addEventListener("wheel", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handleManualNavigation, { - passive: true, + let frame: number | null = null; + const attach = (remainingAttempts: number) => { + frame = requestAnimationFrame(() => { + frame = null; + const scrollNode = legendListRef.current?.getScrollableNode(); + if (!scrollNode) { + // The list may not have mounted on the first frame after a thread + // switch — without a retry the opt-out listeners never attach and + // live-follow becomes impossible to escape for the whole thread. + if (remainingAttempts > 0) { + attach(remainingAttempts - 1); + } + return; + } + const handleManualNavigation = () => { + cancelTimelineLiveFollowForUserNavigationRef.current(); + }; + // The gestures below must only break follow when they can actually + // move the viewport away from the live edge. Follow now gates + // LegendList's maintainScrollAtEnd, so a spurious break while pinned + // at the end produces no scroll event, never re-arms, and streaming + // silently stops following. Underflowing content can't scroll at all, + // so nothing there should break follow. + const contentScrollsUp = () => timelineRealContentOverflowsViewport(); + // The follow re-arm band, not the strict flag: streaming growth makes + // isAtEnd flicker false for a frame before the follow scroll catches + // up, and a gesture landing in that window while still pinned would + // otherwise break follow with no scroll event left to re-arm it. + const viewportIsAwayFromEnd = () => + resolveTimelineIsAtEnd(legendListRef.current?.getState(), composerOverlayHeight) === + false; + // Only an upward wheel is a navigation intent; wheeling down while + // following either does nothing (at the end) or moves toward it. + const handleWheel = (event: WheelEvent) => { + if (event.deltaY < 0 && contentScrollsUp()) { + handleManualNavigation(); + } + }; + // Touch direction isn't observable here (touchmove fires on any + // finger motion, scrolling or not), so break only once the drag has + // actually carried the viewport out of the end band — an upward flick + // gets there within its first few events and later touchmoves break. + const handleTouchMove = () => { + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Scrollbar drags produce no wheel/touch events; they are the only + // pointerdowns whose target is the scroll node itself rather than a + // message row. Content clicks break follow only away from the end + // (reading or selecting up there must hold position); clicking near + // the live edge keeps following. + const handlePointerDown = (event: PointerEvent) => { + if (event.target === scrollNode) { + if (contentScrollsUp()) { + handleManualNavigation(); + } + return; + } + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and + // pointer events entirely; without this the timeline yanks back to + // the end on the next stream chunk. + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case "PageUp": + case "Home": + case "ArrowUp": + if (contentScrollsUp()) { + handleManualNavigation(); + } + break; + default: + break; + } + }; + scrollNode.addEventListener("wheel", handleWheel, { + passive: true, + }); + scrollNode.addEventListener("touchmove", handleTouchMove, { + passive: true, + }); + scrollNode.addEventListener("pointerdown", handlePointerDown, { + passive: true, + }); + scrollNode.addEventListener("keydown", handleKeyDown); + removeListeners = () => { + scrollNode.removeEventListener("wheel", handleWheel); + scrollNode.removeEventListener("touchmove", handleTouchMove); + scrollNode.removeEventListener("pointerdown", handlePointerDown); + scrollNode.removeEventListener("keydown", handleKeyDown); + }; }); - removeListeners = () => { - scrollNode.removeEventListener("wheel", handleManualNavigation); - scrollNode.removeEventListener("touchmove", handleManualNavigation); - scrollNode.removeEventListener("pointerdown", handleManualNavigation); - }; - }); + }; + attach(12); return () => { - cancelAnimationFrame(frame); + if (frame !== null) { + cancelAnimationFrame(frame); + } removeListeners?.(); }; - }, [activeThread?.id]); + }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { if (pendingTimelineAnchorRef.current === messageId) { @@ -3798,6 +3882,7 @@ function ChatViewContent(props: ChatViewProps) { if (isAtEnd) { timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { @@ -3878,6 +3963,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; @@ -4936,6 +5022,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -5380,6 +5467,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -6046,6 +6134,7 @@ function ChatViewContent(props: ChatViewProps) { onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} + liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index e5ecdbd2004..c204499273a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -18,11 +18,37 @@ export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; export interface TimelineEndState { readonly isAtEnd?: boolean; - readonly isNearEnd?: boolean; + readonly contentLength?: number; + readonly scroll?: number; + readonly scrollLength?: number; } -export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { - return state?.isNearEnd ?? state?.isAtEnd; +/** + * Follow re-arm band above the hard bottom. Strict on purpose: LegendList's + * isNearEnd fires within half a viewport, which re-armed live-follow while the + * user was reading history and yanked them back down on the next stream chunk. + * A small pixel band (instead of the 1px isAtEnd epsilon alone) keeps re-arming + * reliable while streaming content is still growing under the viewport. + */ +export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; + +export function resolveTimelineIsAtEnd( + state: TimelineEndState | undefined, + endInset = 0, +): boolean | undefined { + if (!state) { + return undefined; + } + if (state.isAtEnd) { + return true; + } + const { contentLength, scroll, scrollLength } = state; + if (contentLength === undefined || scroll === undefined || scrollLength === undefined) { + return state.isAtEnd; + } + // contentLength includes the end inset (composer overlay), so subtract it to + // measure the distance to the real content bottom. + return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; } export function resolveTimelineMinimapHeightStyle(itemCount: number): string { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83ca7d3e952..cf055f05b74 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -194,6 +194,7 @@ function buildProps() { onAnchorReady: () => {}, onAnchorSizeChanged: () => {}, contentInsetEndAdjustment: 0, + liveFollowEnabled: true, onIsAtEndChange: () => {}, onManualNavigation: () => {}, }; @@ -296,7 +297,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("1 changed file"); }); - it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => { + it("treats only the strict list end as the live edge", async () => { const { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -307,10 +308,36 @@ describe("MessagesTimeline", () => { resolveTimelineMinimapTopPercent, } = await import("./MessagesTimeline.logic"); - expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true); - expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false); expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true); expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined(); + // Within the pixel band above the content bottom counts as the end... + expect( + resolveTimelineIsAtEnd({ + isAtEnd: false, + contentLength: 2000, + scroll: 1170, + scrollLength: 800, + }), + ).toBe(true); + // ...but half a viewport up (LegendList's isNearEnd territory) does not. + expect( + resolveTimelineIsAtEnd({ + isAtEnd: false, + contentLength: 2000, + scroll: 900, + scrollLength: 800, + }), + ).toBe(false); + // The composer inset is part of contentLength and must not count as + // distance-to-end. + expect( + resolveTimelineIsAtEnd( + { isAtEnd: false, contentLength: 2100, scroll: 1170, scrollLength: 800 }, + 100, + ), + ).toBe(true); + // Geometry missing (older state shape): fall back to the strict flag. + expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false); expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8e27b7b6962..a5fb0360204 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -219,6 +219,13 @@ interface MessagesTimelineProps { onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; + /** + * Whether the timeline should keep pinning to the live edge as content + * grows. Off while the user is reading history; LegendList's own + * maintainScrollAtEnd would otherwise re-pin regardless of ChatView's + * scroll-mode refs whenever the user drifts near the bottom. + */ + liveFollowEnabled: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; @@ -258,6 +265,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, + liveFollowEnabled, onIsAtEndChange, onManualNavigation, hideEmptyPlaceholder = false, @@ -401,7 +409,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); - const isAtEnd = resolveTimelineIsAtEnd(state); + const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment); if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } @@ -427,7 +435,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + }, [contentInsetEndAdjustment, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -543,7 +551,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace + anchoredEndSpace || !liveFollowEnabled ? false : { animated: false, From f7fa3137265af2aaf2d80f7b4a0e869c4836e956 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 21:08:32 -0700 Subject: [PATCH 2/2] fix(web): keep scroll position when a turn completes --- apps/web/src/components/ChatView.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b4c6f9a5c43..a924e4dcd1d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3777,6 +3777,12 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { + // Anchored-end space can be remeasured when the turn completes. Once the + // user has scrolled away (or returned to ordinary end-following), that + // remeasurement must not restart the send-time anchor positioning. + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } if (pendingTimelineAnchorRef.current === messageId) { pendingTimelineAnchorRef.current = null; }