fix: keep chat prompt bar editable while a run streams (#1533)

* fix: keep prompt bar editable while a run streams

The follow-up submit path awaited stream.submit, which resolves only when the
run finishes. That kept the react-query mutation isPending for the whole run,
so AgentThreadView disabled the prompt bar textarea the entire time the agent
was streaming - the user saw the "queue next" placeholder but couldn't click
in or type. Fire the run without awaiting its full lifecycle (matching the
new-thread path in AgentsHome) so the input stays editable for queueing.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* refactor(ui): guard double-submit internally instead of via disabled prop

Stop threading a run-lifecycle signal (the send mutation's isPending) into the
prompt bar to gate the input. Instead, the prompt bar owns a synchronous
double-submit guard (submittingRef) plus a short-lived isSubmitting state
scoped to the in-flight send. The textarea now stays editable while a run
streams (so follow-ups can be queued), and onSubmit is awaitable so the guard
tracks the actual send request rather than the run.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix(ui): surface failed run-start instead of leaving thread running

When stream.submit rejects (e.g. 401 expired token or 409 active-run
race), the fire-and-forget catch was a no-op while onSuccess had already
optimistically set status: running, leaving the thread stuck in a busy
state with no surfaced error. Clear the busy state and mark the thread
errored on submit failure.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>
This commit is contained in:
Caroline di Vittorio 2026-06-15 17:29:17 -07:00 • committed by GitHub
parent 911c835c2a
commit 3297e799de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 52 additions and 20 deletions

View file

@ -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,

View file

@ -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 (
<IconButton
type="button"
@ -39,7 +39,7 @@ function PlainSubmitButton({ canSubmit, disabled, onSubmit }: SubmitButtonProps)
aria-label="Send message"
className="shrink-0 rounded-full bg-[var(--ui-accent)] text-white hover:bg-[var(--ui-accent)] hover:opacity-90 disabled:cursor-default disabled:opacity-40"
>
{disabled ? (
{submitting ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<ArrowUp className="size-3.5" strokeWidth={2.5} />
@ -111,7 +111,7 @@ export interface CloudPromptBarProps {
compact?: boolean
disabled?: boolean
busy?: boolean
onSubmit?: (value: string, images: Array<ImageChunk>) => void
onSubmit?: (value: string, images: Array<ImageChunk>) => void | Promise<void>
models?: Array<ModelOption>
selection?: ModelSelection | null
onSelectionChange?: (next: ModelSelection) => void
@ -165,10 +165,15 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
const [pendingImages, setPendingImages] = useState<Array<ImageChunk>>([])
const [isDragOver, setIsDragOver] = useState(false)
const [modelDropdownOpen, setModelDropdownOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragDepthRef = useRef(0)
const modelDropdownRef = useRef<HTMLDivElement>(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<Array<ModelSelection>>(() => {
const list: Array<ModelSelection> = []
@ -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<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && canSubmit) {
e.preventDefault()
handleSubmit()
void handleSubmit()
}
}
@ -446,8 +465,8 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
<SubmitButton
canSubmit={canSubmit}
disabled={disabled}
onSubmit={handleSubmit}
submitting={isSubmitting}
onSubmit={() => void handleSubmit()}
/>
</div>
</div>

View file

@ -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) =>