From f9094bc3cc5d0f85b20ece894f3d81204c844734 Mon Sep 17 00:00:00 2001 From: pasta-paul <149531089+pasta-paul@users.noreply.github.com> Date: Fri, 1 May 2026 04:26:14 -0700 Subject: [PATCH] fix(ui): prevent queued message truncation from stale React closure (#1087) * fix(ui): read textarea DOM value in queue handler to prevent truncation When the user types quickly and clicks the Queue button, React may not have committed the latest `message` state yet, causing handleQueueMessage to capture a stale closure (often just the first character typed). Read textareaRef.current.value directly from the DOM instead, which always reflects the current input regardless of React's render cycle. Fall back to the React state when the ref is unavailable. Also recompute hasContent from the DOM value to ensure the guard check is consistent with the actual message being queued. * fix(ui): use current input value when sending --------- Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/components/chat/ChatInput.tsx | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index fea8b17e..da009be6 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1251,6 +1251,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const canAbort = sessionPhase !== 'idle'; + const getCurrentInputSnapshot = React.useCallback(() => { + const currentMessage = textareaRef.current?.value ?? message; + return { + message: currentMessage, + hasContent: currentMessage.trim().length > 0 || sendableAttachedFiles.length > 0 || hasDrafts, + }; + }, [hasDrafts, message, sendableAttachedFiles.length]); + // Keep a ref to handleSubmit so callbacks don't depend on it. type SubmitOptions = { queuedOnly?: boolean; @@ -1259,11 +1267,12 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Add message to queue instead of sending const handleQueueMessage = React.useCallback(() => { - if (!hasContent || !currentSessionId) return; + const inputSnapshot = getCurrentInputSnapshot(); + if (!inputSnapshot.hasContent || !currentSessionId) return; const drafts = consumeDrafts(currentSessionId); - let messageToQueue = message.replace(/^\n+|\n+$/g, ''); + let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); if (drafts.length > 0) { messageToQueue = appendInlineComments(messageToQueue, drafts); } @@ -1292,7 +1301,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!isMobile) { textareaRef.current?.focus(); } - }, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); + }, [getCurrentInputSnapshot, currentSessionId, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); const handleQueuedMessageEdit = React.useCallback((content: string) => { setMessage(content); @@ -1319,10 +1328,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const handleSubmit = async (options?: SubmitOptions) => { const queuedOnly = options?.queuedOnly ?? false; + const inputSnapshot = getCurrentInputSnapshot(); if (queuedOnly) { if (!hasQueuedMessages || !currentSessionId) return; - } else if (!canSend || (!currentSessionId && !newSessionDraftOpen)) { + } else if ((!inputSnapshot.hasContent && !hasQueuedMessages) || (!currentSessionId && !newSessionDraftOpen)) { return; } @@ -1369,8 +1379,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } // Add current input (skip for queued-only auto-send) - if (!queuedOnly && hasContent) { - const messageToSend = message.replace(/^\n+|\n+$/g, ''); + if (!queuedOnly && inputSnapshot.hasContent) { + const messageToSend = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents); const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText); const attachmentsToSend = sanitizeAttachmentsForSend(sendableAttachedFiles); @@ -1655,13 +1665,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Primary action for send button - respects queue mode setting const handlePrimaryAction = React.useCallback(() => { - const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle'; + const inputSnapshot = getCurrentInputSnapshot(); + const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && sessionPhase !== 'idle'; if (queueModeEnabled && canQueue) { handleQueueMessage(); } else { void handleSubmitRef.current(); } - }, [inputMode, hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]); + }, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]); const handleKeyDown = (e: React.KeyboardEvent) => { // Early return during IME composition to prevent interference with autocomplete.