diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index d1126352..b67a1b0f 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -226,14 +226,30 @@ const scheduleDeferredToolBodyMount = (fn: () => void) => { }; 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(() => { if (!isExpanded) { + mountedRef.current = true; setShouldRender(false); return; } + if (!mountedRef.current) { + mountedRef.current = true; + setShouldRender(true); + return; + } + return scheduleDeferredToolBodyMount(() => { setShouldRender(true); }); diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 395bb2f3..f8b543b6 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -43,10 +43,15 @@ export interface UseChatAutoFollowResult { } // ────────────────────────────────────────────────────────────────────────── -// 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: +// Chat auto-follow. The model is deliberately simple, which is what makes it +// 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 // content ResizeObserver, which fires after layout and before paint. There // 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 // 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. +// a user scroll. const AUTO_MARK_TTL_MS = 1500; 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()); @@ -141,11 +149,17 @@ export const useChatAutoFollow = ({ 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. + // state above is a mirror for rendering. `released` means the user scrolled + // up and away from the bottom. const stateRef = React.useRef('following'); const isMobileRef = React.useRef(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 | null>(null); const sessionMessageCountRef = React.useRef(sessionMessageCount); sessionMessageCountRef.current = sessionMessageCount; const currentSessionIdRef = React.useRef(currentSessionId); @@ -153,7 +167,7 @@ export const useChatAutoFollow = ({ const lastSessionIdRef = React.useRef(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 // px while still inside the TTL is OUR write, not the user's. 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) => { if (stateRef.current === next) return; stateRef.current = next; @@ -227,7 +252,7 @@ export const useChatAutoFollow = ({ setShowScrollButton(showButton); }, []); - // ── core scroll primitives (ported from opencode) ──────────────────────── + // ── core scroll primitives ─────────────────────────────────────────────── const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => { const el = scrollRef.current; if (!el) return; @@ -246,6 +271,10 @@ export const useChatAutoFollow = ({ const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => { 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') { setStateValue('following'); } @@ -260,7 +289,7 @@ export const useChatAutoFollow = ({ return; } scrollToBottomNow(force ? behavior : 'auto'); - }, [markAuto, scrollToBottomNow, setStateValue]); + }, [isActive, markAuto, scrollToBottomNow, setStateValue]); // User left the bottom — release auto-follow. const stop = React.useCallback(() => { @@ -355,8 +384,8 @@ export const useChatAutoFollow = ({ } pendingInitialRestoreRef.current = null; - // Always return to the bottom on session switch (opencode resumes on the - // same edge). The content ResizeObserver re-pins instantly as late + // Always return to the bottom on session switch. The content + // ResizeObserver re-pins instantly as late // history measures in, so there is no smooth scroll-from-mid artifact. setStateValue('following'); scrollToBottom(true); @@ -379,12 +408,29 @@ export const useChatAutoFollow = ({ } }, [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. + // When work begins and we are still + // 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(() => { - if (sessionIsWorking && stateRef.current === 'following') { - scrollToBottom(false); + settlingRef.current = 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]); // Suppress the overlay scrollbar thumb only while we are actively following a @@ -405,7 +451,7 @@ export const useChatAutoFollow = ({ } }, [containerEl, currentSessionId, restoreSnapshot]); - // ── scroll event handling (ported from opencode handleScroll) ──────────── + // ── scroll event handling ──────────────────────────────────────────────── const handleScrollEvent = React.useCallback(() => { const el = scrollRef.current; if (!el) return; @@ -524,6 +570,11 @@ export const useChatAutoFollow = ({ return; } 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; scrollToBottom(false); }); @@ -533,7 +584,7 @@ export const useChatAutoFollow = ({ observer.observe(inner); } return () => observer.disconnect(); - }, [containerEl, scrollToBottom, setStateValue, updateOverflowAndButton]); + }, [containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]); React.useEffect(() => { updateOverflowAndButton(); @@ -580,6 +631,10 @@ export const useChatAutoFollow = ({ clearTimeout(autoTimerRef.current); autoTimerRef.current = null; } + if (settleTimerRef.current) { + clearTimeout(settleTimerRef.current); + settleTimerRef.current = null; + } flushSave(); if (saveTimerRef.current !== null) { clearTimeout(saveTimerRef.current);