From 5b47f9ee1330b37d8595cbc3c2b9cd0bee3a6ded Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:01:36 -0700 Subject: [PATCH] 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 Co-authored-by: Claude Opus 4.8 --- app/session/[id].tsx | 56 ++++++++++++++++++++++++++++----------- src/lib/i18n/en.json | 6 ++++- src/lib/i18n/zh-Hans.json | 6 ++++- src/lib/speech.ts | 10 ++++++- src/stores/sessions.ts | 19 ++++--------- 5 files changed, 64 insertions(+), 33 deletions(-) diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 553c3e9..a538595 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -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 const slashActive = input.startsWith("/") && !input.includes(" ") const slashQuery = slashActive ? input.slice(1) : "" @@ -335,12 +343,21 @@ export default function SessionScreen() { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], allowsMultipleSelection: true, + selectionLimit: 10, quality: 1, // full quality - we compress in manipulator }) if (result.canceled) return - const items = await Promise.all(result.assets.map((a) => toJpeg(a.uri, a.width, a.height))) - setAttachments((prev) => [...prev, ...items]) - }, []) + const settled = await Promise.allSettled(result.assets.map((a) => toJpeg(a.uri, a.width, a.height))) + 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 perm = await ImagePicker.requestCameraPermissionsAsync() @@ -351,8 +368,13 @@ export default function SessionScreen() { const result = await ImagePicker.launchCameraAsync({ quality: 1 }) if (result.canceled) return const a = result.assets[0] - const item = await toJpeg(a.uri, a.width, a.height) - setAttachments((prev) => [...prev, item]) + try { + 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]) const pasteFromClipboard = useCallback(async () => { @@ -362,16 +384,8 @@ export default function SessionScreen() { const img = await Clipboard.getImageAsync({ format: "png" }) if (img?.data) { const uri = img.data.startsWith("data:") ? img.data : `data:image/png;base64,${img.data}` - setAttachments((prev) => [ - ...prev, - { - uri, - mime: "image/png", - filename: "clipboard.png", - width: img.size.width, - height: img.size.height, - }, - ]) + const item = await toJpeg(uri, img.size.width, img.size.height) + setAttachments((prev) => [...prev, item]) return } } @@ -617,7 +631,17 @@ export default function SessionScreen() { {revertMessageID && ( {t("session.banners.reverted")} - unrevertSession()} hitSlop={8}> + { + 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} + > {t("session.banners.undo")} diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 8981093..a6e951f 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -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": { diff --git a/src/lib/i18n/zh-Hans.json b/src/lib/i18n/zh-Hans.json index 447aa4e..f7a36a9 100644 --- a/src/lib/i18n/zh-Hans.json +++ b/src/lib/i18n/zh-Hans.json @@ -118,7 +118,11 @@ "replyFailedTitle": "回复失败", "replyFailedMessage": "无法发送您的回复。请重试。", "rejectFailedTitle": "拒绝失败", - "rejectFailedMessage": "无法发送您的回复。请重试。" + "rejectFailedMessage": "无法发送您的回复。请重试。", + "imageFailedTitle": "图片未添加", + "imageFailedMessage": "一张或多张图片无法处理。请尝试其他照片。", + "speechErrorTitle": "语音输入失败", + "speechErrorMessage": "无法使用语音输入。请检查麦克风权限后重试。" } }, "connection": { diff --git a/src/lib/speech.ts b/src/lib/speech.ts index d73519b..597fccf 100644 --- a/src/lib/speech.ts +++ b/src/lib/speech.ts @@ -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 } } diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index 196c589..db22834 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -306,20 +306,10 @@ export const useSessions = create((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((set, get) => ({ sending: { ...state.sending, [session.id]: false }, })) if (stillCurrent) get().refreshMessages() + throw err } },