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
@@ -15,7 +15,6 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatScrollManager, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatScrollManager';
import { useChatTimelineController } from './hooks/useChatTimelineController';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
import { useTimelineStaging } from '@/hooks/useTimelineStaging';
import { useDeviceInfo } from '@/lib/device';
import { Button } from '@/components/ui/button';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
@@ -311,6 +310,7 @@ export const ChatContainer: React.FC = () => {
);
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '');
const [suspendDetachedTailUpdates, setSuspendDetachedTailUpdates] = React.useState(false);
const [forceLiveViewport, setForceLiveViewport] = React.useState(false);
// Messages from sync system
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', undefined, {
suspendPartUpdates: suspendDetachedTailUpdates,
@@ -366,6 +366,10 @@ export const ChatContainer: React.FC = () => {
return false;
}
if (streamingMessageId || activeStreamingPhase) {
return true;
}
const statusType = sessionStatusForCurrent.type ?? 'idle';
if (statusType === 'busy' || statusType === 'retry') {
return true;
@@ -377,7 +381,7 @@ export const ChatContainer: React.FC = () => {
&& lastMessage.role === 'assistant'
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number',
);
}, [currentSessionId, sessionMessages, sessionPermissions.length, sessionStatusForCurrent.type]);
}, [activeStreamingPhase, currentSessionId, sessionMessages, sessionPermissions.length, sessionStatusForCurrent.type, streamingMessageId]);
const activeRetryStatus = React.useMemo(() => {
if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') {
return null;
@@ -487,6 +491,7 @@ export const ChatContainer: React.FC = () => {
scrollRef,
handleMessageContentChange,
getAnimationHandlers,
prepareForBottomResume,
scrollToBottom,
isPinned,
isOverflowing,
@@ -505,9 +510,9 @@ export const ChatContainer: React.FC = () => {
});
React.useEffect(() => {
const next = Boolean(currentSessionId && streamingMessageId && !isPinned);
const next = Boolean(currentSessionId && streamingMessageId && !isPinned && !forceLiveViewport);
setSuspendDetachedTailUpdates((previous) => (previous === next ? previous : next));
}, [currentSessionId, isPinned, streamingMessageId]);
}, [currentSessionId, forceLiveViewport, isPinned, streamingMessageId]);
const viewportMessagesRef = React.useRef<SessionMessageRecord[]>(EMPTY_MESSAGES);
const viewportSessionIdRef = React.useRef<string | null>(null);
@@ -522,6 +527,7 @@ export const ChatContainer: React.FC = () => {
currentSessionId
&& streamingMessageId
&& !isPinned
&& !forceLiveViewport
&& historyMeta?.loading !== true
&& canFreezeDetachedViewport(viewportMessagesRef.current, sessionMessages, streamingMessageId),
);
@@ -532,28 +538,45 @@ export const ChatContainer: React.FC = () => {
viewportMessagesRef.current = sessionMessages;
return sessionMessages;
}, [currentSessionId, historyMeta?.loading, isPinned, sessionMessages, streamingMessageId]);
// Deferred timeline staging — renders 1 message on first paint,
// adds 3 per rAF frame to avoid blocking.
const { stagedMessages } = useTimelineStaging({
sessionKey: currentSessionId ?? '',
messages: viewportMessages,
});
}, [currentSessionId, forceLiveViewport, historyMeta?.loading, isPinned, sessionMessages, streamingMessageId]);
const timelineController = useChatTimelineController({
sessionId: currentSessionId,
messages: stagedMessages,
messages: viewportMessages,
historyMeta,
scrollRef,
messageListRef,
loadMoreMessages,
prepareForBottomResume,
scrollToBottom,
isPinned,
isOverflowing,
});
const { loadEarlier, resumeToBottomInstant } = timelineController;
const runLatestInstantResume = React.useCallback(async () => {
setForceLiveViewport(true);
try {
if (!currentSessionId) {
scrollToBottom({ instant: true, force: true });
return;
}
await resumeToBottomInstant();
} finally {
if (typeof window === 'undefined') {
setForceLiveViewport(false);
} else {
window.requestAnimationFrame(() => {
setForceLiveViewport(false);
});
}
}
}, [currentSessionId, resumeToBottomInstant, scrollToBottom]);
const resumeToLatestInstant = React.useCallback(() => {
void runLatestInstantResume();
}, [runLatestInstantResume]);
React.useEffect(() => {
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
}, [timelineController.handleActiveTurnChange]);
@@ -575,7 +598,7 @@ export const ChatContainer: React.FC = () => {
activeTurnId: timelineController.activeTurnId,
scrollToTurn: timelineController.scrollToTurn,
scrollToMessage: timelineController.scrollToMessage,
resumeToBottom: timelineController.resumeToBottom,
resumeToBottom: timelineController.resumeToBottomInstant,
});
React.useEffect(() => {
@@ -585,7 +608,7 @@ export const ChatContainer: React.FC = () => {
const customEvent = event as CustomEvent<string>;
if (customEvent.detail !== currentSessionId) return;
if (isPinned || !isOverflowing || isProgrammaticFollowActive) return;
resumeToBottomInstant();
void resumeToBottomInstant();
};
window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
@@ -632,11 +655,39 @@ export const ChatContainer: React.FC = () => {
const hasHistoryMetadata = Boolean(historyMeta);
const lastHydratedSessionRef = React.useRef<string | null>(null);
const lastScrolledSessionRef = React.useRef<string | null>(null);
const isSessionHydrating =
Boolean(currentSessionId)
&& (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true);
React.useEffect(() => {
if (!currentSessionId) {
return;
}
if (lastScrolledSessionRef.current === currentSessionId) {
return;
}
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
if (hasHashTarget) {
lastScrolledSessionRef.current = currentSessionId;
return;
}
lastScrolledSessionRef.current = currentSessionId;
if (typeof window === 'undefined') {
resumeToLatestInstant();
return;
}
window.requestAnimationFrame(() => {
resumeToLatestInstant();
});
}, [currentSessionId, resumeToLatestInstant]);
React.useEffect(() => {
if (!currentSessionId) return;
if (hasSessionMessagesEntry && hasHistoryMetadata) return;
@@ -653,10 +704,10 @@ export const ChatContainer: React.FC = () => {
if (!shouldSkipScroll) {
if (typeof window === 'undefined') {
scrollToBottom({ instant: true });
resumeToLatestInstant();
} else {
window.requestAnimationFrame(() => {
scrollToBottom({ instant: true });
resumeToLatestInstant();
});
}
}
@@ -664,7 +715,7 @@ export const ChatContainer: React.FC = () => {
};
void load();
}, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]);
}, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, resumeToLatestInstant, sessionMessages.length, sessionStatusForCurrent.type]);
if (!currentSessionId && !draftOpen) {
return (
@@ -696,7 +747,7 @@ export const ChatContainer: React.FC = () => {
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
<ChatInput scrollToBottom={scrollToBottom} />
<ChatInput scrollToBottom={resumeToLatestInstant} />
</div>
</div>
);
@@ -759,7 +810,7 @@ export const ChatContainer: React.FC = () => {
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
<ChatInput scrollToBottom={scrollToBottom} />
<ChatInput scrollToBottom={resumeToLatestInstant} />
</div>
</div>
);
@@ -795,7 +846,7 @@ export const ChatContainer: React.FC = () => {
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
<ChatInput scrollToBottom={scrollToBottom} />
<ChatInput scrollToBottom={resumeToLatestInstant} />
</div>
</div>
);
@@ -846,7 +897,7 @@ export const ChatContainer: React.FC = () => {
onClick={navigation.resumeToLatest}
/>
)}
<ChatInput scrollToBottom={scrollToBottom} />
<ChatInput scrollToBottom={resumeToLatestInstant} />
</div>
</div>
);
+12 -5
View File
@@ -1305,9 +1305,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
return;
}
// Re-pin and scroll to bottom when sending
scrollToBottom?.({ instant: true, force: true });
if (!currentProviderId || !currentModelId) {
console.warn('Cannot send message: provider or model not selected');
return;
@@ -1483,7 +1480,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
...additionalParts.flatMap(p => p.attachments ?? []),
];
void sendMessage(
const sendPromise = sendMessage(
primaryText,
currentProviderId,
currentModelId,
@@ -1493,7 +1490,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
additionalParts.length > 0 ? additionalParts : undefined,
currentVariant,
inputMode
).then(() => {
);
if (typeof window === 'undefined') {
scrollToBottom?.({ instant: true, force: true });
} else {
window.requestAnimationFrame(() => {
scrollToBottom?.({ instant: true, force: true });
});
}
void sendPromise.then(() => {
// Clear linked issue after successful message send
if (linkedIssue) {
setLinkedIssue(null);
@@ -737,10 +737,10 @@ const buildMarkdownComponents = ({
return <td {...props} className={cn('border-r border-border/60 px-4 py-2.5 align-middle text-foreground/90 last:border-r-0', props.className)}>{children}</td>;
},
ul({ children, ...props }) {
return <ul {...props} className={cn('typography-markdown-body my-2 pl-6', props.className)}>{children}</ul>;
return <ul {...props} className={cn('typography-markdown-body my-2', props.className)}>{children}</ul>;
},
ol({ children, ...props }) {
return <ol {...props} className={cn('typography-markdown-body my-2 pl-6', props.className)}>{children}</ol>;
return <ol {...props} className={cn('typography-markdown-body my-2', props.className)}>{children}</ol>;
},
li({ children, ...props }) {
return <li {...props} className={cn('typography-markdown-body my-0.5 text-foreground/90', props.className)}>{children}</li>;
@@ -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);