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:
@@ -145,6 +145,14 @@ export default function SessionScreen() {
|
|||||||
}, []),
|
}, []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Surface speech recognition failures (e.g. mic permission denied). Keyed
|
||||||
|
// on the error value itself so it only fires once per distinct error, not
|
||||||
|
// on every re-render while it remains set.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!speech.error) return
|
||||||
|
Alert.alert(t("session.alerts.speechErrorTitle"), t("session.alerts.speechErrorMessage"))
|
||||||
|
}, [speech.error, t])
|
||||||
|
|
||||||
// Slash command state
|
// Slash command state
|
||||||
const slashActive = input.startsWith("/") && !input.includes(" ")
|
const slashActive = input.startsWith("/") && !input.includes(" ")
|
||||||
const slashQuery = slashActive ? input.slice(1) : ""
|
const slashQuery = slashActive ? input.slice(1) : ""
|
||||||
@@ -335,12 +343,21 @@ export default function SessionScreen() {
|
|||||||
const result = await ImagePicker.launchImageLibraryAsync({
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
mediaTypes: ["images"],
|
mediaTypes: ["images"],
|
||||||
allowsMultipleSelection: true,
|
allowsMultipleSelection: true,
|
||||||
|
selectionLimit: 10,
|
||||||
quality: 1, // full quality - we compress in manipulator
|
quality: 1, // full quality - we compress in manipulator
|
||||||
})
|
})
|
||||||
if (result.canceled) return
|
if (result.canceled) return
|
||||||
const items = await Promise.all(result.assets.map((a) => toJpeg(a.uri, a.width, a.height)))
|
const settled = await Promise.allSettled(result.assets.map((a) => toJpeg(a.uri, a.width, a.height)))
|
||||||
setAttachments((prev) => [...prev, ...items])
|
const items = settled.filter((r) => r.status === "fulfilled").map((r) => r.value)
|
||||||
}, [])
|
if (items.length) setAttachments((prev) => [...prev, ...items])
|
||||||
|
if (settled.some((r) => r.status === "rejected")) {
|
||||||
|
console.error(
|
||||||
|
"Failed to process image(s):",
|
||||||
|
settled.filter((r) => r.status === "rejected").map((r) => r.reason),
|
||||||
|
)
|
||||||
|
Alert.alert(t("session.alerts.imageFailedTitle"), t("session.alerts.imageFailedMessage"))
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
const pickFromCamera = useCallback(async () => {
|
const pickFromCamera = useCallback(async () => {
|
||||||
const perm = await ImagePicker.requestCameraPermissionsAsync()
|
const perm = await ImagePicker.requestCameraPermissionsAsync()
|
||||||
@@ -351,8 +368,13 @@ export default function SessionScreen() {
|
|||||||
const result = await ImagePicker.launchCameraAsync({ quality: 1 })
|
const result = await ImagePicker.launchCameraAsync({ quality: 1 })
|
||||||
if (result.canceled) return
|
if (result.canceled) return
|
||||||
const a = result.assets[0]
|
const a = result.assets[0]
|
||||||
const item = await toJpeg(a.uri, a.width, a.height)
|
try {
|
||||||
setAttachments((prev) => [...prev, item])
|
const item = await toJpeg(a.uri, a.width, a.height)
|
||||||
|
setAttachments((prev) => [...prev, item])
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to process photo:", err)
|
||||||
|
Alert.alert(t("session.alerts.imageFailedTitle"), t("session.alerts.imageFailedMessage"))
|
||||||
|
}
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
const pasteFromClipboard = useCallback(async () => {
|
const pasteFromClipboard = useCallback(async () => {
|
||||||
@@ -362,16 +384,8 @@ export default function SessionScreen() {
|
|||||||
const img = await Clipboard.getImageAsync({ format: "png" })
|
const img = await Clipboard.getImageAsync({ format: "png" })
|
||||||
if (img?.data) {
|
if (img?.data) {
|
||||||
const uri = img.data.startsWith("data:") ? img.data : `data:image/png;base64,${img.data}`
|
const uri = img.data.startsWith("data:") ? img.data : `data:image/png;base64,${img.data}`
|
||||||
setAttachments((prev) => [
|
const item = await toJpeg(uri, img.size.width, img.size.height)
|
||||||
...prev,
|
setAttachments((prev) => [...prev, item])
|
||||||
{
|
|
||||||
uri,
|
|
||||||
mime: "image/png",
|
|
||||||
filename: "clipboard.png",
|
|
||||||
width: img.size.width,
|
|
||||||
height: img.size.height,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -617,7 +631,17 @@ export default function SessionScreen() {
|
|||||||
{revertMessageID && (
|
{revertMessageID && (
|
||||||
<View style={[s.banner, s.bannerRevert]}>
|
<View style={[s.banner, s.bannerRevert]}>
|
||||||
<Text style={s.bannerText}>{t("session.banners.reverted")}</Text>
|
<Text style={s.bannerText}>{t("session.banners.reverted")}</Text>
|
||||||
<TouchableOpacity onPress={() => unrevertSession()} hitSlop={8}>
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
unrevertSession()
|
||||||
|
// The composer was prefilled with the reverted message's text/
|
||||||
|
// attachments (see applyRevertResult) — clear it so Undo doesn't
|
||||||
|
// leave a stale draft that could be sent as a duplicate.
|
||||||
|
setInput("")
|
||||||
|
setAttachments([])
|
||||||
|
}}
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
<Text style={s.bannerAction}>{t("session.banners.undo")}</Text>
|
<Text style={s.bannerAction}>{t("session.banners.undo")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -118,7 +118,11 @@
|
|||||||
"replyFailedTitle": "Reply Failed",
|
"replyFailedTitle": "Reply Failed",
|
||||||
"replyFailedMessage": "Could not send your response. Please try again.",
|
"replyFailedMessage": "Could not send your response. Please try again.",
|
||||||
"rejectFailedTitle": "Reject Failed",
|
"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": {
|
"connection": {
|
||||||
|
|||||||
@@ -118,7 +118,11 @@
|
|||||||
"replyFailedTitle": "回复失败",
|
"replyFailedTitle": "回复失败",
|
||||||
"replyFailedMessage": "无法发送您的回复。请重试。",
|
"replyFailedMessage": "无法发送您的回复。请重试。",
|
||||||
"rejectFailedTitle": "拒绝失败",
|
"rejectFailedTitle": "拒绝失败",
|
||||||
"rejectFailedMessage": "无法发送您的回复。请重试。"
|
"rejectFailedMessage": "无法发送您的回复。请重试。",
|
||||||
|
"imageFailedTitle": "图片未添加",
|
||||||
|
"imageFailedMessage": "一张或多张图片无法处理。请尝试其他照片。",
|
||||||
|
"speechErrorTitle": "语音输入失败",
|
||||||
|
"speechErrorMessage": "无法使用语音输入。请检查麦克风权限后重试。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useRef } from "react"
|
import { useState, useCallback, useRef, useEffect } from "react"
|
||||||
import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent } from "expo-speech-recognition"
|
import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent } from "expo-speech-recognition"
|
||||||
|
|
||||||
interface SpeechState {
|
interface SpeechState {
|
||||||
@@ -76,5 +76,13 @@ export function useSpeech(onResult: (text: string) => void): SpeechState & Speec
|
|||||||
setTranscript("")
|
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 }
|
return { listening, transcript, error, start, stop, cancel }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,20 +306,10 @@ export const useSessions = create<SessionsState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire and forget - SSE events will update messages/parts/status in real-time
|
// Await submission (POST to /prompt_async resolves fast, well before the
|
||||||
client.session.prompt(session.id, { parts: promptParts, model, agent, variant }).catch((err) => {
|
// streamed response) so a failure here can propagate to the caller — SSE
|
||||||
console.error("Failed to send message:", err)
|
// events still update messages/parts/status in real-time on success.
|
||||||
// The user may have switched sessions while this send was in flight. Clear
|
await client.session.prompt(session.id, { parts: promptParts, model, agent, variant })
|
||||||
// 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()
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[sendMessage] error:", err)
|
console.error("[sendMessage] error:", err)
|
||||||
const stillCurrent = get().currentSession?.id === session.id
|
const stillCurrent = get().currentSession?.id === session.id
|
||||||
@@ -328,6 +318,7 @@ export const useSessions = create<SessionsState>((set, get) => ({
|
|||||||
sending: { ...state.sending, [session.id]: false },
|
sending: { ...state.sending, [session.id]: false },
|
||||||
}))
|
}))
|
||||||
if (stillCurrent) get().refreshMessages()
|
if (stillCurrent) get().refreshMessages()
|
||||||
|
throw err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user