Five correctness bugs from an adversarial review of the notification and permission/approval paths (each verified against the code): 1. Notifications never worked for most users (HIGH): OS permission was only requested when a user manually toggled a Settings switch off→on. Since categories default on, that path never fired, permission stayed 'undetermined', and send() silently no-op'd every notification. Now request it once on first live connection (in-context). (app/_layout.tsx) 2. Wrong-session data after back-navigation (HIGH): session screen reads a global store and its resync ran only on mount; the native stack keeps screens mounted underneath a pushed one, so returning to a session could show another session's messages and permission prompts — approving the wrong session's tool call. Re-select on focus via useFocusEffect. (app/session/[id].tsx) 3. 'Task completed' fired on aborted/errored runs (misleading, and a duplicate push alongside 'Session error'). Gate the notify by !aborted && !errored. (src/stores/events.ts) 4. Tapping a connection-drop notification (no sessionId) navigated to an empty '/session/' dead-end. Route to home instead. (app/_layout.tsx) 5. Double-tap on a single-select question sent two replies; the second hit an already-resolved request and popped a spurious 'Reply failed' alert. One-shot guard on reply/reject. (src/components/chat/QuestionPrompt.tsx) Verified but intentionally NOT changed: 'completed' notifications default off (a defensible anti-spam choice — the app still notifies when the agent needs input). typecheck clean, 187/187 tests. Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6 Co-authored-by: engineer <engineer@macbookpro.lan> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
211 lines
7.0 KiB
TypeScript
211 lines
7.0 KiB
TypeScript
import { useRef, useState } from "react"
|
|
import { View, Text, TextInput, TouchableOpacity, StyleSheet } from "react-native"
|
|
import { Ionicons } from "@expo/vector-icons"
|
|
import { useTranslation } from "react-i18next"
|
|
|
|
interface QuestionOption {
|
|
label: string
|
|
description: string
|
|
}
|
|
|
|
interface Question {
|
|
question: string
|
|
header: string
|
|
options: QuestionOption[]
|
|
multiple?: boolean
|
|
custom?: boolean
|
|
}
|
|
|
|
interface Props {
|
|
request: {
|
|
id: string
|
|
questions: Question[]
|
|
}
|
|
isDark: boolean
|
|
onReply: (answers: string[][]) => void
|
|
onReject: () => void
|
|
}
|
|
|
|
export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
|
|
const { t } = useTranslation()
|
|
const [answers, setAnswers] = useState<string[][]>(request.questions.map(() => []))
|
|
const [custom, setCustom] = useState("")
|
|
const [showCustom, setShowCustom] = useState(false)
|
|
const [current, setCurrent] = useState(0)
|
|
|
|
// A question is answered exactly once. Without this guard, a double-tap on a
|
|
// single-select option schedules two `onReply` timers; the second reply hits
|
|
// an already-resolved request server-side and surfaces a spurious
|
|
// "Reply failed" alert even though the answer went through.
|
|
const replied = useRef(false)
|
|
const reply = (a: string[][]) => {
|
|
if (replied.current) return
|
|
replied.current = true
|
|
onReply(a)
|
|
}
|
|
const reject = () => {
|
|
if (replied.current) return
|
|
replied.current = true
|
|
onReject()
|
|
}
|
|
|
|
const q = request.questions[current]
|
|
if (!q) return null
|
|
|
|
const toggleOption = (label: string) => {
|
|
setAnswers((prev) => {
|
|
const copy = [...prev]
|
|
const selected = copy[current] || []
|
|
if (q.multiple) {
|
|
copy[current] = selected.includes(label) ? selected.filter((a) => a !== label) : [...selected, label]
|
|
} else {
|
|
copy[current] = [label]
|
|
if (request.questions.length === 1) {
|
|
setTimeout(() => reply(copy), 100)
|
|
}
|
|
}
|
|
return copy
|
|
})
|
|
}
|
|
|
|
const submitCustom = () => {
|
|
if (!custom.trim()) return
|
|
const copy = [...answers]
|
|
copy[current] = [custom.trim()]
|
|
setAnswers(copy)
|
|
setCustom("")
|
|
setShowCustom(false)
|
|
if (request.questions.length === 1) {
|
|
reply(copy)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<View style={[s.card, isDark && s.cardDark]}>
|
|
<View style={s.header}>
|
|
<Ionicons name="chatbubble-ellipses-outline" size={18} color="#8b5cf6" />
|
|
<Text style={[s.title, isDark && s.textWhite]}>{q.header || t("chat.questionPrompt.headerFallback")}</Text>
|
|
</View>
|
|
<Text style={[s.question, isDark && s.textWhite]}>{q.question}</Text>
|
|
|
|
<View style={s.options}>
|
|
{q.options.map((opt) => {
|
|
const selected = (answers[current] || []).includes(opt.label)
|
|
return (
|
|
<TouchableOpacity
|
|
key={opt.label}
|
|
style={[
|
|
s.option,
|
|
isDark && s.optionDark,
|
|
selected && s.optionSelected,
|
|
selected && isDark && s.optionSelectedDark,
|
|
]}
|
|
onPress={() => toggleOption(opt.label)}
|
|
>
|
|
<Text style={[s.optionLabel, isDark && s.textWhite, selected && s.optionLabelSelected]}>{opt.label}</Text>
|
|
{opt.description ? <Text style={[s.optionDesc, isDark && s.metaDark]}>{opt.description}</Text> : null}
|
|
</TouchableOpacity>
|
|
)
|
|
})}
|
|
|
|
{q.custom !== false &&
|
|
(showCustom ? (
|
|
<View style={s.customRow}>
|
|
<TextInput
|
|
style={[s.customInput, isDark && s.customInputDark]}
|
|
placeholder={t("chat.questionPrompt.answerPlaceholder")}
|
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
|
value={custom}
|
|
onChangeText={setCustom}
|
|
onSubmitEditing={submitCustom}
|
|
autoFocus
|
|
/>
|
|
<TouchableOpacity onPress={submitCustom} style={s.customSubmit}>
|
|
<Ionicons name="send" size={18} color="#8b5cf6" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
) : (
|
|
<TouchableOpacity style={[s.option, isDark && s.optionDark]} onPress={() => setShowCustom(true)}>
|
|
<Text style={[s.optionLabel, { color: "#8b5cf6" }]}>{t("chat.questionPrompt.customAnswerLabel")}</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
|
|
<View style={s.footer}>
|
|
<TouchableOpacity onPress={reject}>
|
|
<Text style={[s.dismiss, isDark && s.metaDark]}>{t("chat.questionPrompt.dismiss")}</Text>
|
|
</TouchableOpacity>
|
|
{(request.questions.length > 1 || q.multiple) && (
|
|
<TouchableOpacity
|
|
style={[s.submitBtn, isDark && s.submitBtnDark]}
|
|
onPress={() => {
|
|
if (current < request.questions.length - 1) {
|
|
setCurrent(current + 1)
|
|
} else {
|
|
reply(answers)
|
|
}
|
|
}}
|
|
>
|
|
<Text style={s.submitText}>
|
|
{current < request.questions.length - 1 ? t("chat.questionPrompt.next") : t("chat.questionPrompt.submit")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const s = StyleSheet.create({
|
|
card: {
|
|
margin: 12,
|
|
padding: 16,
|
|
backgroundColor: "#f5f3ff",
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: "#ede9fe",
|
|
},
|
|
cardDark: { backgroundColor: "#1a1a2e", borderColor: "#2a2a3e" },
|
|
header: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 },
|
|
title: { fontSize: 15, fontWeight: "600", color: "#6d28d9" },
|
|
textWhite: { color: "#ffffff" },
|
|
question: { fontSize: 14, lineHeight: 20, color: "#0a0a0a", marginBottom: 12 },
|
|
metaDark: { color: "#666666" },
|
|
|
|
options: { gap: 8 },
|
|
option: {
|
|
padding: 12,
|
|
borderRadius: 8,
|
|
backgroundColor: "#ffffff",
|
|
borderWidth: 1,
|
|
borderColor: "#e5e5e5",
|
|
},
|
|
optionDark: { backgroundColor: "#2a2a2a", borderColor: "#3a3a3a" },
|
|
optionSelected: { borderColor: "#8b5cf6", backgroundColor: "#f5f3ff" },
|
|
optionSelectedDark: { borderColor: "#8b5cf6", backgroundColor: "#2a1a3e" },
|
|
optionLabel: { fontSize: 14, fontWeight: "600", color: "#0a0a0a" },
|
|
optionLabelSelected: { color: "#6d28d9" },
|
|
optionDesc: { fontSize: 12, color: "#666666", marginTop: 2 },
|
|
|
|
customRow: { flexDirection: "row", gap: 8 },
|
|
customInput: {
|
|
flex: 1,
|
|
backgroundColor: "#ffffff",
|
|
borderRadius: 8,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 10,
|
|
fontSize: 14,
|
|
borderWidth: 1,
|
|
borderColor: "#e5e5e5",
|
|
color: "#0a0a0a",
|
|
},
|
|
customInputDark: { backgroundColor: "#2a2a2a", borderColor: "#3a3a3a", color: "#ffffff" },
|
|
customSubmit: { justifyContent: "center", alignItems: "center", padding: 8 },
|
|
|
|
footer: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginTop: 12 },
|
|
dismiss: { fontSize: 14, color: "#999999" },
|
|
submitBtn: { backgroundColor: "#8b5cf6", paddingHorizontal: 20, paddingVertical: 10, borderRadius: 8 },
|
|
submitBtnDark: { backgroundColor: "#7c3aed" },
|
|
submitText: { color: "#ffffff", fontWeight: "600", fontSize: 14 },
|
|
})
|