diff --git a/ui/src/components/agents/AgentThreadView.tsx b/ui/src/components/agents/AgentThreadView.tsx index 300b75f3..1272dad2 100644 --- a/ui/src/components/agents/AgentThreadView.tsx +++ b/ui/src/components/agents/AgentThreadView.tsx @@ -81,9 +81,8 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) { placeholder="Add a follow up" compact busy={isStreaming} - disabled={sendMessage.isPending} onSubmit={(content, images) => - sendMessage.mutate({ + sendMessage.mutateAsync({ content, images, model_id: activeSelection?.modelId ?? null, @@ -111,9 +110,8 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) { placeholder="Send the first message" compact busy={isStreaming} - disabled={sendMessage.isPending} onSubmit={(content, images) => - sendMessage.mutate({ + sendMessage.mutateAsync({ content, images, model_id: activeSelection?.modelId ?? null, diff --git a/ui/src/components/agents/ported/CloudPromptBar.tsx b/ui/src/components/agents/ported/CloudPromptBar.tsx index 2b365fbe..f0dbcc8c 100644 --- a/ui/src/components/agents/ported/CloudPromptBar.tsx +++ b/ui/src/components/agents/ported/CloudPromptBar.tsx @@ -26,11 +26,11 @@ const PROMPT_TEXTAREA_MAX_HEIGHT = 200 interface SubmitButtonProps { canSubmit: boolean - disabled: boolean + submitting: boolean onSubmit: () => void } -function PlainSubmitButton({ canSubmit, disabled, onSubmit }: SubmitButtonProps) { +function PlainSubmitButton({ canSubmit, submitting, onSubmit }: SubmitButtonProps) { return ( - {disabled ? ( + {submitting ? ( ) : ( @@ -111,7 +111,7 @@ export interface CloudPromptBarProps { compact?: boolean disabled?: boolean busy?: boolean - onSubmit?: (value: string, images: Array) => void + onSubmit?: (value: string, images: Array) => void | Promise models?: Array selection?: ModelSelection | null onSelectionChange?: (next: ModelSelection) => void @@ -165,10 +165,15 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({ const [pendingImages, setPendingImages] = useState>([]) const [isDragOver, setIsDragOver] = useState(false) const [modelDropdownOpen, setModelDropdownOpen] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) const inputRef = useRef(null) const fileInputRef = useRef(null) const dragDepthRef = useRef(0) const modelDropdownRef = useRef(null) + // Synchronous double-submit guard: blocks a same-tick second send (Enter + + // click, or two rapid Enters) before React re-renders. Scoped to the send + // request only — never the run lifecycle. + const submittingRef = useRef(false) const combos = useMemo>(() => { const list: Array = [] @@ -183,15 +188,29 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({ const selectionLabel = formatModelSelection(models, selection) const canSubmit = - !disabled && (value.trim().length > 0 || pendingImages.length > 0) + !disabled && + !isSubmitting && + (value.trim().length > 0 || pendingImages.length > 0) - const handleSubmit = useCallback(() => { + const handleSubmit = useCallback(async () => { + if (submittingRef.current || disabled) return const trimmed = value.trim() - if (!canSubmit) return - onSubmit?.(trimmed, pendingImages) + if (trimmed.length === 0 && pendingImages.length === 0) return + + const images = pendingImages + submittingRef.current = true + setIsSubmitting(true) setValue("") setPendingImages([]) - }, [canSubmit, onSubmit, pendingImages, value]) + try { + await onSubmit?.(trimmed, images) + } catch { + // Caller surfaces send errors (e.g. via react-query mutation state). + } finally { + submittingRef.current = false + setIsSubmitting(false) + } + }, [disabled, onSubmit, pendingImages, value]) useLayoutEffect(() => { const el = inputRef.current @@ -291,7 +310,7 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({ const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey && canSubmit) { e.preventDefault() - handleSubmit() + void handleSubmit() } } @@ -446,8 +465,8 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({ void handleSubmit()} /> diff --git a/ui/src/lib/agents/provider/useSubmitAgentMessage.ts b/ui/src/lib/agents/provider/useSubmitAgentMessage.ts index ca9166a8..9a15076c 100644 --- a/ui/src/lib/agents/provider/useSubmitAgentMessage.ts +++ b/ui/src/lib/agents/provider/useSubmitAgentMessage.ts @@ -73,10 +73,25 @@ export function useSubmitAgentMessage(threadId: string) { }, }; - await stream.submit( - { messages: [{ type: "human", content: messageContent(vars) }] }, - { config }, - ); + // Don't await: `stream.submit` resolves only when the run *finishes*, so + // awaiting would keep the mutation `isPending` (and the prompt bar + // disabled) for the entire run, blocking the user from queueing a + // follow-up while it streams. + void stream + .submit( + { messages: [{ type: "human", content: messageContent(vars) }] }, + { config }, + ) + .catch(() => { + // The run failed to start (e.g. expired OAuth token → 401, or a + // 409 active-run race), but `onSuccess` already optimistically set + // `status: "running"`. Surface the failure and clear the busy state + // instead of leaving the thread falsely running. + queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) => + prev ? { ...prev, status: "error" as const } : prev, + ); + invalidateAgentThreadLists(queryClient); + }); }, onSuccess: () => { queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) =>