mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-18 10:07:58 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user