mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 08:03:15 +00:00
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:
parent
911c835c2a
commit
3297e799de
3 changed files with 52 additions and 20 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue