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');
+195 -272
View File
@@ -42,16 +42,37 @@ export interface UseChatAutoFollowResult {
restoreSnapshot: () => Promise<boolean>;
}
// ──────────────────────────────────────────────────────────────────────────
// This is a direct port of opencode's `createAutoScroll` (SolidJS) to a React
// hook. The model is deliberately simple, which is what makes it flicker-free:
//
// • Auto-follow is ALWAYS on unless the user scrolled up (`released`).
// • Following the bottom is INSTANT — `scrollTop = scrollHeight` inside the
// content ResizeObserver, which fires after layout and before paint. There
// is NO easing loop and NO settle burst, so there are never two writers
// racing for `scrollTop` (the root cause of the old jiggle/double-scroll).
// • A short-lived "auto" marker (position + 1500ms) lets the scroll handler
// distinguish our own programmatic writes from genuine user scrolling, so
// a scroll event that lands at our just-written bottom never trips a false
// release.
//
// The public interface below is unchanged from the old implementation so every
// consumer (ChatContainer, message parts, the timeline controller) keeps
// working without edits.
// ──────────────────────────────────────────────────────────────────────────
const BOTTOM_SPACER_DESKTOP_VH = 0.10;
const BOTTOM_SPACER_MOBILE_PX = 40;
const PROGRAMMATIC_WRITE_WINDOW_MS = 200;
const SAVE_DEBOUNCE_MS = 150;
const LERP = 0.18;
const SETTLE_EPSILON = 0.5;
const SETTLE_FRAMES = 4;
const TOUCH_FINGER_DOWN_THRESHOLD = 2;
const SETTLE_BURST_DURATION_MS = 280;
const REPIN_GRACE_AFTER_RELEASE_MS = 1200;
// How long an "auto" (programmatic) scroll position stays trusted. Browsers can
// dispatch the `scroll` event for our write asynchronously, after newer content
// has already changed the geometry; the window keeps us from reading that lag as
// a user scroll. Mirrors opencode's 1500ms.
const AUTO_MARK_TTL_MS = 1500;
const AUTO_MATCH_TOLERANCE_PX = 2;
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
// The bottom of the chat has an empty spacer (10vh on desktop, 40px on mobile)
// — its height is exactly how far above scrollHeight the user can be while still
@@ -68,6 +89,10 @@ const distanceFromBottom = (el: HTMLElement): number => {
return el.scrollHeight - el.scrollTop - el.clientHeight;
};
const canScroll = (el: HTMLElement): boolean => {
return el.scrollHeight - el.clientHeight > 1;
};
const isNearBottom = (el: HTMLElement, isMobile: boolean): boolean => {
return distanceFromBottom(el) <= computeBottomZoneThreshold(isMobile, el);
};
@@ -115,21 +140,27 @@ export const useChatAutoFollow = ({
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
// `stateRef` is the single source of truth for follow vs released; the React
// state above is a mirror for rendering. `released === userScrolled` in
// opencode terms.
const stateRef = React.useRef<AutoFollowState>('following');
const isMobileRef = React.useRef(isMobile);
isMobileRef.current = isMobile;
const sessionMessageCountRef = React.useRef(sessionMessageCount);
sessionMessageCountRef.current = sessionMessageCount;
const currentSessionIdRef = React.useRef(currentSessionId);
currentSessionIdRef.current = currentSessionId;
const lastSessionIdRef = React.useRef<string | null>(null);
const programmaticWriteUntilRef = React.useRef(0);
const followRafRef = React.useRef<number | null>(null);
const settledFramesRef = React.useRef(0);
const lastScrollTopRef = React.useRef(0);
// Programmatic-scroll marker (opencode's `auto`): the bottom position we last
// wrote and when. A scroll event whose scrollTop matches `top` within a few
// px while still inside the TTL is OUR write, not the user's.
const autoRef = React.useRef<{ top: number; time: number } | null>(null);
const autoTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
const settleBurstRafRef = React.useRef<number | null>(null);
const lastUserReleaseAtRef = React.useRef(0);
// When restoreSnapshot is invoked while ChatViewport is still hydrating
// (skeleton rendered, no scroll container yet), we record the session here
// so a follow-up effect can replay the restore once the container mounts.
@@ -155,210 +186,117 @@ export const useChatAutoFollow = ({
setState(next);
}, []);
const markProgrammaticWrite = React.useCallback(() => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
programmaticWriteUntilRef.current = now + PROGRAMMATIC_WRITE_WINDOW_MS;
// ── auto marker ────────────────────────────────────────────────────────
const markAuto = React.useCallback((el: HTMLElement) => {
autoRef.current = {
top: Math.max(0, el.scrollHeight - el.clientHeight),
time: now(),
};
if (autoTimerRef.current) clearTimeout(autoTimerRef.current);
autoTimerRef.current = setTimeout(() => {
autoRef.current = null;
autoTimerRef.current = null;
}, AUTO_MARK_TTL_MS);
}, []);
const isInProgrammaticWindow = React.useCallback(() => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
return now < programmaticWriteUntilRef.current;
const isAuto = React.useCallback((el: HTMLElement): boolean => {
const a = autoRef.current;
if (!a) return false;
if (now() - a.time > AUTO_MARK_TTL_MS) {
autoRef.current = null;
return false;
}
return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX;
}, []);
const stopFollowLoop = React.useCallback(() => {
if (followRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(followRafRef.current);
}
followRafRef.current = null;
settledFramesRef.current = 0;
// 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(() => {
followRafRef.current = null;
// ── overflow / scroll-to-bottom button ──────────────────────────────────
const updateOverflowAndButton = React.useCallback(() => {
const container = scrollRef.current;
if (!container) {
stopFollowLoop();
setIsOverflowing(false);
setShowScrollButton(false);
return;
}
if (stateRef.current !== 'following') {
stopFollowLoop();
const overflowing = canScroll(container);
setIsOverflowing(overflowing);
if (!overflowing) {
setShowScrollButton(false);
return;
}
const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobileRef.current);
setShowScrollButton(showButton);
}, []);
const target = Math.max(0, container.scrollHeight - container.clientHeight);
const current = container.scrollTop;
const delta = target - current;
// A delta larger than a full viewport is a DISCRETE jump (late history
// measurement, session entry, a big block rendering in one commit) — not
// incremental streaming growth. Easing it produces a visible smooth scroll
// from a mid position to the bottom, which felt inconsistent when entering
// historical sessions. Snap such jumps; only ease small, streaming-sized
// deltas below.
if (Math.abs(delta) > container.clientHeight) {
markProgrammaticWrite();
container.scrollTop = target;
lastScrollTopRef.current = target;
settledFramesRef.current = 0;
followRafRef.current = window.requestAnimationFrame(tickFollow);
// ── core scroll primitives (ported from opencode) ────────────────────────
const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => {
const el = scrollRef.current;
if (!el) return;
markAuto(el);
if (behavior === 'smooth') {
el.scrollTo({ top: el.scrollHeight, behavior });
return;
}
// Direct `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`
// and lands in the same frame — no visible catch-up animation.
el.scrollTop = el.scrollHeight;
}, [markAuto]);
if (Math.abs(delta) <= SETTLE_EPSILON) {
if (current !== target) {
markProgrammaticWrite();
container.scrollTop = target;
lastScrollTopRef.current = target;
}
settledFramesRef.current += 1;
if (settledFramesRef.current >= SETTLE_FRAMES) {
stopFollowLoop();
return;
}
followRafRef.current = window.requestAnimationFrame(tickFollow);
// `force` true = user-intent jump (clears released and always scrolls).
// `force` false = passive follow (only while still following).
const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => {
const el = scrollRef.current;
if (force && stateRef.current !== 'following') {
setStateValue('following');
}
if (!el) return;
if (!force && stateRef.current !== 'following') return;
const distance = distanceFromBottom(el);
if (distance < AUTO_MATCH_TOLERANCE_PX) {
// Already at the bottom; just refresh the auto marker so the next
// scroll event is recognised as ours.
markAuto(el);
return;
}
scrollToBottomNow(force ? behavior : 'auto');
}, [markAuto, scrollToBottomNow, setStateValue]);
settledFramesRef.current = 0;
const next = current + delta * LERP;
markProgrammaticWrite();
container.scrollTop = next;
lastScrollTopRef.current = container.scrollTop;
followRafRef.current = window.requestAnimationFrame(tickFollow);
}, [markProgrammaticWrite, stopFollowLoop]);
const startFollowLoop = React.useCallback(() => {
if (typeof window === 'undefined') return;
if (stateRef.current !== 'following') return;
// Single-writer invariant, asymmetric on purpose: the settle burst is the
// AUTHORITATIVE instant pin (session restore / goToBottom 'instant'). While
// it is snapping to the bottom, YIELD — never preempt it with the easing
// follow loop. Preempting it let a content-measurement ResizeObserver tick
// downgrade an instant restore into a visible smooth scroll from a mid
// position when entering a historical session. When the burst ends, the
// next content kick starts the follow loop. (startSettleBurst still stops
// this loop, so the two never write scrollTop in the same frame.)
if (settleBurstRafRef.current !== null) return;
if (followRafRef.current !== null) return;
settledFramesRef.current = 0;
setIsFollowingProgrammatically(true);
followRafRef.current = window.requestAnimationFrame(tickFollow);
}, [tickFollow]);
const writeScrollTopInstant = React.useCallback((target: number) => {
const container = scrollRef.current;
if (!container) return;
const max = Math.max(0, container.scrollHeight - container.clientHeight);
const clamped = Math.max(0, Math.min(target, max));
markProgrammaticWrite();
container.scrollTop = clamped;
lastScrollTopRef.current = container.scrollTop;
}, [markProgrammaticWrite]);
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') {
finish();
return;
}
const c = scrollRef.current;
if (!c) {
finish();
return;
}
const target = Math.max(0, c.scrollHeight - c.clientHeight);
if (Math.abs(c.scrollTop - target) > SETTLE_EPSILON) {
markProgrammaticWrite();
c.scrollTop = target;
lastScrollTopRef.current = target;
}
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, stopFollowLoop, stopSettleBurst]);
const releaseAutoFollow = React.useCallback(() => {
stopFollowLoop();
stopSettleBurst();
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
// User left the bottom — release auto-follow.
const stop = React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
if (!canScroll(el)) {
setStateValue('following');
return;
}
if (stateRef.current === 'released') return;
setStateValue('released');
}, [setStateValue, stopFollowLoop, stopSettleBurst]);
const releaseFromUserIntent = React.useCallback(() => {
if (stateRef.current === 'following') {
stopFollowLoop();
stopSettleBurst();
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
setStateValue('released');
} else {
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
}
}, [setStateValue, stopFollowLoop, stopSettleBurst]);
updateOverflowAndButton();
}, [setStateValue, updateOverflowAndButton]);
// ── public scroll API (mapped onto the primitives) ───────────────────────
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
const container = scrollRef.current;
setStateValue('following');
lastUserReleaseAtRef.current = 0;
if (!container) return;
if (mode === 'smooth') {
startFollowLoop();
return;
}
const target = Math.max(0, container.scrollHeight - container.clientHeight);
writeScrollTopInstant(target);
startSettleBurst();
}, [setStateValue, startFollowLoop, startSettleBurst, writeScrollTopInstant]);
scrollToBottom(true, mode === 'smooth' ? 'smooth' : 'auto');
}, [scrollToBottom]);
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]);
// Single movement to the just-sent message. Force re-pins to the bottom
// whether we were following or scrolled up; the content ResizeObserver
// keeps us pinned as the optimistic message and its reply stream in.
scrollToBottom(true);
}, [scrollToBottom]);
const releaseAutoFollow = React.useCallback(() => {
setStateValue('released');
updateOverflowAndButton();
}, [setStateValue, updateOverflowAndButton]);
const releaseFromUserIntent = React.useCallback(() => {
stop();
}, [stop]);
// ── per-session snapshot persistence (kept; restore still goes to bottom) ─
const flushSave = React.useCallback(() => {
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
@@ -417,21 +355,16 @@ export const useChatAutoFollow = ({
}
pendingInitialRestoreRef.current = null;
// Always return to the bottom on session switch. The previous saved-ratio
// restore had a low success rate and, by landing 'released' partway up,
// produced the visible backward jump as content finished loading.
// Always return to the bottom on session switch (opencode resumes on the
// same edge). The content ResizeObserver re-pins instantly as late
// history measures in, so there is no smooth scroll-from-mid artifact.
setStateValue('following');
lastUserReleaseAtRef.current = 0;
const target = Math.max(0, container.scrollHeight - container.clientHeight);
// Mirror goToBottom('instant'): jump to the bottom now, then hold it with the
// settle burst while late history content measures in. Do NOT also start the
// easing follow loop here — that is what produced the smooth scroll-from-mid
// position on session entry.
writeScrollTopInstant(target);
startSettleBurst();
scrollToBottom(true);
updateOverflowAndButton();
return false;
}, [setStateValue, startSettleBurst, writeScrollTopInstant]);
}, [scrollToBottom, setStateValue, updateOverflowAndButton]);
// ── session change ───────────────────────────────────────────────────────
React.useEffect(() => {
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
return;
@@ -439,20 +372,28 @@ export const useChatAutoFollow = ({
lastSessionIdRef.current = currentSessionId;
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
flushSave();
stopFollowLoop();
stopSettleBurst();
markProgrammaticWrite();
autoRef.current = null;
// Drop any pending restore request inherited from a different session.
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionId) {
pendingInitialRestoreRef.current = null;
}
}, [currentSessionId, flushSave, markProgrammaticWrite, stopFollowLoop, stopSettleBurst]);
}, [currentSessionId, flushSave]);
// When work begins (a reply starts streaming) and we are still following,
// make sure we are pinned to the bottom. Mirrors opencode's `working` effect.
React.useEffect(() => {
if (sessionIsWorking && stateRef.current === 'following') {
startFollowLoop();
scrollToBottom(false);
}
}, [sessionIsWorking, startFollowLoop]);
}, [sessionIsWorking, scrollToBottom]);
// Suppress the overlay scrollbar thumb only while we are actively following a
// live stream (the thumb would otherwise jump on every instant re-pin). When
// idle or released the scrollbar behaves normally. Stable: changes only when
// follow-state or working-state flips, not on every frame.
React.useEffect(() => {
setIsFollowingProgrammatically(state === 'following' && sessionIsWorking);
}, [state, sessionIsWorking]);
// Replay a deferred restoreSnapshot once ChatViewport mounts.
// useLayoutEffect ensures scroll position is set before the browser paints,
@@ -464,67 +405,38 @@ export const useChatAutoFollow = ({
}
}, [containerEl, currentSessionId, restoreSnapshot]);
const updateOverflowAndButton = React.useCallback(() => {
const container = scrollRef.current;
if (!container) {
setIsOverflowing(false);
setShowScrollButton(false);
return;
}
const overflowing = container.scrollHeight > container.clientHeight + 1;
setIsOverflowing(overflowing);
if (!overflowing) {
setShowScrollButton(false);
return;
}
const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobile);
setShowScrollButton(showButton);
}, [isMobile]);
// ── scroll event handling (ported from opencode handleScroll) ────────────
const handleScrollEvent = React.useCallback(() => {
const container = scrollRef.current;
if (!container) return;
const programmatic = isInProgrammaticWindow();
const currentTop = container.scrollTop;
lastScrollTopRef.current = currentTop;
const el = scrollRef.current;
if (!el) return;
updateOverflowAndButton();
if (programmatic) {
if (!canScroll(el)) {
setStateValue('following');
return;
}
// Release auto-follow only when the user has actually left the near-bottom
// zone — not on the small scrollTop clamp the browser applies when the
// composer grows and shrinks the viewport (which keeps you at the bottom).
// Position-based, mirroring the re-pin check below; this removes the false
// release that produced the visible backward jump on session switch.
if (stateRef.current === 'following' && !isNearBottom(container, isMobile)) {
stopFollowLoop();
stopSettleBurst();
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
setStateValue('released');
}
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const inGrace = (now - lastUserReleaseAtRef.current) < REPIN_GRACE_AFTER_RELEASE_MS;
if (stateRef.current === 'released' && isNearBottom(container, isMobile) && !inGrace) {
// Within the bottom zone → (re-)pin to following. This is how scrolling
// back down to the bottom resumes auto-follow.
if (isNearBottom(el, isMobileRef.current)) {
setStateValue('following');
startFollowLoop();
queueSave();
return;
}
// Our own programmatic write that landed at the bottom but where content
// grew between the write and this event — keep following, don't release.
if (stateRef.current === 'following' && isAuto(el)) {
scrollToBottom(false);
queueSave();
return;
}
// Genuine user scroll away from the bottom.
stop();
queueSave();
}, [
isInProgrammaticWindow,
isMobile,
queueSave,
setStateValue,
startFollowLoop,
stopFollowLoop,
stopSettleBurst,
updateOverflowAndButton,
]);
}, [isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]);
React.useEffect(() => {
const container = containerEl;
@@ -596,15 +508,24 @@ export const useChatAutoFollow = ({
};
}, [containerEl, handleScrollEvent, releaseFromUserIntent]);
// The heart of the follow behaviour: the content ResizeObserver fires after
// layout and before paint, so re-pinning to the bottom here is invisible —
// there is no "jump up then catch up". Observe both the container (composer
// growth shrinks the viewport) and the inner content (streaming growth).
React.useEffect(() => {
const container = containerEl;
if (!container || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
updateOverflowAndButton();
if (stateRef.current === 'following') {
startFollowLoop();
const el = scrollRef.current;
if (el && !canScroll(el)) {
setStateValue('following');
updateOverflowAndButton();
return;
}
updateOverflowAndButton();
if (stateRef.current !== 'following') return;
scrollToBottom(false);
});
observer.observe(container);
const inner = container.firstElementChild;
@@ -612,7 +533,7 @@ export const useChatAutoFollow = ({
observer.observe(inner);
}
return () => observer.disconnect();
}, [containerEl, startFollowLoop, updateOverflowAndButton]);
}, [containerEl, scrollToBottom, setStateValue, updateOverflowAndButton]);
React.useEffect(() => {
updateOverflowAndButton();
@@ -622,9 +543,9 @@ export const useChatAutoFollow = ({
void _reason;
updateOverflowAndButton();
if (stateRef.current === 'following') {
startFollowLoop();
scrollToBottom(false);
}
}, [startFollowLoop, updateOverflowAndButton]);
}, [scrollToBottom, updateOverflowAndButton]);
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
@@ -634,7 +555,7 @@ export const useChatAutoFollow = ({
const kick = () => {
if (stateRef.current === 'following') {
startFollowLoop();
scrollToBottom(false);
}
};
@@ -651,19 +572,21 @@ export const useChatAutoFollow = ({
};
animationHandlersRef.current.set(messageId, handlers);
return handlers;
}, [startFollowLoop, updateOverflowAndButton]);
}, [scrollToBottom, updateOverflowAndButton]);
React.useEffect(() => {
return () => {
stopFollowLoop();
stopSettleBurst();
if (autoTimerRef.current) {
clearTimeout(autoTimerRef.current);
autoTimerRef.current = null;
}
flushSave();
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, [flushSave, stopFollowLoop, stopSettleBurst]);
}, [flushSave]);
React.useEffect(() => {
if (!onActiveTurnChange) return;