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:
@@ -1007,6 +1007,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
const resumeToLatestInstant = React.useCallback(() => {
|
const resumeToLatestInstant = React.useCallback(() => {
|
||||||
goToBottom('instant');
|
goToBottom('instant');
|
||||||
}, [goToBottom]);
|
}, [goToBottom]);
|
||||||
|
|
||||||
// Mobile loads older history via an explicit top button instead of a
|
// Mobile loads older history via an explicit top button instead of a
|
||||||
// scroll-position trigger (see handleHistoryScroll in the controller).
|
// scroll-position trigger (see handleHistoryScroll in the controller).
|
||||||
const showLoadOlderButton = isMobileSurfaceRuntime()
|
const showLoadOlderButton = isMobileSurfaceRuntime()
|
||||||
@@ -1033,7 +1034,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
resumeToBottom: timelineController.resumeToBottomInstant,
|
resumeToBottom: timelineController.resumeToBottomInstant,
|
||||||
});
|
});
|
||||||
const handlePromptNavigatorSelect = React.useCallback((turnId: string) => {
|
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]);
|
}, [navigation]);
|
||||||
const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier;
|
const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier;
|
||||||
const showPromptNavigator = !isMobile
|
const showPromptNavigator = !isMobile
|
||||||
@@ -1430,6 +1434,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
<ChatInput
|
<ChatInput
|
||||||
active={active}
|
active={active}
|
||||||
scrollToBottom={scrollToBottomOnSend}
|
scrollToBottom={scrollToBottomOnSend}
|
||||||
|
scrollToLatest={resumeToLatestInstant}
|
||||||
draftPresentationExiting={draftPresentationExiting}
|
draftPresentationExiting={draftPresentationExiting}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -225,6 +225,10 @@ const MemoStatusRow = React.memo(StatusRow);
|
|||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
scrollToBottom?: () => 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;
|
active?: boolean;
|
||||||
draftPresentationExiting?: boolean;
|
draftPresentationExiting?: boolean;
|
||||||
}
|
}
|
||||||
@@ -243,6 +247,7 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
|
|||||||
const ChatInputComponent: React.FC<ChatInputProps> = ({
|
const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
|
scrollToLatest,
|
||||||
active = true,
|
active = true,
|
||||||
draftPresentationExiting = false,
|
draftPresentationExiting = false,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -898,6 +903,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
} : undefined,
|
} : 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
|
// Clear input and attachments
|
||||||
// Note: confirmedMentionsRef is NOT cleared here because queued messages
|
// Note: confirmedMentionsRef is NOT cleared here because queued messages
|
||||||
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
|
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
|
||||||
@@ -910,7 +921,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
composerRef.current?.focus();
|
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) => {
|
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||||
setMessage(content);
|
setMessage(content);
|
||||||
|
|||||||
Reference in New Issue
Block a user