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);