Files
opencode-mobile/src/components/chat/QuestionPrompt.tsx
Den 27362cee98 fix: request notification permission; resync session on focus; notif + reply bugs (#121)
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>
2026-07-18 06:04:15 -07:00

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