fix: stabilize chat auto-scroll and bottom resume

- Unify send and session resumes around the latest chat tail
- Keep smooth follow active during assistant message growth
Remove staged chat rendering from the main scroll path
This commit is contained in:
Bohdan Triapitsyn
2026-04-06 20:18:20 +03:00
parent f884919165
commit a516650f96
9 changed files with 289 additions and 152 deletions
@@ -32,7 +32,8 @@ interface UseChatTimelineControllerOptions {
scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>;
loadMoreMessages: (sessionId: string, direction: 'up' | 'down') => Promise<void>;
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
prepareForBottomResume: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom: (options?: { instant?: boolean; force?: boolean; followBottom?: boolean }) => void;
isPinned: boolean;
isOverflowing: boolean;
}
@@ -65,6 +66,7 @@ export const useChatTimelineController = ({
scrollRef,
messageListRef,
loadMoreMessages,
prepareForBottomResume,
scrollToBottom,
isPinned,
isOverflowing,
@@ -484,21 +486,35 @@ export const useChatTimelineController = ({
}
}, [attemptPendingScrollRequest, sessionId]);
const resumeToBottom = React.useCallback(() => {
const resumeToBottom = React.useCallback(async () => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setTurnStart(nextStart);
setPendingRevealWork(false);
setIsLoadingOlder(false);
scrollToBottom({ force: true });
}, [scrollToBottom]);
prepareForBottomResume({ force: true });
const resumeToBottomInstant = React.useCallback(() => {
const shouldWaitForRender = nextStart !== turnStartRef.current;
if (shouldWaitForRender) {
setTurnStart(nextStart);
await waitForNextRenderCommit();
}
scrollToBottom({ force: true });
}, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]);
const resumeToBottomInstant = React.useCallback(async () => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setTurnStart(nextStart);
setPendingRevealWork(false);
setIsLoadingOlder(false);
scrollToBottom({ instant: true, force: true });
}, [scrollToBottom]);
prepareForBottomResume({ instant: true, force: true });
const shouldWaitForRender = nextStart !== turnStartRef.current;
if (shouldWaitForRender) {
setTurnStart(nextStart);
await waitForNextRenderCommit();
}
scrollToBottom({ instant: true, force: true, followBottom: true });
}, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]);
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
setActiveTurnId(turnId);