diff --git a/mobile/.gitignore b/mobile/.gitignore
new file mode 100644
index 0000000000..06d2cece39
--- /dev/null
+++ b/mobile/.gitignore
@@ -0,0 +1,54 @@
+# 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
+
+# generated native folders
+/ios
+/android
+
+# logs (expo.log, expo-web.log, metro, etc.)
+*.log
+
+# one-shot / scratch scripts (e.g. asset generation via --no-save deps)
+.gen-*.js
+*.local.*
+
+# generated QR codes / scratch images at repo root
+/qr-*.png
+
+# Agents
+.claude
diff --git a/mobile/.npmrc b/mobile/.npmrc
new file mode 100644
index 0000000000..521a9f7c07
--- /dev/null
+++ b/mobile/.npmrc
@@ -0,0 +1 @@
+legacy-peer-deps=true
diff --git a/mobile/AGENTS.md b/mobile/AGENTS.md
new file mode 100644
index 0000000000..61769c981c
--- /dev/null
+++ b/mobile/AGENTS.md
@@ -0,0 +1 @@
+@CLAUDE.md
diff --git a/mobile/CLAUDE.md b/mobile/CLAUDE.md
new file mode 100644
index 0000000000..f765e06072
--- /dev/null
+++ b/mobile/CLAUDE.md
@@ -0,0 +1,113 @@
+# Expo HAS CHANGED
+
+Read the exact versioned docs at https://docs.expo.dev/versions/v54.0.0/ before writing any code.
+
+# AO Mobile — project guide
+
+A phone remote-control for **Agent Orchestrator (AO)**, the monorepo this folder
+lives in. It mirrors the AO web dashboard for a phone: a Kanban board of agent
+sessions, a live controllable terminal per session, PR review/merge, and
+orchestrator launch/open — over LAN or Tailscale.
+
+## Location & tooling
+
+- Lives at `ao-fork/mobile/` **but is a standalone npm + Expo project**, NOT part of
+ AO's pnpm workspace (`pnpm-workspace.yaml` only globs `packages/*`). Run
+ `npm install` / `npx expo start` **from inside `mobile/`** — never `pnpm` here.
+- `.npmrc` has `legacy-peer-deps=true` because `@fressh` pins exact peer versions.
+- TypeScript, file-based routing via **expo-router**. Dark-only.
+
+## Expo SDK is pinned to 54 — do not bump
+
+Expo Go supports a **single** SDK at a time. This app is pinned to **SDK 54** to
+match the test phone's Expo Go. Symptoms of a mismatch: _"incompatible with this
+version of Expo Go."_ Don't change the SDK unless the user's Expo Go updated.
+Read **v54** docs () — APIs differ by SDK.
+Pinned: `expo 54`, `react 19.1.0`, `react-native 0.81.5`, `expo-router 6`,
+`react-native-webview 13.15.0` (react + webview match `@fressh` peers exactly).
+
+## How it connects to AO
+
+AO's web dashboard is `ao-fork/packages/web` (Next.js). This app talks to that
+server two ways, configured in the **Settings** tab (`host` + ports, persisted via
+AsyncStorage in `lib/config.ts`):
+
+1. **REST API** (`http://:`, default `3000` — but AO often runs on
+ **`3001`** when something holds 3000). Client: `lib/api.ts`. Endpoints used:
+ - `GET /api/sessions?project=all` → `{ sessions[], orchestrators[], orchestratorId, stats }`
+ - `GET /api/projects` → `{ projects[] }`
+ - `POST /api/spawn` `{ projectId, prompt?, issueId? }` — new worker
+ - `POST /api/orchestrators` `{ projectId, clean? }` — launch/relaunch orchestrator
+ - `POST /api/sessions/:id/kill | /restore | /send` `{ message }`
+ - `POST /api/prs/:number/merge?owner=&repo=` — squash-merge
+2. **Mux WebSocket** (`ws://:/mux`, default `14801`). Client:
+ `lib/mux.ts`. One multiplexed socket carries:
+ - **Terminal I/O**: `{ch:'terminal', id, type:'open'|'data'|'resize'|'close', projectId?}`
+ out; `data`/`opened`/`exited`/`error` in. (`id` = AO session id; `projectId`
+ disambiguates across projects.)
+ - **Live session snapshots**: `{ch:'subscribe', topics:['sessions','notifications']}`
+ → periodic `{ch:'sessions', type:'snapshot', sessions:[SessionPatch]}` (id,
+ status, activity, attentionLevel, lastActivityAt).
+ - Heartbeat `{ch:'system', type:'ping'}`; auto-reconnect with backoff.
+
+**No auth** — AO's API is unauthenticated; the network (LAN/Tailscale) is the
+boundary. `lib/config.ts` builds `http`/`ws` (or `https`/`wss` when the TLS flag is
+on) and strips any scheme the user pastes into Host.
+
+The **attention levels** (merge / respond / review / pending / working / done) and
+the **Mission Control palette** (bg `#0a0b0d`, blue `#4d8dff` = conductor, orange
+`#f59f4c` = working agent, amber/red/green states) mirror AO's `DESIGN.md`.
+
+## Architecture
+
+- **`lib/store.tsx` (``)** is the heart: opens **one shared mux socket**
+ (live session patches) + a periodic REST poll, merges them (patches are
+ authoritative for live fields; snapshot-only sessions are surfaced immediately),
+ and exposes everything via `useApp()` plus `useVisibleSessions()` / `usePRs()`.
+ All actions (spawn, merge, kill, restore, send, launchConductor) live here. The
+ context value is memoized; consumers re-render only on real changes.
+- **Screens** consume the store. Board groups by `attentionOf(session)`; the
+ Orchestrator tab lists every project's orchestrator (open if a link exists, else
+ spawn).
+- **Terminal** (`app/session/[id].tsx`) opens its **own** MuxClient for terminal I/O
+ (a known duplication vs the store's socket — a deferred refactor).
+
+## The terminal (xterm.js in a WebView) — read before touching
+
+`@fressh/react-native-xtermjs-webview` runs xterm.js inside `react-native-webview`.
+Hard-won constraints (don't relearn them the hard way):
+
+- **Keyboard is RN-controlled, not the WebView's.** The injected JS disables the
+ WebView's hidden textarea so a tap can't raise a keyboard; a hidden RN
+ `` is the real keyboard (focus/blur via the ⌨ button), and `onKeyPress`
+ → mux `sendInput`. This is why single-tap doesn't open the keyboard and the
+ terminal resizes above the keyboard.
+- **Scroll**: inject `.xterm-screen{pointer-events:none}` so drags fall through the
+ selection canvas to native momentum scroll. Custom touch-scroll handlers do NOT
+ work (xterm's selection auto-scroll is timer-based).
+- **Sizing is measured, not guessed**: the WebView's FitAddon fits on container
+ resize and reports real cols/rows back through fressh's **`debug → logger.log`**
+ channel; RN forwards them to the PTY. **Never** pass `onMessage` via
+ `webViewOptions` — fressh spreads user options after its own `onMessage`, so it
+ **clobbers the bridge** and breaks the terminal. Use the `logger` prop.
+- `window.terminal` / `window.fitAddon` are exposed; injected JS reaches the WebView
+ via `webViewOptions.injectedJavaScript`.
+
+## Dev & test workflow
+
+- **Claude verifies the UI on Expo Web** (`npx expo start --web`) with the
+ chrome-devtools MCP at a phone viewport. The **terminal does not render on web**
+ (native WebView), and browser **CORS blocks REST** to AO — so use a `fetch` shim
+ in an `initScript` to mock `/api/*` when checking populated screens.
+- **The user tests the terminal + keyboard on a physical iPhone via Expo Go** — only
+ they can verify WebView behavior. Don't claim terminal/keyboard fixes are
+ verified; ask them to confirm on device.
+- After changes: `npx tsc --noEmit` must be clean.
+
+## Conventions
+
+- Match the existing screen structure: `ScreenHeader` (title + AO mascot) → optional
+ `ProjectSwitcher` → list/scroll. Reuse `lib/ui.tsx` primitives and `lib/theme.ts`
+ helpers (`statusVisual`, `attentionMeta`, `ciVisual`) — don't re-derive colors
+ inline. Color is rationed (it always means something); the card is the only
+ bordered surface.
diff --git a/mobile/LICENSE b/mobile/LICENSE
new file mode 100644
index 0000000000..30b20e3b5f
--- /dev/null
+++ b/mobile/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/mobile/README.md b/mobile/README.md
new file mode 100644
index 0000000000..140f970a0d
--- /dev/null
+++ b/mobile/README.md
@@ -0,0 +1,104 @@
+# AO Mobile
+
+A phone remote-control for **Agent Orchestrator (AO)** — the project this folder
+lives inside. Triage your fleet on a Kanban board, open a **live terminal** for any
+session and drive it, review and merge PRs, and launch/open orchestrators — from
+your phone, over your LAN or Tailscale.
+
+This is an [Expo](https://expo.dev) (React Native) app. It lives in the AO monorepo
+at `mobile/` but is a **standalone npm project** — it is _not_ part of AO's pnpm
+workspace. Run all commands below from inside `mobile/`.
+
+## Requirements — Expo Go is pinned to SDK 54
+
+> [!IMPORTANT]
+> **The Expo Go app supports only ONE Expo SDK at a time** (whatever the latest
+> store build targets). This project is **pinned to Expo SDK 54** to match the
+> Expo Go currently installed on the test phone. If your Expo Go shows
+> _"Project is incompatible with this version of Expo Go"_, your Expo Go and this
+> project's SDK don't match.
+>
+> - **Don't bump the Expo SDK** unless your Expo Go has updated to that SDK. When
+> you do upgrade, run `npx expo install expo@^ && npx expo install --fix`.
+> - When writing code, read the **v54** docs:
+> (the API changes between SDKs — don't trust older/newer snippets).
+
+Pinned versions: `expo 54`, `react 19.1.0`, `react-native 0.81.5`,
+`expo-router 6`, `react-native-webview 13.15.0`. `react`/`react-native-webview`
+are pinned exactly to `@fressh`'s peer requirements (see `.npmrc`:
+`legacy-peer-deps=true`).
+
+## Run it (Expo Go)
+
+```bash
+cd mobile
+npm install
+npx expo start
+```
+
+Scan the QR with **Expo Go** (Android: scan in the app; iOS: scan with the Camera
+app). Phone and PC must be on the same Wi-Fi. If they aren't, use
+`npx expo start --tunnel`. Edits hot-reload on the device.
+
+### Preview the UI in a browser (no phone)
+
+```bash
+npx expo start --web
+```
+
+Every screen renders in the browser **except the terminal** (it's a native
+WebView — device only). Note: browser **CORS** blocks the cross-origin REST calls
+to AO, so the web preview shows empty/"couldn't reach server" data — that's a
+browser-only limit; on a real device (native fetch, no CORS) the data loads.
+
+## First-run setup
+
+Open the **Settings** tab and enter your AO server:
+
+- **Host** — your PC's LAN IP (e.g. `192.168.x.x`) or Tailscale name / `100.x`.
+- **API port** — AO's dashboard (Next.js). Default `3000`, but AO often runs on
+ **`3001`** when another app holds `3000`.
+- **Terminal port** — AO's mux/terminal WebSocket, `14801`.
+- **Use TLS** — leave **off** for plain LAN/Tailscale (AO serves http/ws). Only
+ turn on if AO is behind HTTPS (proxy / Tailscale funnel).
+
+Tap **Test connection**, then **Save**.
+
+## Server side (AO) checklist
+
+- The terminal WebSocket (`:14801`) already binds all interfaces — reachable over
+ LAN/Tailscale out of the box.
+- The REST API must be reachable too. If a connection test fails, start AO's web
+ server bound to all interfaces (`HOSTNAME=0.0.0.0`) and confirm phone + PC are on
+ the same network/tailnet.
+- AO's API has **no auth** — your network (LAN/Tailscale) is the boundary. Don't
+ expose these ports to the public internet.
+
+## What's inside
+
+```
+app/
+ _layout.tsx Root Stack: tabs + pushed terminal + spawn modal; wraps
+ (tabs)/
+ _layout.tsx Bottom tab bar (Kanban · PRs · Orchestrator · Settings)
+ index.tsx Kanban board — sessions grouped by AO attention level
+ prs.tsx Pull requests — filter, merge, open
+ orchestrator.tsx Per-project orchestrator: status, worker zones, open/spawn
+ settings.tsx Server config + projects list
+ session/[id].tsx Live terminal (xterm.js in a WebView) + keys + send + Kill
+ spawn.tsx New-agent modal (pick project + optional task)
+lib/
+ config.ts Server config (AsyncStorage), http/ws URL builders, TLS flag
+ api.ts AO REST client (sessions, spawn, merge, kill, restore, send)
+ mux.ts AO mux WebSocket client (terminal I/O + live session snapshots)
+ store.tsx — one shared mux socket + REST, app-wide state
+ theme.ts AO "Mission Control" palette + status/attention helpers
+ ui.tsx Shared primitives (Dot, Chip, Pill, Card, Button, ScreenHeader…)
+ SessionCard.tsx A session card for the board
+ ProjectSwitcher.tsx Multi-project pill switcher
+assets/ App icon / splash / favicon / header mascot (AO brand)
+```
+
+Terminal rendering uses
+[`@fressh/react-native-xtermjs-webview`](https://www.npmjs.com/package/@fressh/react-native-xtermjs-webview)
+(MIT). Design language mirrors AO's `DESIGN.md`.
diff --git a/mobile/app.json b/mobile/app.json
new file mode 100644
index 0000000000..895210af18
--- /dev/null
+++ b/mobile/app.json
@@ -0,0 +1,54 @@
+{
+ "expo": {
+ "name": "AO",
+ "slug": "ao-mobile",
+ "owner": "priyanchew",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/icon.png",
+ "userInterfaceStyle": "dark",
+ "backgroundColor": "#0a0b0d",
+ "scheme": "aomobile",
+ "splash": {
+ "image": "./assets/splash-icon.png",
+ "resizeMode": "contain",
+ "backgroundColor": "#0a0b0d"
+ },
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "aoagents.ao",
+ "config": {
+ "usesNonExemptEncryption": false
+ }
+ },
+ "android": {
+ "package": "aoagents.ao",
+ "adaptiveIcon": {
+ "backgroundColor": "#0a0b0d",
+ "foregroundImage": "./assets/android-icon-foreground.png"
+ },
+ "predictiveBackGestureEnabled": false
+ },
+ "web": {
+ "favicon": "./assets/favicon.png",
+ "bundler": "metro"
+ },
+ "plugins": [
+ "expo-router",
+ [
+ "expo-build-properties",
+ {
+ "android": {
+ "usesCleartextTraffic": true
+ }
+ }
+ ]
+ ],
+ "extra": {
+ "router": {},
+ "eas": {
+ "projectId": "5bd2863a-4238-4f2e-8017-f5df7e6899c3"
+ }
+ }
+ }
+}
diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx
new file mode 100644
index 0000000000..1013fec4e9
--- /dev/null
+++ b/mobile/app/(tabs)/_layout.tsx
@@ -0,0 +1,59 @@
+import { Feather } from "@expo/vector-icons";
+import { Tabs } from "expo-router";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { theme } from "../../lib/theme";
+
+export default function TabsLayout() {
+ const insets = useSafeAreaInsets();
+ return (
+
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+
+ );
+}
diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx
new file mode 100644
index 0000000000..1f981b8aa8
--- /dev/null
+++ b/mobile/app/(tabs)/index.tsx
@@ -0,0 +1,185 @@
+import { Feather } from "@expo/vector-icons";
+import { useRouter } from "expo-router";
+import { useCallback, useMemo, useState } from "react";
+import { ActivityIndicator, Pressable, RefreshControl, SectionList, StyleSheet, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { attentionOf, type DashboardSession } from "../../lib/api";
+import { ProjectSwitcher } from "../../lib/ProjectSwitcher";
+import { SessionCard } from "../../lib/SessionCard";
+import { useApp, useVisibleSessions } from "../../lib/store";
+import { attentionMeta, theme } from "../../lib/theme";
+import { Button, ConnectionPill, EmptyState, ScreenHeader, SectionHeader } from "../../lib/ui";
+
+type Section = { key: string; label: string; color: string; order: number; data: DashboardSession[] };
+
+function groupByAttention(sessions: DashboardSession[]): Section[] {
+ const buckets = new Map();
+ for (const s of sessions) {
+ const key = attentionOf(s);
+ if (!buckets.has(key)) buckets.set(key, []);
+ buckets.get(key)!.push(s);
+ }
+ return [...buckets.entries()]
+ .map(([key, data]) => {
+ const meta = attentionMeta[key] ?? {
+ label: key,
+ color: theme.textTertiary,
+ order: 99,
+ };
+ return { key, label: meta.label, color: meta.color, order: meta.order, data };
+ })
+ .sort((a, b) => a.order - b.order);
+}
+
+export default function FleetScreen() {
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const { configured, loading, error, connection, config, refresh } = useApp();
+ const sessions = useVisibleSessions();
+ const [refreshing, setRefreshing] = useState(false);
+
+ const sections = useMemo(() => groupByAttention(sessions), [sessions]);
+
+ const counts = useMemo(() => {
+ let working = 0,
+ needsYou = 0,
+ mergeable = 0;
+ for (const s of sessions) {
+ const a = attentionOf(s);
+ if (a === "working") working++;
+ else if (a === "respond" || a === "action") needsYou++;
+ else if (a === "merge") mergeable++;
+ }
+ return { working, needsYou, mergeable };
+ }, [sessions]);
+
+ const onRefresh = useCallback(async () => {
+ setRefreshing(true);
+ await refresh();
+ setRefreshing(false);
+ }, [refresh]);
+
+ if (!configured) {
+ return (
+
+
+ router.push("/settings")} />}
+ />
+
+ );
+ }
+
+ return (
+
+
+ } />
+
+
+
+
+
+
+
+
+
+ {loading && sessions.length === 0 ? (
+
+
+
+ ) : (
+ `${item.projectId}:${item.id}`}
+ contentContainerStyle={{ paddingBottom: 120 }}
+ stickySectionHeadersEnabled={false}
+ refreshControl={}
+ renderSectionHeader={({ section }) => (
+
+ )}
+ renderItem={({ item }) => }
+ ListEmptyComponent={
+ error ? (
+ }
+ />
+ ) : (
+ router.push("/spawn")} />}
+ />
+ )
+ }
+ />
+ )}
+
+ {/* Spawn FAB */}
+ router.push("/spawn")}
+ style={({ pressed }) => [styles.fab, pressed && { opacity: 0.85 }]}
+ >
+
+
+
+ );
+}
+
+function Stat({ n, label, color }: { n: number; label: string; color: string }) {
+ return (
+
+ 0 ? color : theme.textFaint }]}>{n}
+ {label}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: { flex: 1, backgroundColor: theme.bgBase },
+ center: { flex: 1, alignItems: "center", justifyContent: "center", paddingVertical: 60 },
+ stats: {
+ flexDirection: "row",
+ gap: 10,
+ paddingHorizontal: 16,
+ paddingTop: 4,
+ paddingBottom: 14,
+ },
+ stat: {
+ flex: 1,
+ backgroundColor: theme.bgElevated,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: theme.borderSubtle,
+ paddingVertical: 12,
+ paddingHorizontal: 14,
+ },
+ statN: { fontSize: 24, fontWeight: "800", fontFamily: theme.fontMono },
+ statLabel: { color: theme.textTertiary, fontSize: 11, fontWeight: "600", marginTop: 2 },
+ fab: {
+ position: "absolute",
+ right: 18,
+ bottom: 24,
+ width: 56,
+ height: 56,
+ borderRadius: 28,
+ backgroundColor: theme.blue,
+ alignItems: "center",
+ justifyContent: "center",
+ shadowColor: "#000",
+ shadowOpacity: 0.4,
+ shadowRadius: 12,
+ shadowOffset: { width: 0, height: 4 },
+ elevation: 8,
+ },
+});
diff --git a/mobile/app/(tabs)/orchestrator.tsx b/mobile/app/(tabs)/orchestrator.tsx
new file mode 100644
index 0000000000..3338579558
--- /dev/null
+++ b/mobile/app/(tabs)/orchestrator.tsx
@@ -0,0 +1,222 @@
+import { Feather } from "@expo/vector-icons";
+import { useRouter } from "expo-router";
+import { useState } from "react";
+import { Alert, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { attentionOf, type DashboardSession, type OrchestratorLink } from "../../lib/api";
+import { useApp } from "../../lib/store";
+import { attentionMeta, statusVisual, theme, type AttentionLevel, type StatusVisual } from "../../lib/theme";
+import { Button, ConnectionPill, Dot, EmptyState, ScreenHeader } from "../../lib/ui";
+
+const ZONE_ORDER: AttentionLevel[] = ["merge", "respond", "review", "pending", "working", "done"];
+
+export default function OrchestratorScreen() {
+ const insets = useSafeAreaInsets();
+ const { configured, connection, projects, sessions, orchestrators, refresh } = useApp();
+ const [refreshing, setRefreshing] = useState(false);
+
+ // Always show every project's orchestrator here — no per-project filtering.
+ const visibleProjects = projects;
+
+ const onRefresh = async () => {
+ setRefreshing(true);
+ await refresh();
+ setRefreshing(false);
+ };
+
+ if (!configured) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ }
+ />
+
+ }
+ >
+ {visibleProjects.length === 0 ? (
+
+ ) : (
+ visibleProjects.map((p) => {
+ const link = orchestrators.find((o) => o.projectId === p.id) ?? null;
+ const workers = sessions.filter((s) => s.projectId === p.id && s.id !== link?.id);
+ return (
+
+ );
+ })
+ )}
+
+
+ );
+}
+
+function zoneCounts(sessions: DashboardSession[]): Record {
+ const out: Record = {};
+ for (const s of sessions) {
+ const a = attentionOf(s);
+ out[a] = (out[a] ?? 0) + 1;
+ }
+ return out;
+}
+
+function OrchestratorCard({
+ projectId,
+ projectName,
+ link,
+ workerCount,
+ zones,
+}: {
+ projectId: string;
+ projectName: string;
+ link: OrchestratorLink | null;
+ workerCount: number;
+ zones: Record;
+}) {
+ const router = useRouter();
+ const { launchConductor } = useApp();
+ const [busy, setBusy] = useState(false);
+
+ // The link only appears when an orchestrator exists — so its presence means
+ // it's openable. Some AO builds add hasRuntime/isTerminal; treat those as
+ // "stopped" only when explicitly flagged, never on a missing field.
+ const present = !!link?.id;
+ const stopped = present && (link.hasRuntime === false || link.isTerminal === true);
+ const open = present && !stopped;
+ const v: StatusVisual = link?.status ? statusVisual(link.status) : { color: theme.blue, label: "Online" };
+
+ const openTerminal = (id: string) => router.push({ pathname: "/session/[id]", params: { id, projectId } });
+
+ const onLaunch = async (clean: boolean) => {
+ setBusy(true);
+ try {
+ const l = await launchConductor(projectId, clean);
+ if (l?.id) openTerminal(l.id);
+ } catch (e) {
+ Alert.alert("Could not launch", e instanceof Error ? e.message : "Unknown error");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {projectName}
+
+
+
+ {open ? v.label : stopped ? "Stopped" : "Not started"}
+
+
+ · {workerCount} worker{workerCount === 1 ? "" : "s"}
+
+
+
+
+
+ {workerCount > 0 ? (
+
+ {ZONE_ORDER.filter((z) => zones[z]).map((z) => {
+ const m = attentionMeta[z];
+ return (
+
+
+ {zones[z]}
+ {m.label}
+
+ );
+ })}
+
+ ) : null}
+
+
+ {open ? (
+ <>
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: { flex: 1, backgroundColor: theme.bgBase },
+ card: {
+ backgroundColor: theme.bgElevated,
+ borderRadius: 14,
+ borderWidth: 1,
+ borderColor: theme.borderSubtle,
+ padding: 16,
+ marginHorizontal: 12,
+ marginVertical: 6,
+ },
+ head: { flexDirection: "row", alignItems: "center", gap: 12 },
+ orgIcon: {
+ width: 40,
+ height: 40,
+ borderRadius: 11,
+ backgroundColor: theme.tintBlue,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ projName: { color: theme.textPrimary, fontSize: 17, fontWeight: "700" },
+ statusRow: { flexDirection: "row", alignItems: "center", gap: 6, marginTop: 3 },
+ statusText: { fontSize: 12, fontWeight: "600" },
+ workers: { color: theme.textTertiary, fontSize: 12 },
+ zones: { flexDirection: "row", flexWrap: "wrap", gap: 7, marginTop: 14 },
+ zonePill: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 5,
+ paddingHorizontal: 9,
+ paddingVertical: 5,
+ borderRadius: 8,
+ },
+ zoneN: { fontSize: 12, fontWeight: "800", fontFamily: theme.fontMono },
+ zoneLabel: { color: theme.textSecondary, fontSize: 11, fontWeight: "600" },
+ actions: { flexDirection: "row", gap: 8, marginTop: 16 },
+});
diff --git a/mobile/app/(tabs)/prs.tsx b/mobile/app/(tabs)/prs.tsx
new file mode 100644
index 0000000000..35e9070a3d
--- /dev/null
+++ b/mobile/app/(tabs)/prs.tsx
@@ -0,0 +1,233 @@
+import { useRouter } from "expo-router";
+import { useMemo, useState } from "react";
+import { Alert, Linking, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import type { DashboardPR, DashboardSession } from "../../lib/api";
+import { ProjectSwitcher } from "../../lib/ProjectSwitcher";
+import { useApp, usePRs } from "../../lib/store";
+import { ciVisual, theme } from "../../lib/theme";
+import { Button, Chip, ConnectionPill, EmptyState, Pill, ScreenHeader } from "../../lib/ui";
+
+type Filter = "open" | "merged" | "all";
+
+const prKey = (pr: DashboardPR) => `${pr.owner ?? ""}/${pr.repo ?? ""}#${pr.number}`;
+
+export default function PRsScreen() {
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const { configured, connection, merge, refresh } = useApp();
+ const prs = usePRs();
+ const [filter, setFilter] = useState("open");
+ const [refreshing, setRefreshing] = useState(false);
+ const [merging, setMerging] = useState(null);
+
+ const filtered = useMemo(() => {
+ return prs.filter(({ pr }) => {
+ const st = pr.state ?? "open";
+ if (filter === "all") return true;
+ if (filter === "open") return st === "open";
+ return st === "merged";
+ });
+ }, [prs, filter]);
+
+ const onRefresh = async () => {
+ setRefreshing(true);
+ await refresh();
+ setRefreshing(false);
+ };
+
+ const onMerge = (pr: DashboardPR) => {
+ const blockers = pr.mergeability?.blockers ?? [];
+ Alert.alert(
+ `Merge #${pr.number}?`,
+ blockers.length ? `Blockers: ${blockers.join(", ")}` : `Squash-merge "${pr.title ?? pr.number}".`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Merge",
+ style: "default",
+ onPress: async () => {
+ setMerging(prKey(pr));
+ try {
+ await merge(pr);
+ } catch (e) {
+ Alert.alert("Merge failed", e instanceof Error ? e.message : "Unknown error");
+ } finally {
+ setMerging(null);
+ }
+ },
+ },
+ ],
+ );
+ };
+
+ if (!configured) {
+ return (
+
+
+
+
+ );
+ }
+
+ const counts = {
+ open: prs.filter((p) => (p.pr.state ?? "open") === "open").length,
+ merged: prs.filter((p) => p.pr.state === "merged").length,
+ all: prs.length,
+ };
+
+ return (
+
+
+ } />
+
+
+
+ {(["open", "merged", "all"] as Filter[]).map((f) => (
+ setFilter(f)}
+ />
+ ))}
+
+
+ }
+ >
+ {filtered.length === 0 ? (
+
+ ) : (
+ filtered.map(({ pr, session }) => (
+ onMerge(pr)}
+ onOpenSession={() =>
+ router.push({
+ pathname: "/session/[id]",
+ params: { id: session.id, projectId: session.projectId },
+ })
+ }
+ />
+ ))
+ )}
+
+
+ );
+}
+
+function PRCard({
+ pr,
+ session,
+ merging,
+ onMerge,
+ onOpenSession,
+}: {
+ pr: DashboardPR;
+ session: DashboardSession;
+ merging: boolean;
+ onMerge: () => void;
+ onOpenSession: () => void;
+}) {
+ const mergeable = pr.mergeability?.mergeable && (pr.state ?? "open") === "open";
+ const ci = pr.ciStatus;
+ const review = pr.reviewDecision;
+
+ return (
+
+
+
+ {pr.repo ? `${pr.owner}/${pr.repo}` : session.projectId}
+
+
+ {pr.state === "merged" ? (
+
+ ) : pr.state === "closed" ? (
+
+ ) : (
+ #{pr.number}
+ )}
+
+
+
+ {pr.title ?? `Pull request #${pr.number}`}
+
+
+
+ {ci && ci !== "none"
+ ? (() => {
+ const c = ciVisual(ci);
+ return ;
+ })()
+ : null}
+ {review === "approved" ? (
+
+ ) : review === "changes_requested" ? (
+
+ ) : null}
+ {pr.additions !== undefined && pr.deletions !== undefined ? (
+
+ +{pr.additions}
+ −{pr.deletions}
+
+ ) : null}
+ {pr.unresolvedThreads ? (
+
+ ) : null}
+
+
+
+
+ {pr.url ? (
+ Linking.openURL(pr.url!)}
+ style={styles.flexBtn}
+ />
+ ) : null}
+ {mergeable ? (
+
+ ) : null}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: { flex: 1, backgroundColor: theme.bgBase },
+ filters: { flexDirection: "row", gap: 8, paddingHorizontal: 16, paddingBottom: 12 },
+ card: {
+ backgroundColor: theme.bgElevated,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: theme.borderSubtle,
+ padding: 14,
+ marginHorizontal: 12,
+ marginVertical: 5,
+ },
+ cardTop: { flexDirection: "row", alignItems: "center", marginBottom: 8 },
+ repo: { color: theme.textTertiary, fontSize: 12, fontFamily: theme.fontMono },
+ num: { color: theme.textSecondary, fontSize: 13, fontWeight: "700", fontFamily: theme.fontMono },
+ title: { color: theme.textPrimary, fontSize: 15, fontWeight: "500", lineHeight: 20 },
+ chips: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 12 },
+ diffChip: { flexDirection: "row", gap: 6, alignItems: "center", paddingHorizontal: 4 },
+ diffText: { fontSize: 11, fontWeight: "700", fontFamily: theme.fontMono },
+ actions: { flexDirection: "row", gap: 8, marginTop: 14 },
+ flexBtn: { flex: 1, paddingVertical: 10 },
+});
diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx
new file mode 100644
index 0000000000..8cc77f2410
--- /dev/null
+++ b/mobile/app/(tabs)/settings.tsx
@@ -0,0 +1,235 @@
+import { Feather } from "@expo/vector-icons";
+import { useEffect, useState } from "react";
+import {
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ Switch,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { pingServer } from "../../lib/api";
+import { DEFAULT_CONFIG, loadConfig, saveConfig, type ServerConfig } from "../../lib/config";
+import { useApp } from "../../lib/store";
+import { theme } from "../../lib/theme";
+import { Button, ConnectionPill, ScreenHeader } from "../../lib/ui";
+
+export default function SettingsScreen() {
+ const insets = useSafeAreaInsets();
+ const { reloadConfig, projects, connection } = useApp();
+ const [cfg, setCfg] = useState(DEFAULT_CONFIG);
+ const [loaded, setLoaded] = useState(false);
+ const [testing, setTesting] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
+
+ useEffect(() => {
+ loadConfig().then((c) => {
+ setCfg(c);
+ setLoaded(true);
+ });
+ }, []);
+
+ const set = (k: keyof ServerConfig) => (v: string) => setCfg((prev) => ({ ...prev, [k]: v }));
+
+ async function test() {
+ setTesting(true);
+ setResult(null);
+ try {
+ await saveConfig(cfg);
+ const count = await pingServer(cfg);
+ setResult({ ok: true, msg: `Connected — ${count} session(s) found.` });
+ await reloadConfig();
+ } catch (e) {
+ setResult({ ok: false, msg: e instanceof Error ? e.message : "Could not reach server." });
+ } finally {
+ setTesting(false);
+ }
+ }
+
+ async function save() {
+ await saveConfig(cfg);
+ await reloadConfig();
+ setSaved(true);
+ setTimeout(() => setSaved(false), 1800);
+ }
+
+ if (!loaded) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ } />
+
+ SERVER
+
+ Point the app at your AO server — your PC's Tailscale name / 100.x address (or LAN IP on the same Wi-Fi).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Use TLS (https / wss)
+ On only if AO is served over HTTPS (e.g. a Tailscale funnel).
+
+ setCfg((prev) => ({ ...prev, secure: v }))}
+ trackColor={{ true: theme.blue, false: theme.borderStrong }}
+ />
+
+
+
+ {result && (
+
+
+ {result.msg}
+
+ )}
+
+
+ PROJECTS
+ {projects.length === 0 ? (
+ No projects found. Add a project from the AO dashboard.
+ ) : (
+ projects.map((p) => (
+
+
+ {p.name}
+ {p.sessionPrefix ? {p.sessionPrefix} : null}
+
+ ))
+ )}
+
+
+ );
+}
+
+function Field(props: {
+ label: string;
+ value: string;
+ onChangeText: (v: string) => void;
+ placeholder?: string;
+ autoCapitalize?: "none" | "sentences";
+ keyboardType?: "default" | "url" | "number-pad";
+}) {
+ return (
+
+ {props.label}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: { flex: 1, backgroundColor: theme.bgBase },
+ center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: theme.bgBase },
+ sectionTitle: { color: theme.textTertiary, fontSize: 11, letterSpacing: 1.2, fontWeight: "700", marginBottom: 10 },
+ intro: { color: theme.textSecondary, fontSize: 13, lineHeight: 19, marginBottom: 18 },
+ field: { marginBottom: 16 },
+ row: { flexDirection: "row" },
+ label: { color: theme.textTertiary, fontSize: 10, letterSpacing: 1, marginBottom: 6, fontWeight: "600" },
+ input: {
+ backgroundColor: theme.bgElevated,
+ borderColor: theme.borderDefault,
+ borderWidth: 1,
+ borderRadius: 10,
+ color: theme.textPrimary,
+ paddingHorizontal: 12,
+ paddingVertical: 12,
+ fontSize: 14,
+ },
+ resultBox: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ marginTop: 12,
+ padding: 12,
+ borderRadius: 10,
+ borderWidth: 1,
+ backgroundColor: theme.bgElevated,
+ },
+ result: { fontSize: 13, lineHeight: 18, flex: 1 },
+ toggleRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ paddingVertical: 6,
+ marginBottom: 8,
+ },
+ toggleLabel: { color: theme.textPrimary, fontSize: 14, fontWeight: "600" },
+ toggleHint: { color: theme.textTertiary, fontSize: 12, marginTop: 2, lineHeight: 16 },
+ projRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 10,
+ paddingVertical: 13,
+ paddingHorizontal: 14,
+ backgroundColor: theme.bgElevated,
+ borderRadius: 10,
+ borderWidth: 1,
+ borderColor: theme.borderSubtle,
+ marginBottom: 8,
+ },
+ projName: { color: theme.textPrimary, fontSize: 14, fontWeight: "600", flex: 1 },
+ projPrefix: { color: theme.textTertiary, fontSize: 12, fontFamily: theme.fontMono },
+});
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
new file mode 100644
index 0000000000..c474fab5be
--- /dev/null
+++ b/mobile/app/_layout.tsx
@@ -0,0 +1,28 @@
+import { Stack } from "expo-router";
+import { StatusBar } from "expo-status-bar";
+import { SafeAreaProvider } from "react-native-safe-area-context";
+import { AppProvider } from "../lib/store";
+import { theme } from "../lib/theme";
+
+export default function RootLayout() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/mobile/app/session/[id].tsx b/mobile/app/session/[id].tsx
new file mode 100644
index 0000000000..41a5310445
--- /dev/null
+++ b/mobile/app/session/[id].tsx
@@ -0,0 +1,657 @@
+import { Feather } from "@expo/vector-icons";
+import { XtermJsWebView, type XtermWebViewHandle } from "@fressh/react-native-xtermjs-webview";
+import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { Alert, Keyboard, Platform, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { killSession, sendMessage } from "../../lib/api";
+import { isConfigured, loadConfig, type ServerConfig } from "../../lib/config";
+import { MuxClient, type MuxStatus } from "../../lib/mux";
+import { theme } from "../../lib/theme";
+
+const FONT_SIZE = 12;
+
+// Injected into the xterm WebView after load. xterm has its own touch handlers
+// that scroll by discrete lines (the janky "1 line per swipe"). We intercept in
+// the CAPTURE phase and stopPropagation so those handlers never fire, then drive
+// the viewport's scrollTop in proportion to finger movement (+ momentum). Taps
+// (no significant movement) are left alone so tap-to-focus / keyboard still work.
+const TERMINAL_ENHANCE_JS = `
+(function () {
+ // The text layer (xterm-screen canvas) captures touches for selection, which
+ // blocks the smooth native scroll. Make it (and the hidden input) transparent
+ // to touch so drags fall through to the viewport's native scroll, and so a tap
+ // can't focus the input (no surprise keyboard).
+ var s = document.createElement('style');
+ s.textContent =
+ '.xterm-screen{pointer-events:none !important;}' +
+ '.xterm-helper-textarea{pointer-events:none !important;}' +
+ '.xterm-viewport{pointer-events:auto !important;-webkit-overflow-scrolling:touch !important;}';
+ document.head.appendChild(s);
+
+ // Report xterm's REAL grid size (measured by the FitAddon from the actual
+ // rendered cell) back to RN through fressh's own debug channel, so RN can tell
+ // the PTY the exact cols/rows xterm is using — no font/DPR guessing.
+ function postDims(sz) {
+ try {
+ var T = window.terminal; if (!T) return;
+ var c = (sz && sz.cols) || T.cols, r = (sz && sz.rows) || T.rows;
+ if (window.ReactNativeWebView && c > 0 && r > 0) {
+ window.ReactNativeWebView.postMessage(
+ JSON.stringify({ type: 'debug', message: 'FRESSH_DIMS ' + c + ' ' + r }));
+ }
+ } catch (_) {}
+ }
+
+ // When the keyboard/rotation resizes the terminal, keep it pinned to the bottom
+ // (latest output) instead of jumping to the top.
+ function pinBottom() { try { window.terminal.scrollToBottom(); } catch (_) {} }
+ (function wire() {
+ if (window.terminal && window.terminal.onResize && window.fitAddon) {
+ window.terminal.onResize(function (sz) { setTimeout(pinBottom, 0); postDims(sz); });
+ // Re-fit whenever the WebView's box changes (keyboard show/hide, rotation).
+ // fit() updates xterm to the real fit; onResize above then reports the dims.
+ try {
+ var host = document.getElementById('terminal') || document.body;
+ var ro = new ResizeObserver(function () {
+ try { window.fitAddon.fit(); } catch (_) {}
+ });
+ ro.observe(host);
+ } catch (_) {}
+ postDims(); // report the initial (boot-fit) dims immediately
+ } else {
+ setTimeout(wire, 200);
+ }
+ })();
+
+ // Keyboard is handled by a React-Native TextInput, NOT the WebView. We disable
+ // the WebView's hidden textarea (see harden) so it can never raise a keyboard
+ // or steal first-responder. The ⌨ button shows/hides the keyboard.
+
+ // Gesture routing (canvas is pointer-events:none, so we read touches here):
+ // • quick drag -> native scroll (we don't preventDefault)
+ // • long-press -> select the line; drag extends by lines; release copies
+ // • single tap -> nothing • double-tap -> focus (keyboard)
+ function term() { return window.terminal; }
+ function lineAt(clientY) {
+ var T = term(), screen = document.querySelector('.xterm-screen');
+ if (!T || !screen) return 0;
+ var r = screen.getBoundingClientRect();
+ var ch = r.height / T.rows;
+ var vis = Math.floor((clientY - r.top) / ch);
+ if (vis < 0) vis = 0; if (vis > T.rows - 1) vis = T.rows - 1;
+ var top = (T.buffer && T.buffer.active) ? T.buffer.active.viewportY : 0;
+ return top + vis;
+ }
+ function copySel() {
+ var T = term(); if (!T) return; var txt = '';
+ try { txt = T.getSelection(); } catch (_) {}
+ if (!txt) return;
+ try { if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt); } catch (_) {}
+ }
+
+ var sX = 0, sY = 0, mode = 'idle', anchor = 0, lpTimer = 0;
+ var MOVE = 10, LONGPRESS = 350;
+ // Android: we drive the viewport's scrollTop directly off finger movement —
+ // its native overflow-scroll doesn't respond to touch reliably in the WebView,
+ // which is why the terminal felt unscrollable there. iOS keeps native momentum.
+ var _vp = null, startScroll = 0;
+ function clearLP() { if (lpTimer) { clearTimeout(lpTimer); lpTimer = 0; } }
+
+ document.addEventListener('touchstart', function (e) {
+ var t = e.touches ? e.touches[0] : e;
+ sX = t.clientX; sY = t.clientY; mode = 'pending';
+ _vp = document.querySelector('.xterm-viewport');
+ startScroll = _vp ? _vp.scrollTop : 0;
+ try { term() && term().clearSelection(); } catch (_) {}
+ clearLP();
+ lpTimer = setTimeout(function () {
+ if (mode !== 'pending') return;
+ mode = 'select'; anchor = lineAt(sY);
+ try { term().selectLines(anchor, anchor); } catch (_) {}
+ }, LONGPRESS);
+ }, { capture: true, passive: true });
+
+ document.addEventListener('touchmove', function (e) {
+ var t = e.touches ? e.touches[0] : e;
+ if (mode === 'pending') {
+ if (Math.abs(t.clientX - sX) > MOVE || Math.abs(t.clientY - sY) > MOVE) {
+ mode = 'scroll'; clearLP();
+ }
+ return;
+ }
+ if (mode === 'scroll') {
+ // Android: move the viewport ourselves, 1:1 with the finger. iOS: leave it
+ // to the viewport's native momentum scroll (don't preventDefault).
+ if (IS_ANDROID && _vp) {
+ _vp.scrollTop = startScroll - (t.clientY - sY);
+ if (e.cancelable) e.preventDefault();
+ }
+ return;
+ }
+ if (mode === 'select') {
+ if (e.cancelable) e.preventDefault(); // stop native scroll while selecting
+ var cur = lineAt(t.clientY);
+ try { term().selectLines(Math.min(anchor, cur), Math.max(anchor, cur)); } catch (_) {}
+ }
+ }, { capture: true, passive: false });
+
+ document.addEventListener('touchend', function () {
+ clearLP();
+ if (mode === 'select') copySel(); // a tap (no move) does nothing
+ mode = 'idle';
+ }, { capture: true, passive: true });
+
+ // Disable the WebView's hidden textarea so it can NEVER show a keyboard or
+ // steal first-responder from the RN input. RN handles all keyboard I/O.
+ function harden() {
+ var t = document.querySelector('.xterm-helper-textarea');
+ if (t) {
+ t.disabled = true;
+ t.setAttribute('inputmode', 'none');
+ t.setAttribute('readonly', 'readonly');
+ t.setAttribute('autocorrect', 'off');
+ t.setAttribute('autocapitalize', 'off');
+ t.setAttribute('autocomplete', 'off');
+ t.setAttribute('spellcheck', 'false');
+ }
+ }
+ harden(); setTimeout(harden, 400); setTimeout(harden, 1500);
+ setInterval(harden, 3000); // keep it disabled if xterm recreates it
+ true;
+})();
+true;
+`;
+
+// Keys a phone keyboard lacks — sent straight to the PTY as escape sequences.
+const EXTRA_KEYS: { label: string; seq: string }[] = [
+ { label: "esc", seq: "\x1b" },
+ { label: "tab", seq: "\t" },
+ { label: "^C", seq: "\x03" },
+ { label: "←", seq: "\x1b[D" },
+ { label: "↑", seq: "\x1b[A" },
+ { label: "↓", seq: "\x1b[B" },
+ { label: "→", seq: "\x1b[C" },
+ { label: "↵", seq: "\r" },
+];
+
+// Named keys a hardware/Bluetooth keyboard emits (key.length > 1) mapped to the
+// bytes the PTY expects. Single-char keys are sent as-is.
+const NAMED_KEYS: Record = {
+ Backspace: "\x7f",
+ Enter: "\r",
+ "\n": "\r",
+ Space: " ",
+ Tab: "\t",
+ Escape: "\x1b",
+ ArrowUp: "\x1b[A",
+ ArrowDown: "\x1b[B",
+ ArrowRight: "\x1b[C",
+ ArrowLeft: "\x1b[D",
+};
+
+const statusLabel: Record = {
+ connecting: "connecting…",
+ open: "live",
+ closed: "disconnected",
+ error: "error",
+};
+const statusColors: Record = {
+ connecting: theme.attention,
+ open: theme.green,
+ closed: theme.textTertiary,
+ error: theme.red,
+};
+
+export default function TerminalScreen() {
+ const params = useLocalSearchParams<{ id: string; projectId?: string }>();
+ const id = String(params.id);
+ const projectId = params.projectId ? String(params.projectId) : undefined;
+ const router = useRouter();
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+
+ const xtermRef = useRef(null);
+ const muxRef = useRef(null);
+ const openedRef = useRef(false);
+ // Last grid size reported by the WebView's FitAddon, so we can send it to the
+ // PTY the moment the terminal opens (dims may arrive before or after open).
+ const lastDimsRef = useRef<{ cols: number; rows: number } | null>(null);
+ // The REAL keyboard input. The WebView can't show/control a keyboard reliably,
+ // so this hidden RN TextInput is what raises the keyboard and captures typing,
+ // which we forward to the PTY over the mux. Focus it to type, blur it to hide.
+ const kbInputRef = useRef(null);
+
+ const [cfg, setCfg] = useState(null);
+ const [status, setStatus] = useState("connecting");
+ const [size, setSize] = useState<{ cols: number; rows: number } | null>(null);
+ const [banner, setBanner] = useState(null);
+ const [kbHeight, setKbHeight] = useState(0); // iOS: space to reserve for keyboard
+ const [kbVisible, setKbVisible] = useState(false); // both platforms
+ const [compose, setCompose] = useState(false); // high-level "send message" bar
+ const [msg, setMsg] = useState("");
+ const [sending, setSending] = useState(false);
+
+ // iOS doesn't resize the layout when the keyboard opens, so the key bar would
+ // hide behind it — reserve kbHeight so the bar rides above the keyboard.
+ // (Android's adjustResize shrinks the window for us, so no height needed there.)
+ useEffect(() => {
+ const isIOS = Platform.OS === "ios";
+ const showEvt = isIOS ? "keyboardWillShow" : "keyboardDidShow";
+ const hideEvt = isIOS ? "keyboardWillHide" : "keyboardDidHide";
+ const show = Keyboard.addListener(showEvt, (e) => {
+ setKbVisible(true);
+ if (isIOS) setKbHeight(e.endCoordinates.height);
+ });
+ const hide = Keyboard.addListener(hideEvt, () => {
+ setKbVisible(false);
+ setKbHeight(0);
+ });
+ // willShow can report a height that still includes the accessory bar we hid,
+ // leaving a gap. didShow reports the actual final frame — use it to correct.
+ const didShow = isIOS ? Keyboard.addListener("keyboardDidShow", (e) => setKbHeight(e.endCoordinates.height)) : null;
+ // Backup: guarantee the reserved space collapses even if willHide is missed.
+ const didHide = Keyboard.addListener("keyboardDidHide", () => {
+ setKbVisible(false);
+ setKbHeight(0);
+ });
+ return () => {
+ show.remove();
+ hide.remove();
+ didShow?.remove();
+ didHide.remove();
+ };
+ }, []);
+
+ // Header shows just the short id; Kill lives in our own status bar below so we
+ // fully control its shape/alignment (iOS draws its own box behind header
+ // buttons, which fights any custom background).
+ useLayoutEffect(() => {
+ navigation.setOptions({
+ title: id.length > 22 ? `${id.slice(0, 20)}…` : id,
+ });
+ }, [navigation, id]);
+
+ // Load config, then connect the mux socket.
+ useEffect(() => {
+ let disposed = false;
+ (async () => {
+ const config = await loadConfig();
+ if (disposed) return;
+ setCfg(config);
+ if (!isConfigured(config)) return;
+
+ const mux = new MuxClient(config, {
+ onStatus: (s) => setStatus(s),
+ onTerminalData: (tid, bytes) => {
+ if (tid === id) xtermRef.current?.write(bytes);
+ },
+ onTerminalExited: (tid, code) => {
+ if (tid === id) setBanner(`Session exited (code ${code})`);
+ },
+ onTerminalError: (tid, msg) => {
+ if (tid === id) setBanner(msg);
+ },
+ });
+ muxRef.current = mux;
+ mux.connect();
+ })();
+ return () => {
+ disposed = true;
+ muxRef.current?.disconnect();
+ muxRef.current = null;
+ };
+ }, [id]);
+
+ // The WebView's FitAddon measures the real cell size and reports the resulting
+ // cols/rows back through fressh's debug→logger.log channel. We forward those
+ // exact dims to the PTY so the display and the PTY always agree, regardless of
+ // font, DPR, or accessibility text scale.
+ const applyDims = useCallback(
+ (cols: number, rows: number) => {
+ lastDimsRef.current = { cols, rows };
+ setSize((prev) => (prev && prev.cols === cols && prev.rows === rows ? prev : { cols, rows }));
+ if (openedRef.current) muxRef.current?.resize(id, cols, rows, projectId);
+ },
+ [id, projectId],
+ );
+
+ // fressh routes WebView {type:'debug'} messages to logger.log(prefix, message).
+ // We piggyback on it for the FRESSH_DIMS report (using a custom onMessage would
+ // clobber fressh's own bridge).
+ const logger = useMemo(
+ () => ({
+ log: (...args: unknown[]) => {
+ const m = args[args.length - 1];
+ if (typeof m === "string" && m.startsWith("FRESSH_DIMS ")) {
+ const parts = m.split(" ");
+ const cols = parseInt(parts[1], 10);
+ const rows = parseInt(parts[2], 10);
+ if (cols > 0 && rows > 0) applyDims(cols, rows);
+ }
+ },
+ }),
+ [applyDims],
+ );
+
+ const onInitialized = useCallback(() => {
+ // Guard against a second open if the WebView re-fires onInitialized (e.g.
+ // remount on orientation change) — that would attach the PTY twice.
+ if (openedRef.current) return;
+ openedRef.current = true;
+ muxRef.current?.openTerminal(id, projectId);
+ // If the FitAddon already reported dims before open, send them to the PTY now.
+ const d = lastDimsRef.current;
+ if (d) muxRef.current?.resize(id, d.cols, d.rows, projectId);
+ }, [id, projectId]);
+
+ const onData = useCallback(
+ (data: string) => {
+ muxRef.current?.sendInput(id, data, projectId);
+ },
+ [id, projectId],
+ );
+
+ const sendKey = useCallback(
+ (seq: string) => {
+ muxRef.current?.sendInput(id, seq, projectId);
+ },
+ [id, projectId],
+ );
+
+ // Show/hide the keyboard by focusing/blurring our RN input (fully reliable,
+ // unlike the WebView's keyboard).
+ const toggleKeyboard = useCallback(() => {
+ if (kbVisible) kbInputRef.current?.blur();
+ else kbInputRef.current?.focus();
+ }, [kbVisible]);
+
+ // Each key press in the hidden input -> the matching byte(s) to the PTY.
+ const onKeyPress = useCallback(
+ (e: { nativeEvent: { key: string } }) => {
+ const k = e.nativeEvent.key;
+ const seq = NAMED_KEYS[k] ?? (k.length === 1 ? k : null);
+ if (seq !== null) muxRef.current?.sendInput(id, seq, projectId);
+ },
+ [id, projectId],
+ );
+
+ // High-level message to the agent (AO's /send) — distinct from raw keystrokes.
+ const sendPrompt = useCallback(async () => {
+ const text = msg.trim();
+ if (!text) return;
+ setSending(true);
+ try {
+ const config = cfg ?? (await loadConfig());
+ await sendMessage(config, id, text);
+ setMsg("");
+ setCompose(false);
+ } catch (e) {
+ setBanner(`Send failed: ${e instanceof Error ? e.message : String(e)}`);
+ } finally {
+ setSending(false);
+ }
+ }, [msg, cfg, id]);
+
+ const confirmKill = useCallback(() => {
+ const doKill = async () => {
+ try {
+ const config = cfg ?? (await loadConfig());
+ await killSession(config, id);
+ router.back();
+ } catch (e) {
+ setBanner(`Kill failed: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ };
+ if (Platform.OS === "web") {
+ doKill();
+ return;
+ }
+ Alert.alert("Kill session?", `This stops ${id}.`, [
+ { text: "Cancel", style: "cancel" },
+ { text: "Kill", style: "destructive", onPress: doKill },
+ ]);
+ }, [cfg, id, router]);
+
+ const xtermOptions = useMemo(
+ () => ({
+ fontSize: FONT_SIZE,
+ cursorBlink: true,
+ scrollback: 5000,
+ // Move more rows per swipe so touch scrolling feels responsive.
+ scrollSensitivity: 3,
+ fastScrollSensitivity: 8,
+ theme: {
+ background: theme.term,
+ foreground: theme.textPrimary,
+ cursor: theme.orange,
+ },
+ }),
+ [],
+ );
+
+ const webViewOptions = useMemo(
+ () => ({
+ // Removes the extra "< > Done" / autofill bar iOS shows above the keyboard.
+ hideKeyboardAccessoryView: true,
+ // Custom drag/momentum scroll + input hardening (see TERMINAL_ENHANCE_JS).
+ // Prepend the platform flag the enhance script branches on for scrolling.
+ injectedJavaScript: `var IS_ANDROID=${Platform.OS === "android"};\n${TERMINAL_ENHANCE_JS}`,
+ androidLayerType: "hardware" as const,
+ nestedScrollEnabled: true,
+ }),
+ [],
+ );
+
+ if (cfg && !isConfigured(cfg)) {
+ return (
+
+ No server configured.
+
+ );
+ }
+
+ // The composer and key bar sit directly atop each other, so they share one
+ // bottom inset: reserve room above the keyboard, else the home-indicator inset.
+ const bottomPad = kbHeight > 0 ? 8 : insets.bottom > 0 ? insets.bottom : 8;
+
+ return (
+
+ {}}
+ blurOnSubmit={false}
+ multiline={false}
+ autoCapitalize="none"
+ autoCorrect={false}
+ autoComplete="off"
+ spellCheck={false}
+ keyboardAppearance="dark"
+ caretHidden
+ style={styles.kbInput}
+ />
+
+
+ {statusLabel[status]}
+ {size && (
+
+ {size.cols}×{size.rows}
+
+ )}
+ [styles.killBtn, pressed && { opacity: 0.7 }]}
+ >
+
+ Kill
+
+
+
+ {banner && (
+ setBanner(null)} style={styles.banner}>
+ {banner} (tap to dismiss)
+
+ )}
+
+
+
+
+
+ {compose && (
+
+
+ [styles.sendBtn, pressed && { opacity: 0.8 }, !msg.trim() && { opacity: 0.4 }]}
+ onPress={sendPrompt}
+ disabled={!msg.trim() || sending}
+ >
+
+
+
+ )}
+
+
+ {EXTRA_KEYS.map((k) => (
+ [styles.key, pressed && styles.keyPressed]}
+ onPress={() => sendKey(k.seq)}
+ >
+ {k.label}
+
+ ))}
+ {/* Compose a high-level message to the agent. */}
+ [styles.key, compose && styles.keyToggle, pressed && styles.keyPressed]}
+ onPress={() => setCompose((c) => !c)}
+ >
+
+
+ {/* Show/hide the keyboard (replaces the OS "Done" button we removed). */}
+ [styles.key, styles.keyToggle, pressed && styles.keyPressed]}
+ onPress={toggleKeyboard}
+ >
+ {kbVisible ? "⌨▾" : "⌨▴"}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: { flex: 1, backgroundColor: theme.bgBase },
+ center: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: theme.bgBase,
+ },
+ statusBar: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 14,
+ paddingVertical: 6,
+ borderBottomWidth: 1,
+ borderBottomColor: theme.borderSubtle,
+ },
+ statusDot: { width: 8, height: 8, borderRadius: 4, marginRight: 8 },
+ statusText: { color: theme.textSecondary, fontSize: 12, flex: 1 },
+ dims: { color: theme.textTertiary, fontSize: 11, fontFamily: theme.fontMono },
+ banner: {
+ backgroundColor: theme.bgElevated,
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ borderBottomWidth: 1,
+ borderBottomColor: theme.borderDefault,
+ },
+ bannerText: { color: theme.attention, fontSize: 12 },
+ termWrap: { flex: 1, backgroundColor: theme.bgBase },
+ keys: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: 6,
+ paddingHorizontal: 8,
+ paddingTop: 8,
+ borderTopWidth: 1,
+ borderTopColor: theme.borderSubtle,
+ backgroundColor: theme.bgSurface,
+ },
+ key: {
+ backgroundColor: theme.bgElevated,
+ borderWidth: 1,
+ borderColor: theme.borderDefault,
+ borderRadius: 6,
+ paddingVertical: 8,
+ paddingHorizontal: 12,
+ minWidth: 44,
+ alignItems: "center",
+ },
+ keyPressed: { backgroundColor: theme.accentTint, borderColor: theme.accent },
+ keyToggle: { borderColor: theme.accent, marginLeft: "auto" },
+ kbInput: { position: "absolute", width: 1, height: 1, top: 0, left: 0, opacity: 0 },
+ keyText: { color: theme.textPrimary, fontFamily: theme.fontMono, fontSize: 14 },
+ killBtn: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 4,
+ backgroundColor: theme.tintRed,
+ borderRadius: 12,
+ paddingHorizontal: 11,
+ paddingVertical: 4,
+ marginLeft: 12,
+ },
+ killText: { color: theme.red, fontWeight: "700", fontSize: 12 },
+ composer: {
+ flexDirection: "row",
+ alignItems: "flex-end",
+ gap: 8,
+ paddingHorizontal: 10,
+ paddingTop: 8,
+ backgroundColor: theme.bgSurface,
+ borderTopWidth: 1,
+ borderTopColor: theme.borderSubtle,
+ },
+ composerInput: {
+ flex: 1,
+ backgroundColor: theme.bgElevated,
+ borderWidth: 1,
+ borderColor: theme.borderDefault,
+ borderRadius: 10,
+ color: theme.textPrimary,
+ paddingHorizontal: 12,
+ paddingVertical: 9,
+ fontSize: 14,
+ maxHeight: 110,
+ },
+ sendBtn: {
+ width: 40,
+ height: 40,
+ borderRadius: 10,
+ backgroundColor: theme.blue,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+});
diff --git a/mobile/app/spawn.tsx b/mobile/app/spawn.tsx
new file mode 100644
index 0000000000..567113f2a0
--- /dev/null
+++ b/mobile/app/spawn.tsx
@@ -0,0 +1,98 @@
+import { useRouter } from "expo-router";
+import { useEffect, useState } from "react";
+import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
+import { useApp } from "../lib/store";
+import { theme } from "../lib/theme";
+import { Button, Pill } from "../lib/ui";
+
+export default function SpawnModal() {
+ const router = useRouter();
+ const { projects, activeProjectId, spawn } = useApp();
+ const [projectId, setProjectId] = useState(null);
+ const [prompt, setPrompt] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Default to the active project, else the only project.
+ useEffect(() => {
+ if (projectId) return;
+ if (activeProjectId !== "all") setProjectId(activeProjectId);
+ else if (projects.length === 1) setProjectId(projects[0].id);
+ }, [activeProjectId, projects, projectId]);
+
+ const onSpawn = async () => {
+ if (!projectId) {
+ setError("Pick a project first.");
+ return;
+ }
+ setBusy(true);
+ setError(null);
+ try {
+ await spawn(prompt.trim() || undefined, projectId);
+ router.back();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to spawn agent.");
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ Spawn a worker agent. It gets its own git worktree and branch, then starts on the task you give it.
+
+
+ PROJECT
+
+ {projects.map((p) => (
+ setProjectId(p.id)} />
+ ))}
+
+
+ TASK (OPTIONAL)
+
+
+ {error ? {error} : null}
+
+
+ router.back()} style={{ marginTop: 10 }} />
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: { flex: 1, backgroundColor: theme.bgBase },
+ lead: { color: theme.textSecondary, fontSize: 14, lineHeight: 20, marginBottom: 22 },
+ label: { color: theme.textTertiary, fontSize: 10, letterSpacing: 1, fontWeight: "700", marginBottom: 10 },
+ projects: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
+ input: {
+ backgroundColor: theme.bgElevated,
+ borderColor: theme.borderDefault,
+ borderWidth: 1,
+ borderRadius: 10,
+ color: theme.textPrimary,
+ paddingHorizontal: 12,
+ paddingVertical: 12,
+ fontSize: 14,
+ minHeight: 96,
+ textAlignVertical: "top",
+ },
+ error: { color: theme.red, fontSize: 13, marginTop: 14 },
+});
diff --git a/mobile/assets/android-icon-foreground.png b/mobile/assets/android-icon-foreground.png
new file mode 100644
index 0000000000..fb773b69c1
Binary files /dev/null and b/mobile/assets/android-icon-foreground.png differ
diff --git a/mobile/assets/favicon.png b/mobile/assets/favicon.png
new file mode 100644
index 0000000000..d95cfe7503
Binary files /dev/null and b/mobile/assets/favicon.png differ
diff --git a/mobile/assets/icon.png b/mobile/assets/icon.png
new file mode 100644
index 0000000000..afca5fdcfa
Binary files /dev/null and b/mobile/assets/icon.png differ
diff --git a/mobile/assets/mascot.png b/mobile/assets/mascot.png
new file mode 100644
index 0000000000..35490a8e75
Binary files /dev/null and b/mobile/assets/mascot.png differ
diff --git a/mobile/assets/splash-icon.png b/mobile/assets/splash-icon.png
new file mode 100644
index 0000000000..7e68c3bdaf
Binary files /dev/null and b/mobile/assets/splash-icon.png differ
diff --git a/mobile/eas.json b/mobile/eas.json
new file mode 100644
index 0000000000..772b82780e
--- /dev/null
+++ b/mobile/eas.json
@@ -0,0 +1,27 @@
+{
+ "cli": {
+ "version": ">= 20.0.0",
+ "appVersionSource": "remote"
+ },
+ "build": {
+ "development": {
+ "developmentClient": true,
+ "distribution": "internal"
+ },
+ "preview": {
+ "distribution": "internal",
+ "android": {
+ "buildType": "apk"
+ }
+ },
+ "production": {
+ "autoIncrement": true,
+ "android": {
+ "buildType": "app-bundle"
+ }
+ }
+ },
+ "submit": {
+ "production": {}
+ }
+}
diff --git a/mobile/images.d.ts b/mobile/images.d.ts
new file mode 100644
index 0000000000..a2f367a590
--- /dev/null
+++ b/mobile/images.d.ts
@@ -0,0 +1,6 @@
+// Static image imports (Metro resolves these to an asset reference at runtime,
+// which React Native's accepts as a number).
+declare module "*.png" {
+ const content: number;
+ export default content;
+}
diff --git a/mobile/lib/ProjectSwitcher.tsx b/mobile/lib/ProjectSwitcher.tsx
new file mode 100644
index 0000000000..bcf4fc0bdf
--- /dev/null
+++ b/mobile/lib/ProjectSwitcher.tsx
@@ -0,0 +1,30 @@
+import { ScrollView, StyleSheet } from "react-native";
+import { useApp } from "./store";
+import { Pill } from "./ui";
+
+// Horizontal pill row to scope the view to one project (or All). Only renders
+// when there's more than one project — single-project users never see clutter.
+export function ProjectSwitcher() {
+ const { projects, activeProjectId, setActiveProject } = useApp();
+ if (projects.length <= 1) return null;
+
+ const items = [{ id: "all", name: "All" }, ...projects];
+
+ return (
+
+ {items.map((p) => (
+ setActiveProject(p.id)} />
+ ))}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ scroll: { flexGrow: 0 },
+ row: { paddingHorizontal: 16, paddingBottom: 12, gap: 8, alignItems: "center" },
+});
diff --git a/mobile/lib/SessionCard.tsx b/mobile/lib/SessionCard.tsx
new file mode 100644
index 0000000000..dafd93b1f5
--- /dev/null
+++ b/mobile/lib/SessionCard.tsx
@@ -0,0 +1,105 @@
+import { Feather } from "@expo/vector-icons";
+import { useRouter } from "expo-router";
+import { Pressable, StyleSheet, Text, View } from "react-native";
+import { sessionTitle, type DashboardSession } from "./api";
+import { ciColor, statusVisual, theme } from "./theme";
+import { Dot } from "./ui";
+
+export function SessionCard({ session, showProject = false }: { session: DashboardSession; showProject?: boolean }) {
+ const router = useRouter();
+ const v = statusVisual(session.status);
+ const pr = session.pr ?? session.prs?.[0];
+ const title = sessionTitle(session);
+
+ return (
+
+ router.push({
+ pathname: "/session/[id]",
+ params: { id: session.id, projectId: session.projectId },
+ })
+ }
+ style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
+ >
+
+
+ {v.label}
+
+ {showProject ? {session.projectId} : null}
+ {session.id}
+
+
+
+ {title}
+
+
+
+ {session.branch ? (
+
+
+
+ {session.branch}
+
+
+ ) : null}
+ {pr?.number ? (
+
+
+ #{pr.number}
+ {pr.additions !== undefined && pr.deletions !== undefined ? (
+
+ +{pr.additions}{" "}
+ −{pr.deletions}
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ card: {
+ backgroundColor: theme.bgElevated,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: theme.borderSubtle,
+ paddingHorizontal: 14,
+ paddingVertical: 13,
+ marginHorizontal: 12,
+ marginVertical: 5,
+ },
+ cardPressed: { backgroundColor: theme.bgElevatedHover, borderColor: theme.borderDefault },
+ top: { flexDirection: "row", alignItems: "center", gap: 6, marginBottom: 8 },
+ status: { fontSize: 12, fontWeight: "600" },
+ project: {
+ color: theme.textTertiary,
+ fontSize: 11,
+ fontFamily: theme.fontMono,
+ marginRight: 8,
+ },
+ id: { color: theme.textTertiary, fontSize: 11, fontFamily: theme.fontMono },
+ title: { color: theme.textPrimary, fontSize: 15, fontWeight: "500", lineHeight: 20 },
+ meta: { flexDirection: "row", alignItems: "center", gap: 10, marginTop: 10 },
+ metaItem: { flexDirection: "row", alignItems: "center", gap: 4, flexShrink: 1 },
+ branch: {
+ color: theme.textTertiary,
+ fontSize: 12,
+ fontFamily: theme.fontMono,
+ flexShrink: 1,
+ },
+ prChip: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 5,
+ paddingHorizontal: 7,
+ paddingVertical: 3,
+ borderRadius: 6,
+ borderWidth: 1,
+ },
+ prText: { color: theme.textSecondary, fontSize: 11, fontWeight: "700", fontFamily: theme.fontMono },
+ diff: { fontSize: 10, fontFamily: theme.fontMono },
+});
diff --git a/mobile/lib/api.ts b/mobile/lib/api.ts
new file mode 100644
index 0000000000..28ab3bc937
--- /dev/null
+++ b/mobile/lib/api.ts
@@ -0,0 +1,244 @@
+import { httpBase, type ServerConfig } from "./config";
+import type { AttentionLevel } from "./theme";
+
+// ---- Types (subset of AO's DashboardSession we use on the phone) ------------
+
+export type DashboardPR = {
+ number: number;
+ url: string;
+ title?: string;
+ owner?: string;
+ repo?: string;
+ branch?: string;
+ baseBranch?: string;
+ isDraft?: boolean;
+ state?: "open" | "merged" | "closed";
+ additions?: number;
+ deletions?: number;
+ changedFiles?: number;
+ ciStatus?: "pending" | "passing" | "failing" | "none";
+ reviewDecision?: "approved" | "changes_requested" | "pending" | "none";
+ mergeability?: {
+ mergeable?: boolean;
+ ciPassing?: boolean;
+ approved?: boolean;
+ noConflicts?: boolean;
+ blockers?: string[];
+ };
+ unresolvedThreads?: number;
+};
+
+export type DashboardSession = {
+ id: string;
+ projectId: string;
+ status: string | null;
+ attentionLevel?: AttentionLevel | string | null;
+ activity?: string | null;
+ branch: string | null;
+ issueId: string | null;
+ issueUrl?: string | null;
+ issueLabel?: string | null;
+ issueTitle: string | null;
+ userPrompt: string | null;
+ displayName: string | null;
+ summary: string | null;
+ createdAt: string;
+ lastActivityAt: string;
+ pr?: DashboardPR | null;
+ prs?: DashboardPR[];
+ metadata?: Record;
+};
+
+export type OrchestratorLink = {
+ id: string;
+ projectId: string;
+ projectName: string;
+ status?: string | null;
+ activity?: string | null;
+ runtimeState?: string | null;
+ hasRuntime?: boolean;
+ isTerminal?: boolean;
+ isRestorable?: boolean;
+};
+
+export type ProjectInfo = {
+ id: string;
+ name: string;
+ sessionPrefix?: string;
+};
+
+export type DashboardStats = {
+ totalSessions?: number;
+ workingSessions?: number;
+ openPRs?: number;
+ needsReview?: number;
+};
+
+export type SessionsResponse = {
+ sessions: DashboardSession[];
+ orchestrators: OrchestratorLink[];
+ orchestratorId: string | null;
+ stats: DashboardStats;
+};
+
+// ---- Low-level fetch with friendly errors ----------------------------------
+
+const REQUEST_TIMEOUT_MS = 12000;
+
+async function req(cfg: ServerConfig, path: string, init?: RequestInit): Promise {
+ const url = `${httpBase(cfg)}${path}`;
+ // Without a timeout a sleeping/unreachable host (common over Tailscale) hangs
+ // the call for the OS TCP timeout (~75-120s), freezing Kill/send and the poll.
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
+ let res: Response;
+ try {
+ res = await fetch(url, {
+ ...init,
+ signal: controller.signal,
+ headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
+ });
+ } catch (e) {
+ if ((e as { name?: string })?.name === "AbortError") {
+ throw new Error("Request timed out — is the server reachable?", { cause: e });
+ }
+ throw e;
+ } finally {
+ clearTimeout(timer);
+ }
+ if (!res.ok) {
+ let detail = "";
+ try {
+ detail = (await res.json())?.error ?? "";
+ } catch {
+ /* ignore */
+ }
+ throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
+ }
+ return res;
+}
+
+// ---- Reads ------------------------------------------------------------------
+
+export async function getProjects(cfg: ServerConfig): Promise {
+ const res = await req(cfg, "/api/projects");
+ const data = await res.json();
+ return Array.isArray(data?.projects) ? data.projects : [];
+}
+
+export async function getSessions(cfg: ServerConfig, projectId?: string): Promise {
+ const q = projectId && projectId !== "all" ? `?project=${encodeURIComponent(projectId)}` : "?project=all";
+ const res = await req(cfg, `/api/sessions${q}`);
+ const data = await res.json();
+ return {
+ sessions: Array.isArray(data?.sessions) ? data.sessions : [],
+ orchestrators: Array.isArray(data?.orchestrators) ? data.orchestrators : [],
+ orchestratorId: data?.orchestratorId ?? null,
+ stats: data?.stats ?? {},
+ };
+}
+
+// ---- Writes / actions -------------------------------------------------------
+
+export async function killSession(cfg: ServerConfig, id: string): Promise {
+ await req(cfg, `/api/sessions/${encodeURIComponent(id)}/kill`, { method: "POST" });
+}
+
+export async function restoreSession(cfg: ServerConfig, id: string): Promise {
+ await req(cfg, `/api/sessions/${encodeURIComponent(id)}/restore`, { method: "POST" });
+}
+
+export async function sendMessage(cfg: ServerConfig, id: string, message: string): Promise {
+ await req(cfg, `/api/sessions/${encodeURIComponent(id)}/send`, {
+ method: "POST",
+ body: JSON.stringify({ message }),
+ });
+}
+
+export async function spawnSession(
+ cfg: ServerConfig,
+ opts: { projectId: string; prompt?: string; issueId?: string },
+): Promise {
+ const res = await req(cfg, "/api/spawn", {
+ method: "POST",
+ body: JSON.stringify(opts),
+ });
+ const data = await res.json();
+ return data?.session;
+}
+
+export async function launchOrchestrator(
+ cfg: ServerConfig,
+ projectId: string,
+ clean = false,
+): Promise {
+ const res = await req(cfg, "/api/orchestrators", {
+ method: "POST",
+ body: JSON.stringify({ projectId, clean }),
+ });
+ const data = await res.json();
+ return data?.orchestrator;
+}
+
+export async function mergePR(cfg: ServerConfig, pr: DashboardPR): Promise {
+ const params: string[] = [];
+ if (pr.owner) params.push(`owner=${encodeURIComponent(pr.owner)}`);
+ if (pr.repo) params.push(`repo=${encodeURIComponent(pr.repo)}`);
+ const q = params.length ? `?${params.join("&")}` : "";
+ await req(cfg, `/api/prs/${pr.number}/merge${q}`, { method: "POST" });
+}
+
+// Quick reachability probe for the Settings "Test connection" button.
+export async function pingServer(cfg: ServerConfig): Promise {
+ const res = await req(cfg, "/api/sessions?project=all");
+ const data = await res.json();
+ return Array.isArray(data?.sessions) ? data.sessions.length : 0;
+}
+
+// ---- Derived helpers --------------------------------------------------------
+
+const TERMINAL_STATUSES = new Set(["killed", "terminated", "done", "cleanup", "errored", "merged"]);
+
+export function isTerminalStatus(status?: string | null): boolean {
+ return !!status && TERMINAL_STATUSES.has(status);
+}
+
+// Fallback attention bucket when the server didn't compute attentionLevel.
+export function attentionOf(s: DashboardSession): AttentionLevel {
+ if (s.attentionLevel) return s.attentionLevel as AttentionLevel;
+ const pr = s.pr ?? s.prs?.[0];
+ if (s.status === "merged" || s.status === "done" || isTerminalStatus(s.status)) return "done";
+ if (pr?.mergeability?.mergeable || s.status === "mergeable" || s.status === "approved") return "merge";
+ if (s.status === "needs_input" || s.status === "stuck" || s.status === "errored") return "respond";
+ if (
+ pr?.ciStatus === "failing" ||
+ pr?.reviewDecision === "changes_requested" ||
+ s.status === "ci_failed" ||
+ s.status === "changes_requested"
+ )
+ return "review";
+ if (s.status === "pr_open" || s.status === "review_pending") return "pending";
+ return "working";
+}
+
+export function sessionTitle(s: DashboardSession): string {
+ return s.displayName || s.issueTitle || s.userPrompt || s.summary || s.id;
+}
+
+// All PRs across sessions, de-duplicated by number+repo.
+export function collectPRs(sessions: DashboardSession[]): { pr: DashboardPR; session: DashboardSession }[] {
+ const seen = new Set();
+ const out: { pr: DashboardPR; session: DashboardSession }[] = [];
+ for (const s of sessions) {
+ const list = s.prs && s.prs.length ? s.prs : s.pr ? [s.pr] : [];
+ for (const pr of list) {
+ // Real GitHub/GitLab PR numbers are >= 1; 0/missing signals a placeholder.
+ if (!pr || !pr.number || pr.number <= 0) continue;
+ const key = `${pr.owner ?? ""}/${pr.repo ?? ""}#${pr.number}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push({ pr, session: s });
+ }
+ }
+ return out;
+}
diff --git a/mobile/lib/config.ts b/mobile/lib/config.ts
new file mode 100644
index 0000000000..923dea4221
--- /dev/null
+++ b/mobile/lib/config.ts
@@ -0,0 +1,75 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { useCallback, useEffect, useState } from "react";
+
+// The user points the app at their AO server (over Tailscale). We store just the
+// host + ports; HTTP and WS URLs are derived from them.
+export type ServerConfig = {
+ host: string; // e.g. "100.101.102.103" or "my-pc.tail1234.ts.net"
+ httpPort: string; // AO Next.js REST API, default 3000
+ muxPort: string; // AO direct-terminal-ws mux, default 14801
+ secure?: boolean; // use https/wss instead of http/ws (TLS / Tailscale funnel)
+};
+
+export const DEFAULT_CONFIG: ServerConfig = {
+ host: "",
+ httpPort: "3000",
+ muxPort: "14801",
+ secure: false,
+};
+
+// Strip a pasted scheme (http://, ws://, …) and trailing slashes so we never
+// build a double-scheme URL like "http://https://host".
+function cleanHost(host: string): string {
+ return host
+ .trim()
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")
+ .replace(/\/+$/, "");
+}
+
+const KEY = "ao.serverConfig";
+
+export async function loadConfig(): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(KEY);
+ if (!raw) return DEFAULT_CONFIG;
+ return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
+ } catch {
+ return DEFAULT_CONFIG;
+ }
+}
+
+export async function saveConfig(cfg: ServerConfig): Promise {
+ await AsyncStorage.setItem(KEY, JSON.stringify(cfg));
+}
+
+export function httpBase(cfg: ServerConfig): string {
+ return `${cfg.secure ? "https" : "http"}://${cleanHost(cfg.host)}:${cfg.httpPort}`;
+}
+
+export function muxUrl(cfg: ServerConfig): string {
+ return `${cfg.secure ? "wss" : "ws"}://${cleanHost(cfg.host)}:${cfg.muxPort}/mux`;
+}
+
+export function isConfigured(cfg: ServerConfig): boolean {
+ return cleanHost(cfg.host).length > 0;
+}
+
+// Small reactive hook so screens re-render when the config changes.
+export function useServerConfig() {
+ const [config, setConfig] = useState(null);
+
+ const reload = useCallback(async () => {
+ setConfig(await loadConfig());
+ }, []);
+
+ useEffect(() => {
+ reload();
+ }, [reload]);
+
+ const update = useCallback(async (cfg: ServerConfig) => {
+ await saveConfig(cfg);
+ setConfig(cfg);
+ }, []);
+
+ return { config, update, reload };
+}
diff --git a/mobile/lib/mux.ts b/mobile/lib/mux.ts
new file mode 100644
index 0000000000..f26a8a7cc7
--- /dev/null
+++ b/mobile/lib/mux.ts
@@ -0,0 +1,219 @@
+import { muxUrl, type ServerConfig } from "./config";
+
+// Mirrors AO's mux-protocol.ts (the bits we use).
+export type SessionPatch = {
+ id: string;
+ status: string;
+ activity: string | null;
+ attentionLevel: string;
+ lastActivityAt: string;
+};
+
+export type MuxStatus = "connecting" | "open" | "closed" | "error";
+
+type Handlers = {
+ onStatus?: (s: MuxStatus, detail?: string) => void;
+ onTerminalData?: (id: string, bytes: Uint8Array) => void;
+ onTerminalOpened?: (id: string) => void;
+ onTerminalExited?: (id: string, code: number) => void;
+ onTerminalError?: (id: string, message: string) => void;
+ onSessions?: (sessions: SessionPatch[]) => void;
+};
+
+// Encode a JS string (already UTF-8 decoded by the server) back to UTF-8 bytes
+// for xterm. Prefer the native TextEncoder; fall back to a manual encoder if a
+// runtime ever lacks it, so the terminal never hard-crashes on a missing global.
+const nativeEncoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
+
+function utf8Encode(str: string): Uint8Array {
+ if (nativeEncoder) return nativeEncoder.encode(str);
+ const out: number[] = [];
+ for (let i = 0; i < str.length; i++) {
+ let c = str.charCodeAt(i);
+ if (c < 0x80) {
+ out.push(c);
+ } else if (c < 0x800) {
+ out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
+ } else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) {
+ const c2 = str.charCodeAt(++i);
+ c = 0x10000 + ((c & 0x3ff) << 10) + (c2 & 0x3ff);
+ out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
+ } else {
+ out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
+ }
+ }
+ return new Uint8Array(out);
+}
+
+/**
+ * Thin client over AO's mux WebSocket. One socket multiplexes session-status
+ * snapshots and per-session terminal I/O. Auto-reconnects with backoff.
+ */
+export class MuxClient {
+ private ws: WebSocket | null = null;
+ private cfg: ServerConfig;
+ private handlers: Handlers;
+ private closedByUser = false;
+ private reconnectTimer: ReturnType | null = null;
+ private pingTimer: ReturnType | null = null;
+ private backoff = 1000;
+ // Terminals we want open, so we can re-open them after a reconnect. Maps the
+ // session id -> its projectId so the re-open carries projectId too (the server
+ // may need it to locate the right session across projects).
+ private openTerminals = new Map();
+ private subscribed = false;
+
+ constructor(cfg: ServerConfig, handlers: Handlers) {
+ this.cfg = cfg;
+ this.handlers = handlers;
+ }
+
+ connect() {
+ this.closedByUser = false;
+ this.open();
+ }
+
+ private open() {
+ this.handlers.onStatus?.("connecting");
+ let ws: WebSocket;
+ try {
+ ws = new WebSocket(muxUrl(this.cfg));
+ } catch (e) {
+ this.handlers.onStatus?.("error", String(e));
+ this.scheduleReconnect();
+ return;
+ }
+ this.ws = ws;
+
+ ws.onopen = () => {
+ this.backoff = 1000;
+ this.handlers.onStatus?.("open");
+ if (this.subscribed) this.send({ ch: "subscribe", topics: ["sessions", "notifications"] });
+ // Re-open any terminals that were active before a reconnect (with projectId).
+ for (const [id, projectId] of this.openTerminals) {
+ this.send({ ch: "terminal", id, type: "open", projectId });
+ }
+ this.pingTimer = setInterval(() => {
+ this.send({ ch: "system", type: "ping" });
+ }, 20000);
+ };
+
+ ws.onmessage = (ev) => {
+ let msg: unknown;
+ try {
+ msg = JSON.parse(typeof ev.data === "string" ? ev.data : "");
+ } catch {
+ return;
+ }
+ this.handle(msg);
+ };
+
+ ws.onerror = () => {
+ this.handlers.onStatus?.("error");
+ };
+
+ ws.onclose = () => {
+ this.clearPing();
+ if (this.closedByUser) {
+ this.handlers.onStatus?.("closed");
+ return;
+ }
+ this.handlers.onStatus?.("closed");
+ this.scheduleReconnect();
+ };
+ }
+
+ private handle(raw: unknown) {
+ if (!raw || typeof raw !== "object") return;
+ const msg = raw as {
+ ch?: string;
+ type?: string;
+ sessions?: SessionPatch[];
+ id?: string;
+ data?: string;
+ code?: number;
+ message?: string;
+ };
+ if (msg.ch === "sessions" && msg.type === "snapshot") {
+ this.handlers.onSessions?.(msg.sessions ?? []);
+ } else if (msg.ch === "terminal") {
+ const id = msg.id ?? "";
+ switch (msg.type) {
+ case "data":
+ this.handlers.onTerminalData?.(id, utf8Encode(String(msg.data ?? "")));
+ break;
+ case "opened":
+ this.handlers.onTerminalOpened?.(id);
+ break;
+ case "exited":
+ this.handlers.onTerminalExited?.(id, msg.code ?? 0);
+ break;
+ case "error":
+ this.handlers.onTerminalError?.(id, msg.message ?? "terminal error");
+ break;
+ }
+ }
+ }
+
+ private scheduleReconnect() {
+ if (this.closedByUser) return;
+ this.clearReconnect();
+ this.reconnectTimer = setTimeout(() => this.open(), this.backoff);
+ this.backoff = Math.min(this.backoff * 2, 15000);
+ }
+
+ private clearReconnect() {
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ }
+
+ private clearPing() {
+ if (this.pingTimer) {
+ clearInterval(this.pingTimer);
+ this.pingTimer = null;
+ }
+ }
+
+ private send(obj: unknown) {
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(obj));
+ }
+ }
+
+ subscribeSessions() {
+ this.subscribed = true;
+ this.send({ ch: "subscribe", topics: ["sessions", "notifications"] });
+ }
+
+ openTerminal(id: string, projectId?: string) {
+ this.openTerminals.set(id, projectId);
+ this.send({ ch: "terminal", id, type: "open", projectId });
+ }
+
+ sendInput(id: string, data: string, projectId?: string) {
+ this.send({ ch: "terminal", id, type: "data", data, projectId });
+ }
+
+ resize(id: string, cols: number, rows: number, projectId?: string) {
+ this.send({ ch: "terminal", id, type: "resize", cols, rows, projectId });
+ }
+
+ closeTerminal(id: string, projectId?: string) {
+ this.openTerminals.delete(id);
+ this.send({ ch: "terminal", id, type: "close", projectId });
+ }
+
+ disconnect() {
+ this.closedByUser = true;
+ this.clearReconnect();
+ this.clearPing();
+ try {
+ this.ws?.close();
+ } catch {
+ /* ignore */
+ }
+ this.ws = null;
+ }
+}
diff --git a/mobile/lib/store.tsx b/mobile/lib/store.tsx
new file mode 100644
index 0000000000..5a716c76da
--- /dev/null
+++ b/mobile/lib/store.tsx
@@ -0,0 +1,331 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import {
+ collectPRs,
+ getProjects,
+ getSessions,
+ killSession,
+ launchOrchestrator as apiLaunchOrchestrator,
+ mergePR as apiMergePR,
+ restoreSession,
+ sendMessage,
+ spawnSession,
+ type DashboardPR,
+ type DashboardSession,
+ type DashboardStats,
+ type OrchestratorLink,
+ type ProjectInfo,
+} from "./api";
+import { isConfigured, loadConfig, type ServerConfig } from "./config";
+import { MuxClient, type MuxStatus, type SessionPatch } from "./mux";
+
+const ACTIVE_PROJECT_KEY = "ao.activeProject";
+
+type AppState = {
+ config: ServerConfig | null;
+ configured: boolean;
+ projects: ProjectInfo[];
+ sessions: DashboardSession[];
+ orchestrators: OrchestratorLink[];
+ orchestratorId: string | null;
+ stats: DashboardStats;
+ activeProjectId: string; // 'all' or a projectId
+ connection: MuxStatus;
+ loading: boolean;
+ error: string | null;
+ // actions
+ reloadConfig: () => Promise;
+ refresh: () => Promise;
+ setActiveProject: (id: string) => void;
+ spawn: (prompt?: string, projectId?: string) => Promise;
+ launchConductor: (projectId: string, clean?: boolean) => Promise;
+ merge: (pr: DashboardPR) => Promise;
+ kill: (id: string) => Promise;
+ restore: (id: string) => Promise;
+ send: (id: string, message: string) => Promise;
+};
+
+const AppContext = createContext(null);
+
+export function useApp(): AppState {
+ const ctx = useContext(AppContext);
+ if (!ctx) throw new Error("useApp must be used within ");
+ return ctx;
+}
+
+// Convenience selectors -------------------------------------------------------
+
+export function useVisibleSessions(): DashboardSession[] {
+ const { sessions, activeProjectId } = useApp();
+ return useMemo(
+ () => (activeProjectId === "all" ? sessions : sessions.filter((s) => s.projectId === activeProjectId)),
+ [sessions, activeProjectId],
+ );
+}
+
+export function usePRs() {
+ const sessions = useVisibleSessions();
+ return useMemo(() => collectPRs(sessions), [sessions]);
+}
+
+// Provider --------------------------------------------------------------------
+
+export function AppProvider({ children }: { children: ReactNode }) {
+ const [config, setConfig] = useState(null);
+ const [projects, setProjects] = useState([]);
+ const [rawSessions, setRawSessions] = useState([]);
+ const [patches, setPatches] = useState