fix(chat): stop scroll twitch and message skipping with expanded tools

Gate passive auto-follow on active (working/settling) state so idle layout
churn from virtualizer re-measurement no longer re-pins the viewport to the
bottom. Render default-open tool bodies synchronously on mount so the
virtualizer measures the real row height up front instead of growing a frame
later and lurching scroll past several messages.
This commit is contained in:
Bohdan Triapitsyn
2026-06-28 12:01:26 +03:00
parent b72230bc24
commit b2598fa45e
2 changed files with 89 additions and 18 deletions
@@ -226,14 +226,30 @@ const scheduleDeferredToolBodyMount = (fn: () => void) => {
}; };
const useDeferredExpandedContent = (isExpanded: boolean) => { const useDeferredExpandedContent = (isExpanded: boolean) => {
const [shouldRender, setShouldRender] = React.useState(false); // If the tool is expanded when the row first mounts (e.g. "show tools open
// by default", or scrolling a default-open tool back into a virtualized
// view), render the body SYNCHRONOUSLY so the virtualizer measures the real
// height immediately. Deferring it would let the row mount short and grow a
// frame later, which makes the virtualizer compensate scroll and lurch the
// viewport past several messages on slow scroll. Only defer LATER
// user-initiated expansions, where instant single-item feedback isn't worth
// blocking the click on a heavy body render.
const [shouldRender, setShouldRender] = React.useState(isExpanded);
const mountedRef = React.useRef(false);
React.useEffect(() => { React.useEffect(() => {
if (!isExpanded) { if (!isExpanded) {
mountedRef.current = true;
setShouldRender(false); setShouldRender(false);
return; return;
} }
if (!mountedRef.current) {
mountedRef.current = true;
setShouldRender(true);
return;
}
return scheduleDeferredToolBodyMount(() => { return scheduleDeferredToolBodyMount(() => {
setShouldRender(true); setShouldRender(true);
}); });
+72 -17
View File
@@ -43,10 +43,15 @@ export interface UseChatAutoFollowResult {
} }
// ────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────
// This is a direct port of opencode's `createAutoScroll` (SolidJS) to a React // Chat auto-follow. The model is deliberately simple, which is what makes it
// hook. The model is deliberately simple, which is what makes it flicker-free: // flicker-free:
// //
// • Auto-follow is ALWAYS on unless the user scrolled up (`released`). // • Auto-follow is on unless the user scrolled up (`released`), AND passive
// following only acts while the session is active (working, plus a short
// settle window). When idle, content-size changes are layout churn
// (virtualizer re-measurement, async tool/code rendering) rather than live
// growth, so the hook leaves scroll alone — re-pinning then would fight the
// virtualizer and twitch the viewport.
// • Following the bottom is INSTANT — `scrollTop = scrollHeight` inside the // • Following the bottom is INSTANT — `scrollTop = scrollHeight` inside the
// content ResizeObserver, which fires after layout and before paint. There // content ResizeObserver, which fires after layout and before paint. There
// is NO easing loop and NO settle burst, so there are never two writers // is NO easing loop and NO settle burst, so there are never two writers
@@ -68,9 +73,12 @@ const TOUCH_FINGER_DOWN_THRESHOLD = 2;
// How long an "auto" (programmatic) scroll position stays trusted. Browsers can // How long an "auto" (programmatic) scroll position stays trusted. Browsers can
// dispatch the `scroll` event for our write asynchronously, after newer content // 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 // has already changed the geometry; the window keeps us from reading that lag as
// a user scroll. Mirrors opencode's 1500ms. // a user scroll.
const AUTO_MARK_TTL_MS = 1500; const AUTO_MARK_TTL_MS = 1500;
const AUTO_MATCH_TOLERANCE_PX = 2; const AUTO_MATCH_TOLERANCE_PX = 2;
// After streaming stops, keep following the bottom for a short window so the
// final content can settle into place.
const SETTLE_MS = 300;
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()); const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
@@ -141,11 +149,17 @@ export const useChatAutoFollow = ({
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false); const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
// `stateRef` is the single source of truth for follow vs released; the React // `stateRef` is the single source of truth for follow vs released; the React
// state above is a mirror for rendering. `released === userScrolled` in // state above is a mirror for rendering. `released` means the user scrolled
// opencode terms. // up and away from the bottom.
const stateRef = React.useRef<AutoFollowState>('following'); const stateRef = React.useRef<AutoFollowState>('following');
const isMobileRef = React.useRef(isMobile); const isMobileRef = React.useRef(isMobile);
isMobileRef.current = isMobile; isMobileRef.current = isMobile;
const sessionIsWorkingRef = React.useRef(sessionIsWorking);
sessionIsWorkingRef.current = sessionIsWorking;
// `settling` keeps passive follow alive for a short window after work stops
// so the final content can land at the bottom.
const settlingRef = React.useRef(false);
const settleTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const sessionMessageCountRef = React.useRef(sessionMessageCount); const sessionMessageCountRef = React.useRef(sessionMessageCount);
sessionMessageCountRef.current = sessionMessageCount; sessionMessageCountRef.current = sessionMessageCount;
const currentSessionIdRef = React.useRef(currentSessionId); const currentSessionIdRef = React.useRef(currentSessionId);
@@ -153,7 +167,7 @@ export const useChatAutoFollow = ({
const lastSessionIdRef = React.useRef<string | null>(null); const lastSessionIdRef = React.useRef<string | null>(null);
// Programmatic-scroll marker (opencode's `auto`): the bottom position we last // Programmatic-scroll marker: the bottom position we last
// wrote and when. A scroll event whose scrollTop matches `top` within a few // 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. // px while still inside the TTL is OUR write, not the user's.
const autoRef = React.useRef<{ top: number; time: number } | null>(null); const autoRef = React.useRef<{ top: number; time: number } | null>(null);
@@ -180,6 +194,17 @@ export const useChatAutoFollow = ({
} }
}); });
// `active` is `working || settling`. Passive auto-follow
// (the ResizeObserver re-pin and any non-forced scrollToBottom) only runs
// while active. When the session is idle, content-size changes are layout
// churn — virtualizer re-measurement, async tool/code rendering — NOT live
// growth, so we must NOT yank the user to the bottom. Forcing this gate is
// what stops the twitch when tall items (expanded tools) re-measure as the
// user scrolls.
const isActive = React.useCallback((): boolean => {
return sessionIsWorkingRef.current || settlingRef.current;
}, []);
const setStateValue = React.useCallback((next: AutoFollowState) => { const setStateValue = React.useCallback((next: AutoFollowState) => {
if (stateRef.current === next) return; if (stateRef.current === next) return;
stateRef.current = next; stateRef.current = next;
@@ -227,7 +252,7 @@ export const useChatAutoFollow = ({
setShowScrollButton(showButton); setShowScrollButton(showButton);
}, []); }, []);
// ── core scroll primitives (ported from opencode) ──────────────────────── // ── core scroll primitives ───────────────────────────────────────────────
const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => { const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => {
const el = scrollRef.current; const el = scrollRef.current;
if (!el) return; if (!el) return;
@@ -246,6 +271,10 @@ export const useChatAutoFollow = ({
const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => { const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => {
const el = scrollRef.current; const el = scrollRef.current;
// Passive follow only while active (working/settling). Forced jumps
// (send, go-to-bottom, session restore) always proceed.
if (!force && !isActive()) return;
if (force && stateRef.current !== 'following') { if (force && stateRef.current !== 'following') {
setStateValue('following'); setStateValue('following');
} }
@@ -260,7 +289,7 @@ export const useChatAutoFollow = ({
return; return;
} }
scrollToBottomNow(force ? behavior : 'auto'); scrollToBottomNow(force ? behavior : 'auto');
}, [markAuto, scrollToBottomNow, setStateValue]); }, [isActive, markAuto, scrollToBottomNow, setStateValue]);
// User left the bottom — release auto-follow. // User left the bottom — release auto-follow.
const stop = React.useCallback(() => { const stop = React.useCallback(() => {
@@ -355,8 +384,8 @@ export const useChatAutoFollow = ({
} }
pendingInitialRestoreRef.current = null; pendingInitialRestoreRef.current = null;
// Always return to the bottom on session switch (opencode resumes on the // Always return to the bottom on session switch. The content
// same edge). The content ResizeObserver re-pins instantly as late // ResizeObserver re-pins instantly as late
// history measures in, so there is no smooth scroll-from-mid artifact. // history measures in, so there is no smooth scroll-from-mid artifact.
setStateValue('following'); setStateValue('following');
scrollToBottom(true); scrollToBottom(true);
@@ -379,12 +408,29 @@ export const useChatAutoFollow = ({
} }
}, [currentSessionId, flushSave]); }, [currentSessionId, flushSave]);
// When work begins (a reply starts streaming) and we are still following, // When work begins and we are still
// make sure we are pinned to the bottom. Mirrors opencode's `working` effect. // following, pin to the bottom. When work stops, keep following alive for a
// short settle window so the final content lands at the bottom, then go
// idle (after which passive follow is disabled — see `isActive`).
React.useEffect(() => { React.useEffect(() => {
if (sessionIsWorking && stateRef.current === 'following') { settlingRef.current = false;
scrollToBottom(false); if (settleTimerRef.current) {
clearTimeout(settleTimerRef.current);
settleTimerRef.current = null;
} }
if (sessionIsWorking) {
if (stateRef.current === 'following') {
scrollToBottom(true);
}
return;
}
settlingRef.current = true;
settleTimerRef.current = setTimeout(() => {
settlingRef.current = false;
settleTimerRef.current = null;
}, SETTLE_MS);
}, [sessionIsWorking, scrollToBottom]); }, [sessionIsWorking, scrollToBottom]);
// Suppress the overlay scrollbar thumb only while we are actively following a // Suppress the overlay scrollbar thumb only while we are actively following a
@@ -405,7 +451,7 @@ export const useChatAutoFollow = ({
} }
}, [containerEl, currentSessionId, restoreSnapshot]); }, [containerEl, currentSessionId, restoreSnapshot]);
// ── scroll event handling (ported from opencode handleScroll) ──────────── // ── scroll event handling ────────────────────────────────────────────────
const handleScrollEvent = React.useCallback(() => { const handleScrollEvent = React.useCallback(() => {
const el = scrollRef.current; const el = scrollRef.current;
if (!el) return; if (!el) return;
@@ -524,6 +570,11 @@ export const useChatAutoFollow = ({
return; return;
} }
updateOverflowAndButton(); updateOverflowAndButton();
// Idle resize = layout churn (virtualizer re-measurement, async
// tool/code rendering), NOT live growth. Never re-pin when idle, or
// tall items re-measuring as the user scrolls cause an endless
// scroll-to-bottom/re-measure twitch.
if (!isActive()) return;
if (stateRef.current !== 'following') return; if (stateRef.current !== 'following') return;
scrollToBottom(false); scrollToBottom(false);
}); });
@@ -533,7 +584,7 @@ export const useChatAutoFollow = ({
observer.observe(inner); observer.observe(inner);
} }
return () => observer.disconnect(); return () => observer.disconnect();
}, [containerEl, scrollToBottom, setStateValue, updateOverflowAndButton]); }, [containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
React.useEffect(() => { React.useEffect(() => {
updateOverflowAndButton(); updateOverflowAndButton();
@@ -580,6 +631,10 @@ export const useChatAutoFollow = ({
clearTimeout(autoTimerRef.current); clearTimeout(autoTimerRef.current);
autoTimerRef.current = null; autoTimerRef.current = null;
} }
if (settleTimerRef.current) {
clearTimeout(settleTimerRef.current);
settleTimerRef.current = null;
}
flushSave(); flushSave();
if (saveTimerRef.current !== null) { if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current); clearTimeout(saveTimerRef.current);