Skip to content

Commit e70b379

Browse files
fix: handle queued prompt edge cases during pending submission and SSE errors
1 parent 36a9b28 commit e70b379

6 files changed

Lines changed: 99 additions & 15 deletions

File tree

frontend/src/components/message/PromptInput.stt.test.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({
2424
useSessionAgent: vi.fn(),
2525
useAgents: vi.fn(),
2626
useSendPromptMutate: vi.fn(),
27+
sendPromptPending: vi.fn(() => false),
2728
useUserBash: vi.fn(),
2829
useSessionAgentStore: vi.fn(),
2930
useSendErrorStore: vi.fn(),
@@ -40,9 +41,9 @@ vi.mock('@/hooks/useMobile', () => ({
4041
}))
4142

4243
vi.mock('@/hooks/useOpenCode', () => ({
43-
useSendPrompt: () => ({ mutate: mocks.useSendPromptMutate }),
44+
useSendPrompt: () => ({ mutate: mocks.useSendPromptMutate, isPending: mocks.sendPromptPending() }),
4445
useAbortSession: () => ({ mutate: vi.fn() }),
45-
useSendShell: () => ({ mutate: vi.fn() }),
46+
useSendShell: () => ({ mutate: vi.fn(), isPending: false }),
4647
useOpenCodeClient: () => ({}),
4748
useAgents: () => ({ data: [] }),
4849
}))
@@ -162,6 +163,7 @@ describe('PromptInput STT Gesture Tests', () => {
162163
mocks.useSendPromptMutate.mockImplementation((_variables, options) => {
163164
options?.onSuccess?.()
164165
})
166+
mocks.sendPromptPending.mockReturnValue(false)
165167

166168
mocks.useMobile.mockReturnValue(true)
167169
mocks.useSTT.mockReturnValue({
@@ -274,6 +276,54 @@ describe('PromptInput STT Gesture Tests', () => {
274276
})
275277
})
276278

279+
it('clears submitted text once the server confirms it is processing', async () => {
280+
mocks.useSendPromptMutate.mockImplementation(() => undefined)
281+
const queryClient = createTestQueryClient()
282+
const { rerender } = render(
283+
<QueryClientProvider client={queryClient}>
284+
<PromptInput {...defaultProps} />
285+
</QueryClientProvider>,
286+
)
287+
288+
const input = screen.getByPlaceholderText('Send a message...')
289+
fireEvent.change(input, { target: { value: 'do the thing' } })
290+
fireEvent.click(screen.getByTitle('Send'))
291+
292+
expect(input).toHaveValue('do the thing')
293+
294+
rerender(
295+
<QueryClientProvider client={queryClient}>
296+
<PromptInput {...defaultProps} isStreamingResponse />
297+
</QueryClientProvider>,
298+
)
299+
300+
await waitFor(() => {
301+
expect(input).toHaveValue('')
302+
})
303+
})
304+
305+
it('allows queuing a follow-up while a non-queued send is pending', async () => {
306+
mocks.sendPromptPending.mockReturnValue(true)
307+
render(
308+
<QueryClientProvider client={createTestQueryClient()}>
309+
<PromptInput {...defaultProps} isStreamingResponse />
310+
</QueryClientProvider>,
311+
)
312+
313+
const input = screen.getByPlaceholderText('Send a message...')
314+
fireEvent.change(input, { target: { value: 'follow-up' } })
315+
316+
const queueButton = screen.getByTitle('Queue message')
317+
expect(queueButton).not.toBeDisabled()
318+
319+
fireEvent.click(queueButton)
320+
321+
expect(mocks.useSendPromptMutate).toHaveBeenCalledWith(
322+
expect.objectContaining({ prompt: 'follow-up', queued: true }),
323+
expect.any(Object),
324+
)
325+
})
326+
277327
it('restores a failed queued prompt when the input is empty', async () => {
278328
const queryClient = createTestQueryClient()
279329
const { rerender } = render(

frontend/src/components/message/PromptInput.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,21 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
174174
setSelectedAgent(null)
175175
clearSTT()
176176
}, [clearSTT])
177-
177+
178+
const pendingConfirmClearRef = useRef<{
179+
prompt: string
180+
files: Map<string, FileAttachmentInfo>
181+
images: ImageAttachment[]
182+
} | null>(null)
183+
184+
useEffect(() => {
185+
if (!isStreamingResponse) return
186+
const pending = pendingConfirmClearRef.current
187+
if (!pending) return
188+
pendingConfirmClearRef.current = null
189+
clearSubmittedPrompt(pending.prompt, pending.files, pending.images)
190+
}, [isStreamingResponse, clearSubmittedPrompt])
191+
178192
useImperativeHandle(ref, () => ({
179193
setPromptValue: (value: string) => {
180194
setPrompt(value)
@@ -262,7 +276,6 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
262276

263277
const handleSubmit = () => {
264278
if (disabled) return
265-
if (isPromptSubmitPending) return
266279
if (!prompt.trim() && imageAttachments.length === 0) return
267280

268281
pendingVoiceAutoSubmitRef.current = false
@@ -296,6 +309,8 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
296309
return
297310
}
298311

312+
if (isPromptSubmitPending) return
313+
299314
if (isBashMode) {
300315
const command = prompt.startsWith('!') ? prompt.slice(1) : prompt
301316
addUserBashCommand(command)
@@ -340,6 +355,12 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
340355
const submittedAttachedFiles = attachedFiles
341356
const submittedImageAttachments = imageAttachments
342357

358+
pendingConfirmClearRef.current = {
359+
prompt: submittedPrompt,
360+
files: submittedAttachedFiles,
361+
images: submittedImageAttachments
362+
}
363+
343364
sendPrompt.mutate(
344365
{
345366
sessionID,
@@ -350,7 +371,13 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
350371
variant: currentVariant
351372
},
352373
{
353-
onSuccess: () => clearSubmittedPrompt(submittedPrompt, submittedAttachedFiles, submittedImageAttachments)
374+
onSuccess: () => {
375+
pendingConfirmClearRef.current = null
376+
clearSubmittedPrompt(submittedPrompt, submittedAttachedFiles, submittedImageAttachments)
377+
},
378+
onError: () => {
379+
pendingConfirmClearRef.current = null
380+
}
354381
}
355382
)
356383

@@ -1367,7 +1394,7 @@ return (
13671394
<button
13681395
data-submit-prompt
13691396
onClick={hasPendingPermissionForSession ? () => setShowDialog(true) : handleSubmit}
1370-
disabled={hasPendingPermissionForSession ? false : ((!prompt.trim() && imageAttachments.length === 0) || disabled || isPromptSubmitPending)}
1397+
disabled={hasPendingPermissionForSession ? false : ((!prompt.trim() && imageAttachments.length === 0) || disabled || (isPromptSubmitPending && !isStreamingResponse))}
13711398
className={`px-4 md:px-5 py-1.5 md:py-2 rounded-lg text-sm font-medium transition-colors dark:border flex-shrink-0 min-w-[52px] ${
13721399
hasPendingPermissionForSession
13731400
? 'bg-orange-500 hover:bg-orange-600 border-orange-400 text-primary-foreground ring-orange-500/20'

frontend/src/hooks/useSSE.test.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ describe('useSSE', () => {
401401
unmount()
402402
})
403403

404-
it('clears queued prompt text when the queued user message is observed', async () => {
404+
it('does not create a send error banner once the queued prompt has been cleared', async () => {
405405
const queryClient = new QueryClient({
406406
defaultOptions: {
407407
queries: { retry: false },
@@ -453,11 +453,7 @@ describe('useSSE', () => {
453453
})
454454
})
455455

456-
expect(useSendErrorStore.getState().getError('session-1')).toEqual({
457-
sessionID: 'session-1',
458-
title: 'Error',
459-
message: 'Unrelated failure',
460-
})
456+
expect(useSendErrorStore.getState().getError('session-1')).toBeNull()
461457

462458
unmount()
463459
})

frontend/src/hooks/useSSE.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string
373373
if (!client || !primaryDirectory) return
374374

375375
const interval = setInterval(() => {
376-
void fetchInitialData()
376+
void fetchInitialData().catch(() => undefined)
377377
}, STATUS_POLL_INTERVAL_MS)
378378

379379
return () => clearInterval(interval)
@@ -417,7 +417,7 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string
417417

418418
if (connected) {
419419
setError(null)
420-
fetchInitialData()
420+
void fetchInitialData().catch(() => undefined)
421421
syncCurrentSession()
422422
eventStreamSubscriptionRef.current?.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current)
423423
} else {

frontend/src/stores/sendErrorStore.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,14 @@ describe('useSendErrorStore', () => {
4141
})
4242
expect(useSendErrorStore.getState().queuedPrompts['session-1']).toBeUndefined()
4343
})
44+
45+
it('does not store an error when no queued prompt was tracked', () => {
46+
useSendErrorStore.getState().failQueuedPrompt({
47+
sessionID: 'session-1',
48+
title: 'Error',
49+
message: 'Failed',
50+
})
51+
52+
expect(useSendErrorStore.getState().getError('session-1')).toBeNull()
53+
})
4454
})

frontend/src/stores/sendErrorStore.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,13 @@ export const useSendErrorStore = create<SendErrorStore>((set, get) => ({
4242
failQueuedPrompt: (err: Omit<SendError, 'failedPrompt'>) => {
4343
set((state) => {
4444
const failedPrompt = state.queuedPrompts[err.sessionID]
45+
if (!failedPrompt) return state
4546
const queuedPrompts = { ...state.queuedPrompts }
4647
delete queuedPrompts[err.sessionID]
4748
return {
4849
errors: {
4950
...state.errors,
50-
[err.sessionID]: failedPrompt ? { ...err, failedPrompt } : err,
51+
[err.sessionID]: { ...err, failedPrompt },
5152
},
5253
queuedPrompts,
5354
}

0 commit comments

Comments
 (0)