mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-10 15:55:53 +08:00
fix(chat): re-anchor initial scroll across lazy content reflow
The previous initial-scroll behavior fired one scrollToBottom() at +200ms after the session load and cleared the pending flag. When markdown, syntax highlighting, or images finished rendering after that window, scrollHeight grew but nothing re-anchored the viewport. The chat tab appeared "scrolled way up" with the latest assistant message off-screen until the user manually scrolled or sent a new message. This replaces the setTimeout with a requestAnimationFrame loop that re-scrolls every frame while scrollHeight is still growing, capped at ~1s (60 frames) or 3 consecutive stable frames. The loop cancels cleanly on session change via the existing pendingInitialScrollRef flag, and the cleanup function cancels any in-flight rAF on unmount. No behavior change for sessions whose content layout is already stable at the first frame. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -383,12 +383,47 @@ export function useChatSessionState({
|
|||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
}, [selectedProject?.projectId, selectedSession?.id]);
|
}, [selectedProject?.projectId, selectedSession?.id]);
|
||||||
|
|
||||||
// Initial scroll to bottom
|
// Initial scroll to bottom — robust to lazy content reflow.
|
||||||
|
// The previous implementation fired one scrollToBottom() at +200ms and
|
||||||
|
// cleared the pending flag. When markdown blocks, code highlighting, or
|
||||||
|
// images finished rendering after that window, scrollHeight grew but
|
||||||
|
// nothing re-anchored the viewport, leaving the chat tab visually
|
||||||
|
// "scrolled way up" with the latest assistant message off-screen.
|
||||||
|
//
|
||||||
|
// This version re-scrolls every animation frame while scrollHeight is
|
||||||
|
// still growing, capped at ~1s (60 frames) or 3 consecutive stable
|
||||||
|
// frames. Cancels cleanly on session change via the pending flag.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
|
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
|
||||||
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
|
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
|
||||||
pendingInitialScrollRef.current = false;
|
if (searchScrollActiveRef.current) { pendingInitialScrollRef.current = false; return; }
|
||||||
if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200);
|
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
let frame = 0;
|
||||||
|
let lastHeight = 0;
|
||||||
|
let stableCount = 0;
|
||||||
|
let rafId = 0;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (!pendingInitialScrollRef.current || !scrollContainerRef.current) return;
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
if (container.scrollHeight === lastHeight) {
|
||||||
|
stableCount++;
|
||||||
|
} else {
|
||||||
|
stableCount = 0;
|
||||||
|
lastHeight = container.scrollHeight;
|
||||||
|
}
|
||||||
|
frame++;
|
||||||
|
if (stableCount < 3 && frame < 60) {
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
pendingInitialScrollRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
||||||
|
|
||||||
// Main session loading effect — store-based
|
// Main session loading effect — store-based
|
||||||
|
|||||||
Reference in New Issue
Block a user