Browse Source

refactor mobile screen orchestration

Extract server/session and monitoring workflows into focused hooks so DictationScreen no longer owns every network and notification path. Add a dedicated mobile typecheck config so TypeScript checks pass without breaking Expo export resolution.
Ryan Vogel 3 weeks ago
parent
commit
abf79ae24c

+ 3 - 0
packages/mobile-voice/AGENTS.md

@@ -24,6 +24,7 @@ Run all commands from `packages/mobile-voice`.
 - iOS run: `bun run ios`
 - Android run: `bun run android`
 - Lint: `bun run lint`
+- Typecheck: `bun run typecheck`
 - Expo doctor: `bunx expo-doctor`
 - Dependency compatibility check: `bunx expo install --check`
 - Export bundle smoke test: `bunx expo export --platform ios --clear`
@@ -31,6 +32,7 @@ Run all commands from `packages/mobile-voice`.
 ## Build / Verification Expectations
 
 - For JS-only changes: run `bun run lint` and verify app behavior via dev client.
+- For TS-heavy refactors: run `bun run typecheck` in addition to lint.
 - For native dependency/config/plugin changes: rebuild dev client via EAS before validation.
 - If notifications, camera, microphone, or audio-session behavior changes, verify on a physical iOS device.
 - Do not claim a fix unless you validated in Metro logs and app runtime behavior.
@@ -40,6 +42,7 @@ Run all commands from `packages/mobile-voice`.
 - This package currently has no dedicated unit test script.
 - Use targeted validation commands instead:
   - `bun run lint`
+  - `bun run typecheck`
   - `bunx expo export --platform ios --clear`
   - manual runtime test in dev client
 

+ 2 - 1
packages/mobile-voice/package.json

@@ -10,7 +10,8 @@
     "android": "expo run:android",
     "ios": "expo run:ios",
     "web": "expo start --web",
-    "lint": "expo lint"
+    "lint": "expo lint",
+    "typecheck": "tsc -p tsconfig.typecheck.json --noEmit"
   },
   "dependencies": {
     "@fugood/react-native-audio-pcm-stream": "1.1.4",

File diff suppressed because it is too large
+ 55 - 912
packages/mobile-voice/src/app/index.tsx


+ 716 - 0
packages/mobile-voice/src/hooks/use-monitoring.ts

@@ -0,0 +1,716 @@
+import {
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+  type Dispatch,
+  type MutableRefObject,
+  type SetStateAction,
+} from "react"
+import { AppState, Platform, type AppStateStatus } from "react-native"
+import * as Haptics from "expo-haptics"
+import * as Notifications from "expo-notifications"
+import Constants from "expo-constants"
+import { fetch as expoFetch } from "expo/fetch"
+
+import {
+  classifyMonitorEvent,
+  extractSessionID,
+  formatMonitorEventLabel,
+  type OpenCodeEvent,
+  type MonitorEventType,
+} from "@/lib/opencode-events"
+import { registerRelayDevice, unregisterRelayDevice } from "@/lib/relay-client"
+import { parseSSEStream } from "@/lib/sse"
+import { getDevicePushToken, onPushTokenChange } from "@/notifications/monitoring-notifications"
+import type { ServerItem } from "@/hooks/use-server-sessions"
+
+export type MonitorJob = {
+  id: string
+  sessionID: string
+  opencodeBaseURL: string
+  startedAt: number
+}
+
+type SessionRuntimeStatus = "idle" | "busy" | "retry"
+
+type PermissionPromptState = "idle" | "pending" | "granted" | "denied"
+
+type NotificationPayload = {
+  serverID: string | null
+  eventType: MonitorEventType | null
+  sessionID: string | null
+}
+
+type CuePlayer = {
+  seekTo: (position: number) => unknown
+  play: () => unknown
+}
+
+type UseMonitoringOptions = {
+  completePlayer: CuePlayer
+  closeDropdown: () => void
+  findServerForSession: (sessionID: string, preferredServerID?: string | null) => Promise<ServerItem | null>
+  refreshServerStatusAndSessions: (serverID: string, includeSessions?: boolean) => Promise<void>
+  servers: ServerItem[]
+  serversRef: MutableRefObject<ServerItem[]>
+  restoredRef: MutableRefObject<boolean>
+  activeServerId: string | null
+  activeSessionId: string | null
+  activeServerIdRef: MutableRefObject<string | null>
+  activeSessionIdRef: MutableRefObject<string | null>
+  setActiveServerId: Dispatch<SetStateAction<string | null>>
+  setActiveSessionId: Dispatch<SetStateAction<string | null>>
+  setAgentStateDismissed: Dispatch<SetStateAction<boolean>>
+  setNotificationPermissionState: Dispatch<SetStateAction<PermissionPromptState>>
+}
+
+function parseMonitorEventType(value: unknown): MonitorEventType | null {
+  if (value === "complete" || value === "permission" || value === "error") {
+    return value
+  }
+
+  return null
+}
+
+function parseNotificationPayload(data: unknown): NotificationPayload | null {
+  if (!data || typeof data !== "object") return null
+
+  const serverIDRaw = (data as { serverID?: unknown }).serverID
+  const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null
+
+  const eventType = parseMonitorEventType((data as { eventType?: unknown }).eventType)
+  const sessionIDRaw = (data as { sessionID?: unknown }).sessionID
+  const sessionID = typeof sessionIDRaw === "string" && sessionIDRaw.length > 0 ? sessionIDRaw : null
+
+  if (!eventType && !sessionID && !serverID) return null
+
+  return {
+    serverID,
+    eventType,
+    sessionID,
+  }
+}
+
+export function useMonitoring({
+  completePlayer,
+  closeDropdown,
+  findServerForSession,
+  refreshServerStatusAndSessions,
+  servers,
+  serversRef,
+  restoredRef,
+  activeServerId,
+  activeSessionId,
+  activeServerIdRef,
+  activeSessionIdRef,
+  setActiveServerId,
+  setActiveSessionId,
+  setAgentStateDismissed,
+  setNotificationPermissionState,
+}: UseMonitoringOptions) {
+  const [devicePushToken, setDevicePushToken] = useState<string | null>(null)
+  const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null)
+  const [monitorStatus, setMonitorStatus] = useState("")
+  const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
+  const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
+
+  const foregroundMonitorAbortRef = useRef<AbortController | null>(null)
+  const monitorJobRef = useRef<MonitorJob | null>(null)
+  const pendingNotificationEventsRef = useRef<{ payload: NotificationPayload; source: "received" | "response" }[]>([])
+  const notificationHandlerRef = useRef<(payload: NotificationPayload, source: "received" | "response") => void>(
+    (payload, source) => {
+      pendingNotificationEventsRef.current.push({ payload, source })
+    },
+  )
+  const previousPushTokenRef = useRef<string | null>(null)
+  const previousAppStateRef = useRef<AppStateStatus>(AppState.currentState)
+  const latestAssistantRequestRef = useRef(0)
+
+  useEffect(() => {
+    monitorJobRef.current = monitorJob
+  }, [monitorJob])
+
+  useEffect(() => {
+    const sub = AppState.addEventListener("change", (nextState) => {
+      setAppState(nextState)
+    })
+    return () => sub.remove()
+  }, [])
+
+  useEffect(() => {
+    let active = true
+
+    void (async () => {
+      try {
+        if (Platform.OS !== "ios") return
+        const existing = await Notifications.getPermissionsAsync()
+        const granted = Boolean((existing as { granted?: unknown }).granted)
+        if (active) {
+          setNotificationPermissionState(granted ? "granted" : "idle")
+        }
+        if (!granted) return
+        const token = await getDevicePushToken()
+        if (token) {
+          setDevicePushToken(token)
+        }
+      } catch {
+        // Non-fatal: monitoring can still work in-app via foreground SSE.
+      }
+    })()
+
+    const sub = onPushTokenChange((token) => {
+      if (!active) return
+      setDevicePushToken(token)
+    })
+
+    return () => {
+      active = false
+      sub.remove()
+    }
+  }, [setNotificationPermissionState])
+
+  useEffect(() => {
+    const notificationSub = Notifications.addNotificationReceivedListener((notification: unknown) => {
+      const data = (notification as { request?: { content?: { data?: unknown } } }).request?.content?.data
+      const payload = parseNotificationPayload(data)
+      if (!payload) return
+      notificationHandlerRef.current(payload, "received")
+    })
+
+    const responseSub = Notifications.addNotificationResponseReceivedListener((response: unknown) => {
+      const data = (response as { notification?: { request?: { content?: { data?: unknown } } } }).notification?.request
+        ?.content?.data
+      const payload = parseNotificationPayload(data)
+      if (!payload) return
+      notificationHandlerRef.current(payload, "response")
+    })
+
+    void Notifications.getLastNotificationResponseAsync()
+      .then((response) => {
+        if (!response) return
+        const data = (response as { notification?: { request?: { content?: { data?: unknown } } } }).notification
+          ?.request?.content?.data
+        const payload = parseNotificationPayload(data)
+        if (!payload) return
+        notificationHandlerRef.current(payload, "response")
+      })
+      .catch(() => {})
+
+    return () => {
+      notificationSub.remove()
+      responseSub.remove()
+    }
+  }, [])
+
+  const stopForegroundMonitor = useCallback(() => {
+    const aborter = foregroundMonitorAbortRef.current
+    if (aborter) {
+      aborter.abort()
+      foregroundMonitorAbortRef.current = null
+    }
+  }, [])
+
+  const loadLatestAssistantResponse = useCallback(
+    async (baseURL: string, sessionID: string) => {
+      const requestID = latestAssistantRequestRef.current + 1
+      latestAssistantRequestRef.current = requestID
+
+      const base = baseURL.replace(/\/+$/, "")
+
+      try {
+        const response = await fetch(`${base}/session/${sessionID}/message?limit=60`)
+        if (!response.ok) {
+          throw new Error(`Session messages failed (${response.status})`)
+        }
+
+        const payload = (await response.json()) as unknown
+        const text = findLatestAssistantCompletionText(payload)
+
+        if (latestAssistantRequestRef.current !== requestID) return
+        if (activeSessionIdRef.current !== sessionID) return
+        setLatestAssistantResponse(text)
+        if (text) {
+          setAgentStateDismissed(false)
+        }
+      } catch {
+        if (latestAssistantRequestRef.current !== requestID) return
+        if (activeSessionIdRef.current !== sessionID) return
+        setLatestAssistantResponse("")
+      }
+    },
+    [activeSessionIdRef, setAgentStateDismissed],
+  )
+
+  const fetchSessionRuntimeStatus = useCallback(
+    async (baseURL: string, sessionID: string): Promise<SessionRuntimeStatus | null> => {
+      const base = baseURL.replace(/\/+$/, "")
+
+      try {
+        const response = await fetch(`${base}/session/status`)
+        if (!response.ok) {
+          throw new Error(`Session status failed (${response.status})`)
+        }
+
+        const payload = (await response.json()) as unknown
+        if (!payload || typeof payload !== "object") return null
+
+        const status = (payload as Record<string, unknown>)[sessionID]
+        if (!status || typeof status !== "object") return "idle"
+
+        const type = (status as { type?: unknown }).type
+        if (type === "busy" || type === "retry" || type === "idle") {
+          return type
+        }
+
+        return null
+      } catch {
+        return null
+      }
+    },
+    [],
+  )
+
+  const handleMonitorEvent = useCallback(
+    (eventType: MonitorEventType, job: MonitorJob) => {
+      setMonitorStatus(formatMonitorEventLabel(eventType))
+
+      if (eventType === "permission") {
+        void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(() => {})
+        return
+      }
+
+      if (eventType === "complete") {
+        void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
+        void completePlayer.seekTo(0)
+        void completePlayer.play()
+        stopForegroundMonitor()
+        setMonitorJob(null)
+        void loadLatestAssistantResponse(job.opencodeBaseURL, job.sessionID)
+        return
+      }
+
+      void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {})
+      stopForegroundMonitor()
+      setMonitorJob(null)
+    },
+    [completePlayer, loadLatestAssistantResponse, stopForegroundMonitor],
+  )
+
+  const startForegroundMonitor = useCallback(
+    (job: MonitorJob) => {
+      stopForegroundMonitor()
+
+      const abortController = new AbortController()
+      foregroundMonitorAbortRef.current = abortController
+
+      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",
+            },
+          })
+
+          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 (!parsed) continue
+            const sessionID = extractSessionID(parsed)
+            if (sessionID !== job.sessionID) continue
+
+            const eventType = classifyMonitorEvent(parsed)
+            if (!eventType) continue
+
+            const active = monitorJobRef.current
+            if (!active || active.id !== job.id) return
+            handleMonitorEvent(eventType, job)
+          }
+        } catch {
+          if (abortController.signal.aborted) return
+        }
+      })()
+    },
+    [handleMonitorEvent, stopForegroundMonitor],
+  )
+
+  const beginMonitoring = useCallback(
+    async (job: MonitorJob) => {
+      setMonitorJob(job)
+      setMonitorStatus("Monitoring…")
+      startForegroundMonitor(job)
+    },
+    [startForegroundMonitor],
+  )
+
+  useEffect(() => {
+    const active = monitorJobRef.current
+    if (!active) return
+
+    if (appState === "active") {
+      startForegroundMonitor(active)
+      return
+    }
+
+    stopForegroundMonitor()
+  }, [appState, startForegroundMonitor, stopForegroundMonitor])
+
+  useEffect(() => {
+    const active = monitorJobRef.current
+    if (!active) return
+    if (activeSessionId === active.sessionID) return
+
+    stopForegroundMonitor()
+    setMonitorJob(null)
+    setMonitorStatus("")
+  }, [activeSessionId, stopForegroundMonitor])
+
+  useEffect(() => {
+    setLatestAssistantResponse("")
+    setAgentStateDismissed(false)
+    if (!activeServerId || !activeSessionId) return
+
+    const server = serversRef.current.find((item) => item.id === activeServerId)
+    if (!server || server.status !== "online") return
+    void loadLatestAssistantResponse(server.url, activeSessionId)
+  }, [activeServerId, activeSessionId, loadLatestAssistantResponse, serversRef, setAgentStateDismissed])
+
+  useEffect(() => {
+    return () => {
+      stopForegroundMonitor()
+    }
+  }, [stopForegroundMonitor])
+
+  const syncSessionState = useCallback(
+    async (input: { serverID: string; sessionID: string; preserveStatusLabel?: boolean }) => {
+      await refreshServerStatusAndSessions(input.serverID)
+
+      const server = serversRef.current.find((item) => item.id === input.serverID)
+      if (!server || server.status !== "online") return
+
+      const runtimeStatus = await fetchSessionRuntimeStatus(server.url, input.sessionID)
+      await loadLatestAssistantResponse(server.url, input.sessionID)
+
+      if (runtimeStatus === "busy" || runtimeStatus === "retry") {
+        const nextJob: MonitorJob = {
+          id: `job-resume-${Date.now()}`,
+          sessionID: input.sessionID,
+          opencodeBaseURL: server.url.replace(/\/+$/, ""),
+          startedAt: Date.now(),
+        }
+
+        setMonitorJob(nextJob)
+        setMonitorStatus("Monitoring…")
+        if (appState === "active") {
+          startForegroundMonitor(nextJob)
+        }
+        return
+      }
+
+      if (runtimeStatus === "idle") {
+        stopForegroundMonitor()
+        setMonitorJob(null)
+        if (!input.preserveStatusLabel) {
+          setMonitorStatus("")
+        }
+      }
+    },
+    [
+      appState,
+      fetchSessionRuntimeStatus,
+      loadLatestAssistantResponse,
+      refreshServerStatusAndSessions,
+      serversRef,
+      startForegroundMonitor,
+      stopForegroundMonitor,
+    ],
+  )
+
+  const handleNotificationPayload = useCallback(
+    async (payload: NotificationPayload, source: "received" | "response") => {
+      const activeServer = activeServerIdRef.current
+        ? serversRef.current.find((server) => server.id === activeServerIdRef.current)
+        : null
+      const matchesActiveSession =
+        !!payload.sessionID &&
+        activeSessionIdRef.current === payload.sessionID &&
+        (!payload.serverID || activeServer?.serverID === payload.serverID)
+
+      if (payload.eventType && (source === "response" || matchesActiveSession || !payload.sessionID)) {
+        setMonitorStatus(formatMonitorEventLabel(payload.eventType))
+      }
+
+      if (payload.eventType === "complete" && source === "received") {
+        void completePlayer.seekTo(0)
+        void completePlayer.play()
+      }
+
+      if (
+        (payload.eventType === "complete" || payload.eventType === "error") &&
+        (source === "response" || matchesActiveSession)
+      ) {
+        stopForegroundMonitor()
+        setMonitorJob(null)
+      }
+
+      if (!payload.sessionID) return
+
+      if (source === "response") {
+        const matched = await findServerForSession(payload.sessionID, payload.serverID)
+        if (!matched) {
+          console.log("[Notification] open:session-not-found", {
+            serverID: payload.serverID,
+            sessionID: payload.sessionID,
+            eventType: payload.eventType,
+          })
+          return
+        }
+
+        activeServerIdRef.current = matched.id
+        activeSessionIdRef.current = payload.sessionID
+        setActiveServerId(matched.id)
+        setActiveSessionId(payload.sessionID)
+        closeDropdown()
+        setAgentStateDismissed(false)
+
+        await syncSessionState({
+          serverID: matched.id,
+          sessionID: payload.sessionID,
+          preserveStatusLabel: Boolean(payload.eventType),
+        })
+        return
+      }
+
+      if (!matchesActiveSession) return
+
+      const activeServerID = activeServerIdRef.current
+      if (!activeServerID) return
+
+      await syncSessionState({
+        serverID: activeServerID,
+        sessionID: payload.sessionID,
+        preserveStatusLabel: Boolean(payload.eventType),
+      })
+    },
+    [
+      activeServerIdRef,
+      activeSessionIdRef,
+      closeDropdown,
+      completePlayer,
+      findServerForSession,
+      serversRef,
+      setActiveServerId,
+      setActiveSessionId,
+      setAgentStateDismissed,
+      stopForegroundMonitor,
+      syncSessionState,
+    ],
+  )
+
+  useEffect(() => {
+    notificationHandlerRef.current = (payload, source) => {
+      void handleNotificationPayload(payload, source)
+    }
+
+    if (!pendingNotificationEventsRef.current.length) return
+
+    const queued = [...pendingNotificationEventsRef.current]
+    pendingNotificationEventsRef.current = []
+    queued.forEach(({ payload, source }) => {
+      void handleNotificationPayload(payload, source)
+    })
+  }, [handleNotificationPayload])
+
+  useEffect(() => {
+    const previous = previousAppStateRef.current
+    previousAppStateRef.current = appState
+
+    if (appState !== "active" || previous === "active") return
+
+    const serverID = activeServerIdRef.current
+    const sessionID = activeSessionIdRef.current
+    if (!serverID || !sessionID) return
+
+    void syncSessionState({ serverID, sessionID })
+  }, [activeServerIdRef, activeSessionIdRef, appState, syncSessionState])
+
+  const relayServersKey = useMemo(
+    () =>
+      servers
+        .filter((server) => server.relaySecret.trim().length > 0)
+        .map((server) => `${server.id}:${server.relayURL}:${server.relaySecret.trim()}`)
+        .join("|"),
+    [servers],
+  )
+
+  useEffect(() => {
+    if (Platform.OS !== "ios") return
+    if (!devicePushToken) return
+
+    const list = serversRef.current.filter((server) => server.relaySecret.trim().length > 0)
+    if (!list.length) return
+
+    const bundleId = Constants.expoConfig?.ios?.bundleIdentifier ?? "com.anomalyco.mobilevoice"
+    const apnsEnv = "production"
+    console.log("[Relay] env", {
+      dev: __DEV__,
+      node: process.env.NODE_ENV,
+      apnsEnv,
+    })
+    console.log("[Relay] register:batch", {
+      tokenSuffix: devicePushToken.slice(-8),
+      count: list.length,
+      apnsEnv,
+      bundleId,
+    })
+
+    void Promise.allSettled(
+      list.map(async (server) => {
+        const secret = server.relaySecret.trim()
+        const relay = server.relayURL
+        console.log("[Relay] register:start", {
+          id: server.id,
+          relay,
+          tokenSuffix: devicePushToken.slice(-8),
+          secretLength: secret.length,
+        })
+        try {
+          await registerRelayDevice({
+            relayBaseURL: relay,
+            secret,
+            deviceToken: devicePushToken,
+            bundleId,
+            apnsEnv,
+          })
+          console.log("[Relay] register:ok", { id: server.id, relay })
+        } catch (err) {
+          console.log("[Relay] register:error", {
+            id: server.id,
+            relay,
+            error: err instanceof Error ? err.message : String(err),
+          })
+        }
+      }),
+    ).catch(() => {})
+  }, [devicePushToken, relayServersKey, serversRef])
+
+  useEffect(() => {
+    if (Platform.OS !== "ios") return
+    if (!devicePushToken) return
+    const previous = previousPushTokenRef.current
+    previousPushTokenRef.current = devicePushToken
+    if (!previous || previous === devicePushToken) return
+
+    const list = serversRef.current.filter((server) => server.relaySecret.trim().length > 0)
+    if (!list.length) return
+    console.log("[Relay] unregister:batch", {
+      previousSuffix: previous.slice(-8),
+      nextSuffix: devicePushToken.slice(-8),
+      count: list.length,
+    })
+
+    void Promise.allSettled(
+      list.map(async (server) => {
+        const secret = server.relaySecret.trim()
+        const relay = server.relayURL
+        console.log("[Relay] unregister:start", {
+          id: server.id,
+          relay,
+          tokenSuffix: previous.slice(-8),
+          secretLength: secret.length,
+        })
+        try {
+          await unregisterRelayDevice({
+            relayBaseURL: relay,
+            secret,
+            deviceToken: previous,
+          })
+          console.log("[Relay] unregister:ok", { id: server.id, relay })
+        } catch (err) {
+          console.log("[Relay] unregister:error", {
+            id: server.id,
+            relay,
+            error: err instanceof Error ? err.message : String(err),
+          })
+        }
+      }),
+    ).catch(() => {})
+  }, [devicePushToken, relayServersKey, serversRef])
+
+  return {
+    devicePushToken,
+    setDevicePushToken,
+    monitorJob,
+    monitorStatus,
+    setMonitorStatus,
+    latestAssistantResponse,
+    beginMonitoring,
+  }
+}
+
+type SessionMessageInfo = {
+  role?: unknown
+  time?: unknown
+}
+
+type SessionMessagePart = {
+  type?: unknown
+  text?: unknown
+}
+
+type SessionMessagePayload = {
+  info?: unknown
+  parts?: unknown
+}
+
+function cleanTranscriptText(text: string): string {
+  return text.replace(/[ \t]+$/gm, "").trimEnd()
+}
+
+function cleanSessionText(text: string): string {
+  return cleanTranscriptText(text).trimStart()
+}
+
+function findLatestAssistantCompletionText(payload: unknown): string {
+  if (!Array.isArray(payload)) return ""
+
+  for (let index = payload.length - 1; index >= 0; index -= 1) {
+    const candidate = payload[index] as SessionMessagePayload
+    if (!candidate || typeof candidate !== "object") continue
+
+    const info = candidate.info as SessionMessageInfo
+    if (!info || typeof info !== "object") continue
+    if (info.role !== "assistant") continue
+
+    const time = info.time as { completed?: unknown } | undefined
+    if (!time || typeof time !== "object") continue
+    if (typeof time.completed !== "number") continue
+
+    const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
+    const text = parts
+      .filter((part) => part && part.type === "text" && typeof part.text === "string")
+      .map((part) => cleanSessionText(part.text as string))
+      .filter((part) => part.length > 0)
+      .join("\n\n")
+
+    if (text.length > 0) {
+      return text
+    }
+  }
+
+  return ""
+}

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

@@ -0,0 +1,386 @@
+import { useCallback, useEffect, useRef, useState } from "react"
+
+import {
+  DEFAULT_RELAY_URL,
+  parseSessionItems,
+  persistServerState,
+  restoreServerState,
+  serverBases,
+  looksLikeLocalHost,
+  type ServerItem,
+} from "@/lib/server-sessions"
+
+export { DEFAULT_RELAY_URL, looksLikeLocalHost, type ServerItem, type SessionItem } from "@/lib/server-sessions"
+
+export function useServerSessions() {
+  const [servers, setServers] = useState<ServerItem[]>([])
+  const [activeServerId, setActiveServerId] = useState<string | null>(null)
+  const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
+
+  const serversRef = useRef<ServerItem[]>([])
+  const restoredRef = useRef(false)
+  const refreshSeqRef = useRef<Record<string, number>>({})
+  const activeServerIdRef = useRef<string | null>(null)
+  const activeSessionIdRef = useRef<string | null>(null)
+
+  useEffect(() => {
+    serversRef.current = servers
+  }, [servers])
+
+  useEffect(() => {
+    activeServerIdRef.current = activeServerId
+  }, [activeServerId])
+
+  useEffect(() => {
+    activeSessionIdRef.current = activeSessionId
+  }, [activeSessionId])
+
+  useEffect(() => {
+    let mounted = true
+
+    void (async () => {
+      try {
+        const next = await restoreServerState()
+        if (!mounted || !next) return
+
+        setServers(next.servers)
+        setActiveServerId(next.activeServerId)
+        setActiveSessionId(next.activeSessionId)
+        console.log("[Server] restore", {
+          count: next.servers.length,
+          activeServerId: next.activeServerId,
+        })
+      } finally {
+        restoredRef.current = true
+      }
+    })()
+
+    return () => {
+      mounted = false
+    }
+  }, [])
+
+  useEffect(() => {
+    if (!restoredRef.current) return
+
+    void persistServerState(servers, activeServerId, activeSessionId).catch(() => {})
+  }, [activeServerId, activeSessionId, servers])
+
+  const refreshServerStatusAndSessions = useCallback(async (serverID: string, includeSessions = true) => {
+    const server = serversRef.current.find((item) => item.id === serverID)
+    if (!server) return
+
+    const req = (refreshSeqRef.current[serverID] ?? 0) + 1
+    refreshSeqRef.current[serverID] = req
+    const current = () => refreshSeqRef.current[serverID] === req
+
+    const candidates = serverBases(server.url)
+    const base = candidates[0] ?? server.url.replace(/\/+$/, "")
+    const healthURL = `${base}/health`
+    const sessionsURL = `${base}/experimental/session?limit=100`
+    let insecureRemote = false
+    try {
+      const parsedBase = new URL(base)
+      insecureRemote = parsedBase.protocol === "http:" && !looksLikeLocalHost(parsedBase.hostname)
+    } catch {
+      insecureRemote = base.startsWith("http://")
+    }
+
+    console.log("[Server] refresh:start", {
+      id: server.id,
+      name: server.name,
+      base,
+      healthURL,
+      sessionsURL,
+      includeSessions,
+    })
+
+    setServers((prev) =>
+      prev.map((item) => (item.id === serverID && includeSessions ? { ...item, sessionsLoading: true } : item)),
+    )
+
+    let activeBase = base
+    try {
+      let healthRes: Response | null = null
+      let healthErr: unknown
+
+      for (const item of candidates) {
+        const url = `${item}/health`
+        try {
+          const next = await fetch(url)
+          if (next.ok) {
+            healthRes = next
+            activeBase = item
+            if (item !== server.url.replace(/\/+$/, "") && current()) {
+              setServers((prev) => prev.map((entry) => (entry.id === serverID ? { ...entry, url: item } : entry)))
+              console.log("[Server] refresh:scheme-upgrade", {
+                id: server.id,
+                from: server.url,
+                to: item,
+              })
+            }
+            break
+          }
+          healthRes = next
+          activeBase = item
+        } catch (err) {
+          healthErr = err
+          console.log("[Server] health:attempt-error", {
+            id: server.id,
+            url,
+            error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
+          })
+        }
+      }
+
+      const online = !!healthRes?.ok
+      if (!current()) {
+        console.log("[Server] refresh:stale-skip", { id: server.id, req })
+        return
+      }
+
+      console.log("[Server] health", {
+        id: server.id,
+        base: activeBase,
+        url: `${activeBase}/health`,
+        status: healthRes?.status ?? "fetch_error",
+        online,
+      })
+
+      if (!online) {
+        setServers((prev) =>
+          prev.map((item) =>
+            item.id === serverID ? { ...item, status: "offline", sessionsLoading: false, sessions: [] } : item,
+          ),
+        )
+        console.log("[Server] refresh:offline", {
+          id: server.id,
+          base,
+          candidates,
+          error: healthErr instanceof Error ? `${healthErr.name}: ${healthErr.message}` : String(healthErr),
+        })
+        return
+      }
+
+      if (!includeSessions) {
+        setServers((prev) =>
+          prev.map((item) => (item.id === serverID ? { ...item, status: "online", sessionsLoading: false } : item)),
+        )
+        console.log("[Server] refresh:online", { id: server.id, base })
+        return
+      }
+
+      const resolvedSessionsURL = `${activeBase}/experimental/session?limit=100`
+      const sessionsRes = await fetch(resolvedSessionsURL)
+      if (!current()) {
+        console.log("[Server] refresh:stale-skip", { id: server.id, req })
+        return
+      }
+
+      if (!sessionsRes.ok) {
+        console.log("[Server] sessions:http-error", {
+          id: server.id,
+          url: resolvedSessionsURL,
+          status: sessionsRes.status,
+        })
+      }
+
+      const json = sessionsRes.ok ? await sessionsRes.json() : []
+      const sessions = parseSessionItems(json)
+
+      setServers((prev) =>
+        prev.map((item) =>
+          item.id === serverID ? { ...item, status: "online", sessionsLoading: false, sessions } : item,
+        ),
+      )
+      console.log("[Server] sessions", { id: server.id, count: sessions.length })
+    } catch (err) {
+      if (!current()) {
+        console.log("[Server] refresh:stale-skip", { id: server.id, req })
+        return
+      }
+
+      setServers((prev) =>
+        prev.map((item) =>
+          item.id === serverID ? { ...item, status: "offline", sessionsLoading: false, sessions: [] } : item,
+        ),
+      )
+      console.log("[Server] refresh:error", {
+        id: server.id,
+        base,
+        healthURL,
+        sessionsURL,
+        candidates,
+        insecureRemote,
+        error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
+      })
+      if (insecureRemote) {
+        console.log("[Server] refresh:hint", {
+          id: server.id,
+          message: "Remote http:// host may be blocked by iOS ATS; prefer https:// for non-local hosts.",
+        })
+      }
+    }
+  }, [])
+
+  const refreshAllServerHealth = useCallback(() => {
+    const ids = serversRef.current.map((item) => item.id)
+    ids.forEach((id) => {
+      void refreshServerStatusAndSessions(id, false)
+    })
+  }, [refreshServerStatusAndSessions])
+
+  const selectServer = useCallback((id: string) => {
+    setActiveServerId(id)
+    setActiveSessionId(null)
+  }, [])
+
+  const selectSession = useCallback((id: string) => {
+    setActiveSessionId(id)
+  }, [])
+
+  const removeServer = useCallback((id: string) => {
+    setServers((prev) => prev.filter((item) => item.id !== id))
+    setActiveServerId((prev) => (prev === id ? null : prev))
+    if (activeServerIdRef.current === id) {
+      setActiveSessionId(null)
+    }
+  }, [])
+
+  const addServer = useCallback(
+    (serverURL: string, relayURL: string, relaySecretRaw: string, serverIDRaw?: string) => {
+      const raw = serverURL.trim()
+      if (!raw) return false
+
+      const normalized = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `http://${raw}`
+
+      const rawRelay = relayURL.trim()
+      const relayNormalizedRaw = rawRelay.length > 0 ? rawRelay : DEFAULT_RELAY_URL
+      const normalizedRelay =
+        relayNormalizedRaw.startsWith("http://") || relayNormalizedRaw.startsWith("https://")
+          ? relayNormalizedRaw
+          : `http://${relayNormalizedRaw}`
+
+      let parsed: URL
+      let relayParsed: URL
+      try {
+        parsed = new URL(normalized)
+        relayParsed = new URL(normalizedRelay)
+      } catch {
+        return false
+      }
+
+      const id = `srv-${Date.now()}`
+      const relaySecret = relaySecretRaw.trim()
+      const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null
+      const url = `${parsed.protocol}//${parsed.host}`
+      const inferredName =
+        parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname
+      const relay = `${relayParsed.protocol}//${relayParsed.host}`
+      const existing = serversRef.current.find(
+        (item) =>
+          item.url === url &&
+          item.relayURL === relay &&
+          item.relaySecret.trim() === relaySecret &&
+          (!serverID || item.serverID === serverID || item.serverID === null),
+      )
+
+      if (existing) {
+        if (serverID && existing.serverID !== serverID) {
+          setServers((prev) =>
+            prev.map((item) => (item.id === existing.id ? { ...item, serverID: serverID ?? item.serverID } : item)),
+          )
+        }
+
+        setActiveServerId(existing.id)
+        setActiveSessionId(null)
+        void refreshServerStatusAndSessions(existing.id)
+        return true
+      }
+
+      setServers((prev) => [
+        ...prev,
+        {
+          id,
+          name: inferredName,
+          url,
+          serverID,
+          relayURL: relay,
+          relaySecret,
+          status: "offline",
+          sessions: [],
+          sessionsLoading: false,
+        },
+      ])
+      setActiveServerId(id)
+      setActiveSessionId(null)
+      void refreshServerStatusAndSessions(id)
+      return true
+    },
+    [refreshServerStatusAndSessions],
+  )
+
+  const findServerForSession = useCallback(
+    async (sessionID: string, preferredServerID?: string | null): Promise<ServerItem | null> => {
+      if (!serversRef.current.length && !restoredRef.current) {
+        for (let attempt = 0; attempt < 20; attempt += 1) {
+          await new Promise((resolve) => setTimeout(resolve, 150))
+          if (serversRef.current.length > 0 || restoredRef.current) {
+            break
+          }
+        }
+      }
+
+      if (preferredServerID) {
+        const preferred = serversRef.current.find((server) => server.serverID === preferredServerID)
+        if (preferred?.sessions.some((session) => session.id === sessionID)) {
+          return preferred
+        }
+        if (preferred) {
+          await refreshServerStatusAndSessions(preferred.id)
+          const refreshed = serversRef.current.find((server) => server.id === preferred.id)
+          if (refreshed?.sessions.some((session) => session.id === sessionID)) {
+            return refreshed
+          }
+        }
+      }
+
+      const direct = serversRef.current.find((server) => server.sessions.some((session) => session.id === sessionID))
+      if (direct) return direct
+
+      const ids = serversRef.current.map((server) => server.id)
+      for (const id of ids) {
+        await refreshServerStatusAndSessions(id)
+        const matched = serversRef.current.find(
+          (server) => server.id === id && server.sessions.some((session) => session.id === sessionID),
+        )
+        if (matched) {
+          return matched
+        }
+      }
+
+      return null
+    },
+    [refreshServerStatusAndSessions],
+  )
+
+  return {
+    servers,
+    setServers,
+    serversRef,
+    activeServerId,
+    setActiveServerId,
+    activeServerIdRef,
+    activeSessionId,
+    setActiveSessionId,
+    activeSessionIdRef,
+    restoredRef,
+    refreshServerStatusAndSessions,
+    refreshAllServerHealth,
+    selectServer,
+    selectSession,
+    removeServer,
+    addServer,
+    findServerForSession,
+  }
+}

+ 169 - 0
packages/mobile-voice/src/lib/server-sessions.ts

@@ -0,0 +1,169 @@
+import * as FileSystem from "expo-file-system/legacy"
+
+export const DEFAULT_RELAY_URL = "https://apn.dev.opencode.ai"
+
+const SERVER_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-servers.json`
+
+export type SessionItem = {
+  id: string
+  title: string
+  updated: number
+}
+
+type ServerSessionPayload = {
+  id?: unknown
+  title?: unknown
+  time?: {
+    updated?: unknown
+  }
+}
+
+export type ServerItem = {
+  id: string
+  name: string
+  url: string
+  serverID: string | null
+  relayURL: string
+  relaySecret: string
+  status: "checking" | "online" | "offline"
+  sessions: SessionItem[]
+  sessionsLoading: boolean
+}
+
+type SavedServer = {
+  id: string
+  name: string
+  url: string
+  serverID: string | null
+  relayURL: string
+  relaySecret: string
+}
+
+type SavedState = {
+  servers: SavedServer[]
+  activeServerId: string | null
+  activeSessionId: string | null
+}
+
+export function parseSessionItems(payload: unknown): SessionItem[] {
+  if (!Array.isArray(payload)) return []
+
+  return payload
+    .filter((item): item is ServerSessionPayload => !!item && typeof item === "object")
+    .map((item) => ({
+      id: String(item.id ?? ""),
+      title: String(item.title ?? item.id ?? "Untitled session"),
+      updated: Number(item.time?.updated ?? 0),
+    }))
+    .filter((item) => item.id.length > 0)
+    .sort((a, b) => b.updated - a.updated)
+}
+
+function isCarrierGradeNat(hostname: string): boolean {
+  const match = /^100\.(\d{1,3})\./.exec(hostname)
+  if (!match) return false
+  const octet = Number(match[1])
+  return octet >= 64 && octet <= 127
+}
+
+export function looksLikeLocalHost(hostname: string): boolean {
+  return (
+    hostname === "127.0.0.1" ||
+    hostname === "::1" ||
+    hostname === "localhost" ||
+    hostname.endsWith(".local") ||
+    hostname.startsWith("10.") ||
+    hostname.startsWith("192.168.") ||
+    /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) ||
+    isCarrierGradeNat(hostname)
+  )
+}
+
+export function serverBases(input: string): string[] {
+  const base = input.replace(/\/+$/, "")
+  const list = [base]
+  try {
+    const url = new URL(base)
+    const local = looksLikeLocalHost(url.hostname)
+    const tailnet = url.hostname.endsWith(".ts.net")
+    const secure = `https://${url.host}`
+    const insecure = `http://${url.host}`
+    if (url.protocol === "http:" && !local) {
+      if (tailnet) {
+        list.unshift(secure)
+      } else {
+        list.push(secure)
+      }
+    } else if (url.protocol === "https:" && tailnet) {
+      list.push(insecure)
+    }
+  } catch {
+    // Keep original base only.
+  }
+  return [...new Set(list)]
+}
+
+function toSaved(servers: ServerItem[], activeServerId: string | null, activeSessionId: string | null): SavedState {
+  return {
+    servers: servers.map((item) => ({
+      id: item.id,
+      name: item.name,
+      url: item.url,
+      serverID: item.serverID,
+      relayURL: item.relayURL,
+      relaySecret: item.relaySecret,
+    })),
+    activeServerId,
+    activeSessionId,
+  }
+}
+
+function fromSaved(input: SavedState): {
+  servers: ServerItem[]
+  activeServerId: string | null
+  activeSessionId: string | null
+} {
+  const servers = input.servers.map((item) => ({
+    id: item.id,
+    name: item.name,
+    url: item.url,
+    serverID: item.serverID ?? null,
+    relayURL: item.relayURL,
+    relaySecret: item.relaySecret,
+    status: "checking" as const,
+    sessions: [] as SessionItem[],
+    sessionsLoading: false,
+  }))
+  const hasActive = input.activeServerId && servers.some((item) => item.id === input.activeServerId)
+  const activeServerId = hasActive ? input.activeServerId : (servers[0]?.id ?? null)
+  return {
+    servers,
+    activeServerId,
+    activeSessionId: hasActive ? input.activeSessionId : null,
+  }
+}
+
+export async function restoreServerState(): Promise<{
+  servers: ServerItem[]
+  activeServerId: string | null
+  activeSessionId: string | null
+} | null> {
+  try {
+    const data = await FileSystem.readAsStringAsync(SERVER_STATE_FILE)
+    if (!data) {
+      return null
+    }
+    return fromSaved(JSON.parse(data) as SavedState)
+  } catch {
+    return null
+  }
+}
+
+export function persistServerState(
+  servers: ServerItem[],
+  activeServerId: string | null,
+  activeSessionId: string | null,
+): Promise<void> {
+  const payload = toSaved(servers, activeServerId, activeSessionId)
+  return FileSystem.writeAsStringAsync(SERVER_STATE_FILE, JSON.stringify(payload))
+}

+ 2 - 0
packages/mobile-voice/tsconfig.json

@@ -2,6 +2,8 @@
   "extends": "expo/tsconfig.base",
   "compilerOptions": {
     "strict": true,
+    "baseUrl": ".",
+    "typeRoots": ["./node_modules/@types"],
     "paths": {
       "@/*": ["./src/*"],
       "@/assets/*": ["./assets/*"]

+ 12 - 0
packages/mobile-voice/tsconfig.typecheck.json

@@ -0,0 +1,12 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "paths": {
+      "@/*": ["./src/*"],
+      "@/assets/*": ["./assets/*"],
+      "react": ["./node_modules/@types/react"],
+      "react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"],
+      "react/jsx-dev-runtime": ["./node_modules/@types/react/jsx-dev-runtime"]
+    }
+  }
+}

Some files were not shown because too many files changed in this diff