Ryan Vogel 4 месяцев назад
Родитель
Сommit
776e61d1ec

+ 3 - 0
bun.lock

@@ -353,6 +353,7 @@
         "react-native-screens": "~4.23.0",
         "react-native-web": "~0.21.0",
         "react-native-worklets": "0.7.2",
+        "react-native-zeroconf": "0.14.0",
         "whisper.rn": "0.5.5",
       },
       "devDependencies": {
@@ -4764,6 +4765,8 @@
 
     "react-native-worklets": ["react-native-worklets@0.7.2", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "7.27.1", "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-classes": "7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", "@babel/plugin-transform-optional-chaining": "7.27.1", "@babel/plugin-transform-shorthand-properties": "7.27.1", "@babel/plugin-transform-template-literals": "7.27.1", "@babel/plugin-transform-unicode-regex": "7.27.1", "@babel/preset-typescript": "7.27.1", "convert-source-map": "2.0.0", "semver": "7.7.3" }, "peerDependencies": { "@babel/core": "*", "react": "*", "react-native": "*" } }, "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog=="],
 
+    "react-native-zeroconf": ["react-native-zeroconf@0.14.0", "", { "dependencies": { "events": "^3.0.0" }, "peerDependencies": { "react-native": ">=0.60" } }, "sha512-TqjORroaVZrBYLzk3YtviQy8lUl/iiMacknxixRYlmGaqgsv4LJXIYafpnvPa3y2SC4/qu2mvF8D1/VTTxylgQ=="],
+
     "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
 
     "react-remove-scroll": ["react-remove-scroll@2.5.5", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.3", "react-style-singleton": "^2.2.1", "tslib": "^2.1.0", "use-callback-ref": "^1.3.0", "use-sidecar": "^1.1.2" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw=="],

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

@@ -123,6 +123,46 @@ Run all commands from `packages/mobile-voice`.
 - Rebuild the dev client after native module additions or changes.
 - For optional native capability usage, prefer runtime fallback paths instead of hard crashes.
 
+## Expo Native Config (EAS)
+
+- Treat `packages/mobile-voice/app.json` as the source of truth for iOS native metadata in EAS cloud builds.
+- Do not rely on manual edits in `ios/mobilevoice/Info.plist`, entitlements files, or `PrivacyInfo.xcprivacy`; for this package they are generated outputs.
+- Keep generated native folders untracked in git (`/ios`, `/android`) to avoid mixed CNG/bare behavior during EAS builds.
+- Put App Store compliance and permission metadata in app config using these fields:
+  - `expo.ios.infoPlist` for Info.plist keys (usage strings, ATS, Bonjour, and related keys).
+  - `expo.ios.config.usesNonExemptEncryption` for export-compliance encryption declaration.
+  - `expo.ios.entitlements` for iOS entitlements.
+  - `expo.ios.privacyManifests` for Apple privacy manifest declarations.
+- Keep `app.json` entries explicit and review-friendly:
+  - Permission descriptions should be complete, product-specific sentences.
+  - Compliance keys should be set intentionally rather than relying on implicit defaults.
+  - Preserve existing JSON style in this package (concise arrays and stable key grouping).
+- After native config changes, verify resolved config with `bunx expo config --type prebuild --json` and check the resulting `ios` fields.
+
+Example shape:
+
+```json
+{
+  "expo": {
+    "ios": {
+      "infoPlist": {
+        "NSCameraUsageDescription": "...",
+        "NSMicrophoneUsageDescription": "..."
+      },
+      "config": {
+        "usesNonExemptEncryption": false
+      },
+      "entitlements": {
+        "com.apple.developer.kernel.extended-virtual-addressing": true
+      },
+      "privacyManifests": {
+        "NSPrivacyAccessedAPITypes": []
+      }
+    }
+  }
+}
+```
+
 ## Common Pitfalls
 
 - Black screen + "No script URL provided" often means a stale dev client binary.

+ 12 - 4
packages/mobile-voice/app.json

@@ -10,11 +10,17 @@
     "ios": {
       "icon": "./assets/images/icon.png",
       "bundleIdentifier": "com.anomalyco.mobilevoice",
+      "config": {
+        "usesNonExemptEncryption": false
+      },
       "entitlements": {
         "com.apple.developer.kernel.extended-virtual-addressing": true
       },
       "infoPlist": {
-        "NSMicrophoneUsageDescription": "This app needs microphone access for live speech-to-text dictation.",
+        "NSMicrophoneUsageDescription": "Control uses the microphone while you hold Record to turn your speech into text for an OpenCode session.",
+        "NSCameraUsageDescription": "Control uses the camera to scan the OpenCode pairing QR code shown on your computer.",
+        "NSLocalNetworkUsageDescription": "Control uses your local network to discover and connect to OpenCode servers running on your computer.",
+        "NSBonjourServices": ["_http._tcp."],
         "NSAppTransportSecurity": {
           "NSAllowsLocalNetworking": true,
           "NSExceptionDomains": {
@@ -23,8 +29,7 @@
               "NSExceptionAllowsInsecureHTTPLoads": true
             }
           }
-        },
-        "ITSAppUsesNonExemptEncryption": false
+        }
       }
     },
     "android": {
@@ -40,7 +45,10 @@
         "android.permission.FOREGROUND_SERVICE",
         "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
         "android.permission.RECORD_AUDIO",
-        "android.permission.MODIFY_AUDIO_SETTINGS"
+        "android.permission.MODIFY_AUDIO_SETTINGS",
+        "android.permission.ACCESS_NETWORK_STATE",
+        "android.permission.ACCESS_WIFI_STATE",
+        "android.permission.CHANGE_WIFI_MULTICAST_STATE"
       ],
       "predictiveBackGestureEnabled": false
     },

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

@@ -50,6 +50,7 @@
     "react-native-screens": "~4.23.0",
     "react-native-web": "~0.21.0",
     "react-native-worklets": "0.7.2",
+    "react-native-zeroconf": "0.14.0",
     "whisper.rn": "0.5.5"
   },
   "devDependencies": {

+ 275 - 58
packages/mobile-voice/src/app/index.tsx

@@ -37,8 +37,9 @@ import * as FileSystem from "expo-file-system/legacy"
 import { fetch as expoFetch } from "expo/fetch"
 import { buildPermissionCardModel } from "@/lib/pending-permissions"
 import { unregisterRelayDevice } from "@/lib/relay-client"
+import { useMdnsDiscovery } from "@/hooks/use-mdns-discovery"
 import { useMonitoring, type MonitorJob, type PermissionDecision } from "@/hooks/use-monitoring"
-import { looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions"
+import { DEFAULT_RELAY_URL, looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions"
 import { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications"
 
 const CONTROL_HEIGHT = 86
@@ -229,6 +230,20 @@ function formatSessionUpdated(updatedMs: number): string {
   }
 }
 
+function formatWorkingDirectory(directory?: string): string {
+  if (!directory) return "Not available"
+
+  if (directory.startsWith("/Users/")) {
+    const segments = directory.split("/")
+    if (segments.length >= 4) {
+      const tail = segments.slice(3).join("/")
+      return tail.length > 0 ? `~/${tail}` : "~"
+    }
+  }
+
+  return directory
+}
+
 type DropdownMode = "none" | "server" | "session"
 
 type Pair = {
@@ -638,10 +653,17 @@ export default function DictationScreen() {
     findServerForSession,
   } = useServerSessions()
 
+  const { discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery } = useMdnsDiscovery(
+    {
+      enabled: onboardingComplete && localNetworkPermissionState !== "denied",
+    },
+  )
+
   const {
     beginMonitoring,
     activePermissionRequest,
     devicePushToken,
+    latestAssistantContext,
     latestAssistantResponse,
     monitorJob,
     monitorStatus,
@@ -1755,7 +1777,29 @@ export default function DictationScreen() {
   const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…"
   const shouldShowSend = hasCompletedSession && hasTranscript && !hasPendingPermission
   const activeServer = servers.find((s) => s.id === activeServerId) ?? null
+  const discoveredServerOptions = useMemo(() => {
+    const saved = new Set(servers.map((server) => server.url.replace(/\/+$/, "")))
+    return discoveredServers.filter((server) => !saved.has(server.url.replace(/\/+$/, "")))
+  }, [discoveredServers, servers])
+  const discoveredServerEmptyLabel =
+    discoveryStatus === "error"
+      ? "Unable to discover local servers"
+      : discoveryStatus === "scanning"
+        ? "Scanning local network..."
+        : "No local servers found"
   const activeSession = activeServer?.sessions.find((s) => s.id === activeSessionId) ?? null
+  let currentSessionModelLabel = "Not available"
+  if (latestAssistantContext?.modelID) {
+    currentSessionModelLabel = latestAssistantContext.modelID
+    if (latestAssistantContext.providerID) {
+      currentSessionModelLabel = `${latestAssistantContext.providerID}/${latestAssistantContext.modelID}`
+    }
+  }
+  const currentSessionDirectory = latestAssistantContext?.workingDirectory ?? activeSession?.directory
+  const currentSessionUpdated = activeSession ? formatSessionUpdated(activeSession.updated) : ""
+  const sessionList = activeSession
+    ? (activeServer?.sessions ?? []).filter((session) => session.id !== activeSession.id)
+    : (activeServer?.sessions ?? [])
   const canSendToSession = !!activeServer && activeServer.status === "online" && !!activeSession
   const isReplyingToActivePermission =
     activePermissionRequest !== null && respondingPermissionID === activePermissionRequest.id
@@ -1956,8 +2000,8 @@ export default function DictationScreen() {
     ],
   }))
 
-  const menuRows =
-    effectiveDropdownMode === "server" ? Math.max(servers.length, 1) : Math.max(activeServer?.sessions.length ?? 0, 1)
+  const serverMenuRows = 2 + Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1)
+  const menuRows = effectiveDropdownMode === "server" ? serverMenuRows : Math.max(activeServer?.sessions.length ?? 0, 1)
   const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42
   const dropdownFooterExtraHeight =
     effectiveDropdownMode === "server"
@@ -2069,10 +2113,11 @@ export default function DictationScreen() {
       }
       if (next === "server") {
         refreshAllServerHealth()
+        refreshDiscovery()
       }
       return next
     })
-  }, [refreshAllServerHealth])
+  }, [refreshAllServerHealth, refreshDiscovery])
 
   const toggleSessionMenu = useCallback(() => {
     if (!activeServer || activeServer.status !== "online") return
@@ -2158,6 +2203,20 @@ export default function DictationScreen() {
     [devicePushToken, removeServer, serversRef],
   )
 
+  const handleConnectDiscoveredServer = useCallback(
+    (url: string) => {
+      const ok = addServer(url, DEFAULT_RELAY_URL, "")
+      if (!ok) {
+        Alert.alert("Could not add server", "The discovered server could not be added. Try scanning the QR code.")
+        return
+      }
+
+      setDropdownMode("none")
+      void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
+    },
+    [addServer],
+  )
+
   const handleStartScan = useCallback(async () => {
     scanLockRef.current = false
     const current =
@@ -2467,9 +2526,9 @@ export default function DictationScreen() {
   }
   const onboardingSteps = [
     {
-      title: "Allow mic access.",
+      title: "Microphone access.",
       body: "Control only listens while you hold the record button.",
-      primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone..." : "Allow microphone",
+      primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone access..." : "Continue",
       primaryDisabled: microphonePermissionState === "pending",
       secondaryLabel: "Continue without granting",
       visualTag: "MIC",
@@ -2480,7 +2539,7 @@ export default function DictationScreen() {
     {
       title: "Turn on notifications.",
       body: "Get alerts when your OpenCode run finishes, fails, or needs your attention.",
-      primaryLabel: notificationPermissionState === "pending" ? "Requesting notifications..." : "Allow notifications",
+      primaryLabel: notificationPermissionState === "pending" ? "Requesting notification access..." : "Continue",
       primaryDisabled: notificationPermissionState === "pending",
       secondaryLabel: "Continue without granting",
       visualTag: "PUSH",
@@ -2489,9 +2548,9 @@ export default function DictationScreen() {
       visualTagStyle: styles.onboardingVisualTagNotifications,
     },
     {
-      title: "Enable local network.",
+      title: "Local network access.",
       body: "This lets Control discover your machine on the same network.",
-      primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network..." : "Allow local network",
+      primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network access..." : "Continue",
       primaryDisabled: localNetworkPermissionState === "pending",
       secondaryLabel: "Continue without granting",
       visualTag: "LAN",
@@ -2501,10 +2560,10 @@ export default function DictationScreen() {
     },
     {
       title: "Pair your computer.",
-      body: "Start `opencode serve` on your computer, then scan the QR code to pair.",
-      primaryLabel: "Scan OpenCode QR",
+      body: "Start `opencode serve --mdns` on your computer. Control can discover nearby servers automatically, or you can scan a QR code.",
+      primaryLabel: "Scan OpenCode QR (optional)",
       primaryDisabled: false,
-      secondaryLabel: "I will do this later",
+      secondaryLabel: "Skip and use discovery",
       visualTag: "PAIR",
       visualSurfaceStyle: styles.onboardingVisualSurfacePair,
       visualOrbStyle: styles.onboardingVisualOrbPair,
@@ -2705,52 +2764,133 @@ export default function DictationScreen() {
               bounces={false}
             >
               {effectiveDropdownMode === "server" ? (
-                servers.length === 0 ? (
-                  <Text style={styles.serverEmptyText}>No servers yet</Text>
-                ) : (
-                  servers.map((server) => (
-                    <Pressable
-                      key={server.id}
-                      onPress={() => handleSelectServer(server.id)}
-                      style={({ pressed }) => [styles.serverRow, pressed && styles.serverRowPressed]}
-                    >
-                      <View
-                        style={[
-                          styles.serverStatusDot,
-                          server.status === "online" ? styles.serverStatusActive : styles.serverStatusOffline,
+                <>
+                  <Text style={styles.serverGroupLabel}>Saved:</Text>
+
+                  {servers.length === 0 ? (
+                    <Text style={[styles.serverEmptyText, styles.serverGroupEmptyText]}>No saved servers</Text>
+                  ) : (
+                    servers.map((server) => (
+                      <Pressable
+                        key={server.id}
+                        onPress={() => handleSelectServer(server.id)}
+                        style={({ pressed }) => [styles.serverRow, pressed && styles.serverRowPressed]}
+                      >
+                        <View
+                          style={[
+                            styles.serverStatusDot,
+                            server.status === "online" ? styles.serverStatusActive : styles.serverStatusOffline,
+                          ]}
+                        />
+                        <Text style={styles.serverNameText}>{server.name}</Text>
+                        <Pressable onPress={() => handleDeleteServer(server.id)} hitSlop={8}>
+                          <Text style={styles.serverDeleteIcon}>✕</Text>
+                        </Pressable>
+                      </Pressable>
+                    ))
+                  )}
+
+                  <View style={styles.serverGroupHeaderRow}>
+                    <Text style={styles.serverGroupLabel}>Discovered:</Text>
+                    {discoveryStatus === "scanning" ? <ActivityIndicator size="small" color="#8790A3" /> : null}
+                  </View>
+
+                  {!discoveryAvailable ? (
+                    <Text style={[styles.serverEmptyText, styles.serverGroupEmptyText]}>
+                      Discovery unavailable in this build
+                    </Text>
+                  ) : discoveredServerOptions.length === 0 ? (
+                    <Text style={[styles.serverEmptyText, styles.serverGroupEmptyText]}>
+                      {discoveredServerEmptyLabel}
+                    </Text>
+                  ) : (
+                    discoveredServerOptions.map((server, index) => (
+                      <Pressable
+                        key={server.id}
+                        onPress={() => handleConnectDiscoveredServer(server.url)}
+                        style={({ pressed }) => [
+                          styles.serverRow,
+                          index === discoveredServerOptions.length - 1 && styles.serverRowLast,
+                          pressed && styles.serverRowPressed,
                         ]}
-                      />
-                      <Text style={styles.serverNameText}>{server.name}</Text>
-                      <Pressable onPress={() => handleDeleteServer(server.id)} hitSlop={8}>
-                        <Text style={styles.serverDeleteIcon}>✕</Text>
+                      >
+                        <View style={[styles.serverStatusDot, styles.serverStatusChecking]} />
+                        <View style={styles.discoveredServerCopy}>
+                          <Text style={styles.serverNameText} numberOfLines={1}>
+                            {server.name}
+                          </Text>
+                          <Text style={styles.discoveredServerMeta} numberOfLines={1} ellipsizeMode="middle">
+                            {server.url}
+                          </Text>
+                        </View>
+                        <Text style={styles.discoveredServerAction}>Connect</Text>
                       </Pressable>
-                    </Pressable>
-                  ))
-                )
+                    ))
+                  )}
+
+                  {discoveryStatus === "error" && discoveryError ? (
+                    <Text style={styles.discoveryErrorText} numberOfLines={1} ellipsizeMode="tail">
+                      {discoveryError}
+                    </Text>
+                  ) : null}
+                </>
               ) : activeServer ? (
-                activeServer.sessions.length === 0 ? (
-                  activeServer.sessionsLoading ? null : (
-                    <Text style={styles.serverEmptyText}>No sessions available</Text>
-                  )
-                ) : (
-                  activeServer.sessions.map((session, index) => (
-                    <Pressable
-                      key={session.id}
-                      onPress={() => handleSelectSession(session.id)}
-                      style={({ pressed }) => [
-                        styles.serverRow,
-                        index === activeServer.sessions.length - 1 && styles.serverRowLast,
-                        pressed && styles.serverRowPressed,
-                      ]}
-                    >
-                      <View style={[styles.serverStatusDot, styles.serverStatusActive]} />
-                      <Text style={styles.serverNameText} numberOfLines={1}>
-                        {session.title}
+                <>
+                  {activeSession ? (
+                    <>
+                      <View style={styles.currentSessionSummary}>
+                        <Text style={styles.currentSessionLabel}>Current session</Text>
+
+                        <View style={styles.currentSessionMetaRow}>
+                          <Text style={styles.currentSessionMetaKey}>Working dir</Text>
+                          <Text style={styles.currentSessionMetaValue} numberOfLines={1} ellipsizeMode="middle">
+                            {formatWorkingDirectory(currentSessionDirectory)}
+                          </Text>
+                        </View>
+
+                        <View style={styles.currentSessionMetaRow}>
+                          <Text style={styles.currentSessionMetaKey}>Model</Text>
+                          <Text style={styles.currentSessionMetaValue} numberOfLines={1} ellipsizeMode="middle">
+                            {currentSessionModelLabel}
+                          </Text>
+                        </View>
+
+                        <View style={styles.currentSessionMetaRow}>
+                          <Text style={styles.currentSessionMetaKey}>Updated</Text>
+                          <Text style={styles.currentSessionMetaValue}>{currentSessionUpdated || "Just now"}</Text>
+                        </View>
+                      </View>
+
+                      <View style={styles.currentSessionDivider} />
+                    </>
+                  ) : null}
+
+                  {sessionList.length === 0 ? (
+                    activeServer.sessionsLoading ? null : (
+                      <Text style={styles.serverEmptyText}>
+                        {activeSession ? "No other sessions available" : "No sessions available"}
                       </Text>
-                      <Text style={styles.sessionUpdatedText}>{formatSessionUpdated(session.updated)}</Text>
-                    </Pressable>
-                  ))
-                )
+                    )
+                  ) : (
+                    sessionList.map((session, index) => (
+                      <Pressable
+                        key={session.id}
+                        onPress={() => handleSelectSession(session.id)}
+                        style={({ pressed }) => [
+                          styles.serverRow,
+                          index === sessionList.length - 1 && styles.serverRowLast,
+                          pressed && styles.serverRowPressed,
+                        ]}
+                      >
+                        <View style={[styles.serverStatusDot, styles.serverStatusActive]} />
+                        <Text style={styles.serverNameText} numberOfLines={1}>
+                          {session.title}
+                        </Text>
+                        <Text style={styles.sessionUpdatedText}>{formatSessionUpdated(session.updated)}</Text>
+                      </Pressable>
+                    ))
+                  )}
+                </>
               ) : (
                 <Text style={styles.serverEmptyText}>Select a server first</Text>
               )}
@@ -3756,12 +3896,68 @@ const styles = StyleSheet.create({
   dropdownListContent: {
     paddingBottom: 2,
   },
+  currentSessionSummary: {
+    paddingHorizontal: 4,
+    paddingTop: 2,
+    paddingBottom: 8,
+    gap: 5,
+  },
+  currentSessionLabel: {
+    color: "#A3ACC0",
+    fontSize: 12,
+    fontWeight: "700",
+    letterSpacing: 0.4,
+    textTransform: "uppercase",
+  },
+  currentSessionMetaRow: {
+    flexDirection: "row",
+    alignItems: "center",
+    gap: 8,
+  },
+  currentSessionMetaKey: {
+    width: 74,
+    color: "#7C8599",
+    fontSize: 12,
+    fontWeight: "600",
+  },
+  currentSessionMetaValue: {
+    flex: 1,
+    color: "#D7DCE6",
+    fontSize: 13,
+    fontWeight: "500",
+  },
+  currentSessionDivider: {
+    width: "100%",
+    height: 1,
+    backgroundColor: "#222733",
+    marginBottom: 4,
+  },
+  serverGroupHeaderRow: {
+    flexDirection: "row",
+    alignItems: "center",
+    justifyContent: "space-between",
+    marginTop: 8,
+  },
+  serverGroupLabel: {
+    color: "#8F97AA",
+    fontSize: 12,
+    fontWeight: "700",
+    letterSpacing: 0.4,
+    textTransform: "uppercase",
+    paddingHorizontal: 4,
+    paddingVertical: 4,
+  },
   serverEmptyText: {
     color: "#6F7686",
-    fontSize: 13,
+    fontSize: 14,
     textAlign: "center",
     paddingVertical: 10,
   },
+  serverGroupEmptyText: {
+    textAlign: "left",
+    paddingHorizontal: 4,
+    paddingVertical: 8,
+  },
   serverRow: {
     flexDirection: "row",
     alignItems: "center",
@@ -3794,15 +3990,36 @@ const styles = StyleSheet.create({
   serverNameText: {
     flex: 1,
     color: "#D6DAE4",
-    fontSize: 14,
+    fontSize: 16,
     fontWeight: "500",
   },
   sessionUpdatedText: {
     color: "#8E96A8",
-    fontSize: 12,
+    fontSize: 14,
     fontWeight: "500",
     marginLeft: 8,
   },
+  discoveredServerCopy: {
+    flex: 1,
+    gap: 2,
+  },
+  discoveredServerMeta: {
+    color: "#818A9E",
+    fontSize: 12,
+    fontWeight: "500",
+  },
+  discoveredServerAction: {
+    color: "#B9C2D8",
+    fontSize: 13,
+    fontWeight: "700",
+  },
+  discoveryErrorText: {
+    color: "#7D8598",
+    fontSize: 11,
+    fontWeight: "500",
+    paddingHorizontal: 4,
+    paddingTop: 4,
+  },
   serverDeleteIcon: {
     color: "#8C93A3",
     fontSize: 15,
@@ -3845,7 +4062,7 @@ const styles = StyleSheet.create({
   sessionMenuActionText: {
     flex: 1,
     color: "#D6DAE4",
-    fontSize: 14,
+    fontSize: 16,
     fontWeight: "500",
   },
   statusLeft: {

+ 281 - 0
packages/mobile-voice/src/hooks/use-mdns-discovery.ts

@@ -0,0 +1,281 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { Platform } from "react-native"
+
+type ZeroconfService = {
+  name?: unknown
+  fullName?: unknown
+  host?: unknown
+  port?: unknown
+  addresses?: unknown
+}
+
+type ZeroconfInstance = {
+  scan: (type?: string, protocol?: string, domain?: string, implType?: string) => void
+  stop: (implType?: string) => void
+  removeDeviceListeners: () => void
+  getServices: () => Record<string, ZeroconfService>
+  on: (event: string, listener: (...args: unknown[]) => void) => void
+}
+
+type ZeroconfModule = {
+  default: new () => ZeroconfInstance
+  ImplType?: {
+    DNSSD?: string
+  }
+}
+
+export type DiscoveredServer = {
+  id: string
+  name: string
+  host: string
+  port: number
+  url: string
+}
+
+type DiscoveryStatus = "idle" | "scanning" | "error"
+
+type UseMdnsDiscoveryInput = {
+  enabled: boolean
+}
+
+function toErrorMessage(error: unknown): string {
+  if (error instanceof Error && error.message.trim().length > 0) {
+    return error.message
+  }
+
+  const next = String(error ?? "")
+  return next.trim().length > 0 ? next : "Unknown discovery error"
+}
+
+function cleanHost(input: string): string {
+  const trimmed = input.trim().replace(/\.$/, "")
+  if (!trimmed) return ""
+  if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
+    return trimmed.slice(1, -1)
+  }
+  return trimmed
+}
+
+function isIPv4(input: string): boolean {
+  return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(input)
+}
+
+function hostTier(input: string): number {
+  if (input.endsWith(".local")) return 0
+  if (isIPv4(input)) {
+    if (input === "127.0.0.1") return 4
+    if (input.startsWith("10.") || input.startsWith("192.168.") || /^172\.(1[6-9]|2\d|3[0-1])\./.test(input)) {
+      return 1
+    }
+    if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(input)) {
+      return 1
+    }
+    return 2
+  }
+  if (input.includes(":")) return 3
+  return 2
+}
+
+function formatHostForURL(input: string): string {
+  return input.includes(":") ? `[${input}]` : input
+}
+
+function isOpenCodeService(service: ZeroconfService): boolean {
+  if (typeof service.name === "string" && service.name.toLowerCase().startsWith("opencode-")) {
+    return true
+  }
+
+  if (typeof service.fullName === "string" && service.fullName.toLowerCase().includes("opencode-")) {
+    return true
+  }
+
+  return false
+}
+
+function parseService(service: ZeroconfService): DiscoveredServer | null {
+  const port = typeof service.port === "number" ? service.port : Number(service.port)
+  if (!Number.isFinite(port) || port <= 0) {
+    return null
+  }
+
+  const hosts = new Set<string>()
+
+  if (typeof service.host === "string") {
+    const host = cleanHost(service.host)
+    if (host.length > 0) {
+      hosts.add(host)
+    }
+  }
+
+  if (Array.isArray(service.addresses)) {
+    for (const address of service.addresses) {
+      if (typeof address !== "string") continue
+      const host = cleanHost(address)
+      if (host.length > 0) {
+        hosts.add(host)
+      }
+    }
+  }
+
+  const sortedHosts = [...hosts].sort((a, b) => hostTier(a) - hostTier(b))
+  const host = sortedHosts[0]
+  if (!host) {
+    return null
+  }
+
+  const name = typeof service.name === "string" && service.name.trim().length > 0 ? service.name.trim() : host
+  const fullName =
+    typeof service.fullName === "string" && service.fullName.trim().length > 0
+      ? service.fullName.trim()
+      : `${name}:${port}`
+  const url = `http://${formatHostForURL(host)}:${port}`
+
+  return {
+    id: `${fullName}|${url}`,
+    name,
+    host,
+    port,
+    url,
+  }
+}
+
+export function useMdnsDiscovery(input: UseMdnsDiscoveryInput) {
+  const [discoveredServers, setDiscoveredServers] = useState<DiscoveredServer[]>([])
+  const [discoveryStatus, setDiscoveryStatus] = useState<DiscoveryStatus>("idle")
+  const [discoveryError, setDiscoveryError] = useState<string | null>(null)
+  const [discoveryAvailable, setDiscoveryAvailable] = useState(Platform.OS !== "web")
+  const startScanRef = useRef<(() => void) | null>(null)
+
+  const refreshDiscovery = useCallback(() => {
+    startScanRef.current?.()
+  }, [])
+
+  useEffect(() => {
+    if (!input.enabled) {
+      startScanRef.current = null
+      setDiscoveredServers([])
+      setDiscoveryStatus("idle")
+      setDiscoveryError(null)
+      return
+    }
+
+    if (Platform.OS === "web") {
+      setDiscoveryAvailable(false)
+      setDiscoveryStatus("idle")
+      setDiscoveryError(null)
+      return
+    }
+
+    let active = true
+    let zeroconf: ZeroconfInstance | null = null
+    let androidImplType: string | undefined
+
+    const rebuildServices = () => {
+      if (!active || !zeroconf) return
+      const values = Object.values(zeroconf.getServices() ?? {})
+      const next = new Map<string, DiscoveredServer>()
+
+      for (const value of values) {
+        if (!isOpenCodeService(value)) continue
+        const parsed = parseService(value)
+        if (!parsed) continue
+        if (!next.has(parsed.url)) {
+          next.set(parsed.url, parsed)
+        }
+      }
+
+      setDiscoveredServers(
+        [...next.values()].sort((a, b) => {
+          const nameOrder = a.name.localeCompare(b.name)
+          if (nameOrder !== 0) return nameOrder
+          return a.url.localeCompare(b.url)
+        }),
+      )
+    }
+
+    const startScan = () => {
+      if (!active || !zeroconf) return
+
+      try {
+        zeroconf.stop(androidImplType)
+      } catch {
+        // noop
+      }
+
+      try {
+        zeroconf.scan("http", "tcp", "local.", androidImplType)
+        setDiscoveryStatus("scanning")
+        setDiscoveryError(null)
+      } catch (error) {
+        setDiscoveryStatus("error")
+        setDiscoveryError(toErrorMessage(error))
+      }
+    }
+
+    startScanRef.current = startScan
+
+    void import("react-native-zeroconf")
+      .then((module) => {
+        if (!active) return
+
+        const mod = module as ZeroconfModule
+        const Zeroconf = mod.default
+        if (typeof Zeroconf !== "function") {
+          setDiscoveryAvailable(false)
+          setDiscoveryStatus("error")
+          setDiscoveryError("mDNS module unavailable")
+          return
+        }
+
+        zeroconf = new Zeroconf()
+        androidImplType = Platform.OS === "android" ? (mod.ImplType?.DNSSD ?? "DNSSD") : undefined
+        setDiscoveryAvailable(true)
+
+        zeroconf.on("resolved", rebuildServices)
+        zeroconf.on("remove", rebuildServices)
+        zeroconf.on("update", rebuildServices)
+        zeroconf.on("error", (error) => {
+          if (!active) return
+          setDiscoveryStatus("error")
+          setDiscoveryError(toErrorMessage(error))
+        })
+
+        startScan()
+      })
+      .catch((error) => {
+        if (!active) return
+        setDiscoveryAvailable(false)
+        setDiscoveryStatus("error")
+        setDiscoveryError(toErrorMessage(error))
+      })
+
+    return () => {
+      active = false
+      startScanRef.current = null
+      if (!zeroconf) return
+
+      try {
+        zeroconf.stop(androidImplType)
+      } catch {
+        // noop
+      }
+
+      try {
+        zeroconf.removeDeviceListeners()
+      } catch {
+        // noop
+      }
+    }
+  }, [input.enabled])
+
+  return useMemo(
+    () => ({
+      discoveredServers,
+      discoveryStatus,
+      discoveryError,
+      discoveryAvailable,
+      refreshDiscovery,
+    }),
+    [discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery],
+  )
+}

+ 67 - 8
packages/mobile-voice/src/hooks/use-monitoring.ts

@@ -121,6 +121,7 @@ export function useMonitoring({
   const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null)
   const [monitorStatus, setMonitorStatus] = useState("")
   const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
+  const [latestAssistantContext, setLatestAssistantContext] = useState<LatestAssistantContext | null>(null)
   const [pendingPermissions, setPendingPermissions] = useState<PendingPermissionRequest[]>([])
   const [replyingPermissionID, setReplyingPermissionID] = useState<string | null>(null)
   const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
@@ -248,18 +249,20 @@ export function useMonitoring({
         }
 
         const payload = (await response.json()) as unknown
-        const text = findLatestAssistantCompletionText(payload)
+        const latest = findLatestAssistantCompletion(payload)
 
         if (latestAssistantRequestRef.current !== requestID) return
         if (activeSessionIdRef.current !== sessionID) return
-        setLatestAssistantResponse(text)
-        if (text) {
+        setLatestAssistantResponse(latest.text)
+        setLatestAssistantContext(latest.context)
+        if (latest.text) {
           setAgentStateDismissed(false)
         }
       } catch {
         if (latestAssistantRequestRef.current !== requestID) return
         if (activeSessionIdRef.current !== sessionID) return
         setLatestAssistantResponse("")
+        setLatestAssistantContext(null)
       }
     },
     [activeSessionIdRef, setAgentStateDismissed],
@@ -443,6 +446,7 @@ export function useMonitoring({
 
   useEffect(() => {
     setLatestAssistantResponse("")
+    setLatestAssistantContext(null)
     setPendingPermissions([])
     setAgentStateDismissed(false)
     if (!activeServerId || !activeSessionId) return
@@ -787,6 +791,7 @@ export function useMonitoring({
     monitorStatus,
     setMonitorStatus,
     latestAssistantResponse,
+    latestAssistantContext,
     activePermissionRequest,
     pendingPermissionCount: pendingPermissions.length,
     respondingPermissionID: replyingPermissionID,
@@ -798,6 +803,10 @@ export function useMonitoring({
 type SessionMessageInfo = {
   role?: unknown
   time?: unknown
+  modelID?: unknown
+  providerID?: unknown
+  path?: unknown
+  agent?: unknown
 }
 
 type SessionMessagePart = {
@@ -810,6 +819,18 @@ type SessionMessagePayload = {
   parts?: unknown
 }
 
+type LatestAssistantContext = {
+  providerID: string | null
+  modelID: string | null
+  workingDirectory: string | null
+  agent: string | null
+}
+
+type LatestAssistantSnapshot = {
+  text: string
+  context: LatestAssistantContext | null
+}
+
 function cleanTranscriptText(text: string): string {
   return text.replace(/[ \t]+$/gm, "").trimEnd()
 }
@@ -818,8 +839,39 @@ function cleanSessionText(text: string): string {
   return cleanTranscriptText(text).trimStart()
 }
 
-function findLatestAssistantCompletionText(payload: unknown): string {
-  if (!Array.isArray(payload)) return ""
+function maybeString(value: unknown): string | null {
+  if (typeof value !== "string") return null
+  const trimmed = value.trim()
+  return trimmed.length > 0 ? trimmed : null
+}
+
+function extractAssistantContext(info: SessionMessageInfo): LatestAssistantContext | null {
+  const providerID = maybeString(info.providerID)
+  const modelID = maybeString(info.modelID)
+  const pathValue = info.path
+  const pathRecord = pathValue && typeof pathValue === "object" ? (pathValue as { cwd?: unknown }) : null
+  const workingDirectory = maybeString(pathRecord?.cwd)
+  const agent = maybeString(info.agent)
+
+  if (!providerID && !modelID && !workingDirectory && !agent) {
+    return null
+  }
+
+  return {
+    providerID,
+    modelID,
+    workingDirectory,
+    agent,
+  }
+}
+
+function findLatestAssistantCompletion(payload: unknown): LatestAssistantSnapshot {
+  if (!Array.isArray(payload)) {
+    return {
+      text: "",
+      context: null,
+    }
+  }
 
   for (let index = payload.length - 1; index >= 0; index -= 1) {
     const candidate = payload[index] as SessionMessagePayload
@@ -832,6 +884,7 @@ function findLatestAssistantCompletionText(payload: unknown): string {
     const time = info.time as { completed?: unknown } | undefined
     if (!time || typeof time !== "object") continue
     if (typeof time.completed !== "number") continue
+    const context = extractAssistantContext(info)
 
     const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
     const text = parts
@@ -840,10 +893,16 @@ function findLatestAssistantCompletionText(payload: unknown): string {
       .filter((part) => part.length > 0)
       .join("\n\n")
 
-    if (text.length > 0) {
-      return text
+    if (text.length > 0 || context) {
+      return {
+        text,
+        context,
+      }
     }
   }
 
-  return ""
+  return {
+    text: "",
+    context: null,
+  }
 }

+ 4 - 0
packages/mobile-voice/src/types/css-modules.d.ts

@@ -0,0 +1,4 @@
+declare module "*.module.css" {
+  const classes: Record<string, string>
+  export default classes
+}

+ 23 - 0
packages/mobile-voice/src/types/react-native-zeroconf.d.ts

@@ -0,0 +1,23 @@
+declare module "react-native-zeroconf" {
+  export const ImplType: {
+    NSD: string
+    DNSSD: string
+  }
+
+  export type ZeroconfService = {
+    name?: string
+    fullName?: string
+    host?: string
+    port?: number
+    addresses?: string[]
+    txt?: Record<string, string>
+  }
+
+  export default class Zeroconf {
+    scan(type?: string, protocol?: string, domain?: string, implType?: string): void
+    stop(implType?: string): void
+    removeDeviceListeners(): void
+    getServices(): Record<string, ZeroconfService>
+    on(event: string, listener: (...args: unknown[]) => void): this
+  }
+}

+ 0 - 18
packages/opencode/src/server/push-relay.ts

@@ -294,15 +294,6 @@ async function post(input: { type: Type; sessionID: string }) {
 
   const content = await notify(input)
 
-  console.log("[ APN RELAY ] posting event", {
-    serverID: next.pair.serverID,
-    relayURL: next.relayURL,
-    secretHash: secretHash(next.relaySecret),
-    type: input.type,
-    sessionID: input.sessionID,
-    title: content.title,
-  })
-
   log.info("[ APN RELAY ] posting event", {
     serverID: next.pair.serverID,
     relayURL: next.relayURL,
@@ -328,15 +319,6 @@ async function post(input: { type: Type; sessionID: string }) {
   })
     .then(async (res) => {
       if (res.ok) {
-        console.log("[ APN RELAY ] relay accepted event", {
-          status: res.status,
-          serverID: next.pair.serverID,
-          secretHash: secretHash(next.relaySecret),
-          type: input.type,
-          sessionID: input.sessionID,
-          title: content.title,
-        })
-
         log.info("[ APN RELAY ] relay accepted event", {
           status: res.status,
           serverID: next.pair.serverID,