Skip to content

Commit 1c7d059

Browse files
t3dotggclaude
andauthored
fix: scrolling up during a running thread no longer snaps back to the bottom (#5566)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6fa4576 commit 1c7d059

5 files changed

Lines changed: 258 additions & 35 deletions

File tree

apps/mobile/src/features/threads/ThreadFeed.tsx

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,6 +1335,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
13351335
);
13361336
const [viewportHeight, setViewportHeight] = useState(0);
13371337
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+
}, []);
13381356
const [interactionState, setInteractionState] = useState<{
13391357
readonly copiedRowId: string | null;
13401358
readonly expandedWorkGroups: Record<string, boolean>;
@@ -1454,9 +1472,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14541472
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
14551473
nearListEnd.value =
14561474
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+
}
14571490
},
1458-
[reportHeaderMaterialVisibility, anchorTopInset, nearListEnd],
1491+
[reportHeaderMaterialVisibility, anchorTopInset, nearListEnd, props.listRef, setEndFollow],
14591492
);
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

14611511
// Gated variant of the 180ms feed layout slide. Instant while browsing
14621512
// history: maintainVisibleContentPosition compensates the scroll offset in
@@ -1496,6 +1546,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14961546
reportHeaderMaterialVisibility(false);
14971547
}, [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+
14991563
const expandedWorkGroupIds = useMemo(() => {
15001564
const ids = new Set<string>();
15011565
for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {
@@ -1847,7 +1911,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18471911
// anchor scrolls also lets it correct a scroll that landed on a
18481912
// stale end target once the anchor row finishes measuring.
18491913
maintainScrollAtEnd={
1850-
disclosureToggleSettling
1914+
disclosureToggleSettling || !endFollowEnabled
18511915
? false
18521916
: {
18531917
animated: true,
@@ -1896,6 +1960,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18961960
alignItemsAtEnd
18971961
initialScrollAtEnd
18981962
onScroll={handleScroll}
1963+
onScrollBeginDrag={handleScrollBeginDrag}
1964+
onScrollEndDrag={handleScrollEndDrag}
1965+
onMomentumScrollEnd={handleMomentumScrollEnd}
18991966
scrollEventThrottle={16}
19001967
ListHeaderComponent={
19011968
<>

apps/web/src/components/ChatView.tsx

Lines changed: 119 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
244244
import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
245245
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
246246
import { MessagesTimeline } from "./chat/MessagesTimeline";
247+
import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
247248
import { ChatHeader } from "./chat/ChatHeader";
248249
import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
249250
import { 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}

apps/web/src/components/chat/MessagesTimeline.logic.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,37 @@ export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;
1818

1919
export interface TimelineEndState {
2020
readonly isAtEnd?: boolean;
21-
readonly isNearEnd?: boolean;
21+
readonly contentLength?: number;
22+
readonly scroll?: number;
23+
readonly scrollLength?: number;
2224
}
2325

24-
export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined {
25-
return state?.isNearEnd ?? state?.isAtEnd;
26+
/**
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+
*/
33+
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
34+
35+
export 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;
2652
}
2753

2854
export function resolveTimelineMinimapHeightStyle(itemCount: number): string {

0 commit comments

Comments
 (0)