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
}
},