Просмотр исходного кода

Apply PR #19545: feat: opencode remote control + opencode serve dependencies

opencode-agent[bot] 14 часов назад
Родитель
Сommit
de2787d362
100 измененных файлов с 11795 добавлено и 64 удалено
  1. 27 0
      .github/workflows/porter-app-5534-apn-relay.yml
  2. 38 42
      AGENTS.md
  3. 331 17
      bun.lock
  4. 0 0
      eas.json
  5. 11 0
      packages/apn-relay/.env.example
  6. 106 0
      packages/apn-relay/AGENTS.md
  7. 14 0
      packages/apn-relay/Dockerfile
  8. 46 0
      packages/apn-relay/README.md
  9. 17 0
      packages/apn-relay/drizzle.config.ts
  10. 27 0
      packages/apn-relay/package.json
  11. 185 0
      packages/apn-relay/src/apns.ts
  12. 28 0
      packages/apn-relay/src/check.ts
  13. 11 0
      packages/apn-relay/src/db.ts
  14. 47 0
      packages/apn-relay/src/env.ts
  15. 5 0
      packages/apn-relay/src/hash.ts
  16. 448 0
      packages/apn-relay/src/index.ts
  17. 35 0
      packages/apn-relay/src/schema.sql.ts
  18. 34 0
      packages/apn-relay/src/setup.ts
  19. 8 0
      packages/apn-relay/tsconfig.json
  20. 15 2
      packages/app/src/components/dialog-settings.tsx
  21. 166 0
      packages/app/src/components/settings-pair.tsx
  22. 15 0
      packages/app/src/i18n/en.ts
  23. 9 2
      packages/app/src/pages/layout.tsx
  24. 4 0
      packages/desktop-electron/src/main/env.d.ts
  25. 25 1
      packages/desktop-electron/src/main/server.ts
  26. 7 0
      packages/mobile-voice/.cursor/settings.json
  27. 43 0
      packages/mobile-voice/.gitignore
  28. 183 0
      packages/mobile-voice/AGENTS.md
  29. 39 0
      packages/mobile-voice/README.md
  30. 101 0
      packages/mobile-voice/app.json
  31. BIN
      packages/mobile-voice/assets/android-icon-background.png
  32. BIN
      packages/mobile-voice/assets/android-icon-foreground.png
  33. BIN
      packages/mobile-voice/assets/android-icon-monochrome.png
  34. BIN
      packages/mobile-voice/assets/control-icon.png
  35. 3 0
      packages/mobile-voice/assets/expo.icon/Assets/expo-symbol 2.svg
  36. BIN
      packages/mobile-voice/assets/expo.icon/Assets/grid.png
  37. 40 0
      packages/mobile-voice/assets/expo.icon/icon.json
  38. BIN
      packages/mobile-voice/assets/favicon.png
  39. BIN
      packages/mobile-voice/assets/icon.png
  40. BIN
      packages/mobile-voice/assets/images/android-icon-background.png
  41. BIN
      packages/mobile-voice/assets/images/android-icon-foreground.png
  42. BIN
      packages/mobile-voice/assets/images/android-icon-monochrome.png
  43. BIN
      packages/mobile-voice/assets/images/expo-badge-white.png
  44. BIN
      packages/mobile-voice/assets/images/expo-badge.png
  45. BIN
      packages/mobile-voice/assets/images/expo-logo.png
  46. BIN
      packages/mobile-voice/assets/images/favicon.png
  47. BIN
      packages/mobile-voice/assets/images/icon.png
  48. BIN
      packages/mobile-voice/assets/images/logo-glow.png
  49. BIN
      packages/mobile-voice/assets/images/react-logo.png
  50. BIN
      packages/mobile-voice/assets/images/[email protected]
  51. BIN
      packages/mobile-voice/assets/images/[email protected]
  52. BIN
      packages/mobile-voice/assets/images/splash-icon.png
  53. BIN
      packages/mobile-voice/assets/images/tabIcons/explore.png
  54. BIN
      packages/mobile-voice/assets/images/tabIcons/[email protected]
  55. BIN
      packages/mobile-voice/assets/images/tabIcons/[email protected]
  56. BIN
      packages/mobile-voice/assets/images/tabIcons/home.png
  57. BIN
      packages/mobile-voice/assets/images/tabIcons/[email protected]
  58. BIN
      packages/mobile-voice/assets/images/tabIcons/[email protected]
  59. BIN
      packages/mobile-voice/assets/images/tutorial-web.png
  60. BIN
      packages/mobile-voice/assets/new-control.png
  61. BIN
      packages/mobile-voice/assets/sounds/alert.wav
  62. BIN
      packages/mobile-voice/assets/sounds/complete.wav
  63. BIN
      packages/mobile-voice/assets/sounds/send-whoosh.mp3
  64. BIN
      packages/mobile-voice/assets/splash-icon.png
  65. 328 0
      packages/mobile-voice/docs/live-activity-plan.md
  66. 27 0
      packages/mobile-voice/eas.json
  67. 52 0
      packages/mobile-voice/eslint.config.js
  68. 8 0
      packages/mobile-voice/metro.config.js
  69. 7 0
      packages/mobile-voice/notes.md
  70. 66 0
      packages/mobile-voice/package.json
  71. 88 0
      packages/mobile-voice/refactor.md
  72. 328 0
      packages/mobile-voice/relay/opencode-relay.mjs
  73. 114 0
      packages/mobile-voice/scripts/reset-project.js
  74. 22 0
      packages/mobile-voice/src/app/_layout.tsx
  75. 5503 0
      packages/mobile-voice/src/app/index.tsx
  76. 6 0
      packages/mobile-voice/src/components/animated-icon.module.css
  77. 132 0
      packages/mobile-voice/src/components/animated-icon.tsx
  78. 108 0
      packages/mobile-voice/src/components/animated-icon.web.tsx
  79. 7 0
      packages/mobile-voice/src/components/app-tabs.tsx
  80. 7 0
      packages/mobile-voice/src/components/app-tabs.web.tsx
  81. 25 0
      packages/mobile-voice/src/components/external-link.tsx
  82. 35 0
      packages/mobile-voice/src/components/hint-row.tsx
  83. 73 0
      packages/mobile-voice/src/components/themed-text.tsx
  84. 16 0
      packages/mobile-voice/src/components/themed-view.tsx
  85. 65 0
      packages/mobile-voice/src/components/ui/collapsible.tsx
  86. 44 0
      packages/mobile-voice/src/components/web-badge.tsx
  87. 65 0
      packages/mobile-voice/src/constants/theme.ts
  88. 9 0
      packages/mobile-voice/src/global.css
  89. 1 0
      packages/mobile-voice/src/hooks/use-color-scheme.ts
  90. 29 0
      packages/mobile-voice/src/hooks/use-color-scheme.web.ts
  91. 278 0
      packages/mobile-voice/src/hooks/use-mdns-discovery.ts
  92. 1051 0
      packages/mobile-voice/src/hooks/use-monitoring.ts
  93. 494 0
      packages/mobile-voice/src/hooks/use-server-sessions.ts
  94. 14 0
      packages/mobile-voice/src/hooks/use-theme.ts
  95. 65 0
      packages/mobile-voice/src/lib/opencode-events.ts
  96. 256 0
      packages/mobile-voice/src/lib/pending-permissions.ts
  97. 63 0
      packages/mobile-voice/src/lib/relay-client.ts
  98. 181 0
      packages/mobile-voice/src/lib/server-sessions.ts
  99. 72 0
      packages/mobile-voice/src/lib/sse.ts
  100. 88 0
      packages/mobile-voice/src/notifications/monitoring-notifications.ts

+ 27 - 0
.github/workflows/porter-app-5534-apn-relay.yml

@@ -0,0 +1,27 @@
+"on":
+    push:
+        branches:
+            - opencode-remote-voice
+name: Deploy to apn-relay
+jobs:
+    porter-deploy:
+        runs-on: ubuntu-latest
+        steps:
+            - name: Checkout code
+              uses: actions/checkout@v4
+            - name: Set Github tag
+              id: vars
+              run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+            - name: Setup porter
+              uses: porter-dev/[email protected]
+            - name: Deploy stack
+              timeout-minutes: 30
+              run: porter apply
+              env:
+                PORTER_APP_NAME: apn-relay
+                PORTER_CLUSTER: "5534"
+                PORTER_DEPLOYMENT_TARGET_ID: d60e67f5-b0a6-4275-8ed6-3cebaf092147
+                PORTER_HOST: https://dashboard.porter.run
+                PORTER_PROJECT: "18525"
+                PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
+                PORTER_TOKEN: ${{ secrets.PORTER_APP_18525_975734319 }}

+ 38 - 42
AGENTS.md

@@ -1,12 +1,8 @@
-- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
-- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
-- The default branch in this repo is `dev`.
-- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
-- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
+# OpenCode Monorepo Agent Guide
 
-## Style Guide
+This file is for coding agents working in `/Users/ryanvogel/dev/opencode`.
 
-### General Principles
+## Scope And Precedence
 
 - Keep things in one function unless composable or reusable
 - Avoid `try`/`catch` where possible
@@ -56,48 +52,48 @@ else foo = 2
 
 ### Control Flow
 
-Avoid `else` statements. Prefer early returns.
+- Prefer early returns over nested `else` blocks.
+- Keep functions focused; split only when it improves reuse or readability.
 
-```ts
-// Good
-function foo() {
-  if (condition) return 1
-  return 2
-}
+### Error Handling
 
-// Bad
-function foo() {
-  if (condition) return 1
-  else return 2
-}
-```
+- Fail with actionable messages.
+- Avoid swallowing errors silently.
+- Log enough context to debug production issues (IDs, env, status), but never secrets.
+- In UI code, degrade gracefully for missing capabilities.
 
-### Schema Definitions (Drizzle)
+### Data / DB
 
-Use snake_case for field names so column names don't need to be redefined as strings.
+- For Drizzle schema, use snake_case fields and columns.
+- Keep migration and schema changes minimal and explicit.
+- Follow package-specific DB guidance in `packages/opencode/AGENTS.md`.
 
-```ts
-// Good
-const table = sqliteTable("session", {
-  id: text().primaryKey(),
-  project_id: text().notNull(),
-  created_at: integer().notNull(),
-})
+### Testing Philosophy
 
-// Bad
-const table = sqliteTable("session", {
-  id: text("id").primaryKey(),
-  projectID: text("project_id").notNull(),
-  createdAt: integer("created_at").notNull(),
-})
-```
+- Prefer testing real behavior over mocks.
+- Add regression tests for bug fixes where practical.
+- Keep fixtures small and focused.
+
+## Agent Workflow Tips
+
+- Read existing code paths before introducing new abstractions.
+- Match local patterns first; do not impose a new style per file.
+- If a package has its own `AGENTS.md`, review it before editing.
+- For OpenCode Effect services, follow `packages/opencode/AGENTS.md` strictly.
+
+## Known Operational Notes
+
+- `packages/app/AGENTS.md` says: never restart app/server processes during that package's debugging workflow.
+- `packages/app/AGENTS.md` also documents local backend+web split for UI work.
+- `packages/opencode/AGENTS.md` contains mandatory Effect and database conventions.
 
-## Testing
+## Regeneration / Special Scripts
 
-- Avoid mocks as much as possible
-- Test actual implementation, do not duplicate logic into tests
-- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
+- Regenerate JS SDK with: `./packages/sdk/js/script/build.ts`
 
-## Type Checking
+## Quick Checklist Before Finishing
 
-- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
+- Ran relevant package checks.
+- Updated docs/config when behavior changed.
+- Avoided committing unrelated files.
+- Kept edits minimal and aligned with local conventions.

Разница между файлами не показана из-за своего большого размера
+ 331 - 17
bun.lock



+ 11 - 0
packages/apn-relay/.env.example

@@ -0,0 +1,11 @@
+PORT=8787
+
+DATABASE_HOST=
+DATABASE_USERNAME=
+DATABASE_PASSWORD=
+DATABASE_NAME=main
+
+APNS_TEAM_ID=
+APNS_KEY_ID=
+APNS_PRIVATE_KEY=
+APNS_DEFAULT_BUNDLE_ID=com.anomalyco.mobilevoice

+ 106 - 0
packages/apn-relay/AGENTS.md

@@ -0,0 +1,106 @@
+# apn-relay Agent Guide
+
+This file defines package-specific guidance for agents working in `packages/apn-relay`.
+
+## Scope And Precedence
+
+- Follow root `AGENTS.md` first.
+- This file provides stricter package-level conventions for relay service work.
+- If future local guides are added, closest guide wins.
+
+## Project Overview
+
+- Minimal APNs relay service (Hono + Bun + PlanetScale via Drizzle).
+- Core routes:
+  - `GET /health`
+  - `GET /`
+  - `POST /v1/device/register`
+  - `POST /v1/device/unregister`
+  - `POST /v1/event`
+
+## Commands
+
+Run all commands from `packages/apn-relay`.
+
+- Install deps: `bun install`
+- Start relay locally: `bun run dev`
+- Typecheck: `bun run typecheck`
+- DB connectivity check: `bun run db:check`
+
+## Build / Test Expectations
+
+- There is no dedicated package test script currently.
+- Required validation for behavior changes:
+  - `bun run typecheck`
+  - `bun run db:check` when DB/env changes are involved
+  - manual endpoint verification against `/health`, `/v1/device/register`, `/v1/event`
+
+## Single-Test Guidance
+
+- No single-test command exists for this package today.
+- For focused checks, run endpoint-level manual tests against a local dev server.
+
+## Code Style Guidelines
+
+### Formatting / Structure
+
+- Keep handlers compact and explicit.
+- Prefer small local helpers for repeated route logic.
+- Avoid broad refactors when a targeted fix is enough.
+
+### Types / Validation
+
+- Validate request bodies with `zod` at route boundaries.
+- Keep payload and DB row shapes explicit and close to usage.
+- Avoid `any`; narrow unknown input immediately after parsing.
+
+### Naming
+
+- Follow existing concise naming in this package (`reg`, `unreg`, `evt`, `row`, `key`).
+- For DB columns, keep snake_case alignment with schema.
+
+### Error Handling
+
+- Return clear JSON errors for invalid input.
+- Keep handler failures observable via `app.onError` and structured logs.
+- Do not leak secrets in responses or logs.
+
+### Logging
+
+- Log delivery lifecycle at key checkpoints:
+  - registration/unregistration attempts
+  - event fanout start/end
+  - APNs send failures and retries
+- Mask sensitive values; prefer token suffixes and metadata.
+
+### APNs Environment Rules
+
+- Keep APNs env explicit per registration (`sandbox` / `production`).
+- For `BadEnvironmentKeyInToken`, retry once with flipped env and persist correction.
+- Avoid infinite retry loops; one retry max per delivery attempt.
+
+## Database Conventions
+
+- Schema is in `src/schema.sql.ts`.
+- Keep table/column names snake_case.
+- Maintain index naming consistency with existing schema.
+- For upserts, update only fields required by current behavior.
+
+## API Behavior Expectations
+
+- `register`/`unregister` must be idempotent.
+- `event` should return success envelope even when no devices are registered.
+- Delivery logs should capture per-attempt result and error payload.
+
+## Operational Notes
+
+- Ensure `APNS_PRIVATE_KEY` supports escaped newline format (`\n`) and raw multiline.
+- Validate that `APNS_DEFAULT_BUNDLE_ID` matches mobile app bundle identifier.
+- Avoid coupling route behavior to deployment platform specifics.
+
+## Before Finishing
+
+- Run `bun run typecheck`.
+- If DB/env behavior changed, run `bun run db:check`.
+- Manually exercise affected endpoints.
+- Confirm logs are useful and secret-safe.

+ 14 - 0
packages/apn-relay/Dockerfile

@@ -0,0 +1,14 @@
+FROM oven/bun:1.3.11-alpine
+
+WORKDIR /app
+
+COPY package.json ./
+COPY tsconfig.json ./
+COPY drizzle.config.ts ./
+RUN bun install --production
+
+COPY src ./src
+
+EXPOSE 8787
+
+CMD ["bun", "run", "src/index.ts"]

+ 46 - 0
packages/apn-relay/README.md

@@ -0,0 +1,46 @@
+# APN Relay
+
+Minimal APNs relay for OpenCode mobile background notifications.
+
+## What it does
+
+- Registers iOS device tokens for a shared secret.
+- Receives OpenCode event posts (`complete`, `permission`, `error`).
+- Sends APNs notifications to mapped devices.
+- Stores delivery rows in PlanetScale.
+
+## Routes
+
+- `GET /health`
+- `GET /` (simple dashboard)
+- `POST /v1/device/register`
+- `POST /v1/device/unregister`
+- `POST /v1/event`
+
+## Environment
+
+Use `.env.example` as a starting point.
+
+- `DATABASE_HOST`
+- `DATABASE_USERNAME`
+- `DATABASE_PASSWORD`
+- `APNS_TEAM_ID`
+- `APNS_KEY_ID`
+- `APNS_PRIVATE_KEY`
+- `APNS_DEFAULT_BUNDLE_ID`
+
+## Run locally
+
+```bash
+bun install
+bun run src/index.ts
+```
+
+## Docker
+
+Build from this directory:
+
+```bash
+docker build -t apn-relay .
+docker run --rm -p 8787:8787 --env-file .env apn-relay
+```

+ 17 - 0
packages/apn-relay/drizzle.config.ts

@@ -0,0 +1,17 @@
+import { defineConfig } from "drizzle-kit"
+
+export default defineConfig({
+  out: "./migration",
+  strict: true,
+  schema: ["./src/**/*.sql.ts"],
+  dialect: "mysql",
+  dbCredentials: {
+    host: process.env.DATABASE_HOST ?? "",
+    user: process.env.DATABASE_USERNAME ?? "",
+    password: process.env.DATABASE_PASSWORD ?? "",
+    database: process.env.DATABASE_NAME ?? "main",
+    ssl: {
+      rejectUnauthorized: false,
+    },
+  },
+})

+ 27 - 0
packages/apn-relay/package.json

@@ -0,0 +1,27 @@
+{
+  "$schema": "https://json.schemastore.org/package.json",
+  "name": "@opencode-ai/apn-relay",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "license": "MIT",
+  "scripts": {
+    "dev": "bun run src/index.ts",
+    "db:check": "bun run --env-file .env src/check.ts",
+    "typecheck": "tsgo --noEmit"
+  },
+  "dependencies": {
+    "@planetscale/database": "1.19.0",
+    "drizzle-orm": "1.0.0-beta.19-d95b7a4",
+    "hono": "4.10.7",
+    "jose": "6.0.11",
+    "zod": "4.1.8"
+  },
+  "devDependencies": {
+    "@tsconfig/bun": "1.0.9",
+    "@types/bun": "1.3.11",
+    "@typescript/native-preview": "7.0.0-dev.20251207.1",
+    "drizzle-kit": "1.0.0-beta.19-d95b7a4",
+    "typescript": "5.8.2"
+  }
+}

+ 185 - 0
packages/apn-relay/src/apns.ts

@@ -0,0 +1,185 @@
+import { connect } from "node:http2"
+import { SignJWT, importPKCS8 } from "jose"
+import { env } from "./env"
+
+export type PushEnv = "sandbox" | "production"
+
+type PushInput = {
+  token: string
+  bundle: string
+  env: PushEnv
+  title: string
+  body: string
+  data: Record<string, unknown>
+}
+
+type PushResult = {
+  ok: boolean
+  code: number
+  error?: string
+}
+
+function tokenSuffix(input: string) {
+  return input.length > 8 ? input.slice(-8) : input
+}
+
+let jwt = ""
+let exp = 0
+let pk: Awaited<ReturnType<typeof importPKCS8>> | undefined
+
+function host(input: PushEnv) {
+  if (input === "sandbox") return "api.sandbox.push.apple.com"
+  return "api.push.apple.com"
+}
+
+function key() {
+  if (env.APNS_PRIVATE_KEY.includes("\\n")) return env.APNS_PRIVATE_KEY.replace(/\\n/g, "\n")
+  return env.APNS_PRIVATE_KEY
+}
+
+async function sign() {
+  if (!pk) pk = await importPKCS8(key(), "ES256")
+  const now = Math.floor(Date.now() / 1000)
+  if (jwt && now < exp) return jwt
+  jwt = await new SignJWT({})
+    .setProtectedHeader({ alg: "ES256", kid: env.APNS_KEY_ID })
+    .setIssuer(env.APNS_TEAM_ID)
+    .setIssuedAt(now)
+    .sign(pk)
+  exp = now + 50 * 60
+  return jwt
+}
+
+function post(input: {
+  host: string
+  token: string
+  auth: string
+  bundle: string
+  payload: string
+}): Promise<{ code: number; body: string }> {
+  return new Promise((resolve, reject) => {
+    const cli = connect(`https://${input.host}`)
+    let done = false
+    let code = 0
+    let body = ""
+
+    const stop = (fn: () => void) => {
+      if (done) return
+      done = true
+      fn()
+    }
+
+    cli.on("error", (err) => {
+      stop(() => reject(err))
+      cli.close()
+    })
+
+    const req = cli.request({
+      ":method": "POST",
+      ":path": `/3/device/${input.token}`,
+      authorization: `bearer ${input.auth}`,
+      "apns-topic": input.bundle,
+      "apns-push-type": "alert",
+      "apns-priority": "10",
+      "content-type": "application/json",
+    })
+
+    req.setEncoding("utf8")
+    req.on("response", (headers) => {
+      code = Number(headers[":status"] ?? 0)
+    })
+    req.on("data", (chunk) => {
+      body += chunk
+    })
+    req.on("end", () => {
+      stop(() => resolve({ code, body }))
+      cli.close()
+    })
+    req.on("error", (err) => {
+      stop(() => reject(err))
+      cli.close()
+    })
+    req.end(input.payload)
+  })
+}
+
+export async function send(input: PushInput): Promise<PushResult> {
+  const apnsHost = host(input.env)
+  const suffix = tokenSuffix(input.token)
+
+  console.log("[ APN RELAY ] push:start", {
+    env: input.env,
+    host: apnsHost,
+    bundle: input.bundle,
+    tokenSuffix: suffix,
+  })
+
+  const auth = await sign().catch((err) => {
+    return `error:${String(err)}`
+  })
+  if (auth.startsWith("error:")) {
+    console.log("[ APN RELAY ] push:auth-failed", {
+      env: input.env,
+      host: apnsHost,
+      bundle: input.bundle,
+      tokenSuffix: suffix,
+      error: auth,
+    })
+    return {
+      ok: false,
+      code: 0,
+      error: auth,
+    }
+  }
+
+  const payload = JSON.stringify({
+    aps: {
+      alert: {
+        title: input.title,
+        body: input.body,
+      },
+      sound: "alert.wav",
+    },
+    ...input.data,
+  })
+
+  const out = await post({
+    host: apnsHost,
+    token: input.token,
+    auth,
+    bundle: input.bundle,
+    payload,
+  }).catch((err) => ({
+    code: 0,
+    body: String(err),
+  }))
+
+  if (out.code === 200) {
+    console.log("[ APN RELAY ] push:sent", {
+      env: input.env,
+      host: apnsHost,
+      bundle: input.bundle,
+      tokenSuffix: suffix,
+      code: out.code,
+    })
+    return {
+      ok: true,
+      code: 200,
+    }
+  }
+
+  console.log("[ APN RELAY ] push:failed", {
+    env: input.env,
+    host: apnsHost,
+    bundle: input.bundle,
+    tokenSuffix: suffix,
+    code: out.code,
+    error: out.body,
+  })
+
+  return {
+    ok: false,
+    code: out.code,
+    error: out.body,
+  }
+}

+ 28 - 0
packages/apn-relay/src/check.ts

@@ -0,0 +1,28 @@
+import { sql } from "drizzle-orm"
+import { db } from "./db"
+import { env } from "./env"
+import { delivery_log, device_registration } from "./schema.sql"
+import { setup } from "./setup"
+
+async function run() {
+  console.log(`[apn-relay] DB host: ${env.DATABASE_HOST}`)
+
+  await db.execute(sql`SELECT 1`)
+  console.log("[apn-relay] DB connection OK")
+
+  await setup()
+  console.log("[apn-relay] Setup migration OK")
+
+  const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
+  const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
+
+  console.log(`[apn-relay] device_registration rows: ${Number(a?.value ?? 0)}`)
+  console.log(`[apn-relay] delivery_log rows: ${Number(b?.value ?? 0)}`)
+  console.log("[apn-relay] DB check passed")
+}
+
+run().catch((err) => {
+  console.error("[apn-relay] DB check failed")
+  console.error(err)
+  process.exit(1)
+})

+ 11 - 0
packages/apn-relay/src/db.ts

@@ -0,0 +1,11 @@
+import { Client } from "@planetscale/database"
+import { drizzle } from "drizzle-orm/planetscale-serverless"
+import { env } from "./env"
+
+const client = new Client({
+  host: env.DATABASE_HOST,
+  username: env.DATABASE_USERNAME,
+  password: env.DATABASE_PASSWORD,
+})
+
+export const db = drizzle({ client })

+ 47 - 0
packages/apn-relay/src/env.ts

@@ -0,0 +1,47 @@
+import { z } from "zod"
+
+const bad = new Set(["undefined", "null"])
+const txt = z
+  .string()
+  .transform((input) => input.trim())
+  .refine((input) => input.length > 0 && !bad.has(input.toLowerCase()))
+
+const schema = z.object({
+  PORT: z.coerce.number().int().positive().default(8787),
+  DATABASE_HOST: txt,
+  DATABASE_USERNAME: txt,
+  DATABASE_PASSWORD: txt,
+  APNS_TEAM_ID: txt,
+  APNS_KEY_ID: txt,
+  APNS_PRIVATE_KEY: txt,
+  APNS_DEFAULT_BUNDLE_ID: txt,
+})
+
+const req = [
+  "DATABASE_HOST",
+  "DATABASE_USERNAME",
+  "DATABASE_PASSWORD",
+  "APNS_TEAM_ID",
+  "APNS_KEY_ID",
+  "APNS_PRIVATE_KEY",
+  "APNS_DEFAULT_BUNDLE_ID",
+] as const
+
+const out = schema.safeParse(process.env)
+
+if (!out.success) {
+  const miss = req.filter((key) => !process.env[key]?.trim())
+  const bad = out.error.issues
+    .map((item) => item.path[0])
+    .filter((key): key is string => typeof key === "string")
+    .filter((key) => !miss.includes(key as (typeof req)[number]))
+
+  console.error("[apn-relay] Invalid startup configuration")
+  if (miss.length) console.error(`[apn-relay] Missing required env vars: ${miss.join(", ")}`)
+  if (bad.length) console.error(`[apn-relay] Invalid env vars: ${Array.from(new Set(bad)).join(", ")}`)
+  console.error("[apn-relay] Check .env.example and restart")
+
+  throw new Error("Startup configuration invalid")
+}
+
+export const env = out.data

+ 5 - 0
packages/apn-relay/src/hash.ts

@@ -0,0 +1,5 @@
+import { createHash } from "node:crypto"
+
+export function hash(input: string) {
+  return createHash("sha256").update(input).digest("hex")
+}

+ 448 - 0
packages/apn-relay/src/index.ts

@@ -0,0 +1,448 @@
+import { randomUUID } from "node:crypto"
+import { and, desc, eq, sql } from "drizzle-orm"
+import { Hono } from "hono"
+import { z } from "zod"
+import { send } from "./apns"
+import { db } from "./db"
+import { env } from "./env"
+import { hash } from "./hash"
+import { delivery_log, device_registration } from "./schema.sql"
+import { setup } from "./setup"
+
+function bad(input?: string) {
+  if (!input) return false
+  return input.includes("BadEnvironmentKeyInToken")
+}
+
+function flip(input: "sandbox" | "production") {
+  if (input === "sandbox") return "production"
+  return "sandbox"
+}
+
+function tail(input: string) {
+  return input.slice(-8)
+}
+
+function esc(input: unknown) {
+  return String(input ?? "")
+    .replaceAll("&", "&amp;")
+    .replaceAll("<", "&lt;")
+    .replaceAll(">", "&gt;")
+    .replaceAll('"', "&quot;")
+    .replaceAll("'", "&#39;")
+}
+
+function fmt(input: number) {
+  return new Date(input).toISOString()
+}
+
+const reg = z.object({
+  secret: z.string().min(1),
+  deviceToken: z.string().min(1),
+  bundleId: z.string().min(1).optional(),
+  apnsEnv: z.enum(["sandbox", "production"]).default("production"),
+})
+
+const unreg = z.object({
+  secret: z.string().min(1),
+  deviceToken: z.string().min(1),
+})
+
+const evt = z.object({
+  secret: z.string().min(1),
+  serverID: z.string().min(1).optional(),
+  eventType: z.enum(["complete", "permission", "error"]),
+  sessionID: z.string().min(1),
+  title: z.string().min(1).optional(),
+  body: z.string().min(1).optional(),
+})
+
+function title(input: z.infer<typeof evt>["eventType"]) {
+  if (input === "complete") return "Session complete"
+  if (input === "permission") return "Action needed"
+  return "Session error"
+}
+
+function body(input: z.infer<typeof evt>["eventType"]) {
+  if (input === "complete") return "OpenCode finished your session."
+  if (input === "permission") return "OpenCode needs your permission decision."
+  return "OpenCode reported an error for your session."
+}
+
+const app = new Hono()
+
+app.onError((err, c) => {
+  return c.json(
+    {
+      ok: false,
+      error: err.message,
+    },
+    500,
+  )
+})
+
+app.notFound((c) => {
+  return c.json(
+    {
+      ok: false,
+      error: "Not found",
+    },
+    404,
+  )
+})
+
+app.get("/health", async (c) => {
+  const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
+  const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
+  return c.json({
+    ok: true,
+    devices: Number(a?.value ?? 0),
+    deliveries: Number(b?.value ?? 0),
+  })
+})
+
+app.get("/", async (c) => {
+  const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
+  const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
+  const devices = await db.select().from(device_registration).orderBy(desc(device_registration.updated_at)).limit(100)
+  const byBundle = await db
+    .select({
+      bundle: device_registration.bundle_id,
+      env: device_registration.apns_env,
+      value: sql<number>`count(*)`,
+    })
+    .from(device_registration)
+    .groupBy(device_registration.bundle_id, device_registration.apns_env)
+    .orderBy(desc(sql<number>`count(*)`))
+  const rows = await db.select().from(delivery_log).orderBy(desc(delivery_log.created_at)).limit(20)
+
+  const html = `<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <title>APN Relay</title>
+    <style>
+      body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 24px; color: #111827; }
+      h1 { margin: 0 0 12px 0; }
+      h2 { margin: 22px 0 10px 0; font-size: 16px; }
+      .stats { display: flex; gap: 16px; margin: 0 0 18px 0; }
+      .card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px; min-width: 160px; }
+      .muted { color: #6b7280; font-size: 12px; }
+      .small { font-size: 11px; color: #6b7280; }
+      table { border-collapse: collapse; width: 100%; }
+      th, td { border: 1px solid #e5e7eb; text-align: left; padding: 8px; font-size: 12px; }
+      th { background: #f9fafb; }
+    </style>
+  </head>
+  <body>
+    <h1>APN Relay</h1>
+    <p class="muted">MVP dashboard</p>
+    <div class="stats">
+      <div class="card">
+        <div class="muted">Registered devices</div>
+        <div>${Number(a?.value ?? 0)}</div>
+      </div>
+      <div class="card">
+        <div class="muted">Delivery log rows</div>
+        <div>${Number(b?.value ?? 0)}</div>
+      </div>
+    </div>
+    <h2>Registered devices</h2>
+    <p class="small">Most recent 100 registrations. Token values are masked to suffix only.</p>
+    <table>
+      <thead>
+        <tr>
+          <th>updated</th>
+          <th>created</th>
+          <th>token suffix</th>
+          <th>env</th>
+          <th>bundle</th>
+          <th>secret hash</th>
+        </tr>
+      </thead>
+      <tbody>
+        ${
+          devices.length
+            ? devices
+                .map(
+                  (row) => `<tr>
+          <td>${esc(fmt(row.updated_at))}</td>
+          <td>${esc(fmt(row.created_at))}</td>
+          <td>${esc(tail(row.device_token))}</td>
+          <td>${esc(row.apns_env)}</td>
+          <td>${esc(row.bundle_id)}</td>
+          <td>${esc(`${row.secret_hash.slice(0, 12)}…`)}</td>
+        </tr>`,
+                )
+                .join("")
+            : `<tr><td colspan="6" class="muted">No devices registered.</td></tr>`
+        }
+      </tbody>
+    </table>
+    <h2>Bundle breakdown</h2>
+    <table>
+      <thead>
+        <tr>
+          <th>bundle</th>
+          <th>env</th>
+          <th>count</th>
+        </tr>
+      </thead>
+      <tbody>
+        ${
+          byBundle.length
+            ? byBundle
+                .map(
+                  (row) => `<tr>
+          <td>${esc(row.bundle)}</td>
+          <td>${esc(row.env)}</td>
+          <td>${esc(Number(row.value ?? 0))}</td>
+        </tr>`,
+                )
+                .join("")
+            : `<tr><td colspan="3" class="muted">No device data.</td></tr>`
+        }
+      </tbody>
+    </table>
+    <h2>Recent deliveries</h2>
+    <table>
+      <thead>
+        <tr>
+          <th>time</th>
+          <th>event</th>
+          <th>session</th>
+          <th>status</th>
+          <th>error</th>
+        </tr>
+      </thead>
+      <tbody>
+        ${rows
+          .map(
+            (row) => `<tr>
+          <td>${esc(fmt(row.created_at))}</td>
+          <td>${esc(row.event_type)}</td>
+          <td>${esc(row.session_id)}</td>
+          <td>${esc(row.status)}</td>
+          <td>${esc(row.error ?? "")}</td>
+        </tr>`,
+          )
+          .join("")}
+      </tbody>
+    </table>
+  </body>
+</html>`
+
+  return c.html(html)
+})
+
+app.post("/v1/device/register", async (c) => {
+  const raw = await c.req.json().catch(() => undefined)
+  const check = reg.safeParse(raw)
+  if (!check.success) {
+    return c.json(
+      {
+        ok: false,
+        error: "Invalid request body",
+      },
+      400,
+    )
+  }
+
+  const now = Date.now()
+  const key = hash(check.data.secret)
+  const row = {
+    id: randomUUID(),
+    secret_hash: key,
+    device_token: check.data.deviceToken,
+    bundle_id: check.data.bundleId ?? env.APNS_DEFAULT_BUNDLE_ID,
+    apns_env: check.data.apnsEnv,
+    created_at: now,
+    updated_at: now,
+  }
+
+  console.log("[relay] register", {
+    token: tail(row.device_token),
+    env: row.apns_env,
+    bundle: row.bundle_id,
+    secretHash: `${key.slice(0, 12)}...`,
+  })
+
+  await db
+    .insert(device_registration)
+    .values(row)
+    .onDuplicateKeyUpdate({
+      set: {
+        bundle_id: row.bundle_id,
+        apns_env: row.apns_env,
+        updated_at: now,
+      },
+    })
+
+  return c.json({ ok: true })
+})
+
+app.post("/v1/device/unregister", async (c) => {
+  const raw = await c.req.json().catch(() => undefined)
+  const check = unreg.safeParse(raw)
+  if (!check.success) {
+    return c.json(
+      {
+        ok: false,
+        error: "Invalid request body",
+      },
+      400,
+    )
+  }
+
+  const key = hash(check.data.secret)
+
+  console.log("[relay] unregister", {
+    token: tail(check.data.deviceToken),
+    secretHash: `${key.slice(0, 12)}...`,
+  })
+
+  await db
+    .delete(device_registration)
+    .where(and(eq(device_registration.secret_hash, key), eq(device_registration.device_token, check.data.deviceToken)))
+
+  return c.json({ ok: true })
+})
+
+app.post("/v1/event", async (c) => {
+  const raw = await c.req.json().catch(() => undefined)
+  const check = evt.safeParse(raw)
+  if (!check.success) {
+    return c.json(
+      {
+        ok: false,
+        error: "Invalid request body",
+      },
+      400,
+    )
+  }
+
+  const key = hash(check.data.secret)
+  const list = await db.select().from(device_registration).where(eq(device_registration.secret_hash, key))
+  console.log("[relay] event", {
+    type: check.data.eventType,
+    serverID: check.data.serverID,
+    session: check.data.sessionID,
+    secretHash: `${key.slice(0, 12)}...`,
+    devices: list.length,
+  })
+  if (!list.length) {
+    const [total] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
+    console.log("[relay] event:no-matching-devices", {
+      type: check.data.eventType,
+      serverID: check.data.serverID,
+      session: check.data.sessionID,
+      secretHash: `${key.slice(0, 12)}...`,
+      totalDevices: Number(total?.value ?? 0),
+    })
+
+    return c.json({
+      ok: true,
+      sent: 0,
+      failed: 0,
+    })
+  }
+
+  const out = await Promise.all(
+    list.map(async (row) => {
+      const env = row.apns_env === "sandbox" ? "sandbox" : "production"
+      const payload = {
+        token: row.device_token,
+        bundle: row.bundle_id,
+        title: check.data.title ?? title(check.data.eventType),
+        body: check.data.body ?? body(check.data.eventType),
+        data: {
+          serverID: check.data.serverID,
+          eventType: check.data.eventType,
+          sessionID: check.data.sessionID,
+        },
+      }
+      const first = await send({ ...payload, env })
+      if (first.ok || !bad(first.error)) {
+        if (!first.ok) {
+          console.log("[relay] send:error", {
+            token: tail(row.device_token),
+            env,
+            error: first.error,
+          })
+        }
+        return first
+      }
+
+      const alt = flip(env)
+      console.log("[relay] send:retry-env", {
+        token: tail(row.device_token),
+        from: env,
+        to: alt,
+      })
+      const second = await send({ ...payload, env: alt })
+      if (!second.ok) {
+        console.log("[relay] send:error", {
+          token: tail(row.device_token),
+          env: alt,
+          error: second.error,
+        })
+        return second
+      }
+
+      await db
+        .update(device_registration)
+        .set({ apns_env: alt, updated_at: Date.now() })
+        .where(
+          and(
+            eq(device_registration.secret_hash, row.secret_hash),
+            eq(device_registration.device_token, row.device_token),
+          ),
+        )
+
+      console.log("[relay] send:env-updated", {
+        token: tail(row.device_token),
+        env: alt,
+      })
+      return second
+    }),
+  )
+
+  const now = Date.now()
+  await db.insert(delivery_log).values(
+    out.map((item) => ({
+      id: randomUUID(),
+      secret_hash: key,
+      event_type: check.data.eventType,
+      session_id: check.data.sessionID,
+      status: item.ok ? "sent" : "failed",
+      error: item.error,
+      created_at: now,
+    })),
+  )
+
+  const sent = out.filter((item) => item.ok).length
+  console.log("[relay] event:done", {
+    type: check.data.eventType,
+    session: check.data.sessionID,
+    sent,
+    failed: out.length - sent,
+  })
+  return c.json({
+    ok: true,
+    sent,
+    failed: out.length - sent,
+  })
+})
+
+await setup()
+
+if (import.meta.main) {
+  Bun.serve({
+    port: env.PORT,
+    fetch: app.fetch,
+  })
+  console.log(`apn-relay listening on http://0.0.0.0:${env.PORT}`)
+}
+
+export { app }

+ 35 - 0
packages/apn-relay/src/schema.sql.ts

@@ -0,0 +1,35 @@
+import { bigint, index, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
+
+export const device_registration = mysqlTable(
+  "device_registration",
+  {
+    id: varchar("id", { length: 36 }).primaryKey(),
+    secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
+    device_token: varchar("device_token", { length: 255 }).notNull(),
+    bundle_id: varchar("bundle_id", { length: 255 }).notNull(),
+    apns_env: varchar("apns_env", { length: 16 }).notNull().default("production"),
+    created_at: bigint("created_at", { mode: "number" }).notNull(),
+    updated_at: bigint("updated_at", { mode: "number" }).notNull(),
+  },
+  (table) => [
+    uniqueIndex("device_registration_secret_token_idx").on(table.secret_hash, table.device_token),
+    index("device_registration_secret_hash_idx").on(table.secret_hash),
+  ],
+)
+
+export const delivery_log = mysqlTable(
+  "delivery_log",
+  {
+    id: varchar("id", { length: 36 }).primaryKey(),
+    secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
+    event_type: varchar("event_type", { length: 32 }).notNull(),
+    session_id: varchar("session_id", { length: 255 }).notNull(),
+    status: varchar("status", { length: 16 }).notNull(),
+    error: varchar("error", { length: 1024 }),
+    created_at: bigint("created_at", { mode: "number" }).notNull(),
+  },
+  (table) => [
+    index("delivery_log_secret_hash_idx").on(table.secret_hash),
+    index("delivery_log_created_at_idx").on(table.created_at),
+  ],
+)

+ 34 - 0
packages/apn-relay/src/setup.ts

@@ -0,0 +1,34 @@
+import { sql } from "drizzle-orm"
+import { db } from "./db"
+
+export async function setup() {
+  await db.execute(sql`
+    CREATE TABLE IF NOT EXISTS device_registration (
+      id varchar(36) NOT NULL,
+      secret_hash varchar(64) NOT NULL,
+      device_token varchar(255) NOT NULL,
+      bundle_id varchar(255) NOT NULL,
+      apns_env varchar(16) NOT NULL DEFAULT 'production',
+      created_at bigint NOT NULL,
+      updated_at bigint NOT NULL,
+      PRIMARY KEY (id),
+      UNIQUE KEY device_registration_secret_token_idx (secret_hash, device_token),
+      KEY device_registration_secret_hash_idx (secret_hash)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+  `)
+
+  await db.execute(sql`
+    CREATE TABLE IF NOT EXISTS delivery_log (
+      id varchar(36) NOT NULL,
+      secret_hash varchar(64) NOT NULL,
+      event_type varchar(32) NOT NULL,
+      session_id varchar(255) NOT NULL,
+      status varchar(16) NOT NULL,
+      error varchar(1024) NULL,
+      created_at bigint NOT NULL,
+      PRIMARY KEY (id),
+      KEY delivery_log_secret_hash_idx (secret_hash),
+      KEY delivery_log_created_at_idx (created_at)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+  `)
+}

+ 8 - 0
packages/apn-relay/tsconfig.json

@@ -0,0 +1,8 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "@tsconfig/bun/tsconfig.json",
+  "compilerOptions": {
+    "lib": ["ESNext", "DOM", "DOM.Iterable"],
+    "noUncheckedIndexedAccess": false
+  }
+}

+ 15 - 2
packages/app/src/components/dialog-settings.tsx

@@ -8,14 +8,20 @@ import { SettingsGeneral } from "./settings-general"
 import { SettingsKeybinds } from "./settings-keybinds"
 import { SettingsProviders } from "./settings-providers"
 import { SettingsModels } from "./settings-models"
+import { SettingsPair } from "./settings-pair"
 
-export const DialogSettings: Component = () => {
+export const DialogSettings: Component<{ defaultTab?: string }> = (props) => {
   const language = useLanguage()
   const platform = usePlatform()
 
   return (
     <Dialog size="x-large" transition>
-      <Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
+      <Tabs
+        orientation="vertical"
+        variant="settings"
+        defaultValue={props.defaultTab ?? "general"}
+        class="h-full settings-dialog"
+      >
         <Tabs.List>
           <div class="flex flex-col justify-between h-full w-full">
             <div class="flex flex-col gap-3 w-full pt-3">
@@ -45,6 +51,10 @@ export const DialogSettings: Component = () => {
                       <Icon name="models" />
                       {language.t("settings.models.title")}
                     </Tabs.Trigger>
+                    <Tabs.Trigger value="pair">
+                      <Icon name="link" />
+                      {language.t("settings.pair.title")}
+                    </Tabs.Trigger>
                   </div>
                 </div>
               </div>
@@ -67,6 +77,9 @@ export const DialogSettings: Component = () => {
         <Tabs.Content value="models" class="no-scrollbar">
           <SettingsModels />
         </Tabs.Content>
+        <Tabs.Content value="pair" class="no-scrollbar">
+          <SettingsPair />
+        </Tabs.Content>
       </Tabs>
     </Dialog>
   )

+ 166 - 0
packages/app/src/components/settings-pair.tsx

@@ -0,0 +1,166 @@
+import { type Component, createResource, Show } from "solid-js"
+import { Icon } from "@opencode-ai/ui/icon"
+import { useLanguage } from "@/context/language"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useServer } from "@/context/server"
+import { usePlatform } from "@/context/platform"
+import { SettingsList } from "./settings-list"
+
+type PairResult =
+  | { enabled: false }
+  | {
+      enabled: true
+      hosts: string[]
+      relayURL?: string
+      serverID?: string
+      relaySecretHash?: string
+      link: string
+      qr: string
+    }
+
+export const SettingsPair: Component = () => {
+  const language = useLanguage()
+  const globalSDK = useGlobalSDK()
+  const server = useServer()
+  const platform = usePlatform()
+
+  const [data] = createResource(async () => {
+    const url = `${globalSDK.url}/experimental/push/pair`
+    console.debug("[settings-pair] fetching pair data", {
+      serverUrl: globalSDK.url,
+      serverName: server.name,
+      serverKey: server.key,
+    })
+    const f = platform.fetch ?? fetch
+    const res = await f(url)
+    if (!res.ok) {
+      console.debug("[settings-pair] pair endpoint returned non-ok", {
+        status: res.status,
+        serverUrl: globalSDK.url,
+      })
+      return { enabled: false as const }
+    }
+    const result = (await res.json()) as PairResult
+    console.debug("[settings-pair] pair data received", {
+      enabled: result.enabled,
+      serverUrl: globalSDK.url,
+      serverName: server.name,
+      ...(result.enabled
+        ? {
+            relayURL: result.relayURL,
+            serverID: result.serverID,
+            relaySecretHash: result.relaySecretHash,
+            hostCount: result.hosts.length,
+            hosts: result.hosts,
+          }
+        : {}),
+    })
+    return result
+  })
+
+  return (
+    <div class="flex flex-col gap-6 py-4 px-5">
+      <div class="flex flex-col gap-1">
+        <h2 class="text-16-semibold text-text-strong">{language.t("settings.pair.title")}</h2>
+        <p class="text-13-regular text-text-weak">{language.t("settings.pair.description")}</p>
+      </div>
+
+      <Show when={data.loading}>
+        <SettingsList>
+          <div class="flex items-center justify-center py-12">
+            <span class="text-14-regular text-text-weak">{language.t("settings.pair.loading")}</span>
+          </div>
+        </SettingsList>
+      </Show>
+
+      <Show when={data.error}>
+        <SettingsList>
+          <div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
+            <Icon name="warning" size="large" />
+            <div class="flex flex-col gap-1">
+              <span class="text-14-medium text-text-strong">{language.t("settings.pair.error.title")}</span>
+              <span class="text-13-regular text-text-weak max-w-md">
+                {language.t("settings.pair.error.description")}
+              </span>
+            </div>
+          </div>
+        </SettingsList>
+      </Show>
+
+      <Show when={!data.loading && !data.error && data()}>
+        {(result) => (
+          <Show
+            when={result().enabled && result()}
+            fallback={
+              <SettingsList>
+                <div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
+                  <Icon name="link" size="large" />
+                  <div class="flex flex-col gap-1">
+                    <span class="text-14-medium text-text-strong">{language.t("settings.pair.disabled.title")}</span>
+                    <span class="text-13-regular text-text-weak max-w-md">
+                      {language.t("settings.pair.disabled.description")}
+                    </span>
+                  </div>
+                  <code class="text-12-regular text-text-weak bg-surface-inset px-3 py-1.5 rounded mt-1">
+                    opencode serve --relay-url &lt;url&gt; --relay-secret &lt;secret&gt;
+                  </code>
+                </div>
+              </SettingsList>
+            }
+          >
+            {(pair) => {
+              const p = pair() as PairResult & { enabled: true }
+              return (
+                <SettingsList>
+                  <div class="flex flex-col items-center py-8 gap-4">
+                    <Show when={server.list.length > 1 || p.relayURL}>
+                      <div class="flex flex-col gap-1.5 w-full max-w-sm text-left">
+                        <div class="flex items-center gap-2">
+                          <span class="text-12-medium text-text-weak shrink-0">
+                            {language.t("settings.pair.server.label")}
+                          </span>
+                          <code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
+                            {server.name}
+                          </code>
+                        </div>
+                        <Show when={p.relayURL}>
+                          <div class="flex items-center gap-2">
+                            <span class="text-12-medium text-text-weak shrink-0">
+                              {language.t("settings.pair.relay.label")}
+                            </span>
+                            <code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
+                              {p.relayURL}
+                            </code>
+                          </div>
+                        </Show>
+                        <Show when={p.relaySecretHash}>
+                          <div class="flex items-center gap-2">
+                            <span class="text-12-medium text-text-weak shrink-0">
+                              {language.t("settings.pair.secret.label")}
+                            </span>
+                            <code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
+                              {p.relaySecretHash}
+                            </code>
+                          </div>
+                        </Show>
+                      </div>
+                    </Show>
+                    <img src={p.qr} alt="Pairing QR code" class="w-64 h-64" />
+                    <div class="flex flex-col gap-1 text-center max-w-sm">
+                      <span class="text-14-medium text-text-strong">
+                        {language.t("settings.pair.instructions.title")}
+                      </span>
+                      <span class="text-13-regular text-text-weak">
+                        {language.t("settings.pair.instructions.description")}
+                      </span>
+                    </div>
+                  </div>
+                </SettingsList>
+              )
+            }}
+          </Show>
+        )}
+      </Show>
+    </div>
+  )
+}

+ 15 - 0
packages/app/src/i18n/en.ts

@@ -28,6 +28,7 @@ export const dict = {
   "command.provider.connect": "Connect provider",
   "command.server.switch": "Switch server",
   "command.settings.open": "Open settings",
+  "command.pair.show": "Pair mobile device",
   "command.session.previous": "Previous session",
   "command.session.next": "Next session",
   "command.session.previous.unseen": "Previous unread session",
@@ -868,6 +869,20 @@ export const dict = {
   "settings.providers.tag.config": "Config",
   "settings.providers.tag.custom": "Custom",
   "settings.providers.tag.other": "Other",
+  "settings.pair.title": "Pair",
+  "settings.pair.description": "Pair a mobile device for push notifications.",
+  "settings.pair.loading": "Loading pairing info...",
+  "settings.pair.error.title": "Could not load pairing info",
+  "settings.pair.error.description": "Check that the server is reachable and try again.",
+  "settings.pair.disabled.title": "Push relay is not enabled",
+  "settings.pair.disabled.description": "Start the server with push relay options to enable mobile pairing.",
+  "settings.pair.server.label": "Server",
+  "settings.pair.relay.label": "Relay",
+  "settings.pair.secret.label": "Secret",
+  "settings.pair.instructions.title": "Scan with the OpenCode Control app",
+  "settings.pair.instructions.description":
+    "Open the OpenCode Control app and scan this QR code to pair your device for push notifications.",
+
   "settings.models.title": "Models",
   "settings.models.description": "Model settings will be configurable here.",
   "settings.agents.title": "Agents",

+ 9 - 2
packages/app/src/pages/layout.tsx

@@ -1061,6 +1061,13 @@ export default function Layout(props: ParentProps) {
         keybind: "mod+comma",
         onSelect: () => openSettings(),
       },
+      {
+        id: "pair.show",
+        title: language.t("command.pair.show"),
+        category: language.t("command.category.settings"),
+        slash: "pair",
+        onSelect: () => openSettings("pair"),
+      },
       {
         id: "session.previous",
         title: language.t("command.session.previous"),
@@ -1213,11 +1220,11 @@ export default function Layout(props: ParentProps) {
     })
   }
 
-  function openSettings() {
+  function openSettings(defaultTab?: string) {
     const run = ++dialogRun
     void import("@/components/dialog-settings").then((x) => {
       if (dialogDead || dialogRun !== run) return
-      dialog.show(() => <x.DialogSettings />)
+      dialog.show(() => <x.DialogSettings defaultTab={defaultTab} />)
     })
   }
 

+ 4 - 0
packages/desktop-electron/src/main/env.d.ts

@@ -10,6 +10,10 @@ declare module "virtual:opencode-server" {
     export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
     export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
   }
+  export namespace PushRelay {
+    export const start: typeof import("../../../opencode/dist/types/src/node").PushRelay.start
+    export const stop: typeof import("../../../opencode/dist/types/src/node").PushRelay.stop
+  }
   export namespace Config {
     export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
     export type Info = import("../../../opencode/dist/types/src/node").Config.Info

+ 25 - 1
packages/desktop-electron/src/main/server.ts

@@ -1,8 +1,20 @@
+import { randomBytes } from "node:crypto"
 import { app } from "electron"
 import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
 import { getUserShell, loadShellEnv } from "./shell-env"
 import { store } from "./store"
 
+const DEFAULT_RELAY_URL = "https://apn.dev.opencode.ai"
+const RELAY_SECRET_KEY = "relaySecret"
+
+function getOrCreateRelaySecret(): string {
+  const existing = store.get(RELAY_SECRET_KEY)
+  if (typeof existing === "string" && existing.length > 0) return existing
+  const secret = randomBytes(18).toString("base64url")
+  store.set(RELAY_SECRET_KEY, secret)
+  return secret
+}
+
 export type WslConfig = { enabled: boolean }
 
 export type HealthCheck = { wait: Promise<void> }
@@ -32,7 +44,7 @@ export function setWslConfig(config: WslConfig) {
 
 export async function spawnLocalServer(hostname: string, port: number, password: string) {
   prepareServerEnv(password)
-  const { Log, Server } = await import("virtual:opencode-server")
+  const { Log, Server, PushRelay } = await import("virtual:opencode-server")
   await Log.init({ level: "WARN" })
   const listener = await Server.listen({
     port,
@@ -41,6 +53,18 @@ export async function spawnLocalServer(hostname: string, port: number, password:
     password,
   })
 
+  const relayURL = (process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ?? DEFAULT_RELAY_URL).trim()
+  const relaySecretInput = (process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
+  const relaySecret = relaySecretInput || getOrCreateRelaySecret()
+  if (relayURL && relaySecret) {
+    PushRelay.start({
+      relayURL,
+      relaySecret,
+      hostname,
+      port: listener.port,
+    })
+  }
+
   const wait = (async () => {
     const url = `http://${hostname}:${port}`
 

+ 7 - 0
packages/mobile-voice/.cursor/settings.json

@@ -0,0 +1,7 @@
+{
+  "plugins": {
+    "figma": {
+      "enabled": true
+    }
+  }
+}

+ 43 - 0
packages/mobile-voice/.gitignore

@@ -0,0 +1,43 @@
+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+
+# dependencies
+node_modules/
+
+# Expo
+.expo/
+dist/
+web-build/
+expo-env.d.ts
+
+# Native
+.kotlin/
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+# Metro
+.metro-health-check*
+
+# debug
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+# macOS
+.DS_Store
+*.pem
+
+# local env files
+.env*.local
+
+# typescript
+*.tsbuildinfo
+
+app-example
+
+# generated native folders
+/ios
+/android

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

@@ -0,0 +1,183 @@
+# mobile-voice Agent Guide
+
+This file defines package-specific guidance for agents working in `packages/mobile-voice`.
+
+## Scope And Precedence
+
+- Follow root `AGENTS.md` first.
+- This file overrides root guidance for this package when rules conflict.
+- If additional local guides are added later, treat the closest guide as highest priority.
+
+## Project Overview
+
+- Expo + React Native app for voice dictation and OpenCode session monitoring.
+- Uses native/device-heavy modules such as `whisper.rn`, `react-native-audio-api`, `expo-notifications`, and `expo-camera`.
+- Development builds are required for native module changes.
+
+## Commands
+
+Run all commands from `packages/mobile-voice`.
+
+- Install deps: `bun install`
+- Start Metro: `bun run start`
+- Start dev client server (recommended): `bunx expo start --dev-client --clear --host lan`
+- 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`
+
+## 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.
+
+## Single-Test Guidance
+
+- 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
+
+## Architecture Priorities
+
+- Keep screens focused on composition and orchestration. Once a screen owns multiple workflows, extract hooks/components before adding more local state.
+- Prefer extracting pure helpers and config objects before introducing new stores or abstractions.
+- Treat `src/app/index.tsx` as a composition root, not as the permanent home for onboarding, dictation, monitoring, pairing, persistence, and all UI details.
+- Avoid mirrored `state + ref` pairs unless they are needed for imperative native APIs, race cancellation, or subscription callbacks.
+
+## Code Style And Patterns
+
+### Formatting / Structure
+
+- Preserve existing style (`semi: false`, concise JSX, stable import grouping).
+- Keep UI changes localized and behavior-preserving; avoid unrelated formatting churn.
+- Prefer feature-adjacent hooks/components over growing a single screen file.
+
+### React State / Effects
+
+- Effects are for subscriptions, timers, persistence, network I/O, and native bridge setup/cleanup.
+- Do not add `useEffect` just to derive render data from props or state. Derive during render instead.
+- Prefer one source of truth. If a value can be computed from existing state, do not store it separately.
+- Use `useMemo` only when computation is expensive or stable identity actually matters.
+- Use `useCallback` only when stable function identity matters for dependencies, cleanup, or memoized children.
+- When UI branches are driven by a small finite state, prefer config tables/objects over long nested ternaries.
+
+### Types
+
+- Avoid `any`; prefer local type aliases for component state and network payloads.
+- Keep exported/shared boundaries typed explicitly.
+- Parse persisted and network payloads as `unknown` first, then validate before use.
+- Use discriminated unions for UI modes/status where practical.
+
+### Naming
+
+- Prefer short, readable names consistent with nearby code.
+- Keep naming aligned with existing app state keys (`monitorStatus`, `activeSessionId`, etc.).
+
+### Error Handling / Logging
+
+- Fail gracefully in UI (alerts, disabled actions, fallback text).
+- Avoid bare `catch {}` or `.catch(() => {})` for meaningful work. If failure is intentionally best-effort, leave a short comment or use a helper that makes that explicit.
+- Log actionable diagnostics for runtime workflows such as server health checks, relay registration, and notification token lifecycle.
+- Never log secrets or full APNs tokens.
+- Keep hot-path logging behind `__DEV__` when possible.
+
+### Network / Relay Integration
+
+- Normalize and validate URLs before storing server configs.
+- Use `AbortController` or request IDs for overlapping requests, streams, and polling.
+- Keep relay registration idempotent.
+- Guard duplicate scan/add flows to avoid repeated server entries.
+
+### Notifications / APNs
+
+- This package currently assumes APNs relay registration uses the `production` environment only. Do not add environment switching unless explicitly requested.
+- On registration changes, ensure old token unregister flow remains intact.
+- Treat permission failures as non-fatal and degrade to foreground monitoring when needed.
+
+### Performance / RN
+
+- Validate performance-sensitive changes in a dev client or release build, not only Metro dev mode.
+- During recording and monitoring flows, keep JS-thread work light.
+- Prefer Reanimated/native-thread-friendly animations for motion.
+- For small menus a `ScrollView` is fine; if a list grows beyond a small bounded menu, move to `FlatList` or `FlashList`.
+
+## Lint / Quality Bar
+
+- Keep hooks lint warnings clean before finishing.
+- Treat `any`, `no-console`, complexity, and max-lines warnings as refactor prompts, not noise to suppress.
+- Do not disable React Hooks lint rules inline unless there is a documented native-interop reason.
+- When introducing new persistence or network payloads, add or reuse a parser instead of scattering casts.
+
+## Native-Module Safety
+
+- If adding a native module, ensure it is in `package.json` with an SDK-compatible version.
+- 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.
+- `expo-doctor` duplicate module warnings may appear in Bun workspaces; prioritize runtime verification.
+- `expo lint` may auto-generate `eslint.config.js`; do not commit accidental generated config unless requested.
+
+## Before Finishing
+
+- Run `bun run lint`.
+- If behavior could break startup, run `bunx expo export --platform ios --clear`.
+- Confirm no accidental config side effects were introduced.
+- Summarize what was verified on-device vs only in tooling.
+
+
+- Dev build (internal/dev client):
+  - bunx eas build --profile development --platform ios
+- Production build + auto-submit:
+  - bunx eas build --profile production --platform ios --auto-submit

+ 39 - 0
packages/mobile-voice/README.md

@@ -0,0 +1,39 @@
+# Mobile Voice
+
+Expo app for voice dictation and OpenCode session monitoring.
+
+## Current monitoring behavior
+
+- Foreground: app reads OpenCode SSE (`GET /event`) and updates monitor status live.
+- Background/terminated: app relies on APNs notifications sent by `apn-relay`.
+- The app registers its native APNs device token with relay route `POST /v1/device/register`.
+
+## App requirements
+
+- Use a development build or production build (not Expo Go).
+- `expo-notifications` plugin is enabled with `enableBackgroundRemoteNotifications: true`.
+- Notification permission must be granted.
+
+## Server entry fields in app
+
+When adding a server, provide:
+
+- OpenCode URL
+- APN relay URL
+- Relay shared secret
+
+Default APN relay URL: `https://apn.dev.opencode.ai`
+
+The app uses these values to:
+
+- send prompts to OpenCode
+- register/unregister APNs token with relay
+- receive background push updates for monitored sessions
+
+## Local dev
+
+```bash
+npx expo start
+```
+
+Use your machine LAN IP / reachable host values for OpenCode and relay when testing on a physical device.

+ 101 - 0
packages/mobile-voice/app.json

@@ -0,0 +1,101 @@
+{
+  "expo": {
+    "name": "Control",
+    "slug": "control",
+    "version": "1.0.2",
+    "orientation": "portrait",
+    "icon": "./assets/new-control.png",
+    "scheme": "mobilevoice",
+    "userInterfaceStyle": "automatic",
+    "ios": {
+      "icon": "./assets/new-control.png",
+      "bundleIdentifier": "com.anomalyco.mobilevoice",
+      "config": {
+        "usesNonExemptEncryption": false
+      },
+      "entitlements": {
+        "com.apple.developer.kernel.extended-virtual-addressing": true
+      },
+      "infoPlist": {
+        "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": {
+            "100.64.0.0/10": {
+              "NSExceptionAllowsInsecureHTTPLoads": true
+            },
+            "ts.net": {
+              "NSIncludesSubdomains": true,
+              "NSExceptionAllowsInsecureHTTPLoads": true
+            }
+          }
+        }
+      }
+    },
+    "android": {
+      "adaptiveIcon": {
+        "backgroundColor": "#1a1a1a",
+        "foregroundImage": "./assets/images/android-icon-foreground.png",
+        "backgroundImage": "./assets/images/android-icon-background.png",
+        "monochromeImage": "./assets/images/android-icon-monochrome.png"
+      },
+      "permissions": [
+        "RECORD_AUDIO",
+        "POST_NOTIFICATIONS",
+        "android.permission.FOREGROUND_SERVICE",
+        "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
+        "android.permission.RECORD_AUDIO",
+        "android.permission.MODIFY_AUDIO_SETTINGS",
+        "android.permission.ACCESS_NETWORK_STATE",
+        "android.permission.ACCESS_WIFI_STATE",
+        "android.permission.CHANGE_WIFI_MULTICAST_STATE"
+      ],
+      "predictiveBackGestureEnabled": false
+    },
+    "web": {
+      "output": "static",
+      "favicon": "./assets/images/favicon.png"
+    },
+    "plugins": [
+      "expo-router",
+      [
+        "expo-splash-screen",
+        {
+          "backgroundColor": "#121212",
+          "android": {
+            "image": "./assets/images/splash-icon.png",
+            "imageWidth": 76
+          }
+        }
+      ],
+      "react-native-audio-api",
+      "expo-asset",
+      "expo-audio",
+      [
+        "expo-notifications",
+        {
+          "enableBackgroundRemoteNotifications": true,
+          "sounds": ["./assets/sounds/alert.wav"]
+        }
+      ]
+    ],
+    "experiments": {
+      "typedRoutes": true,
+      "reactCompiler": true
+    },
+    "extra": {
+      "router": {},
+      "eas": {
+        "projectId": "50b3dac3-8b5e-4142-b749-65ecf7b2904d"
+      }
+    },
+    "owner": "anomaly-co",
+    "runtimeVersion": "1.0.2",
+    "updates": {
+      "url": "https://u.expo.dev/50b3dac3-8b5e-4142-b749-65ecf7b2904d"
+    }
+  }
+}

BIN
packages/mobile-voice/assets/android-icon-background.png


BIN
packages/mobile-voice/assets/android-icon-foreground.png


BIN
packages/mobile-voice/assets/android-icon-monochrome.png


BIN
packages/mobile-voice/assets/control-icon.png


+ 3 - 0
packages/mobile-voice/assets/expo.icon/Assets/expo-symbol 2.svg

@@ -0,0 +1,3 @@
+<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
+</svg>

BIN
packages/mobile-voice/assets/expo.icon/Assets/grid.png


+ 40 - 0
packages/mobile-voice/assets/expo.icon/icon.json

@@ -0,0 +1,40 @@
+{
+  "fill" : {
+    "automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
+  },
+  "groups" : [
+    {
+      "layers" : [
+        {
+          "image-name" : "expo-symbol 2.svg",
+          "name" : "expo-symbol 2",
+          "position" : {
+            "scale" : 1,
+            "translation-in-points" : [
+              1.1008400065293245e-05,
+              -16.046875
+            ]
+          }
+        },
+        {
+          "image-name" : "grid.png",
+          "name" : "grid"
+        }
+      ],
+      "shadow" : {
+        "kind" : "neutral",
+        "opacity" : 0.5
+      },
+      "translucency" : {
+        "enabled" : true,
+        "value" : 0.5
+      }
+    }
+  ],
+  "supported-platforms" : {
+    "circles" : [
+      "watchOS"
+    ],
+    "squares" : "shared"
+  }
+}

BIN
packages/mobile-voice/assets/favicon.png


BIN
packages/mobile-voice/assets/icon.png


BIN
packages/mobile-voice/assets/images/android-icon-background.png


BIN
packages/mobile-voice/assets/images/android-icon-foreground.png


BIN
packages/mobile-voice/assets/images/android-icon-monochrome.png


BIN
packages/mobile-voice/assets/images/expo-badge-white.png


BIN
packages/mobile-voice/assets/images/expo-badge.png


BIN
packages/mobile-voice/assets/images/expo-logo.png


BIN
packages/mobile-voice/assets/images/favicon.png


BIN
packages/mobile-voice/assets/images/icon.png


BIN
packages/mobile-voice/assets/images/logo-glow.png


BIN
packages/mobile-voice/assets/images/react-logo.png


BIN
packages/mobile-voice/assets/images/[email protected]


BIN
packages/mobile-voice/assets/images/[email protected]


BIN
packages/mobile-voice/assets/images/splash-icon.png


BIN
packages/mobile-voice/assets/images/tabIcons/explore.png


BIN
packages/mobile-voice/assets/images/tabIcons/[email protected]


BIN
packages/mobile-voice/assets/images/tabIcons/[email protected]


BIN
packages/mobile-voice/assets/images/tabIcons/home.png


BIN
packages/mobile-voice/assets/images/tabIcons/[email protected]


BIN
packages/mobile-voice/assets/images/tabIcons/[email protected]


BIN
packages/mobile-voice/assets/images/tutorial-web.png


BIN
packages/mobile-voice/assets/new-control.png


BIN
packages/mobile-voice/assets/sounds/alert.wav


BIN
packages/mobile-voice/assets/sounds/complete.wav


BIN
packages/mobile-voice/assets/sounds/send-whoosh.mp3


BIN
packages/mobile-voice/assets/splash-icon.png


+ 328 - 0
packages/mobile-voice/docs/live-activity-plan.md

@@ -0,0 +1,328 @@
+# Live Activity Implementation Plan
+
+## Architecture Overview
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│  iPhone Lock Screen / Dynamic Island                        │
+│  ┌─────────────────────────────────────────────────────┐    │
+│  │  Live Activity (expo-widgets)                       │    │
+│  │  "Installing dependencies..."  ● Working            │    │
+│  └─────────────────────────────────────────────────────┘    │
+│         ▲ local update (foreground)    ▲ APNs push (bg)     │
+│         │                              │                    │
+│  ┌──────┴─────┐                        │                    │
+│  │ SSE stream │                        │                    │
+│  │ /event     │                        │                    │
+│  └──────┬─────┘                        │                    │
+└─────────┼──────────────────────────────┼────────────────────┘
+          │                              │
+          ▼                              │
+┌──────────────────┐            ┌────────┴─────────┐
+│  OpenCode Server │──event──>  │   APN Relay      │
+│  (push-relay.ts) │            │  /v1/activity/*   │
+│  GlobalBus       │            │  apns.ts          │
+└──────────────────┘            └──────────────────┘
+```
+
+**Foreground**: App receives SSE events, updates the Live Activity locally via `instance.update()`.
+**Background**: OpenCode server fires events to the relay, relay sends `liveactivity` APNs pushes with `content-state`, iOS updates the widget.
+**Push-to-start**: Relay sends a `start` push to begin a Live Activity even when the app hasn't initiated one.
+
+## Content-State Shape
+
+```typescript
+type SessionActivityProps = {
+  status: "working" | "retry" | "permission" | "complete" | "error"
+  sessionTitle: string // e.g. "Fix auth bug"
+  lastMessage: string // truncated ~120 chars, e.g. "Installing dependencies..."
+  retryInfo: string | null // e.g. "Retry 2/5 in 8s" when status is "retry"
+}
+```
+
+This is intentionally lean -- it keeps APNs payload size well under the 4KB limit.
+
+## Dynamic Island / Lock Screen Layout
+
+| Slot                     | Content                                        |
+| ------------------------ | ---------------------------------------------- |
+| **Banner** (Lock Screen) | Session title, status badge, last message text |
+| **Compact leading**      | App icon or "OC" text                          |
+| **Compact trailing**     | Status word ("Working", "Done", "Needs input") |
+| **Minimal**              | Small status dot/icon                          |
+| **Expanded leading**     | Session title + status                         |
+| **Expanded trailing**    | Time elapsed or ETA                            |
+| **Expanded bottom**      | Last message text, retry info if applicable    |
+
+---
+
+## Phase 1: Core Live Activity (App-Initiated, Local + Push Updates)
+
+This phase delivers the end-to-end working feature.
+
+### 1a. Install and configure expo-widgets
+
+**Package**: `mobile-voice`
+
+- Add `expo-widgets` and `@expo/ui` to dependencies
+- Add the plugin to `app.json`:
+  ```json
+  [
+    "expo-widgets",
+    {
+      "enablePushNotifications": true,
+      "widgets": [
+        {
+          "name": "SessionActivity",
+          "displayName": "OpenCode Session",
+          "description": "Live session monitoring on Lock Screen and Dynamic Island"
+        }
+      ]
+    }
+  ]
+  ```
+- Add `NSSupportsLiveActivities: true` and `NSSupportsLiveActivitiesFrequentUpdates: true` to `expo.ios.infoPlist` in `app.json`
+- Requires a new EAS dev build after this step
+
+### 1b. Create the Live Activity component
+
+**New file**: `src/widgets/session-activity.tsx`
+
+- Define `SessionActivityProps` type
+- Build the `LiveActivityLayout` using `@expo/ui/swift-ui` primitives (`Text`, `VStack`, `HStack`)
+- Export via `createLiveActivity('SessionActivity', SessionActivity)`
+- Adapt layout per slot (banner, compact, minimal, expanded)
+- Use `LiveActivityEnvironment.colorScheme` to handle dark/light
+
+### 1c. Create a Live Activity management hook
+
+**New file**: `src/hooks/use-live-activity.ts`
+
+Responsibilities:
+
+- `startActivity(sessionTitle, sessionId)` -- calls `SessionActivity.start(props, deepLinkURL)`, stores the instance, gets the push token
+- `updateActivity(props)` -- calls `instance.update(props)` for foreground SSE-driven updates
+- `endActivity(finalStatus)` -- calls `instance.end('default', finalProps)`
+- Manages the per-activity push token lifecycle
+- Exposes `activityPushToken: string | null` for relay registration
+- Handles edge cases: activity already running (end previous before starting new), activities disabled by user, iOS version checks
+
+### 1d. Integrate into useMonitoring
+
+**File**: `src/hooks/use-monitoring.ts`
+
+- Import and use `useLiveActivity`
+- **On `beginMonitoring(job)`**: call `startActivity(sessionTitle, job.sessionID)`
+- **In the SSE event handler** (foreground): map classified events to `updateActivity()` calls:
+  - `session.status` busy -> `{ status: "working", lastMessage: <latest text> }`
+  - `session.status` retry -> `{ status: "retry", retryInfo: "Retry N in Xs" }`
+  - `permission.asked` -> `{ status: "permission", lastMessage: "Needs your decision" }`
+  - `session.status` idle (complete) -> `endActivity("complete")`
+  - `session.error` -> `endActivity("error")`
+- **On stop monitoring**: end the activity if still running
+- **On app background**: stop SSE (already happens), rely on APNs pushes for updates
+- **On app foreground**: reconnect SSE, sync local activity state with `syncSessionState()`
+
+### 1e. Register activity push tokens with the relay
+
+**File**: `src/lib/relay-client.ts`
+
+- New function: `registerActivityToken(input)` -- calls new relay endpoint `POST /v1/activity/register`
+  ```typescript
+  registerActivityToken(input: {
+    relayBaseURL: string
+    secret: string
+    activityToken: string
+    sessionID: string
+    bundleId?: string
+  }): Promise<void>
+  ```
+- New function: `unregisterActivityToken(input)` -- cleanup
+  ```typescript
+  unregisterActivityToken(input: {
+    relayBaseURL: string
+    secret: string
+    sessionID: string
+  }): Promise<void>
+  ```
+
+**File**: `src/hooks/use-live-activity.ts` or `use-monitoring.ts`
+
+- When `activityPushToken` becomes available after `startActivity()`, send it to the relay alongside the `sessionID`
+- On activity end, unregister the token
+
+### 1f. Extend the APN relay for Live Activity pushes
+
+**Package**: `apn-relay`
+
+New endpoint: `POST /v1/activity/register`
+
+```typescript
+{
+  secret: string,
+  sessionID: string,
+  activityToken: string,    // the per-activity push token
+  bundleId?: string
+}
+```
+
+New endpoint: `POST /v1/activity/unregister`
+
+```typescript
+{
+  secret: string,
+  sessionID: string
+}
+```
+
+New DB table: `activity_registration`
+
+```sql
+id TEXT PRIMARY KEY,
+secret_hash TEXT NOT NULL,
+session_id TEXT NOT NULL,
+activity_token TEXT NOT NULL,
+bundle_id TEXT NOT NULL,
+apns_env TEXT NOT NULL DEFAULT 'production',
+created_at INTEGER NOT NULL,
+updated_at INTEGER NOT NULL,
+UNIQUE(secret_hash, session_id)
+```
+
+Modified: `POST /v1/event` handler
+
+- After sending the regular alert push (existing behavior), also check `activity_registration` for matching `(secret_hash, session_id)`
+- If a registration exists, send a second push with:
+  - `apns-push-type: liveactivity`
+  - `apns-topic: {bundleId}.push-type.liveactivity`
+  - Payload with `content-state` containing the `SessionActivityProps` fields
+  - `event: "update"` for progress, `event: "end"` for complete/error
+
+New function in `apns.ts`: `sendLiveActivityUpdate(input)`
+
+- Separate from the existing `send()` function
+- Uses `liveactivity` push type headers
+- Constructs `content-state` payload format
+
+### 1g. Extend the OpenCode server push-relay for richer events
+
+**File**: `packages/opencode/src/server/push-relay.ts`
+
+- Extend `Type` union: `"complete" | "permission" | "error" | "progress"`
+- Add cases to `map()` function:
+  - `session.status` with `type: "busy"` -> `{ type: "progress", sessionID }`
+  - `session.status` with `type: "retry"` -> `{ type: "progress", sessionID }` (with retry metadata)
+  - `message.updated` where the message has tool-use or assistant text -> `{ type: "progress", sessionID }` (throttled)
+- Add to `notify()` / `post()`: include a `contentState` object in the relay payload for progress events
+- Add throttling: don't send more than ~1 progress push per 10-15 seconds to stay within APNs budget
+- Extend `evt` payload sent to relay:
+  ```typescript
+  {
+    secret, serverID, eventType, sessionID, title, body,
+    // New field for Live Activity updates:
+    contentState?: {
+      status: "working" | "retry" | "permission" | "complete" | "error",
+      sessionTitle: string,
+      lastMessage: string,
+      retryInfo: string | null
+    }
+  }
+  ```
+
+---
+
+## Phase 2: Push-to-Start
+
+This lets the server start a Live Activity on the phone when a session begins, even if the user didn't initiate it from the app.
+
+### 2a. Register push-to-start token from the app
+
+**File**: `src/hooks/use-live-activity.ts`
+
+- On app launch, call `addPushToStartTokenListener()` from `expo-widgets`
+- Send the push-to-start token to the relay at registration time (extend existing `/v1/device/register` or new field)
+- This token is app-wide (not per-activity), so it lives alongside the device push token
+
+### 2b. Extend relay for push-to-start
+
+**Package**: `apn-relay`
+
+- Add `push_to_start_token` column to `device_registration` table (nullable)
+- Extend `/v1/device/register` to accept `pushToStartToken` field
+- New logic in `/v1/event`: if `eventType` is the first event for a session and no `activity_registration` exists yet, send a push-to-start payload:
+  ```json
+  {
+    "aps": {
+      "timestamp": 1712345678,
+      "event": "start",
+      "content-state": {
+        "status": "working",
+        "sessionTitle": "Fix auth bug",
+        "lastMessage": "Starting...",
+        "retryInfo": null
+      },
+      "attributes-type": "SessionActivityAttributes",
+      "attributes": {
+        "sessionId": "abc123"
+      },
+      "alert": {
+        "title": "Session Started",
+        "body": "OpenCode is working on: Fix auth bug"
+      }
+    }
+  }
+  ```
+
+### 2c. Server-side: emit session start events
+
+**File**: `packages/opencode/src/server/push-relay.ts`
+
+- Add a `"start"` event type
+- Map `session.status` with `type: "busy"` (first time for a session) to `{ type: "start", sessionID }`
+- Include session metadata (title, directory) in the payload so the relay can populate the `attributes` field for push-to-start
+
+---
+
+## Phase 3: Polish and Edge Cases
+
+- **Deep linking**: When user taps the Live Activity, open the app and navigate to that session (`mobilevoice://session/{id}`)
+- **Multiple activities**: Handle the case where the user starts multiple sessions from different servers. iOS supports multiple concurrent Live Activities.
+- **Activity expiry**: iOS ends Live Activities after 8 hours. Handle the timeout gracefully (end with a "timed out" status).
+- **Token rotation**: Activity push tokens can rotate. The `addPushTokenListener` handles this -- forward new tokens to the relay.
+- **Cleanup**: When the relay receives an APNs error like `InvalidToken` for an activity token, delete the `activity_registration` row.
+- **Stale activities**: On app foreground, check `SessionActivity.getInstances()` to clean up any orphaned activities.
+
+---
+
+## Changes Per Package Summary
+
+| Package          | Files Changed                                                      | Files Added                                                          |
+| ---------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------- |
+| **mobile-voice** | `app.json`, `package.json`, `use-monitoring.ts`, `relay-client.ts` | `src/widgets/session-activity.tsx`, `src/hooks/use-live-activity.ts` |
+| **apn-relay**    | `src/index.ts`, `src/apns.ts`, `src/schema.sql.ts`                 | (none)                                                               |
+| **opencode**     | `src/server/push-relay.ts`                                         | (none)                                                               |
+
+## Build Requirements
+
+- New EAS dev build required after Phase 1a (native widget extension target)
+- Relay deployment after Phase 1f
+- OpenCode server rebuild after Phase 1g
+
+## Key Technical References
+
+- `expo-widgets` docs: https://docs.expo.dev/versions/latest/sdk/widgets/
+- `expo-widgets` alpha blog post: https://expo.dev/blog/home-screen-widgets-and-live-activities-in-expo
+- Apple ActivityKit push notifications: https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications
+- Existing APN relay: `packages/apn-relay/src/`
+- Existing push-relay (server-side): `packages/opencode/src/server/push-relay.ts`
+- Existing monitoring hook: `packages/mobile-voice/src/hooks/use-monitoring.ts`
+- Existing relay client: `packages/mobile-voice/src/lib/relay-client.ts`
+
+## Limitations / Risks
+
+- **expo-widgets is alpha** (March 2026) -- APIs may break
+- **Images not yet supported** in `@expo/ui` widget components (on Expo's roadmap)
+- **Live Activities have an 8-hour max duration** enforced by iOS
+- **APNs budget**: iOS throttles frequent updates; keep progress pushes to ~1 per 10-15 seconds
+- **NSSupportsLiveActivitiesFrequentUpdates** needed in Info.plist for higher update frequency
+- **Dev builds required** -- adding the widget extension is a native change, OTA won't cover it

+ 27 - 0
packages/mobile-voice/eas.json

@@ -0,0 +1,27 @@
+{
+  "cli": {
+    "version": ">= 18.4.0",
+    "appVersionSource": "remote"
+  },
+  "build": {
+    "development": {
+      "bun": "1.3.11",
+      "developmentClient": true,
+      "distribution": "internal",
+      "channel": "development"
+    },
+    "preview": {
+      "bun": "1.3.11",
+      "distribution": "internal",
+      "channel": "preview"
+    },
+    "production": {
+      "bun": "1.3.11",
+      "autoIncrement": true,
+      "channel": "production"
+    }
+  },
+  "submit": {
+    "production": {}
+  }
+}

+ 52 - 0
packages/mobile-voice/eslint.config.js

@@ -0,0 +1,52 @@
+// https://docs.expo.dev/guides/using-eslint/
+const { defineConfig } = require("eslint/config")
+const tsGuard = require("@typescript-eslint/eslint-plugin")
+const expoConfig = require("eslint-config-expo/flat")
+const reactHooksNext = require("eslint-plugin-react-hooks")
+
+module.exports = defineConfig([
+  expoConfig,
+  {
+    ignores: ["dist/*"],
+  },
+  {
+    files: ["**/*.{ts,tsx}"],
+    languageOptions: {
+      parserOptions: {
+        projectService: true,
+        tsconfigRootDir: __dirname,
+      },
+    },
+    plugins: {
+      "react-hooks-next": reactHooksNext,
+      "ts-guard": tsGuard,
+    },
+    rules: {
+      "ts-guard/no-explicit-any": "warn",
+      "ts-guard/no-floating-promises": "warn",
+      complexity: ["warn", 20],
+      "max-lines": [
+        "warn",
+        {
+          max: 1200,
+          skipBlankLines: true,
+          skipComments: true,
+        },
+      ],
+      "max-lines-per-function": [
+        "warn",
+        {
+          max: 250,
+          skipBlankLines: true,
+          skipComments: true,
+        },
+      ],
+      "no-console": ["warn", { allow: ["warn", "error"] }],
+      "no-nested-ternary": "warn",
+      "react-hooks/exhaustive-deps": "error",
+      "react-hooks-next/refs": "warn",
+      "react-hooks-next/set-state-in-effect": "warn",
+      "react-hooks-next/static-components": "warn",
+    },
+  },
+])

+ 8 - 0
packages/mobile-voice/metro.config.js

@@ -0,0 +1,8 @@
+const { getDefaultConfig } = require("expo/metro-config")
+
+const config = getDefaultConfig(__dirname)
+
+// Required for react-native-executorch model files
+config.resolver.assetExts.push("pte", "bin")
+
+module.exports = config

+ 7 - 0
packages/mobile-voice/notes.md

@@ -0,0 +1,7 @@
+- While the model is loading for the first time, there should be some fun little like onboarding sequence that you can go through that makes sure the model is automated properly.
+- When a permission/session complete notification is sent, if you click on it, the session/server should auto be selected.
+- We need some sort of permissions UI in the top half of the generation.
+- Need to figure out a good way to start new sessions.
+- When an agent returns a generation, we should be able to expand it into a reader mode view.
+- Work on the live activity widget.
+- In the OpenCode Control app, if a link is generated in Markdown, it should be tappable and open in the device's default browser.

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

@@ -0,0 +1,66 @@
+{
+  "name": "mobile-voice",
+  "main": "expo-router/entry",
+  "version": "1.0.2",
+  "scripts": {
+    "start": "expo start",
+    "expo:start": "ELECTRON_DISABLE_SANDBOX=1 REACT_NATIVE_PACKAGER_HOSTNAME=exos.husky-tilapia.ts.net expo start --dev-client --clear --host lan",
+    "relay": "echo 'Use packages/apn-relay for APNs relay server'",
+    "relay:legacy": "node ./relay/opencode-relay.mjs",
+    "reset-project": "node ./scripts/reset-project.js",
+    "android": "expo run:android",
+    "ios": "expo run:ios",
+    "web": "expo start --web",
+    "lint": "expo lint",
+    "typecheck": "tsc -p tsconfig.typecheck.json --noEmit"
+  },
+  "dependencies": {
+    "@fugood/react-native-audio-pcm-stream": "1.1.4",
+    "@react-navigation/bottom-tabs": "^7.15.5",
+    "@react-navigation/elements": "^2.9.10",
+    "@react-navigation/native": "^7.1.33",
+    "expo": "~55.0.9",
+    "expo-asset": "~55.0.10",
+    "expo-audio": "~55.0.9",
+    "expo-camera": "~55.0.11",
+    "expo-constants": "~55.0.9",
+    "expo-dev-client": "~55.0.19",
+    "expo-device": "~55.0.10",
+    "expo-file-system": "~55.0.12",
+    "expo-font": "~55.0.4",
+    "expo-glass-effect": "~55.0.8",
+    "expo-haptics": "~55.0.9",
+    "expo-image": "~55.0.6",
+    "expo-linking": "~55.0.9",
+    "expo-notifications": "~55.0.14",
+    "expo-router": "~55.0.8",
+    "expo-splash-screen": "~55.0.13",
+    "expo-status-bar": "~55.0.4",
+    "expo-symbols": "~55.0.5",
+    "expo-system-ui": "~55.0.11",
+    "expo-task-manager": "~55.0.10",
+    "expo-updates": "~55.0.16",
+    "expo-web-browser": "~55.0.10",
+    "react": "19.2.0",
+    "react-dom": "19.2.0",
+    "react-native": "0.83.4",
+    "react-native-audio-api": "^0.11.7",
+    "react-native-gesture-handler": "~2.30.0",
+    "react-native-reanimated": "4.2.1",
+    "react-native-safe-area-context": "~5.6.2",
+    "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": {
+    "@typescript-eslint/eslint-plugin": "^8.57.2",
+    "@typescript-eslint/parser": "^8.57.2",
+    "@types/react": "~19.2.2",
+    "babel-preset-expo": "~55.0.8",
+    "eslint-plugin-react-hooks": "^7.0.1",
+    "typescript": "~5.9.2"
+  },
+  "private": true
+}

+ 88 - 0
packages/mobile-voice/refactor.md

@@ -0,0 +1,88 @@
+# Mobile Voice Refactor Plan
+
+## Goals
+
+- Reduce the surface area of `src/app/index.tsx` without changing product behavior.
+- Make device, network, and monitoring flows easier to reason about.
+- Move toward React Native / Expo best practices for state, effects, and file structure.
+- Use the new lint warnings as refactor prompts, not as permanent background noise.
+
+## Current Pain Points
+
+- `DictationScreen` currently owns onboarding, permissions, Whisper/model lifecycle, dictation, pairing, server/session sync, relay registration, notification handling, and most UI rendering.
+- The screen mixes render-time derived state, imperative refs, polling, persistence, and native cleanup in one place.
+- There are many nested conditionals and long derived blocks that are hard to scan.
+- Best-effort async cleanup and silent catches make failures harder to understand.
+
+## Target Shape
+
+- `src/app/index.tsx`
+  - compose hooks and presentational sections
+  - keep only screen-level orchestration
+- `src/features/onboarding/`
+  - onboarding step config
+  - onboarding UI component
+- `src/features/dictation/`
+  - `use-whisper-dictation`
+  - transcript helpers
+- `src/features/servers/`
+  - server/session refresh and pairing helpers
+  - persisted server state helpers
+- `src/features/monitoring/`
+  - foreground SSE monitoring
+  - notification payload handling
+  - relay registration helpers
+- `src/lib/`
+  - parser/validation helpers
+  - logger helper for dev-only diagnostics
+
+## Refactor Order
+
+### Phase 1: Extract pure helpers first
+
+- Move onboarding step text/style selection into a config object or array.
+- Move server/session payload parsing into dedicated helpers.
+- Keep existing behavior and props the same.
+
+### Phase 2: Extract onboarding UI
+
+- Create an `OnboardingFlow` component that receives explicit state and handlers.
+- Keep onboarding persistence in the screen until the UI extraction is stable.
+
+### Phase 3: Extract dictation logic
+
+- Move Whisper loading, recording, bulk/realtime transcription, and waveform state into a `useWhisperDictation` hook.
+- Expose a small interface: recording state, transcript, actions, and model status.
+
+### Phase 4: Extract server/session management
+
+- Move server restore/save, pairing, health refresh, and active server/session selection into a dedicated hook.
+- Centralize server parsing and dedupe logic.
+
+### Phase 5: Extract monitoring and notifications
+
+- Move SSE monitoring, push payload handling, and relay registration into a `useMonitoring` hook.
+- Keep side effects close to the feature that owns them.
+
+### Phase 6: Lint burn-down
+
+- Replace `any` with explicit parsed shapes.
+- Reduce nested ternaries in favor of config tables.
+- Replace ad hoc `console.log` calls with a logger helper or `__DEV__`-gated diagnostics.
+- Audit bare `.catch(() => {})` and convert non-trivial cases to explicit best-effort helpers or real error handling.
+
+## Guardrails During Refactor
+
+- Keep one behavior-preserving slice per PR.
+- Do not introduce more derived state in `useEffect`.
+- Prefer explicit hook inputs/outputs over hidden cross-hook coupling.
+- Only use refs for imperative APIs, subscriptions, and race control.
+- Re-run lint after each slice.
+- Validate app behavior in the dev client for microphone, notifications, pairing, and monitoring flows.
+
+## Exit Criteria
+
+- `src/app/index.tsx` is mostly screen composition and stays under roughly 800-1200 lines.
+- Feature logic lives in focused hooks/components with clearer ownership.
+- New payload parsing does not rely on `any`.
+- Lint warnings trend down instead of growing.

+ 328 - 0
packages/mobile-voice/relay/opencode-relay.mjs

@@ -0,0 +1,328 @@
+import { createServer } from 'node:http';
+
+const PORT = Number(process.env.PORT || 8787);
+const HOST = process.env.HOST || '0.0.0.0';
+const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
+
+/** @type {Map<string, {jobID: string, sessionID: string, opencodeBaseURL: string, relayBaseURL: string, expoPushToken: string, createdAt: number, done: boolean}>} */
+const jobs = new Map();
+
+/** @type {Map<string, {key: string, opencodeBaseURL: string, abortController: AbortController, sessions: Set<string>, running: boolean}>} */
+const streams = new Map();
+
+/** @type {Set<string>} */
+const dedupe = new Set();
+
+function json(res, status, body) {
+  const value = JSON.stringify(body);
+  res.writeHead(status, {
+    'Content-Type': 'application/json',
+    'Content-Length': Buffer.byteLength(value),
+  });
+  res.end(value);
+}
+
+async function readJSON(req) {
+  let raw = '';
+  for await (const chunk of req) {
+    raw += chunk;
+    if (raw.length > 1_000_000) {
+      throw new Error('Payload too large');
+    }
+  }
+  if (!raw.trim()) return {};
+  return JSON.parse(raw);
+}
+
+function extractSessionID(event) {
+  const properties = event?.properties ?? {};
+  if (typeof properties.sessionID === 'string') return properties.sessionID;
+  if (properties.info && typeof properties.info === 'object' && typeof properties.info.sessionID === 'string') {
+    return properties.info.sessionID;
+  }
+  if (properties.part && typeof properties.part === 'object' && typeof properties.part.sessionID === 'string') {
+    return properties.part.sessionID;
+  }
+  return null;
+}
+
+function classifyEvent(event) {
+  const type = String(event?.type || '');
+  const lower = type.toLowerCase();
+
+  if (lower.includes('permission')) return 'permission';
+  if (lower.includes('error')) return 'error';
+
+  if (type === 'session.status') {
+    const statusType = event?.properties?.status?.type;
+    if (statusType === 'idle') return 'complete';
+  }
+
+  if (type === 'message.updated') {
+    const info = event?.properties?.info;
+    if (info && typeof info === 'object') {
+      if (info.error) return 'error';
+      if (info.role === 'assistant' && info.time && typeof info.time === 'object' && info.time.completed) {
+        return 'complete';
+      }
+    }
+  }
+
+  return null;
+}
+
+function notificationBody(eventType) {
+  if (eventType === 'complete') {
+    return {
+      title: 'Session complete',
+      body: 'OpenCode finished your monitored prompt.',
+    };
+  }
+  if (eventType === 'permission') {
+    return {
+      title: 'Action needed',
+      body: 'OpenCode needs a permission decision.',
+    };
+  }
+  return {
+    title: 'Session error',
+    body: 'OpenCode reported an error for your monitored session.',
+  };
+}
+
+async function sendPush({ expoPushToken, eventType, sessionID, jobID }) {
+  const dedupeKey = `${jobID}:${eventType}`;
+  if (dedupe.has(dedupeKey)) return;
+  dedupe.add(dedupeKey);
+
+  const text = notificationBody(eventType);
+
+  const payload = {
+    to: expoPushToken,
+    priority: 'high',
+    _contentAvailable: true,
+    data: {
+      eventType,
+      sessionID,
+      jobID,
+      title: text.title,
+      body: text.body,
+      dedupeKey,
+      at: Date.now(),
+    },
+  };
+
+  const response = await fetch(EXPO_PUSH_URL, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Accept: 'application/json',
+    },
+    body: JSON.stringify(payload),
+  });
+
+  if (!response.ok) {
+    const body = await response.text();
+    throw new Error(`Push send failed (${response.status}): ${body || response.statusText}`);
+  }
+}
+
+async function* parseSSE(readable) {
+  const reader = readable.getReader();
+  const decoder = new TextDecoder();
+  let pending = '';
+
+  try {
+    while (true) {
+      const next = await reader.read();
+      if (next.done) break;
+
+      pending += decoder.decode(next.value, { stream: true });
+      const blocks = pending.split(/\r?\n\r?\n/);
+      pending = blocks.pop() || '';
+
+      for (const block of blocks) {
+        const lines = block.split(/\r?\n/);
+        const dataLines = [];
+        for (const line of lines) {
+          if (!line || line.startsWith(':')) continue;
+          if (line.startsWith('data:')) {
+            dataLines.push(line.slice(5).trimStart());
+          }
+        }
+        if (dataLines.length > 0) {
+          yield dataLines.join('\n');
+        }
+      }
+    }
+  } finally {
+    reader.releaseLock();
+  }
+}
+
+function cleanupStreamIfUnused(baseURL) {
+  const key = baseURL.replace(/\/+$/, '');
+  const entry = streams.get(key);
+  if (!entry) return;
+
+  const stillUsed = Array.from(jobs.values()).some((job) => !job.done && job.opencodeBaseURL === key);
+  if (stillUsed) return;
+
+  entry.abortController.abort();
+  streams.delete(key);
+}
+
+async function runStream(baseURL) {
+  const key = baseURL.replace(/\/+$/, '');
+  if (streams.has(key)) return;
+
+  const abortController = new AbortController();
+  streams.set(key, {
+    key,
+    opencodeBaseURL: key,
+    abortController,
+    sessions: new Set(),
+    running: true,
+  });
+
+  while (!abortController.signal.aborted) {
+    try {
+      const response = await fetch(`${key}/event`, {
+        signal: abortController.signal,
+        headers: {
+          Accept: 'text/event-stream',
+          'Cache-Control': 'no-cache',
+        },
+      });
+
+      if (!response.ok || !response.body) {
+        throw new Error(`SSE connect failed (${response.status})`);
+      }
+
+      for await (const data of parseSSE(response.body)) {
+        if (abortController.signal.aborted) break;
+
+        let event;
+        try {
+          event = JSON.parse(data);
+        } catch {
+          continue;
+        }
+
+        const sessionID = extractSessionID(event);
+        if (!sessionID) continue;
+
+        const eventType = classifyEvent(event);
+        if (!eventType) continue;
+
+        const related = Array.from(jobs.values()).filter(
+          (job) => !job.done && job.opencodeBaseURL === key && job.sessionID === sessionID,
+        );
+        if (related.length === 0) continue;
+
+        await Promise.allSettled(
+          related.map(async (job) => {
+            await sendPush({
+              expoPushToken: job.expoPushToken,
+              eventType,
+              sessionID,
+              jobID: job.jobID,
+            });
+
+            if (eventType === 'complete' || eventType === 'error') {
+              const current = jobs.get(job.jobID);
+              if (current) current.done = true;
+            }
+          }),
+        );
+      }
+    } catch (error) {
+      if (abortController.signal.aborted) break;
+      console.warn('[relay] SSE loop error:', error instanceof Error ? error.message : String(error));
+      await new Promise((resolve) => setTimeout(resolve, 1200));
+    }
+  }
+}
+
+const server = createServer(async (req, res) => {
+  if (!req.url || !req.method) {
+    json(res, 400, { ok: false, error: 'Invalid request' });
+    return;
+  }
+
+  if (req.url === '/health' && req.method === 'GET') {
+    json(res, 200, {
+      ok: true,
+      activeJobs: Array.from(jobs.values()).filter((job) => !job.done).length,
+      streams: streams.size,
+    });
+    return;
+  }
+
+  if (req.url === '/v1/monitor/start' && req.method === 'POST') {
+    try {
+      const body = await readJSON(req);
+      const jobID = String(body.jobID || '').trim();
+      const sessionID = String(body.sessionID || '').trim();
+      const opencodeBaseURL = String(body.opencodeBaseURL || '').trim().replace(/\/+$/, '');
+      const relayBaseURL = String(body.relayBaseURL || '').trim().replace(/\/+$/, '');
+      const expoPushToken = String(body.expoPushToken || '').trim();
+
+      if (!jobID || !sessionID || !opencodeBaseURL || !expoPushToken) {
+        json(res, 400, { ok: false, error: 'Missing required fields' });
+        return;
+      }
+
+      jobs.set(jobID, {
+        jobID,
+        sessionID,
+        opencodeBaseURL,
+        relayBaseURL,
+        expoPushToken,
+        createdAt: Date.now(),
+        done: false,
+      });
+
+      runStream(opencodeBaseURL).catch((error) => {
+        console.warn('[relay] runStream failed:', error instanceof Error ? error.message : String(error));
+      });
+
+      json(res, 200, { ok: true });
+      return;
+    } catch (error) {
+      json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
+      return;
+    }
+  }
+
+  if (req.url === '/v1/monitor/stop' && req.method === 'POST') {
+    try {
+      const body = await readJSON(req);
+      const jobID = String(body.jobID || '').trim();
+      const token = String(body.expoPushToken || '').trim();
+
+      if (!jobID || !token) {
+        json(res, 400, { ok: false, error: 'Missing required fields' });
+        return;
+      }
+
+      const job = jobs.get(jobID);
+      if (job && job.expoPushToken === token) {
+        job.done = true;
+        cleanupStreamIfUnused(job.opencodeBaseURL);
+      }
+
+      json(res, 200, { ok: true });
+      return;
+    } catch (error) {
+      json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
+      return;
+    }
+  }
+
+  json(res, 404, { ok: false, error: 'Not found' });
+});
+
+server.listen(PORT, HOST, () => {
+  console.log(`[relay] listening on http://${HOST}:${PORT}`);
+});

+ 114 - 0
packages/mobile-voice/scripts/reset-project.js

@@ -0,0 +1,114 @@
+#!/usr/bin/env node
+
+/**
+ * This script is used to reset the project to a blank state.
+ * It deletes or moves the /src and /scripts directories to /example based on user input and creates a new /src/app directory with an index.tsx and _layout.tsx file.
+ * You can remove the `reset-project` script from package.json and safely delete this file after running it.
+ */
+
+const fs = require("fs");
+const path = require("path");
+const readline = require("readline");
+
+const root = process.cwd();
+const oldDirs = ["src", "scripts"];
+const exampleDir = "example";
+const newAppDir = "src/app";
+const exampleDirPath = path.join(root, exampleDir);
+
+const indexContent = `import { Text, View, StyleSheet } from "react-native";
+
+export default function Index() {
+  return (
+    <View style={styles.container}>
+      <Text>Edit src/app/index.tsx to edit this screen.</Text>
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    alignItems: "center",
+    justifyContent: "center",
+  },
+});
+`;
+
+const layoutContent = `import { Stack } from "expo-router";
+
+export default function RootLayout() {
+  return <Stack />;
+}
+`;
+
+const rl = readline.createInterface({
+  input: process.stdin,
+  output: process.stdout,
+});
+
+const moveDirectories = async (userInput) => {
+  try {
+    if (userInput === "y") {
+      // Create the app-example directory
+      await fs.promises.mkdir(exampleDirPath, { recursive: true });
+      console.log(`📁 /${exampleDir} directory created.`);
+    }
+
+    // Move old directories to new app-example directory or delete them
+    for (const dir of oldDirs) {
+      const oldDirPath = path.join(root, dir);
+      if (fs.existsSync(oldDirPath)) {
+        if (userInput === "y") {
+          const newDirPath = path.join(root, exampleDir, dir);
+          await fs.promises.rename(oldDirPath, newDirPath);
+          console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
+        } else {
+          await fs.promises.rm(oldDirPath, { recursive: true, force: true });
+          console.log(`❌ /${dir} deleted.`);
+        }
+      } else {
+        console.log(`➡️ /${dir} does not exist, skipping.`);
+      }
+    }
+
+    // Create new /src/app directory
+    const newAppDirPath = path.join(root, newAppDir);
+    await fs.promises.mkdir(newAppDirPath, { recursive: true });
+    console.log("\n📁 New /src/app directory created.");
+
+    // Create index.tsx
+    const indexPath = path.join(newAppDirPath, "index.tsx");
+    await fs.promises.writeFile(indexPath, indexContent);
+    console.log("📄 src/app/index.tsx created.");
+
+    // Create _layout.tsx
+    const layoutPath = path.join(newAppDirPath, "_layout.tsx");
+    await fs.promises.writeFile(layoutPath, layoutContent);
+    console.log("📄 src/app/_layout.tsx created.");
+
+    console.log("\n✅ Project reset complete. Next steps:");
+    console.log(
+      `1. Run \`npx expo start\` to start a development server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${
+        userInput === "y"
+          ? `\n4. Delete the /${exampleDir} directory when you're done referencing it.`
+          : ""
+      }`
+    );
+  } catch (error) {
+    console.error(`❌ Error during script execution: ${error.message}`);
+  }
+};
+
+rl.question(
+  "Do you want to move existing files to /example instead of deleting them? (Y/n): ",
+  (answer) => {
+    const userInput = answer.trim().toLowerCase() || "y";
+    if (userInput === "y" || userInput === "n") {
+      moveDirectories(userInput).finally(() => rl.close());
+    } else {
+      console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
+      rl.close();
+    }
+  }
+);

+ 22 - 0
packages/mobile-voice/src/app/_layout.tsx

@@ -0,0 +1,22 @@
+import React from "react"
+import { Slot } from "expo-router"
+import { LogBox } from "react-native"
+import {
+  configureNotificationBehavior,
+  registerBackgroundNotificationTask,
+} from "@/notifications/monitoring-notifications"
+
+// Suppress known non-actionable warnings from third-party libs.
+LogBox.ignoreLogs([
+  "RecordingNotificationManager is not implemented on iOS",
+  "`transcribeRealtime` is deprecated, use `RealtimeTranscriber` instead",
+  "Parsed error meta:",
+  "Session activation failed",
+])
+
+configureNotificationBehavior()
+registerBackgroundNotificationTask().catch(() => {})
+
+export default function RootLayout() {
+  return <Slot />
+}

+ 5503 - 0
packages/mobile-voice/src/app/index.tsx

@@ -0,0 +1,5503 @@
+import React, { useEffect, useState, useRef, useCallback, useMemo } from "react"
+import {
+  StyleSheet,
+  Text,
+  View,
+  Pressable,
+  ScrollView,
+  FlatList,
+  Modal,
+  Alert,
+  ActivityIndicator,
+  LayoutChangeEvent,
+  Linking,
+  Platform,
+  Switch,
+} from "react-native"
+import Animated, {
+  useSharedValue,
+  useAnimatedStyle,
+  withSpring,
+  withSequence,
+  withTiming,
+  Easing,
+  runOnJS,
+  interpolate,
+  Extrapolation,
+} from "react-native-reanimated"
+import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"
+import { StatusBar } from "expo-status-bar"
+import { SymbolView } from "expo-symbols"
+import * as Haptics from "expo-haptics"
+import { useAudioPlayer } from "expo-audio"
+import { initWhisper, releaseAllWhisper, type WhisperContext } from "whisper.rn"
+import { RealtimeTranscriber, type RealtimeTranscribeEvent } from "whisper.rn/src/realtime-transcription"
+import { AudioPcmStreamAdapter } from "whisper.rn/src/realtime-transcription/adapters/AudioPcmStreamAdapter"
+import { AudioManager } from "react-native-audio-api"
+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,
+  type PromptHistoryEntry,
+} from "@/hooks/use-monitoring"
+import { DEFAULT_RELAY_URL, looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions"
+import { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications"
+
+const CONTROL_HEIGHT = 86
+const SEND_SETTLE_MS = 240
+const WAVEFORM_ROWS = 5
+const WAVEFORM_CELL_SIZE = 8
+const WAVEFORM_CELL_GAP = 2
+const READER_ACTION_SIZE = 20
+const READER_ACTION_GAP = 14
+const READER_ACTION_TRAVEL = READER_ACTION_SIZE + READER_ACTION_GAP
+const READER_ACTION_RAIL_WIDTH = READER_ACTION_SIZE * 2 + READER_ACTION_GAP
+const AGENT_SUCCESS_GREEN = "#91C29D"
+
+// ---------------------------------------------------------------------------
+// Semantic color tokens
+// ---------------------------------------------------------------------------
+const C = {
+  bg: "#121212",
+  surface: "#151515",
+  surfaceRaised: "#17181B",
+  border: "#282828",
+  borderSubtle: "#222733",
+  borderMuted: "#242424",
+  textPrimary: "#F1F1F1",
+  textSecondary: "#D6DAE4",
+  textTertiary: "#B8BDC9",
+  textMuted: "#8F97AA",
+  textDimmed: "#6F7686",
+  textPlaceholder: "#4A5060",
+  blue: "#1D6FF4",
+  blueBorder: "#1557C3",
+  red: "#FF2E3F",
+  redBg: "#421B17",
+  green: AGENT_SUCCESS_GREEN,
+  orange: "#FFB347",
+  yellow: "#D2A542",
+} as const
+
+// Unified primary button metrics
+const BTN_PRIMARY = {
+  minHeight: 52,
+  borderRadius: 16,
+  borderWidth: 2,
+  backgroundColor: C.blue,
+  borderColor: C.blueBorder,
+} as const
+const DROPDOWN_VISIBLE_ROWS = 6
+const DROPDOWN_ROW_HEIGHT = 42
+const SERVER_MENU_SECTION_HEIGHT = 56
+const SERVER_MENU_ENTRY_HEIGHT = 36
+const SERVER_MENU_FOOTER_HEIGHT = 28
+// If the press duration is shorter than this, treat it as a tap (toggle)
+const TAP_THRESHOLD_MS = 300
+const SERVER_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-servers.json`
+const WHISPER_SETTINGS_FILE = `${FileSystem.documentDirectory}mobile-voice-whisper-settings.json`
+const ONBOARDING_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-onboarding.json`
+const WHISPER_MODELS_DIR = `${FileSystem.documentDirectory}whisper-models`
+const WHISPER_REPO = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main"
+const WHISPER_MODELS = [
+  "ggml-tiny.en-q5_1.bin",
+  "ggml-tiny.en-q8_0.bin",
+  "ggml-tiny.en.bin",
+  "ggml-tiny-q5_1.bin",
+  "ggml-tiny-q8_0.bin",
+  "ggml-tiny.bin",
+  "ggml-base.en-q5_1.bin",
+  "ggml-base.en-q8_0.bin",
+  "ggml-base.en.bin",
+  "ggml-base-q5_1.bin",
+  "ggml-base-q8_0.bin",
+  "ggml-base.bin",
+  "ggml-small.en-q5_1.bin",
+  "ggml-small.en-q8_0.bin",
+  "ggml-small.en.bin",
+  "ggml-small-q5_1.bin",
+  "ggml-small-q8_0.bin",
+  "ggml-small.bin",
+  "ggml-medium.en-q5_0.bin",
+  "ggml-medium.en-q8_0.bin",
+  "ggml-medium.en.bin",
+  "ggml-medium-q5_0.bin",
+  "ggml-medium-q8_0.bin",
+  "ggml-medium.bin",
+] as const
+
+type WhisperModelID = (typeof WHISPER_MODELS)[number]
+type TranscriptionMode = "bulk" | "realtime"
+type PermissionPromptState = "idle" | "pending" | "granted" | "denied"
+const DEFAULT_WHISPER_MODEL: WhisperModelID = "ggml-small-q8_0.bin"
+const DEFAULT_TRANSCRIPTION_MODE: TranscriptionMode = "bulk"
+
+const WHISPER_MODEL_LABELS: Record<WhisperModelID, string> = {
+  "ggml-tiny.en-q5_1.bin": "tiny.en q5_1",
+  "ggml-tiny.en-q8_0.bin": "tiny.en q8_0",
+  "ggml-tiny.en.bin": "tiny.en",
+  "ggml-tiny-q5_1.bin": "tiny q5_1",
+  "ggml-tiny-q8_0.bin": "tiny q8_0",
+  "ggml-tiny.bin": "tiny",
+  "ggml-base.en-q5_1.bin": "base.en q5_1",
+  "ggml-base.en-q8_0.bin": "base.en q8_0",
+  "ggml-base.en.bin": "base.en",
+  "ggml-base-q5_1.bin": "base q5_1",
+  "ggml-base-q8_0.bin": "base q8_0",
+  "ggml-base.bin": "base",
+  "ggml-small.en-q5_1.bin": "small.en q5_1",
+  "ggml-small.en-q8_0.bin": "small.en q8_0",
+  "ggml-small.en.bin": "small.en",
+  "ggml-small-q5_1.bin": "small q5_1",
+  "ggml-small-q8_0.bin": "small q8_0",
+  "ggml-small.bin": "small",
+  "ggml-medium.en-q5_0.bin": "medium.en q5_0",
+  "ggml-medium.en-q8_0.bin": "medium.en q8_0",
+  "ggml-medium.en.bin": "medium.en",
+  "ggml-medium-q5_0.bin": "medium q5_0",
+  "ggml-medium-q8_0.bin": "medium q8_0",
+  "ggml-medium.bin": "medium",
+}
+
+const READER_OPEN_SYMBOL = {
+  ios: "arrow.up.left.and.arrow.down.right",
+  android: "open_in_full",
+  web: "open_in_full",
+} as const
+
+const READER_CLOSE_SYMBOL = {
+  ios: "arrow.down.right.and.arrow.up.left",
+  android: "close_fullscreen",
+  web: "close_fullscreen",
+} as const
+
+const WHISPER_MODEL_SIZES: Record<WhisperModelID, number> = {
+  "ggml-tiny.en-q5_1.bin": 32166155,
+  "ggml-tiny.en-q8_0.bin": 43550795,
+  "ggml-tiny.en.bin": 77704715,
+  "ggml-tiny-q5_1.bin": 32152673,
+  "ggml-tiny-q8_0.bin": 43537433,
+  "ggml-tiny.bin": 77691713,
+  "ggml-base.en-q5_1.bin": 59721011,
+  "ggml-base.en-q8_0.bin": 81781811,
+  "ggml-base.en.bin": 147964211,
+  "ggml-base-q5_1.bin": 59707625,
+  "ggml-base-q8_0.bin": 81768585,
+  "ggml-base.bin": 147951465,
+  "ggml-small.en-q5_1.bin": 190098681,
+  "ggml-small.en-q8_0.bin": 264477561,
+  "ggml-small.en.bin": 487614201,
+  "ggml-small-q5_1.bin": 190085487,
+  "ggml-small-q8_0.bin": 264464607,
+  "ggml-small.bin": 487601967,
+  "ggml-medium.en-q5_0.bin": 539225533,
+  "ggml-medium.en-q8_0.bin": 823382461,
+  "ggml-medium.en.bin": 1533774781,
+  "ggml-medium-q5_0.bin": 539212467,
+  "ggml-medium-q8_0.bin": 823369779,
+  "ggml-medium.bin": 1533763059,
+}
+
+type WhisperModelFamily = {
+  key: string
+  label: string
+  description: string
+  models: WhisperModelID[]
+}
+
+const WHISPER_MODEL_FAMILIES: WhisperModelFamily[] = [
+  {
+    key: "tiny",
+    label: "Tiny",
+    description: "Fastest, lowest quality",
+    models: [
+      "ggml-tiny.en-q5_1.bin",
+      "ggml-tiny.en-q8_0.bin",
+      "ggml-tiny.en.bin",
+      "ggml-tiny-q5_1.bin",
+      "ggml-tiny-q8_0.bin",
+      "ggml-tiny.bin",
+    ],
+  },
+  {
+    key: "base",
+    label: "Base",
+    description: "Good balance of speed and quality",
+    models: [
+      "ggml-base.en-q5_1.bin",
+      "ggml-base.en-q8_0.bin",
+      "ggml-base.en.bin",
+      "ggml-base-q5_1.bin",
+      "ggml-base-q8_0.bin",
+      "ggml-base.bin",
+    ],
+  },
+  {
+    key: "small",
+    label: "Small",
+    description: "Higher quality, moderate size",
+    models: [
+      "ggml-small.en-q5_1.bin",
+      "ggml-small.en-q8_0.bin",
+      "ggml-small.en.bin",
+      "ggml-small-q5_1.bin",
+      "ggml-small-q8_0.bin",
+      "ggml-small.bin",
+    ],
+  },
+  {
+    key: "medium",
+    label: "Medium",
+    description: "Best quality, largest download",
+    models: [
+      "ggml-medium.en-q5_0.bin",
+      "ggml-medium.en-q8_0.bin",
+      "ggml-medium.en.bin",
+      "ggml-medium-q5_0.bin",
+      "ggml-medium-q8_0.bin",
+      "ggml-medium.bin",
+    ],
+  },
+]
+
+function isWhisperModelID(value: unknown): value is WhisperModelID {
+  return typeof value === "string" && (WHISPER_MODELS as readonly string[]).includes(value)
+}
+
+function isEnglishOnlyWhisperModel(modelID: WhisperModelID): boolean {
+  return modelID.includes(".en")
+}
+
+function isTranscriptionMode(value: unknown): value is TranscriptionMode {
+  return value === "bulk" || value === "realtime"
+}
+
+function formatWhisperModelSize(bytes: number): string {
+  const mib = bytes / (1024 * 1024)
+  if (mib >= 1024) {
+    return `${(mib / 1024).toFixed(1)} GB`
+  }
+
+  return `${Math.round(mib)} MB`
+}
+
+function cleanTranscriptText(text: string): string {
+  return text.replace(/[ \t]+$/gm, "").trimEnd()
+}
+
+function cleanSessionText(text: string): string {
+  return cleanTranscriptText(text).trimStart()
+}
+
+function normalizeTranscriptSessions(text: string): string {
+  const cleaned = cleanTranscriptText(text)
+  if (!cleaned) {
+    return ""
+  }
+
+  return cleaned
+    .split(/\n\n+/)
+    .map((session) => cleanSessionText(session))
+    .filter((session) => session.length > 0)
+    .join("\n\n")
+}
+
+function mergeTranscriptChunk(previous: string, chunk: string): string {
+  const cleanPrevious = cleanTranscriptText(previous)
+  const cleanChunk = cleanSessionText(chunk)
+
+  if (!cleanChunk) {
+    return cleanPrevious
+  }
+
+  if (!cleanPrevious) {
+    return cleanChunk
+  }
+
+  const normalizedChunk = cleanChunk
+  if (!normalizedChunk) {
+    return cleanPrevious
+  }
+
+  if (/^[,.;:!?)]/.test(normalizedChunk)) {
+    return `${cleanPrevious}${normalizedChunk}`
+  }
+
+  return `${cleanPrevious} ${normalizedChunk}`
+}
+
+function formatSessionUpdated(updatedMs: number): string {
+  if (!updatedMs) return ""
+
+  const now = Date.now()
+  const deltaMs = Math.max(0, now - updatedMs)
+  const deltaMin = Math.floor(deltaMs / 60000)
+
+  if (deltaMin < 60) {
+    return `${Math.max(1, deltaMin)} min`
+  }
+
+  const date = new Date(updatedMs)
+  try {
+    return new Intl.DateTimeFormat(undefined, {
+      hour: "numeric",
+      minute: "2-digit",
+    }).format(date)
+  } catch {
+    return date.toLocaleTimeString()
+  }
+}
+
+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 = {
+  serverID?: string
+  relayURL: string
+  relaySecret: string
+  hosts: string[]
+}
+
+type PairHostKind = "tailnet_name" | "tailnet_ip" | "mdns" | "lan" | "loopback" | "public" | "unknown"
+
+type PairHostOption = {
+  url: string
+  kind: PairHostKind
+  label: string
+}
+
+type PairHostProbe = {
+  status: "checking" | "online" | "offline"
+  latencyMs?: number
+  note?: string
+}
+
+type ReaderHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6
+
+type ReaderBlock =
+  | {
+      type: "text"
+      content: string
+    }
+  | {
+      type: "heading"
+      level: ReaderHeadingLevel
+      content: string
+    }
+  | {
+      type: "code"
+      language: string
+      content: string
+    }
+
+type ReaderInlineSegment =
+  | {
+      type: "text"
+      content: string
+    }
+  | {
+      type: "inline_code"
+      content: string
+    }
+  | {
+      type: "italic"
+      content: string
+    }
+  | {
+      type: "bold"
+      content: string
+    }
+  | {
+      type: "bold_italic"
+      content: string
+    }
+
+const AUDIO_SESSION_BUSY_MESSAGE = "Microphone is unavailable while another call is active. End the call and try again."
+
+type Scan = {
+  data: string
+}
+
+type WhisperSavedState = {
+  defaultModel: WhisperModelID
+  mode: TranscriptionMode
+  autoSendOnDictationEnd: boolean
+}
+
+type OnboardingSavedState = {
+  completed: boolean
+}
+
+type Cam = {
+  CameraView: (typeof import("expo-camera"))["CameraView"]
+  requestCameraPermissionsAsync: () => Promise<{ granted: boolean }>
+}
+
+function parsePairShape(data: unknown): Pair | undefined {
+  if (!data || typeof data !== "object") return
+  const version = (data as { v?: unknown }).v
+  if (version !== undefined && version !== 1) return
+  if (typeof (data as { relaySecret?: unknown }).relaySecret !== "string") return
+  if (!Array.isArray((data as { hosts?: unknown }).hosts)) return
+  const hosts = (data as { hosts: unknown[] }).hosts.filter((item): item is string => typeof item === "string")
+  if (!hosts.length) return
+  const relayURLRaw = (data as { relayURL?: unknown }).relayURL
+  const relayURL = typeof relayURLRaw === "string" && relayURLRaw.length > 0 ? relayURLRaw : DEFAULT_RELAY_URL
+  const serverIDRaw = (data as { serverID?: unknown }).serverID
+  const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : undefined
+  return {
+    serverID,
+    relayURL,
+    relaySecret: (data as { relaySecret: string }).relaySecret,
+    hosts,
+  }
+}
+
+function parsePair(input: string): Pair | undefined {
+  const raw = input.trim()
+  if (!raw) return
+
+  const candidates: string[] = [raw]
+
+  try {
+    const url = new URL(raw)
+    const query = url.searchParams.get("pair") ?? url.searchParams.get("payload")
+    if (query) {
+      candidates.unshift(query)
+    }
+  } catch {
+    // Raw JSON payload is still supported.
+  }
+
+  const seen = new Set<string>()
+  for (const candidate of candidates) {
+    if (!candidate || seen.has(candidate)) continue
+    seen.add(candidate)
+
+    try {
+      const parsed = JSON.parse(candidate)
+      const pair = parsePairShape(parsed)
+      if (pair) {
+        return pair
+      }
+    } catch {
+      // keep trying fallbacks
+    }
+  }
+}
+
+function isLoopback(hostname: string): boolean {
+  return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "0.0.0.0" || hostname === "::1"
+}
+
+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
+}
+
+function classifyPairHost(hostname: string): PairHostKind {
+  if (isLoopback(hostname)) return "loopback"
+  if (hostname.endsWith(".ts.net")) return "tailnet_name"
+  if (isCarrierGradeNat(hostname)) return "tailnet_ip"
+  if (hostname.endsWith(".local")) return "mdns"
+  if (hostname.startsWith("10.") || hostname.startsWith("192.168.") || /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) {
+    return "lan"
+  }
+  if (hostname.includes(".")) return "public"
+  return "unknown"
+}
+
+function pairHostKindLabel(kind: PairHostKind): string {
+  switch (kind) {
+    case "tailnet_name":
+      return "Tailscale DNS"
+    case "tailnet_ip":
+      return "Tailscale IP"
+    case "mdns":
+      return "mDNS"
+    case "lan":
+      return "LAN"
+    case "loopback":
+      return "Loopback"
+    case "public":
+      return "Public"
+    default:
+      return "Unknown"
+  }
+}
+
+function normalizePairHosts(input: string[]): PairHostOption[] {
+  const seen = new Set<string>()
+  const normalized = input
+    .map((item) => item.trim())
+    .filter(Boolean)
+    .map((item) => {
+      try {
+        const parsed = new URL(item)
+        const url = `${parsed.protocol}//${parsed.host}`
+        if (seen.has(url)) return null
+        seen.add(url)
+        return {
+          url,
+          kind: classifyPairHost(parsed.hostname),
+          label: parsed.hostname,
+        } as PairHostOption
+      } catch {
+        return null
+      }
+    })
+    .filter((item): item is PairHostOption => !!item)
+
+  const nonLoopback = normalized.filter((item) => item.kind !== "loopback")
+  return nonLoopback.length > 0 ? nonLoopback : normalized
+}
+
+function pairProbeLabel(probe: PairHostProbe | undefined): string {
+  if (!probe || probe.status === "checking") return "Checking..."
+  if (probe.status === "online") return `${probe.latencyMs ?? 0} ms`
+  return probe.note ?? "Unavailable"
+}
+
+function pairProbeSummary(probe: PairHostProbe | undefined): string {
+  if (!probe || probe.status === "checking") {
+    return "Health check in progress"
+  }
+
+  if (probe.status === "online") {
+    return `Healthy, reached in ${probe.latencyMs ?? 0} ms`
+  }
+
+  return `Health check: ${probe.note ?? "Unavailable"}`
+}
+
+function parseReaderBlocks(input: string): ReaderBlock[] {
+  const normalized = input.replace(/\r\n/g, "\n")
+  const lines = normalized.split("\n")
+
+  const blocks: ReaderBlock[] = []
+  const prose: string[] = []
+  const code: string[] = []
+  let fence: "```" | "~~~" | null = null
+  let language = ""
+
+  const flushProse = () => {
+    const content = prose.join("\n").trim()
+    if (content.length > 0) {
+      blocks.push({ type: "text", content })
+    }
+    prose.length = 0
+  }
+
+  const flushCode = () => {
+    const content = code.join("\n").replace(/\n+$/, "")
+    blocks.push({ type: "code", language, content })
+    code.length = 0
+    language = ""
+    fence = null
+  }
+
+  for (const line of lines) {
+    if (!fence) {
+      const match = /^\s*(```|~~~)(.*)$/.exec(line)
+      if (match) {
+        flushProse()
+        fence = match[1] as "```" | "~~~"
+        language = match[2]?.trim().split(/\s+/)[0] ?? ""
+      } else {
+        const headingMatch = /^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line)
+        if (headingMatch) {
+          flushProse()
+          blocks.push({
+            type: "heading",
+            level: headingMatch[1].length as ReaderHeadingLevel,
+            content: headingMatch[2].trim(),
+          })
+        } else {
+          prose.push(line)
+        }
+      }
+      continue
+    }
+
+    if (line.trimStart().startsWith(fence)) {
+      flushCode()
+      continue
+    }
+
+    code.push(line)
+  }
+
+  if (fence) {
+    prose.push(`${fence}${language ? language : ""}`)
+    prose.push(...code)
+  }
+
+  flushProse()
+
+  return blocks
+}
+
+function parseReaderAsteriskSegments(input: string): ReaderInlineSegment[] {
+  const segments: ReaderInlineSegment[] = []
+  let cursor = 0
+  let textStart = 0
+
+  while (cursor < input.length) {
+    if (input[cursor] !== "*") {
+      cursor += 1
+      continue
+    }
+
+    const marker = input.startsWith("***", cursor) ? "***" : input.startsWith("**", cursor) ? "**" : "*"
+    const end = input.indexOf(marker, cursor + marker.length)
+    if (end === -1) {
+      cursor += 1
+      continue
+    }
+
+    const content = input.slice(cursor + marker.length, end)
+    if (content.trim().length === 0 || content !== content.trim() || content.includes("\n")) {
+      cursor += 1
+      continue
+    }
+
+    if (cursor > textStart) {
+      segments.push({ type: "text", content: input.slice(textStart, cursor) })
+    }
+
+    segments.push({
+      type: marker === "***" ? "bold_italic" : marker === "**" ? "bold" : "italic",
+      content,
+    })
+
+    cursor = end + marker.length
+    textStart = cursor
+  }
+
+  if (textStart < input.length) {
+    segments.push({ type: "text", content: input.slice(textStart) })
+  }
+
+  return segments.length > 0 ? segments : [{ type: "text", content: input }]
+}
+
+function parseReaderInlineSegments(input: string): ReaderInlineSegment[] {
+  const segments: ReaderInlineSegment[] = []
+  const pattern = /(`+|~+)([^`~\n]+?)\1/g
+  let cursor = 0
+
+  for (const match of input.matchAll(pattern)) {
+    const full = match[0] ?? ""
+    const code = match[2] ?? ""
+    const start = match.index ?? 0
+    const end = start + full.length
+
+    if (start > cursor) {
+      segments.push(...parseReaderAsteriskSegments(input.slice(cursor, start)))
+    }
+
+    if (code.length > 0) {
+      segments.push({ type: "inline_code", content: code })
+    }
+
+    cursor = end
+  }
+
+  if (cursor < input.length) {
+    segments.push(...parseReaderAsteriskSegments(input.slice(cursor)))
+  }
+
+  if (segments.length === 0) {
+    segments.push({ type: "text", content: input })
+  }
+
+  return segments
+}
+
+function isAudioSessionBusyError(error: unknown): boolean {
+  const message = error instanceof Error ? `${error.name} ${error.message}` : String(error ?? "")
+  return (
+    message.includes("InsufficientPriority") ||
+    message.includes("561017449") ||
+    message.includes("Session activation failed")
+  )
+}
+
+function normalizeAudioStartErrorMessage(error: unknown): string {
+  if (isAudioSessionBusyError(error)) {
+    return AUDIO_SESSION_BUSY_MESSAGE
+  }
+
+  const raw = error instanceof Error ? error.message.trim() : String(error ?? "").trim()
+  if (!raw) {
+    return "Unable to activate microphone."
+  }
+
+  return raw
+}
+
+export default function DictationScreen() {
+  const insets = useSafeAreaInsets()
+  const [camera, setCamera] = useState<Cam | null>(null)
+  const [defaultWhisperModel, setDefaultWhisperModel] = useState<WhisperModelID>(DEFAULT_WHISPER_MODEL)
+  const [onboardingReady, setOnboardingReady] = useState(false)
+  const [onboardingComplete, setOnboardingComplete] = useState(false)
+  const [onboardingStep, setOnboardingStep] = useState(0)
+  const [microphonePermissionState, setMicrophonePermissionState] = useState<PermissionPromptState>("idle")
+  const [notificationPermissionState, setNotificationPermissionState] = useState<PermissionPromptState>("idle")
+  const [localNetworkPermissionState, setLocalNetworkPermissionState] = useState<PermissionPromptState>("idle")
+  const [activeWhisperModel, setActiveWhisperModel] = useState<WhisperModelID | null>(null)
+  const [installedWhisperModels, setInstalledWhisperModels] = useState<WhisperModelID[]>([])
+  const [whisperSettingsOpen, setWhisperSettingsOpen] = useState(false)
+  const [downloadingModelID, setDownloadingModelID] = useState<WhisperModelID | null>(null)
+  const [downloadProgress, setDownloadProgress] = useState(0)
+  const [isPreparingWhisperModel, setIsPreparingWhisperModel] = useState(true)
+  const [transcriptionMode, setTranscriptionMode] = useState<TranscriptionMode>(DEFAULT_TRANSCRIPTION_MODE)
+  const [autoSendOnDictationEnd, setAutoSendOnDictationEnd] = useState(false)
+  const [isTranscribingBulk, setIsTranscribingBulk] = useState(false)
+  const [whisperError, setWhisperError] = useState("")
+  const [transcribedText, setTranscribedText] = useState("")
+  const [isRecording, setIsRecording] = useState(false)
+  const [permissionGranted, setPermissionGranted] = useState(false)
+  const [controlsWidth, setControlsWidth] = useState(0)
+  const [hasCompletedSession, setHasCompletedSession] = useState(false)
+  const [isSending, setIsSending] = useState(false)
+  const [agentStateDismissed, setAgentStateDismissed] = useState(false)
+  const [readerModeOpen, setReaderModeOpen] = useState(false)
+  const [readerModeRendered, setReaderModeRendered] = useState(false)
+  const [transcriptionAreaHeight, setTranscriptionAreaHeight] = useState(0)
+  const [agentStateCardHeight, setAgentStateCardHeight] = useState(0)
+  const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none")
+  const [dropdownRenderMode, setDropdownRenderMode] = useState<Exclude<DropdownMode, "none">>("server")
+  const [serverMenuListHeight, setServerMenuListHeight] = useState(0)
+  const [sessionMenuListHeight, setSessionMenuListHeight] = useState(0)
+  const [serverMenuFooterHeight, setServerMenuFooterHeight] = useState(0)
+  const [sessionMenuFooterHeight, setSessionMenuFooterHeight] = useState(0)
+  const [sessionCreateMode, setSessionCreateMode] = useState<"same" | "root" | null>(null)
+  const [scanOpen, setScanOpen] = useState(false)
+  const [pairSelectionOpen, setPairSelectionOpen] = useState(false)
+  const [pendingPair, setPendingPair] = useState<Pair | null>(null)
+  const [pairHostOptions, setPairHostOptions] = useState<PairHostOption[]>([])
+  const [selectedPairHostURL, setSelectedPairHostURL] = useState<string | null>(null)
+  const [pairHostProbes, setPairHostProbes] = useState<Record<string, PairHostProbe>>({})
+  const [isConnectingPairHost, setIsConnectingPairHost] = useState(false)
+  const [camGranted, setCamGranted] = useState(false)
+  const [waveformLevels, setWaveformLevels] = useState<number[]>(Array.from({ length: 24 }, () => 0))
+  const [waveformTick, setWaveformTick] = useState(0)
+  const waveformLevelsRef = useRef<number[]>(Array.from({ length: 24 }, () => 0))
+  const lastWaveformCommitRef = useRef(0)
+  const sendPlayer = useAudioPlayer(require("../../assets/sounds/send-whoosh.mp3"))
+  const completePlayer = useAudioPlayer(require("../../assets/sounds/complete.wav"))
+
+  const isRecordingRef = useRef(false)
+  const isStartingRef = useRef(false)
+  const activeSessionRef = useRef(0)
+  const scrollViewRef = useRef<ScrollView>(null)
+  const isHoldingRef = useRef(false)
+  const pressInTimeRef = useRef(0)
+  const accumulatedRef = useRef("")
+  const baseTextRef = useRef("")
+  const whisperContextRef = useRef<WhisperContext | null>(null)
+  const whisperContextModelRef = useRef<WhisperModelID | null>(null)
+  const whisperTranscriberRef = useRef<RealtimeTranscriber | null>(null)
+  const bulkAudioStreamRef = useRef<AudioPcmStreamAdapter | null>(null)
+  const bulkAudioChunksRef = useRef<Uint8Array[]>([])
+  const bulkTranscriptionJobRef = useRef(0)
+  const downloadProgressRef = useRef(0)
+  const autoSendSignatureRef = useRef("")
+  const waveformPulseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
+  const sendSettleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const scanLockRef = useRef(false)
+  const pairProbeRunRef = useRef(0)
+  const whisperRestoredRef = useRef(false)
+  const promptPagerRef = useRef<FlatList<PromptHistoryEntry | "live">>(null)
+  const promptPagerPageRef = useRef(-1)
+
+  const closeDropdown = useCallback(() => {
+    setDropdownMode("none")
+  }, [])
+
+  const discoveryEnabled = onboardingComplete && localNetworkPermissionState !== "denied" && dropdownMode === "server"
+
+  const {
+    servers,
+    serversRef,
+    activeServerId,
+    setActiveServerId,
+    activeServerIdRef,
+    activeSessionId,
+    setActiveSessionId,
+    activeSessionIdRef,
+    restoredRef,
+    refreshServerStatusAndSessions,
+    refreshAllServerHealth,
+    selectServer,
+    selectSession,
+    removeServer,
+    addServer,
+    createSession,
+    findServerForSession,
+  } = useServerSessions()
+
+  const { discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery } = useMdnsDiscovery(
+    {
+      enabled: discoveryEnabled,
+    },
+  )
+
+  const {
+    beginMonitoring,
+    activePermissionRequest,
+    devicePushToken,
+    latestAssistantContext,
+    latestPromptText,
+    latestAssistantResponse,
+    monitorJob,
+    monitorStatus,
+    pendingPermissionCount,
+    promptHistory,
+    respondingPermissionID,
+    respondToPermission,
+    setDevicePushToken,
+    setLatestPromptText,
+    setPromptHistory,
+    setMonitorStatus,
+  } = useMonitoring({
+    completePlayer,
+    closeDropdown,
+    findServerForSession,
+    refreshServerStatusAndSessions,
+    servers,
+    serversRef,
+    restoredRef,
+    activeServerId,
+    activeSessionId,
+    activeServerIdRef,
+    activeSessionIdRef,
+    setActiveServerId,
+    setActiveSessionId,
+    setAgentStateDismissed,
+    setNotificationPermissionState,
+  })
+
+  useEffect(() => {
+    let mounted = true
+
+    void (async () => {
+      let complete = false
+
+      try {
+        const data = await FileSystem.readAsStringAsync(ONBOARDING_STATE_FILE)
+        if (data) {
+          const parsed = JSON.parse(data) as Partial<OnboardingSavedState>
+          complete = Boolean(parsed.completed)
+        }
+      } catch {
+        // No onboarding state file yet.
+      }
+
+      if (!complete) {
+        try {
+          const [serverInfo, whisperInfo] = await Promise.all([
+            FileSystem.getInfoAsync(SERVER_STATE_FILE),
+            FileSystem.getInfoAsync(WHISPER_SETTINGS_FILE),
+          ])
+
+          if (serverInfo.exists || whisperInfo.exists) {
+            complete = true
+          }
+        } catch {
+          // Keep first-install behavior if metadata check fails.
+        }
+
+        if (complete) {
+          void FileSystem.writeAsStringAsync(ONBOARDING_STATE_FILE, JSON.stringify({ completed: true })).catch(() => {})
+        }
+      }
+
+      if (mounted) {
+        setOnboardingComplete(complete)
+        setOnboardingReady(true)
+      }
+    })()
+
+    return () => {
+      mounted = false
+    }
+  }, [])
+
+  const modelPath = useCallback((modelID: WhisperModelID) => `${WHISPER_MODELS_DIR}/${modelID}`, [])
+
+  const refreshInstalledWhisperModels = useCallback(async () => {
+    const next: WhisperModelID[] = []
+
+    for (const modelID of WHISPER_MODELS) {
+      try {
+        const info = await FileSystem.getInfoAsync(modelPath(modelID))
+        if (info.exists) {
+          next.push(modelID)
+        }
+      } catch {
+        // Ignore model metadata read errors.
+      }
+    }
+
+    setInstalledWhisperModels(next)
+    return next
+  }, [modelPath])
+
+  const stopWaveformPulse = useCallback(() => {
+    if (waveformPulseIntervalRef.current) {
+      clearInterval(waveformPulseIntervalRef.current)
+      waveformPulseIntervalRef.current = null
+    }
+  }, [])
+
+  const clearWaveform = useCallback(() => {
+    const cleared = new Array(waveformLevelsRef.current.length).fill(0)
+    waveformLevelsRef.current = cleared
+    setWaveformLevels(cleared)
+    setWaveformTick(Date.now())
+  }, [])
+
+  useEffect(() => {
+    return () => {
+      if (sendSettleTimeoutRef.current) {
+        clearTimeout(sendSettleTimeoutRef.current)
+      }
+      stopWaveformPulse()
+    }
+  }, [stopWaveformPulse])
+
+  const ensureAudioInputRoute = useCallback(async () => {
+    try {
+      const devices = await AudioManager.getDevicesInfo()
+      if (devices.currentInputs.length === 0 && devices.availableInputs.length > 0) {
+        const pick = devices.availableInputs[0]
+        await AudioManager.setInputDevice(pick.id)
+      }
+    } catch {
+      // Input route setup is best-effort.
+    }
+  }, [])
+
+  const activateAudioSession = useCallback(
+    async (trigger: "startup" | "record") => {
+      try {
+        await AudioManager.setAudioSessionActivity(true)
+        return true
+      } catch (error) {
+        const message = normalizeAudioStartErrorMessage(error)
+        if (trigger === "record") {
+          setWhisperError(message)
+        }
+
+        if (isAudioSessionBusyError(error)) {
+          console.warn("[Audio] Session activation deferred:", { trigger, message })
+          return false
+        }
+
+        console.warn("[Audio] Session activation failed:", { trigger, message })
+        return false
+      }
+    },
+    [setWhisperError],
+  )
+
+  // Set up audio session and check microphone permissions on mount.
+  useEffect(() => {
+    void (async () => {
+      try {
+        AudioManager.setAudioSessionOptions({
+          iosCategory: "playAndRecord",
+          iosMode: "spokenAudio",
+          iosOptions: ["allowBluetoothHFP", "defaultToSpeaker"],
+        })
+
+        const sessionReady = await activateAudioSession("startup")
+
+        const permission = await AudioManager.checkRecordingPermissions()
+        const granted = permission === "Granted"
+        setPermissionGranted(granted)
+        setMicrophonePermissionState(granted ? "granted" : permission === "Denied" ? "denied" : "idle")
+
+        if (granted && sessionReady) {
+          await ensureAudioInputRoute()
+        }
+      } catch (e) {
+        const message = normalizeAudioStartErrorMessage(e)
+        console.warn("[Audio] Setup warning:", message)
+      }
+    })()
+  }, [activateAudioSession, ensureAudioInputRoute])
+
+  const loadWhisperContext = useCallback(
+    async (modelID: WhisperModelID) => {
+      if (whisperContextRef.current && whisperContextModelRef.current === modelID) {
+        setActiveWhisperModel(modelID)
+        return whisperContextRef.current
+      }
+
+      setIsPreparingWhisperModel(true)
+      setWhisperError("")
+
+      try {
+        const existing = whisperContextRef.current
+        whisperContextRef.current = null
+        whisperContextModelRef.current = null
+        if (existing) {
+          await existing.release().catch(() => {})
+        }
+
+        const context = await initWhisper({
+          filePath: modelPath(modelID),
+          useGpu: Platform.OS === "ios",
+        })
+
+        whisperContextRef.current = context
+        whisperContextModelRef.current = modelID
+        setActiveWhisperModel(modelID)
+        return context
+      } catch (error) {
+        const message = error instanceof Error ? error.message : "Failed to load Whisper model"
+        setWhisperError(message)
+        throw error
+      } finally {
+        setIsPreparingWhisperModel(false)
+      }
+    },
+    [modelPath],
+  )
+
+  const downloadWhisperModel = useCallback(
+    async (modelID: WhisperModelID) => {
+      if (downloadingModelID && downloadingModelID !== modelID) {
+        return false
+      }
+
+      setDownloadingModelID(modelID)
+      downloadProgressRef.current = 0
+      setDownloadProgress(0)
+      setWhisperError("")
+
+      try {
+        await FileSystem.makeDirectoryAsync(WHISPER_MODELS_DIR, { intermediates: true }).catch(() => {})
+
+        const targetPath = modelPath(modelID)
+        await FileSystem.deleteAsync(targetPath, { idempotent: true }).catch(() => {})
+
+        const download = FileSystem.createDownloadResumable(
+          `${WHISPER_REPO}/${modelID}`,
+          targetPath,
+          {},
+          (event: FileSystem.DownloadProgressData) => {
+            const total = event.totalBytesExpectedToWrite
+            if (!total) return
+            const rawProgress = Math.max(0, Math.min(1, event.totalBytesWritten / total))
+            const progress = Math.max(downloadProgressRef.current, rawProgress)
+            downloadProgressRef.current = progress
+            setDownloadProgress(progress)
+          },
+        )
+
+        const result = await download.downloadAsync()
+        if (!result?.uri) {
+          throw new Error("Whisper model download did not complete")
+        }
+
+        await refreshInstalledWhisperModels()
+        return true
+      } catch (error) {
+        const message = error instanceof Error ? error.message : "Failed to download Whisper model"
+        setWhisperError(message)
+        return false
+      } finally {
+        setDownloadingModelID((current) => (current === modelID ? null : current))
+      }
+    },
+    [downloadingModelID, modelPath, refreshInstalledWhisperModels],
+  )
+
+  const ensureWhisperModelReady = useCallback(
+    async (modelID: WhisperModelID) => {
+      const info = await FileSystem.getInfoAsync(modelPath(modelID))
+      if (!info.exists) {
+        const downloaded = await downloadWhisperModel(modelID)
+        if (!downloaded) {
+          throw new Error(`Unable to download ${modelID}`)
+        }
+      }
+      return loadWhisperContext(modelID)
+    },
+    [downloadWhisperModel, loadWhisperContext, modelPath],
+  )
+
+  useEffect(() => {
+    let mounted = true
+
+    void (async () => {
+      await FileSystem.makeDirectoryAsync(WHISPER_MODELS_DIR, { intermediates: true }).catch(() => {})
+
+      let nextDefaultModel: WhisperModelID = DEFAULT_WHISPER_MODEL
+      let nextMode: TranscriptionMode = DEFAULT_TRANSCRIPTION_MODE
+      let nextAutoSendOnDictationEnd = false
+      try {
+        const data = await FileSystem.readAsStringAsync(WHISPER_SETTINGS_FILE)
+        if (data) {
+          const parsed = JSON.parse(data) as Partial<WhisperSavedState>
+          if (isWhisperModelID(parsed.defaultModel)) {
+            nextDefaultModel = parsed.defaultModel
+          }
+          if (isTranscriptionMode(parsed.mode)) {
+            nextMode = parsed.mode
+          }
+          if (parsed.autoSendOnDictationEnd === true) {
+            nextAutoSendOnDictationEnd = true
+          }
+        }
+      } catch {
+        // Use default settings if state file is missing or invalid.
+      }
+
+      if (!mounted) return
+
+      whisperRestoredRef.current = true
+      setDefaultWhisperModel(nextDefaultModel)
+      setTranscriptionMode(nextMode)
+      setAutoSendOnDictationEnd(nextAutoSendOnDictationEnd)
+
+      await refreshInstalledWhisperModels()
+
+      try {
+        await ensureWhisperModelReady(nextDefaultModel)
+      } catch (error) {
+        console.error("[Whisper] Failed to initialize default model:", error)
+      } finally {
+        if (mounted) {
+          setIsPreparingWhisperModel(false)
+        }
+      }
+    })()
+
+    return () => {
+      mounted = false
+    }
+  }, [ensureWhisperModelReady, refreshInstalledWhisperModels])
+
+  useEffect(() => {
+    if (!whisperRestoredRef.current) return
+    const payload: WhisperSavedState = {
+      defaultModel: defaultWhisperModel,
+      mode: transcriptionMode,
+      autoSendOnDictationEnd,
+    }
+    void FileSystem.writeAsStringAsync(WHISPER_SETTINGS_FILE, JSON.stringify(payload)).catch(() => {})
+  }, [autoSendOnDictationEnd, defaultWhisperModel, transcriptionMode])
+
+  useEffect(() => {
+    return () => {
+      const transcriber = whisperTranscriberRef.current
+      whisperTranscriberRef.current = null
+      if (transcriber) {
+        void (async () => {
+          await transcriber.stop().catch(() => {})
+          await transcriber.release().catch(() => {})
+        })()
+      }
+
+      const bulkStream = bulkAudioStreamRef.current
+      bulkAudioStreamRef.current = null
+      if (bulkStream) {
+        void (async () => {
+          await bulkStream.stop().catch(() => {})
+          await bulkStream.release().catch(() => {})
+        })()
+      }
+
+      const context = whisperContextRef.current
+      whisperContextRef.current = null
+      whisperContextModelRef.current = null
+
+      if (context) {
+        void context.release().catch(() => {})
+      }
+
+      void releaseAllWhisper().catch(() => {})
+    }
+  }, [])
+
+  const startWaveformPulse = useCallback(() => {
+    if (waveformPulseIntervalRef.current) return
+
+    waveformPulseIntervalRef.current = setInterval(() => {
+      if (!isRecordingRef.current) return
+
+      const next = waveformLevelsRef.current.map((value) => {
+        const decay = value * 0.45
+        const lift = Math.random() * 0.95
+        return Math.max(0.08, Math.min(1, decay + lift * 0.55))
+      })
+
+      waveformLevelsRef.current = next
+
+      const now = Date.now()
+      if (now - lastWaveformCommitRef.current > 45) {
+        setWaveformLevels(next)
+        setWaveformTick(now)
+        lastWaveformCommitRef.current = now
+      }
+    }, 70)
+  }, [])
+
+  const finalizeRecordingState = useCallback(() => {
+    isRecordingRef.current = false
+    activeSessionRef.current = 0
+    isStartingRef.current = false
+    setIsRecording(false)
+    stopWaveformPulse()
+    clearWaveform()
+  }, [clearWaveform, stopWaveformPulse])
+
+  const startRecording = useCallback(async () => {
+    if (isRecordingRef.current || isStartingRef.current || downloadingModelID || isTranscribingBulk) return
+
+    isStartingRef.current = true
+    const sessionID = Date.now()
+    activeSessionRef.current = sessionID
+    accumulatedRef.current = ""
+    baseTextRef.current = normalizeTranscriptSessions(transcribedText)
+    if (baseTextRef.current !== transcribedText) {
+      setTranscribedText(baseTextRef.current)
+    }
+    isRecordingRef.current = true
+    setIsRecording(true)
+    setWhisperError("")
+
+    const cancelled = () => !isRecordingRef.current || activeSessionRef.current !== sessionID
+
+    try {
+      const permission = await AudioManager.checkRecordingPermissions()
+      const granted = permission === "Granted"
+      setPermissionGranted(granted)
+      setMicrophonePermissionState(granted ? "granted" : permission === "Denied" ? "denied" : "idle")
+
+      if (!granted) {
+        setWhisperError("Microphone permission is required to record.")
+        finalizeRecordingState()
+        void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(() => {})
+        return
+      }
+
+      const sessionReady = await activateAudioSession("record")
+      if (!sessionReady) {
+        finalizeRecordingState()
+        void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(() => {})
+        return
+      }
+
+      await ensureAudioInputRoute()
+
+      const context = await ensureWhisperModelReady(defaultWhisperModel)
+      if (cancelled()) {
+        isStartingRef.current = false
+        return
+      }
+
+      const previousTranscriber = whisperTranscriberRef.current
+      whisperTranscriberRef.current = null
+      if (previousTranscriber) {
+        await previousTranscriber.stop().catch(() => {})
+        await previousTranscriber.release().catch(() => {})
+      }
+
+      const previousBulkStream = bulkAudioStreamRef.current
+      bulkAudioStreamRef.current = null
+      if (previousBulkStream) {
+        await previousBulkStream.stop().catch(() => {})
+        await previousBulkStream.release().catch(() => {})
+      }
+
+      bulkAudioChunksRef.current = []
+      bulkTranscriptionJobRef.current = 0
+
+      startWaveformPulse()
+
+      const englishOnlyModel = isEnglishOnlyWhisperModel(defaultWhisperModel)
+
+      if (transcriptionMode === "bulk") {
+        const audioStream = new AudioPcmStreamAdapter()
+        audioStream.onData((packet: unknown) => {
+          if (activeSessionRef.current !== sessionID) return
+          const data = (packet as { data?: unknown }).data
+          if (!(data instanceof Uint8Array) || data.length === 0) return
+          bulkAudioChunksRef.current.push(new Uint8Array(data))
+        })
+        audioStream.onError((error: string) => {
+          if (activeSessionRef.current !== sessionID) return
+          setWhisperError(error)
+          console.error("[Dictation] Bulk audio stream error:", error)
+        })
+
+        await audioStream.initialize({
+          sampleRate: 16000,
+          channels: 1,
+          bitsPerSample: 16,
+          bufferSize: 16 * 1024,
+          audioSource: 6,
+        })
+        await audioStream.start()
+
+        bulkAudioStreamRef.current = audioStream
+
+        if (cancelled()) {
+          await audioStream.stop().catch(() => {})
+          await audioStream.release().catch(() => {})
+          if (bulkAudioStreamRef.current === audioStream) {
+            bulkAudioStreamRef.current = null
+          }
+          finalizeRecordingState()
+          return
+        }
+
+        isStartingRef.current = false
+        return
+      }
+
+      const transcriber = new RealtimeTranscriber(
+        {
+          whisperContext: context,
+          audioStream: new AudioPcmStreamAdapter(),
+        },
+        {
+          audioSliceSec: 4,
+          audioMinSec: 0.8,
+          maxSlicesInMemory: 6,
+          transcribeOptions: {
+            language: englishOnlyModel ? "en" : "auto",
+            translate: !englishOnlyModel,
+            maxLen: 1,
+          },
+          logger: () => {},
+        },
+        {
+          onTranscribe: (event: RealtimeTranscribeEvent) => {
+            if (activeSessionRef.current !== sessionID) return
+            if (event.type !== "transcribe") return
+
+            const nextSessionText = mergeTranscriptChunk(accumulatedRef.current, event.data?.result ?? "")
+            accumulatedRef.current = nextSessionText
+
+            const base = normalizeTranscriptSessions(baseTextRef.current)
+            const separator = base.length > 0 && nextSessionText.length > 0 ? "\n\n" : ""
+            setTranscribedText(normalizeTranscriptSessions(base + separator + nextSessionText))
+
+            if (nextSessionText.length > 0) {
+              setHasCompletedSession(true)
+            }
+          },
+          onError: (error: string) => {
+            if (activeSessionRef.current !== sessionID) return
+            console.error("[Dictation] Whisper realtime error:", error)
+            setWhisperError(error)
+          },
+          onStatusChange: (active: boolean) => {
+            if (activeSessionRef.current !== sessionID) return
+            if (!active) {
+              if (whisperTranscriberRef.current === transcriber) {
+                whisperTranscriberRef.current = null
+              }
+              finalizeRecordingState()
+            }
+          },
+        },
+      )
+
+      whisperTranscriberRef.current = transcriber
+      await transcriber.start()
+
+      if (cancelled()) {
+        await transcriber.stop().catch(() => {})
+        await transcriber.release().catch(() => {})
+        if (whisperTranscriberRef.current === transcriber) {
+          whisperTranscriberRef.current = null
+        }
+        finalizeRecordingState()
+        return
+      }
+
+      isStartingRef.current = false
+    } catch (error) {
+      const busy = isAudioSessionBusyError(error)
+      const message = normalizeAudioStartErrorMessage(error)
+      setWhisperError(message)
+
+      if (busy) {
+        console.warn("[Dictation] Recording blocked while call is active")
+      } else {
+        console.error("[Dictation] Failed to start realtime transcription:", error)
+      }
+
+      const activeTranscriber = whisperTranscriberRef.current
+      whisperTranscriberRef.current = null
+      if (activeTranscriber) {
+        void (async () => {
+          await activeTranscriber.stop().catch(() => {})
+          await activeTranscriber.release().catch(() => {})
+        })()
+      }
+
+      finalizeRecordingState()
+      void Haptics.notificationAsync(
+        busy ? Haptics.NotificationFeedbackType.Warning : Haptics.NotificationFeedbackType.Error,
+      ).catch(() => {})
+    }
+  }, [
+    defaultWhisperModel,
+    downloadingModelID,
+    ensureWhisperModelReady,
+    finalizeRecordingState,
+    isTranscribingBulk,
+    activateAudioSession,
+    ensureAudioInputRoute,
+    startWaveformPulse,
+    transcriptionMode,
+    transcribedText,
+  ])
+
+  const stopRecording = useCallback(() => {
+    if (!isRecordingRef.current && !isStartingRef.current) return
+
+    void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {})
+
+    const baseAtStop = normalizeTranscriptSessions(baseTextRef.current)
+    const englishOnlyModel = isEnglishOnlyWhisperModel(defaultWhisperModel)
+
+    const transcriber = whisperTranscriberRef.current
+    whisperTranscriberRef.current = null
+    if (transcriber) {
+      void (async () => {
+        await transcriber.stop().catch((error: unknown) => {
+          console.warn("[Dictation] Failed to stop realtime transcription:", error)
+        })
+        await transcriber.release().catch(() => {})
+      })()
+    }
+
+    const bulkStream = bulkAudioStreamRef.current
+    bulkAudioStreamRef.current = null
+    const bulkChunks = bulkAudioChunksRef.current
+    bulkAudioChunksRef.current = []
+
+    finalizeRecordingState()
+
+    if (transcriptionMode !== "bulk") {
+      return
+    }
+
+    const runID = Date.now()
+    bulkTranscriptionJobRef.current = runID
+
+    void (async () => {
+      if (bulkStream) {
+        await bulkStream.stop().catch((error: unknown) => {
+          console.warn("[Dictation] Failed to stop bulk audio stream:", error)
+        })
+        await bulkStream.release().catch(() => {})
+      }
+
+      if (bulkChunks.length === 0) {
+        return
+      }
+
+      const totalLength = bulkChunks.reduce((sum, chunk) => sum + chunk.length, 0)
+      if (totalLength === 0) {
+        return
+      }
+
+      const merged = new Uint8Array(totalLength)
+      let offset = 0
+      for (const chunk of bulkChunks) {
+        merged.set(chunk, offset)
+        offset += chunk.length
+      }
+
+      const context = whisperContextRef.current
+      if (!context) {
+        setWhisperError("Whisper model is not loaded")
+        return
+      }
+
+      setIsTranscribingBulk(true)
+
+      try {
+        const { promise } = context.transcribeData(merged.buffer, {
+          language: englishOnlyModel ? "en" : "auto",
+          translate: !englishOnlyModel,
+          maxLen: 1,
+        })
+
+        const result = await promise
+        if (bulkTranscriptionJobRef.current !== runID) {
+          return
+        }
+
+        const sessionText = cleanSessionText(result.result ?? "")
+        if (!sessionText) {
+          return
+        }
+
+        const separator = baseAtStop.length > 0 ? "\n\n" : ""
+        setTranscribedText(normalizeTranscriptSessions(baseAtStop + separator + sessionText))
+        setHasCompletedSession(true)
+      } catch (error) {
+        if (bulkTranscriptionJobRef.current !== runID) {
+          return
+        }
+        const message = error instanceof Error ? error.message : "Bulk transcription failed"
+        setWhisperError(message)
+        console.error("[Dictation] Bulk transcription failed:", error)
+      } finally {
+        if (bulkTranscriptionJobRef.current === runID) {
+          setIsTranscribingBulk(false)
+        }
+      }
+    })()
+  }, [defaultWhisperModel, finalizeRecordingState, transcriptionMode])
+
+  const clearIconRotation = useSharedValue(0)
+  const sendOutProgress = useSharedValue(0)
+
+  const handleClearTranscript = useCallback(() => {
+    void Haptics.selectionAsync().catch(() => {})
+
+    clearIconRotation.value = withSequence(
+      withTiming(-30, { duration: 90 }),
+      withTiming(30, { duration: 120 }),
+      withTiming(0, { duration: 90 }),
+    )
+
+    if (isRecordingRef.current) {
+      stopRecording()
+    }
+    accumulatedRef.current = ""
+    baseTextRef.current = ""
+    setTranscribedText("")
+    setHasCompletedSession(false)
+    clearWaveform()
+    sendOutProgress.value = 0
+    setIsSending(false)
+  }, [clearIconRotation, clearWaveform, sendOutProgress, stopRecording])
+
+  const handleHideAgentState = useCallback(() => {
+    void Haptics.selectionAsync().catch(() => {})
+    setReaderModeOpen(false)
+    setAgentStateDismissed(true)
+  }, [])
+
+  const handleOpenReaderMode = useCallback(() => {
+    void Haptics.selectionAsync().catch(() => {})
+    setReaderModeRendered(true)
+    setReaderModeOpen(true)
+  }, [])
+
+  const handleCloseReaderMode = useCallback(() => {
+    void Haptics.selectionAsync().catch(() => {})
+    setReaderModeOpen(false)
+  }, [])
+
+  const handlePermissionDecision = useCallback(
+    (reply: PermissionDecision) => {
+      if (!activePermissionRequest || !activeServerId) return
+
+      void Haptics.selectionAsync().catch(() => {})
+      void respondToPermission({
+        serverID: activeServerId,
+        sessionID: activePermissionRequest.sessionID,
+        requestID: activePermissionRequest.id,
+        reply,
+      }).catch((error) => {
+        Alert.alert(
+          "Could not send decision",
+          error instanceof Error ? error.message : "OpenCode did not accept that decision.",
+        )
+      })
+    },
+    [activePermissionRequest, activeServerId, respondToPermission],
+  )
+
+  const resetTranscriptState = useCallback(() => {
+    if (isRecordingRef.current) {
+      stopRecording()
+    }
+    accumulatedRef.current = ""
+    baseTextRef.current = ""
+    setTranscribedText("")
+    setHasCompletedSession(false)
+    clearWaveform()
+  }, [clearWaveform, stopRecording])
+
+  const handleOpenWhisperSettings = useCallback(() => {
+    void Haptics.selectionAsync().catch(() => {})
+    setDropdownMode("none")
+    setWhisperSettingsOpen(true)
+  }, [])
+
+  const handleDownloadWhisperModel = useCallback(
+    async (modelID: WhisperModelID) => {
+      const ok = await downloadWhisperModel(modelID)
+      if (ok) {
+        void Haptics.selectionAsync().catch(() => {})
+      }
+    },
+    [downloadWhisperModel],
+  )
+
+  const handleSelectWhisperModel = useCallback(
+    async (modelID: WhisperModelID) => {
+      if (isRecordingRef.current || isStartingRef.current) {
+        stopRecording()
+      }
+
+      try {
+        await ensureWhisperModelReady(modelID)
+        setDefaultWhisperModel(modelID)
+        setWhisperError("")
+        void Haptics.selectionAsync().catch(() => {})
+      } catch (error) {
+        const message = error instanceof Error ? error.message : "Unable to switch Whisper model"
+        setWhisperError(message)
+      }
+    },
+    [ensureWhisperModelReady, stopRecording],
+  )
+
+  const handleDeleteWhisperModel = useCallback(
+    async (modelID: WhisperModelID) => {
+      if (downloadingModelID === modelID) return
+
+      if (isRecordingRef.current || isStartingRef.current) {
+        stopRecording()
+      }
+
+      if (whisperContextModelRef.current === modelID && whisperContextRef.current) {
+        const activeContext = whisperContextRef.current
+        whisperContextRef.current = null
+        whisperContextModelRef.current = null
+        setActiveWhisperModel(null)
+        await activeContext.release().catch(() => {})
+      }
+
+      await FileSystem.deleteAsync(modelPath(modelID), { idempotent: true }).catch(() => {})
+      const nextInstalled = await refreshInstalledWhisperModels()
+
+      if (defaultWhisperModel === modelID) {
+        const fallbackModel = nextInstalled[0] ?? DEFAULT_WHISPER_MODEL
+        setDefaultWhisperModel(fallbackModel)
+        try {
+          await ensureWhisperModelReady(fallbackModel)
+        } catch {
+          // Keep UI responsive if fallback init fails.
+        }
+      } else if (activeWhisperModel == null && nextInstalled.includes(defaultWhisperModel)) {
+        try {
+          await ensureWhisperModelReady(defaultWhisperModel)
+        } catch {
+          // Keep UI responsive if default model init fails.
+        }
+      }
+
+      void Haptics.selectionAsync().catch(() => {})
+    },
+    [
+      activeWhisperModel,
+      defaultWhisperModel,
+      downloadingModelID,
+      ensureWhisperModelReady,
+      modelPath,
+      refreshInstalledWhisperModels,
+      stopRecording,
+    ],
+  )
+
+  const handleRequestNotificationPermission = useCallback(async () => {
+    if (notificationPermissionState === "pending") return
+
+    setNotificationPermissionState("pending")
+
+    try {
+      const granted = await ensureNotificationPermissions()
+      setNotificationPermissionState(granted ? "granted" : "denied")
+
+      if (!granted) {
+        return
+      }
+
+      const token = await getDevicePushToken()
+      if (token) {
+        setDevicePushToken(token)
+      }
+    } catch {
+      setNotificationPermissionState("denied")
+    }
+  }, [notificationPermissionState, setDevicePushToken])
+
+  const handleRequestMicrophonePermission = useCallback(async () => {
+    if (microphonePermissionState === "pending") return
+
+    setMicrophonePermissionState("pending")
+
+    try {
+      const permission = await AudioManager.requestRecordingPermissions()
+      const granted = permission === "Granted"
+      setPermissionGranted(granted)
+      setMicrophonePermissionState(granted ? "granted" : "denied")
+
+      if (granted) {
+        await ensureAudioInputRoute()
+      }
+    } catch {
+      setPermissionGranted(false)
+      setMicrophonePermissionState("denied")
+    }
+  }, [ensureAudioInputRoute, microphonePermissionState])
+
+  const handleRequestLocalNetworkPermission = useCallback(async () => {
+    if (localNetworkPermissionState === "pending") return
+
+    setLocalNetworkPermissionState("pending")
+
+    const localProbes = new Set<string>([
+      "http://192.168.1.1",
+      "http://192.168.0.1",
+      "http://10.0.0.1",
+      "http://100.100.100.100",
+    ])
+
+    for (const server of serversRef.current) {
+      try {
+        const url = new URL(server.url)
+        if (looksLikeLocalHost(url.hostname)) {
+          localProbes.add(`${url.protocol}//${url.host}`)
+        }
+      } catch {
+        // Skip malformed saved server URL.
+      }
+    }
+
+    const controller = new AbortController()
+    const timeout = setTimeout(() => {
+      controller.abort()
+    }, 1800)
+
+    try {
+      await Promise.allSettled(
+        [...localProbes].map((base) =>
+          expoFetch(`${base.replace(/\/+$/, "")}/health`, {
+            method: "GET",
+            signal: controller.signal,
+          }),
+        ),
+      )
+      setLocalNetworkPermissionState("granted")
+    } catch {
+      setLocalNetworkPermissionState("denied")
+    } finally {
+      clearTimeout(timeout)
+    }
+  }, [localNetworkPermissionState, serversRef])
+
+  const completeSend = useCallback(() => {
+    if (sendSettleTimeoutRef.current) {
+      clearTimeout(sendSettleTimeoutRef.current)
+    }
+
+    sendSettleTimeoutRef.current = setTimeout(() => {
+      resetTranscriptState()
+      sendOutProgress.value = 0
+      setIsSending(false)
+      sendSettleTimeoutRef.current = null
+    }, SEND_SETTLE_MS)
+  }, [resetTranscriptState, sendOutProgress])
+
+  const handleSendTranscript = useCallback(async () => {
+    const text = transcribedText.trim()
+    if (text.length === 0 || isSending || !activeServerId || !activeSessionId) return
+
+    const server = serversRef.current.find((item) => item.id === activeServerId)
+    if (!server) return
+
+    const session = server.sessions.find((item) => item.id === activeSessionId)
+    if (!session) return
+
+    const base = server.url.replace(/\/+$/, "")
+
+    setIsSending(true)
+    setMonitorStatus("Sending prompt…")
+
+    try {
+      const response = await fetch(`${base}/session/${session.id}/prompt_async`, {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({
+          parts: [
+            {
+              type: "text",
+              text,
+            },
+          ],
+        }),
+      })
+
+      if (!response.ok) {
+        throw new Error(`Prompt request failed (${response.status})`)
+      }
+
+      setLatestPromptText(text)
+
+      const nextJob: MonitorJob = {
+        id: `job-${Date.now()}`,
+        sessionID: session.id,
+        opencodeBaseURL: base,
+        startedAt: Date.now(),
+      }
+
+      await beginMonitoring(nextJob)
+
+      if (server.relaySecret.trim().length === 0) {
+        setMonitorStatus("Monitoring (foreground only)")
+      }
+
+      void sendPlayer.seekTo(0)
+      void sendPlayer.play()
+
+      void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy).catch(() => {})
+      setTimeout(() => {
+        void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
+      }, 70)
+
+      sendOutProgress.value = withTiming(
+        1,
+        {
+          duration: 320,
+          easing: Easing.bezier(0.2, 0.8, 0.2, 1),
+        },
+        (finished) => {
+          if (finished) {
+            runOnJS(completeSend)()
+          }
+        },
+      )
+
+      // 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(() => {})
+      setIsSending(false)
+      sendOutProgress.value = 0
+    }
+  }, [
+    activeServerId,
+    activeSessionId,
+    beginMonitoring,
+    completeSend,
+    isSending,
+    serversRef,
+    setMonitorStatus,
+    setLatestPromptText,
+    sendOutProgress,
+    sendPlayer,
+    transcribedText,
+  ])
+
+  // --- Gesture handling: tap vs hold ---
+
+  const handlePressIn = useCallback(() => {
+    pressInTimeRef.current = Date.now()
+
+    if (isRecordingRef.current) return
+
+    setDropdownMode("none")
+    void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {})
+    isHoldingRef.current = true
+    // Snap pager to live page (index 0) so user sees their transcription
+    if (promptPagerRef.current) {
+      try {
+        promptPagerRef.current.scrollToIndex({ index: 0, animated: true })
+      } catch {
+        // FlatList may not have items yet
+      }
+    }
+    void startRecording()
+  }, [startRecording])
+
+  const handlePressOut = useCallback(() => {
+    const pressDuration = Date.now() - pressInTimeRef.current
+
+    if (pressDuration < TAP_THRESHOLD_MS) {
+      if (isHoldingRef.current) {
+        // Tap started recording on pressIn -- keep it running (toggle ON)
+        isHoldingRef.current = false
+      } else {
+        // Already recording from a previous tap -- this tap stops it
+        stopRecording()
+      }
+    } else {
+      // Long press = hold-to-record, stop on release
+      isHoldingRef.current = false
+      stopRecording()
+    }
+  }, [stopRecording])
+
+  const modelDownloading = downloadingModelID !== null
+  const modelLoading = isPreparingWhisperModel || activeWhisperModel == null || modelDownloading || isTranscribingBulk
+  const dictationSettingsLocked = isRecording || isTranscribingBulk || isSending
+  let modelLoadingState: "downloading" | "loading" | "ready" = "ready"
+  if (modelDownloading) {
+    modelLoadingState = "downloading"
+  } else if (modelLoading) {
+    modelLoadingState = "loading"
+  }
+  const pct = Math.round(Math.max(0, Math.min(1, downloadProgress)) * 100)
+  const loadingModelLabel = downloadingModelID
+    ? WHISPER_MODEL_LABELS[downloadingModelID]
+    : WHISPER_MODEL_LABELS[defaultWhisperModel]
+  const hasTranscript = transcribedText.trim().length > 0
+  const hasAssistantResponse = latestAssistantResponse.trim().length > 0
+  const readerBlocks = useMemo(() => parseReaderBlocks(latestAssistantResponse), [latestAssistantResponse])
+  const activePermissionCard = activePermissionRequest ? buildPermissionCardModel(activePermissionRequest) : null
+  const hasPendingPermission = activePermissionRequest !== null && activePermissionCard !== null
+  const readerModeEnabled = readerModeOpen && hasAssistantResponse && !hasPendingPermission
+  const readerModeVisible = readerModeEnabled || readerModeRendered
+  const fallbackAgentStateCardHeight = transcriptionAreaHeight > 0 ? Math.max(0, (transcriptionAreaHeight - 8) / 2) : 0
+  const collapsedReaderHeight = agentStateCardHeight > 0 ? agentStateCardHeight : fallbackAgentStateCardHeight
+  const expandedReaderHeight = Math.max(collapsedReaderHeight, transcriptionAreaHeight)
+  const hasAgentActivity = hasAssistantResponse || monitorStatus.trim().length > 0 || monitorJob !== null
+  const shouldShowAgentStateCard = !hasPendingPermission && hasAgentActivity && !agentStateDismissed
+  const showsCompleteState = monitorStatus.toLowerCase().includes("complete")
+  let agentStateIcon: "loading" | "done" = "loading"
+  if (monitorJob === null && (hasAssistantResponse || showsCompleteState)) {
+    agentStateIcon = "done"
+  }
+  const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…"
+  const agentStateBlocks = useMemo(() => parseReaderBlocks(agentStateText), [agentStateText])
+  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
+  const displayedTranscript = isSending ? "" : transcribedText
+  const [transcriptionPanelWidth, setTranscriptionPanelWidth] = useState(0)
+  const handleTranscriptionPanelLayout = useCallback((e: LayoutChangeEvent) => {
+    setTranscriptionPanelWidth(e.nativeEvent.layout.width)
+  }, [])
+  const pagerPageWidth = transcriptionPanelWidth || 1
+
+  // Prompt history pager: "live" at index 0 (leftmost), then history newest-first to the right.
+  // Swipe right-to-left to browse older prompts, swipe left-to-right to return to live.
+  const promptPagerData = useMemo<(PromptHistoryEntry | "live")[]>(
+    () => (promptHistory.length > 0 ? ["live" as const, ...[...promptHistory].reverse()] : []),
+    [promptHistory],
+  )
+  const promptPagerKeyExtractor = useCallback(
+    (item: PromptHistoryEntry | "live") => (item === "live" ? "live" : item.userMessageID),
+    [],
+  )
+  const handlePromptPagerSnap = useCallback(
+    (e: { nativeEvent: { contentOffset: { x: number } } }) => {
+      const pageIndex = Math.round(e.nativeEvent.contentOffset.x / pagerPageWidth)
+      if (pageIndex !== promptPagerPageRef.current) {
+        promptPagerPageRef.current = pageIndex
+        void Haptics.selectionAsync().catch(() => {})
+      }
+    },
+    [pagerPageWidth],
+  )
+  const isDropdownOpen = dropdownMode !== "none"
+  const effectiveDropdownMode = isDropdownOpen ? dropdownMode : dropdownRenderMode
+  const isCreatingSession = sessionCreateMode !== null
+  const showSessionCreationChoices =
+    effectiveDropdownMode === "session" && !!activeServer && activeServer.status === "online"
+  const sessionCreationChoiceCount = showSessionCreationChoices ? (activeSession ? 2 : 1) : 0
+  const recommendedPairHostURL = useMemo(() => {
+    const online = pairHostOptions
+      .map((item) => ({ item, probe: pairHostProbes[item.url] }))
+      .filter((entry) => entry.probe?.status === "online")
+      .sort(
+        (a, b) => (a.probe?.latencyMs ?? Number.POSITIVE_INFINITY) - (b.probe?.latencyMs ?? Number.POSITIVE_INFINITY),
+      )
+
+    if (online[0]) {
+      return online[0].item.url
+    }
+
+    return pairHostOptions[0]?.url ?? null
+  }, [pairHostOptions, pairHostProbes])
+  const headerTitle = activeServer?.name ?? "No server configured"
+  let headerDotStyle = styles.serverStatusOffline
+  if (activeServer?.status === "online") {
+    headerDotStyle = styles.serverStatusActive
+  } else if (activeServer?.status === "checking") {
+    headerDotStyle = styles.serverStatusChecking
+  }
+
+  const recordingProgress = useSharedValue(0)
+  const sendVisibility = useSharedValue(hasTranscript ? 1 : 0)
+  const waveformVisibility = useSharedValue(0)
+  const serverMenuProgress = useSharedValue(0)
+  const readerExpandProgress = useSharedValue(0)
+
+  useEffect(() => {
+    recordingProgress.value = withSpring(isRecording ? 1 : 0, {
+      damping: 14,
+      stiffness: 140,
+      mass: 0.8,
+    })
+  }, [isRecording, recordingProgress])
+
+  useEffect(() => {
+    const isGenerating = isRecording
+    waveformVisibility.value = withTiming(isGenerating ? 1 : 0, {
+      duration: isGenerating ? 180 : 240,
+      easing: Easing.inOut(Easing.quad),
+    })
+  }, [isRecording, waveformVisibility])
+
+  useEffect(() => {
+    serverMenuProgress.value = withTiming(isDropdownOpen ? 1 : 0, {
+      duration: isDropdownOpen ? 240 : 240,
+      easing: isDropdownOpen ? Easing.bezier(0.2, 0.8, 0.2, 1) : Easing.bezier(0.4, 0, 0.2, 1),
+    })
+  }, [isDropdownOpen, serverMenuProgress])
+
+  useEffect(() => {
+    if (readerModeEnabled) {
+      readerExpandProgress.value = withTiming(1, {
+        duration: 320,
+        easing: Easing.bezier(0.22, 1, 0.36, 1),
+      })
+      return
+    }
+
+    if (!readerModeRendered) {
+      readerExpandProgress.value = 0
+      return
+    }
+
+    readerExpandProgress.value = withTiming(
+      0,
+      {
+        duration: 240,
+        easing: Easing.bezier(0.4, 0, 0.2, 1),
+      },
+      (finished) => {
+        if (finished) {
+          runOnJS(setReaderModeRendered)(false)
+        }
+      },
+    )
+  }, [readerExpandProgress, readerModeEnabled, readerModeRendered])
+
+  useEffect(() => {
+    if (dropdownMode !== "none") {
+      setDropdownRenderMode(dropdownMode)
+    }
+  }, [dropdownMode])
+
+  useEffect(() => {
+    sendVisibility.value = shouldShowSend
+      ? withTiming(1, {
+          duration: 320,
+          easing: Easing.bezier(0.2, 0.8, 0.2, 1),
+        })
+      : withTiming(0, {
+          duration: 360,
+          easing: Easing.bezier(0.22, 0.61, 0.36, 1),
+        })
+  }, [shouldShowSend, sendVisibility])
+
+  useEffect(() => {
+    const text = transcribedText.trim()
+    if (!hasCompletedSession || text.length === 0) {
+      autoSendSignatureRef.current = ""
+      return
+    }
+
+    if (
+      !autoSendOnDictationEnd ||
+      isRecording ||
+      isTranscribingBulk ||
+      isSending ||
+      hasPendingPermission ||
+      !activeServerId ||
+      !activeSessionId
+    ) {
+      return
+    }
+
+    const signature = `${activeServerId}:${activeSessionId}:${transcriptionMode}:${text}`
+    if (autoSendSignatureRef.current === signature) {
+      return
+    }
+
+    autoSendSignatureRef.current = signature
+    void handleSendTranscript()
+  }, [
+    activeServerId,
+    activeSessionId,
+    autoSendOnDictationEnd,
+    handleSendTranscript,
+    hasCompletedSession,
+    hasPendingPermission,
+    isRecording,
+    isSending,
+    isTranscribingBulk,
+    transcriptionMode,
+    transcribedText,
+  ])
+
+  // Parent clips outer half of center-stroke, so only inner half is visible.
+  // borderWidth 6 → 3px visible inward, borderWidth 12 → 6px visible inward.
+  const animatedBorderStyle = useAnimatedStyle(() => {
+    const progress = recordingProgress.value
+    // Width: 3 → ~1.5px visible inward at rest (matches other cards),
+    // 12 → ~6px visible inward when active
+    const bw = interpolate(progress, [0, 1], [3, 12], Extrapolation.CLAMP)
+    return {
+      borderWidth: bw,
+      borderColor: "#FF2E3F",
+    }
+  })
+
+  const animatedDotStyle = useAnimatedStyle(() => ({
+    borderRadius: interpolate(recordingProgress.value, [0, 1], [19, 2], Extrapolation.CLAMP),
+  }))
+
+  const animatedClearIconStyle = useAnimatedStyle(() => ({
+    transform: [{ rotate: `${clearIconRotation.value}deg` }],
+  }))
+
+  const animatedSendStyle = useAnimatedStyle(() => ({
+    width: interpolate(sendVisibility.value, [0, 1], [0, Math.max((controlsWidth - 8) / 2, 0)], Extrapolation.CLAMP),
+    marginLeft: interpolate(sendVisibility.value, [0, 1], [0, 8], Extrapolation.CLAMP),
+    opacity: sendVisibility.value,
+    transform: [
+      {
+        translateX: interpolate(sendVisibility.value, [0, 1], [14, 0], Extrapolation.CLAMP),
+      },
+      {
+        scale: interpolate(sendVisibility.value, [0, 1], [0.98, 1], Extrapolation.CLAMP),
+      },
+    ],
+  }))
+
+  const animatedTranscriptSendStyle = useAnimatedStyle(() => ({
+    opacity: interpolate(sendOutProgress.value, [0, 1], [1, 0], Extrapolation.CLAMP),
+    transform: [
+      {
+        translateY: interpolate(sendOutProgress.value, [0, 1], [0, -44], Extrapolation.CLAMP),
+      },
+    ],
+  }))
+
+  const animatedWaveformRowStyle = useAnimatedStyle(() => ({
+    opacity: waveformVisibility.value,
+    transform: [
+      {
+        translateY: interpolate(waveformVisibility.value, [0, 1], [6, 0], Extrapolation.CLAMP),
+      },
+    ],
+  }))
+
+  // 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(
+    SERVER_MENU_SECTION_HEIGHT + serverMenuEntries * SERVER_MENU_ENTRY_HEIGHT,
+    maxDropdownListHeight,
+  )
+  const sessionMenuRows = Math.max(activeServer?.sessions.length ?? 0, 1)
+  const estimatedSessionMenuRowsHeight = Math.min(sessionMenuRows, DROPDOWN_VISIBLE_ROWS) * DROPDOWN_ROW_HEIGHT
+  const serverMenuRowsHeight = Math.min(serverMenuListHeight || estimatedServerMenuRowsHeight, maxDropdownListHeight)
+  const sessionMenuRowsHeight = Math.min(sessionMenuListHeight || estimatedSessionMenuRowsHeight, maxDropdownListHeight)
+  const expandedRowsHeight = effectiveDropdownMode === "server" ? serverMenuRowsHeight : sessionMenuRowsHeight
+
+  const estimatedSessionFooterHeight = sessionCreationChoiceCount === 2 ? 72 : sessionCreationChoiceCount === 1 ? 38 : 8
+
+  const measuredServerFooterHeight = serverMenuFooterHeight || SERVER_MENU_FOOTER_HEIGHT
+  const measuredSessionFooterHeight = sessionMenuFooterHeight || estimatedSessionFooterHeight
+
+  const dropdownFooterExtraHeight =
+    effectiveDropdownMode === "server"
+      ? measuredServerFooterHeight
+      : showSessionCreationChoices
+        ? measuredSessionFooterHeight
+        : 8
+  const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + dropdownFooterExtraHeight
+
+  const animatedHeaderStyle = useAnimatedStyle(() => ({
+    height: interpolate(serverMenuProgress.value, [0, 1], [51, expandedHeaderHeight], Extrapolation.CLAMP),
+  }))
+
+  const animatedServerMenuStyle = useAnimatedStyle(() => ({
+    opacity: serverMenuProgress.value,
+    transform: [
+      {
+        translateY: interpolate(serverMenuProgress.value, [0, 1], [-8, 0], Extrapolation.CLAMP),
+      },
+    ],
+  }))
+
+  const animatedHeaderShadowStyle = useAnimatedStyle(() => ({
+    shadowOpacity: interpolate(serverMenuProgress.value, [0, 1], [0, 0.35], Extrapolation.CLAMP),
+    shadowRadius: interpolate(serverMenuProgress.value, [0, 1], [0, 18], Extrapolation.CLAMP),
+    elevation: interpolate(serverMenuProgress.value, [0, 1], [0, 16], Extrapolation.CLAMP),
+  }))
+
+  const animatedReaderExpandStyle = useAnimatedStyle(() => ({
+    height: interpolate(
+      readerExpandProgress.value,
+      [0, 1],
+      [collapsedReaderHeight, expandedReaderHeight],
+      Extrapolation.CLAMP,
+    ),
+    opacity: interpolate(readerExpandProgress.value, [0, 0.12, 1], [0, 1, 1], Extrapolation.CLAMP),
+  }))
+
+  const animatedAgentStateActionsStyle = useAnimatedStyle(() => ({
+    opacity: interpolate(readerExpandProgress.value, [0, 0.16, 1], [1, 0, 0], Extrapolation.CLAMP),
+  }))
+
+  const animatedReaderToggleTravelStyle = useAnimatedStyle(() => ({
+    transform: [
+      {
+        translateX: interpolate(readerExpandProgress.value, [0, 1], [-READER_ACTION_TRAVEL, 0], Extrapolation.CLAMP),
+      },
+    ],
+  }))
+
+  const animatedReaderToggleOpenIconStyle = useAnimatedStyle(() => ({
+    opacity: interpolate(readerExpandProgress.value, [0, 0.45, 1], [1, 0.18, 0], Extrapolation.CLAMP),
+    transform: [{ scale: interpolate(readerExpandProgress.value, [0, 1], [1, 0.92], Extrapolation.CLAMP) }],
+  }))
+
+  const animatedReaderToggleCloseIconStyle = useAnimatedStyle(() => ({
+    opacity: interpolate(readerExpandProgress.value, [0, 0.45, 1], [0, 0.35, 1], Extrapolation.CLAMP),
+    transform: [{ scale: interpolate(readerExpandProgress.value, [0, 1], [0.92, 1], Extrapolation.CLAMP) }],
+  }))
+
+  const waveformColumnMeta = useMemo(
+    () =>
+      Array.from({ length: waveformLevels.length }, () => ({
+        delay: Math.random() * 1.5,
+        duration: 1 + Math.random(),
+        phase: Math.random() * Math.PI * 2,
+      })),
+    [waveformLevels.length],
+  )
+
+  const getWaveformCellStyle = useCallback(
+    (row: number, col: number) => {
+      const level = waveformLevels[col] ?? 0
+      const rowFromBottom = WAVEFORM_ROWS - 1 - row
+      const intensity = Math.max(0, Math.min(1, level * WAVEFORM_ROWS - rowFromBottom))
+
+      const meta = waveformColumnMeta[col]
+      const t = waveformTick / 1000
+      const basePhase = (Math.max(0, t - meta.delay) / meta.duration) * Math.PI * 2 + meta.phase + row * 0.35
+      const pulse = 0.5 + 0.5 * Math.sin(basePhase)
+
+      let alpha = 0.08
+      if (intensity > 0) {
+        alpha = (0.4 + intensity * 0.6) * (0.85 + pulse * 0.15)
+      } else if (isRecording) {
+        alpha = 0.1 + pulse * 0.07
+      }
+
+      // Base palette around #78839A, with brighter/lower variants by intensity.
+      const baseR = 120
+      const baseG = 131
+      const baseB = 154
+      const lift = Math.round(intensity * 28)
+      const r = Math.min(255, baseR + lift)
+      const g = Math.min(255, baseG + lift)
+      const b = Math.min(255, baseB + lift)
+
+      return {
+        backgroundColor: `rgba(${r}, ${g}, ${b}, ${alpha})`,
+        borderColor: `rgba(${Math.min(255, r + 8)}, ${Math.min(255, g + 8)}, ${Math.min(255, b + 8)}, ${Math.min(1, alpha + 0.16)})`,
+      }
+    },
+    [isRecording, waveformColumnMeta, waveformLevels, waveformTick],
+  )
+
+  const handleControlsLayout = useCallback((event: LayoutChangeEvent) => {
+    setControlsWidth(event.nativeEvent.layout.width)
+  }, [])
+
+  const handleTranscriptionAreaLayout = useCallback((event: LayoutChangeEvent) => {
+    const next = Math.ceil(event.nativeEvent.layout.height)
+    setTranscriptionAreaHeight((prev) => (prev === next ? prev : next))
+  }, [])
+
+  const handleAgentStateCardLayout = useCallback((event: LayoutChangeEvent) => {
+    const next = Math.ceil(event.nativeEvent.layout.height)
+    setAgentStateCardHeight((prev) => (prev === next ? prev : next))
+  }, [])
+
+  const handleWaveformLayout = useCallback((event: LayoutChangeEvent) => {
+    const width = event.nativeEvent.layout.width
+    const columns = Math.max(14, Math.floor((width + WAVEFORM_CELL_GAP) / (WAVEFORM_CELL_SIZE + WAVEFORM_CELL_GAP)))
+
+    if (columns === waveformLevelsRef.current.length) return
+
+    const next = Array.from({ length: columns }, () => 0)
+    waveformLevelsRef.current = next
+    setWaveformLevels(next)
+  }, [])
+
+  const handleServerMenuListLayout = useCallback((event: LayoutChangeEvent) => {
+    const next = Math.ceil(event.nativeEvent.layout.height)
+    setServerMenuListHeight((prev) => (prev === next ? prev : next))
+  }, [])
+
+  const handleSessionMenuListLayout = useCallback((event: LayoutChangeEvent) => {
+    const next = Math.ceil(event.nativeEvent.layout.height)
+    setSessionMenuListHeight((prev) => (prev === next ? prev : next))
+  }, [])
+
+  const handleServerMenuFooterLayout = useCallback((event: LayoutChangeEvent) => {
+    const next = Math.ceil(event.nativeEvent.layout.height)
+    setServerMenuFooterHeight((prev) => (prev === next ? prev : next))
+  }, [])
+
+  const handleSessionMenuFooterLayout = useCallback((event: LayoutChangeEvent) => {
+    const next = Math.ceil(event.nativeEvent.layout.height)
+    setSessionMenuFooterHeight((prev) => (prev === next ? prev : next))
+  }, [])
+
+  const renderMarkdownBlocks = (blocks: ReaderBlock[], variant: "reader" | "reply") => {
+    const keyPrefix = variant === "reader" ? "reader" : "reply"
+    const paragraphStyle = variant === "reader" ? styles.readerParagraph : styles.replyText
+
+    const renderMarkdownInline = (input: string, blockIndex: number) =>
+      parseReaderInlineSegments(input).map((segment, segmentIndex) => {
+        const segmentKey = `${keyPrefix}-inline-${blockIndex}-${segmentIndex}`
+
+        switch (segment.type) {
+          case "inline_code":
+            return (
+              <Text key={segmentKey} style={styles.readerInlineCode}>
+                {segment.content}
+              </Text>
+            )
+          case "italic":
+            return (
+              <Text key={segmentKey} style={styles.markdownItalic}>
+                {segment.content}
+              </Text>
+            )
+          case "bold":
+            return (
+              <Text key={segmentKey} style={styles.markdownBold}>
+                {segment.content}
+              </Text>
+            )
+          case "bold_italic":
+            return (
+              <Text key={segmentKey} style={styles.markdownBoldItalic}>
+                {segment.content}
+              </Text>
+            )
+          default:
+            return <Text key={segmentKey}>{segment.content}</Text>
+        }
+      })
+
+    const getHeadingStyle = (level: ReaderHeadingLevel) => {
+      if (variant === "reader") {
+        return [
+          styles.readerHeading,
+          level === 1 ? styles.readerHeading1 : level === 2 ? styles.readerHeading2 : styles.readerHeading3,
+        ]
+      }
+
+      return [
+        styles.replyHeading,
+        level === 1 ? styles.replyHeading1 : level === 2 ? styles.replyHeading2 : styles.replyHeading3,
+      ]
+    }
+
+    return blocks.map((block, index) =>
+      block.type === "code" ? (
+        <View key={`${keyPrefix}-code-${index}`} style={styles.readerCodeBlock}>
+          {block.language ? <Text style={styles.readerCodeLanguage}>{block.language}</Text> : null}
+          <Text style={styles.readerCodeText}>{block.content}</Text>
+        </View>
+      ) : block.type === "heading" ? (
+        <Text key={`${keyPrefix}-heading-${index}`} style={getHeadingStyle(block.level)}>
+          {renderMarkdownInline(block.content, index)}
+        </Text>
+      ) : (
+        <Text key={`${keyPrefix}-text-${index}`} style={paragraphStyle}>
+          {renderMarkdownInline(block.content, index)}
+        </Text>
+      ),
+    )
+  }
+
+  const renderTranscriptionPanel = () => (
+    <View style={styles.transcriptionPanel} onLayout={handleTranscriptionPanelLayout}>
+      <View style={styles.transcriptionTopActions} pointerEvents="box-none">
+        <Pressable
+          onPress={handleOpenWhisperSettings}
+          style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
+          hitSlop={8}
+        >
+          <SymbolView
+            name={{ ios: "gearshape.fill", android: "settings", web: "settings" }}
+            size={18}
+            weight="semibold"
+            tintColor={C.textTertiary}
+          />
+        </Pressable>
+        <Pressable
+          onPress={handleClearTranscript}
+          style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
+          hitSlop={8}
+        >
+          <Animated.View style={animatedClearIconStyle}>
+            <SymbolView
+              name={{ ios: "arrow.counterclockwise", android: "refresh", web: "refresh" }}
+              size={18}
+              weight="semibold"
+              tintColor="#A0A0A0"
+            />
+          </Animated.View>
+        </Pressable>
+      </View>
+
+      {whisperError ? (
+        <View style={styles.modelErrorBadge}>
+          <Text style={styles.modelErrorText}>{whisperError}</Text>
+        </View>
+      ) : null}
+
+      {promptPagerData.length > 1 ? (
+        <FlatList
+          ref={promptPagerRef}
+          data={promptPagerData}
+          keyExtractor={promptPagerKeyExtractor}
+          horizontal
+          pagingEnabled
+          bounces={false}
+          showsHorizontalScrollIndicator={false}
+          onMomentumScrollEnd={handlePromptPagerSnap}
+          initialScrollIndex={0}
+          getItemLayout={(_data, index) => ({
+            length: pagerPageWidth,
+            offset: pagerPageWidth * index,
+            index,
+          })}
+          style={styles.transcriptionScroll}
+          renderItem={({ item }) =>
+            item === "live" ? (
+              <ScrollView
+                ref={scrollViewRef}
+                style={{ width: pagerPageWidth }}
+                contentContainerStyle={[styles.transcriptionContent, styles.transcriptionContentLive]}
+                onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
+              >
+                <Animated.View style={animatedTranscriptSendStyle}>
+                  {displayedTranscript ? (
+                    <Text style={styles.transcriptionText}>{displayedTranscript}</Text>
+                  ) : isSending ? null : (
+                    <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}>
+                <Text style={styles.promptHistoryLabel}>Previous prompt</Text>
+                <Text style={styles.promptHistoryText}>{item.promptText}</Text>
+              </ScrollView>
+            )
+          }
+        />
+      ) : (
+        <ScrollView
+          ref={scrollViewRef}
+          style={styles.transcriptionScroll}
+          contentContainerStyle={styles.transcriptionContent}
+          onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
+        >
+          <Animated.View style={animatedTranscriptSendStyle}>
+            {displayedTranscript ? (
+              <Text style={styles.transcriptionText}>{displayedTranscript}</Text>
+            ) : isSending ? null : (
+              <Text style={styles.placeholderText}>Your transcription will appear here…</Text>
+            )}
+          </Animated.View>
+        </ScrollView>
+      )}
+
+      <Animated.View
+        style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
+        pointerEvents="none"
+        onLayout={handleWaveformLayout}
+      >
+        {Array.from({ length: WAVEFORM_ROWS }).map((_, row) => (
+          <View key={`row-${row}`} style={styles.waveformGridRow}>
+            {waveformLevels.map((_, col) => (
+              <View key={`cell-${row}-${col}`} style={[styles.waveformBox, getWaveformCellStyle(row, col)]} />
+            ))}
+          </View>
+        ))}
+      </Animated.View>
+    </View>
+  )
+
+  const toggleServerMenu = useCallback(() => {
+    void Haptics.selectionAsync().catch(() => {})
+    setDropdownMode((prev) => {
+      const next = prev === "server" ? "none" : "server"
+      if (next === "server") {
+        setDropdownRenderMode("server")
+      }
+      if (next === "server") {
+        refreshAllServerHealth()
+        refreshDiscovery()
+      }
+      return next
+    })
+  }, [refreshAllServerHealth, refreshDiscovery])
+
+  const toggleSessionMenu = useCallback(() => {
+    if (!activeServer || activeServer.status !== "online") return
+    void Haptics.selectionAsync().catch(() => {})
+    void refreshServerStatusAndSessions(activeServer.id)
+    setDropdownRenderMode("session")
+    setDropdownMode((prev) => (prev === "session" ? "none" : "session"))
+  }, [activeServer, refreshServerStatusAndSessions])
+
+  const handleSelectServer = useCallback(
+    (id: string) => {
+      selectServer(id)
+      setDropdownMode("none")
+      void refreshServerStatusAndSessions(id)
+    },
+    [refreshServerStatusAndSessions, selectServer],
+  )
+
+  const handleSelectSession = useCallback(
+    (id: string) => {
+      selectSession(id)
+      setDropdownMode("none")
+    },
+    [selectSession],
+  )
+
+  const handleCreateRootSession = useCallback(() => {
+    if (!activeServer || activeServer.status !== "online" || isCreatingSession) {
+      return
+    }
+
+    setSessionCreateMode("root")
+    void createSession(activeServer.id)
+      .then((created) => {
+        if (!created) {
+          Alert.alert("Could not create session", "Please check that your server is online and try again.")
+          return
+        }
+
+        setDropdownMode("none")
+      })
+      .finally(() => {
+        setSessionCreateMode(null)
+      })
+  }, [activeServer, createSession, isCreatingSession])
+
+  const handleCreateSessionLikeCurrent = useCallback(() => {
+    if (!activeServer || activeServer.status !== "online" || !activeSession || isCreatingSession) {
+      return
+    }
+
+    setSessionCreateMode("same")
+    void createSession(activeServer.id, {
+      directory: activeSession.directory,
+      workspaceID: activeSession.workspaceID,
+    })
+      .then((created) => {
+        if (!created) {
+          Alert.alert("Could not create session", "Please check that your server is online and try again.")
+          return
+        }
+
+        setDropdownMode("none")
+      })
+      .finally(() => {
+        setSessionCreateMode(null)
+      })
+  }, [activeServer, activeSession, createSession, isCreatingSession])
+
+  const handleDeleteServer = useCallback(
+    (id: string) => {
+      const server = serversRef.current.find((s) => s.id === id)
+      if (server && devicePushToken && server.relaySecret.trim().length > 0) {
+        unregisterRelayDevice({
+          relayBaseURL: server.relayURL,
+          secret: server.relaySecret.trim(),
+          deviceToken: devicePushToken,
+        }).catch(() => {})
+      }
+
+      removeServer(id)
+    },
+    [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 =
+      camera ??
+      (() => {
+        try {
+          // Expo dev builds were failing to resolve this native module through async import().
+          const mod = require("expo-camera") as typeof import("expo-camera") & {
+            Camera?: { requestCameraPermissionsAsync?: unknown }
+          }
+          const direct = (mod as { requestCameraPermissionsAsync?: unknown }).requestCameraPermissionsAsync
+          const fromCamera = mod.Camera?.requestCameraPermissionsAsync
+          let requestCameraPermissionsAsync: (() => Promise<{ granted: boolean }>) | null = null
+          if (typeof direct === "function") {
+            requestCameraPermissionsAsync = direct as () => Promise<{ granted: boolean }>
+          } else if (typeof fromCamera === "function") {
+            requestCameraPermissionsAsync = fromCamera as () => Promise<{ granted: boolean }>
+          }
+
+          if (!mod.CameraView || !requestCameraPermissionsAsync) {
+            return null
+          }
+
+          const next = {
+            CameraView: mod.CameraView,
+            requestCameraPermissionsAsync,
+          }
+          setCamera(next)
+          return next
+        } catch {
+          return null
+        }
+      })()
+    if (!current) {
+      Alert.alert("Scanner unavailable", "This build does not include camera support. Reinstall the latest dev build.")
+      return
+    }
+    if (camGranted) {
+      setScanOpen(true)
+      return
+    }
+    const res = await current.requestCameraPermissionsAsync()
+    if (!res.granted) return
+    setCamGranted(true)
+    setScanOpen(true)
+  }, [camGranted, camera])
+
+  const completeOnboarding = useCallback(
+    (openScanner: boolean) => {
+      setOnboardingComplete(true)
+      void FileSystem.writeAsStringAsync(ONBOARDING_STATE_FILE, JSON.stringify({ completed: true })).catch(() => {})
+
+      if (openScanner) {
+        void handleStartScan()
+      }
+    },
+    [handleStartScan],
+  )
+
+  const handleReplayOnboarding = useCallback(() => {
+    setWhisperSettingsOpen(false)
+    setScanOpen(false)
+    setPairSelectionOpen(false)
+    setPendingPair(null)
+    setPairHostOptions([])
+    setPairHostProbes({})
+    setSelectedPairHostURL(null)
+    setIsConnectingPairHost(false)
+    setDropdownMode("none")
+    setOnboardingStep(0)
+    setMicrophonePermissionState(permissionGranted ? "granted" : "idle")
+    setNotificationPermissionState("idle")
+    setLocalNetworkPermissionState("idle")
+    setOnboardingReady(true)
+    setOnboardingComplete(false)
+    void FileSystem.deleteAsync(ONBOARDING_STATE_FILE, { idempotent: true }).catch(() => {})
+  }, [permissionGranted])
+
+  const closePairSelection = useCallback(() => {
+    setPairSelectionOpen(false)
+    setPendingPair(null)
+    setPairHostOptions([])
+    setPairHostProbes({})
+    setSelectedPairHostURL(null)
+    setIsConnectingPairHost(false)
+    pairProbeRunRef.current += 1
+  }, [])
+
+  const handleConnectSelectedPairHost = useCallback(() => {
+    if (!pendingPair || !selectedPairHostURL || isConnectingPairHost) {
+      return
+    }
+
+    setIsConnectingPairHost(true)
+    const ok = addServer(selectedPairHostURL, pendingPair.relayURL, pendingPair.relaySecret, pendingPair.serverID)
+
+    if (!ok) {
+      Alert.alert("Could not add server", "The selected host could not be added. Try another host.")
+      setIsConnectingPairHost(false)
+      return
+    }
+
+    closePairSelection()
+    void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
+  }, [addServer, closePairSelection, isConnectingPairHost, pendingPair, selectedPairHostURL])
+
+  const handleRescanFromPairSelection = useCallback(() => {
+    closePairSelection()
+    scanLockRef.current = false
+    void handleStartScan()
+  }, [closePairSelection, handleStartScan])
+
+  useEffect(() => {
+    if (latestAssistantResponse.trim().length === 0 || activePermissionRequest !== null) {
+      setReaderModeOpen(false)
+    }
+  }, [activePermissionRequest, latestAssistantResponse])
+
+  const connectPairPayload = useCallback((rawData: string, source: "scan" | "link") => {
+    const fromScan = source === "scan"
+    if (fromScan && scanLockRef.current) return
+
+    if (fromScan) {
+      scanLockRef.current = true
+    }
+
+    const pair = parsePair(rawData)
+    if (!pair) {
+      if (fromScan) {
+        void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {})
+        setTimeout(() => {
+          scanLockRef.current = false
+        }, 750)
+      }
+      return
+    }
+
+    const options = normalizePairHosts(pair.hosts)
+    if (!options.length) {
+      if (fromScan) {
+        scanLockRef.current = false
+      }
+      Alert.alert("No valid hosts found", "The QR payload did not include any valid server hosts.")
+      return
+    }
+
+    if (fromScan) {
+      setScanOpen(false)
+    }
+
+    setPendingPair(pair)
+    setPairHostOptions(options)
+    setSelectedPairHostURL(options[0]?.url ?? null)
+    setPairHostProbes(Object.fromEntries(options.map((item) => [item.url, { status: "checking" as const }])))
+    setPairSelectionOpen(true)
+
+    if (fromScan) {
+      scanLockRef.current = false
+    }
+  }, [])
+
+  const handleScan = useCallback(
+    (event: Scan) => {
+      connectPairPayload(event.data, "scan")
+    },
+    [connectPairPayload],
+  )
+
+  useEffect(() => {
+    if (scanOpen) return
+    scanLockRef.current = false
+  }, [scanOpen])
+
+  useEffect(() => {
+    if (!pairSelectionOpen || !pairHostOptions.length) {
+      return
+    }
+
+    const runID = pairProbeRunRef.current + 1
+    pairProbeRunRef.current = runID
+
+    setPairHostProbes((prev) => {
+      const next: Record<string, PairHostProbe> = {}
+      for (const option of pairHostOptions) {
+        next[option.url] = prev[option.url]?.status === "online" ? prev[option.url] : { status: "checking" }
+      }
+      return next
+    })
+
+    pairHostOptions.forEach((option) => {
+      void (async () => {
+        const controller = new AbortController()
+        const timeout = setTimeout(() => controller.abort(), 2800)
+        const startedAt = Date.now()
+
+        try {
+          const response = await fetch(`${option.url}/health`, {
+            method: "GET",
+            signal: controller.signal,
+          })
+          if (pairProbeRunRef.current !== runID) return
+
+          if (response.ok) {
+            setPairHostProbes((prev) => ({
+              ...prev,
+              [option.url]: {
+                status: "online",
+                latencyMs: Math.max(1, Date.now() - startedAt),
+              },
+            }))
+            return
+          }
+
+          setPairHostProbes((prev) => ({
+            ...prev,
+            [option.url]: {
+              status: "offline",
+              note: `HTTP ${response.status}`,
+            },
+          }))
+        } catch (err) {
+          if (pairProbeRunRef.current !== runID) return
+
+          const aborted = err instanceof Error && err.name === "AbortError"
+          let note = aborted ? "Timed out" : "Unavailable"
+          if (!aborted) {
+            try {
+              const parsed = new URL(option.url)
+              if (Platform.OS === "ios" && parsed.protocol === "http:" && !looksLikeLocalHost(parsed.hostname)) {
+                note = "ATS blocked"
+              }
+            } catch {
+              // ignore parse failure and keep default note
+            }
+          }
+
+          setPairHostProbes((prev) => ({
+            ...prev,
+            [option.url]: {
+              status: "offline",
+              note,
+            },
+          }))
+        } finally {
+          clearTimeout(timeout)
+        }
+      })()
+    })
+  }, [pairHostOptions, pairSelectionOpen])
+
+  useEffect(() => {
+    let active = true
+
+    const handleURL = async (url: string | null) => {
+      if (!url) return
+      if (!parsePair(url)) return
+
+      if (!restoredRef.current) {
+        for (let attempt = 0; attempt < 20; attempt += 1) {
+          await new Promise((resolve) => setTimeout(resolve, 100))
+          if (restoredRef.current || !active) {
+            break
+          }
+        }
+      }
+
+      if (!active) return
+      connectPairPayload(url, "link")
+    }
+
+    void Linking.getInitialURL()
+      .then((url) => handleURL(url))
+      .catch(() => {})
+
+    const sub = Linking.addEventListener("url", (event) => {
+      void handleURL(event.url)
+    })
+
+    return () => {
+      active = false
+      sub.remove()
+    }
+  }, [connectPairPayload, restoredRef])
+
+  useEffect(() => {
+    if (!activeServerId) return
+    void refreshServerStatusAndSessions(activeServerId)
+    const timer = setInterval(() => {
+      void refreshServerStatusAndSessions(activeServerId)
+    }, 15000)
+    return () => clearInterval(timer)
+  }, [activeServerId, refreshServerStatusAndSessions])
+
+  const defaultModelInstalled = installedWhisperModels.includes(defaultWhisperModel)
+  let onboardingProgressRaw = 0
+  if (downloadingModelID) {
+    onboardingProgressRaw = downloadProgress
+  } else if (defaultModelInstalled || activeWhisperModel === defaultWhisperModel) {
+    onboardingProgressRaw = 1
+  } else if (isPreparingWhisperModel) {
+    onboardingProgressRaw = 0.12
+  }
+  const onboardingProgress = Math.max(0, Math.min(1, onboardingProgressRaw))
+  const onboardingProgressPct = Math.round(onboardingProgress * 100)
+  let onboardingModelStatus = "Downloading model in background"
+  if (downloadingModelID) {
+    onboardingModelStatus = `Downloading model in background ${onboardingProgressPct}%`
+  } else if (onboardingProgress >= 1) {
+    onboardingModelStatus = "Model ready in background"
+  }
+  const onboardingSteps = [
+    {
+      title: "Microphone access.",
+      body: "Control only listens while you hold the record button.",
+      primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone access..." : "Continue",
+      primaryDisabled: microphonePermissionState === "pending",
+      secondaryLabel: undefined,
+      visualTag: "MIC",
+      visualSurfaceStyle: styles.onboardingVisualSurfaceMic,
+      visualOrbStyle: styles.onboardingVisualOrbMic,
+      visualTagStyle: styles.onboardingVisualTagMic,
+    },
+    {
+      title: "Turn on notifications.",
+      body: "Get alerts when your OpenCode run finishes, fails, or needs your attention.",
+      primaryLabel: notificationPermissionState === "pending" ? "Requesting notification access..." : "Continue",
+      primaryDisabled: notificationPermissionState === "pending",
+      secondaryLabel: undefined,
+      visualTag: "PUSH",
+      visualSurfaceStyle: styles.onboardingVisualSurfaceNotifications,
+      visualOrbStyle: styles.onboardingVisualOrbNotifications,
+      visualTagStyle: styles.onboardingVisualTagNotifications,
+    },
+    {
+      title: "Local network access.",
+      body: "This lets Control discover your machine on the same network.",
+      primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network access..." : "Continue",
+      primaryDisabled: localNetworkPermissionState === "pending",
+      secondaryLabel: undefined,
+      visualTag: "LAN",
+      visualSurfaceStyle: styles.onboardingVisualSurfaceNetwork,
+      visualOrbStyle: styles.onboardingVisualOrbNetwork,
+      visualTagStyle: styles.onboardingVisualTagNetwork,
+    },
+    {
+      title: "Pair your computer.",
+      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: "Skip and use discovery",
+      visualTag: "PAIR",
+      visualSurfaceStyle: styles.onboardingVisualSurfacePair,
+      visualOrbStyle: styles.onboardingVisualOrbPair,
+      visualTagStyle: styles.onboardingVisualTagPair,
+    },
+  ] as const
+  const onboardingStepCount = onboardingSteps.length
+  const clampedOnboardingStep = Math.max(0, Math.min(onboardingStep, onboardingStepCount - 1))
+  const onboardingCurrentStep = onboardingSteps[clampedOnboardingStep]
+  const {
+    title: onboardingTitle,
+    body: onboardingBody,
+    primaryLabel: onboardingPrimaryLabel,
+    primaryDisabled: onboardingPrimaryDisabled,
+    secondaryLabel: onboardingSecondaryLabel,
+    visualTag: onboardingVisualTag,
+    visualSurfaceStyle: onboardingVisualSurfaceStyle,
+    visualOrbStyle: onboardingVisualOrbStyle,
+    visualTagStyle: onboardingVisualTagStyle,
+  } = onboardingCurrentStep
+  const onboardingSafeStyle = useMemo(
+    () => [styles.onboardingRoot, { paddingTop: insets.top + 8, paddingBottom: Math.max(insets.bottom, 16) }],
+    [insets.bottom, insets.top],
+  )
+
+  if (!onboardingReady) {
+    return (
+      <SafeAreaView style={onboardingSafeStyle} edges={["left", "right"]}>
+        <StatusBar style="light" />
+      </SafeAreaView>
+    )
+  }
+
+  if (!onboardingComplete) {
+    return (
+      <SafeAreaView style={onboardingSafeStyle} edges={["left", "right"]}>
+        <StatusBar style="light" />
+
+        <View style={styles.onboardingShell}>
+          <View style={styles.onboardingTopRail}>
+            <View style={styles.onboardingModelRow}>
+              <Text style={styles.onboardingModelText}>{onboardingModelStatus}</Text>
+              <View style={styles.onboardingModelTrack}>
+                <View
+                  style={[
+                    styles.onboardingModelFill,
+                    { width: `${Math.max(onboardingProgressPct, onboardingProgress > 0 ? 6 : 0)}%` },
+                  ]}
+                />
+              </View>
+            </View>
+          </View>
+
+          <View style={styles.onboardingContent}>
+            <View style={[styles.onboardingVisualSurface, onboardingVisualSurfaceStyle]}>
+              <View style={[styles.onboardingVisualOrb, styles.onboardingVisualOrbOne, onboardingVisualOrbStyle]} />
+              <View style={[styles.onboardingVisualOrb, styles.onboardingVisualOrbTwo, onboardingVisualOrbStyle]} />
+              <View style={[styles.onboardingVisualTag, onboardingVisualTagStyle]}>
+                <Text style={styles.onboardingVisualTagText}>{onboardingVisualTag}</Text>
+              </View>
+            </View>
+
+            <View style={styles.onboardingCopyBlock}>
+              <Text
+                style={styles.onboardingEyebrow}
+              >{`STEP ${clampedOnboardingStep + 1} OF ${onboardingStepCount}`}</Text>
+              <Text style={styles.onboardingTitle}>{onboardingTitle}</Text>
+              <Text style={styles.onboardingBody}>{onboardingBody}</Text>
+            </View>
+          </View>
+
+          <View style={styles.onboardingFooter}>
+            <Pressable
+              onPress={() => {
+                if (clampedOnboardingStep === 0) {
+                  void (async () => {
+                    await handleRequestMicrophonePermission()
+                    setOnboardingStep(1)
+                  })()
+                  return
+                }
+
+                if (clampedOnboardingStep === 1) {
+                  void (async () => {
+                    await handleRequestNotificationPermission()
+                    setOnboardingStep(2)
+                  })()
+                  return
+                }
+
+                if (clampedOnboardingStep === 2) {
+                  void (async () => {
+                    await handleRequestLocalNetworkPermission()
+                    setOnboardingStep(3)
+                  })()
+                  return
+                }
+
+                completeOnboarding(true)
+              }}
+              style={({ pressed }) => [
+                styles.onboardingPrimaryButton,
+                onboardingPrimaryDisabled && styles.onboardingPrimaryButtonDisabled,
+                pressed && styles.clearButtonPressed,
+              ]}
+              disabled={onboardingPrimaryDisabled}
+            >
+              <Text style={styles.onboardingPrimaryButtonText}>{onboardingPrimaryLabel}</Text>
+              <SymbolView
+                name={{ ios: "arrow.right", android: "arrow_forward", web: "arrow_forward" }}
+                size={20}
+                weight="semibold"
+                tintColor="#FFFFFF"
+              />
+            </Pressable>
+
+            {onboardingSecondaryLabel ? (
+              <Pressable
+                onPress={() => {
+                  if (clampedOnboardingStep < onboardingStepCount - 1) {
+                    setOnboardingStep((step) => Math.min(step + 1, onboardingStepCount - 1))
+                    return
+                  }
+
+                  completeOnboarding(false)
+                }}
+                style={({ pressed }) => [styles.onboardingSecondaryButton, pressed && styles.clearButtonPressed]}
+              >
+                <Text style={styles.onboardingSecondaryText}>{onboardingSecondaryLabel}</Text>
+              </Pressable>
+            ) : null}
+          </View>
+        </View>
+      </SafeAreaView>
+    )
+  }
+
+  return (
+    <SafeAreaView style={styles.container}>
+      <StatusBar style="light" />
+
+      {isDropdownOpen ? <Pressable style={styles.dismissOverlay} onPress={closeDropdown} /> : null}
+
+      {/* Workspace header */}
+      <View style={styles.headerAnchor}>
+        <Animated.View style={[styles.statusBar, animatedHeaderStyle, animatedHeaderShadowStyle]}>
+          {activeServer ? (
+            <View style={styles.headerSplitRow}>
+              <Pressable
+                onPress={toggleServerMenu}
+                style={({ pressed }) => [styles.headerSplitLeft, pressed && styles.clearButtonPressed]}
+              >
+                <View style={styles.headerServerLabel}>
+                  <View style={styles.headerStatusIconWrap}>
+                    <View style={[styles.serverStatusDot, headerDotStyle]} />
+                  </View>
+                  <Text
+                    style={[styles.workspaceHeaderText, styles.headerServerText]}
+                    numberOfLines={1}
+                    ellipsizeMode="tail"
+                  >
+                    {activeServer.name}
+                  </Text>
+                </View>
+              </Pressable>
+
+              <View style={styles.headerSplitDivider} />
+
+              <Pressable
+                onPress={toggleSessionMenu}
+                style={({ pressed }) => [styles.headerSplitRight, pressed && styles.clearButtonPressed]}
+              >
+                <Text
+                  style={[styles.workspaceHeaderText, styles.headerSessionText]}
+                  numberOfLines={1}
+                  ellipsizeMode="tail"
+                >
+                  {activeSession?.title ?? "Select session"}
+                </Text>
+              </Pressable>
+            </View>
+          ) : (
+            <Pressable
+              onPress={toggleServerMenu}
+              style={({ pressed }) => [styles.statusBarTapArea, pressed && styles.clearButtonPressed]}
+            >
+              <View style={styles.headerServerLabel}>
+                <View style={styles.headerStatusIconWrap}>
+                  <View style={[styles.serverStatusDot, headerDotStyle]} />
+                </View>
+                <Text style={styles.workspaceHeaderText}>{headerTitle}</Text>
+              </View>
+            </Pressable>
+          )}
+
+          <Animated.View
+            style={[styles.serverMenuInline, animatedServerMenuStyle]}
+            pointerEvents={isDropdownOpen ? "auto" : "none"}
+          >
+            <ScrollView
+              style={styles.dropdownListViewport}
+              contentContainerStyle={styles.dropdownListContent}
+              showsVerticalScrollIndicator={false}
+              bounces={false}
+            >
+              {effectiveDropdownMode === "server" ? (
+                <View onLayout={handleServerMenuListLayout}>
+                  <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}>
+                          <SymbolView
+                            name={{ ios: "xmark", android: "close", web: "close" }}
+                            size={12}
+                            weight="bold"
+                            tintColor="#8C93A3"
+                          />
+                        </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,
+                        ]}
+                      >
+                        <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>
+                    ))
+                  )}
+
+                  {discoveryStatus === "error" && discoveryError ? (
+                    <Text style={styles.discoveryErrorText} numberOfLines={1} ellipsizeMode="tail">
+                      {discoveryError}
+                    </Text>
+                  ) : null}
+                </View>
+              ) : activeServer ? (
+                <View onLayout={handleSessionMenuListLayout}>
+                  {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>
+                    )
+                  ) : (
+                    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>
+                    ))
+                  )}
+                </View>
+              ) : (
+                <Text style={styles.serverEmptyText}>Select a server first</Text>
+              )}
+            </ScrollView>
+
+            {effectiveDropdownMode === "server" ? (
+              <View onLayout={handleServerMenuFooterLayout}>
+                <Pressable onPress={() => void handleStartScan()} style={styles.addServerButton}>
+                  <Text style={styles.addServerButtonText}>Add server by scanning QR code</Text>
+                </Pressable>
+              </View>
+            ) : effectiveDropdownMode === "session" && activeServer?.status === "online" ? (
+              <View style={styles.sessionMenuActions} onLayout={handleSessionMenuFooterLayout}>
+                {activeSession ? (
+                  <Pressable
+                    onPress={handleCreateSessionLikeCurrent}
+                    disabled={isCreatingSession}
+                    style={({ pressed }) => [
+                      styles.serverRow,
+                      styles.sessionMenuActionRow,
+                      isCreatingSession && styles.sessionMenuActionButtonDisabled,
+                      pressed && styles.clearButtonPressed,
+                    ]}
+                  >
+                    <View style={styles.sessionMenuActionInner}>
+                      <View style={styles.sessionMenuActionIconSlot}>
+                        <SymbolView
+                          name={{ ios: "folder.badge.plus", android: "create_new_folder", web: "create_new_folder" }}
+                          size={12}
+                          tintColor="#9BA3B5"
+                        />
+                      </View>
+                      <Text style={styles.sessionMenuActionText}>
+                        {sessionCreateMode === "same" ? "Creating workspace session..." : "New session with workspace"}
+                      </Text>
+                    </View>
+                  </Pressable>
+                ) : null}
+
+                <Pressable
+                  onPress={handleCreateRootSession}
+                  disabled={isCreatingSession}
+                  style={({ pressed }) => [
+                    styles.serverRow,
+                    styles.sessionMenuActionRow,
+                    styles.serverRowLast,
+                    isCreatingSession && styles.sessionMenuActionButtonDisabled,
+                    pressed && styles.clearButtonPressed,
+                  ]}
+                >
+                  <View style={styles.sessionMenuActionInner}>
+                    <View style={styles.sessionMenuActionIconSlot}>
+                      <SymbolView name={{ ios: "plus", android: "add", web: "add" }} size={12} tintColor="#9BA3B5" />
+                    </View>
+                    <Text style={styles.sessionMenuActionText}>
+                      {sessionCreateMode === "root" ? "Creating new session..." : "New session"}
+                    </Text>
+                  </View>
+                </Pressable>
+              </View>
+            ) : null}
+          </Animated.View>
+        </Animated.View>
+      </View>
+
+      {/* Transcription area */}
+      <View style={styles.transcriptionArea} onLayout={handleTranscriptionAreaLayout}>
+        {hasPendingPermission && activePermissionCard ? (
+          <View style={[styles.splitCard, styles.permissionCard]}>
+            <View style={styles.permissionHeaderRow}>
+              <View style={styles.permissionStatusDot} />
+              <View style={styles.permissionHeaderCopy}>
+                <Text style={styles.replyCardLabel}>Permission</Text>
+                <Text style={styles.permissionStatusText}>
+                  {isReplyingToActivePermission
+                    ? monitorStatus || "Sending decision…"
+                    : pendingPermissionCount > 1
+                      ? `${pendingPermissionCount} requests pending`
+                      : "Action needed"}
+                </Text>
+              </View>
+            </View>
+
+            <ScrollView style={styles.permissionScroll} contentContainerStyle={styles.permissionContent}>
+              <Text style={styles.permissionEyebrow}>{activePermissionCard.eyebrow}</Text>
+              <Text style={styles.permissionTitle}>{activePermissionCard.title}</Text>
+              <Text style={styles.permissionBody}>{activePermissionCard.body}</Text>
+
+              {activePermissionCard.sections.map((section, index) => (
+                <View
+                  key={`permission-section-${section.label}-${index}`}
+                  style={[
+                    styles.permissionSection,
+                    index === activePermissionCard.sections.length - 1 && styles.permissionSectionLast,
+                  ]}
+                >
+                  <Text style={styles.permissionSectionLabel}>{section.label}</Text>
+                  <Text style={[styles.permissionSectionText, section.mono && styles.permissionSectionTextMono]}>
+                    {section.text}
+                  </Text>
+                </View>
+              ))}
+            </ScrollView>
+
+            <View style={styles.permissionFooter}>
+              <Pressable
+                onPress={() => handlePermissionDecision("once")}
+                disabled={isReplyingToActivePermission}
+                style={({ pressed }) => [
+                  styles.permissionPrimaryButton,
+                  isReplyingToActivePermission && styles.permissionActionDisabled,
+                  pressed && styles.clearButtonPressed,
+                ]}
+              >
+                {isReplyingToActivePermission ? (
+                  <ActivityIndicator color="#FFFFFF" size="small" />
+                ) : (
+                  <Text style={styles.permissionPrimaryButtonText}>Allow once</Text>
+                )}
+              </Pressable>
+
+              <View style={styles.permissionSecondaryRow}>
+                {activePermissionRequest.always.length > 0 ? (
+                  <Pressable
+                    onPress={() => handlePermissionDecision("always")}
+                    disabled={isReplyingToActivePermission}
+                    style={({ pressed }) => [
+                      styles.permissionSecondaryButton,
+                      isReplyingToActivePermission && styles.permissionActionDisabled,
+                      pressed && styles.clearButtonPressed,
+                    ]}
+                  >
+                    <Text style={styles.permissionSecondaryButtonText}>Always allow</Text>
+                  </Pressable>
+                ) : null}
+
+                <Pressable
+                  onPress={() => handlePermissionDecision("reject")}
+                  disabled={isReplyingToActivePermission}
+                  style={({ pressed }) => [
+                    styles.permissionRejectButton,
+                    activePermissionRequest.always.length === 0 && styles.permissionRejectButtonWide,
+                    isReplyingToActivePermission && styles.permissionActionDisabled,
+                    pressed && styles.clearButtonPressed,
+                  ]}
+                >
+                  <Text style={styles.permissionRejectButtonText}>Reject</Text>
+                </Pressable>
+              </View>
+            </View>
+          </View>
+        ) : shouldShowAgentStateCard ? (
+          <>
+            <View style={styles.splitCardStack} pointerEvents={readerModeRendered ? "none" : "auto"}>
+              <View style={[styles.splitCard, styles.replyCard]} onLayout={handleAgentStateCardLayout}>
+                <View style={styles.agentStateHeaderRow}>
+                  <View style={styles.agentStateTitleWrap}>
+                    <View style={styles.agentStateIconWrap}>
+                      {agentStateIcon === "loading" ? (
+                        <ActivityIndicator size="small" color="#91A0C0" />
+                      ) : (
+                        <SymbolView
+                          name={{ ios: "checkmark.circle.fill", android: "check_circle", web: "check_circle" }}
+                          size={16}
+                          tintColor={AGENT_SUCCESS_GREEN}
+                        />
+                      )}
+                    </View>
+                    <Text style={styles.replyCardLabel}>Agent</Text>
+                  </View>
+                  <Animated.View
+                    style={[
+                      styles.agentStateActions,
+                      !hasAssistantResponse && styles.agentStateActionsSingle,
+                      animatedAgentStateActionsStyle,
+                    ]}
+                  >
+                    {hasAssistantResponse ? (
+                      <Pressable
+                        onPress={handleOpenReaderMode}
+                        hitSlop={8}
+                        accessibilityLabel="Open Reader"
+                        style={styles.agentStateActionButton}
+                      >
+                        <SymbolView
+                          name={READER_OPEN_SYMBOL}
+                          size={16}
+                          tintColor="#8FA4CC"
+                          style={styles.readerToggleSymbol}
+                        />
+                      </Pressable>
+                    ) : null}
+                    <Pressable onPress={handleHideAgentState} hitSlop={8} style={styles.agentStateActionButton}>
+                      <SymbolView
+                        name={{ ios: "xmark", android: "close", web: "close" }}
+                        size={14}
+                        weight="bold"
+                        tintColor="#8D97AB"
+                      />
+                    </Pressable>
+                  </Animated.View>
+                </View>
+                <ScrollView style={styles.replyScroll} contentContainerStyle={styles.replyContent}>
+                  {renderMarkdownBlocks(agentStateBlocks, "reply")}
+                </ScrollView>
+              </View>
+
+              {renderTranscriptionPanel()}
+            </View>
+
+            {readerModeVisible ? (
+              <Animated.View
+                pointerEvents={readerModeRendered ? "auto" : "none"}
+                style={[styles.splitCard, styles.readerCard, styles.readerOverlayCard, animatedReaderExpandStyle]}
+              >
+                <View style={styles.readerHeaderRow}>
+                  <View style={styles.agentStateTitleWrap}>
+                    <View style={styles.agentStateIconWrap}>
+                      {agentStateIcon === "loading" ? (
+                        <ActivityIndicator size="small" color="#91A0C0" />
+                      ) : (
+                        <SymbolView
+                          name={{ ios: "checkmark.circle.fill", android: "check_circle", web: "check_circle" }}
+                          size={16}
+                          tintColor={AGENT_SUCCESS_GREEN}
+                        />
+                      )}
+                    </View>
+                    <Text style={styles.replyCardLabel}>Agent</Text>
+                  </View>
+                  <View style={styles.readerActionRail}>
+                    <Animated.View style={[styles.readerToggleFloatingAction, animatedReaderToggleTravelStyle]}>
+                      <Pressable
+                        onPress={handleCloseReaderMode}
+                        hitSlop={8}
+                        accessibilityLabel="Close Reader"
+                        style={styles.agentStateActionButton}
+                      >
+                        <Animated.View style={[styles.readerToggleIconLayer, animatedReaderToggleOpenIconStyle]}>
+                          <SymbolView
+                            name={READER_OPEN_SYMBOL}
+                            size={16}
+                            tintColor="#8FA4CC"
+                            style={styles.readerToggleSymbol}
+                          />
+                        </Animated.View>
+                        <Animated.View style={[styles.readerToggleIconLayer, animatedReaderToggleCloseIconStyle]}>
+                          <SymbolView
+                            name={READER_CLOSE_SYMBOL}
+                            size={16}
+                            tintColor="#8FA4CC"
+                            style={styles.readerToggleSymbol}
+                          />
+                        </Animated.View>
+                      </Pressable>
+                    </Animated.View>
+                  </View>
+                </View>
+
+                <ScrollView style={styles.readerScroll} contentContainerStyle={styles.readerContent}>
+                  {renderMarkdownBlocks(readerBlocks, "reader")}
+                </ScrollView>
+              </Animated.View>
+            ) : null}
+          </>
+        ) : (
+          renderTranscriptionPanel()
+        )}
+      </View>
+
+      {hasPendingPermission ? null : (
+        <View style={styles.controlsRow} onLayout={handleControlsLayout}>
+          <Pressable
+            onPressIn={handlePressIn}
+            onPressOut={handlePressOut}
+            disabled={!permissionGranted || modelLoading}
+            style={[styles.recordPressable, !permissionGranted && styles.recordButtonDisabled]}
+          >
+            <View style={styles.recordButton}>
+              {isTranscribingBulk ? (
+                <View style={styles.recordBusyCenter}>
+                  <ActivityIndicator color="#FF2E3F" size="small" />
+                </View>
+              ) : modelLoadingState !== "ready" ? (
+                <>
+                  <View
+                    style={[
+                      styles.loadFill,
+                      modelLoadingState === "loading" && styles.loadFillPending,
+                      { width: modelLoadingState === "downloading" ? `${Math.max(pct, 3)}%` : "100%" },
+                    ]}
+                  />
+                  <View style={styles.loadOverlay} pointerEvents="none">
+                    <Text style={styles.loadText}>
+                      {modelLoadingState === "downloading"
+                        ? `Downloading ${loadingModelLabel} ${pct}%`
+                        : `Loading ${loadingModelLabel}`}
+                    </Text>
+                  </View>
+                </>
+              ) : (
+                <>
+                  <Animated.View style={[styles.recordBorder, animatedBorderStyle]} pointerEvents="none" />
+                  <Animated.View style={[styles.recordDot, animatedDotStyle]} />
+                </>
+              )}
+            </View>
+          </Pressable>
+
+          <Animated.View style={[styles.sendSlot, animatedSendStyle]} pointerEvents={shouldShowSend ? "auto" : "none"}>
+            <Pressable
+              onPress={handleSendTranscript}
+              style={({ pressed }) => [
+                styles.sendButton,
+                (isSending || !hasTranscript || !canSendToSession) && styles.sendButtonDisabled,
+                pressed && styles.clearButtonPressed,
+              ]}
+              disabled={isSending || !hasTranscript || !canSendToSession}
+              hitSlop={8}
+            >
+              <SymbolView
+                name={{ ios: "arrow.up", android: "arrow_upward", web: "arrow_upward" }}
+                size={28}
+                weight="bold"
+                tintColor="#FFFFFF"
+              />
+            </Pressable>
+          </Animated.View>
+        </View>
+      )}
+
+      <Modal
+        visible={whisperSettingsOpen}
+        animationType="slide"
+        presentationStyle="formSheet"
+        onRequestClose={() => setWhisperSettingsOpen(false)}
+      >
+        <SafeAreaView style={styles.settingsRoot}>
+          <View style={styles.settingsTop}>
+            <View style={styles.settingsTitleBlock}>
+              <Text style={styles.settingsTitle}>Settings</Text>
+              <Text style={styles.settingsSubtitle}>Default: {WHISPER_MODEL_LABELS[defaultWhisperModel]}</Text>
+            </View>
+            <Pressable onPress={() => setWhisperSettingsOpen(false)}>
+              <Text style={styles.settingsClose}>Done</Text>
+            </Pressable>
+          </View>
+
+          <ScrollView style={styles.settingsScroll} contentContainerStyle={styles.settingsContent}>
+            <View style={styles.settingsSection}>
+              <Text style={styles.settingsSectionLabel}>DEVELOPMENT:</Text>
+              {__DEV__ ? (
+                <Pressable
+                  onPress={handleReplayOnboarding}
+                  style={({ pressed }) => [styles.settingsTextRow, pressed && styles.clearButtonPressed]}
+                >
+                  <Text style={styles.settingsTextRowTitle}>Replay onboarding</Text>
+                  <Text style={styles.settingsTextRowAction}>Run</Text>
+                </Pressable>
+              ) : (
+                <View style={styles.settingsTextRow}>
+                  <Text style={styles.settingsMutedText}>Available in development builds.</Text>
+                </View>
+              )}
+            </View>
+
+            <View style={styles.settingsSection}>
+              <Text style={styles.settingsSectionLabel}>GENERAL:</Text>
+              <View style={styles.settingsTextRow}>
+                <Text style={styles.settingsTextRowTitle}>Default model</Text>
+                <Text style={styles.settingsTextRowValue}>{WHISPER_MODEL_LABELS[defaultWhisperModel]}</Text>
+              </View>
+
+              <View style={styles.settingsTextRow}>
+                <View style={styles.settingsOptionCopy}>
+                  <Text style={styles.settingsTextRowTitle}>Realtime dictation</Text>
+                  <Text style={styles.settingsTextRowMeta}>Turn off to transcribe after release</Text>
+                </View>
+                <Switch
+                  value={transcriptionMode === "realtime"}
+                  onValueChange={(enabled) => setTranscriptionMode(enabled ? "realtime" : "bulk")}
+                  disabled={dictationSettingsLocked}
+                  trackColor={{ false: "#2D2D31", true: "#6A3A33" }}
+                  thumbColor={transcriptionMode === "realtime" ? "#FF6B56" : "#F2F2F2"}
+                  ios_backgroundColor="#2D2D31"
+                />
+              </View>
+
+              <View style={styles.settingsTextRow}>
+                <View style={styles.settingsOptionCopy}>
+                  <Text style={styles.settingsTextRowTitle}>Auto send on dictation end</Text>
+                  <Text style={styles.settingsTextRowMeta}>Send the transcript as soon as recording finishes</Text>
+                </View>
+                <Switch
+                  value={autoSendOnDictationEnd}
+                  onValueChange={setAutoSendOnDictationEnd}
+                  disabled={dictationSettingsLocked}
+                  trackColor={{ false: "#2D2D31", true: "#6A3A33" }}
+                  thumbColor={autoSendOnDictationEnd ? "#FF6B56" : "#F2F2F2"}
+                  ios_backgroundColor="#2D2D31"
+                />
+              </View>
+            </View>
+
+            {WHISPER_MODEL_FAMILIES.map((family) => (
+              <View key={family.key} style={styles.settingsSection}>
+                <Text style={styles.settingsSectionLabel}>{family.label.toUpperCase()}:</Text>
+                <View style={styles.settingsTextRow}>
+                  <Text style={styles.settingsMutedText}>{family.description}</Text>
+                </View>
+                {family.models.map((modelID) => {
+                  const installed = installedWhisperModels.includes(modelID)
+                  const isDefault = defaultWhisperModel === modelID
+                  const isDownloading = downloadingModelID === modelID
+                  const actionDisabled = (downloadingModelID !== null && !isDownloading) || isTranscribingBulk
+                  const downloadPct = Math.round(Math.max(0, Math.min(1, downloadProgress)) * 100)
+                  const actionLabel = isDownloading
+                    ? `${downloadPct}%`
+                    : installed
+                      ? isDefault
+                        ? "Selected"
+                        : "Select"
+                      : "Download"
+                  const sizeLabel = formatWhisperModelSize(WHISPER_MODEL_SIZES[modelID])
+                  const friendlyName = WHISPER_MODEL_LABELS[modelID]
+                  const rowMeta = [sizeLabel, installed ? "installed" : null, isDefault ? "default" : null]
+                    .filter(Boolean)
+                    .join(" · ")
+
+                  return (
+                    <View key={modelID} style={styles.settingsInlineRow}>
+                      <Pressable
+                        onPress={() => {
+                          if (installed) {
+                            void handleSelectWhisperModel(modelID)
+                          }
+                        }}
+                        onLongPress={() => {
+                          if (!installed || isDownloading) return
+                          Alert.alert("Delete model?", `Remove ${friendlyName} from this device?`, [
+                            { text: "Cancel", style: "cancel" },
+                            {
+                              text: "Delete",
+                              style: "destructive",
+                              onPress: () => {
+                                void handleDeleteWhisperModel(modelID)
+                              },
+                            },
+                          ])
+                        }}
+                        delayLongPress={350}
+                        disabled={!installed || actionDisabled || isPreparingWhisperModel}
+                        style={({ pressed }) => [
+                          styles.settingsInlineLabelPressable,
+                          (!installed || actionDisabled || isPreparingWhisperModel) &&
+                            styles.settingsInlinePressableDisabled,
+                          pressed && styles.clearButtonPressed,
+                        ]}
+                      >
+                        <Text style={styles.settingsInlineName}>{friendlyName}</Text>
+                        <Text style={styles.settingsInlineMeta}>{rowMeta}</Text>
+                      </Pressable>
+
+                      <Pressable
+                        onPress={() => {
+                          if (isDownloading) return
+                          if (installed) {
+                            void handleSelectWhisperModel(modelID)
+                            return
+                          }
+                          void handleDownloadWhisperModel(modelID)
+                        }}
+                        disabled={actionDisabled || (installed && isPreparingWhisperModel)}
+                        accessibilityLabel={actionLabel}
+                        style={({ pressed }) => [
+                          styles.settingsInlineTextActionPressable,
+                          (actionDisabled || (installed && isPreparingWhisperModel)) &&
+                            styles.settingsInlinePressableDisabled,
+                          pressed && styles.clearButtonPressed,
+                        ]}
+                      >
+                        <Text
+                          style={[
+                            styles.settingsInlineTextAction,
+                            installed && styles.settingsInlineTextActionInstalled,
+                            isDownloading && styles.settingsInlineTextActionDownloading,
+                          ]}
+                        >
+                          {actionLabel}
+                        </Text>
+                      </Pressable>
+                    </View>
+                  )
+                })}
+              </View>
+            ))}
+          </ScrollView>
+        </SafeAreaView>
+      </Modal>
+
+      <Modal
+        visible={scanOpen}
+        animationType="slide"
+        presentationStyle="formSheet"
+        onRequestClose={() => setScanOpen(false)}
+      >
+        <SafeAreaView style={styles.scanRoot}>
+          <View style={styles.scanTop}>
+            <Text style={styles.scanTitle}>Scan server QR</Text>
+            <Pressable onPress={() => setScanOpen(false)}>
+              <Text style={styles.scanClose}>Close</Text>
+            </Pressable>
+          </View>
+          {camGranted && camera ? (
+            <camera.CameraView
+              style={styles.scanCam}
+              barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
+              onBarcodeScanned={handleScan}
+            />
+          ) : (
+            <View style={styles.scanEmpty}>
+              <Text style={styles.scanHint}>Camera permission is required to scan setup QR codes.</Text>
+            </View>
+          )}
+        </SafeAreaView>
+      </Modal>
+
+      <Modal
+        visible={pairSelectionOpen}
+        animationType="slide"
+        presentationStyle="formSheet"
+        onRequestClose={closePairSelection}
+      >
+        <SafeAreaView style={styles.pairSelectRoot}>
+          <View style={styles.pairSelectTop}>
+            <View style={styles.pairSelectTitleBlock}>
+              <Text style={styles.pairSelectTitle}>Choose server host</Text>
+              <Text style={styles.pairSelectSubtitle}>Select the best network route for this server.</Text>
+            </View>
+            <Pressable onPress={closePairSelection}>
+              <Text style={styles.pairSelectClose}>Close</Text>
+            </Pressable>
+          </View>
+
+          <ScrollView style={styles.pairSelectList} contentContainerStyle={styles.pairSelectListContent}>
+            {pairHostOptions.map((option, index) => {
+              const probe = pairHostProbes[option.url]
+              const selected = selectedPairHostURL === option.url
+              const recommended = recommendedPairHostURL === option.url
+              let dotStyle = styles.pairSelectDotChecking
+              if (probe?.status === "online") {
+                dotStyle = styles.pairSelectDotOnline
+              } else if (probe?.status === "offline") {
+                dotStyle = styles.pairSelectDotOffline
+              }
+
+              return (
+                <Pressable
+                  key={option.url}
+                  onPress={() => setSelectedPairHostURL(option.url)}
+                  style={({ pressed }) => [
+                    styles.pairSelectRow,
+                    selected && styles.pairSelectRowSelected,
+                    index === pairHostOptions.length - 1 && styles.pairSelectRowLast,
+                    pressed && styles.clearButtonPressed,
+                  ]}
+                >
+                  <View style={styles.pairSelectRowMain}>
+                    <View style={styles.pairSelectLeftCol}>
+                      <View style={[styles.pairSelectDot, dotStyle]} />
+                      <View style={styles.pairSelectRowCopy}>
+                        <View style={styles.pairSelectRowTitleLine}>
+                          <Text style={styles.pairSelectHostLabel} numberOfLines={1}>
+                            {option.label}
+                          </Text>
+                          {recommended ? <Text style={styles.pairSelectRecommended}>recommended</Text> : null}
+                        </View>
+                        <Text style={styles.pairSelectHostMeta}>{pairHostKindLabel(option.kind)}</Text>
+                        <Text style={styles.pairSelectProbeMeta}>{pairProbeSummary(probe)}</Text>
+                        <Text style={styles.pairSelectHostURL} numberOfLines={1} ellipsizeMode="middle">
+                          {option.url}
+                        </Text>
+                      </View>
+                    </View>
+
+                    <View style={styles.pairSelectRightCol}>
+                      <Text style={styles.pairSelectLatency}>{pairProbeLabel(probe)}</Text>
+                      {selected ? (
+                        <SymbolView
+                          name={{
+                            ios: "checkmark",
+                            android: "check",
+                            web: "check",
+                          }}
+                          size={13}
+                          tintColor="#C5C5C5"
+                        />
+                      ) : null}
+                    </View>
+                  </View>
+                </Pressable>
+              )
+            })}
+          </ScrollView>
+
+          <View style={styles.pairSelectFooter}>
+            <Pressable
+              onPress={handleConnectSelectedPairHost}
+              disabled={!selectedPairHostURL || isConnectingPairHost}
+              style={({ pressed }) => [
+                styles.pairSelectPrimaryButton,
+                (!selectedPairHostURL || isConnectingPairHost) && styles.pairSelectPrimaryButtonDisabled,
+                pressed && styles.clearButtonPressed,
+              ]}
+            >
+              <Text style={styles.pairSelectPrimaryButtonText}>
+                {isConnectingPairHost ? "Connecting..." : "Connect selected host"}
+              </Text>
+            </Pressable>
+
+            <Pressable
+              onPress={handleRescanFromPairSelection}
+              style={({ pressed }) => [pressed && styles.clearButtonPressed]}
+            >
+              <Text style={styles.pairSelectSecondaryAction}>Scan again</Text>
+            </Pressable>
+          </View>
+        </SafeAreaView>
+      </Modal>
+    </SafeAreaView>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    backgroundColor: C.bg,
+    position: "relative",
+  },
+  onboardingRoot: {
+    flex: 1,
+    backgroundColor: C.bg,
+    paddingHorizontal: 16,
+  },
+  onboardingShell: {
+    flex: 1,
+  },
+  onboardingTopRail: {
+    gap: 8,
+    marginBottom: 10,
+  },
+  onboardingContent: {
+    flex: 1,
+    justifyContent: "center",
+    alignItems: "stretch",
+    gap: 22,
+    paddingHorizontal: 2,
+  },
+  onboardingModelRow: {
+    gap: 6,
+  },
+  onboardingModelText: {
+    color: "#A9A9A9",
+    fontSize: 11,
+    fontWeight: "700",
+    letterSpacing: 0.35,
+    textTransform: "uppercase",
+  },
+  onboardingModelTrack: {
+    height: 4,
+    width: "100%",
+    borderRadius: 999,
+    backgroundColor: "#2C2C2C",
+    overflow: "hidden",
+  },
+  onboardingModelFill: {
+    height: "100%",
+    borderRadius: 999,
+    backgroundColor: "#FF5B47",
+  },
+  onboardingVisualSurface: {
+    width: "100%",
+    minHeight: 176,
+    borderRadius: 26,
+    borderWidth: 1,
+    alignItems: "center",
+    justifyContent: "center",
+    overflow: "hidden",
+    backgroundColor: "#171717",
+    borderColor: "#2B2B2B",
+  },
+  onboardingVisualSurfaceMic: {
+    backgroundColor: "#1A2118",
+    borderColor: "#2F3D2D",
+  },
+  onboardingVisualSurfaceNotifications: {
+    backgroundColor: "#1A1D2A",
+    borderColor: "#303A5A",
+  },
+  onboardingVisualSurfaceNetwork: {
+    backgroundColor: "#1A2218",
+    borderColor: "#344930",
+  },
+  onboardingVisualSurfacePair: {
+    backgroundColor: "#1F1A27",
+    borderColor: "#413157",
+  },
+  onboardingVisualOrb: {
+    position: "absolute",
+    borderRadius: 999,
+    opacity: 0.22,
+  },
+  onboardingVisualOrbOne: {
+    width: 130,
+    height: 130,
+    top: -28,
+    left: -22,
+  },
+  onboardingVisualOrbTwo: {
+    width: 160,
+    height: 160,
+    bottom: -52,
+    right: -44,
+  },
+  onboardingVisualOrbMic: {
+    backgroundColor: "#61C372",
+  },
+  onboardingVisualOrbNotifications: {
+    backgroundColor: "#4A6EE0",
+  },
+  onboardingVisualOrbNetwork: {
+    backgroundColor: "#78B862",
+  },
+  onboardingVisualOrbPair: {
+    backgroundColor: "#9B6CDC",
+  },
+  onboardingVisualTag: {
+    borderRadius: 20,
+    paddingHorizontal: 24,
+    paddingVertical: 12,
+    borderWidth: 1,
+    shadowColor: "#000000",
+    shadowOffset: { width: 0, height: 8 },
+    shadowOpacity: 0.12,
+    shadowRadius: 16,
+    elevation: 3,
+  },
+  onboardingVisualTagMic: {
+    backgroundColor: "#253A25",
+    borderColor: "#3A5C3A",
+  },
+  onboardingVisualTagNotifications: {
+    backgroundColor: "#223561",
+    borderColor: "#38518C",
+  },
+  onboardingVisualTagNetwork: {
+    backgroundColor: "#284122",
+    borderColor: "#3D6835",
+  },
+  onboardingVisualTagPair: {
+    backgroundColor: "#3B2859",
+    borderColor: "#5A3D86",
+  },
+  onboardingVisualTagText: {
+    color: "#F6F7F8",
+    fontSize: 22,
+    fontWeight: "800",
+    letterSpacing: 1.8,
+  },
+  onboardingCopyBlock: {
+    alignItems: "flex-start",
+    gap: 10,
+    width: "100%",
+  },
+  onboardingEyebrow: {
+    color: "#7F7F7F",
+    fontSize: 11,
+    fontWeight: "700",
+    letterSpacing: 1.3,
+  },
+  onboardingTitle: {
+    color: C.textPrimary,
+    fontSize: 34,
+    fontWeight: "800",
+    textAlign: "left",
+    letterSpacing: -1,
+    lineHeight: 38,
+  },
+  onboardingBody: {
+    color: "#B4B4B4",
+    fontSize: 18,
+    lineHeight: 25,
+    textAlign: "left",
+    paddingHorizontal: 0,
+  },
+  onboardingFooter: {
+    gap: 10,
+    paddingTop: 6,
+  },
+  onboardingPrimaryButton: {
+    minHeight: BTN_PRIMARY.minHeight,
+    borderRadius: BTN_PRIMARY.borderRadius,
+    alignItems: "center",
+    justifyContent: "center",
+    backgroundColor: BTN_PRIMARY.backgroundColor,
+    borderWidth: BTN_PRIMARY.borderWidth,
+    borderColor: BTN_PRIMARY.borderColor,
+    flexDirection: "row",
+    gap: 10,
+    shadowColor: "#000000",
+    shadowOffset: { width: 0, height: 8 },
+    shadowOpacity: 0.2,
+    shadowRadius: 18,
+    elevation: 4,
+  },
+  onboardingPrimaryButtonDisabled: {
+    opacity: 0.6,
+  },
+  onboardingPrimaryButtonText: {
+    color: "#FFFFFF",
+    fontSize: 17,
+    fontWeight: "700",
+    letterSpacing: 0.2,
+  },
+  onboardingSecondaryButton: {
+    alignSelf: "flex-start",
+    paddingVertical: 8,
+    paddingHorizontal: 2,
+  },
+  onboardingSecondaryText: {
+    color: "#959CAA",
+    fontSize: 14,
+    fontWeight: "600",
+    textAlign: "left",
+  },
+  dismissOverlay: {
+    ...StyleSheet.absoluteFillObject,
+    zIndex: 15,
+  },
+  headerAnchor: {
+    marginHorizontal: 6,
+    marginTop: 5,
+    height: 51,
+    zIndex: 30,
+  },
+  statusBar: {
+    position: "absolute",
+    top: 0,
+    left: 0,
+    right: 0,
+    backgroundColor: C.surface,
+    borderRadius: 20,
+    borderWidth: 3,
+    borderColor: C.border,
+    paddingHorizontal: 14,
+    paddingTop: 0,
+    overflow: "hidden",
+    shadowColor: "#000000",
+  },
+  statusBarInner: {
+    flexDirection: "row",
+    alignItems: "center",
+    justifyContent: "space-between",
+    minHeight: 30,
+  },
+  statusBarTapArea: {
+    height: 45,
+    flexDirection: "row",
+    alignItems: "center",
+    justifyContent: "flex-start",
+  },
+  headerServerLabel: {
+    flexDirection: "row",
+    alignItems: "center",
+    gap: 8,
+    width: "100%",
+  },
+  headerSplitRow: {
+    height: 45,
+    flexDirection: "row",
+    alignItems: "center",
+  },
+  headerSplitLeft: {
+    flex: 1,
+    flexBasis: 0,
+    minWidth: 0,
+    height: "100%",
+    justifyContent: "center",
+    alignItems: "flex-start",
+    paddingRight: 8,
+  },
+  headerSplitDivider: {
+    width: 4,
+    height: 4,
+    borderRadius: 2,
+    backgroundColor: "#3F4556",
+    marginHorizontal: 6,
+  },
+  headerSplitRight: {
+    flex: 1,
+    flexBasis: 0,
+    minWidth: 0,
+    height: "100%",
+    justifyContent: "center",
+    alignItems: "flex-start",
+    paddingLeft: 8,
+  },
+  workspaceHeaderText: {
+    color: "#8F8F8F",
+    fontSize: 14,
+    fontWeight: "600",
+  },
+  headerServerText: {
+    flex: 1,
+    minWidth: 0,
+    width: "100%",
+  },
+  headerSessionText: {
+    flexShrink: 1,
+    minWidth: 0,
+    width: "100%",
+    textAlign: "left",
+  },
+  serverMenuInline: {
+    marginTop: 8,
+    paddingBottom: 2,
+    gap: 4,
+  },
+  dropdownListViewport: {
+    maxHeight: DROPDOWN_VISIBLE_ROWS * DROPDOWN_ROW_HEIGHT,
+  },
+  dropdownListContent: {
+    paddingBottom: 0,
+  },
+  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: C.textDimmed,
+    fontSize: 14,
+    textAlign: "center",
+    paddingVertical: 10,
+  },
+  serverGroupEmptyText: {
+    textAlign: "left",
+    paddingHorizontal: 4,
+    paddingVertical: 8,
+  },
+  serverRow: {
+    flexDirection: "row",
+    alignItems: "center",
+    gap: 10,
+    paddingHorizontal: 4,
+    paddingVertical: 8,
+    borderBottomWidth: 1,
+    borderBottomColor: "#222733",
+  },
+  serverRowLast: {
+    borderBottomWidth: 0,
+  },
+  serverRowPressed: {
+    opacity: 0.85,
+  },
+  serverStatusDot: {
+    width: 9,
+    height: 9,
+    borderRadius: 5,
+  },
+  serverStatusActive: {
+    backgroundColor: C.green,
+  },
+  serverStatusChecking: {
+    backgroundColor: C.yellow,
+  },
+  serverStatusOffline: {
+    backgroundColor: "#D14C55",
+  },
+  serverNameText: {
+    flex: 1,
+    color: C.textSecondary,
+    fontSize: 16,
+    fontWeight: "500",
+  },
+  sessionUpdatedText: {
+    color: "#8E96A8",
+    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: removed — replaced with SymbolView
+  addServerButton: {
+    marginTop: 4,
+    alignSelf: "center",
+    paddingHorizontal: 8,
+    paddingTop: 2,
+    paddingBottom: 10,
+  },
+  addServerButtonText: {
+    color: "#B8BDC9",
+    fontSize: 16,
+    fontWeight: "600",
+  },
+  sessionMenuActions: {
+    marginTop: 2,
+    borderTopWidth: 1,
+    borderTopColor: "#222733",
+  },
+  sessionMenuActionRow: {
+    paddingVertical: 9,
+  },
+  sessionMenuActionInner: {
+    flex: 1,
+    flexDirection: "row",
+    alignItems: "center",
+    gap: 10,
+  },
+  sessionMenuActionIconSlot: {
+    width: 9,
+    height: 9,
+    alignItems: "center",
+    justifyContent: "center",
+  },
+  sessionMenuActionButtonDisabled: {
+    opacity: 0.55,
+  },
+  sessionMenuActionText: {
+    flex: 1,
+    color: "#D6DAE4",
+    fontSize: 16,
+    fontWeight: "500",
+  },
+  statusLeft: {
+    flexDirection: "row",
+    alignItems: "center",
+    gap: 8,
+  },
+  readyDot: {
+    width: 8,
+    height: 8,
+    borderRadius: 4,
+    backgroundColor: AGENT_SUCCESS_GREEN,
+  },
+  recordingDot: {
+    width: 8,
+    height: 8,
+    borderRadius: 4,
+    backgroundColor: "#FF2E3F",
+  },
+  statusText: {
+    fontSize: 14,
+    fontWeight: "500",
+    color: "#666",
+  },
+  statusActions: {
+    flexDirection: "row",
+    alignItems: "center",
+    gap: 6,
+  },
+  clearButton: {
+    width: 32,
+    height: 32,
+    alignItems: "center",
+    justifyContent: "center",
+    alignSelf: "center",
+  },
+  clearButtonPressed: {
+    opacity: 0.75,
+  },
+  // clearIcon: removed — replaced with SymbolView
+  transcriptionArea: {
+    flex: 1,
+    marginHorizontal: 6,
+    marginTop: 6,
+  },
+  splitCardStack: {
+    flex: 1,
+    gap: 8,
+  },
+  splitCard: {
+    flex: 1,
+    backgroundColor: C.surface,
+    borderRadius: 20,
+    borderWidth: 3,
+    borderColor: C.border,
+    overflow: "hidden",
+    position: "relative",
+  },
+  replyCard: {
+    paddingTop: 16,
+  },
+  permissionCard: {
+    paddingTop: 16,
+  },
+  permissionHeaderRow: {
+    flexDirection: "row",
+    alignItems: "center",
+    gap: 10,
+    marginHorizontal: 20,
+    marginBottom: 12,
+  },
+  permissionHeaderCopy: {
+    flex: 1,
+    gap: 2,
+  },
+  permissionStatusDot: {
+    width: 10,
+    height: 10,
+    borderRadius: 999,
+    backgroundColor: C.orange,
+  },
+  permissionEyebrow: {
+    color: C.orange,
+    fontSize: 11,
+    fontWeight: "800",
+    letterSpacing: 1.1,
+  },
+  permissionStatusText: {
+    color: "#9099AA",
+    fontSize: 13,
+    fontWeight: "600",
+  },
+  permissionScroll: {
+    flex: 1,
+  },
+  permissionContent: {
+    paddingHorizontal: 20,
+    paddingBottom: 20,
+    gap: 14,
+  },
+  permissionTitle: {
+    color: "#F7F8FB",
+    fontSize: 30,
+    fontWeight: "800",
+    lineHeight: 36,
+    letterSpacing: -0.7,
+  },
+  permissionBody: {
+    color: "#B2BDCF",
+    fontSize: 17,
+    fontWeight: "500",
+    lineHeight: 24,
+  },
+  permissionSection: {
+    gap: 6,
+    paddingVertical: 14,
+    borderBottomWidth: 1,
+    borderBottomColor: "#242424",
+  },
+  permissionSectionLast: {
+    borderBottomWidth: 0,
+  },
+  permissionSectionLabel: {
+    color: "#7F8798",
+    fontSize: 11,
+    fontWeight: "700",
+    letterSpacing: 0.9,
+    textTransform: "uppercase",
+  },
+  permissionSectionText: {
+    color: "#E7E7E7",
+    fontSize: 14,
+    fontWeight: "500",
+    lineHeight: 20,
+  },
+  permissionSectionTextMono: {
+    fontFamily: Platform.select({ ios: "Menlo", android: "monospace", web: "monospace" }),
+    fontSize: 12,
+    lineHeight: 18,
+    color: "#D4D7DE",
+    backgroundColor: "#17181B",
+    borderRadius: 8,
+    overflow: "hidden",
+    paddingHorizontal: 6,
+    paddingVertical: 4,
+  },
+  permissionFooter: {
+    gap: 10,
+    paddingHorizontal: 20,
+    paddingBottom: 18,
+    paddingTop: 8,
+    borderTopWidth: 1,
+    borderTopColor: "#21252F",
+  },
+  permissionPrimaryButton: {
+    minHeight: BTN_PRIMARY.minHeight,
+    borderRadius: BTN_PRIMARY.borderRadius,
+    alignItems: "center",
+    justifyContent: "center",
+    backgroundColor: BTN_PRIMARY.backgroundColor,
+    borderWidth: BTN_PRIMARY.borderWidth,
+    borderColor: BTN_PRIMARY.borderColor,
+    paddingHorizontal: 16,
+  },
+  permissionPrimaryButtonText: {
+    color: "#FFFFFF",
+    fontSize: 16,
+    fontWeight: "800",
+    letterSpacing: 0.2,
+  },
+  permissionSecondaryRow: {
+    flexDirection: "row",
+    gap: 10,
+  },
+  permissionSecondaryButton: {
+    flex: 1,
+    minHeight: 48,
+    borderRadius: 14,
+    alignItems: "center",
+    justifyContent: "center",
+    backgroundColor: "#1C1E22",
+    borderWidth: 1,
+    borderColor: "#32353D",
+    paddingHorizontal: 12,
+  },
+  permissionSecondaryButtonText: {
+    color: "#E0E3EA",
+    fontSize: 14,
+    fontWeight: "700",
+  },
+  permissionRejectButton: {
+    flex: 1,
+    minHeight: 48,
+    borderRadius: 14,
+    alignItems: "center",
+    justifyContent: "center",
+    backgroundColor: "#31181C",
+    borderWidth: 1,
+    borderColor: "#5E2B34",
+    paddingHorizontal: 12,
+  },
+  permissionRejectButtonWide: {
+    flex: 1,
+  },
+  permissionRejectButtonText: {
+    color: "#FFCCD2",
+    fontSize: 14,
+    fontWeight: "700",
+  },
+  permissionActionDisabled: {
+    opacity: 0.6,
+  },
+  transcriptionPanel: {
+    flex: 1,
+    position: "relative",
+    overflow: "hidden",
+  },
+  replyCardLabel: {
+    color: "#AAB5CC",
+    fontSize: 15,
+    fontWeight: "600",
+  },
+  readerCard: {
+    paddingTop: 16,
+  },
+  readerOverlayCard: {
+    position: "absolute",
+    top: 0,
+    left: 0,
+    right: 0,
+    zIndex: 2,
+  },
+  readerHeaderRow: {
+    flexDirection: "row",
+    alignItems: "center",
+    justifyContent: "space-between",
+    marginHorizontal: 20,
+    marginBottom: 8,
+  },
+  readerScroll: {
+    flex: 1,
+  },
+  readerContent: {
+    paddingHorizontal: 20,
+    paddingBottom: 18,
+    gap: 14,
+  },
+  readerParagraph: {
+    color: "#E8EDF8",
+    fontSize: 22,
+    fontWeight: "500",
+    lineHeight: 32,
+  },
+  readerHeading: {
+    color: "#F7F9FF",
+    fontWeight: "800",
+    letterSpacing: -0.2,
+  },
+  readerHeading1: {
+    fontSize: 29,
+    lineHeight: 38,
+  },
+  readerHeading2: {
+    fontSize: 25,
+    lineHeight: 34,
+  },
+  readerHeading3: {
+    fontSize: 22,
+    lineHeight: 30,
+  },
+  markdownItalic: {
+    fontStyle: "italic",
+  },
+  markdownBold: {
+    fontWeight: "800",
+  },
+  markdownBoldItalic: {
+    fontStyle: "italic",
+    fontWeight: "800",
+  },
+  readerInlineCode: {
+    color: "#E7EBF5",
+    backgroundColor: "#17181B",
+    borderWidth: 1,
+    borderColor: "#292A2E",
+    borderRadius: 8,
+    paddingHorizontal: 6,
+    paddingVertical: 2,
+    fontSize: 18,
+    lineHeight: 26,
+    fontFamily: Platform.select({ ios: "Menlo", android: "monospace", web: "monospace" }),
+  },
+  readerCodeBlock: {
+    borderRadius: 14,
+    borderWidth: 1,
+    borderColor: "#292A2E",
+    backgroundColor: "#17181B",
+    paddingHorizontal: 14,
+    paddingVertical: 12,
+    gap: 8,
+  },
+  readerCodeLanguage: {
+    color: "#97A5C2",
+    fontSize: 11,
+    fontWeight: "700",
+    letterSpacing: 0.8,
+    textTransform: "uppercase",
+  },
+  readerCodeText: {
+    color: "#DDE6F7",
+    fontSize: 18,
+    lineHeight: 28,
+    fontFamily: Platform.select({ ios: "Menlo", android: "monospace", web: "monospace" }),
+  },
+  agentStateHeaderRow: {
+    flexDirection: "row",
+    alignItems: "center",
+    justifyContent: "space-between",
+    marginHorizontal: 20,
+    marginBottom: 8,
+  },
+  agentStateTitleWrap: {
+    flexDirection: "row",
+    alignItems: "center",
+    gap: 8,
+  },
+  agentStateIconWrap: {
+    width: 16,
+    height: 16,
+    alignItems: "center",
+    justifyContent: "center",
+  },
+  headerStatusIconWrap: {
+    width: 16,
+    height: 16,
+    justifyContent: "center",
+    paddingLeft: 5,
+  },
+  agentStateActions: {
+    flexDirection: "row",
+    alignItems: "center",
+    justifyContent: "flex-end",
+    width: READER_ACTION_RAIL_WIDTH,
+    gap: READER_ACTION_GAP,
+  },
+  agentStateActionsSingle: {
+    width: READER_ACTION_SIZE,
+    gap: 0,
+  },
+  agentStateActionButton: {
+    width: READER_ACTION_SIZE,
+    height: READER_ACTION_SIZE,
+    alignItems: "center",
+    justifyContent: "center",
+  },
+  agentStateReader: {
+    color: "#8FA4CC",
+    fontSize: 13,
+    fontWeight: "700",
+    letterSpacing: 0.2,
+  },
+  readerActionRail: {
+    width: READER_ACTION_RAIL_WIDTH,
+    height: READER_ACTION_SIZE,
+    position: "relative",
+    alignItems: "flex-end",
+    justifyContent: "center",
+  },
+  readerToggleFloatingAction: {
+    position: "absolute",
+    right: 0,
+    width: READER_ACTION_SIZE,
+    height: READER_ACTION_SIZE,
+    alignItems: "center",
+    justifyContent: "center",
+  },
+  readerToggleIconLayer: {
+    position: "absolute",
+    top: 0,
+    right: 0,
+    bottom: 0,
+    left: 0,
+    alignItems: "center",
+    justifyContent: "center",
+  },
+  readerToggleSymbol: {
+    transform: [{ rotate: "-90deg" }],
+  },
+  // agentStateClose: removed — replaced with SymbolView
+  replyScroll: {
+    flex: 1,
+  },
+  replyContent: {
+    paddingHorizontal: 20,
+    paddingBottom: 18,
+    flexGrow: 1,
+    gap: 14,
+  },
+  replyText: {
+    fontSize: 22,
+    fontWeight: "500",
+    lineHeight: 32,
+    color: "#F4F7FF",
+  },
+  replyHeading: {
+    color: "#F7F9FF",
+    fontWeight: "800",
+    letterSpacing: -0.2,
+  },
+  replyHeading1: {
+    fontSize: 27,
+    lineHeight: 36,
+  },
+  replyHeading2: {
+    fontSize: 24,
+    lineHeight: 32,
+  },
+  replyHeading3: {
+    fontSize: 21,
+    lineHeight: 29,
+  },
+  transcriptionScroll: {
+    flex: 1,
+  },
+  transcriptionContent: {
+    padding: 20,
+    paddingTop: 54,
+    paddingBottom: 54,
+    flexGrow: 1,
+  },
+  transcriptionTopActions: {
+    position: "absolute",
+    top: 10,
+    left: 10,
+    right: 10,
+    zIndex: 4,
+    flexDirection: "row",
+    alignItems: "flex-start",
+    justifyContent: "space-between",
+  },
+  promptHistoryLabel: {
+    color: "#555",
+    fontSize: 13,
+    fontWeight: "700",
+    letterSpacing: 0.6,
+    textTransform: "uppercase",
+    marginBottom: 8,
+  },
+  promptHistoryText: {
+    fontSize: 24,
+    fontWeight: "500",
+    lineHeight: 34,
+    color: "#888",
+  },
+  modelErrorBadge: {
+    alignSelf: "flex-start",
+    marginLeft: 14,
+    marginTop: 8,
+    marginBottom: 2,
+    paddingHorizontal: 10,
+    paddingVertical: 5,
+    borderRadius: 999,
+    backgroundColor: "#3A1A1D",
+    borderWidth: 1,
+    borderColor: "#5D292F",
+  },
+  modelErrorText: {
+    color: "#FFB9BF",
+    fontSize: 12,
+    fontWeight: "600",
+    letterSpacing: 0.1,
+  },
+  transcriptionText: {
+    fontSize: 28,
+    fontWeight: "500",
+    lineHeight: 38,
+    color: "#FFFFFF",
+  },
+  placeholderText: {
+    fontSize: 28,
+    fontWeight: "500",
+    color: C.textPlaceholder,
+  },
+  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,
+    right: 20,
+    bottom: 14,
+    height: WAVEFORM_ROWS * WAVEFORM_CELL_SIZE + (WAVEFORM_ROWS - 1) * WAVEFORM_CELL_GAP,
+    pointerEvents: "none",
+  },
+  waveformGridRow: {
+    flexDirection: "row",
+    gap: WAVEFORM_CELL_GAP,
+    marginBottom: WAVEFORM_CELL_GAP,
+  },
+  waveformBox: {
+    width: WAVEFORM_CELL_SIZE,
+    height: WAVEFORM_CELL_SIZE,
+    borderRadius: 1,
+    backgroundColor: "#78839A",
+    borderWidth: 1,
+    borderColor: "#818DA6",
+  },
+  controlsRow: {
+    paddingHorizontal: 6,
+    paddingBottom: 6,
+    paddingTop: 6,
+    flexDirection: "row",
+    alignItems: "center",
+  },
+  recordPressable: {
+    flex: 1,
+  },
+  recordButton: {
+    alignItems: "center",
+    justifyContent: "center",
+    backgroundColor: C.redBg,
+    height: CONTROL_HEIGHT,
+    borderRadius: 20,
+    width: "100%",
+    overflow: "hidden",
+  },
+  recordBusyCenter: {
+    alignItems: "center",
+    justifyContent: "center",
+    width: "100%",
+    height: "100%",
+  },
+  loadFill: {
+    position: "absolute",
+    left: 0,
+    top: 0,
+    bottom: 0,
+    backgroundColor: "#FF5B47",
+  },
+  loadFillPending: {
+    backgroundColor: "#66423C",
+  },
+  loadOverlay: {
+    ...StyleSheet.absoluteFillObject,
+    alignItems: "center",
+    justifyContent: "center",
+    paddingHorizontal: 18,
+  },
+  loadText: {
+    color: "#FFF6F4",
+    fontSize: 14,
+    fontWeight: "700",
+    letterSpacing: 0.2,
+  },
+  settingsRoot: {
+    flex: 1,
+    backgroundColor: C.bg,
+    paddingHorizontal: 16,
+    paddingTop: 12,
+  },
+  settingsTop: {
+    flexDirection: "row",
+    alignItems: "center",
+    justifyContent: "space-between",
+    gap: 12,
+    marginBottom: 4,
+  },
+  settingsTitleBlock: {
+    flex: 1,
+    gap: 4,
+  },
+  settingsTitle: {
+    color: C.textPrimary,
+    fontSize: 20,
+    fontWeight: "700",
+  },
+  settingsSubtitle: {
+    color: "#999999",
+    fontSize: 13,
+    fontWeight: "500",
+  },
+  settingsClose: {
+    color: "#C5C5C5",
+    fontSize: 15,
+    fontWeight: "700",
+  },
+  settingsScroll: {
+    flex: 1,
+  },
+  settingsContent: {
+    gap: 24,
+    paddingBottom: 24,
+  },
+  settingsSection: {
+    gap: 0,
+  },
+  settingsSectionLabel: {
+    color: "#7D7D7D",
+    fontSize: 11,
+    fontWeight: "700",
+    letterSpacing: 1.05,
+    marginBottom: 6,
+  },
+  settingsTextRow: {
+    minHeight: 46,
+    flexDirection: "row",
+    alignItems: "center",
+    justifyContent: "space-between",
+    gap: 12,
+    borderBottomWidth: 1,
+    borderBottomColor: C.borderMuted,
+    paddingVertical: 10,
+  },
+  settingsToggleRow: {
+    alignItems: "flex-start",
+  },
+  settingsMutedText: {
+    color: "#868686",
+    fontSize: 12,
+    fontWeight: "500",
+  },
+  settingsOptionCopy: {
+    flex: 1,
+    minWidth: 0,
+    gap: 2,
+  },
+  settingsTextRowTitle: {
+    color: "#ECECEC",
+    fontSize: 14,
+    fontWeight: "600",
+  },
+  settingsTextRowMeta: {
+    color: "#8D8D8D",
+    fontSize: 12,
+    fontWeight: "500",
+  },
+  settingsTextRowValue: {
+    color: "#BDBDBD",
+    fontSize: 13,
+    fontWeight: "600",
+    maxWidth: "55%",
+    textAlign: "right",
+  },
+  settingsTextRowAction: {
+    color: "#B8B8B8",
+    fontSize: 12,
+    fontWeight: "700",
+    letterSpacing: 0.2,
+  },
+  settingsTextRowActionActive: {
+    color: "#FFD8D2",
+  },
+  settingsModeToggle: {
+    flexDirection: "row",
+    backgroundColor: "#17181B",
+    borderWidth: 1,
+    borderColor: "#292A2E",
+    borderRadius: 14,
+    padding: 4,
+    gap: 4,
+    alignSelf: "stretch",
+  },
+  settingsModeToggleOption: {
+    flex: 1,
+    minHeight: 40,
+    borderRadius: 10,
+    alignItems: "center",
+    justifyContent: "center",
+    paddingHorizontal: 12,
+  },
+  settingsModeToggleOptionActive: {
+    backgroundColor: "#3F201B",
+  },
+  settingsModeToggleOptionPressed: {
+    opacity: 0.82,
+  },
+  settingsModeToggleText: {
+    color: "#9A9A9A",
+    fontSize: 13,
+    fontWeight: "700",
+  },
+  settingsModeToggleTextActive: {
+    color: "#FFF0EC",
+  },
+  settingsInlineRow: {
+    flexDirection: "row",
+    alignItems: "center",
+    minHeight: 52,
+    borderBottomWidth: 1,
+    borderBottomColor: C.borderMuted,
+  },
+  settingsInlineLabelPressable: {
+    flex: 1,
+    minWidth: 0,
+    paddingVertical: 10,
+    paddingRight: 12,
+    gap: 2,
+  },
+  settingsInlinePressableDisabled: {
+    opacity: 0.55,
+  },
+  settingsInlineName: {
+    color: "#E7E7E7",
+    fontSize: 14,
+    fontWeight: "600",
+  },
+  settingsInlineMeta: {
+    color: "#8F8F8F",
+    fontSize: 12,
+    fontWeight: "500",
+  },
+  settingsInlineTextActionPressable: {
+    marginLeft: 8,
+    paddingVertical: 8,
+    paddingHorizontal: 2,
+    alignItems: "flex-end",
+    justifyContent: "center",
+  },
+  settingsInlineTextAction: {
+    color: "#D0D0D0",
+    fontSize: 12,
+    fontWeight: "700",
+    minWidth: 72,
+    textAlign: "right",
+  },
+  settingsInlineTextActionInstalled: {
+    color: "#E2B1A8",
+  },
+  settingsInlineTextActionDownloading: {
+    color: "#FFD7CE",
+  },
+  scanRoot: {
+    flex: 1,
+    backgroundColor: "#101014",
+    paddingHorizontal: 16,
+    paddingTop: 12,
+    gap: 12,
+  },
+  scanTop: {
+    flexDirection: "row",
+    alignItems: "center",
+    justifyContent: "space-between",
+  },
+  scanTitle: {
+    color: "#E8EAF0",
+    fontSize: 18,
+    fontWeight: "700",
+  },
+  scanClose: {
+    color: "#8FA4CC",
+    fontSize: 15,
+    fontWeight: "600",
+  },
+  scanCam: {
+    flex: 1,
+    borderRadius: 18,
+    overflow: "hidden",
+  },
+  scanEmpty: {
+    flex: 1,
+    alignItems: "center",
+    justifyContent: "center",
+    paddingHorizontal: 24,
+  },
+  scanHint: {
+    color: "#A6ABBA",
+    fontSize: 14,
+    textAlign: "center",
+  },
+  pairSelectRoot: {
+    flex: 1,
+    backgroundColor: C.bg,
+    paddingHorizontal: 16,
+    paddingTop: 12,
+  },
+  pairSelectTop: {
+    flexDirection: "row",
+    alignItems: "center",
+    justifyContent: "space-between",
+    gap: 12,
+    marginBottom: 8,
+  },
+  pairSelectTitleBlock: {
+    flex: 1,
+    gap: 4,
+  },
+  pairSelectTitle: {
+    color: "#E8EAF0",
+    fontSize: 18,
+    fontWeight: "700",
+  },
+  pairSelectSubtitle: {
+    color: "#A3A3A3",
+    fontSize: 13,
+    fontWeight: "500",
+  },
+  pairSelectClose: {
+    color: "#C5C5C5",
+    fontSize: 15,
+    fontWeight: "600",
+  },
+  pairSelectList: {
+    flex: 1,
+  },
+  pairSelectListContent: {
+    paddingBottom: 12,
+  },
+  pairSelectRow: {
+    minHeight: 74,
+    borderBottomWidth: 1,
+    borderBottomColor: "#242424",
+    paddingVertical: 10,
+    paddingHorizontal: 10,
+  },
+  pairSelectRowSelected: {
+    backgroundColor: "#171717",
+  },
+  pairSelectRowLast: {
+    borderBottomColor: "#242424",
+  },
+  pairSelectRowMain: {
+    width: "100%",
+    flexDirection: "row",
+    alignItems: "flex-start",
+    justifyContent: "space-between",
+    gap: 12,
+  },
+  pairSelectLeftCol: {
+    flex: 1,
+    minWidth: 0,
+    flexDirection: "row",
+    alignItems: "flex-start",
+    gap: 10,
+  },
+  pairSelectDot: {
+    width: 8,
+    height: 8,
+    borderRadius: 4,
+    marginTop: 6,
+  },
+  pairSelectDotChecking: {
+    backgroundColor: "#6F778A",
+  },
+  pairSelectDotOnline: {
+    backgroundColor: AGENT_SUCCESS_GREEN,
+  },
+  pairSelectDotOffline: {
+    backgroundColor: "#E35B5B",
+  },
+  pairSelectRowCopy: {
+    flex: 1,
+    minWidth: 0,
+    gap: 2,
+  },
+  pairSelectRowTitleLine: {
+    flexDirection: "row",
+    alignItems: "center",
+    gap: 6,
+  },
+  pairSelectHostLabel: {
+    color: "#ECECEC",
+    fontSize: 15,
+    fontWeight: "600",
+    flexShrink: 1,
+  },
+  pairSelectRecommended: {
+    color: "#D5A79F",
+    fontSize: 10,
+    fontWeight: "700",
+    letterSpacing: 0.4,
+    textTransform: "uppercase",
+  },
+  pairSelectHostMeta: {
+    color: "#9F9F9F",
+    fontSize: 12,
+    fontWeight: "500",
+  },
+  pairSelectProbeMeta: {
+    color: "#B8B8B8",
+    fontSize: 12,
+    fontWeight: "500",
+  },
+  pairSelectHostURL: {
+    color: "#7E7E7E",
+    fontSize: 11,
+    fontWeight: "500",
+  },
+  pairSelectLatency: {
+    color: "#D4D4D4",
+    fontSize: 13,
+    fontWeight: "700",
+    minWidth: 76,
+    textAlign: "right",
+  },
+  pairSelectRightCol: {
+    minWidth: 76,
+    flexShrink: 0,
+    alignItems: "flex-end",
+    gap: 8,
+    marginLeft: 10,
+    paddingTop: 2,
+  },
+  pairSelectFooter: {
+    borderTopWidth: 1,
+    borderTopColor: "#242424",
+    paddingTop: 12,
+    paddingBottom: 10,
+    gap: 8,
+  },
+  pairSelectPrimaryButton: {
+    minHeight: BTN_PRIMARY.minHeight,
+    borderRadius: BTN_PRIMARY.borderRadius,
+    alignItems: "center",
+    justifyContent: "center",
+    backgroundColor: BTN_PRIMARY.backgroundColor,
+    borderWidth: BTN_PRIMARY.borderWidth,
+    borderColor: BTN_PRIMARY.borderColor,
+  },
+  pairSelectPrimaryButtonDisabled: {
+    opacity: 0.6,
+  },
+  pairSelectPrimaryButtonText: {
+    color: "#FFFFFF",
+    fontSize: 15,
+    fontWeight: "700",
+  },
+  pairSelectSecondaryAction: {
+    color: "#A8A8A8",
+    fontSize: 14,
+    fontWeight: "600",
+    textAlign: "center",
+    paddingVertical: 8,
+  },
+  sendSlot: {
+    height: CONTROL_HEIGHT,
+    overflow: "hidden",
+  },
+  sendButton: {
+    width: "100%",
+    height: "100%",
+    borderRadius: 20,
+    alignItems: "center",
+    justifyContent: "center",
+    backgroundColor: BTN_PRIMARY.backgroundColor,
+    borderWidth: BTN_PRIMARY.borderWidth,
+    borderColor: BTN_PRIMARY.borderColor,
+  },
+  sendButtonDisabled: {
+    opacity: 0.7,
+  },
+  // sendIcon: removed — replaced with SymbolView
+  recordBorder: {
+    position: "absolute",
+    top: 0,
+    left: 0,
+    right: 0,
+    bottom: 0,
+    borderRadius: 20,
+  },
+  recordButtonDisabled: {
+    opacity: 0.4,
+  },
+  recordDot: {
+    width: 38,
+    height: 38,
+    backgroundColor: "#FF2E3F",
+  },
+})

+ 6 - 0
packages/mobile-voice/src/components/animated-icon.module.css

@@ -0,0 +1,6 @@
+.expoLogoBackground {
+  background-image: linear-gradient(180deg, #3c9ffe, #0274df);
+  border-radius: 40px;
+  width: 128px;
+  height: 128px;
+}

+ 132 - 0
packages/mobile-voice/src/components/animated-icon.tsx

@@ -0,0 +1,132 @@
+import { Image } from 'expo-image';
+import { useState } from 'react';
+import { Dimensions, StyleSheet, View } from 'react-native';
+import Animated, { Easing, Keyframe } from 'react-native-reanimated';
+import { scheduleOnRN } from 'react-native-worklets';
+
+const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90;
+const DURATION = 600;
+
+export function AnimatedSplashOverlay() {
+  const [visible, setVisible] = useState(true);
+
+  if (!visible) return null;
+
+  const splashKeyframe = new Keyframe({
+    0: {
+      transform: [{ scale: INITIAL_SCALE_FACTOR }],
+      opacity: 1,
+    },
+    20: {
+      opacity: 1,
+    },
+    70: {
+      opacity: 0,
+      easing: Easing.elastic(0.7),
+    },
+    100: {
+      opacity: 0,
+      transform: [{ scale: 1 }],
+      easing: Easing.elastic(0.7),
+    },
+  });
+
+  return (
+    <Animated.View
+      entering={splashKeyframe.duration(DURATION).withCallback((finished) => {
+        'worklet';
+        if (finished) {
+          scheduleOnRN(setVisible, false);
+        }
+      })}
+      style={styles.backgroundSolidColor}
+    />
+  );
+}
+
+const keyframe = new Keyframe({
+  0: {
+    transform: [{ scale: INITIAL_SCALE_FACTOR }],
+  },
+  100: {
+    transform: [{ scale: 1 }],
+    easing: Easing.elastic(0.7),
+  },
+});
+
+const logoKeyframe = new Keyframe({
+  0: {
+    transform: [{ scale: 1.3 }],
+    opacity: 0,
+  },
+  40: {
+    transform: [{ scale: 1.3 }],
+    opacity: 0,
+    easing: Easing.elastic(0.7),
+  },
+  100: {
+    opacity: 1,
+    transform: [{ scale: 1 }],
+    easing: Easing.elastic(0.7),
+  },
+});
+
+const glowKeyframe = new Keyframe({
+  0: {
+    transform: [{ rotateZ: '0deg' }],
+  },
+  100: {
+    transform: [{ rotateZ: '7200deg' }],
+  },
+});
+
+export function AnimatedIcon() {
+  return (
+    <View style={styles.iconContainer}>
+      <Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
+        <Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
+      </Animated.View>
+
+      <Animated.View entering={keyframe.duration(DURATION)} style={styles.background} />
+      <Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
+        <Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
+      </Animated.View>
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  imageContainer: {
+    justifyContent: 'center',
+    alignItems: 'center',
+  },
+  glow: {
+    width: 201,
+    height: 201,
+    position: 'absolute',
+  },
+  iconContainer: {
+    justifyContent: 'center',
+    alignItems: 'center',
+    width: 128,
+    height: 128,
+    zIndex: 100,
+  },
+  image: {
+    position: 'absolute',
+    width: 76,
+    height: 71,
+  },
+  background: {
+    borderRadius: 40,
+    experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`,
+    width: 128,
+    height: 128,
+    position: 'absolute',
+  },
+  backgroundSolidColor: {
+    ...StyleSheet.absoluteFillObject,
+    backgroundColor: '#208AEF',
+    zIndex: 1000,
+  },
+});

+ 108 - 0
packages/mobile-voice/src/components/animated-icon.web.tsx

@@ -0,0 +1,108 @@
+import { Image } from 'expo-image';
+import { StyleSheet, View } from 'react-native';
+import Animated, { Keyframe, Easing } from 'react-native-reanimated';
+
+import classes from './animated-icon.module.css';
+const DURATION = 300;
+
+export function AnimatedSplashOverlay() {
+  return null;
+}
+
+const keyframe = new Keyframe({
+  0: {
+    transform: [{ scale: 0 }],
+  },
+  60: {
+    transform: [{ scale: 1.2 }],
+    easing: Easing.elastic(1.2),
+  },
+  100: {
+    transform: [{ scale: 1 }],
+    easing: Easing.elastic(1.2),
+  },
+});
+
+const logoKeyframe = new Keyframe({
+  0: {
+    opacity: 0,
+  },
+  60: {
+    transform: [{ scale: 1.2 }],
+    opacity: 0,
+    easing: Easing.elastic(1.2),
+  },
+  100: {
+    transform: [{ scale: 1 }],
+    opacity: 1,
+    easing: Easing.elastic(1.2),
+  },
+});
+
+const glowKeyframe = new Keyframe({
+  0: {
+    transform: [{ rotateZ: '-180deg' }, { scale: 0.8 }],
+    opacity: 0,
+  },
+  [DURATION / 1000]: {
+    transform: [{ rotateZ: '0deg' }, { scale: 1 }],
+    opacity: 1,
+    easing: Easing.elastic(0.7),
+  },
+  100: {
+    transform: [{ rotateZ: '7200deg' }],
+  },
+});
+
+export function AnimatedIcon() {
+  return (
+    <View style={styles.iconContainer}>
+      <Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
+        <Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
+      </Animated.View>
+
+      <Animated.View style={styles.background} entering={keyframe.duration(DURATION)}>
+        <div className={classes.expoLogoBackground} />
+      </Animated.View>
+
+      <Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
+        <Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
+      </Animated.View>
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    alignItems: 'center',
+    width: '100%',
+    zIndex: 1000,
+    position: 'absolute',
+    top: 128 / 2 + 138,
+  },
+  imageContainer: {
+    justifyContent: 'center',
+    alignItems: 'center',
+  },
+  glow: {
+    width: 201,
+    height: 201,
+    position: 'absolute',
+  },
+  iconContainer: {
+    justifyContent: 'center',
+    alignItems: 'center',
+    width: 128,
+    height: 128,
+  },
+  image: {
+    position: 'absolute',
+    width: 76,
+    height: 71,
+  },
+  background: {
+    width: 128,
+    height: 128,
+    position: 'absolute',
+  },
+});

+ 7 - 0
packages/mobile-voice/src/components/app-tabs.tsx

@@ -0,0 +1,7 @@
+// Not used - single page app. Kept to avoid breaking template imports.
+import { Slot } from 'expo-router';
+import React from 'react';
+
+export default function AppTabs() {
+  return <Slot />;
+}

+ 7 - 0
packages/mobile-voice/src/components/app-tabs.web.tsx

@@ -0,0 +1,7 @@
+// Not used - single page app. Kept to avoid breaking template imports.
+import { Slot } from 'expo-router';
+import React from 'react';
+
+export default function AppTabs() {
+  return <Slot />;
+}

+ 25 - 0
packages/mobile-voice/src/components/external-link.tsx

@@ -0,0 +1,25 @@
+import { Href, Link } from 'expo-router';
+import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
+import { type ComponentProps } from 'react';
+
+type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
+
+export function ExternalLink({ href, ...rest }: Props) {
+  return (
+    <Link
+      target="_blank"
+      {...rest}
+      href={href}
+      onPress={async (event) => {
+        if (process.env.EXPO_OS !== 'web') {
+          // Prevent the default behavior of linking to the default browser on native.
+          event.preventDefault();
+          // Open the link in an in-app browser.
+          await openBrowserAsync(href, {
+            presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
+          });
+        }
+      }}
+    />
+  );
+}

+ 35 - 0
packages/mobile-voice/src/components/hint-row.tsx

@@ -0,0 +1,35 @@
+import React, { type ReactNode } from 'react';
+import { View, StyleSheet } from 'react-native';
+
+import { ThemedText } from './themed-text';
+import { ThemedView } from './themed-view';
+
+import { Spacing } from '@/constants/theme';
+
+type HintRowProps = {
+  title?: string;
+  hint?: ReactNode;
+};
+
+export function HintRow({ title = 'Try editing', hint = 'app/index.tsx' }: HintRowProps) {
+  return (
+    <View style={styles.stepRow}>
+      <ThemedText type="small">{title}</ThemedText>
+      <ThemedView type="backgroundSelected" style={styles.codeSnippet}>
+        <ThemedText themeColor="textSecondary">{hint}</ThemedText>
+      </ThemedView>
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  stepRow: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+  },
+  codeSnippet: {
+    borderRadius: Spacing.two,
+    paddingVertical: Spacing.half,
+    paddingHorizontal: Spacing.two,
+  },
+});

+ 73 - 0
packages/mobile-voice/src/components/themed-text.tsx

@@ -0,0 +1,73 @@
+import { Platform, StyleSheet, Text, type TextProps } from 'react-native';
+
+import { Fonts, ThemeColor } from '@/constants/theme';
+import { useTheme } from '@/hooks/use-theme';
+
+export type ThemedTextProps = TextProps & {
+  type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code';
+  themeColor?: ThemeColor;
+};
+
+export function ThemedText({ style, type = 'default', themeColor, ...rest }: ThemedTextProps) {
+  const theme = useTheme();
+
+  return (
+    <Text
+      style={[
+        { color: theme[themeColor ?? 'text'] },
+        type === 'default' && styles.default,
+        type === 'title' && styles.title,
+        type === 'small' && styles.small,
+        type === 'smallBold' && styles.smallBold,
+        type === 'subtitle' && styles.subtitle,
+        type === 'link' && styles.link,
+        type === 'linkPrimary' && styles.linkPrimary,
+        type === 'code' && styles.code,
+        style,
+      ]}
+      {...rest}
+    />
+  );
+}
+
+const styles = StyleSheet.create({
+  small: {
+    fontSize: 14,
+    lineHeight: 20,
+    fontWeight: 500,
+  },
+  smallBold: {
+    fontSize: 14,
+    lineHeight: 20,
+    fontWeight: 700,
+  },
+  default: {
+    fontSize: 16,
+    lineHeight: 24,
+    fontWeight: 500,
+  },
+  title: {
+    fontSize: 48,
+    fontWeight: 600,
+    lineHeight: 52,
+  },
+  subtitle: {
+    fontSize: 32,
+    lineHeight: 44,
+    fontWeight: 600,
+  },
+  link: {
+    lineHeight: 30,
+    fontSize: 14,
+  },
+  linkPrimary: {
+    lineHeight: 30,
+    fontSize: 14,
+    color: '#3c87f7',
+  },
+  code: {
+    fontFamily: Fonts.mono,
+    fontWeight: Platform.select({ android: 700 }) ?? 500,
+    fontSize: 12,
+  },
+});

+ 16 - 0
packages/mobile-voice/src/components/themed-view.tsx

@@ -0,0 +1,16 @@
+import { View, type ViewProps } from 'react-native';
+
+import { ThemeColor } from '@/constants/theme';
+import { useTheme } from '@/hooks/use-theme';
+
+export type ThemedViewProps = ViewProps & {
+  lightColor?: string;
+  darkColor?: string;
+  type?: ThemeColor;
+};
+
+export function ThemedView({ style, lightColor, darkColor, type, ...otherProps }: ThemedViewProps) {
+  const theme = useTheme();
+
+  return <View style={[{ backgroundColor: theme[type ?? 'background'] }, style]} {...otherProps} />;
+}

+ 65 - 0
packages/mobile-voice/src/components/ui/collapsible.tsx

@@ -0,0 +1,65 @@
+import { SymbolView } from 'expo-symbols';
+import { PropsWithChildren, useState } from 'react';
+import { Pressable, StyleSheet } from 'react-native';
+import Animated, { FadeIn } from 'react-native-reanimated';
+
+import { ThemedText } from '@/components/themed-text';
+import { ThemedView } from '@/components/themed-view';
+import { Spacing } from '@/constants/theme';
+import { useTheme } from '@/hooks/use-theme';
+
+export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
+  const [isOpen, setIsOpen] = useState(false);
+  const theme = useTheme();
+
+  return (
+    <ThemedView>
+      <Pressable
+        style={({ pressed }) => [styles.heading, pressed && styles.pressedHeading]}
+        onPress={() => setIsOpen((value) => !value)}>
+        <ThemedView type="backgroundElement" style={styles.button}>
+          <SymbolView
+            name={{ ios: 'chevron.right', android: 'chevron_right', web: 'chevron_right' }}
+            size={14}
+            weight="bold"
+            tintColor={theme.text}
+            style={{ transform: [{ rotate: isOpen ? '-90deg' : '90deg' }] }}
+          />
+        </ThemedView>
+
+        <ThemedText type="small">{title}</ThemedText>
+      </Pressable>
+      {isOpen && (
+        <Animated.View entering={FadeIn.duration(200)}>
+          <ThemedView type="backgroundElement" style={styles.content}>
+            {children}
+          </ThemedView>
+        </Animated.View>
+      )}
+    </ThemedView>
+  );
+}
+
+const styles = StyleSheet.create({
+  heading: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: Spacing.two,
+  },
+  pressedHeading: {
+    opacity: 0.7,
+  },
+  button: {
+    width: Spacing.four,
+    height: Spacing.four,
+    borderRadius: 12,
+    justifyContent: 'center',
+    alignItems: 'center',
+  },
+  content: {
+    marginTop: Spacing.three,
+    borderRadius: Spacing.three,
+    marginLeft: Spacing.four,
+    padding: Spacing.four,
+  },
+});

+ 44 - 0
packages/mobile-voice/src/components/web-badge.tsx

@@ -0,0 +1,44 @@
+import { version } from 'expo/package.json';
+import { Image } from 'expo-image';
+import React from 'react';
+import { useColorScheme, StyleSheet } from 'react-native';
+
+import { ThemedText } from './themed-text';
+import { ThemedView } from './themed-view';
+
+import { Spacing } from '@/constants/theme';
+
+export function WebBadge() {
+  const scheme = useColorScheme();
+
+  return (
+    <ThemedView style={styles.container}>
+      <ThemedText type="code" themeColor="textSecondary" style={styles.versionText}>
+        v{version}
+      </ThemedText>
+      <Image
+        source={
+          scheme === 'dark'
+            ? require('@/assets/images/expo-badge-white.png')
+            : require('@/assets/images/expo-badge.png')
+        }
+        style={styles.badgeImage}
+      />
+    </ThemedView>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    padding: Spacing.five,
+    alignItems: 'center',
+    gap: Spacing.two,
+  },
+  versionText: {
+    textAlign: 'center',
+  },
+  badgeImage: {
+    width: 123,
+    aspectRatio: 123 / 24,
+  },
+});

+ 65 - 0
packages/mobile-voice/src/constants/theme.ts

@@ -0,0 +1,65 @@
+/**
+ * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
+ * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
+ */
+
+import '@/global.css';
+
+import { Platform } from 'react-native';
+
+export const Colors = {
+  light: {
+    text: '#000000',
+    background: '#ffffff',
+    backgroundElement: '#F0F0F3',
+    backgroundSelected: '#E0E1E6',
+    textSecondary: '#60646C',
+  },
+  dark: {
+    text: '#ffffff',
+    background: '#000000',
+    backgroundElement: '#212225',
+    backgroundSelected: '#2E3135',
+    textSecondary: '#B0B4BA',
+  },
+} as const;
+
+export type ThemeColor = keyof typeof Colors.light & keyof typeof Colors.dark;
+
+export const Fonts = Platform.select({
+  ios: {
+    /** iOS `UIFontDescriptorSystemDesignDefault` */
+    sans: 'system-ui',
+    /** iOS `UIFontDescriptorSystemDesignSerif` */
+    serif: 'ui-serif',
+    /** iOS `UIFontDescriptorSystemDesignRounded` */
+    rounded: 'ui-rounded',
+    /** iOS `UIFontDescriptorSystemDesignMonospaced` */
+    mono: 'ui-monospace',
+  },
+  default: {
+    sans: 'normal',
+    serif: 'serif',
+    rounded: 'normal',
+    mono: 'monospace',
+  },
+  web: {
+    sans: 'var(--font-display)',
+    serif: 'var(--font-serif)',
+    rounded: 'var(--font-rounded)',
+    mono: 'var(--font-mono)',
+  },
+});
+
+export const Spacing = {
+  half: 2,
+  one: 4,
+  two: 8,
+  three: 16,
+  four: 24,
+  five: 32,
+  six: 64,
+} as const;
+
+export const BottomTabInset = Platform.select({ ios: 50, android: 80 }) ?? 0;
+export const MaxContentWidth = 800;

+ 9 - 0
packages/mobile-voice/src/global.css

@@ -0,0 +1,9 @@
+:root {
+  --font-display:
+    Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji,
+    Segoe UI Symbol, Noto Color Emoji;
+  --font-mono:
+    ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
+  --font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif;
+  --font-serif: Georgia, 'Times New Roman', serif;
+}

+ 1 - 0
packages/mobile-voice/src/hooks/use-color-scheme.ts

@@ -0,0 +1 @@
+export { useColorScheme } from 'react-native';

+ 29 - 0
packages/mobile-voice/src/hooks/use-color-scheme.web.ts

@@ -0,0 +1,29 @@
+import { useSyncExternalStore } from "react"
+import { useColorScheme as useRNColorScheme } from "react-native"
+
+function subscribe() {
+  return () => {}
+}
+
+function getSnapshot() {
+  return true
+}
+
+function getServerSnapshot() {
+  return false
+}
+
+/**
+ * To support static rendering, this value needs to be re-calculated on the client side for web
+ */
+export function useColorScheme() {
+  const hasHydrated = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
+
+  const colorScheme = useRNColorScheme()
+
+  if (hasHydrated) {
+    return colorScheme
+  }
+
+  return "light"
+}

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

@@ -0,0 +1,278 @@
+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
+
+    try {
+      // Expo dev builds were failing to resolve this native module through async import().
+      const mod = require("react-native-zeroconf") 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],
+  )
+}

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

@@ -0,0 +1,1051 @@
+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 {
+  parsePendingPermissionRequest,
+  parsePendingPermissionRequests,
+  type PendingPermissionRequest,
+} from "@/lib/pending-permissions"
+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
+}
+
+export type PermissionDecision = "once" | "always" | "reject"
+
+export type PromptHistoryEntry = {
+  promptText: string
+  userMessageID: string
+}
+
+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 [latestPromptText, setLatestPromptText] = useState("")
+  const [promptHistory, setPromptHistory] = useState<PromptHistoryEntry[]>([])
+  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)
+
+  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) => {
+      pendingNotificationEventsRef.current.push({ payload, source })
+    },
+  )
+  const previousPushTokenRef = useRef<string | null>(null)
+  const previousAppStateRef = useRef<AppStateStatus>(AppState.currentState)
+  const latestAssistantRequestRef = useRef(0)
+  const latestPermissionRequestRef = useRef(0)
+
+  const upsertPendingPermission = useCallback(
+    (request: PendingPermissionRequest) => {
+      setPendingPermissions((current) => {
+        const next = current.filter((item) => item.id !== request.id)
+        return [request, ...next]
+      })
+      closeDropdown()
+      setAgentStateDismissed(false)
+    },
+    [closeDropdown, setAgentStateDismissed],
+  )
+
+  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
+    }
+    if (foregroundPollIntervalRef.current) {
+      clearInterval(foregroundPollIntervalRef.current)
+      foregroundPollIntervalRef.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 latest = findLatestAssistantCompletion(payload)
+        const promptText = findLatestUserPrompt(payload)
+        const history = buildPromptHistory(payload)
+
+        if (latestAssistantRequestRef.current !== requestID) return
+        if (activeSessionIdRef.current !== sessionID) return
+        setLatestAssistantResponse(latest.text)
+        setLatestPromptText(promptText)
+        setPromptHistory(history)
+        setLatestAssistantContext(latest.context)
+        if (latest.text) {
+          setAgentStateDismissed(false)
+        }
+      } catch {
+        if (latestAssistantRequestRef.current !== requestID) return
+        if (activeSessionIdRef.current !== sessionID) return
+        setLatestAssistantResponse("")
+        setLatestPromptText("")
+        setPromptHistory([])
+        setLatestAssistantContext(null)
+      }
+    },
+    [activeSessionIdRef, setAgentStateDismissed],
+  )
+
+  const loadPendingPermissions = useCallback(
+    async (baseURL: string, sessionID: string) => {
+      const requestID = latestPermissionRequestRef.current + 1
+      latestPermissionRequestRef.current = requestID
+
+      const base = baseURL.replace(/\/+$/, "")
+
+      try {
+        const response = await fetch(`${base}/permission`)
+        if (!response.ok) {
+          throw new Error(`Permission list failed (${response.status})`)
+        }
+
+        const payload = (await response.json()) as unknown
+        const requests = parsePendingPermissionRequests(payload).filter((item) => item.sessionID === sessionID)
+
+        if (latestPermissionRequestRef.current !== requestID) return
+        if (activeSessionIdRef.current !== sessionID) return
+
+        setPendingPermissions(requests)
+        if (requests.length > 0) {
+          closeDropdown()
+          setAgentStateDismissed(false)
+        }
+      } catch {
+        if (latestPermissionRequestRef.current !== requestID) return
+        if (activeSessionIdRef.current !== sessionID) return
+      }
+    },
+    [activeSessionIdRef, closeDropdown, 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(() => {})
+        void loadPendingPermissions(job.opencodeBaseURL, job.sessionID)
+        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, loadPendingPermissions, stopForegroundMonitor],
+  )
+
+  const startForegroundMonitor = useCallback(
+    (job: MonitorJob) => {
+      stopForegroundMonitor()
+
+      const abortController = new AbortController()
+      foregroundMonitorAbortRef.current = abortController
+
+      const base = job.opencodeBaseURL.replace(/\/+$/, "")
+
+      // 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 (!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)
+            }
+
+            // 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
+          }
+
+          // 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)
+    },
+    [activeServerIdRef, activeSessionIdRef, handleMonitorEvent, stopForegroundMonitor, upsertPendingPermission],
+  )
+
+  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("")
+    setLatestPromptText("")
+    setPromptHistory([])
+    setLatestAssistantContext(null)
+    setPendingPermissions([])
+    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)
+    void loadPendingPermissions(server.url, activeSessionId)
+  }, [
+    activeServerId,
+    activeSessionId,
+    loadLatestAssistantResponse,
+    loadPendingPermissions,
+    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)
+      await loadPendingPermissions(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("")
+        }
+        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,
+      loadPendingPermissions,
+      refreshServerStatusAndSessions,
+      serversRef,
+      startForegroundMonitor,
+      stopForegroundMonitor,
+    ],
+  )
+
+  useEffect(() => {
+    syncSessionStateRef.current = syncSessionState
+  }, [syncSessionState])
+
+  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 respondToPermission = useCallback(
+    async (input: { serverID: string; sessionID: string; requestID: string; reply: PermissionDecision }) => {
+      const server = serversRef.current.find((item) => item.id === input.serverID)
+      if (!server) {
+        throw new Error("Server unavailable")
+      }
+
+      const base = server.url.replace(/\/+$/, "")
+      setReplyingPermissionID(input.requestID)
+      setMonitorStatus(input.reply === "reject" ? "Rejecting request…" : "Sending approval…")
+      let removed: PendingPermissionRequest | undefined
+      setPendingPermissions((current) => {
+        removed = current.find((item) => item.id === input.requestID)
+        return current.filter((item) => item.id !== input.requestID)
+      })
+
+      try {
+        const response = await fetch(`${base}/permission/${input.requestID}/reply`, {
+          method: "POST",
+          headers: {
+            "Content-Type": "application/json",
+          },
+          body: JSON.stringify({ reply: input.reply }),
+        })
+
+        if (!response.ok) {
+          throw new Error(`Permission reply failed (${response.status})`)
+        }
+
+        await syncSessionState({
+          serverID: input.serverID,
+          sessionID: input.sessionID,
+        })
+      } catch (error) {
+        if (removed) {
+          setPendingPermissions((current) => {
+            const restored = removed
+            if (!restored) {
+              return current
+            }
+            if (current.some((item) => item.id === restored.id)) {
+              return current
+            }
+            return [restored, ...current]
+          })
+        }
+        throw error
+      } finally {
+        setReplyingPermissionID((current) => (current === input.requestID ? null : current))
+      }
+    },
+    [serversRef, syncSessionState],
+  )
+
+  const activePermissionRequest = pendingPermissions[0] ?? null
+
+  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,
+    latestPromptText,
+    setLatestPromptText,
+    promptHistory,
+    setPromptHistory,
+    latestAssistantResponse,
+    latestAssistantContext,
+    activePermissionRequest,
+    pendingPermissionCount: pendingPermissions.length,
+    respondingPermissionID: replyingPermissionID,
+    respondToPermission,
+    beginMonitoring,
+  }
+}
+
+type SessionMessageInfo = {
+  role?: unknown
+  time?: unknown
+  modelID?: unknown
+  providerID?: unknown
+  path?: unknown
+  agent?: unknown
+}
+
+type SessionMessagePart = {
+  type?: unknown
+  text?: unknown
+}
+
+type SessionMessagePayload = {
+  info?: unknown
+  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()
+}
+
+function cleanSessionText(text: string): string {
+  return cleanTranscriptText(text).trimStart()
+}
+
+function extractMessageText(parts: SessionMessagePart[]): string {
+  const textParts: string[] = []
+
+  for (const part of parts) {
+    if (!part || part.type !== "text" || typeof part.text !== "string") continue
+
+    const text = cleanSessionText(part.text)
+    if (text.length > 0) {
+      textParts.push(text)
+    }
+  }
+
+  return textParts.join("\n\n")
+}
+
+function maybeString(value: unknown): string | null {
+  if (typeof value !== "string") return null
+  const trimmed = value.trim()
+  return trimmed.length > 0 ? trimmed : null
+}
+
+function buildPromptHistory(payload: unknown): PromptHistoryEntry[] {
+  if (!Array.isArray(payload)) return []
+
+  const entries: PromptHistoryEntry[] = []
+
+  for (const candidate of payload) {
+    const msg = candidate as SessionMessagePayload
+    if (!msg || typeof msg !== "object") continue
+
+    const info = msg.info as SessionMessageInfo
+    if (!info || typeof info !== "object") continue
+    if (info.role !== "user") continue
+
+    const id = (info as { id?: unknown }).id
+    if (typeof id !== "string") continue
+
+    const parts = Array.isArray(msg.parts) ? (msg.parts as SessionMessagePart[]) : []
+    const text = extractMessageText(parts)
+    if (text.length === 0) continue
+
+    entries.push({ promptText: text, userMessageID: id })
+  }
+
+  return entries
+}
+
+function findLatestUserPrompt(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 !== "user") continue
+
+    const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
+    const text = extractMessageText(parts)
+    if (text.length > 0) {
+      return text
+    }
+  }
+
+  return ""
+}
+
+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
+    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 context = extractAssistantContext(info)
+
+    const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
+    const text = extractMessageText(parts)
+
+    if (text.length > 0 || context) {
+      return {
+        text,
+        context,
+      }
+    }
+  }
+
+  return {
+    text: "",
+    context: null,
+  }
+}

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

@@ -0,0 +1,494 @@
+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&roots=true`
+      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 createSession = useCallback(
+    async (
+      serverID: string,
+      options?: {
+        directory?: string
+        workspaceID?: string
+        title?: string
+      },
+    ) => {
+      const server = serversRef.current.find((item) => item.id === serverID)
+      if (!server) {
+        return null
+      }
+
+      const base = server.url.replace(/\/+$/, "")
+      const params = new URLSearchParams()
+      const directory = options?.directory?.trim()
+      const workspaceID = options?.workspaceID?.trim()
+      const title = options?.title?.trim()
+
+      if (directory) {
+        params.set("directory", directory)
+      }
+
+      const body: {
+        workspaceID?: string
+        title?: string
+      } = {}
+
+      if (workspaceID) {
+        body.workspaceID = workspaceID
+      }
+
+      if (title) {
+        body.title = title
+      }
+
+      const query = params.toString()
+      const endpoint = `${base}/session${query ? `?${query}` : ""}`
+
+      try {
+        const response = await fetch(endpoint, {
+          method: "POST",
+          headers: {
+            "Content-Type": "application/json",
+          },
+          body: JSON.stringify(body),
+        })
+
+        if (!response.ok) {
+          console.log("[Server] session:create:http-error", {
+            id: server.id,
+            endpoint,
+            status: response.status,
+          })
+          return null
+        }
+
+        const payload = (await response.json()) as unknown
+        const parsed = parseSessionItems([payload])[0]
+
+        if (!parsed) {
+          void refreshServerStatusAndSessions(serverID)
+          return null
+        }
+
+        const created = parsed.updated > 0 ? parsed : { ...parsed, updated: Date.now() }
+
+        setServers((prev) =>
+          prev.map((item) => {
+            if (item.id !== serverID) return item
+
+            const sessions = [created, ...item.sessions.filter((session) => session.id !== created.id)].sort(
+              (a, b) => b.updated - a.updated,
+            )
+
+            return {
+              ...item,
+              status: "online",
+              sessionsLoading: false,
+              sessions,
+            }
+          }),
+        )
+        setActiveServerId(serverID)
+        setActiveSessionId(created.id)
+
+        console.log("[Server] session:create", {
+          id: server.id,
+          sessionID: created.id,
+          hasDirectory: Boolean(created.directory),
+          hasWorkspaceID: Boolean(created.workspaceID),
+        })
+
+        return created
+      } catch (err) {
+        console.log("[Server] session:create:error", {
+          id: server.id,
+          endpoint,
+          error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
+        })
+        return null
+      }
+    },
+    [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,
+    createSession,
+    findServerForSession,
+  }
+}

+ 14 - 0
packages/mobile-voice/src/hooks/use-theme.ts

@@ -0,0 +1,14 @@
+/**
+ * Learn more about light and dark modes:
+ * https://docs.expo.dev/guides/color-schemes/
+ */
+
+import { Colors } from '@/constants/theme';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+
+export function useTheme() {
+  const scheme = useColorScheme();
+  const theme = scheme === 'unspecified' ? 'light' : scheme;
+
+  return Colors[theme];
+}

+ 65 - 0
packages/mobile-voice/src/lib/opencode-events.ts

@@ -0,0 +1,65 @@
+export type OpenCodeEvent = {
+  type: string
+  properties?: Record<string, unknown>
+}
+
+export type MonitorEventType = "complete" | "permission" | "error"
+
+export function extractSessionID(event: OpenCodeEvent): string | null {
+  const props = event.properties ?? {}
+
+  const fromDirect = props.sessionID
+  if (typeof fromDirect === "string" && fromDirect.length > 0) return fromDirect
+
+  const info = props.info
+  if (info && typeof info === "object") {
+    const infoSessionID = (info as Record<string, unknown>).sessionID
+    if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
+  }
+
+  const part = props.part
+  if (part && typeof part === "object") {
+    const partSessionID = (part as Record<string, unknown>).sessionID
+    if (typeof partSessionID === "string" && partSessionID.length > 0) return partSessionID
+  }
+
+  return null
+}
+
+export function classifyMonitorEvent(event: OpenCodeEvent): MonitorEventType | null {
+  const type = event.type
+  const lowerType = type.toLowerCase()
+
+  if (lowerType === "permission.asked" || lowerType === "permission") {
+    return "permission"
+  }
+
+  if (lowerType.includes("error")) {
+    return "error"
+  }
+
+  if (type === "session.status") {
+    const status = event.properties?.status
+    if (status && typeof status === "object") {
+      const statusType = (status as Record<string, unknown>).type
+      if (statusType === "idle") {
+        return "complete"
+      }
+    }
+  }
+
+  return null
+}
+
+export function formatMonitorEventLabel(eventType: MonitorEventType): string {
+  switch (eventType) {
+    case "complete":
+      return "Session complete"
+    case "permission":
+      return "Action needed"
+    case "error":
+      return "Session error"
+    default:
+      return "Session update"
+  }
+}

+ 256 - 0
packages/mobile-voice/src/lib/pending-permissions.ts

@@ -0,0 +1,256 @@
+export type PendingPermissionRequest = {
+  id: string
+  sessionID: string
+  permission: string
+  patterns: string[]
+  metadata: Record<string, unknown>
+  always: string[]
+  tool?: {
+    messageID: string
+    callID: string
+  }
+}
+
+export type PermissionCardSection = {
+  label: string
+  text: string
+  mono?: boolean
+}
+
+export type PermissionCardModel = {
+  eyebrow: string
+  title: string
+  body: string
+  sections: PermissionCardSection[]
+}
+
+function record(input: unknown): Record<string, unknown> | null {
+  if (!input || typeof input !== "object") return null
+  return input as Record<string, unknown>
+}
+
+function maybeString(input: unknown): string | undefined {
+  return typeof input === "string" && input.trim().length > 0 ? input : undefined
+}
+
+function stringList(input: unknown): string[] {
+  if (!Array.isArray(input)) return []
+  return input.filter((item): item is string => typeof item === "string" && item.length > 0)
+}
+
+function previewText(input: string, options?: { maxLines?: number; maxChars?: number }): string {
+  const maxLines = options?.maxLines ?? 18
+  const maxChars = options?.maxChars ?? 1200
+  const normalized = input.replace(/\r\n/g, "\n").trim()
+  if (!normalized) return ""
+
+  const lines = normalized.split("\n")
+  const sliced = lines.slice(0, maxLines)
+  let text = sliced.join("\n")
+
+  if (text.length > maxChars) {
+    text = `${text.slice(0, maxChars).trimEnd()}\n…`
+  } else if (lines.length > maxLines) {
+    text = `${text}\n…`
+  }
+
+  return text
+}
+
+function formatPath(input: string): string {
+  return input.replace(/\\/g, "/")
+}
+
+function permissionTool(input: unknown): PendingPermissionRequest["tool"] | undefined {
+  const value = record(input)
+  if (!value) return
+
+  const messageID = maybeString(value.messageID)
+  const callID = maybeString(value.callID)
+  if (!messageID || !callID) return
+
+  return {
+    messageID,
+    callID,
+  }
+}
+
+function parsePendingPermissionRequest(input: unknown): PendingPermissionRequest | null {
+  const value = record(input)
+  if (!value) return null
+
+  const id = maybeString(value.id)
+  const sessionID = maybeString(value.sessionID)
+  const permission = maybeString(value.permission)
+  if (!id || !sessionID || !permission) return null
+
+  return {
+    id,
+    sessionID,
+    permission,
+    patterns: stringList(value.patterns),
+    metadata: record(value.metadata) ?? {},
+    always: stringList(value.always),
+    tool: permissionTool(value.tool),
+  }
+}
+
+export { parsePendingPermissionRequest }
+
+export function parsePendingPermissionRequests(payload: unknown): PendingPermissionRequest[] {
+  if (!Array.isArray(payload)) return []
+
+  return payload
+    .map((item) => parsePendingPermissionRequest(item))
+    .filter((item): item is PendingPermissionRequest => item !== null)
+}
+
+function firstPattern(request: PendingPermissionRequest): string | undefined {
+  return request.patterns.find((item) => item.trim().length > 0)
+}
+
+function externalDirectory(request: PendingPermissionRequest): string | undefined {
+  const fromMetadata = maybeString(request.metadata.parentDir) ?? maybeString(request.metadata.filepath)
+  if (fromMetadata) return fromMetadata
+
+  const pattern = firstPattern(request)
+  if (!pattern) return
+  return pattern.endsWith("/*") ? pattern.slice(0, -2) : pattern
+}
+
+function allowScopeSection(request: PendingPermissionRequest): PermissionCardSection | null {
+  if (request.always.length === 0) return null
+  if (
+    request.always.length === request.patterns.length &&
+    request.always.every((item, index) => item === request.patterns[index])
+  ) {
+    return null
+  }
+  if (request.always.length === 1 && request.always[0] === "*") {
+    return {
+      label: "Always allow",
+      text: "Applies to all future requests of this permission until OpenCode restarts.",
+    }
+  }
+
+  return {
+    label: "Always allow scope",
+    text: previewText(request.always.join("\n"), { maxLines: 8, maxChars: 600 }),
+    mono: true,
+  }
+}
+
+export function buildPermissionCardModel(request: PendingPermissionRequest): PermissionCardModel {
+  const filepath = maybeString(request.metadata.filepath)
+  const diff = maybeString(request.metadata.diff)
+  const commandText = previewText(request.patterns.join("\n"), { maxLines: 6, maxChars: 700 })
+  const scope = allowScopeSection(request)
+
+  if (request.permission === "edit") {
+    const sections: PermissionCardSection[] = []
+    if (filepath) {
+      sections.push({ label: "File", text: formatPath(filepath), mono: true })
+    }
+    if (diff) {
+      sections.push({ label: "Diff preview", text: previewText(diff), mono: true })
+    }
+    if (scope) sections.push(scope)
+
+    return {
+      eyebrow: "EDIT",
+      title: "Allow file edit?",
+      body: "OpenCode wants to change a file in this session.",
+      sections,
+    }
+  }
+
+  if (request.permission === "bash") {
+    const sections: PermissionCardSection[] = []
+    if (commandText) {
+      sections.push({ label: "Command", text: commandText, mono: true })
+    }
+    if (scope) sections.push(scope)
+
+    return {
+      eyebrow: "BASH",
+      title: "Allow shell command?",
+      body: "OpenCode wants to run a shell command for this session.",
+      sections,
+    }
+  }
+
+  if (request.permission === "read") {
+    const sections: PermissionCardSection[] = []
+    const path = firstPattern(request)
+    if (path) {
+      sections.push({ label: "Path", text: formatPath(path), mono: true })
+    }
+    if (scope) sections.push(scope)
+
+    return {
+      eyebrow: "READ",
+      title: "Allow file read?",
+      body: "OpenCode wants to read a path from your machine.",
+      sections,
+    }
+  }
+
+  if (request.permission === "external_directory") {
+    const sections: PermissionCardSection[] = []
+    const dir = externalDirectory(request)
+    if (dir) {
+      sections.push({ label: "Directory", text: formatPath(dir), mono: true })
+    }
+    if (request.patterns.length > 0) {
+      sections.push({
+        label: "Patterns",
+        text: previewText(request.patterns.join("\n"), { maxLines: 8, maxChars: 600 }),
+        mono: true,
+      })
+    }
+    if (scope) sections.push(scope)
+
+    return {
+      eyebrow: "DIRECTORY",
+      title: "Allow external access?",
+      body: "OpenCode wants to work with files outside the current project directory.",
+      sections,
+    }
+  }
+
+  if (request.permission === "task") {
+    const sections: PermissionCardSection[] = []
+    if (request.patterns.length > 0) {
+      sections.push({
+        label: "Patterns",
+        text: previewText(request.patterns.join("\n"), { maxLines: 8, maxChars: 600 }),
+        mono: true,
+      })
+    }
+    if (scope) sections.push(scope)
+
+    return {
+      eyebrow: "TASK",
+      title: "Allow delegated task?",
+      body: "OpenCode wants to launch another task as part of this session.",
+      sections,
+    }
+  }
+
+  const sections: PermissionCardSection[] = []
+  if (request.patterns.length > 0) {
+    sections.push({
+      label: "Patterns",
+      text: previewText(request.patterns.join("\n"), { maxLines: 8, maxChars: 600 }),
+      mono: true,
+    })
+  }
+  if (scope) sections.push(scope)
+
+  return {
+    eyebrow: request.permission.toUpperCase(),
+    title: `Allow ${request.permission}?`,
+    body: "OpenCode needs your permission before it can continue this session.",
+    sections,
+  }
+}

+ 63 - 0
packages/mobile-voice/src/lib/relay-client.ts

@@ -0,0 +1,63 @@
+export type RegisterDeviceInput = {
+  relayBaseURL: string
+  secret: string
+  deviceToken: string
+  bundleId?: string
+  apnsEnv?: "sandbox" | "production"
+}
+
+export type UnregisterDeviceInput = {
+  relayBaseURL: string
+  secret: string
+  deviceToken: string
+}
+
+function normalizeBase(url: string): string {
+  return url.replace(/\/+$/, "")
+}
+
+async function postRelay(path: string, relayBaseURL: string, body: Record<string, unknown>): Promise<void> {
+  const relay = normalizeBase(relayBaseURL)
+  const response = await fetch(`${relay}${path}`, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+    },
+    body: JSON.stringify(body),
+  })
+
+  if (!response.ok) {
+    const text = await response.text()
+    throw new Error(`Relay request failed (${response.status}): ${text || response.statusText}`)
+  }
+}
+
+export async function registerRelayDevice(input: RegisterDeviceInput): Promise<void> {
+  await postRelay("/v1/device/register", input.relayBaseURL, {
+    secret: input.secret,
+    deviceToken: input.deviceToken,
+    bundleId: input.bundleId,
+    apnsEnv: input.apnsEnv,
+  })
+}
+
+export async function unregisterRelayDevice(input: UnregisterDeviceInput): Promise<void> {
+  await postRelay("/v1/device/unregister", input.relayBaseURL, {
+    secret: input.secret,
+    deviceToken: input.deviceToken,
+  })
+}
+
+export async function sendRelayTestEvent(input: {
+  relayBaseURL: string
+  secret: string
+  sessionID: string
+}): Promise<void> {
+  await postRelay("/v1/event", input.relayBaseURL, {
+    secret: input.secret,
+    eventType: "permission",
+    sessionID: input.sessionID,
+    title: "APN relay test",
+    body: "If you can read this, APN relay registration is working.",
+  })
+}

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

@@ -0,0 +1,181 @@
+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
+  directory?: string
+  workspaceID?: string
+  projectID?: string
+}
+
+type ServerSessionPayload = {
+  id?: unknown
+  title?: unknown
+  directory?: unknown
+  workspaceID?: unknown
+  projectID?: 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) => {
+      const directory = typeof item.directory === "string" && item.directory.length > 0 ? item.directory : undefined
+      const workspaceID =
+        typeof item.workspaceID === "string" && item.workspaceID.length > 0 ? item.workspaceID : undefined
+      const projectID = typeof item.projectID === "string" && item.projectID.length > 0 ? item.projectID : undefined
+
+      return {
+        id: String(item.id ?? ""),
+        title: String(item.title ?? item.id ?? "Untitled session"),
+        updated: Number(item.time?.updated ?? 0),
+        directory,
+        workspaceID,
+        projectID,
+      }
+    })
+    .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) {
+      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))
+}

+ 72 - 0
packages/mobile-voice/src/lib/sse.ts

@@ -0,0 +1,72 @@
+export type SSEMessage = {
+  event?: string;
+  data: string;
+  id?: string;
+};
+
+function parseBlock(block: string): SSEMessage | null {
+  if (!block.trim()) return null;
+
+  const lines = block.split(/\r?\n/);
+  let event: string | undefined;
+  let id: string | undefined;
+  const dataLines: string[] = [];
+
+  for (const line of lines) {
+    if (!line || line.startsWith(':')) continue;
+
+    const sep = line.indexOf(':');
+    const field = sep === -1 ? line : line.slice(0, sep);
+    const value = sep === -1 ? '' : line.slice(sep + 1).replace(/^\s/, '');
+
+    if (field === 'event') {
+      event = value;
+      continue;
+    }
+
+    if (field === 'id') {
+      id = value;
+      continue;
+    }
+
+    if (field === 'data') {
+      dataLines.push(value);
+    }
+  }
+
+  if (dataLines.length === 0) return null;
+
+  return {
+    event,
+    id,
+    data: dataLines.join('\n'),
+  };
+}
+
+export async function* parseSSEStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<SSEMessage> {
+  const reader = stream.getReader();
+  const decoder = new TextDecoder();
+  let pending = '';
+
+  try {
+    while (true) {
+      const result = await reader.read();
+      if (result.done) break;
+
+      pending += decoder.decode(result.value, { stream: true });
+      const blocks = pending.split(/\r?\n\r?\n/);
+      pending = blocks.pop() ?? '';
+
+      for (const block of blocks) {
+        const parsed = parseBlock(block);
+        if (parsed) yield parsed;
+      }
+    }
+
+    pending += decoder.decode();
+    const tail = parseBlock(pending);
+    if (tail) yield tail;
+  } finally {
+    reader.releaseLock();
+  }
+}

+ 88 - 0
packages/mobile-voice/src/notifications/monitoring-notifications.ts

@@ -0,0 +1,88 @@
+import * as Notifications from "expo-notifications"
+import * as TaskManager from "expo-task-manager"
+import { Platform } from "react-native"
+
+const BACKGROUND_TASK_NAME = "monitoring-background-notification-task"
+
+type BackgroundPayload = {
+  eventType?: string
+  sessionID?: string
+  title?: string
+  body?: string
+}
+
+let configured = false
+
+TaskManager.defineTask(BACKGROUND_TASK_NAME, async ({ data }: { data?: unknown }) => {
+  const payload = data as BackgroundPayload | undefined
+  const title = payload?.title ?? "OpenCode update"
+  const body = payload?.body ?? "Your monitored session has a new update."
+
+  await Notifications.scheduleNotificationAsync({
+    content: {
+      title,
+      body,
+      data: payload ?? {},
+      sound: "alert.wav",
+      ...(Platform.OS === "android" ? { channelId: "monitoring" } : {}),
+    },
+    trigger: null,
+  })
+
+  return Notifications.BackgroundNotificationTaskResult.NewData
+})
+
+export function configureNotificationBehavior(): void {
+  if (configured) return
+  configured = true
+
+  Notifications.setNotificationHandler({
+    handleNotification: async () => ({
+      shouldShowBanner: false,
+      shouldShowList: false,
+      shouldPlaySound: false,
+      shouldSetBadge: false,
+    }),
+  })
+}
+
+export async function registerBackgroundNotificationTask(): Promise<void> {
+  const already = await TaskManager.isTaskRegisteredAsync(BACKGROUND_TASK_NAME)
+  if (already) return
+  await Notifications.registerTaskAsync(BACKGROUND_TASK_NAME)
+}
+
+export async function ensureNotificationPermissions(): Promise<boolean> {
+  if (Platform.OS === "android") {
+    await Notifications.setNotificationChannelAsync("monitoring", {
+      name: "OpenCode Monitoring",
+      importance: Notifications.AndroidImportance.HIGH,
+      sound: "alert.wav",
+    })
+  }
+
+  const existing = await Notifications.getPermissionsAsync()
+  let granted = existing.granted
+
+  if (!granted) {
+    const requested = await Notifications.requestPermissionsAsync()
+    granted = requested.granted
+  }
+
+  return granted
+}
+
+export async function getDevicePushToken(): Promise<string | null> {
+  const result = await Notifications.getDevicePushTokenAsync()
+  if (typeof result.data !== "string" || result.data.length === 0) {
+    return null
+  }
+  return result.data
+}
+
+export function onPushTokenChange(callback: (token: string) => void): { remove: () => void } {
+  return Notifications.addPushTokenListener((next: { data: unknown }) => {
+    if (typeof next.data !== "string" || next.data.length === 0) return
+    callback(next.data)
+  })
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов