diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 6e401ed80..95258af1e 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -101,6 +101,7 @@ export function AgentInputArea({ const [isProcessing, setIsProcessing] = useState(false); const [selectedImages, setSelectedImages] = useState([]); const [isCancellingAgent, setIsCancellingAgent] = useState(false); + const [sendError, setSendError] = useState(null); const [commandSelectedIndex, setCommandSelectedIndex] = useState(0); const lastHandledFocusRequestIdRef = useRef(null); @@ -130,6 +131,13 @@ export function AgentInputArea({ setCommandSelectedIndex(0); }, [commandFilter]); + // Clear send error when user edits the input + useEffect(() => { + if (sendError && userInput) { + setSendError(null); + } + }, [userInput, sendError]); + const { pickImages } = useImageAttachmentPicker(); const agentIdRef = useRef(agentId); const sendAgentMessageRef = useRef< @@ -256,28 +264,38 @@ export function AgentInputArea({ return; } - const isControlledLocal = value !== undefined; - setSelectedImages([]); - setIsProcessing(true); - - try { - await submitMessage(trimmedMessage, imageAttachments); - // Clear input only after successful submission - // For controlled inputs with onSubmitMessage, the parent handles clearing - // because agent creation is async (WebSocket) and errors come back later - if (onSubmitMessageRef.current) { - // Parent manages input state - don't clear here - // Parent will clear on success via onChangeText - } else if (isControlledLocal) { + // Clear input optimistically before awaiting server ack. + // Save values so we can restore on error. + const savedImages = imageAttachments; + if (!onSubmitMessageRef.current) { + if (value !== undefined) { onChangeText?.(""); } else { setUserInput(""); } + } + setSelectedImages([]); + setSendError(null); + setIsProcessing(true); + + try { + await submitMessage(trimmedMessage, imageAttachments); } catch (error) { console.error("[AgentInput] Failed to send message:", error); - if (imageAttachments) { - setSelectedImages(imageAttachments); + // Restore input so the user never loses their message + if (!onSubmitMessageRef.current) { + if (value !== undefined) { + onChangeText?.(trimmedMessage); + } else { + setUserInput(trimmedMessage); + } } + if (savedImages) { + setSelectedImages(savedImages); + } + setSendError( + error instanceof Error ? error.message : "Failed to send message" + ); } finally { setIsProcessing(false); } @@ -449,7 +467,14 @@ export function AgentInputArea({ // Cancels current agent run before sending queued prompt handleCancelAgent(); - await submitMessage(item.text, item.images); + try { + await submitMessage(item.text, item.images); + } catch (error) { + updateQueue((current) => [item, ...current]); + setSendError( + error instanceof Error ? error.message : "Failed to send message" + ); + } } const handleQueue = useCallback((payload: MessagePayload) => { @@ -616,6 +641,10 @@ export function AgentInputArea({ /> )} + {sendError && ( + {sendError} + )} + {/* MessageInput handles everything: text, dictation, attachments, all buttons */} ({ queueSendButton: { backgroundColor: theme.colors.palette.blue[600], }, + sendErrorText: { + color: theme.colors.palette.red[500], + fontSize: theme.fontSize.sm, + }, })) as any) as Record; diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index ced095407..90b24d0fc 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -826,8 +826,7 @@ function WorkingIndicator() { withTiming(1, { duration: bounceDuration }), withTiming(0, { duration: bounceDuration }) ), - -1, - true + -1 ) ); }); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 9d4b39d38..288785a3a 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -2,6 +2,7 @@ import { View, TextInput, Pressable, + ActivityIndicator, NativeSyntheticEvent, TextInputContentSizeChangeEventData, TextInputKeyPressEventData, @@ -533,7 +534,7 @@ export const MessageInput = forwardRef( const hasImages = images.length > 0; const hasSendableContent = value.trim().length > 0 || hasImages; - const shouldShowSendButton = hasSendableContent; + const shouldShowSendButton = hasSendableContent || isSubmitLoading; const isConnected = client?.isConnected ?? false; return ( @@ -675,7 +676,11 @@ export const MessageInput = forwardRef( styles.buttonDisabled, ]} > - + {isSubmitLoading ? ( + + ) : ( + + )} )}