fix(chat): rebuild auto-follow on a single instant writer to kill scroll twitch

Replace the RAF easing follow loop + settle burst with an always-on
instant-follow model: while pinned, the content ResizeObserver re-pins to
the bottom synchronously (scrollTop = scrollHeight, before paint) and is
the only writer of scrollTop. A position+TTL auto marker distinguishes our
own programmatic writes from genuine user scrolling, so a scroll event that
lands at the just-written bottom never trips a false release.

This removes the feedback loop where the easing animation, growing content,
and the user's own scroll all fought for scrollTop in the same frame -- the
infinite twitch when scrolling down during streaming, and the jiggle on
send / from the queue. The public hook interface is unchanged; all
consumers keep working untouched.
This commit is contained in:
Bohdan Triapitsyn
2026-06-27 23:45:34 +03:00
parent 7f45963050
commit 757c704956
2 changed files with 202 additions and 278 deletions
@@ -382,12 +382,13 @@ export const useChatTimelineController = ({
// 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.
// re-pin here. Auto-follow already owns the bottom: its content
// ResizeObserver re-pins instantly (scrollTop = scrollHeight, before
// paint) on every append. Re-pinning again from here would just be a
// second writer chasing the same target a frame later — redundant at
// best, and the source of the old up/down jiggle on send / from the
// queue / while streaming. So for an append we do nothing and let
// auto-follow own it.
if (snap || isPrepend) {
prePrependScrollRef.current = null;
goToBottom('instant');