fix: propagate send failures, guard image conversion, cleanup speech mic (#130)

Seven correctness bugs in the session composer:

- sessions.ts sendMessage: await the prompt submission and rethrow on
  failure instead of a fire-and-forget .catch(), so handleSend's existing
  restore-draft-and-alert catch actually runs.
- pasteFromClipboard: route pasted images through toJpeg() so they get
  the same resize/compress treatment as picked/captured photos.
- pickFromLibrary/pickFromCamera: wrap toJpeg() in try/catch (and switch
  to Promise.allSettled for the multi-select batch) so one bad asset
  doesn't silently drop the whole batch; surface a new imageFailed alert.
- pickFromLibrary: cap selection at 10 images.
- useSpeech: abort the native recognition session on unmount so the mic
  doesn't stay hot after leaving the screen.
- Surface useSpeech's error via Alert, keyed on the error value so it
  fires once per distinct error.
- Undo on the revert banner now also clears the composer, since it was
  prefilled by the edit flow and could otherwise be sent as a duplicate.


Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6

Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Den
2026-07-18 15:01:36 -07:00
committed by GitHub
parent 9a561b0c74
commit 5b47f9ee13
5 changed files with 64 additions and 33 deletions

View File

@@ -118,7 +118,11 @@
"replyFailedTitle": "Reply Failed",
"replyFailedMessage": "Could not send your response. Please try again.",
"rejectFailedTitle": "Reject Failed",
"rejectFailedMessage": "Could not send your response. Please try again."
"rejectFailedMessage": "Could not send your response. Please try again.",
"imageFailedTitle": "Image not attached",
"imageFailedMessage": "One or more images could not be processed. Please try a different photo.",
"speechErrorTitle": "Voice input failed",
"speechErrorMessage": "Could not use voice input. Check your microphone permission and try again."
}
},
"connection": {

View File

@@ -118,7 +118,11 @@
"replyFailedTitle": "回复失败",
"replyFailedMessage": "无法发送您的回复。请重试。",
"rejectFailedTitle": "拒绝失败",
"rejectFailedMessage": "无法发送您的回复。请重试。"
"rejectFailedMessage": "无法发送您的回复。请重试。",
"imageFailedTitle": "图片未添加",
"imageFailedMessage": "一张或多张图片无法处理。请尝试其他照片。",
"speechErrorTitle": "语音输入失败",
"speechErrorMessage": "无法使用语音输入。请检查麦克风权限后重试。"
}
},
"connection": {

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from "react"
import { useState, useCallback, useRef, useEffect } from "react"
import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent } from "expo-speech-recognition"
interface SpeechState {
@@ -76,5 +76,13 @@ export function useSpeech(onResult: (text: string) => void): SpeechState & Speec
setTranscript("")
}, [])
// Stop the native recognition session when the screen unmounts — otherwise
// the mic stays hot in the background. abort() is a no-op when not listening.
useEffect(() => {
return () => {
ExpoSpeechRecognitionModule.abort()
}
}, [])
return { listening, transcript, error, start, stop, cancel }
}

View File

@@ -306,20 +306,10 @@ export const useSessions = create<SessionsState>((set, get) => ({
}
}
// Fire and forget - SSE events will update messages/parts/status in real-time
client.session.prompt(session.id, { parts: promptParts, model, agent, variant }).catch((err) => {
console.error("Failed to send message:", err)
// The user may have switched sessions while this send was in flight. Clear
// the sending flag for the session we actually sent to (keyed by id, safe),
// but only surface the error / refresh messages if it's still on screen —
// otherwise we'd flash an error on, and refetch, the wrong session.
const stillCurrent = get().currentSession?.id === session.id
set((state) => ({
...(stillCurrent ? { error: String(err) } : {}),
sending: { ...state.sending, [session.id]: false },
}))
if (stillCurrent) get().refreshMessages()
})
// Await submission (POST to /prompt_async resolves fast, well before the
// streamed response) so a failure here can propagate to the caller — SSE
// events still update messages/parts/status in real-time on success.
await client.session.prompt(session.id, { parts: promptParts, model, agent, variant })
} catch (err) {
console.error("[sendMessage] error:", err)
const stillCurrent = get().currentSession?.id === session.id
@@ -328,6 +318,7 @@ export const useSessions = create<SessionsState>((set, get) => ({
sending: { ...state.sending, [session.id]: false },
}))
if (stillCurrent) get().refreshMessages()
throw err
}
},