Skip to content

Commit 3d65020

Browse files
feat: replace event-source-polyfill with native SSE for PromptInput
1 parent fb63ae3 commit 3d65020

4 files changed

Lines changed: 152 additions & 41 deletions

File tree

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({
2323
useVariants: vi.fn(),
2424
useSessionAgent: vi.fn(),
2525
useAgents: vi.fn(),
26+
useSendPromptMutate: vi.fn(),
2627
useUserBash: vi.fn(),
2728
useSessionAgentStore: vi.fn(),
2829
useSettings: vi.fn(),
@@ -38,7 +39,7 @@ vi.mock('@/hooks/useMobile', () => ({
3839
}))
3940

4041
vi.mock('@/hooks/useOpenCode', () => ({
41-
useSendPrompt: () => ({ mutate: vi.fn() }),
42+
useSendPrompt: () => ({ mutate: mocks.useSendPromptMutate }),
4243
useAbortSession: () => ({ mutate: vi.fn() }),
4344
useSendShell: () => ({ mutate: vi.fn() }),
4445
useOpenCodeClient: () => ({}),
@@ -153,6 +154,9 @@ describe('PromptInput STT Gesture Tests', () => {
153154
mockReset.mockReturnValue(undefined)
154155
mockClear.mockReturnValue(undefined)
155156
mockSetAgent.mockClear()
157+
mocks.useSendPromptMutate.mockImplementation((_variables, options) => {
158+
options?.onSuccess?.()
159+
})
156160

157161
mocks.useMobile.mockReturnValue(true)
158162
mocks.useSTT.mockReturnValue({
@@ -189,8 +193,8 @@ describe('PromptInput STT Gesture Tests', () => {
189193
})
190194
mocks.useSessionAgent.mockReturnValue({ agent: 'default' })
191195
mocks.useAgents.mockReturnValue({ data: [] })
192-
mocks.useUserBash.mockReturnValue({ addUserBashCommand: vi.fn() })
193-
mocks.useSessionAgentStore.mockReturnValue({ setAgent: mockSetAgent })
196+
mocks.useUserBash.mockImplementation((selector) => selector({ addUserBashCommand: vi.fn() }))
197+
mocks.useSessionAgentStore.mockImplementation((selector) => selector({ setAgent: mockSetAgent }))
194198
useUIState.getState().clearPendingPromptCommand()
195199
useUIState.getState().clearPendingPromptFile()
196200
})
@@ -241,6 +245,29 @@ describe('PromptInput STT Gesture Tests', () => {
241245
}
242246

243247
describe('quick tap behavior', () => {
248+
it('keeps submitted text until send succeeds', async () => {
249+
mocks.useSendPromptMutate.mockImplementation(() => undefined)
250+
renderComponent()
251+
252+
const input = screen.getByPlaceholderText('Send a message...')
253+
fireEvent.change(input, { target: { value: 'retry me' } })
254+
fireEvent.click(screen.getByTitle('Send'))
255+
256+
expect(input).toHaveValue('retry me')
257+
})
258+
259+
it('clears submitted text after send success', async () => {
260+
renderComponent()
261+
262+
const input = screen.getByPlaceholderText('Send a message...')
263+
fireEvent.change(input, { target: { value: 'sent message' } })
264+
fireEvent.click(screen.getByTitle('Send'))
265+
266+
await waitFor(() => {
267+
expect(input).toHaveValue('')
268+
})
269+
})
270+
244271
it('inserts a command selected from the mobile drawer', async () => {
245272
renderComponent()
246273

frontend/src/components/message/PromptInput.tsx

Lines changed: 76 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
117117
const ignoreVoiceClickUntilRef = useRef(0)
118118
const voiceStartRequestRef = useRef(0)
119119
const handleSubmitRef = useRef<() => void>(() => {})
120+
const promptRef = useRef(prompt)
121+
const attachedFilesRef = useRef(attachedFiles)
122+
const imageAttachmentsRef = useRef(imageAttachments)
120123
const pendingPromptCommand = useUIState((state) => state.pendingPromptCommand)
121124
const pendingPromptFile = useUIState((state) => state.pendingPromptFile)
122125
const clearPendingPromptCommand = useUIState((state) => state.clearPendingPromptCommand)
@@ -148,6 +151,29 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
148151
setIsVoiceAutoSendPending(false)
149152
setIsVoiceAutoSendWaitingForTranscript(false)
150153
}, [])
154+
155+
useEffect(() => {
156+
promptRef.current = prompt
157+
attachedFilesRef.current = attachedFiles
158+
imageAttachmentsRef.current = imageAttachments
159+
}, [attachedFiles, imageAttachments, prompt])
160+
161+
const clearSubmittedPrompt = useCallback((submittedPrompt: string, submittedAttachedFiles: Map<string, FileAttachmentInfo>, submittedImageAttachments: ImageAttachment[]) => {
162+
if (
163+
promptRef.current !== submittedPrompt ||
164+
attachedFilesRef.current !== submittedAttachedFiles ||
165+
imageAttachmentsRef.current !== submittedImageAttachments
166+
) {
167+
return
168+
}
169+
170+
setPrompt('')
171+
setAttachedFiles(new Map())
172+
revokeBlobUrls(submittedImageAttachments)
173+
setImageAttachments([])
174+
setSelectedAgent(null)
175+
clearSTT()
176+
}, [clearSTT])
151177

152178
useImperativeHandle(ref, () => ({
153179
setPromptValue: (value: string) => {
@@ -174,6 +200,7 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
174200
}), [imageAttachments, clearSTT, isRecording, abortRecording, resetVoiceGestureState])
175201
const sendPrompt = useSendPrompt(opcodeUrl, directory)
176202
const sendShell = useSendShell(opcodeUrl, directory)
203+
const isPromptSubmitPending = sendPrompt.isPending || sendShell.isPending
177204
const abortSession = useAbortSession(opcodeUrl, directory, sessionID)
178205
const { filterCommands } = useCommands(opcodeUrl)
179206
const { executeCommand } = useCommandHandler({
@@ -225,6 +252,7 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
225252

226253
const handleSubmit = () => {
227254
if (disabled) return
255+
if (isPromptSubmitPending) return
228256
if (!prompt.trim() && imageAttachments.length === 0) return
229257

230258
pendingVoiceAutoSubmitRef.current = false
@@ -234,39 +262,49 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
234262
onScrollToBottom()
235263
const parts = parsePromptToParts(prompt, attachedFiles, imageAttachments)
236264
const agentUsed = selectedAgent || currentMode
237-
sendPrompt.mutate({
238-
sessionID,
239-
parts,
240-
model: currentModel,
241-
agent: agentUsed,
242-
variant: currentVariant,
243-
queued: true
244-
})
265+
const submittedPrompt = prompt
266+
const submittedAttachedFiles = attachedFiles
267+
const submittedImageAttachments = imageAttachments
268+
sendPrompt.mutate(
269+
{
270+
sessionID,
271+
parts,
272+
model: currentModel,
273+
agent: agentUsed,
274+
variant: currentVariant,
275+
queued: true
276+
},
277+
{
278+
onSuccess: () => clearSubmittedPrompt(submittedPrompt, submittedAttachedFiles, submittedImageAttachments)
279+
}
280+
)
245281
setStoredAgent(sessionID, agentUsed)
246282
if (model) {
247283
setStoredModel({ providerID: model.providerID, modelID: model.modelID })
248284
}
249-
setPrompt('')
250-
setAttachedFiles(new Map())
251-
revokeBlobUrls(imageAttachments)
252-
setImageAttachments([])
253-
setSelectedAgent(null)
254-
clearSTT()
255285
return
256286
}
257287

258288
if (isBashMode) {
259289
const command = prompt.startsWith('!') ? prompt.slice(1) : prompt
260290
addUserBashCommand(command)
261-
sendShell.mutate({
262-
sessionID,
263-
command,
264-
agent: currentMode
265-
})
291+
const submittedPrompt = prompt
292+
sendShell.mutate(
293+
{
294+
sessionID,
295+
command,
296+
agent: currentMode
297+
},
298+
{
299+
onSuccess: () => {
300+
if (promptRef.current !== submittedPrompt) return
301+
setPrompt('')
302+
setIsBashMode(false)
303+
clearSTT()
304+
}
305+
}
306+
)
266307
setStoredAgent(sessionID, currentMode)
267-
setPrompt('')
268-
setIsBashMode(false)
269-
clearSTT()
270308
return
271309
}
272310

@@ -287,27 +325,29 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
287325

288326
const parts = parsePromptToParts(prompt, attachedFiles, imageAttachments)
289327
const agentUsed = selectedAgent || currentMode
328+
const submittedPrompt = prompt
329+
const submittedAttachedFiles = attachedFiles
330+
const submittedImageAttachments = imageAttachments
290331

291-
sendPrompt.mutate({
292-
sessionID,
293-
parts,
294-
model: currentModel,
295-
agent: agentUsed,
296-
variant: currentVariant
297-
})
332+
sendPrompt.mutate(
333+
{
334+
sessionID,
335+
parts,
336+
model: currentModel,
337+
agent: agentUsed,
338+
variant: currentVariant
339+
},
340+
{
341+
onSuccess: () => clearSubmittedPrompt(submittedPrompt, submittedAttachedFiles, submittedImageAttachments)
342+
}
343+
)
298344

299345
onScrollToBottom()
300346

301347
setStoredAgent(sessionID, agentUsed)
302348
if (model) {
303349
setStoredModel({ providerID: model.providerID, modelID: model.modelID })
304350
}
305-
setPrompt('')
306-
setAttachedFiles(new Map())
307-
revokeBlobUrls(imageAttachments)
308-
setImageAttachments([])
309-
setSelectedAgent(null)
310-
clearSTT()
311351
}
312352

313353
handleSubmitRef.current = handleSubmit
@@ -1317,7 +1357,7 @@ return (
13171357
<button
13181358
data-submit-prompt
13191359
onClick={hasPendingPermissionForSession ? () => setShowDialog(true) : handleSubmit}
1320-
disabled={hasPendingPermissionForSession ? false : ((!prompt.trim() && imageAttachments.length === 0) || disabled)}
1360+
disabled={hasPendingPermissionForSession ? false : ((!prompt.trim() && imageAttachments.length === 0) || disabled || isPromptSubmitPending)}
13211361
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] ${
13221362
hasPendingPermissionForSession
13231363
? '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: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,51 @@ describe('useSSE', () => {
277277
unmount()
278278
})
279279

280+
it('clears optimistic active status when session status reports idle', async () => {
281+
const queryClient = new QueryClient({
282+
defaultOptions: {
283+
queries: { retry: false },
284+
},
285+
})
286+
const wrapper = ({ children }: { children: ReactNode }) => (
287+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
288+
)
289+
290+
queryClient.setQueryData(
291+
['opencode', 'messages', 'http://localhost:5551', 'session-1', '/repo'],
292+
[],
293+
)
294+
useSessionStatus.getState().setOptimisticActive('session-1')
295+
296+
const { result, unmount } = renderHook(
297+
() => useSSE('http://localhost:5551', '/repo', 'session-1'),
298+
{ wrapper },
299+
)
300+
301+
await waitFor(() => expect(MockEventSource.instances).toHaveLength(1))
302+
303+
act(() => {
304+
MockEventSource.instances[0].emit('connected', { clientId: 'client-1' })
305+
})
306+
307+
await waitFor(() => expect(result.current.isConnected).toBe(true))
308+
useSessionStatus.getState().setOptimisticActive('session-1')
309+
310+
act(() => {
311+
MockEventSource.instances[0].emit('message', {
312+
type: 'session.status',
313+
properties: {
314+
sessionID: 'session-1',
315+
status: { type: 'idle' },
316+
},
317+
})
318+
})
319+
320+
expect(useSessionStatus.getState().getStatus('session-1')).toEqual({ type: 'idle' })
321+
322+
unmount()
323+
})
324+
280325
it('routes streamed part deltas to the event directory in multi-directory subscriptions', async () => {
281326
const origRAF = window.requestAnimationFrame
282327
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
@@ -450,4 +495,3 @@ function assistantMessage(sessionID: string, messageID: string): MessageWithPart
450495
parts: [],
451496
}
452497
}
453-

frontend/src/hooks/useSSE.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string
155155

156156
const { info } = event.properties
157157
const sessionID = info.sessionID
158-
158+
159159
const queryKey = messagesQueryKey(opcodeUrl, sessionID, cacheDirectory)
160160
const currentData = queryClient.getQueryData<MessageWithParts[]>(queryKey)
161161
if (!currentData) {

0 commit comments

Comments
 (0)