From b384deb8122e545994b3e92d85a89928f6956e99 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 27 Jun 2026 00:06:19 +0300 Subject: [PATCH] fix(chat): stop scroll jiggle and double-scroll on send while pinned Two scroll owners were writing the chat container's scrollTop concurrently during pinned content growth and on send, fighting frame-to-frame and producing the reported flicker/jiggle (after a pause, from the queue, on user interruptions) plus a visible double scroll on a normal user send. Enforce a single-writer invariant in useChatAutoFollow: - The easing follow loop and the instant settle burst now mutually exclude: starting one stops the other, so they can never write scrollTop in the same frame. The isFollowingProgrammatically flag (which suppresses the overlay scrollbar) is owned by whichever loop is active and cleared only when both are idle, including the settle burst's natural 280ms end. Stop the redundant re-pin storm in useChatTimelineController: - While pinned, route goToBottom('instant') only for a prepend (history loaded above), not on every bottom append / streaming part. Normal growth is owned by the follow loop (kicked by the content ResizeObserver and chunk handlers). Remove the double movement on send: - Add scrollToBottomOnSend: when already following, just (re)kick the follow loop for a single smooth movement instead of also firing an instant goToBottom that raced the ResizeObserver-driven loop. When released (scrolled up), keep the instant jump to the just-sent message. --- .../ui/src/components/chat/ChatContainer.tsx | 9 +- .../chat/hooks/useChatTimelineController.ts | 91 ++++++++++--------- packages/ui/src/hooks/useChatAutoFollow.ts | 75 ++++++++++++--- 3 files changed, 115 insertions(+), 60 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index dcb2465f..b73c52ee 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -568,6 +568,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr notifyContentChange: handleMessageContentChange, getAnimationHandlers, goToBottom, + scrollToBottomOnSend, releaseAutoFollow, restoreSnapshot, isPinned, @@ -792,7 +793,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr : 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]' )} > - {promptReadOnly ? : } + {promptReadOnly ? : } ); @@ -852,7 +853,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr : 'bg-background' )} > - {promptReadOnly ? : } + {promptReadOnly ? : } ); @@ -885,7 +886,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr : 'bg-background' )} > - {promptReadOnly ? : } + {promptReadOnly ? : } ); @@ -933,7 +934,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr onClick={navigation.resumeToLatest} /> )} - {promptReadOnly ? : } + {promptReadOnly ? : } { prependTrackingRef.current = { - oldestId: renderedMessages[0]?.info?.id ?? null, - newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null, + oldestId: currentOldestId, + newestId: currentNewestId, scrollHeight: container.scrollHeight, }; + }; + + if (isPinnedRef.current) { + // Bottom-pinned. Only content inserted ABOVE (a prepend / history load) + // needs an explicit re-pin: with overflow-anchor:none the browser leaves + // scrollTop unchanged, so the viewport would visibly jump. Route that + // through goToBottom — the single programmatic writer. + // + // A normal bottom APPEND (a sent message, a streaming part) must NOT + // re-pin here. Auto-follow's own follow loop — kicked by the content + // ResizeObserver and the streaming chunk handlers — already eases to the + // new bottom. Calling goToBottom on every append layered its settle burst + // on top of that loop: two writers aiming at different positions, which + // is exactly the up/down jiggle reported on send / from the queue / while + // streaming. So for an append we do nothing and let the follow loop own it. + if (snap || isPrepend) { + prePrependScrollRef.current = null; + goToBottom('instant'); + } + updateTracking(); return; } - const snap = prePrependScrollRef.current; if (snap) { prePrependScrollRef.current = null; // When a viewport anchor is available, delegate to MessageList @@ -382,38 +408,17 @@ export const useChatTimelineController = ({ container.scrollTop = snap.top + delta; } } - } else { - // Auto-detect a prepend: the oldest message changed while the newest - // stayed the same (distinguishes a real prepend from a session - // switch, a bottom append, or a streaming part growing). Compensate - // synchronously by the exact height delta — for a bottom-pinned - // viewport this keeps it pinned, for a released one it preserves the - // read position, with no intermediate frame for auto-follow to fight. - const prev = prependTrackingRef.current; - const currentOldestId = renderedMessages[0]?.info?.id ?? null; - const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null; - const isPrepend = Boolean( - prev - && prev.oldestId - && currentOldestId - && currentOldestId !== prev.oldestId - && prev.newestId - && currentNewestId - && currentNewestId === prev.newestId, - ); - if (isPrepend && prev) { - const delta = container.scrollHeight - prev.scrollHeight; - if (delta > 0) { - container.scrollTop = container.scrollTop + delta; - } + } else if (isPrepend && prev) { + // Released viewport: preserve the read position by compensating for the + // exact height the prepend added above, with no intermediate frame for + // auto-follow to fight. + const delta = container.scrollHeight - prev.scrollHeight; + if (delta > 0) { + container.scrollTop = container.scrollTop + delta; } } - prependTrackingRef.current = { - oldestId: renderedMessages[0]?.info?.id ?? null, - newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null, - scrollHeight: container.scrollHeight, - }; + updateTracking(); }, [renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]); const revealBufferedTurns = React.useCallback(async (): Promise => false, []); diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 7ead5106..95c927e5 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -36,6 +36,7 @@ export interface UseChatAutoFollowResult { notifyContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; goToBottom: (mode?: 'instant' | 'smooth') => void; + scrollToBottomOnSend: () => void; releaseAutoFollow: () => void; saveSnapshotNow: () => void; restoreSnapshot: () => Promise; @@ -170,7 +171,21 @@ export const useChatAutoFollow = ({ } followRafRef.current = null; settledFramesRef.current = 0; - setIsFollowingProgrammatically(false); + // Only the active scroll-writer owns the "programmatic follow" flag. If the + // settle burst is still running it remains the owner, so don't clear here. + if (settleBurstRafRef.current === null) { + setIsFollowingProgrammatically(false); + } + }, []); + + const stopSettleBurst = React.useCallback(() => { + if (settleBurstRafRef.current !== null && typeof window !== 'undefined') { + window.cancelAnimationFrame(settleBurstRafRef.current); + } + settleBurstRafRef.current = null; + if (followRafRef.current === null) { + setIsFollowingProgrammatically(false); + } }, []); const tickFollow = React.useCallback(() => { @@ -214,12 +229,18 @@ export const useChatAutoFollow = ({ const startFollowLoop = React.useCallback(() => { if (typeof window === 'undefined') return; - if (followRafRef.current !== null) return; if (stateRef.current !== 'following') return; + // Single-writer invariant: never let the easing follow loop run alongside + // the instant settle burst. They both write scrollTop every frame but aim + // at different positions (the burst snaps to the exact bottom, this loop + // eases toward it), so concurrently they fight frame-to-frame and produce + // the visible up/down jiggle during pinned content growth and sends. + stopSettleBurst(); + if (followRafRef.current !== null) return; settledFramesRef.current = 0; setIsFollowingProgrammatically(true); followRafRef.current = window.requestAnimationFrame(tickFollow); - }, [tickFollow]); + }, [stopSettleBurst, tickFollow]); const writeScrollTopInstant = React.useCallback((target: number) => { const container = scrollRef.current; @@ -231,22 +252,32 @@ export const useChatAutoFollow = ({ lastScrollTopRef.current = container.scrollTop; }, [markProgrammaticWrite]); - const stopSettleBurst = React.useCallback(() => { - if (settleBurstRafRef.current !== null && typeof window !== 'undefined') { - window.cancelAnimationFrame(settleBurstRafRef.current); - } - settleBurstRafRef.current = null; - }, []); - const startSettleBurst = React.useCallback(() => { if (typeof window === 'undefined') return; + // Single-writer invariant (mirror of startFollowLoop): the settle burst is + // taking over scroll ownership, so stop the easing follow loop first. The + // two must never write scrollTop in the same frame. + stopFollowLoop(); stopSettleBurst(); + setIsFollowingProgrammatically(true); const until = (typeof performance !== 'undefined' ? performance.now() : Date.now()) + SETTLE_BURST_DURATION_MS; + const finish = () => { + settleBurstRafRef.current = null; + if (followRafRef.current === null) { + setIsFollowingProgrammatically(false); + } + }; const tick = () => { settleBurstRafRef.current = null; - if (stateRef.current !== 'following') return; + if (stateRef.current !== 'following') { + finish(); + return; + } const c = scrollRef.current; - if (!c) return; + if (!c) { + finish(); + return; + } const target = Math.max(0, c.scrollHeight - c.clientHeight); if (Math.abs(c.scrollTop - target) > SETTLE_EPSILON) { markProgrammaticWrite(); @@ -256,10 +287,12 @@ export const useChatAutoFollow = ({ const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (now < until) { settleBurstRafRef.current = window.requestAnimationFrame(tick); + } else { + finish(); } }; settleBurstRafRef.current = window.requestAnimationFrame(tick); - }, [markProgrammaticWrite, stopSettleBurst]); + }, [markProgrammaticWrite, stopFollowLoop, stopSettleBurst]); const releaseAutoFollow = React.useCallback(() => { stopFollowLoop(); @@ -293,6 +326,21 @@ export const useChatAutoFollow = ({ startSettleBurst(); }, [setStateValue, startFollowLoop, startSettleBurst, writeScrollTopInstant]); + const scrollToBottomOnSend = React.useCallback(() => { + // Keep a SINGLE movement to the just-sent message. + // If we're already following the bottom, the optimistic message is eased + // into view by the follow loop (kicked by the content ResizeObserver). Just + // (re)kick that one owner — do NOT also fire an instant goToBottom here, or + // the instant snap races the easing loop and you see a visible double scroll + // (ease, then snap). + if (stateRef.current === 'following') { + startFollowLoop(); + return; + } + // Scrolled up (released): bring the user down to the message they just sent. + goToBottom('instant'); + }, [goToBottom, startFollowLoop]); + const flushSave = React.useCallback(() => { if (saveTimerRef.current !== null) { clearTimeout(saveTimerRef.current); @@ -676,6 +724,7 @@ export const useChatAutoFollow = ({ notifyContentChange, getAnimationHandlers, goToBottom, + scrollToBottomOnSend, releaseAutoFollow, saveSnapshotNow, restoreSnapshot,