From 9091ad57dffc5c0016b58dcd866356a18d47f11f Mon Sep 17 00:00:00 2001 From: Priyanshu Choudhary <57816400+Priyanchew@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:45:57 +0530 Subject: [PATCH 1/2] feat: add mobile app --- mobile/.gitignore | 54 + mobile/.npmrc | 1 + mobile/AGENTS.md | 1 + mobile/CLAUDE.md | 113 + mobile/LICENSE | 21 + mobile/README.md | 104 + mobile/app.json | 54 + mobile/app/(tabs)/_layout.tsx | 65 + mobile/app/(tabs)/index.tsx | 201 + mobile/app/(tabs)/orchestrator.tsx | 234 + mobile/app/(tabs)/prs.tsx | 231 + mobile/app/(tabs)/settings.tsx | 224 + mobile/app/_layout.tsx | 34 + mobile/app/session/[id].tsx | 670 ++ mobile/app/spawn.tsx | 115 + mobile/assets/android-icon-foreground.png | Bin 0 -> 22400 bytes mobile/assets/favicon.png | Bin 0 -> 3309 bytes mobile/assets/icon.png | Bin 0 -> 141464 bytes mobile/assets/mascot.png | Bin 0 -> 13041 bytes mobile/assets/splash-icon.png | Bin 0 -> 61691 bytes mobile/eas.json | 27 + mobile/images.d.ts | 6 + mobile/lib/ProjectSwitcher.tsx | 35 + mobile/lib/SessionCard.tsx | 111 + mobile/lib/api.ts | 267 + mobile/lib/config.ts | 72 + mobile/lib/mux.ts | 224 + mobile/lib/store.tsx | 346 + mobile/lib/theme.ts | 147 + mobile/lib/ui.tsx | 363 + mobile/package-lock.json | 9548 +++++++++++++++++++++ mobile/package.json | 38 + mobile/tsconfig.json | 6 + 33 files changed, 13312 insertions(+) create mode 100644 mobile/.gitignore create mode 100644 mobile/.npmrc create mode 100644 mobile/AGENTS.md create mode 100644 mobile/CLAUDE.md create mode 100644 mobile/LICENSE create mode 100644 mobile/README.md create mode 100644 mobile/app.json create mode 100644 mobile/app/(tabs)/_layout.tsx create mode 100644 mobile/app/(tabs)/index.tsx create mode 100644 mobile/app/(tabs)/orchestrator.tsx create mode 100644 mobile/app/(tabs)/prs.tsx create mode 100644 mobile/app/(tabs)/settings.tsx create mode 100644 mobile/app/_layout.tsx create mode 100644 mobile/app/session/[id].tsx create mode 100644 mobile/app/spawn.tsx create mode 100644 mobile/assets/android-icon-foreground.png create mode 100644 mobile/assets/favicon.png create mode 100644 mobile/assets/icon.png create mode 100644 mobile/assets/mascot.png create mode 100644 mobile/assets/splash-icon.png create mode 100644 mobile/eas.json create mode 100644 mobile/images.d.ts create mode 100644 mobile/lib/ProjectSwitcher.tsx create mode 100644 mobile/lib/SessionCard.tsx create mode 100644 mobile/lib/api.ts create mode 100644 mobile/lib/config.ts create mode 100644 mobile/lib/mux.ts create mode 100644 mobile/lib/store.tsx create mode 100644 mobile/lib/theme.ts create mode 100644 mobile/lib/ui.tsx create mode 100644 mobile/package-lock.json create mode 100644 mobile/package.json create mode 100644 mobile/tsconfig.json 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..b7b125a9ee --- /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..6fd889e42f --- /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..73fa3a8243 --- /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..d5e089608d --- /dev/null +++ b/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,65 @@ +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..54b98a5b90 --- /dev/null +++ b/mobile/app/(tabs)/index.tsx @@ -0,0 +1,201 @@ +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..7332751bab --- /dev/null +++ b/mobile/app/(tabs)/orchestrator.tsx @@ -0,0 +1,234 @@ +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 ? ( + <> +