Skip to content

Commit fec0a21

Browse files
waleedlatif1Sg312
authored andcommitted
fix(home): voice input text persistence bugs (#3737)
* fix(home): voice input text persistence bugs * fix(home): gate setIsListening on startRecognition success * fix(home): handle startRecognition failure in restartRecognition * fix(home): reset speech prefix on submit while mic is active
1 parent 3030abe commit fec0a21

File tree

1 file changed

+112
-71
lines changed
  • apps/sim/app/workspace/[workspaceId]/home/components/user-input

1 file changed

+112
-71
lines changed

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 112 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ const SEND_BUTTON_ACTIVE =
106106
const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
107107

108108
const MAX_CHAT_TEXTAREA_HEIGHT = 200
109+
const SPEECH_RECOGNITION_LANG = 'en-US'
109110

110111
const DROP_OVERLAY_ICONS = [
111112
PdfIcon,
@@ -267,13 +268,18 @@ export function UserInput({
267268
const [isListening, setIsListening] = useState(false)
268269
const recognitionRef = useRef<SpeechRecognitionInstance | null>(null)
269270
const prefixRef = useRef('')
271+
const valueRef = useRef(value)
270272

271273
useEffect(() => {
272274
return () => {
273275
recognitionRef.current?.abort()
274276
}
275277
}, [])
276278

279+
useEffect(() => {
280+
valueRef.current = value
281+
}, [value])
282+
277283
const textareaRef = mentionMenu.textareaRef
278284
const wasSendingRef = useRef(false)
279285
const atInsertPosRef = useRef<number | null>(null)
@@ -390,6 +396,84 @@ export function UserInput({
390396
[textareaRef]
391397
)
392398

399+
const startRecognition = useCallback((): boolean => {
400+
const w = window as WindowWithSpeech
401+
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
402+
if (!SpeechRecognitionAPI) return false
403+
404+
const recognition = new SpeechRecognitionAPI()
405+
recognition.continuous = true
406+
recognition.interimResults = true
407+
recognition.lang = SPEECH_RECOGNITION_LANG
408+
409+
recognition.onresult = (event: SpeechRecognitionEvent) => {
410+
let transcript = ''
411+
for (let i = 0; i < event.results.length; i++) {
412+
transcript += event.results[i][0].transcript
413+
}
414+
const prefix = prefixRef.current
415+
const newVal = prefix ? `${prefix} ${transcript}` : transcript
416+
setValue(newVal)
417+
valueRef.current = newVal
418+
}
419+
420+
recognition.onend = () => {
421+
if (recognitionRef.current === recognition) {
422+
prefixRef.current = valueRef.current
423+
try {
424+
recognition.start()
425+
} catch {
426+
recognitionRef.current = null
427+
setIsListening(false)
428+
}
429+
}
430+
}
431+
432+
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
433+
if (recognitionRef.current !== recognition) return
434+
if (e.error === 'aborted' || e.error === 'not-allowed') {
435+
recognitionRef.current = null
436+
setIsListening(false)
437+
}
438+
}
439+
440+
recognitionRef.current = recognition
441+
try {
442+
recognition.start()
443+
return true
444+
} catch {
445+
recognitionRef.current = null
446+
return false
447+
}
448+
}, [])
449+
450+
const restartRecognition = useCallback(
451+
(newPrefix: string) => {
452+
if (!recognitionRef.current) return
453+
prefixRef.current = newPrefix
454+
recognitionRef.current.abort()
455+
recognitionRef.current = null
456+
if (!startRecognition()) {
457+
setIsListening(false)
458+
}
459+
},
460+
[startRecognition]
461+
)
462+
463+
const toggleListening = useCallback(() => {
464+
if (isListening) {
465+
recognitionRef.current?.stop()
466+
recognitionRef.current = null
467+
setIsListening(false)
468+
return
469+
}
470+
471+
prefixRef.current = value
472+
if (startRecognition()) {
473+
setIsListening(true)
474+
}
475+
}, [isListening, value, startRecognition])
476+
393477
const handleSubmit = useCallback(() => {
394478
const fileAttachmentsForApi: FileAttachmentForApi[] = files.attachedFiles
395479
.filter((f) => !f.uploading && f.key)
@@ -407,13 +491,14 @@ export function UserInput({
407491
contextManagement.selectedContexts.length > 0 ? contextManagement.selectedContexts : undefined
408492
)
409493
setValue('')
494+
restartRecognition('')
410495
files.clearAttachedFiles()
411496
contextManagement.clearContexts()
412497

413498
if (textareaRef.current) {
414499
textareaRef.current.style.height = 'auto'
415500
}
416-
}, [onSubmit, files, value, contextManagement, textareaRef])
501+
}, [onSubmit, files, value, contextManagement, textareaRef, restartRecognition])
417502

418503
const handleKeyDown = useCallback(
419504
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -488,27 +573,33 @@ export function UserInput({
488573
[handleSubmit, mentionTokensWithContext, value, textareaRef]
489574
)
490575

491-
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
492-
const newValue = e.target.value
493-
const caret = e.target.selectionStart ?? newValue.length
494-
495-
if (
496-
caret > 0 &&
497-
newValue.charAt(caret - 1) === '@' &&
498-
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
499-
) {
500-
const before = newValue.slice(0, caret - 1)
501-
const after = newValue.slice(caret)
502-
setValue(`${before}${after}`)
503-
atInsertPosRef.current = caret - 1
504-
setPlusMenuOpen(true)
505-
setPlusMenuSearch('')
506-
setPlusMenuActiveIndex(0)
507-
return
508-
}
576+
const handleInputChange = useCallback(
577+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
578+
const newValue = e.target.value
579+
const caret = e.target.selectionStart ?? newValue.length
580+
581+
if (
582+
caret > 0 &&
583+
newValue.charAt(caret - 1) === '@' &&
584+
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
585+
) {
586+
const before = newValue.slice(0, caret - 1)
587+
const after = newValue.slice(caret)
588+
const adjusted = `${before}${after}`
589+
setValue(adjusted)
590+
atInsertPosRef.current = caret - 1
591+
setPlusMenuOpen(true)
592+
setPlusMenuSearch('')
593+
setPlusMenuActiveIndex(0)
594+
restartRecognition(adjusted)
595+
return
596+
}
509597

510-
setValue(newValue)
511-
}, [])
598+
setValue(newValue)
599+
restartRecognition(newValue)
600+
},
601+
[restartRecognition]
602+
)
512603

513604
const handleSelectAdjust = useCallback(() => {
514605
const textarea = textareaRef.current
@@ -536,56 +627,6 @@ export function UserInput({
536627
[isInitialView]
537628
)
538629

539-
const toggleListening = useCallback(() => {
540-
if (isListening) {
541-
recognitionRef.current?.stop()
542-
recognitionRef.current = null
543-
setIsListening(false)
544-
return
545-
}
546-
547-
const w = window as WindowWithSpeech
548-
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
549-
if (!SpeechRecognitionAPI) return
550-
551-
prefixRef.current = value
552-
553-
const recognition = new SpeechRecognitionAPI()
554-
recognition.continuous = true
555-
recognition.interimResults = true
556-
recognition.lang = 'en-US'
557-
558-
recognition.onresult = (event: SpeechRecognitionEvent) => {
559-
let transcript = ''
560-
for (let i = 0; i < event.results.length; i++) {
561-
transcript += event.results[i][0].transcript
562-
}
563-
const prefix = prefixRef.current
564-
setValue(prefix ? `${prefix} ${transcript}` : transcript)
565-
}
566-
567-
recognition.onend = () => {
568-
if (recognitionRef.current === recognition) {
569-
try {
570-
recognition.start()
571-
} catch {
572-
recognitionRef.current = null
573-
setIsListening(false)
574-
}
575-
}
576-
}
577-
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
578-
if (e.error === 'aborted' || e.error === 'not-allowed') {
579-
recognitionRef.current = null
580-
setIsListening(false)
581-
}
582-
}
583-
584-
recognitionRef.current = recognition
585-
recognition.start()
586-
setIsListening(true)
587-
}, [isListening, value])
588-
589630
const renderOverlayContent = useCallback(() => {
590631
const contexts = contextManagement.selectedContexts
591632

0 commit comments

Comments
 (0)