diff --git a/packages/mobile-voice/src/app/index.tsx b/packages/mobile-voice/src/app/index.tsx
index 7cadcfa2fc..f08a37dea5 100644
--- a/packages/mobile-voice/src/app/index.tsx
+++ b/packages/mobile-voice/src/app/index.tsx
@@ -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() {
scrollViewRef.current?.scrollToEnd({ animated: true })}
>
@@ -3447,6 +3459,14 @@ export default function DictationScreen() {
Your transcription will appear here…
)}
+
+ {!displayedTranscript && !isSending ? (
+ <>
+ Swipe left to see previous prompts
+ →
+ >
+ ) : null}
+
) : (
scrollViewRef.current?.scrollToEnd({ animated: true })}
>
@@ -3605,6 +3625,14 @@ export default function DictationScreen() {
Your transcription will appear here…
)}
+
+ {!displayedTranscript && !isSending ? (
+ <>
+ Swipe left to see previous prompts
+ →
+ >
+ ) : null}
+
) : (
@@ -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,
diff --git a/packages/mobile-voice/src/hooks/use-monitoring.ts b/packages/mobile-voice/src/hooks/use-monitoring.ts
index f7ebf9663a..471ab9153e 100644
--- a/packages/mobile-voice/src/hooks/use-monitoring.ts
+++ b/packages/mobile-voice/src/hooks/use-monitoring.ts
@@ -134,7 +134,11 @@ export function useMonitoring({
const [appState, setAppState] = useState(AppState.currentState)
const foregroundMonitorAbortRef = useRef(null)
+ const foregroundPollIntervalRef = useRef | null>(null)
const monitorJobRef = useRef(null)
+ const syncSessionStateRef = useRef<
+ ((input: { serverID: string; sessionID: string; preserveStatusLabel?: boolean }) => Promise) | 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
diff --git a/packages/mobile-voice/src/hooks/use-server-sessions.ts b/packages/mobile-voice/src/hooks/use-server-sessions.ts
index f237b17526..1ae72403cc 100644
--- a/packages/mobile-voice/src/hooks/use-server-sessions.ts
+++ b/packages/mobile-voice/src/hooks/use-server-sessions.ts
@@ -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 })