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