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
This commit is contained in:
Ryan Vogel
2026-04-04 19:43:24 +00:00
parent e0894d7b63
commit 6db0f9855c
3 changed files with 152 additions and 41 deletions

View File

@@ -1813,6 +1813,13 @@ export default function DictationScreen() {
}
},
)
// Safety timeout: if the Reanimated animation callback never fires (e.g. app
// backgrounded during the 320ms animation), force-reset isSending so the user
// isn't permanently blocked from sending new prompts.
setTimeout(() => {
completeSend()
}, 5_000)
} catch {
setMonitorStatus("Failed to send prompt")
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {})
@@ -2155,6 +2162,11 @@ export default function DictationScreen() {
],
}))
// Inverse of waveform: visible when waveform is hidden, fades out when waveform appears
const animatedSwipeHintStyle = useAnimatedStyle(() => ({
opacity: interpolate(waveformVisibility.value, [0, 1], [1, 0], Extrapolation.CLAMP),
}))
const maxDropdownListHeight = DROPDOWN_VISIBLE_ROWS * DROPDOWN_ROW_HEIGHT
const serverMenuEntries = Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1)
const estimatedServerMenuRowsHeight = Math.min(
@@ -3437,7 +3449,7 @@ export default function DictationScreen() {
<ScrollView
ref={scrollViewRef}
style={{ width: pagerPageWidth }}
contentContainerStyle={styles.transcriptionContent}
contentContainerStyle={[styles.transcriptionContent, styles.transcriptionContentLive]}
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
>
<Animated.View style={animatedTranscriptSendStyle}>
@@ -3447,6 +3459,14 @@ export default function DictationScreen() {
<Text style={styles.placeholderText}>Your transcription will appear here</Text>
)}
</Animated.View>
<Animated.View style={[styles.swipeHint, animatedSwipeHintStyle]} pointerEvents="none">
{!displayedTranscript && !isSending ? (
<>
<Text style={styles.swipeHintText}>Swipe left to see previous prompts</Text>
<Text style={styles.swipeHintArrow}></Text>
</>
) : null}
</Animated.View>
</ScrollView>
) : (
<ScrollView
@@ -3595,7 +3615,7 @@ export default function DictationScreen() {
<ScrollView
ref={scrollViewRef}
style={{ width: pagerPageWidth }}
contentContainerStyle={styles.transcriptionContent}
contentContainerStyle={[styles.transcriptionContent, styles.transcriptionContentLive]}
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
>
<Animated.View style={animatedTranscriptSendStyle}>
@@ -3605,6 +3625,14 @@ export default function DictationScreen() {
<Text style={styles.placeholderText}>Your transcription will appear here</Text>
)}
</Animated.View>
<Animated.View style={[styles.swipeHint, animatedSwipeHintStyle]} pointerEvents="none">
{!displayedTranscript && !isSending ? (
<>
<Text style={styles.swipeHintText}>Swipe left to see previous prompts</Text>
<Text style={styles.swipeHintArrow}></Text>
</>
) : null}
</Animated.View>
</ScrollView>
) : (
<ScrollView style={{ width: pagerPageWidth }} contentContainerStyle={styles.transcriptionContent}>
@@ -4924,7 +4952,7 @@ const styles = StyleSheet.create({
justifyContent: "space-between",
},
promptHistoryLabel: {
color: "#6B7A99",
color: "#555",
fontSize: 13,
fontWeight: "700",
letterSpacing: 0.6,
@@ -4935,7 +4963,7 @@ const styles = StyleSheet.create({
fontSize: 24,
fontWeight: "500",
lineHeight: 34,
color: "#8B96AD",
color: "#888",
},
modelErrorBadge: {
alignSelf: "flex-start",
@@ -4966,6 +4994,25 @@ const styles = StyleSheet.create({
fontWeight: "500",
color: "#333",
},
transcriptionContentLive: {
justifyContent: "space-between",
},
swipeHint: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-end",
gap: 6,
},
swipeHintText: {
color: "#444",
fontSize: 13,
fontWeight: "500",
},
swipeHintArrow: {
color: "#444",
fontSize: 15,
fontWeight: "600",
},
waveformBoxesRow: {
position: "absolute",
left: 20,

View File

@@ -134,7 +134,11 @@ export function useMonitoring({
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
const foregroundMonitorAbortRef = useRef<AbortController | null>(null)
const foregroundPollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const monitorJobRef = useRef<MonitorJob | null>(null)
const syncSessionStateRef = useRef<
((input: { serverID: string; sessionID: string; preserveStatusLabel?: boolean }) => Promise<void>) | null
>(null)
const pendingNotificationEventsRef = useRef<{ payload: NotificationPayload; source: "received" | "response" }[]>([])
const notificationHandlerRef = useRef<(payload: NotificationPayload, source: "received" | "response") => void>(
(payload, source) => {
@@ -240,6 +244,10 @@ export function useMonitoring({
aborter.abort()
foregroundMonitorAbortRef.current = null
}
if (foregroundPollIntervalRef.current) {
clearInterval(foregroundPollIntervalRef.current)
foregroundPollIntervalRef.current = null
}
}, [])
const loadLatestAssistantResponse = useCallback(
@@ -378,52 +386,89 @@ export function useMonitoring({
const base = job.opencodeBaseURL.replace(/\/+$/, "")
void (async () => {
try {
const response = await expoFetch(`${base}/event`, {
signal: abortController.signal,
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache",
},
})
// SSE stream with automatic recovery on failure or natural close
const connectSSE = () => {
void (async () => {
try {
const response = await expoFetch(`${base}/event`, {
signal: abortController.signal,
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache",
},
})
if (!response.ok || !response.body) {
throw new Error(`SSE monitor failed (${response.status})`)
}
for await (const message of parseSSEStream(response.body)) {
let parsed: OpenCodeEvent | null = null
try {
parsed = JSON.parse(message.data) as OpenCodeEvent
} catch {
continue
if (!response.ok || !response.body) {
throw new Error(`SSE monitor failed (${response.status})`)
}
if (!parsed) continue
const sessionID = extractSessionID(parsed)
if (sessionID !== job.sessionID) continue
if (parsed.type === "permission.asked") {
const request = parsePendingPermissionRequest(parsed.properties)
if (request) {
upsertPendingPermission(request)
for await (const message of parseSSEStream(response.body)) {
let parsed: OpenCodeEvent | null = null
try {
parsed = JSON.parse(message.data) as OpenCodeEvent
} catch {
continue
}
if (!parsed) continue
const sessionID = extractSessionID(parsed)
if (sessionID !== job.sessionID) continue
if (parsed.type === "permission.asked") {
const request = parsePendingPermissionRequest(parsed.properties)
if (request) {
upsertPendingPermission(request)
}
}
const eventType = classifyMonitorEvent(parsed)
if (!eventType) continue
const active = monitorJobRef.current
if (!active || active.id !== job.id) return
handleMonitorEvent(eventType, job)
}
const eventType = classifyMonitorEvent(parsed)
if (!eventType) continue
const active = monitorJobRef.current
if (!active || active.id !== job.id) return
handleMonitorEvent(eventType, job)
// Stream ended naturally (server closed connection) -- fall through to recovery
} catch {
if (abortController.signal.aborted) return
// SSE failed (network drop, server restart, etc.) -- fall through to recovery
}
} catch {
// Recovery: if this job is still active and we weren't explicitly aborted, poll session status
if (abortController.signal.aborted) return
const active = monitorJobRef.current
if (!active || active.id !== job.id) return
const serverID = activeServerIdRef.current
const sessionID = activeSessionIdRef.current
if (serverID && sessionID) {
void syncSessionStateRef.current?.({ serverID, sessionID })
}
})()
}
connectSSE()
// Periodic polling fallback: check session status every 20s in case SSE silently drops
foregroundPollIntervalRef.current = setInterval(() => {
const active = monitorJobRef.current
if (!active || active.id !== job.id) {
if (foregroundPollIntervalRef.current) {
clearInterval(foregroundPollIntervalRef.current)
foregroundPollIntervalRef.current = null
}
return
}
})()
const serverID = activeServerIdRef.current
const sessionID = activeSessionIdRef.current
if (serverID && sessionID) {
void syncSessionStateRef.current?.({ serverID, sessionID, preserveStatusLabel: true })
}
}, 20_000)
},
[handleMonitorEvent, stopForegroundMonitor, upsertPendingPermission],
[activeServerIdRef, activeSessionIdRef, handleMonitorEvent, stopForegroundMonitor, upsertPendingPermission],
)
const beginMonitoring = useCallback(
@@ -518,9 +563,24 @@ export function useMonitoring({
if (!input.preserveStatusLabel) {
setMonitorStatus("")
}
return
}
// runtimeStatus is null (fetch failed or unparseable) -- retry after a short delay
// if a monitor job is still active, so we don't leave the user stuck
if (runtimeStatus === null && monitorJobRef.current) {
setTimeout(() => {
const serverID = activeServerIdRef.current
const sessionID = activeSessionIdRef.current
if (serverID && sessionID && monitorJobRef.current) {
void syncSessionStateRef.current?.({ serverID, sessionID })
}
}, 5_000)
}
},
[
activeServerIdRef,
activeSessionIdRef,
appState,
fetchSessionRuntimeStatus,
loadLatestAssistantResponse,
@@ -532,6 +592,10 @@ export function useMonitoring({
],
)
useEffect(() => {
syncSessionStateRef.current = syncSessionState
}, [syncSessionState])
const handleNotificationPayload = useCallback(
async (payload: NotificationPayload, source: "received" | "response") => {
const activeServer = activeServerIdRef.current

View File

@@ -170,7 +170,7 @@ export function useServerSessions() {
return
}
const resolvedSessionsURL = `${activeBase}/experimental/session?limit=100`
const resolvedSessionsURL = `${activeBase}/experimental/session?limit=100&roots=true`
const sessionsRes = await fetch(resolvedSessionsURL)
if (!current()) {
console.log("[Server] refresh:stale-skip", { id: server.id, req })