diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index dcb2465f..b73c52ee 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -568,6 +568,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr notifyContentChange: handleMessageContentChange, getAnimationHandlers, goToBottom, + scrollToBottomOnSend, releaseAutoFollow, restoreSnapshot, isPinned, @@ -792,7 +793,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr : 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]' )} > - {promptReadOnly ? : } + {promptReadOnly ? : } ); @@ -852,7 +853,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr : 'bg-background' )} > - {promptReadOnly ? : } + {promptReadOnly ? : } ); @@ -885,7 +886,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr : 'bg-background' )} > - {promptReadOnly ? : } + {promptReadOnly ? : } ); @@ -933,7 +934,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr onClick={navigation.resumeToLatest} /> )} - {promptReadOnly ? : } + {promptReadOnly ? : } { prependTrackingRef.current = { - oldestId: renderedMessages[0]?.info?.id ?? null, - newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null, + oldestId: currentOldestId, + newestId: currentNewestId, scrollHeight: container.scrollHeight, }; + }; + + if (isPinnedRef.current) { + // Bottom-pinned. Only content inserted ABOVE (a prepend / history load) + // needs an explicit re-pin: with overflow-anchor:none the browser leaves + // scrollTop unchanged, so the viewport would visibly jump. Route that + // 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. + if (snap || isPrepend) { + prePrependScrollRef.current = null; + goToBottom('instant'); + } + updateTracking(); return; } - const snap = prePrependScrollRef.current; if (snap) { prePrependScrollRef.current = null; // When a viewport anchor is available, delegate to MessageList @@ -382,38 +408,17 @@ export const useChatTimelineController = ({ container.scrollTop = snap.top + delta; } } - } else { - // Auto-detect a prepend: the oldest message changed while the newest - // stayed the same (distinguishes a real prepend from a session - // switch, a bottom append, or a streaming part growing). Compensate - // synchronously by the exact height delta — for a bottom-pinned - // viewport this keeps it pinned, for a released one it preserves the - // read position, with no intermediate frame for auto-follow to fight. - const prev = prependTrackingRef.current; - const currentOldestId = renderedMessages[0]?.info?.id ?? null; - const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null; - const isPrepend = Boolean( - prev - && prev.oldestId - && currentOldestId - && currentOldestId !== prev.oldestId - && prev.newestId - && currentNewestId - && currentNewestId === prev.newestId, - ); - if (isPrepend && prev) { - const delta = container.scrollHeight - prev.scrollHeight; - if (delta > 0) { - container.scrollTop = container.scrollTop + delta; - } + } else if (isPrepend && prev) { + // Released viewport: preserve the read position by compensating for the + // exact height the prepend added above, with no intermediate frame for + // auto-follow to fight. + const delta = container.scrollHeight - prev.scrollHeight; + if (delta > 0) { + container.scrollTop = container.scrollTop + delta; } } - prependTrackingRef.current = { - oldestId: renderedMessages[0]?.info?.id ?? null, - newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null, - scrollHeight: container.scrollHeight, - }; + updateTracking(); }, [renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]); const revealBufferedTurns = React.useCallback(async (): Promise => false, []); diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 7ead5106..95c927e5 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -36,6 +36,7 @@ export interface UseChatAutoFollowResult { notifyContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; goToBottom: (mode?: 'instant' | 'smooth') => void; + scrollToBottomOnSend: () => void; releaseAutoFollow: () => void; saveSnapshotNow: () => void; restoreSnapshot: () => Promise; @@ -170,7 +171,21 @@ export const useChatAutoFollow = ({ } followRafRef.current = null; settledFramesRef.current = 0; - setIsFollowingProgrammatically(false); + // 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(() => { @@ -214,12 +229,18 @@ export const useChatAutoFollow = ({ const startFollowLoop = React.useCallback(() => { if (typeof window === 'undefined') return; - if (followRafRef.current !== null) return; if (stateRef.current !== 'following') return; + // Single-writer invariant: never let the easing follow loop run alongside + // the instant settle burst. They both write scrollTop every frame but aim + // at different positions (the burst snaps to the exact bottom, this loop + // eases toward it), so concurrently they fight frame-to-frame and produce + // the visible up/down jiggle during pinned content growth and sends. + stopSettleBurst(); + if (followRafRef.current !== null) return; settledFramesRef.current = 0; setIsFollowingProgrammatically(true); followRafRef.current = window.requestAnimationFrame(tickFollow); - }, [tickFollow]); + }, [stopSettleBurst, tickFollow]); const writeScrollTopInstant = React.useCallback((target: number) => { const container = scrollRef.current; @@ -231,22 +252,32 @@ export const useChatAutoFollow = ({ lastScrollTopRef.current = container.scrollTop; }, [markProgrammaticWrite]); - const stopSettleBurst = React.useCallback(() => { - if (settleBurstRafRef.current !== null && typeof window !== 'undefined') { - window.cancelAnimationFrame(settleBurstRafRef.current); - } - settleBurstRafRef.current = null; - }, []); - 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') return; + if (stateRef.current !== 'following') { + finish(); + return; + } const c = scrollRef.current; - if (!c) return; + if (!c) { + finish(); + return; + } const target = Math.max(0, c.scrollHeight - c.clientHeight); if (Math.abs(c.scrollTop - target) > SETTLE_EPSILON) { markProgrammaticWrite(); @@ -256,10 +287,12 @@ export const useChatAutoFollow = ({ 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, stopSettleBurst]); + }, [markProgrammaticWrite, stopFollowLoop, stopSettleBurst]); const releaseAutoFollow = React.useCallback(() => { stopFollowLoop(); @@ -293,6 +326,21 @@ export const useChatAutoFollow = ({ startSettleBurst(); }, [setStateValue, startFollowLoop, startSettleBurst, writeScrollTopInstant]); + 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]); + const flushSave = React.useCallback(() => { if (saveTimerRef.current !== null) { clearTimeout(saveTimerRef.current); @@ -676,6 +724,7 @@ export const useChatAutoFollow = ({ notifyContentChange, getAnimationHandlers, goToBottom, + scrollToBottomOnSend, releaseAutoFollow, saveSnapshotNow, restoreSnapshot,