fix(chat): return to the live edge on queued sends and teleport rail jumps

Sending while the agent is working queues the message without creating a
user row, so the anchor-arming send path had nothing to claim and the
viewport stayed parked mid-history. Queued sends now jump straight to
the live edge (instant, so end following re-latches immediately).

Rail selection also switches from smooth to instant scrolling: a long
smooth scroll through the virtualized timeline gets cancelled by row
remounts and lands mid-way or on the wrong message, while a teleport
always arrives.
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 17:09:55 +03:00
parent 04f338cc49
commit f85e70b359
2 changed files with 18 additions and 2 deletions
@@ -1007,6 +1007,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
const resumeToLatestInstant = React.useCallback(() => {
goToBottom('instant');
}, [goToBottom]);
// Mobile loads older history via an explicit top button instead of a
// scroll-position trigger (see handleHistoryScroll in the controller).
const showLoadOlderButton = isMobileSurfaceRuntime()
@@ -1033,7 +1034,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
resumeToBottom: timelineController.resumeToBottomInstant,
});
const handlePromptNavigatorSelect = React.useCallback((turnId: string) => {
void navigation.scrollToTurnId(turnId, { behavior: 'smooth' });
// Instant on purpose: a long smooth scroll through a virtualized
// timeline gets cancelled by row remounts and lands mid-way or on the
// wrong message; a teleport always arrives.
void navigation.scrollToTurnId(turnId, { behavior: 'auto' });
}, [navigation]);
const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier;
const showPromptNavigator = !isMobile
@@ -1430,6 +1434,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
<ChatInput
active={active}
scrollToBottom={scrollToBottomOnSend}
scrollToLatest={resumeToLatestInstant}
draftPresentationExiting={draftPresentationExiting}
/>
)}
+12 -1
View File
@@ -225,6 +225,10 @@ const MemoStatusRow = React.memo(StatusRow);
interface ChatInputProps {
onOpenSettings?: () => void;
scrollToBottom?: () => void;
// Queued sends do not create a user row (the queue delivers later), so
// the anchor-arming scrollToBottom is wrong for them; this returns the
// viewport to the live edge instead.
scrollToLatest?: () => void;
active?: boolean;
draftPresentationExiting?: boolean;
}
@@ -243,6 +247,7 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
const ChatInputComponent: React.FC<ChatInputProps> = ({
onOpenSettings,
scrollToBottom,
scrollToLatest,
active = true,
draftPresentationExiting = false,
}) => {
@@ -898,6 +903,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
} : undefined,
});
// Sending while the agent works must still take the reader to the
// live edge — a queued message produces no user row yet, so the
// anchor path has nothing to claim and would leave the viewport
// parked mid-history.
scrollToLatest?.();
// Clear input and attachments
// Note: confirmedMentionsRef is NOT cleared here because queued messages
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
@@ -910,7 +921,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
if (!isMobile) {
composerRef.current?.focus();
}
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant]);
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest]);
const handleQueuedMessageEdit = React.useCallback((content: string) => {
setMessage(content);