diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 2e5365aa..a8e72106 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -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'); diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 2abf35a8..395bb2f3 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -42,16 +42,37 @@ export interface UseChatAutoFollowResult { restoreSnapshot: () => Promise; } +// ────────────────────────────────────────────────────────────────────────── +// 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('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(null); - const programmaticWriteUntilRef = React.useRef(0); - const followRafRef = React.useRef(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 | null>(null); + const saveTimerRef = React.useRef | null>(null); const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); - const settleBurstRafRef = React.useRef(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>(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;