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>
This commit is contained in:
Den
2026-07-18 06:04:15 -07:00
committed by GitHub
parent 4681126832
commit 27362cee98
4 changed files with 74 additions and 25 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from "react"
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"
@@ -33,6 +33,22 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
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
@@ -45,7 +61,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
} else {
copy[current] = [label]
if (request.questions.length === 1) {
setTimeout(() => onReply(copy), 100)
setTimeout(() => reply(copy), 100)
}
}
return copy
@@ -60,7 +76,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
setCustom("")
setShowCustom(false)
if (request.questions.length === 1) {
onReply(copy)
reply(copy)
}
}
@@ -116,7 +132,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
</View>
<View style={s.footer}>
<TouchableOpacity onPress={onReject}>
<TouchableOpacity onPress={reject}>
<Text style={[s.dismiss, isDark && s.metaDark]}>{t("chat.questionPrompt.dismiss")}</Text>
</TouchableOpacity>
{(request.questions.length > 1 || q.multiple) && (
@@ -126,7 +142,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
if (current < request.questions.length - 1) {
setCurrent(current + 1)
} else {
onReply(answers)
reply(answers)
}
}}
>