fix(chat): anchored-scroll behavior parity with the reference model

Six reported defects, one root theme — our port diverged from the
reference semantics:

- a user gesture no longer collapses the reserved anchored end space
  (that snap-to-bottom was the 'anchoring vanishes on a micro-scroll');
  it only disarms the follow/anchor machinery, and anchor remeasures
  after the user takes over can no longer re-position the viewport
- the anchor arms synchronously BEFORE the send reaches the store, and
  the claim compares against the arm-time baseline — arming a frame
  after the optimistic row committed is why anchoring 'rarely worked'
  and sending from mid-history did nothing
- gestures are selective (wheel up, touch/pointer away from the end,
  PageUp/Home/ArrowUp), so scrolling toward the end or clicking a row
  at the live edge no longer kills follow
- 'at end' is a tight 40px band against the full content length instead
  of the list's half-viewport isNearEnd, so the scroll-to-bottom pill
  appears when the viewport actually leaves the end
- reaching the end re-arms follow without knocking an anchored turn out
  of its mode, and the overlay scrollbar suppression follows scroll
  ownership rather than the anchor's mere existence
- the status/working row moved out of the list footer to a fixed spot
  above the composer: inside the list it walked down with every
  streamed line, and its unmount was part of the end-of-stream jerk
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 01:35:45 +03:00
parent 42a8eafafc
commit 955b2cc812
5 changed files with 120 additions and 42 deletions
@@ -361,10 +361,6 @@ const ChatViewport = React.memo(({
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
<div className="mb-3">
<StatusRowContainer />
</div>
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
</>
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
@@ -417,6 +413,13 @@ const ChatViewport = React.memo(({
scrollContainerProps={scrollContainerProps}
/>
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
{/* Static above the composer: inside the list it walked down
with every streamed line while a turn was anchored. */}
<div className="pointer-events-none absolute inset-x-0 bottom-2 z-10">
<div className="pointer-events-auto [&:not(:has(*))]:hidden">
<StatusRowContainer />
</div>
</div>
{showPromptNavigator && promptTurnIds.length >= 2 ? (
<PromptNavigatorRail
turnIds={promptTurnIds}
@@ -1289,6 +1289,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
...additionalParts.flatMap(p => p.attachments ?? []),
];
// Arm the timeline anchor BEFORE the optimistic user row can commit;
// arming after (or a frame later) races the commit and the anchor
// never claims the new message.
scrollToBottom?.();
const sendPromise = sendMessage(
primaryText,
providerIdToSend,
@@ -1307,14 +1312,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
}
};
if (typeof window === 'undefined') {
scrollToBottom?.();
} else {
window.requestAnimationFrame(() => {
scrollToBottom?.();
});
}
void sendPromise.then(() => {
// Record what this session was pointed at, so the work-status panel
// can show it as a context source long after the message scrolled
@@ -184,12 +184,14 @@ describe('getAnchoredTurnMetrics', () => {
});
describe('resolveTimelineIsAtEnd', () => {
test('prefers the near-end threshold over the exact content bottom', () => {
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false);
test('uses a tight distance band against the full content length', () => {
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
});
test('falls back to the exact end when near-end is unavailable', () => {
test('falls back to the list flags when distances are unavailable', () => {
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
});
@@ -108,15 +108,35 @@ export const getAnchoredTurnMetrics = ({
};
};
// "At the end" for follow purposes is the NEAR-end threshold, not the exact
// content bottom: the timeline's footer (status row plus bottom spacer) sits
// below the last row, so requiring the exact bottom would drop out of follow —
// and pop the scroll-to-bottom pill — while the user is still looking at the
// live edge. `isAtEnd` is only the fallback for states that predate the
// near-end signal.
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
// follow while the user had genuinely scrolled away, yanking them back on the
// next stream chunk. Distance is measured against the full content length —
// reserved anchored end space included — so a parked anchored turn counts as
// the live edge.
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
export const resolveTimelineIsAtEnd = (
state: { readonly isNearEnd?: boolean; readonly isAtEnd?: boolean } | undefined,
): boolean | undefined => state?.isNearEnd ?? state?.isAtEnd;
state: {
readonly contentLength?: number;
readonly scroll?: number;
readonly scrollLength?: number;
readonly isNearEnd?: boolean;
readonly isAtEnd?: boolean;
} | undefined,
): boolean | undefined => {
if (!state) return undefined;
const { contentLength, scroll, scrollLength } = state;
if (
typeof contentLength === 'number'
&& typeof scroll === 'number'
&& typeof scrollLength === 'number'
&& Number.isFinite(contentLength)
) {
return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
}
return state.isNearEnd ?? state.isAtEnd;
};
export interface ChatListAnchoredEndSpace {
readonly anchorIndex: number;
+74 -18
View File
@@ -136,6 +136,9 @@ export const useChatTimelineScroll = ({
// it may load older pages without disturbing the read position.
const [isPinned, setIsPinned] = React.useState(true);
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
// True after a real gesture until an explicit opt back in; drives the
// overlay scrollbar suppression instead of the anchor's mere existence.
const [userOwnsScroll, setUserOwnsScroll] = React.useState(false);
const modeRef = React.useRef<TimelineScrollMode>('following-end');
const isAtEndRef = React.useRef(true);
@@ -203,13 +206,25 @@ export const useChatTimelineScroll = ({
setAnchorMessageId(null);
}, []);
// A real gesture: stop every automatic movement until the user opts back in.
// A real gesture: stop every automatic movement until the user opts back
// in. The anchored END SPACE stays — collapsing it mid-gesture clamps the
// viewport back to the end — only the anchor machinery is disarmed.
const onManualNavigation = React.useCallback(() => {
userGenerationRef.current += 1;
modeRef.current = 'free-scrolling';
liveFollowGenerationRef.current = null;
clearAnchor();
}, [clearAnchor]);
setUserOwnsScroll(true);
armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = null;
positionedAnchorRef.current = null;
settledAnchorRef.current = null;
activeAnchorIndexRef.current = null;
pendingAnchorRestoreRef.current = null;
if (anchorRestoreFrameRef.current !== null) {
cancelAnimationFrame(anchorRestoreFrameRef.current);
anchorRestoreFrameRef.current = null;
}
}, []);
const isLiveFollowActive = React.useCallback(() => (
liveFollowGenerationRef.current === userGenerationRef.current
@@ -267,6 +282,7 @@ export const useChatTimelineScroll = ({
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
isAtEndRef.current = true;
setIsPinned(true);
setUserOwnsScroll(false);
modeRef.current = 'following-end';
// Returning to the end is an explicit opt back IN to live follow.
liveFollowGenerationRef.current = userGenerationRef.current;
@@ -279,9 +295,14 @@ export const useChatTimelineScroll = ({
// row is created by the store), so the next new user message id claims it.
const scrollToBottomOnSend = React.useCallback(() => {
isAtEndRef.current = true;
setUserOwnsScroll(false);
modeRef.current = 'anchoring-new-turn';
liveFollowGenerationRef.current = userGenerationRef.current;
armedForNextUserMessageRef.current = true;
// The optimistic row is not committed yet; the next NEW user message id
// relative to this baseline claims the anchor, independent of whether
// the commit lands before or after this call.
armBaselineUserMessageIdRef.current = lastArmedUserMessageIdRef.current;
pendingAnchorRef.current = null;
positionedAnchorRef.current = null;
settledAnchorRef.current = null;
@@ -289,13 +310,16 @@ export const useChatTimelineScroll = ({
hideScrollButton();
}, [hideScrollButton]);
// Claim the anchor as soon as the sent row exists in the timeline.
// Claim the anchor as soon as the sent row exists in the timeline. The
// comparison is against the baseline captured when the send armed the
// anchor, so the claim works whether the optimistic row committed before
// or after the arming call.
const lastArmedUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
const armBaselineUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
React.useEffect(() => {
const previous = lastArmedUserMessageIdRef.current;
lastArmedUserMessageIdRef.current = lastUserMessageId;
if (!armedForNextUserMessageRef.current) return;
if (!lastUserMessageId || lastUserMessageId === previous) return;
if (!lastUserMessageId || lastUserMessageId === armBaselineUserMessageIdRef.current) return;
armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = lastUserMessageId;
setAnchorMessageId(lastUserMessageId);
@@ -308,6 +332,7 @@ export const useChatTimelineScroll = ({
// Entering a session always returns to the live edge. Late async growth
// is handled by the list staying at the end, not by a timed hold.
isAtEndRef.current = true;
setUserOwnsScroll(false);
modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
@@ -336,8 +361,11 @@ export const useChatTimelineScroll = ({
isAtEndRef.current = isAtEnd;
setIsPinned(isAtEnd);
if (isAtEnd) {
modeRef.current = 'following-end';
if (modeRef.current !== 'anchoring-new-turn') {
modeRef.current = 'following-end';
}
liveFollowGenerationRef.current = userGenerationRef.current;
setUserOwnsScroll(false);
hideScrollButton();
} else {
modeRef.current = 'free-scrolling';
@@ -349,6 +377,10 @@ export const useChatTimelineScroll = ({
// Park the anchored row near the top once the list has measured it.
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
// The anchored end space can be remeasured long after the send (turn
// completion, images decoding). Only the send-time anchoring mode may
// position the viewport.
if (modeRef.current !== 'anchoring-new-turn') return;
if (pendingAnchorRef.current === messageId) {
pendingAnchorRef.current = null;
}
@@ -404,7 +436,7 @@ export const useChatTimelineScroll = ({
// against sub-pixel drift and only while the user has not taken over.
const onAnchorSizeChanged = React.useCallback((messageId: string) => {
if (settledAnchorRef.current !== messageId) return;
if (isLiveFollowActive()) return;
if (!isLiveFollowActive()) return;
const scrollOffset = listRef.current?.getState().scroll;
if (scrollOffset === undefined) return;
@@ -530,25 +562,48 @@ export const useChatTimelineScroll = ({
React.useEffect(() => {
if (!scrollNode) return;
const handleGesture = () => {
const contentScrollsUp = () => {
const list = listRef.current;
return list ? realContentOverflowsViewport(list) : false;
};
const gesture = () => {
onManualNavigationRef.current();
};
const handleWheel = (event: WheelEvent) => {
// Scrolling toward the end is not opting out of follow.
if (event.deltaY < 0 && contentScrollsUp()) gesture();
};
const handleTouchMove = () => {
if (!isAtEndRef.current && contentScrollsUp()) gesture();
};
const handlePointerDown = (event: PointerEvent) => {
// The scrollbar track is the scroll node itself; a tap on a row
// only breaks follow when the viewport already left the end.
if ((event.target === scrollNode || !isAtEndRef.current) && contentScrollsUp()) gesture();
};
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && contentScrollsUp()) {
gesture();
}
};
const handleScroll = () => {
queueSave();
};
scrollNode.addEventListener('wheel', handleGesture, { passive: true });
scrollNode.addEventListener('touchmove', handleGesture, { passive: true });
scrollNode.addEventListener('pointerdown', handleGesture, { passive: true });
scrollNode.addEventListener('wheel', handleWheel, { passive: true });
scrollNode.addEventListener('touchmove', handleTouchMove, { passive: true });
scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true });
scrollNode.addEventListener('keydown', handleKeyDown);
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollNode.removeEventListener('wheel', handleGesture);
scrollNode.removeEventListener('touchmove', handleGesture);
scrollNode.removeEventListener('pointerdown', handleGesture);
scrollNode.removeEventListener('wheel', handleWheel);
scrollNode.removeEventListener('touchmove', handleTouchMove);
scrollNode.removeEventListener('pointerdown', handlePointerDown);
scrollNode.removeEventListener('keydown', handleKeyDown);
scrollNode.removeEventListener('scroll', handleScroll);
};
}, [queueSave, scrollNode]);
}, [queueSave, realContentOverflowsViewport, scrollNode]);
// ── session lifecycle ───────────────────────────────────────────────────
const lastSessionKeyRef = React.useRef<string | null>(null);
@@ -561,6 +616,7 @@ export const useChatTimelineScroll = ({
// Persist the outgoing session's position before the new one takes over.
flushSave();
isAtEndRef.current = true;
setUserOwnsScroll(false);
modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
@@ -570,8 +626,8 @@ export const useChatTimelineScroll = ({
// Suppress the overlay scrollbar thumb while automatic movement owns the
// scroll position, so it does not jump on each correction.
React.useEffect(() => {
setIsFollowingProgrammatically(!showScrollButton && anchorMessageId === null);
}, [anchorMessageId, showScrollButton]);
setIsFollowingProgrammatically(!showScrollButton && !userOwnsScroll);
}, [showScrollButton, userOwnsScroll]);
React.useEffect(() => () => {
cancelShowButtonTimer();