Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>;
Expand Down Expand Up @@ -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<NativeScrollEvent>) => {
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
Expand Down Expand Up @@ -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<string>();
for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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={
<>
Expand Down
143 changes: 119 additions & 24 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -3567,6 +3568,10 @@ function ChatViewContent(props: ChatViewProps) {
new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),
);
const timelineScrollModeRef = useRef<TimelineScrollMode>("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<MessageId | null>(null);
const positionedTimelineAnchorRef = useRef<MessageId | null>(null);
const settledTimelineAnchorRef = useRef<MessageId | null>(null);
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -3662,37 +3669,120 @@ 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) => {
// 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;
}
Expand Down Expand Up @@ -3798,6 +3888,7 @@ function ChatViewContent(props: ChatViewProps) {
if (isAtEnd) {
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
setTimelineLiveFollowEnabled(true);
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
} else {
Expand Down Expand Up @@ -3878,6 +3969,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;
Expand Down Expand Up @@ -4936,6 +5028,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();
Expand Down Expand Up @@ -5380,6 +5473,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();
Expand Down Expand Up @@ -6046,6 +6140,7 @@ function ChatViewContent(props: ChatViewProps) {
onAnchorReady={onTimelineAnchorReady}
onAnchorSizeChanged={onTimelineAnchorSizeChanged}
contentInsetEndAdjustment={composerOverlayHeight}
liveFollowEnabled={timelineLiveFollowEnabled}
onIsAtEndChange={onIsAtEndChange}
onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
Expand Down
32 changes: 29 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading