Skip to content
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
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
  • Loading branch information
waleedlatif1 authored Mar 24, 2026
commit 83eb3ed2111c5a6a5fd3f5ffcf40ece3555f9516
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const SEND_BUTTON_ACTIVE =
const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'

const MAX_CHAT_TEXTAREA_HEIGHT = 200
const SPEECH_RECOGNITION_LANG = 'en-US'
Comment thread
waleedlatif1 marked this conversation as resolved.

const DROP_OVERLAY_ICONS = [
PdfIcon,
Expand Down Expand Up @@ -267,13 +268,18 @@ export function UserInput({
const [isListening, setIsListening] = useState(false)
const recognitionRef = useRef<SpeechRecognitionInstance | null>(null)
const prefixRef = useRef('')
const valueRef = useRef(value)

useEffect(() => {
return () => {
recognitionRef.current?.abort()
}
}, [])

useEffect(() => {
valueRef.current = value
}, [value])

const textareaRef = mentionMenu.textareaRef
const wasSendingRef = useRef(false)
const atInsertPosRef = useRef<number | null>(null)
Expand Down Expand Up @@ -390,6 +396,84 @@ export function UserInput({
[textareaRef]
)

const startRecognition = useCallback((): boolean => {
const w = window as WindowWithSpeech
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
if (!SpeechRecognitionAPI) return false

const recognition = new SpeechRecognitionAPI()
recognition.continuous = true
recognition.interimResults = true
recognition.lang = SPEECH_RECOGNITION_LANG

recognition.onresult = (event: SpeechRecognitionEvent) => {
let transcript = ''
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript
}
const prefix = prefixRef.current
const newVal = prefix ? `${prefix} ${transcript}` : transcript
setValue(newVal)
valueRef.current = newVal
}

recognition.onend = () => {
if (recognitionRef.current === recognition) {
prefixRef.current = valueRef.current
try {
recognition.start()
} catch {
recognitionRef.current = null
setIsListening(false)
}
}
}

recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
if (recognitionRef.current !== recognition) return
if (e.error === 'aborted' || e.error === 'not-allowed') {
recognitionRef.current = null
setIsListening(false)
}
}

recognitionRef.current = recognition
try {
recognition.start()
return true
} catch {
recognitionRef.current = null
return false
}
}, [])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startRecognition captures stale setValue closure reference

Low Severity

The startRecognition callback has an empty dependency array [] but closes over setValue and setIsListening. Since setValue comes from a custom hook (not useState directly), it may not have a stable identity. If setValue changes identity across renders, the onresult handler inside startRecognition will call a stale setValue reference, potentially causing speech recognition results to be lost or applied incorrectly. The valueRef sync mitigates the read side, but stale setValue writes could still be a problem.

Fix in Cursor Fix in Web


const restartRecognition = useCallback(
(newPrefix: string) => {
if (!recognitionRef.current) return
prefixRef.current = newPrefix
recognitionRef.current.abort()
recognitionRef.current = null
if (!startRecognition()) {
setIsListening(false)
}
},
[startRecognition]
)

const toggleListening = useCallback(() => {
if (isListening) {
recognitionRef.current?.stop()
recognitionRef.current = null
setIsListening(false)
return
}

prefixRef.current = value
if (startRecognition()) {
setIsListening(true)
}
}, [isListening, value, startRecognition])

const handleSubmit = useCallback(() => {
const fileAttachmentsForApi: FileAttachmentForApi[] = files.attachedFiles
.filter((f) => !f.uploading && f.key)
Expand All @@ -407,13 +491,14 @@ export function UserInput({
contextManagement.selectedContexts.length > 0 ? contextManagement.selectedContexts : undefined
)
setValue('')
restartRecognition('')
files.clearAttachedFiles()
contextManagement.clearContexts()

if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}, [onSubmit, files, value, contextManagement, textareaRef])
}, [onSubmit, files, value, contextManagement, textareaRef, restartRecognition])

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

const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const caret = e.target.selectionStart ?? newValue.length

if (
caret > 0 &&
newValue.charAt(caret - 1) === '@' &&
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
) {
const before = newValue.slice(0, caret - 1)
const after = newValue.slice(caret)
setValue(`${before}${after}`)
atInsertPosRef.current = caret - 1
setPlusMenuOpen(true)
setPlusMenuSearch('')
setPlusMenuActiveIndex(0)
return
}
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const caret = e.target.selectionStart ?? newValue.length

if (
caret > 0 &&
newValue.charAt(caret - 1) === '@' &&
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
) {
const before = newValue.slice(0, caret - 1)
const after = newValue.slice(caret)
const adjusted = `${before}${after}`
setValue(adjusted)
atInsertPosRef.current = caret - 1
setPlusMenuOpen(true)
setPlusMenuSearch('')
setPlusMenuActiveIndex(0)
restartRecognition(adjusted)
return
}

setValue(newValue)
}, [])
setValue(newValue)
restartRecognition(newValue)
},
[restartRecognition]
)

const handleSelectAdjust = useCallback(() => {
const textarea = textareaRef.current
Expand Down Expand Up @@ -536,56 +627,6 @@ export function UserInput({
[isInitialView]
)

const toggleListening = useCallback(() => {
if (isListening) {
recognitionRef.current?.stop()
recognitionRef.current = null
setIsListening(false)
return
}

const w = window as WindowWithSpeech
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
if (!SpeechRecognitionAPI) return

prefixRef.current = value

const recognition = new SpeechRecognitionAPI()
recognition.continuous = true
recognition.interimResults = true
recognition.lang = 'en-US'

recognition.onresult = (event: SpeechRecognitionEvent) => {
let transcript = ''
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript
}
const prefix = prefixRef.current
setValue(prefix ? `${prefix} ${transcript}` : transcript)
}

recognition.onend = () => {
if (recognitionRef.current === recognition) {
try {
recognition.start()
} catch {
recognitionRef.current = null
setIsListening(false)
}
}
}
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
if (e.error === 'aborted' || e.error === 'not-allowed') {
recognitionRef.current = null
setIsListening(false)
}
}

recognitionRef.current = recognition
recognition.start()
setIsListening(true)
}, [isListening, value])

const renderOverlayContent = useCallback(() => {
const contexts = contextManagement.selectedContexts

Expand Down
Loading