From f85e70b35914b4cb7fb7a403af9ccd38379068ee Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 17:09:55 +0300 Subject: [PATCH] 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. --- packages/ui/src/components/chat/ChatContainer.tsx | 7 ++++++- packages/ui/src/components/chat/ChatInput.tsx | 13 ++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 66b40858..25427806 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -1007,6 +1007,7 @@ export const ChatContainer: React.FC = ({ 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 = ({ 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 = ({ )} diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index ccea10a1..57387da7 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ onOpenSettings, scrollToBottom, + scrollToLatest, active = true, draftPresentationExiting = false, }) => { @@ -898,6 +903,12 @@ const ChatInputComponent: React.FC = ({ } : 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 = ({ 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);