Skip to content

Commit 6db0f98

Browse files
author
Ryan Vogel
committed
fix(mobile-voice): monitoring robustness, neutral prompt history colors, swipe hint fade, filter subagents
- SSE recovery: retry syncSessionState on stream failure or natural close - Periodic 20s polling fallback while monitorJob is active in foreground - syncSessionState retries after 5s when runtime status fetch fails - isSending 5s safety timeout to prevent permanent send block - Prompt history and swipe hint colors changed to neutral grays - Swipe hint fades out/in inversely with waveform visibility - Session list excludes subagent sessions via roots=true API param
1 parent e0894d7 commit 6db0f98

3 files changed

Lines changed: 152 additions & 41 deletions

File tree

packages/mobile-voice/src/app/index.tsx

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1813,6 +1813,13 @@ export default function DictationScreen() {
18131813
}
18141814
},
18151815
)
1816+
1817+
// Safety timeout: if the Reanimated animation callback never fires (e.g. app
1818+
// backgrounded during the 320ms animation), force-reset isSending so the user
1819+
// isn't permanently blocked from sending new prompts.
1820+
setTimeout(() => {
1821+
completeSend()
1822+
}, 5_000)
18161823
} catch {
18171824
setMonitorStatus("Failed to send prompt")
18181825
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {})
@@ -2155,6 +2162,11 @@ export default function DictationScreen() {
21552162
],
21562163
}))
21572164

2165+
// Inverse of waveform: visible when waveform is hidden, fades out when waveform appears
2166+
const animatedSwipeHintStyle = useAnimatedStyle(() => ({
2167+
opacity: interpolate(waveformVisibility.value, [0, 1], [1, 0], Extrapolation.CLAMP),
2168+
}))
2169+
21582170
const maxDropdownListHeight = DROPDOWN_VISIBLE_ROWS * DROPDOWN_ROW_HEIGHT
21592171
const serverMenuEntries = Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1)
21602172
const estimatedServerMenuRowsHeight = Math.min(
@@ -3437,7 +3449,7 @@ export default function DictationScreen() {
34373449
<ScrollView
34383450
ref={scrollViewRef}
34393451
style={{ width: pagerPageWidth }}
3440-
contentContainerStyle={styles.transcriptionContent}
3452+
contentContainerStyle={[styles.transcriptionContent, styles.transcriptionContentLive]}
34413453
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
34423454
>
34433455
<Animated.View style={animatedTranscriptSendStyle}>
@@ -3447,6 +3459,14 @@ export default function DictationScreen() {
34473459
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
34483460
)}
34493461
</Animated.View>
3462+
<Animated.View style={[styles.swipeHint, animatedSwipeHintStyle]} pointerEvents="none">
3463+
{!displayedTranscript && !isSending ? (
3464+
<>
3465+
<Text style={styles.swipeHintText}>Swipe left to see previous prompts</Text>
3466+
<Text style={styles.swipeHintArrow}></Text>
3467+
</>
3468+
) : null}
3469+
</Animated.View>
34503470
</ScrollView>
34513471
) : (
34523472
<ScrollView
@@ -3595,7 +3615,7 @@ export default function DictationScreen() {
35953615
<ScrollView
35963616
ref={scrollViewRef}
35973617
style={{ width: pagerPageWidth }}
3598-
contentContainerStyle={styles.transcriptionContent}
3618+
contentContainerStyle={[styles.transcriptionContent, styles.transcriptionContentLive]}
35993619
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
36003620
>
36013621
<Animated.View style={animatedTranscriptSendStyle}>
@@ -3605,6 +3625,14 @@ export default function DictationScreen() {
36053625
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
36063626
)}
36073627
</Animated.View>
3628+
<Animated.View style={[styles.swipeHint, animatedSwipeHintStyle]} pointerEvents="none">
3629+
{!displayedTranscript && !isSending ? (
3630+
<>
3631+
<Text style={styles.swipeHintText}>Swipe left to see previous prompts</Text>
3632+
<Text style={styles.swipeHintArrow}></Text>
3633+
</>
3634+
) : null}
3635+
</Animated.View>
36083636
</ScrollView>
36093637
) : (
36103638
<ScrollView style={{ width: pagerPageWidth }} contentContainerStyle={styles.transcriptionContent}>
@@ -4924,7 +4952,7 @@ const styles = StyleSheet.create({
49244952
justifyContent: "space-between",
49254953
},
49264954
promptHistoryLabel: {
4927-
color: "#6B7A99",
4955+
color: "#555",
49284956
fontSize: 13,
49294957
fontWeight: "700",
49304958
letterSpacing: 0.6,
@@ -4935,7 +4963,7 @@ const styles = StyleSheet.create({
49354963
fontSize: 24,
49364964
fontWeight: "500",
49374965
lineHeight: 34,
4938-
color: "#8B96AD",
4966+
color: "#888",
49394967
},
49404968
modelErrorBadge: {
49414969
alignSelf: "flex-start",
@@ -4966,6 +4994,25 @@ const styles = StyleSheet.create({
49664994
fontWeight: "500",
49674995
color: "#333",
49684996
},
4997+
transcriptionContentLive: {
4998+
justifyContent: "space-between",
4999+
},
5000+
swipeHint: {
5001+
flexDirection: "row",
5002+
alignItems: "center",
5003+
alignSelf: "flex-end",
5004+
gap: 6,
5005+
},
5006+
swipeHintText: {
5007+
color: "#444",
5008+
fontSize: 13,
5009+
fontWeight: "500",
5010+
},
5011+
swipeHintArrow: {
5012+
color: "#444",
5013+
fontSize: 15,
5014+
fontWeight: "600",
5015+
},
49695016
waveformBoxesRow: {
49705017
position: "absolute",
49715018
left: 20,

packages/mobile-voice/src/hooks/use-monitoring.ts

Lines changed: 100 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,11 @@ export function useMonitoring({
134134
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
135135

136136
const foregroundMonitorAbortRef = useRef<AbortController | null>(null)
137+
const foregroundPollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
137138
const monitorJobRef = useRef<MonitorJob | null>(null)
139+
const syncSessionStateRef = useRef<
140+
((input: { serverID: string; sessionID: string; preserveStatusLabel?: boolean }) => Promise<void>) | null
141+
>(null)
138142
const pendingNotificationEventsRef = useRef<{ payload: NotificationPayload; source: "received" | "response" }[]>([])
139143
const notificationHandlerRef = useRef<(payload: NotificationPayload, source: "received" | "response") => void>(
140144
(payload, source) => {
@@ -240,6 +244,10 @@ export function useMonitoring({
240244
aborter.abort()
241245
foregroundMonitorAbortRef.current = null
242246
}
247+
if (foregroundPollIntervalRef.current) {
248+
clearInterval(foregroundPollIntervalRef.current)
249+
foregroundPollIntervalRef.current = null
250+
}
243251
}, [])
244252

245253
const loadLatestAssistantResponse = useCallback(
@@ -378,52 +386,89 @@ export function useMonitoring({
378386

379387
const base = job.opencodeBaseURL.replace(/\/+$/, "")
380388

381-
void (async () => {
382-
try {
383-
const response = await expoFetch(`${base}/event`, {
384-
signal: abortController.signal,
385-
headers: {
386-
Accept: "text/event-stream",
387-
"Cache-Control": "no-cache",
388-
},
389-
})
390-
391-
if (!response.ok || !response.body) {
392-
throw new Error(`SSE monitor failed (${response.status})`)
393-
}
394-
395-
for await (const message of parseSSEStream(response.body)) {
396-
let parsed: OpenCodeEvent | null = null
397-
try {
398-
parsed = JSON.parse(message.data) as OpenCodeEvent
399-
} catch {
400-
continue
389+
// SSE stream with automatic recovery on failure or natural close
390+
const connectSSE = () => {
391+
void (async () => {
392+
try {
393+
const response = await expoFetch(`${base}/event`, {
394+
signal: abortController.signal,
395+
headers: {
396+
Accept: "text/event-stream",
397+
"Cache-Control": "no-cache",
398+
},
399+
})
400+
401+
if (!response.ok || !response.body) {
402+
throw new Error(`SSE monitor failed (${response.status})`)
401403
}
402404

403-
if (!parsed) continue
404-
const sessionID = extractSessionID(parsed)
405-
if (sessionID !== job.sessionID) continue
405+
for await (const message of parseSSEStream(response.body)) {
406+
let parsed: OpenCodeEvent | null = null
407+
try {
408+
parsed = JSON.parse(message.data) as OpenCodeEvent
409+
} catch {
410+
continue
411+
}
412+
413+
if (!parsed) continue
414+
const sessionID = extractSessionID(parsed)
415+
if (sessionID !== job.sessionID) continue
406416

407-
if (parsed.type === "permission.asked") {
408-
const request = parsePendingPermissionRequest(parsed.properties)
409-
if (request) {
410-
upsertPendingPermission(request)
417+
if (parsed.type === "permission.asked") {
418+
const request = parsePendingPermissionRequest(parsed.properties)
419+
if (request) {
420+
upsertPendingPermission(request)
421+
}
411422
}
412-
}
413423

414-
const eventType = classifyMonitorEvent(parsed)
415-
if (!eventType) continue
424+
const eventType = classifyMonitorEvent(parsed)
425+
if (!eventType) continue
426+
427+
const active = monitorJobRef.current
428+
if (!active || active.id !== job.id) return
429+
handleMonitorEvent(eventType, job)
430+
}
416431

417-
const active = monitorJobRef.current
418-
if (!active || active.id !== job.id) return
419-
handleMonitorEvent(eventType, job)
432+
// Stream ended naturally (server closed connection) -- fall through to recovery
433+
} catch {
434+
if (abortController.signal.aborted) return
435+
// SSE failed (network drop, server restart, etc.) -- fall through to recovery
420436
}
421-
} catch {
437+
438+
// Recovery: if this job is still active and we weren't explicitly aborted, poll session status
422439
if (abortController.signal.aborted) return
440+
const active = monitorJobRef.current
441+
if (!active || active.id !== job.id) return
442+
443+
const serverID = activeServerIdRef.current
444+
const sessionID = activeSessionIdRef.current
445+
if (serverID && sessionID) {
446+
void syncSessionStateRef.current?.({ serverID, sessionID })
447+
}
448+
})()
449+
}
450+
451+
connectSSE()
452+
453+
// Periodic polling fallback: check session status every 20s in case SSE silently drops
454+
foregroundPollIntervalRef.current = setInterval(() => {
455+
const active = monitorJobRef.current
456+
if (!active || active.id !== job.id) {
457+
if (foregroundPollIntervalRef.current) {
458+
clearInterval(foregroundPollIntervalRef.current)
459+
foregroundPollIntervalRef.current = null
460+
}
461+
return
423462
}
424-
})()
463+
464+
const serverID = activeServerIdRef.current
465+
const sessionID = activeSessionIdRef.current
466+
if (serverID && sessionID) {
467+
void syncSessionStateRef.current?.({ serverID, sessionID, preserveStatusLabel: true })
468+
}
469+
}, 20_000)
425470
},
426-
[handleMonitorEvent, stopForegroundMonitor, upsertPendingPermission],
471+
[activeServerIdRef, activeSessionIdRef, handleMonitorEvent, stopForegroundMonitor, upsertPendingPermission],
427472
)
428473

429474
const beginMonitoring = useCallback(
@@ -518,9 +563,24 @@ export function useMonitoring({
518563
if (!input.preserveStatusLabel) {
519564
setMonitorStatus("")
520565
}
566+
return
567+
}
568+
569+
// runtimeStatus is null (fetch failed or unparseable) -- retry after a short delay
570+
// if a monitor job is still active, so we don't leave the user stuck
571+
if (runtimeStatus === null && monitorJobRef.current) {
572+
setTimeout(() => {
573+
const serverID = activeServerIdRef.current
574+
const sessionID = activeSessionIdRef.current
575+
if (serverID && sessionID && monitorJobRef.current) {
576+
void syncSessionStateRef.current?.({ serverID, sessionID })
577+
}
578+
}, 5_000)
521579
}
522580
},
523581
[
582+
activeServerIdRef,
583+
activeSessionIdRef,
524584
appState,
525585
fetchSessionRuntimeStatus,
526586
loadLatestAssistantResponse,
@@ -532,6 +592,10 @@ export function useMonitoring({
532592
],
533593
)
534594

595+
useEffect(() => {
596+
syncSessionStateRef.current = syncSessionState
597+
}, [syncSessionState])
598+
535599
const handleNotificationPayload = useCallback(
536600
async (payload: NotificationPayload, source: "received" | "response") => {
537601
const activeServer = activeServerIdRef.current

packages/mobile-voice/src/hooks/use-server-sessions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ export function useServerSessions() {
170170
return
171171
}
172172

173-
const resolvedSessionsURL = `${activeBase}/experimental/session?limit=100`
173+
const resolvedSessionsURL = `${activeBase}/experimental/session?limit=100&roots=true`
174174
const sessionsRes = await fetch(resolvedSessionsURL)
175175
if (!current()) {
176176
console.log("[Server] refresh:stale-skip", { id: server.id, req })

0 commit comments

Comments
 (0)