diff --git a/mobile/CLAUDE.md b/mobile/CLAUDE.md
index b7b125a9e..f765e0607 100644
--- a/mobile/CLAUDE.md
+++ b/mobile/CLAUDE.md
@@ -20,8 +20,8 @@ orchestrator launch/open — over LAN or Tailscale.
## 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.
+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).
diff --git a/mobile/README.md b/mobile/README.md
index 6fd889e42..140f970a0 100644
--- a/mobile/README.md
+++ b/mobile/README.md
@@ -6,7 +6,7 @@ session and drive it, review and merge PRs, and launch/open orchestrators — fr
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
+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
@@ -15,7 +15,7 @@ workspace. Run all commands below from inside `mobile/`.
> **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 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
diff --git a/mobile/app.json b/mobile/app.json
index 73fa3a824..895210af1 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -1,54 +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"
- }
- }
- }
+ "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
index d5e089608..1013fec4e 100644
--- a/mobile/app/(tabs)/_layout.tsx
+++ b/mobile/app/(tabs)/_layout.tsx
@@ -1,65 +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';
+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 (
-
- ,
- }}
- />
- (
-
- ),
- }}
- />
- (
-
- ),
- }}
- />
- (
-
- ),
- }}
- />
-
- );
+ const insets = useSafeAreaInsets();
+ return (
+
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+
+ );
}
diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx
index 54b98a5b9..1f981b8aa 100644
--- a/mobile/app/(tabs)/index.tsx
+++ b/mobile/app/(tabs)/index.tsx
@@ -1,201 +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';
+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);
+ 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 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 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 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]);
+ const onRefresh = useCallback(async () => {
+ setRefreshing(true);
+ await refresh();
+ setRefreshing(false);
+ }, [refresh]);
- if (!configured) {
- return (
-
-
- router.push('/settings')} />
- }
- />
-
- );
- }
+ if (!configured) {
+ return (
+
+
+ router.push("/settings")} />}
+ />
+
+ );
+ }
- return (
-
-
- }
- />
+ return (
+
+
+ } />
-
-
-
-
-
+
+
+
+
+
-
+
- {loading && sessions.length === 0 ? (
-
-
-
- ) : (
- `${item.projectId}:${item.id}`}
- contentContainerStyle={{ paddingBottom: 120 }}
- stickySectionHeadersEnabled={false}
- refreshControl={
-
- }
- renderSectionHeader={({ section }) => (
-
- )}
- renderItem={({ item }) => }
- ListEmptyComponent={
- error ? (
- }
- />
- ) : (
- router.push('/spawn')} />}
- />
- )
- }
- />
- )}
+ {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 }]}
- >
-
-
-
- );
+ {/* 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}
-
- );
+ 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,
- },
+ 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
index 7332751ba..333857955 100644
--- a/mobile/app/(tabs)/orchestrator.tsx
+++ b/mobile/app/(tabs)/orchestrator.tsx
@@ -1,234 +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';
+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'];
+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);
+ 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;
+ // Always show every project's orchestrator here — no per-project filtering.
+ const visibleProjects = projects;
- const onRefresh = async () => {
- setRefreshing(true);
- await refresh();
- setRefreshing(false);
- };
+ const onRefresh = async () => {
+ setRefreshing(true);
+ await refresh();
+ setRefreshing(false);
+ };
- if (!configured) {
- return (
-
-
-
-
- );
- }
+ if (!configured) {
+ return (
+
+
+
+
+ );
+ }
- 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 (
-
- );
- })
- )}
-
-
- );
+ }
+ >
+ {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;
+ 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,
+ projectName,
+ link,
+ workerCount,
+ zones,
}: {
- projectId: string;
- projectName: string;
- link: OrchestratorLink | null;
- workerCount: number;
- zones: Record;
+ projectId: string;
+ projectName: string;
+ link: OrchestratorLink | null;
+ workerCount: number;
+ zones: Record;
}) {
- const router = useRouter();
- const { launchConductor } = useApp();
- const [busy, setBusy] = useState(false);
+ 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' };
+ // 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 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);
- }
- };
+ 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'}
-
-
-
+ 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}
+ {workerCount > 0 ? (
+
+ {ZONE_ORDER.filter((z) => zones[z]).map((z) => {
+ const m = attentionMeta[z];
+ return (
+
+
+ {zones[z]}
+ {m.label}
+
+ );
+ })}
+
+ ) : null}
-
- {open ? (
- <>
-
-
- );
+
+ {open ? (
+ <>
+ openTerminal(link.id)}
+ style={{ flex: 1 }}
+ />
+ onLaunch(false)} />
+ >
+ ) : (
+ onLaunch(present)}
+ style={{ flex: 1 }}
+ />
+ )}
+
+
+ );
}
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 },
+ 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
index 5f61b32fa..35e9070a3 100644
--- a/mobile/app/(tabs)/prs.tsx
+++ b/mobile/app/(tabs)/prs.tsx
@@ -1,231 +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';
+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';
+type Filter = "open" | "merged" | "all";
-const prKey = (pr: DashboardPR) => `${pr.owner ?? ''}/${pr.repo ?? ''}#${pr.number}`;
+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 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 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 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);
- }
- },
- },
- ],
- );
- };
+ 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 (
-
-
-
-
- );
- }
+ 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,
- };
+ 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 (
-
-
- } />
-
+ return (
+
+
+ } />
+
-
- {(['open', 'merged', 'all'] as Filter[]).map((f) => (
- setFilter(f)}
- />
- ))}
-
+
+ {(["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 },
- })
- }
- />
- ))
- )}
-
-
- );
+ }
+ >
+ {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,
+ session,
+ merging,
+ onMerge,
+ onOpenSession,
}: {
- pr: DashboardPR;
- session: DashboardSession;
- merging: boolean;
- onMerge: () => void;
- onOpenSession: () => void;
+ 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;
+ 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}
- )}
-
+ return (
+
+
+
+ {pr.repo ? `${pr.owner}/${pr.repo}` : session.projectId}
+
+
+ {pr.state === "merged" ? (
+
+ ) : pr.state === "closed" ? (
+
+ ) : (
+ #{pr.number}
+ )}
+
-
- {pr.title ?? `Pull request #${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}
-
+
+ {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}
-
-
- );
+
+
+ {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 },
+ 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
index 1ac7724cd..8cc77f241 100644
--- a/mobile/app/(tabs)/settings.tsx
+++ b/mobile/app/(tabs)/settings.tsx
@@ -1,224 +1,235 @@
-import { Feather } from '@expo/vector-icons';
-import { useEffect, useState } from 'react';
+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';
+ 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);
+ 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);
- });
- }, []);
+ useEffect(() => {
+ loadConfig().then((c) => {
+ setCfg(c);
+ setLoaded(true);
+ });
+ }, []);
- const set = (k: keyof ServerConfig) => (v: string) =>
- setCfg((prev) => ({ ...prev, [k]: v }));
+ 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 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);
- }
+ async function save() {
+ await saveConfig(cfg);
+ await reloadConfig();
+ setSaved(true);
+ setTimeout(() => setSaved(false), 1800);
+ }
- if (!loaded) {
- return (
-
-
-
- );
- }
+ 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).
-
+ 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 }}
- />
-
+
+
+ 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}
-
- )}
-
+
+ {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}
-
- ))
- )}
-
-
- );
+ 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';
+ label: string;
+ value: string;
+ onChangeText: (v: string) => void;
+ placeholder?: string;
+ autoCapitalize?: "none" | "sentences";
+ keyboardType?: "default" | "url" | "number-pad";
}) {
- return (
-
- {props.label}
-
-
- );
+ 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 },
+ 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
index 7ab1fc932..c474fab5b 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -1,34 +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';
+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 (
-
-
-
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/mobile/app/session/[id].tsx b/mobile/app/session/[id].tsx
index eed6281ac..41a531044 100644
--- a/mobile/app/session/[id].tsx
+++ b/mobile/app/session/[id].tsx
@@ -1,22 +1,13 @@
-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';
+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;
@@ -174,497 +165,493 @@ 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' },
+ { 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',
+ 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',
+ connecting: "connecting…",
+ open: "live",
+ closed: "disconnected",
+ error: "error",
};
const statusColors: Record = {
- connecting: theme.attention,
- open: theme.green,
- closed: theme.textTertiary,
- error: theme.red,
+ 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 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 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);
+ 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();
- };
- }, []);
+ // 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]);
+ // 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;
+ // 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]);
+ 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],
- );
+ // 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],
- );
+ // 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 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 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],
- );
+ 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]);
+ // 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],
- );
+ // 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]);
+ // 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 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 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,
- }),
- [],
- );
+ 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.
-
- );
- }
+ 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;
+ // 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
-
-
+ 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)
-
- )}
+ {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}
- >
-
-
-
- )}
+ {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 ? '⌨▾' : '⌨▴'}
-
-
-
- );
+
+ {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',
- },
+ 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
index ef1c23d96..567113f2a 100644
--- a/mobile/app/spawn.tsx
+++ b/mobile/app/spawn.tsx
@@ -1,115 +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';
+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);
+ 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]);
+ // 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);
- }
- };
+ 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.
-
+ 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)}
- />
- ))}
-
+ PROJECT
+
+ {projects.map((p) => (
+ setProjectId(p.id)} />
+ ))}
+
- TASK (OPTIONAL)
-
+ TASK (OPTIONAL)
+
- {error ? {error} : null}
+ {error ? {error} : null}
-
- router.back()} style={{ marginTop: 10 }} />
-
-
- );
+
+ 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 },
+ 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/eas.json b/mobile/eas.json
index b5e55cec4..772b82780 100644
--- a/mobile/eas.json
+++ b/mobile/eas.json
@@ -1,27 +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": {}
- }
+ "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
index 25082cfaf..a2f367a59 100644
--- a/mobile/images.d.ts
+++ b/mobile/images.d.ts
@@ -1,6 +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;
+declare module "*.png" {
+ const content: number;
+ export default content;
}
diff --git a/mobile/lib/ProjectSwitcher.tsx b/mobile/lib/ProjectSwitcher.tsx
index e34d42b36..bcf4fc0bd 100644
--- a/mobile/lib/ProjectSwitcher.tsx
+++ b/mobile/lib/ProjectSwitcher.tsx
@@ -1,35 +1,30 @@
-import { ScrollView, StyleSheet } from 'react-native';
-import { useApp } from './store';
-import { Pill } from './ui';
+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 { projects, activeProjectId, setActiveProject } = useApp();
+ if (projects.length <= 1) return null;
- const items = [{ id: 'all', name: 'All' }, ...projects];
+ const items = [{ id: "all", name: "All" }, ...projects];
- return (
-
- {items.map((p) => (
- setActiveProject(p.id)}
- />
- ))}
-
- );
+ return (
+
+ {items.map((p) => (
+ setActiveProject(p.id)} />
+ ))}
+
+ );
}
const styles = StyleSheet.create({
- scroll: { flexGrow: 0 },
- row: { paddingHorizontal: 16, paddingBottom: 12, gap: 8, alignItems: 'center' },
+ scroll: { flexGrow: 0 },
+ row: { paddingHorizontal: 16, paddingBottom: 12, gap: 8, alignItems: "center" },
});
diff --git a/mobile/lib/SessionCard.tsx b/mobile/lib/SessionCard.tsx
index 47321a314..dafd93b1f 100644
--- a/mobile/lib/SessionCard.tsx
+++ b/mobile/lib/SessionCard.tsx
@@ -1,111 +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';
+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);
+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}
-
+ 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}
-
+
+ {title}
+
-
- {session.branch ? (
-
-
-
- {session.branch}
-
-
- ) : null}
- {pr?.number ? (
-
-
- #{pr.number}
- {pr.additions !== undefined && pr.deletions !== undefined ? (
-
- +{pr.additions}{' '}
- −{pr.deletions}
-
- ) : null}
-
- ) : null}
-
-
-
-
- );
+
+ {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 },
+ 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
index 880f797a7..28ab3bc93 100644
--- a/mobile/lib/api.ts
+++ b/mobile/lib/api.ts
@@ -1,267 +1,244 @@
-import { httpBase, type ServerConfig } from './config';
-import type { AttentionLevel } from './theme';
+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;
+ 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;
+ 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;
+ 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;
+ id: string;
+ name: string;
+ sessionPrefix?: string;
};
export type DashboardStats = {
- totalSessions?: number;
- workingSessions?: number;
- openPRs?: number;
- needsReview?: number;
+ totalSessions?: number;
+ workingSessions?: number;
+ openPRs?: number;
+ needsReview?: number;
};
export type SessionsResponse = {
- sessions: DashboardSession[];
- orchestrators: OrchestratorLink[];
- orchestratorId: string | null;
- stats: DashboardStats;
+ 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;
+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 : [];
+ 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 ?? {},
- };
+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' });
+ 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' });
+ 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 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 },
+ 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;
+ 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,
+ 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;
+ 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' });
+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;
+ 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',
-]);
+const TERMINAL_STATUSES = new Set(["killed", "terminated", "done", "cleanup", "errored", "merged"]);
export function isTerminalStatus(status?: string | null): boolean {
- return !!status && TERMINAL_STATUSES.has(status);
+ 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';
+ 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
- );
+ 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;
+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
index 72fe9b4a2..923dea422 100644
--- a/mobile/lib/config.ts
+++ b/mobile/lib/config.ts
@@ -1,72 +1,75 @@
-import AsyncStorage from '@react-native-async-storage/async-storage';
-import { useCallback, useEffect, useState } from 'react';
+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)
+ 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,
+ 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(/\/+$/, '');
+ return host
+ .trim()
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")
+ .replace(/\/+$/, "");
}
-const KEY = 'ao.serverConfig';
+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;
- }
+ 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));
+ await AsyncStorage.setItem(KEY, JSON.stringify(cfg));
}
export function httpBase(cfg: ServerConfig): string {
- return `${cfg.secure ? 'https' : 'http'}://${cleanHost(cfg.host)}:${cfg.httpPort}`;
+ 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`;
+ return `${cfg.secure ? "wss" : "ws"}://${cleanHost(cfg.host)}:${cfg.muxPort}/mux`;
}
export function isConfigured(cfg: ServerConfig): boolean {
- return cleanHost(cfg.host).length > 0;
+ 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 [config, setConfig] = useState(null);
- const reload = useCallback(async () => {
- setConfig(await loadConfig());
- }, []);
+ const reload = useCallback(async () => {
+ setConfig(await loadConfig());
+ }, []);
- useEffect(() => {
- reload();
- }, [reload]);
+ useEffect(() => {
+ reload();
+ }, [reload]);
- const update = useCallback(async (cfg: ServerConfig) => {
- await saveConfig(cfg);
- setConfig(cfg);
- }, []);
+ const update = useCallback(async (cfg: ServerConfig) => {
+ await saveConfig(cfg);
+ setConfig(cfg);
+ }, []);
- return { config, update, reload };
+ return { config, update, reload };
}
diff --git a/mobile/lib/mux.ts b/mobile/lib/mux.ts
index daf7fdb34..f26a8a7cc 100644
--- a/mobile/lib/mux.ts
+++ b/mobile/lib/mux.ts
@@ -1,53 +1,48 @@
-import { muxUrl, type ServerConfig } from './config';
+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;
+ id: string;
+ status: string;
+ activity: string | null;
+ attentionLevel: string;
+ lastActivityAt: string;
};
-export type MuxStatus = 'connecting' | 'open' | 'closed' | 'error';
+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;
+ 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;
+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);
+ 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);
}
/**
@@ -55,170 +50,170 @@ function utf8Encode(str: string): Uint8Array {
* 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;
+ 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;
- }
+ constructor(cfg: ServerConfig, handlers: Handlers) {
+ this.cfg = cfg;
+ this.handlers = handlers;
+ }
- connect() {
- this.closedByUser = false;
- this.open();
- }
+ 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;
+ 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.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.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.onerror = () => {
+ this.handlers.onStatus?.("error");
+ };
- ws.onclose = () => {
- this.clearPing();
- if (this.closedByUser) {
- this.handlers.onStatus?.('closed');
- return;
- }
- this.handlers.onStatus?.('closed');
- this.scheduleReconnect();
- };
- }
+ 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 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 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 clearReconnect() {
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ }
- private clearPing() {
- if (this.pingTimer) {
- clearInterval(this.pingTimer);
- this.pingTimer = 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));
- }
- }
+ 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'] });
- }
+ 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 });
- }
+ 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 });
- }
+ 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 });
- }
+ 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 });
- }
+ 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;
- }
+ 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
index ce5e0e541..5a716c76d 100644
--- a/mobile/lib/store.tsx
+++ b/mobile/lib/store.tsx
@@ -1,346 +1,331 @@
-import AsyncStorage from '@react-native-async-storage/async-storage';
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
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';
+ 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';
+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;
+ 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;
+ 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],
- );
+ 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]);
+ 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