authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 00:32:01-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-06 21:32:01-07:00
log1c7d059f550a53dd94d5b9802640ecd11b759d1a
tree5182c84b4739a354616240fde9020aadb74a3094
parent6fa457607886caf096e7871b67e447f76d3772f6
signature Signed by PGP key B5690EEEBB952194

fix: scrolling up during a running thread no longer snaps back to the bottom (#5566)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

5 files changed, 258 insertions(+), 35 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+69-2
...@@ -1335,6 +1335,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1335,6 +1335,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1335 );1335 );
1336 const [viewportHeight, setViewportHeight] = useState(0);1336 const [viewportHeight, setViewportHeight] = useState(0);
1337 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);1337 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
1338 // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
1339 // whenever the viewport drifts back inside its geometric threshold, which
1340 // yanked users off history they were reading every time a stream chunk grew
1341 // a row. Follow breaks when the user scrolls up and away, and re-arms only
1342 // when the list actually returns to the end (or on send / thread switch).
1343 const [endFollowEnabled, setEndFollowEnabled] = useState(true);
1344 const endFollowEnabledRef = useRef(true);
1345 // A "user scroll session" spans from drag start through the end of its
1346 // momentum; only motion inside a session can break follow, so MVCP
1347 // compensations and programmatic scrolls never strand a follower.
1348 const userScrollSessionRef = useRef(false);
1349 const setEndFollow = useCallback((enabled: boolean) => {
1350 if (endFollowEnabledRef.current === enabled) {
1351 return;
1352 }
1353 endFollowEnabledRef.current = enabled;
1354 setEndFollowEnabled(enabled);
1355 }, []);
1338 const [interactionState, setInteractionState] = useState<{1356 const [interactionState, setInteractionState] = useState<{
1339 readonly copiedRowId: string | null;1357 readonly copiedRowId: string | null;
1340 readonly expandedWorkGroups: Record<string, boolean>;1358 readonly expandedWorkGroups: Record<string, boolean>;
...@@ -1454,9 +1472,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1454,9 +1472,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1454 const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;1472 const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
1455 nearListEnd.value =1473 nearListEnd.value =
1456 contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height;1474 contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height;
1475
1476 // Latch bookkeeping. LegendList recomputes its inset-aware end distance
1477 // before invoking this handler, so getState() is current. Returning to
1478 // the end re-arms follow no matter who scrolled (the user, or our own
1479 // scroll-to-end); moving away breaks it only during a user-initiated
1480 // scroll session, so MVCP compensations and programmatic repositioning
1481 // can never strand a follower.
1482 const listState = props.listRef.current?.getState();
1483 if (listState) {
1484 if (listState.isWithinMaintainScrollAtEndThreshold) {
1485 setEndFollow(true);
1486 } else if (userScrollSessionRef.current) {
1487 setEndFollow(false);
1488 }
1489 }
1457 },1490 },
1458 [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd],1491 [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd, props.listRef, setEndFollow],
1459 );1492 );
1493 const handleScrollBeginDrag = useCallback(() => {
1494 userScrollSessionRef.current = true;
1495 }, []);
1496 // The session must survive past finger-lift so momentum that carries the
1497 // user away from the end still breaks follow; a drag released with no
1498 // momentum ends its session at the release itself, otherwise at momentum
1499 // end. Leaving a session open would let a later animated maintain-scroll
1500 // read as user motion and break follow spuriously.
1501 const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
1502 const velocity = event.nativeEvent.velocity?.y ?? 0;
1503 if (Math.abs(velocity) < 0.05) {
1504 userScrollSessionRef.current = false;
1505 }
1506 }, []);
1507 const handleMomentumScrollEnd = useCallback(() => {
1508 userScrollSessionRef.current = false;
1509 }, []);
14601510
1461 // Gated variant of the 180ms feed layout slide. Instant while browsing1511 // Gated variant of the 180ms feed layout slide. Instant while browsing
1462 // history: maintainVisibleContentPosition compensates the scroll offset in1512 // history: maintainVisibleContentPosition compensates the scroll offset in
...@@ -1496,6 +1546,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1496,6 +1546,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1496 reportHeaderMaterialVisibility(false);1546 reportHeaderMaterialVisibility(false);
1497 }, [props.threadId, reportHeaderMaterialVisibility]);1547 }, [props.threadId, reportHeaderMaterialVisibility]);
14981548
1549 // A thread switch opens pinned to the end; a send explicitly returns to the
1550 // live edge (ThreadDetailScreen scrolls the new message into place). Both
1551 // re-arm follow regardless of where the user had scrolled before.
1552 useEffect(() => {
1553 userScrollSessionRef.current = false;
1554 setEndFollow(true);
1555 }, [props.threadId, setEndFollow]);
1556 useEffect(() => {
1557 if (props.anchorMessageId !== null) {
1558 userScrollSessionRef.current = false;
1559 setEndFollow(true);
1560 }
1561 }, [props.anchorMessageId, setEndFollow]);
1562
1499 const expandedWorkGroupIds = useMemo(() => {1563 const expandedWorkGroupIds = useMemo(() => {
1500 const ids = new Set<string>();1564 const ids = new Set<string>();
1501 for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {1565 for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {
...@@ -1847,7 +1911,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1847,7 +1911,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1847 // anchor scrolls also lets it correct a scroll that landed on a1911 // anchor scrolls also lets it correct a scroll that landed on a
1848 // stale end target once the anchor row finishes measuring.1912 // stale end target once the anchor row finishes measuring.
1849 maintainScrollAtEnd={1913 maintainScrollAtEnd={
1850 disclosureToggleSettling1914 disclosureToggleSettling || !endFollowEnabled
1851 ? false1915 ? false
1852 : {1916 : {
1853 animated: true,1917 animated: true,
...@@ -1896,6 +1960,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1896,6 +1960,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1896 alignItemsAtEnd1960 alignItemsAtEnd
1897 initialScrollAtEnd1961 initialScrollAtEnd
1898 onScroll={handleScroll}1962 onScroll={handleScroll}
1963 onScrollBeginDrag={handleScrollBeginDrag}
1964 onScrollEndDrag={handleScrollEndDrag}
1965 onMomentumScrollEnd={handleMomentumScrollEnd}
1899 scrollEventThrottle={16}1966 scrollEventThrottle={16}
1900 ListHeaderComponent={1967 ListHeaderComponent={
1901 <>1968 <>
apps/web/src/components/ChatView.tsx+119-24
...@@ -244,6 +244,7 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";...@@ -244,6 +244,7 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
244import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";244import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
245import { PullRequestThreadDialog } from "./PullRequestThreadDialog";245import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
246import { MessagesTimeline } from "./chat/MessagesTimeline";246import { MessagesTimeline } from "./chat/MessagesTimeline";
247import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
247import { ChatHeader } from "./chat/ChatHeader";248import { ChatHeader } from "./chat/ChatHeader";
248import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";249import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
249import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";250import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
...@@ -3567,6 +3568,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3567,6 +3568,10 @@ function ChatViewContent(props: ChatViewProps) {
3567 new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),3568 new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),
3568 );3569 );
3569 const timelineScrollModeRef = useRef<TimelineScrollMode>("following-end");3570 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);
3570 const pendingTimelineAnchorRef = useRef<MessageId | null>(null);3575 const pendingTimelineAnchorRef = useRef<MessageId | null>(null);
3571 const positionedTimelineAnchorRef = useRef<MessageId | null>(null);3576 const positionedTimelineAnchorRef = useRef<MessageId | null>(null);
3572 const settledTimelineAnchorRef = useRef<MessageId | null>(null);3577 const settledTimelineAnchorRef = useRef<MessageId | null>(null);
...@@ -3583,6 +3588,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3583,6 +3588,7 @@ function ChatViewContent(props: ChatViewProps) {
3583 anchorUserScrollGenerationRef.current += 1;3588 anchorUserScrollGenerationRef.current += 1;
3584 timelineScrollModeRef.current = "free-scrolling";3589 timelineScrollModeRef.current = "free-scrolling";
3585 liveFollowUserScrollGenerationRef.current = null;3590 liveFollowUserScrollGenerationRef.current = null;
3591 setTimelineLiveFollowEnabled(false);
3586 pendingTimelineAnchorRef.current = null;3592 pendingTimelineAnchorRef.current = null;
3587 positionedTimelineAnchorRef.current = null;3593 positionedTimelineAnchorRef.current = null;
3588 settledTimelineAnchorRef.current = null;3594 settledTimelineAnchorRef.current = null;
...@@ -3654,6 +3660,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3654,6 +3660,7 @@ function ChatViewContent(props: ChatViewProps) {
3654 isAtEndRef.current = true;3660 isAtEndRef.current = true;
3655 timelineScrollModeRef.current = "following-end";3661 timelineScrollModeRef.current = "following-end";
3656 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;3662 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
3663 setTimelineLiveFollowEnabled(true);
3657 pendingTimelineAnchorRef.current = null;3664 pendingTimelineAnchorRef.current = null;
3658 activeTimelineAnchorIndexRef.current = null;3665 activeTimelineAnchorIndexRef.current = null;
3659 showScrollDebouncer.current.cancel();3666 showScrollDebouncer.current.cancel();
...@@ -3662,37 +3669,120 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3662,37 +3669,120 @@ function ChatViewContent(props: ChatViewProps) {
3662 }, []);3669 }, []);
3663 useEffect(() => {3670 useEffect(() => {
3664 let removeListeners: (() => void) | null = null;3671 let removeListeners: (() => void) | null = null;
3665 const frame = requestAnimationFrame(() => {3672 let frame: number | null = null;
3666 const scrollNode = legendListRef.current?.getScrollableNode();3673 const attach = (remainingAttempts: number) => {
3667 if (!scrollNode) {3674 frame = requestAnimationFrame(() => {
3668 return;3675 frame = null;
3669 }3676 const scrollNode = legendListRef.current?.getScrollableNode();
3670 const handleManualNavigation = () => {3677 if (!scrollNode) {
3671 cancelTimelineLiveFollowForUserNavigationRef.current();3678 // The list may not have mounted on the first frame after a thread
3672 };3679 // switch — without a retry the opt-out listeners never attach and
3673 scrollNode.addEventListener("wheel", handleManualNavigation, {3680 // live-follow becomes impossible to escape for the whole thread.
3674 passive: true,3681 if (remainingAttempts > 0) {
3675 });3682 attach(remainingAttempts - 1);
3676 scrollNode.addEventListener("touchmove", handleManualNavigation, {3683 }
3677 passive: true,3684 return;
3678 });3685 }
3679 scrollNode.addEventListener("pointerdown", handleManualNavigation, {3686 const handleManualNavigation = () => {
3680 passive: true,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 };
3681 });3767 });
3682 removeListeners = () => {3768 };
3683 scrollNode.removeEventListener("wheel", handleManualNavigation);3769 attach(12);
3684 scrollNode.removeEventListener("touchmove", handleManualNavigation);
3685 scrollNode.removeEventListener("pointerdown", handleManualNavigation);
3686 };
3687 });
36883770
3689 return () => {3771 return () => {
3690 cancelAnimationFrame(frame);3772 if (frame !== null) {
3773 cancelAnimationFrame(frame);
3774 }
3691 removeListeners?.();3775 removeListeners?.();
3692 };3776 };
3693 }, [activeThread?.id]);3777 }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]);
36943778
3695 const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {3779 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 }
3696 if (pendingTimelineAnchorRef.current === messageId) {3786 if (pendingTimelineAnchorRef.current === messageId) {
3697 pendingTimelineAnchorRef.current = null;3787 pendingTimelineAnchorRef.current = null;
3698 }3788 }
...@@ -3798,6 +3888,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3798,6 +3888,7 @@ function ChatViewContent(props: ChatViewProps) {
3798 if (isAtEnd) {3888 if (isAtEnd) {
3799 timelineScrollModeRef.current = "following-end";3889 timelineScrollModeRef.current = "following-end";
3800 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;3890 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
3891 setTimelineLiveFollowEnabled(true);
3801 showScrollDebouncer.current.cancel();3892 showScrollDebouncer.current.cancel();
3802 setShowScrollToBottom(false);3893 setShowScrollToBottom(false);
3803 } else {3894 } else {
...@@ -3878,6 +3969,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3878,6 +3969,7 @@ function ChatViewContent(props: ChatViewProps) {
3878 isAtEndRef.current = true;3969 isAtEndRef.current = true;
3879 timelineScrollModeRef.current = "following-end";3970 timelineScrollModeRef.current = "following-end";
3880 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;3971 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
3972 setTimelineLiveFollowEnabled(true);
3881 pendingTimelineAnchorRef.current = null;3973 pendingTimelineAnchorRef.current = null;
3882 positionedTimelineAnchorRef.current = null;3974 positionedTimelineAnchorRef.current = null;
3883 settledTimelineAnchorRef.current = null;3975 settledTimelineAnchorRef.current = null;
...@@ -4945,6 +5037,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4945,6 +5037,7 @@ function ChatViewContent(props: ChatViewProps) {
4945 isAtEndRef.current = true;5037 isAtEndRef.current = true;
4946 timelineScrollModeRef.current = "anchoring-new-turn";5038 timelineScrollModeRef.current = "anchoring-new-turn";
4947 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;5039 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
5040 setTimelineLiveFollowEnabled(true);
4948 pendingTimelineAnchorRef.current = messageIdForSend;5041 pendingTimelineAnchorRef.current = messageIdForSend;
4949 activeTimelineAnchorIndexRef.current = null;5042 activeTimelineAnchorIndexRef.current = null;
4950 showScrollDebouncer.current.cancel();5043 showScrollDebouncer.current.cancel();
...@@ -5389,6 +5482,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5389,6 +5482,7 @@ function ChatViewContent(props: ChatViewProps) {
5389 isAtEndRef.current = true;5482 isAtEndRef.current = true;
5390 timelineScrollModeRef.current = "anchoring-new-turn";5483 timelineScrollModeRef.current = "anchoring-new-turn";
5391 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;5484 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
5485 setTimelineLiveFollowEnabled(true);
5392 pendingTimelineAnchorRef.current = messageIdForSend;5486 pendingTimelineAnchorRef.current = messageIdForSend;
5393 activeTimelineAnchorIndexRef.current = null;5487 activeTimelineAnchorIndexRef.current = null;
5394 showScrollDebouncer.current.cancel();5488 showScrollDebouncer.current.cancel();
...@@ -6055,6 +6149,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6055,6 +6149,7 @@ function ChatViewContent(props: ChatViewProps) {
6055 onAnchorReady={onTimelineAnchorReady}6149 onAnchorReady={onTimelineAnchorReady}
6056 onAnchorSizeChanged={onTimelineAnchorSizeChanged}6150 onAnchorSizeChanged={onTimelineAnchorSizeChanged}
6057 contentInsetEndAdjustment={composerOverlayHeight}6151 contentInsetEndAdjustment={composerOverlayHeight}
6152 liveFollowEnabled={timelineLiveFollowEnabled}
6058 onIsAtEndChange={onIsAtEndChange}6153 onIsAtEndChange={onIsAtEndChange}
6059 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}6154 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
6060 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}6155 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
apps/web/src/components/chat/MessagesTimeline.logic.ts+29-3
...@@ -18,11 +18,37 @@ export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;...@@ -18,11 +18,37 @@ export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;
1818
19export interface TimelineEndState {19export interface TimelineEndState {
20 readonly isAtEnd?: boolean;20 readonly isAtEnd?: boolean;
21 readonly isNearEnd?: boolean;21 readonly contentLength?: number;
22 readonly scroll?: number;
23 readonly scrollLength?: number;
22}24}
2325
24export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined {26/**
25 return state?.isNearEnd ?? state?.isAtEnd;27 * Follow re-arm band above the hard bottom. Strict on purpose: LegendList's
28 * isNearEnd fires within half a viewport, which re-armed live-follow while the
29 * user was reading history and yanked them back down on the next stream chunk.
30 * A small pixel band (instead of the 1px isAtEnd epsilon alone) keeps re-arming
31 * reliable while streaming content is still growing under the viewport.
32 */
33export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
34
35export function resolveTimelineIsAtEnd(
36 state: TimelineEndState | undefined,
37 endInset = 0,
38): boolean | undefined {
39 if (!state) {
40 return undefined;
41 }
42 if (state.isAtEnd) {
43 return true;
44 }
45 const { contentLength, scroll, scrollLength } = state;
46 if (contentLength === undefined || scroll === undefined || scrollLength === undefined) {
47 return state.isAtEnd;
48 }
49 // contentLength includes the end inset (composer overlay), so subtract it to
50 // measure the distance to the real content bottom.
51 return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
26}52}
2753
28export function resolveTimelineMinimapHeightStyle(itemCount: number): string {54export function resolveTimelineMinimapHeightStyle(itemCount: number): string {
apps/web/src/components/chat/MessagesTimeline.test.tsx+30-3
...@@ -194,6 +194,7 @@ function buildProps() {...@@ -194,6 +194,7 @@ function buildProps() {
194 onAnchorReady: () => {},194 onAnchorReady: () => {},
195 onAnchorSizeChanged: () => {},195 onAnchorSizeChanged: () => {},
196 contentInsetEndAdjustment: 0,196 contentInsetEndAdjustment: 0,
197 liveFollowEnabled: true,
197 onIsAtEndChange: () => {},198 onIsAtEndChange: () => {},
198 onManualNavigation: () => {},199 onManualNavigation: () => {},
199 };200 };
...@@ -296,7 +297,7 @@ describe("MessagesTimeline", () => {...@@ -296,7 +297,7 @@ describe("MessagesTimeline", () => {
296 expect(markup).toContain("1 changed file");297 expect(markup).toContain("1 changed file");
297 });298 });
298299
299 it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => {300 it("treats only the strict list end as the live edge", async () => {
300 const {301 const {
301 resolveTimelineIsAtEnd,302 resolveTimelineIsAtEnd,
302 resolveTimelineMinimapHasPersistentGutter,303 resolveTimelineMinimapHasPersistentGutter,
...@@ -307,10 +308,36 @@ describe("MessagesTimeline", () => {...@@ -307,10 +308,36 @@ describe("MessagesTimeline", () => {
307 resolveTimelineMinimapTopPercent,308 resolveTimelineMinimapTopPercent,
308 } = await import("./MessagesTimeline.logic");309 } = await import("./MessagesTimeline.logic");
309310
310 expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
311 expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false);
312 expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);311 expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
313 expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined();312 expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined();
313 // Within the pixel band above the content bottom counts as the end...
314 expect(
315 resolveTimelineIsAtEnd({
316 isAtEnd: false,
317 contentLength: 2000,
318 scroll: 1170,
319 scrollLength: 800,
320 }),
321 ).toBe(true);
322 // ...but half a viewport up (LegendList's isNearEnd territory) does not.
323 expect(
324 resolveTimelineIsAtEnd({
325 isAtEnd: false,
326 contentLength: 2000,
327 scroll: 900,
328 scrollLength: 800,
329 }),
330 ).toBe(false);
331 // The composer inset is part of contentLength and must not count as
332 // distance-to-end.
333 expect(
334 resolveTimelineIsAtEnd(
335 { isAtEnd: false, contentLength: 2100, scroll: 1170, scrollLength: 800 },
336 100,
337 ),
338 ).toBe(true);
339 // Geometry missing (older state shape): fall back to the strict flag.
340 expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false);
314341
315 expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))");342 expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))");
316 expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50);343 expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50);
apps/web/src/components/chat/MessagesTimeline.tsx+11-3
...@@ -219,6 +219,13 @@ interface MessagesTimelineProps {...@@ -219,6 +219,13 @@ interface MessagesTimelineProps {
219 onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;219 onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
220 onAnchorSizeChanged: (messageId: MessageId, size: number) => void;220 onAnchorSizeChanged: (messageId: MessageId, size: number) => void;
221 contentInsetEndAdjustment: number;221 contentInsetEndAdjustment: number;
222 /**
223 * Whether the timeline should keep pinning to the live edge as content
224 * grows. Off while the user is reading history; LegendList's own
225 * maintainScrollAtEnd would otherwise re-pin regardless of ChatView's
226 * scroll-mode refs whenever the user drifts near the bottom.
227 */
228 liveFollowEnabled: boolean;
222 onIsAtEndChange: (isAtEnd: boolean) => void;229 onIsAtEndChange: (isAtEnd: boolean) => void;
223 onManualNavigation: () => void;230 onManualNavigation: () => void;
224 hideEmptyPlaceholder?: boolean;231 hideEmptyPlaceholder?: boolean;
...@@ -258,6 +265,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -258,6 +265,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
258 onAnchorReady,265 onAnchorReady,
259 onAnchorSizeChanged,266 onAnchorSizeChanged,
260 contentInsetEndAdjustment,267 contentInsetEndAdjustment,
268 liveFollowEnabled,
261 onIsAtEndChange,269 onIsAtEndChange,
262 onManualNavigation,270 onManualNavigation,
263 hideEmptyPlaceholder = false,271 hideEmptyPlaceholder = false,
...@@ -401,7 +409,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -401,7 +409,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
401409
402 const handleScroll = useCallback(() => {410 const handleScroll = useCallback(() => {
403 const state = listRef.current?.getState?.();411 const state = listRef.current?.getState?.();
404 const isAtEnd = resolveTimelineIsAtEnd(state);412 const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment);
405 if (isAtEnd !== undefined) {413 if (isAtEnd !== undefined) {
406 onIsAtEndChange(isAtEnd);414 onIsAtEndChange(isAtEnd);
407 }415 }
...@@ -427,7 +435,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -427,7 +435,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
427435
428 strip.dataset.inView = inView ? "true" : "false";436 strip.dataset.inView = inView ? "true" : "false";
429 }437 }
430 }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]);438 }, [contentInsetEndAdjustment, listRef, minimapItems, minimapStripMap, onIsAtEndChange]);
431439
432 useEffect(() => {440 useEffect(() => {
433 const frame = requestAnimationFrame(handleScroll);441 const frame = requestAnimationFrame(handleScroll);
...@@ -543,7 +551,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -543,7 +551,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
543 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}551 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
544 contentInsetEndAdjustment={contentInsetEndAdjustment}552 contentInsetEndAdjustment={contentInsetEndAdjustment}
545 maintainScrollAtEnd={553 maintainScrollAtEnd={
546 anchoredEndSpace554 anchoredEndSpace || !liveFollowEnabled
547 ? false555 ? false
548 : {556 : {
549 animated: false,557 animated: false,