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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user