6.3 KiB
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.yamlonly globspackages/*). Run
npm install/npx expo startfrom insidemobile/— neverpnpmhere. .npmrchaslegacy-peer-deps=truebecause@fresshpins 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 (https://docs.expo.dev/versions/v54.0.0/) — 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):
- REST API (
http://<host>:<apiPort>, default3000— but AO often runs on
3001when 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 workerPOST /api/orchestrators{ projectId, clean? }— launch/relaunch orchestratorPOST /api/sessions/:id/kill | /restore | /send{ message }POST /api/prs/:number/merge?owner=&repo=— squash-merge
- Mux WebSocket (
ws://<host>:<muxPort>/mux, default14801). Client:
lib/mux.ts. One multiplexed socket carries:- Terminal I/O:
{ch:'terminal', id, type:'open'|'data'|'resize'|'close', projectId?}
out;data/opened/exited/errorin. (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.
- Terminal I/O:
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(<AppProvider>) 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 viauseApp()plususeVisibleSessions()/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
<TextInput>is the real keyboard (focus/blur via the ⌨ button), andonKeyPress
→ muxsendInput. 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'sdebug → logger.log
channel; RN forwards them to the PTY. Never passonMessagevia
webViewOptions— fressh spreads user options after its ownonMessage, so it
clobbers the bridge and breaks the terminal. Use theloggerprop. window.terminal/window.fitAddonare exposed; injected JS reaches the WebView
viawebViewOptions.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 afetchshim
in aninitScriptto 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 --noEmitmust be clean.
Conventions
- Match the existing screen structure:
ScreenHeader(title + AO mascot) → optional
ProjectSwitcher→ list/scroll. Reuselib/ui.tsxprimitives andlib/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.