chore: format with prettier [skip ci]

This commit is contained in:
github-actions[bot] 2026-06-24 18:19:31 +00:00
parent 9091ad57df
commit 99cb0d4357
23 changed files with 2698 additions and 2842 deletions

View File

@ -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 (<https://docs.expo.dev/versions/v54.0.0/>) — APIs differ by SDK.
Pinned: `expo 54`, `react 19.1.0`, `react-native 0.81.5`, `expo-router 6`,
`react-native-webview 13.15.0` (react + webview match `@fressh` peers exactly).

View File

@ -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

View File

@ -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"
}
}
}
}

View File

@ -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 (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: theme.blue,
tabBarInactiveTintColor: theme.textTertiary,
tabBarStyle: {
backgroundColor: theme.bgSurface,
borderTopColor: theme.borderSubtle,
borderTopWidth: 1,
// Drive height/padding from the real safe-area inset so the bar clears
// the Android gesture-nav bar (edge-to-edge is on by default in SDK 54)
// and the iOS home indicator — instead of guessing fixed per-platform
// numbers that leave the bar under the system nav on Android.
height: 56 + insets.bottom,
paddingTop: 6,
paddingBottom: insets.bottom + 6,
},
tabBarLabelStyle: { fontSize: 11, fontWeight: '600' },
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Kanban',
tabBarIcon: ({ color, size }) => <Feather name="grid" size={size - 2} color={color} />,
}}
/>
<Tabs.Screen
name="prs"
options={{
title: 'PRs',
tabBarIcon: ({ color, size }) => (
<Feather name="git-pull-request" size={size - 2} color={color} />
),
}}
/>
<Tabs.Screen
name="orchestrator"
options={{
title: 'Orchestrator',
tabBarIcon: ({ color, size }) => (
<Feather name="share-2" size={size - 2} color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ color, size }) => (
<Feather name="settings" size={size - 2} color={color} />
),
}}
/>
</Tabs>
);
const insets = useSafeAreaInsets();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: theme.blue,
tabBarInactiveTintColor: theme.textTertiary,
tabBarStyle: {
backgroundColor: theme.bgSurface,
borderTopColor: theme.borderSubtle,
borderTopWidth: 1,
// Drive height/padding from the real safe-area inset so the bar clears
// the Android gesture-nav bar (edge-to-edge is on by default in SDK 54)
// and the iOS home indicator — instead of guessing fixed per-platform
// numbers that leave the bar under the system nav on Android.
height: 56 + insets.bottom,
paddingTop: 6,
paddingBottom: insets.bottom + 6,
},
tabBarLabelStyle: { fontSize: 11, fontWeight: "600" },
}}
>
<Tabs.Screen
name="index"
options={{
title: "Kanban",
tabBarIcon: ({ color, size }) => <Feather name="grid" size={size - 2} color={color} />,
}}
/>
<Tabs.Screen
name="prs"
options={{
title: "PRs",
tabBarIcon: ({ color, size }) => <Feather name="git-pull-request" size={size - 2} color={color} />,
}}
/>
<Tabs.Screen
name="orchestrator"
options={{
title: "Orchestrator",
tabBarIcon: ({ color, size }) => <Feather name="share-2" size={size - 2} color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarIcon: ({ color, size }) => <Feather name="settings" size={size - 2} color={color} />,
}}
/>
</Tabs>
);
}

View File

@ -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<string, DashboardSession[]>();
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<string, DashboardSession[]>();
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 (
<View style={styles.screen}>
<View style={{ height: insets.top }} />
<EmptyState
icon="server"
title="Connect to AO"
message="Point the app at your Agent Orchestrator server to start controlling your fleet."
action={
<Button title="Configure server" icon="settings" onPress={() => router.push('/settings')} />
}
/>
</View>
);
}
if (!configured) {
return (
<View style={styles.screen}>
<View style={{ height: insets.top }} />
<EmptyState
icon="server"
title="Connect to AO"
message="Point the app at your Agent Orchestrator server to start controlling your fleet."
action={<Button title="Configure server" icon="settings" onPress={() => router.push("/settings")} />}
/>
</View>
);
}
return (
<View style={styles.screen}>
<View style={{ height: insets.top }} />
<ScreenHeader
title="Kanban"
subtitle={config?.host}
right={<ConnectionPill status={connection} />}
/>
return (
<View style={styles.screen}>
<View style={{ height: insets.top }} />
<ScreenHeader title="Kanban" subtitle={config?.host} right={<ConnectionPill status={connection} />} />
<View style={styles.stats}>
<Stat n={counts.working} label="working" color={theme.orange} />
<Stat n={counts.needsYou} label="need you" color={theme.amber} />
<Stat n={counts.mergeable} label="mergeable" color={theme.green} />
</View>
<View style={styles.stats}>
<Stat n={counts.working} label="working" color={theme.orange} />
<Stat n={counts.needsYou} label="need you" color={theme.amber} />
<Stat n={counts.mergeable} label="mergeable" color={theme.green} />
</View>
<ProjectSwitcher />
<ProjectSwitcher />
{loading && sessions.length === 0 ? (
<View style={styles.center}>
<ActivityIndicator color={theme.blue} />
</View>
) : (
<SectionList
sections={sections}
keyExtractor={(item) => `${item.projectId}:${item.id}`}
contentContainerStyle={{ paddingBottom: 120 }}
stickySectionHeadersEnabled={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.blue} />
}
renderSectionHeader={({ section }) => (
<SectionHeader
label={(section as Section).label}
color={(section as Section).color}
count={(section as Section).data.length}
/>
)}
renderItem={({ item }) => <SessionCard session={item} showProject />}
ListEmptyComponent={
error ? (
<EmptyState
icon="wifi-off"
title="Couldn't reach server"
message={error}
action={<Button title="Retry" icon="refresh-cw" variant="ghost" onPress={onRefresh} />}
/>
) : (
<EmptyState
icon="moon"
title="No active agents"
message="Spawn a worker to put your fleet to work."
action={<Button title="New agent" icon="plus" onPress={() => router.push('/spawn')} />}
/>
)
}
/>
)}
{loading && sessions.length === 0 ? (
<View style={styles.center}>
<ActivityIndicator color={theme.blue} />
</View>
) : (
<SectionList
sections={sections}
keyExtractor={(item) => `${item.projectId}:${item.id}`}
contentContainerStyle={{ paddingBottom: 120 }}
stickySectionHeadersEnabled={false}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.blue} />}
renderSectionHeader={({ section }) => (
<SectionHeader
label={(section as Section).label}
color={(section as Section).color}
count={(section as Section).data.length}
/>
)}
renderItem={({ item }) => <SessionCard session={item} showProject />}
ListEmptyComponent={
error ? (
<EmptyState
icon="wifi-off"
title="Couldn't reach server"
message={error}
action={<Button title="Retry" icon="refresh-cw" variant="ghost" onPress={onRefresh} />}
/>
) : (
<EmptyState
icon="moon"
title="No active agents"
message="Spawn a worker to put your fleet to work."
action={<Button title="New agent" icon="plus" onPress={() => router.push("/spawn")} />}
/>
)
}
/>
)}
{/* Spawn FAB */}
<Pressable
onPress={() => router.push('/spawn')}
style={({ pressed }) => [styles.fab, pressed && { opacity: 0.85 }]}
>
<Feather name="plus" size={24} color="#06101f" />
</Pressable>
</View>
);
{/* Spawn FAB */}
<Pressable
onPress={() => router.push("/spawn")}
style={({ pressed }) => [styles.fab, pressed && { opacity: 0.85 }]}
>
<Feather name="plus" size={24} color="#06101f" />
</Pressable>
</View>
);
}
function Stat({ n, label, color }: { n: number; label: string; color: string }) {
return (
<View style={styles.stat}>
<Text style={[styles.statN, { color: n > 0 ? color : theme.textFaint }]}>{n}</Text>
<Text style={styles.statLabel}>{label}</Text>
</View>
);
return (
<View style={styles.stat}>
<Text style={[styles.statN, { color: n > 0 ? color : theme.textFaint }]}>{n}</Text>
<Text style={styles.statLabel}>{label}</Text>
</View>
);
}
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,
},
});

View File

@ -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 (
<View style={styles.screen}>
<View style={{ height: insets.top }} />
<EmptyState icon="share-2" title="No server" message="Connect to AO in Settings." />
</View>
);
}
if (!configured) {
return (
<View style={styles.screen}>
<View style={{ height: insets.top }} />
<EmptyState icon="share-2" title="No server" message="Connect to AO in Settings." />
</View>
);
}
return (
<View style={styles.screen}>
<View style={{ height: insets.top }} />
<ScreenHeader
title="Orchestrator"
subtitle="Orchestrators direct your worker agents"
right={<ConnectionPill status={connection} />}
/>
return (
<View style={styles.screen}>
<View style={{ height: insets.top }} />
<ScreenHeader
title="Orchestrator"
subtitle="Orchestrators direct your worker agents"
right={<ConnectionPill status={connection} />}
/>
<ScrollView
contentContainerStyle={{ paddingBottom: 110, paddingTop: 4 }}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.blue} />
}
>
{visibleProjects.length === 0 ? (
<EmptyState icon="folder" title="No projects" message="Add a project in AO to get started." />
) : (
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 (
<OrchestratorCard
key={p.id}
projectId={p.id}
projectName={p.name}
link={link}
workerCount={workers.length}
zones={zoneCounts(workers)}
/>
);
})
)}
</ScrollView>
</View>
);
<ScrollView
contentContainerStyle={{ paddingBottom: 110, paddingTop: 4 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.blue} />}
>
{visibleProjects.length === 0 ? (
<EmptyState icon="folder" title="No projects" message="Add a project in AO to get started." />
) : (
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 (
<OrchestratorCard
key={p.id}
projectId={p.id}
projectName={p.name}
link={link}
workerCount={workers.length}
zones={zoneCounts(workers)}
/>
);
})
)}
</ScrollView>
</View>
);
}
function zoneCounts(sessions: DashboardSession[]): Record<string, number> {
const out: Record<string, number> = {};
for (const s of sessions) {
const a = attentionOf(s);
out[a] = (out[a] ?? 0) + 1;
}
return out;
const out: Record<string, number> = {};
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<string, number>;
projectId: string;
projectName: string;
link: OrchestratorLink | null;
workerCount: number;
zones: Record<string, number>;
}) {
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 (
<View style={styles.card}>
<View style={styles.head}>
<View style={styles.orgIcon}>
<Feather name="share-2" size={18} color={theme.blue} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.projName}>{projectName}</Text>
<View style={styles.statusRow}>
<Dot
color={open ? v.color : stopped ? theme.textTertiary : theme.textFaint}
size={7}
breathing={!!(open && v.breathing)}
/>
<Text
style={[
styles.statusText,
{ color: open ? v.color : stopped ? theme.textTertiary : theme.textFaint },
]}
>
{open ? v.label : stopped ? 'Stopped' : 'Not started'}
</Text>
<Text style={styles.workers}>· {workerCount} worker{workerCount === 1 ? '' : 's'}</Text>
</View>
</View>
</View>
return (
<View style={styles.card}>
<View style={styles.head}>
<View style={styles.orgIcon}>
<Feather name="share-2" size={18} color={theme.blue} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.projName}>{projectName}</Text>
<View style={styles.statusRow}>
<Dot
color={open ? v.color : stopped ? theme.textTertiary : theme.textFaint}
size={7}
breathing={!!(open && v.breathing)}
/>
<Text
style={[styles.statusText, { color: open ? v.color : stopped ? theme.textTertiary : theme.textFaint }]}
>
{open ? v.label : stopped ? "Stopped" : "Not started"}
</Text>
<Text style={styles.workers}>
· {workerCount} worker{workerCount === 1 ? "" : "s"}
</Text>
</View>
</View>
</View>
{workerCount > 0 ? (
<View style={styles.zones}>
{ZONE_ORDER.filter((z) => zones[z]).map((z) => {
const m = attentionMeta[z];
return (
<View key={z} style={[styles.zonePill, { backgroundColor: m.tint }]}>
<Dot color={m.color} size={6} />
<Text style={[styles.zoneN, { color: m.color }]}>{zones[z]}</Text>
<Text style={styles.zoneLabel}>{m.label}</Text>
</View>
);
})}
</View>
) : null}
{workerCount > 0 ? (
<View style={styles.zones}>
{ZONE_ORDER.filter((z) => zones[z]).map((z) => {
const m = attentionMeta[z];
return (
<View key={z} style={[styles.zonePill, { backgroundColor: m.tint }]}>
<Dot color={m.color} size={6} />
<Text style={[styles.zoneN, { color: m.color }]}>{zones[z]}</Text>
<Text style={styles.zoneLabel}>{m.label}</Text>
</View>
);
})}
</View>
) : null}
<View style={styles.actions}>
{open ? (
<>
<Button
title="Open orchestrator"
icon="terminal"
onPress={() => openTerminal(link.id)}
style={{ flex: 1 }}
/>
<Button
title="Restart"
variant="ghost"
icon="rotate-ccw"
loading={busy}
onPress={() => onLaunch(false)}
/>
</>
) : (
<Button
title={present ? 'Restart orchestrator' : 'Spawn orchestrator'}
icon="play"
loading={busy}
onPress={() => onLaunch(present)}
style={{ flex: 1 }}
/>
)}
</View>
</View>
);
<View style={styles.actions}>
{open ? (
<>
<Button
title="Open orchestrator"
icon="terminal"
onPress={() => openTerminal(link.id)}
style={{ flex: 1 }}
/>
<Button title="Restart" variant="ghost" icon="rotate-ccw" loading={busy} onPress={() => onLaunch(false)} />
</>
) : (
<Button
title={present ? "Restart orchestrator" : "Spawn orchestrator"}
icon="play"
loading={busy}
onPress={() => onLaunch(present)}
style={{ flex: 1 }}
/>
)}
</View>
</View>
);
}
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 },
});

View File

@ -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<Filter>('open');
const [refreshing, setRefreshing] = useState(false);
const [merging, setMerging] = useState<string | null>(null);
const insets = useSafeAreaInsets();
const router = useRouter();
const { configured, connection, merge, refresh } = useApp();
const prs = usePRs();
const [filter, setFilter] = useState<Filter>("open");
const [refreshing, setRefreshing] = useState(false);
const [merging, setMerging] = useState<string | null>(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 (
<View style={styles.screen}>
<View style={{ height: insets.top }} />
<EmptyState icon="git-pull-request" title="No server" message="Connect to AO in Settings." />
</View>
);
}
if (!configured) {
return (
<View style={styles.screen}>
<View style={{ height: insets.top }} />
<EmptyState icon="git-pull-request" title="No server" message="Connect to AO in Settings." />
</View>
);
}
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 (
<View style={styles.screen}>
<View style={{ height: insets.top }} />
<ScreenHeader title="Pull Requests" right={<ConnectionPill status={connection} />} />
<ProjectSwitcher />
return (
<View style={styles.screen}>
<View style={{ height: insets.top }} />
<ScreenHeader title="Pull Requests" right={<ConnectionPill status={connection} />} />
<ProjectSwitcher />
<View style={styles.filters}>
{(['open', 'merged', 'all'] as Filter[]).map((f) => (
<Pill
key={f}
label={`${f[0].toUpperCase() + f.slice(1)} ${counts[f]}`}
active={filter === f}
onPress={() => setFilter(f)}
/>
))}
</View>
<View style={styles.filters}>
{(["open", "merged", "all"] as Filter[]).map((f) => (
<Pill
key={f}
label={`${f[0].toUpperCase() + f.slice(1)} ${counts[f]}`}
active={filter === f}
onPress={() => setFilter(f)}
/>
))}
</View>
<ScrollView
contentContainerStyle={{ paddingBottom: 110, paddingTop: 4 }}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.blue} />
}
>
{filtered.length === 0 ? (
<EmptyState
icon="git-pull-request"
title="No pull requests"
message={filter === 'open' ? 'No open PRs right now.' : 'Nothing here yet.'}
/>
) : (
filtered.map(({ pr, session }) => (
<PRCard
key={`${pr.owner}/${pr.repo}#${pr.number}`}
pr={pr}
session={session}
merging={merging === prKey(pr)}
onMerge={() => onMerge(pr)}
onOpenSession={() =>
router.push({
pathname: '/session/[id]',
params: { id: session.id, projectId: session.projectId },
})
}
/>
))
)}
</ScrollView>
</View>
);
<ScrollView
contentContainerStyle={{ paddingBottom: 110, paddingTop: 4 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.blue} />}
>
{filtered.length === 0 ? (
<EmptyState
icon="git-pull-request"
title="No pull requests"
message={filter === "open" ? "No open PRs right now." : "Nothing here yet."}
/>
) : (
filtered.map(({ pr, session }) => (
<PRCard
key={`${pr.owner}/${pr.repo}#${pr.number}`}
pr={pr}
session={session}
merging={merging === prKey(pr)}
onMerge={() => onMerge(pr)}
onOpenSession={() =>
router.push({
pathname: "/session/[id]",
params: { id: session.id, projectId: session.projectId },
})
}
/>
))
)}
</ScrollView>
</View>
);
}
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 (
<View style={styles.card}>
<View style={styles.cardTop}>
<Text style={styles.repo} numberOfLines={1}>
{pr.repo ? `${pr.owner}/${pr.repo}` : session.projectId}
</Text>
<View style={{ flex: 1 }} />
{pr.state === 'merged' ? (
<Chip label="merged" color={theme.green} tint={theme.tintGreen} icon="git-merge" />
) : pr.state === 'closed' ? (
<Chip label="closed" color={theme.red} tint={theme.tintRed} />
) : (
<Text style={styles.num}>#{pr.number}</Text>
)}
</View>
return (
<View style={styles.card}>
<View style={styles.cardTop}>
<Text style={styles.repo} numberOfLines={1}>
{pr.repo ? `${pr.owner}/${pr.repo}` : session.projectId}
</Text>
<View style={{ flex: 1 }} />
{pr.state === "merged" ? (
<Chip label="merged" color={theme.green} tint={theme.tintGreen} icon="git-merge" />
) : pr.state === "closed" ? (
<Chip label="closed" color={theme.red} tint={theme.tintRed} />
) : (
<Text style={styles.num}>#{pr.number}</Text>
)}
</View>
<Text style={styles.title} numberOfLines={2}>
{pr.title ?? `Pull request #${pr.number}`}
</Text>
<Text style={styles.title} numberOfLines={2}>
{pr.title ?? `Pull request #${pr.number}`}
</Text>
<View style={styles.chips}>
{ci && ci !== 'none'
? (() => {
const c = ciVisual(ci);
return (
<Chip
label={c.label}
color={c.color}
tint={c.tint}
icon={c.icon}
/>
);
})()
: null}
{review === 'approved' ? (
<Chip label="approved" color={theme.green} tint={theme.tintGreen} icon="check" />
) : review === 'changes_requested' ? (
<Chip label="changes req." color={theme.amber} tint={theme.tintAmber} icon="edit-3" />
) : null}
{pr.additions !== undefined && pr.deletions !== undefined ? (
<View style={styles.diffChip}>
<Text style={[styles.diffText, { color: theme.green }]}>+{pr.additions}</Text>
<Text style={[styles.diffText, { color: theme.red }]}>{pr.deletions}</Text>
</View>
) : null}
{pr.unresolvedThreads ? (
<Chip label={`${pr.unresolvedThreads} threads`} color={theme.amber} tint={theme.tintAmber} icon="message-square" />
) : null}
</View>
<View style={styles.chips}>
{ci && ci !== "none"
? (() => {
const c = ciVisual(ci);
return <Chip label={c.label} color={c.color} tint={c.tint} icon={c.icon} />;
})()
: null}
{review === "approved" ? (
<Chip label="approved" color={theme.green} tint={theme.tintGreen} icon="check" />
) : review === "changes_requested" ? (
<Chip label="changes req." color={theme.amber} tint={theme.tintAmber} icon="edit-3" />
) : null}
{pr.additions !== undefined && pr.deletions !== undefined ? (
<View style={styles.diffChip}>
<Text style={[styles.diffText, { color: theme.green }]}>+{pr.additions}</Text>
<Text style={[styles.diffText, { color: theme.red }]}>{pr.deletions}</Text>
</View>
) : null}
{pr.unresolvedThreads ? (
<Chip
label={`${pr.unresolvedThreads} threads`}
color={theme.amber}
tint={theme.tintAmber}
icon="message-square"
/>
) : null}
</View>
<View style={styles.actions}>
<Button title="Session" variant="ghost" icon="terminal" onPress={onOpenSession} style={styles.flexBtn} />
{pr.url ? (
<Button title="Open" variant="ghost" icon="external-link" onPress={() => Linking.openURL(pr.url!)} style={styles.flexBtn} />
) : null}
{mergeable ? (
<Button title="Merge" icon="git-merge" loading={merging} onPress={onMerge} style={styles.flexBtn} />
) : null}
</View>
</View>
);
<View style={styles.actions}>
<Button title="Session" variant="ghost" icon="terminal" onPress={onOpenSession} style={styles.flexBtn} />
{pr.url ? (
<Button
title="Open"
variant="ghost"
icon="external-link"
onPress={() => Linking.openURL(pr.url!)}
style={styles.flexBtn}
/>
) : null}
{mergeable ? (
<Button title="Merge" icon="git-merge" loading={merging} onPress={onMerge} style={styles.flexBtn} />
) : null}
</View>
</View>
);
}
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 },
});

View File

@ -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<ServerConfig>(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<ServerConfig>(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 (
<View style={styles.center}>
<ActivityIndicator color={theme.blue} />
</View>
);
}
if (!loaded) {
return (
<View style={styles.center}>
<ActivityIndicator color={theme.blue} />
</View>
);
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: theme.bgBase }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={{ height: insets.top }} />
<ScreenHeader title="Settings" right={<ConnectionPill status={connection} />} />
<ScrollView
style={styles.screen}
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.sectionTitle}>SERVER</Text>
<Text style={styles.intro}>
Point the app at your AO server your PC's Tailscale name / 100.x address (or LAN IP on
the same Wi-Fi).
</Text>
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: theme.bgBase }}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={{ height: insets.top }} />
<ScreenHeader title="Settings" right={<ConnectionPill status={connection} />} />
<ScrollView
style={styles.screen}
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.sectionTitle}>SERVER</Text>
<Text style={styles.intro}>
Point the app at your AO server your PC's Tailscale name / 100.x address (or LAN IP on the same Wi-Fi).
</Text>
<Field
label="HOST"
value={cfg.host}
onChangeText={set('host')}
placeholder="my-pc.tailXXXX.ts.net or 192.168.x.x"
autoCapitalize="none"
keyboardType="url"
/>
<View style={styles.row}>
<View style={{ flex: 1, marginRight: 8 }}>
<Field label="API PORT" value={cfg.httpPort} onChangeText={set('httpPort')} keyboardType="number-pad" />
</View>
<View style={{ flex: 1, marginLeft: 8 }}>
<Field label="TERMINAL PORT" value={cfg.muxPort} onChangeText={set('muxPort')} keyboardType="number-pad" />
</View>
</View>
<Field
label="HOST"
value={cfg.host}
onChangeText={set("host")}
placeholder="my-pc.tailXXXX.ts.net or 192.168.x.x"
autoCapitalize="none"
keyboardType="url"
/>
<View style={styles.row}>
<View style={{ flex: 1, marginRight: 8 }}>
<Field label="API PORT" value={cfg.httpPort} onChangeText={set("httpPort")} keyboardType="number-pad" />
</View>
<View style={{ flex: 1, marginLeft: 8 }}>
<Field label="TERMINAL PORT" value={cfg.muxPort} onChangeText={set("muxPort")} keyboardType="number-pad" />
</View>
</View>
<View style={styles.toggleRow}>
<View style={{ flex: 1 }}>
<Text style={styles.toggleLabel}>Use TLS (https / wss)</Text>
<Text style={styles.toggleHint}>On only if AO is served over HTTPS (e.g. a Tailscale funnel).</Text>
</View>
<Switch
value={!!cfg.secure}
onValueChange={(v) => setCfg((prev) => ({ ...prev, secure: v }))}
trackColor={{ true: theme.blue, false: theme.borderStrong }}
/>
</View>
<View style={styles.toggleRow}>
<View style={{ flex: 1 }}>
<Text style={styles.toggleLabel}>Use TLS (https / wss)</Text>
<Text style={styles.toggleHint}>On only if AO is served over HTTPS (e.g. a Tailscale funnel).</Text>
</View>
<Switch
value={!!cfg.secure}
onValueChange={(v) => setCfg((prev) => ({ ...prev, secure: v }))}
trackColor={{ true: theme.blue, false: theme.borderStrong }}
/>
</View>
<Button title="Test connection" variant="ghost" icon="activity" loading={testing} onPress={test} style={{ marginTop: 4 }} />
{result && (
<View style={[styles.resultBox, { borderColor: result.ok ? theme.tintGreen : theme.tintRed }]}>
<Feather
name={result.ok ? 'check-circle' : 'alert-circle'}
size={15}
color={result.ok ? theme.green : theme.red}
/>
<Text style={[styles.result, { color: result.ok ? theme.green : theme.red }]}>{result.msg}</Text>
</View>
)}
<Button title={saved ? 'Saved ✓' : 'Save'} icon={saved ? undefined : 'save'} onPress={save} disabled={!cfg.host.trim()} style={{ marginTop: 12 }} />
<Button
title="Test connection"
variant="ghost"
icon="activity"
loading={testing}
onPress={test}
style={{ marginTop: 4 }}
/>
{result && (
<View style={[styles.resultBox, { borderColor: result.ok ? theme.tintGreen : theme.tintRed }]}>
<Feather
name={result.ok ? "check-circle" : "alert-circle"}
size={15}
color={result.ok ? theme.green : theme.red}
/>
<Text style={[styles.result, { color: result.ok ? theme.green : theme.red }]}>{result.msg}</Text>
</View>
)}
<Button
title={saved ? "Saved ✓" : "Save"}
icon={saved ? undefined : "save"}
onPress={save}
disabled={!cfg.host.trim()}
style={{ marginTop: 12 }}
/>
<Text style={[styles.sectionTitle, { marginTop: 32 }]}>PROJECTS</Text>
{projects.length === 0 ? (
<Text style={styles.intro}>No projects found. Add a project from the AO dashboard.</Text>
) : (
projects.map((p) => (
<View key={p.id} style={styles.projRow}>
<Feather name="folder" size={16} color={theme.textTertiary} />
<Text style={styles.projName}>{p.name}</Text>
{p.sessionPrefix ? <Text style={styles.projPrefix}>{p.sessionPrefix}</Text> : null}
</View>
))
)}
</ScrollView>
</KeyboardAvoidingView>
);
<Text style={[styles.sectionTitle, { marginTop: 32 }]}>PROJECTS</Text>
{projects.length === 0 ? (
<Text style={styles.intro}>No projects found. Add a project from the AO dashboard.</Text>
) : (
projects.map((p) => (
<View key={p.id} style={styles.projRow}>
<Feather name="folder" size={16} color={theme.textTertiary} />
<Text style={styles.projName}>{p.name}</Text>
{p.sessionPrefix ? <Text style={styles.projPrefix}>{p.sessionPrefix}</Text> : null}
</View>
))
)}
</ScrollView>
</KeyboardAvoidingView>
);
}
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 (
<View style={styles.field}>
<Text style={styles.label}>{props.label}</Text>
<TextInput
style={styles.input}
value={props.value}
onChangeText={props.onChangeText}
placeholder={props.placeholder}
placeholderTextColor={theme.textTertiary}
autoCapitalize={props.autoCapitalize}
autoCorrect={false}
keyboardType={props.keyboardType}
/>
</View>
);
return (
<View style={styles.field}>
<Text style={styles.label}>{props.label}</Text>
<TextInput
style={styles.input}
value={props.value}
onChangeText={props.onChangeText}
placeholder={props.placeholder}
placeholderTextColor={theme.textTertiary}
autoCapitalize={props.autoCapitalize}
autoCorrect={false}
keyboardType={props.keyboardType}
/>
</View>
);
}
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 },
});

View File

@ -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 (
<SafeAreaProvider>
<AppProvider>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: theme.bgSurface },
headerTintColor: theme.textPrimary,
headerTitleStyle: { fontWeight: '700' },
headerShadowVisible: false,
contentStyle: { backgroundColor: theme.bgBase },
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="session/[id]"
options={{ title: 'Terminal', headerBackTitle: 'Back' }}
/>
<Stack.Screen
name="spawn"
options={{ presentation: 'modal', title: 'New agent' }}
/>
</Stack>
</AppProvider>
</SafeAreaProvider>
);
return (
<SafeAreaProvider>
<AppProvider>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: theme.bgSurface },
headerTintColor: theme.textPrimary,
headerTitleStyle: { fontWeight: "700" },
headerShadowVisible: false,
contentStyle: { backgroundColor: theme.bgBase },
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="session/[id]" options={{ title: "Terminal", headerBackTitle: "Back" }} />
<Stack.Screen name="spawn" options={{ presentation: "modal", title: "New agent" }} />
</Stack>
</AppProvider>
</SafeAreaProvider>
);
}

View File

@ -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<string, string> = {
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<MuxStatus, string> = {
connecting: 'connecting…',
open: 'live',
closed: 'disconnected',
error: 'error',
connecting: "connecting…",
open: "live",
closed: "disconnected",
error: "error",
};
const statusColors: Record<MuxStatus, string> = {
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<XtermWebViewHandle | null>(null);
const muxRef = useRef<MuxClient | null>(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<TextInput | null>(null);
const xtermRef = useRef<XtermWebViewHandle | null>(null);
const muxRef = useRef<MuxClient | null>(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<TextInput | null>(null);
const [cfg, setCfg] = useState<ServerConfig | null>(null);
const [status, setStatus] = useState<MuxStatus>('connecting');
const [size, setSize] = useState<{ cols: number; rows: number } | null>(null);
const [banner, setBanner] = useState<string | null>(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<ServerConfig | null>(null);
const [status, setStatus] = useState<MuxStatus>("connecting");
const [size, setSize] = useState<{ cols: number; rows: number } | null>(null);
const [banner, setBanner] = useState<string | null>(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 (
<View style={styles.center}>
<Text style={styles.bannerText}>No server configured.</Text>
</View>
);
}
if (cfg && !isConfigured(cfg)) {
return (
<View style={styles.center}>
<Text style={styles.bannerText}>No server configured.</Text>
</View>
);
}
// 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 (
<View style={[styles.screen, Platform.OS === 'ios' && { paddingBottom: kbHeight }]}>
<TextInput
ref={kbInputRef}
value=""
onKeyPress={onKeyPress}
onChangeText={() => {}}
blurOnSubmit={false}
multiline={false}
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
spellCheck={false}
keyboardAppearance="dark"
caretHidden
style={styles.kbInput}
/>
<View style={styles.statusBar}>
<View style={[styles.statusDot, { backgroundColor: statusColors[status] }]} />
<Text style={styles.statusText}>{statusLabel[status]}</Text>
{size && (
<Text style={styles.dims}>
{size.cols}×{size.rows}
</Text>
)}
<Pressable
hitSlop={8}
onPress={confirmKill}
style={({ pressed }) => [styles.killBtn, pressed && { opacity: 0.7 }]}
>
<Feather name="x" size={12} color={theme.red} />
<Text style={styles.killText}>Kill</Text>
</Pressable>
</View>
return (
<View style={[styles.screen, Platform.OS === "ios" && { paddingBottom: kbHeight }]}>
<TextInput
ref={kbInputRef}
value=""
onKeyPress={onKeyPress}
onChangeText={() => {}}
blurOnSubmit={false}
multiline={false}
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
spellCheck={false}
keyboardAppearance="dark"
caretHidden
style={styles.kbInput}
/>
<View style={styles.statusBar}>
<View style={[styles.statusDot, { backgroundColor: statusColors[status] }]} />
<Text style={styles.statusText}>{statusLabel[status]}</Text>
{size && (
<Text style={styles.dims}>
{size.cols}×{size.rows}
</Text>
)}
<Pressable
hitSlop={8}
onPress={confirmKill}
style={({ pressed }) => [styles.killBtn, pressed && { opacity: 0.7 }]}
>
<Feather name="x" size={12} color={theme.red} />
<Text style={styles.killText}>Kill</Text>
</Pressable>
</View>
{banner && (
<Pressable onPress={() => setBanner(null)} style={styles.banner}>
<Text style={styles.bannerText}>{banner} (tap to dismiss)</Text>
</Pressable>
)}
{banner && (
<Pressable onPress={() => setBanner(null)} style={styles.banner}>
<Text style={styles.bannerText}>{banner} (tap to dismiss)</Text>
</Pressable>
)}
<View style={styles.termWrap}>
<XtermJsWebView
ref={xtermRef}
autoFit={false}
xtermOptions={xtermOptions}
webViewOptions={webViewOptions}
logger={logger}
onInitialized={onInitialized}
onData={onData}
style={{ flex: 1, backgroundColor: theme.bgBase }}
/>
</View>
<View style={styles.termWrap}>
<XtermJsWebView
ref={xtermRef}
autoFit={false}
xtermOptions={xtermOptions}
webViewOptions={webViewOptions}
logger={logger}
onInitialized={onInitialized}
onData={onData}
style={{ flex: 1, backgroundColor: theme.bgBase }}
/>
</View>
{compose && (
<View style={[styles.composer, { paddingBottom: bottomPad }]}>
<TextInput
style={styles.composerInput}
value={msg}
onChangeText={setMsg}
placeholder="Message the agent…"
placeholderTextColor={theme.textTertiary}
autoFocus
multiline
keyboardAppearance="dark"
onSubmitEditing={sendPrompt}
/>
<Pressable
style={({ pressed }) => [styles.sendBtn, pressed && { opacity: 0.8 }, !msg.trim() && { opacity: 0.4 }]}
onPress={sendPrompt}
disabled={!msg.trim() || sending}
>
<Feather name="send" size={16} color="#06101f" />
</Pressable>
</View>
)}
{compose && (
<View style={[styles.composer, { paddingBottom: bottomPad }]}>
<TextInput
style={styles.composerInput}
value={msg}
onChangeText={setMsg}
placeholder="Message the agent…"
placeholderTextColor={theme.textTertiary}
autoFocus
multiline
keyboardAppearance="dark"
onSubmitEditing={sendPrompt}
/>
<Pressable
style={({ pressed }) => [styles.sendBtn, pressed && { opacity: 0.8 }, !msg.trim() && { opacity: 0.4 }]}
onPress={sendPrompt}
disabled={!msg.trim() || sending}
>
<Feather name="send" size={16} color="#06101f" />
</Pressable>
</View>
)}
<View style={[styles.keys, { paddingBottom: bottomPad }]}>
{EXTRA_KEYS.map((k) => (
<Pressable
key={k.label}
style={({ pressed }) => [styles.key, pressed && styles.keyPressed]}
onPress={() => sendKey(k.seq)}
>
<Text style={styles.keyText}>{k.label}</Text>
</Pressable>
))}
{/* Compose a high-level message to the agent. */}
<Pressable
style={({ pressed }) => [styles.key, compose && styles.keyToggle, pressed && styles.keyPressed]}
onPress={() => setCompose((c) => !c)}
>
<Feather name="message-square" size={15} color={compose ? theme.blue : theme.textPrimary} />
</Pressable>
{/* Show/hide the keyboard (replaces the OS "Done" button we removed). */}
<Pressable
style={({ pressed }) => [styles.key, styles.keyToggle, pressed && styles.keyPressed]}
onPress={toggleKeyboard}
>
<Text style={styles.keyText}>{kbVisible ? '⌨▾' : '⌨▴'}</Text>
</Pressable>
</View>
</View>
);
<View style={[styles.keys, { paddingBottom: bottomPad }]}>
{EXTRA_KEYS.map((k) => (
<Pressable
key={k.label}
style={({ pressed }) => [styles.key, pressed && styles.keyPressed]}
onPress={() => sendKey(k.seq)}
>
<Text style={styles.keyText}>{k.label}</Text>
</Pressable>
))}
{/* Compose a high-level message to the agent. */}
<Pressable
style={({ pressed }) => [styles.key, compose && styles.keyToggle, pressed && styles.keyPressed]}
onPress={() => setCompose((c) => !c)}
>
<Feather name="message-square" size={15} color={compose ? theme.blue : theme.textPrimary} />
</Pressable>
{/* Show/hide the keyboard (replaces the OS "Done" button we removed). */}
<Pressable
style={({ pressed }) => [styles.key, styles.keyToggle, pressed && styles.keyPressed]}
onPress={toggleKeyboard}
>
<Text style={styles.keyText}>{kbVisible ? "⌨▾" : "⌨▴"}</Text>
</Pressable>
</View>
</View>
);
}
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",
},
});

View File

@ -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<string | null>(null);
const [prompt, setPrompt] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const { projects, activeProjectId, spawn } = useApp();
const [projectId, setProjectId] = useState<string | null>(null);
const [prompt, setPrompt] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<KeyboardAvoidingView
style={styles.screen}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={{ padding: 16 }} keyboardShouldPersistTaps="handled">
<Text style={styles.lead}>
Spawn a worker agent. It gets its own git worktree and branch, then starts on the task you
give it.
</Text>
return (
<KeyboardAvoidingView style={styles.screen} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={{ padding: 16 }} keyboardShouldPersistTaps="handled">
<Text style={styles.lead}>
Spawn a worker agent. It gets its own git worktree and branch, then starts on the task you give it.
</Text>
<Text style={styles.label}>PROJECT</Text>
<View style={styles.projects}>
{projects.map((p) => (
<Pill
key={p.id}
label={p.name}
active={projectId === p.id}
onPress={() => setProjectId(p.id)}
/>
))}
</View>
<Text style={styles.label}>PROJECT</Text>
<View style={styles.projects}>
{projects.map((p) => (
<Pill key={p.id} label={p.name} active={projectId === p.id} onPress={() => setProjectId(p.id)} />
))}
</View>
<Text style={[styles.label, { marginTop: 20 }]}>TASK (OPTIONAL)</Text>
<TextInput
style={styles.input}
value={prompt}
onChangeText={setPrompt}
placeholder="e.g. Fix the flaky login test and open a PR"
placeholderTextColor={theme.textTertiary}
multiline
autoCapitalize="sentences"
/>
<Text style={[styles.label, { marginTop: 20 }]}>TASK (OPTIONAL)</Text>
<TextInput
style={styles.input}
value={prompt}
onChangeText={setPrompt}
placeholder="e.g. Fix the flaky login test and open a PR"
placeholderTextColor={theme.textTertiary}
multiline
autoCapitalize="sentences"
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title="Spawn agent"
icon="zap"
loading={busy}
onPress={onSpawn}
disabled={!projectId}
style={{ marginTop: 20 }}
/>
<Button title="Cancel" variant="ghost" onPress={() => router.back()} style={{ marginTop: 10 }} />
</ScrollView>
</KeyboardAvoidingView>
);
<Button
title="Spawn agent"
icon="zap"
loading={busy}
onPress={onSpawn}
disabled={!projectId}
style={{ marginTop: 20 }}
/>
<Button title="Cancel" variant="ghost" onPress={() => router.back()} style={{ marginTop: 10 }} />
</ScrollView>
</KeyboardAvoidingView>
);
}
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 },
});

View File

@ -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": {}
}
}

6
mobile/images.d.ts vendored
View File

@ -1,6 +1,6 @@
// Static image imports (Metro resolves these to an asset reference at runtime,
// which React Native's <Image source> accepts as a number).
declare module '*.png' {
const content: number;
export default content;
declare module "*.png" {
const content: number;
export default content;
}

View File

@ -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 (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scroll}
contentContainerStyle={styles.row}
>
{items.map((p) => (
<Pill
key={p.id}
label={p.name}
active={activeProjectId === p.id}
onPress={() => setActiveProject(p.id)}
/>
))}
</ScrollView>
);
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scroll}
contentContainerStyle={styles.row}
>
{items.map((p) => (
<Pill key={p.id} label={p.name} active={activeProjectId === p.id} onPress={() => setActiveProject(p.id)} />
))}
</ScrollView>
);
}
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" },
});

View File

@ -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 (
<Pressable
onPress={() =>
router.push({
pathname: '/session/[id]',
params: { id: session.id, projectId: session.projectId },
})
}
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
>
<View style={styles.top}>
<Dot color={v.color} breathing={v.breathing} size={8} />
<Text style={[styles.status, { color: v.color }]}>{v.label}</Text>
<View style={{ flex: 1 }} />
{showProject ? <Text style={styles.project}>{session.projectId}</Text> : null}
<Text style={styles.id}>{session.id}</Text>
</View>
return (
<Pressable
onPress={() =>
router.push({
pathname: "/session/[id]",
params: { id: session.id, projectId: session.projectId },
})
}
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
>
<View style={styles.top}>
<Dot color={v.color} breathing={v.breathing} size={8} />
<Text style={[styles.status, { color: v.color }]}>{v.label}</Text>
<View style={{ flex: 1 }} />
{showProject ? <Text style={styles.project}>{session.projectId}</Text> : null}
<Text style={styles.id}>{session.id}</Text>
</View>
<Text style={styles.title} numberOfLines={2}>
{title}
</Text>
<Text style={styles.title} numberOfLines={2}>
{title}
</Text>
<View style={styles.meta}>
{session.branch ? (
<View style={styles.metaItem}>
<Feather name="git-branch" size={11} color={theme.textTertiary} />
<Text style={styles.branch} numberOfLines={1}>
{session.branch}
</Text>
</View>
) : null}
{pr?.number ? (
<View style={[styles.prChip, { borderColor: ciColor(pr.ciStatus) }]}>
<Dot color={ciColor(pr.ciStatus)} size={6} />
<Text style={styles.prText}>#{pr.number}</Text>
{pr.additions !== undefined && pr.deletions !== undefined ? (
<Text style={styles.diff}>
<Text style={{ color: theme.green }}>+{pr.additions}</Text>{' '}
<Text style={{ color: theme.red }}>{pr.deletions}</Text>
</Text>
) : null}
</View>
) : null}
<View style={{ flex: 1 }} />
<Feather name="terminal" size={15} color={theme.textTertiary} />
</View>
</Pressable>
);
<View style={styles.meta}>
{session.branch ? (
<View style={styles.metaItem}>
<Feather name="git-branch" size={11} color={theme.textTertiary} />
<Text style={styles.branch} numberOfLines={1}>
{session.branch}
</Text>
</View>
) : null}
{pr?.number ? (
<View style={[styles.prChip, { borderColor: ciColor(pr.ciStatus) }]}>
<Dot color={ciColor(pr.ciStatus)} size={6} />
<Text style={styles.prText}>#{pr.number}</Text>
{pr.additions !== undefined && pr.deletions !== undefined ? (
<Text style={styles.diff}>
<Text style={{ color: theme.green }}>+{pr.additions}</Text>{" "}
<Text style={{ color: theme.red }}>{pr.deletions}</Text>
</Text>
) : null}
</View>
) : null}
<View style={{ flex: 1 }} />
<Feather name="terminal" size={15} color={theme.textTertiary} />
</View>
</Pressable>
);
}
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 },
});

View File

@ -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<string, string>;
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<string, string>;
};
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<Response> {
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<Response> {
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<ProjectInfo[]> {
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<SessionsResponse> {
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<SessionsResponse> {
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<void> {
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<void> {
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<void> {
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<void> {
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<DashboardSession> {
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<OrchestratorLink> {
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<void> {
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<void> {
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<number> {
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<string>();
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<string>();
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;
}

View File

@ -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<ServerConfig> {
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<void> {
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<ServerConfig | null>(null);
const [config, setConfig] = useState<ServerConfig | null>(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 };
}

View File

@ -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<typeof setTimeout> | null = null;
private pingTimer: ReturnType<typeof setInterval> | 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<string, string | undefined>();
private subscribed = false;
private ws: WebSocket | null = null;
private cfg: ServerConfig;
private handlers: Handlers;
private closedByUser = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private pingTimer: ReturnType<typeof setInterval> | 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<string, string | undefined>();
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;
}
}

View File

@ -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<void>;
refresh: () => Promise<void>;
setActiveProject: (id: string) => void;
spawn: (prompt?: string, projectId?: string) => Promise<void>;
launchConductor: (projectId: string, clean?: boolean) => Promise<OrchestratorLink>;
merge: (pr: DashboardPR) => Promise<void>;
kill: (id: string) => Promise<void>;
restore: (id: string) => Promise<void>;
send: (id: string, message: string) => Promise<void>;
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<void>;
refresh: () => Promise<void>;
setActiveProject: (id: string) => void;
spawn: (prompt?: string, projectId?: string) => Promise<void>;
launchConductor: (projectId: string, clean?: boolean) => Promise<OrchestratorLink>;
merge: (pr: DashboardPR) => Promise<void>;
kill: (id: string) => Promise<void>;
restore: (id: string) => Promise<void>;
send: (id: string, message: string) => Promise<void>;
};
const AppContext = createContext<AppState | null>(null);
export function useApp(): AppState {
const ctx = useContext(AppContext);
if (!ctx) throw new Error('useApp must be used within <AppProvider>');
return ctx;
const ctx = useContext(AppContext);
if (!ctx) throw new Error("useApp must be used within <AppProvider>");
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<ServerConfig | null>(null);
const [projects, setProjects] = useState<ProjectInfo[]>([]);
const [rawSessions, setRawSessions] = useState<DashboardSession[]>([]);
const [patches, setPatches] = useState<Map<string, SessionPatch>>(new Map());
const [orchestrators, setOrchestrators] = useState<OrchestratorLink[]>([]);
const [orchestratorId, setOrchestratorId] = useState<string | null>(null);
const [stats, setStats] = useState<DashboardStats>({});
const [activeProjectId, setActiveProjectId] = useState<string>('all');
const [connection, setConnection] = useState<MuxStatus>('closed');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<ServerConfig | null>(null);
const [projects, setProjects] = useState<ProjectInfo[]>([]);
const [rawSessions, setRawSessions] = useState<DashboardSession[]>([]);
const [patches, setPatches] = useState<Map<string, SessionPatch>>(new Map());
const [orchestrators, setOrchestrators] = useState<OrchestratorLink[]>([]);
const [orchestratorId, setOrchestratorId] = useState<string | null>(null);
const [stats, setStats] = useState<DashboardStats>({});
const [activeProjectId, setActiveProjectId] = useState<string>("all");
const [connection, setConnection] = useState<MuxStatus>("closed");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const muxRef = useRef<MuxClient | null>(null);
const cfgRef = useRef<ServerConfig | null>(null);
const muxRef = useRef<MuxClient | null>(null);
const cfgRef = useRef<ServerConfig | null>(null);
// Load persisted active project once.
useEffect(() => {
AsyncStorage.getItem(ACTIVE_PROJECT_KEY).then((v) => {
if (v) setActiveProjectId(v);
});
}, []);
// Load persisted active project once.
useEffect(() => {
AsyncStorage.getItem(ACTIVE_PROJECT_KEY).then((v) => {
if (v) setActiveProjectId(v);
});
}, []);
const reloadConfig = useCallback(async () => {
const c = await loadConfig();
cfgRef.current = c;
setConfig(c);
}, []);
const reloadConfig = useCallback(async () => {
const c = await loadConfig();
cfgRef.current = c;
setConfig(c);
}, []);
useEffect(() => {
reloadConfig();
}, [reloadConfig]);
useEffect(() => {
reloadConfig();
}, [reloadConfig]);
const fetchAll = useCallback(async () => {
const c = cfgRef.current;
if (!c || !isConfigured(c)) {
setLoading(false);
return;
}
try {
const [projs, sess] = await Promise.all([
getProjects(c).catch(() => [] as ProjectInfo[]),
getSessions(c, 'all'),
]);
setProjects(projs);
setRawSessions(sess.sessions);
setOrchestrators(sess.orchestrators);
setOrchestratorId(sess.orchestratorId);
setStats(sess.stats);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load');
} finally {
setLoading(false);
}
}, []);
const fetchAll = useCallback(async () => {
const c = cfgRef.current;
if (!c || !isConfigured(c)) {
setLoading(false);
return;
}
try {
const [projs, sess] = await Promise.all([getProjects(c).catch(() => [] as ProjectInfo[]), getSessions(c, "all")]);
setProjects(projs);
setRawSessions(sess.sessions);
setOrchestrators(sess.orchestrators);
setOrchestratorId(sess.orchestratorId);
setStats(sess.stats);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load");
} finally {
setLoading(false);
}
}, []);
// (Re)connect the live mux socket whenever the config changes.
useEffect(() => {
muxRef.current?.disconnect();
muxRef.current = null;
setPatches(new Map());
if (!config || !isConfigured(config)) {
setLoading(false);
return;
}
setLoading(true);
fetchAll();
const mux = new MuxClient(config, {
onStatus: (s) => setConnection(s),
onSessions: (snapshot) => {
setPatches((prev) => {
// Only allocate a new Map (→ re-render) when something actually changed.
// The server re-sends an identical snapshot every 3s when idle.
let changed = false;
const next = new Map(prev);
for (const p of snapshot) {
const old = prev.get(p.id);
if (
!old ||
old.status !== p.status ||
old.activity !== p.activity ||
old.attentionLevel !== p.attentionLevel ||
old.lastActivityAt !== p.lastActivityAt
) {
next.set(p.id, p);
changed = true;
}
}
return changed ? next : prev;
});
},
});
muxRef.current = mux;
mux.connect();
mux.subscribeSessions();
// (Re)connect the live mux socket whenever the config changes.
useEffect(() => {
muxRef.current?.disconnect();
muxRef.current = null;
setPatches(new Map());
if (!config || !isConfigured(config)) {
setLoading(false);
return;
}
setLoading(true);
fetchAll();
const mux = new MuxClient(config, {
onStatus: (s) => setConnection(s),
onSessions: (snapshot) => {
setPatches((prev) => {
// Only allocate a new Map (→ re-render) when something actually changed.
// The server re-sends an identical snapshot every 3s when idle.
let changed = false;
const next = new Map(prev);
for (const p of snapshot) {
const old = prev.get(p.id);
if (
!old ||
old.status !== p.status ||
old.activity !== p.activity ||
old.attentionLevel !== p.attentionLevel ||
old.lastActivityAt !== p.lastActivityAt
) {
next.set(p.id, p);
changed = true;
}
}
return changed ? next : prev;
});
},
});
muxRef.current = mux;
mux.connect();
mux.subscribeSessions();
// REST safety-net poll (full fields the patch stream doesn't carry).
const poll = setInterval(fetchAll, 12000);
return () => {
clearInterval(poll);
mux.disconnect();
muxRef.current = null;
};
}, [config, fetchAll]);
// REST safety-net poll (full fields the patch stream doesn't carry).
const poll = setInterval(fetchAll, 12000);
return () => {
clearInterval(poll);
mux.disconnect();
muxRef.current = null;
};
}, [config, fetchAll]);
// Merge live patches over the REST snapshot. A patch always carries the live
// status/activity/attention, so it is authoritative for those fields (using
// `??` would keep a stale `activity` when the agent went idle → activity:null).
const sessions = useMemo(() => {
if (patches.size === 0) return rawSessions;
const known = new Set(rawSessions.map((s) => s.id));
const merged = rawSessions.map((s) => {
const p = patches.get(s.id);
if (!p) return s;
return {
...s,
status: p.status,
activity: p.activity,
attentionLevel: p.attentionLevel,
lastActivityAt: p.lastActivityAt,
};
});
// Sessions the server pushed over the mux but that aren't in the REST list
// yet (e.g. just spawned by the orchestrator) — surface a minimal card now
// instead of waiting up to 12s for the next poll to fill in full details.
const fallbackProject = projects.length === 1 ? projects[0].id : '';
const extras: DashboardSession[] = [];
for (const [pid, p] of patches) {
if (known.has(pid)) continue;
extras.push({
id: p.id,
projectId: fallbackProject,
status: p.status,
attentionLevel: p.attentionLevel,
activity: p.activity,
branch: null,
issueId: null,
issueTitle: null,
userPrompt: null,
displayName: null,
summary: null,
createdAt: '',
lastActivityAt: p.lastActivityAt,
pr: null,
prs: [],
});
}
return extras.length ? [...merged, ...extras] : merged;
}, [rawSessions, patches, projects]);
// Merge live patches over the REST snapshot. A patch always carries the live
// status/activity/attention, so it is authoritative for those fields (using
// `??` would keep a stale `activity` when the agent went idle → activity:null).
const sessions = useMemo(() => {
if (patches.size === 0) return rawSessions;
const known = new Set(rawSessions.map((s) => s.id));
const merged = rawSessions.map((s) => {
const p = patches.get(s.id);
if (!p) return s;
return {
...s,
status: p.status,
activity: p.activity,
attentionLevel: p.attentionLevel,
lastActivityAt: p.lastActivityAt,
};
});
// Sessions the server pushed over the mux but that aren't in the REST list
// yet (e.g. just spawned by the orchestrator) — surface a minimal card now
// instead of waiting up to 12s for the next poll to fill in full details.
const fallbackProject = projects.length === 1 ? projects[0].id : "";
const extras: DashboardSession[] = [];
for (const [pid, p] of patches) {
if (known.has(pid)) continue;
extras.push({
id: p.id,
projectId: fallbackProject,
status: p.status,
attentionLevel: p.attentionLevel,
activity: p.activity,
branch: null,
issueId: null,
issueTitle: null,
userPrompt: null,
displayName: null,
summary: null,
createdAt: "",
lastActivityAt: p.lastActivityAt,
pr: null,
prs: [],
});
}
return extras.length ? [...merged, ...extras] : merged;
}, [rawSessions, patches, projects]);
const setActiveProject = useCallback((id: string) => {
setActiveProjectId(id);
AsyncStorage.setItem(ACTIVE_PROJECT_KEY, id).catch(() => {});
}, []);
const setActiveProject = useCallback((id: string) => {
setActiveProjectId(id);
AsyncStorage.setItem(ACTIVE_PROJECT_KEY, id).catch(() => {});
}, []);
// Pick a sensible project for actions that need one (spawn / conductor).
const targetProject = useCallback((): string | null => {
if (activeProjectId !== 'all') return activeProjectId;
if (projects.length === 1) return projects[0].id;
return null;
}, [activeProjectId, projects]);
// Pick a sensible project for actions that need one (spawn / conductor).
const targetProject = useCallback((): string | null => {
if (activeProjectId !== "all") return activeProjectId;
if (projects.length === 1) return projects[0].id;
return null;
}, [activeProjectId, projects]);
const spawn = useCallback(
async (prompt?: string, projectId?: string) => {
const c = cfgRef.current;
const proj = projectId ?? targetProject();
if (!c || !proj) throw new Error('Pick a project first');
await spawnSession(c, { projectId: proj, prompt });
await fetchAll();
},
[targetProject, fetchAll],
);
const spawn = useCallback(
async (prompt?: string, projectId?: string) => {
const c = cfgRef.current;
const proj = projectId ?? targetProject();
if (!c || !proj) throw new Error("Pick a project first");
await spawnSession(c, { projectId: proj, prompt });
await fetchAll();
},
[targetProject, fetchAll],
);
const launchConductor = useCallback(
async (projectId: string, clean = false) => {
const c = cfgRef.current!;
const link = await apiLaunchOrchestrator(c, projectId, clean);
await fetchAll();
return link;
},
[fetchAll],
);
const launchConductor = useCallback(
async (projectId: string, clean = false) => {
const c = cfgRef.current!;
const link = await apiLaunchOrchestrator(c, projectId, clean);
await fetchAll();
return link;
},
[fetchAll],
);
const merge = useCallback(
async (pr: DashboardPR) => {
await apiMergePR(cfgRef.current!, pr);
await fetchAll();
},
[fetchAll],
);
const merge = useCallback(
async (pr: DashboardPR) => {
await apiMergePR(cfgRef.current!, pr);
await fetchAll();
},
[fetchAll],
);
const kill = useCallback(
async (id: string) => {
await killSession(cfgRef.current!, id);
await fetchAll();
},
[fetchAll],
);
const kill = useCallback(
async (id: string) => {
await killSession(cfgRef.current!, id);
await fetchAll();
},
[fetchAll],
);
const restore = useCallback(
async (id: string) => {
await restoreSession(cfgRef.current!, id);
await fetchAll();
},
[fetchAll],
);
const restore = useCallback(
async (id: string) => {
await restoreSession(cfgRef.current!, id);
await fetchAll();
},
[fetchAll],
);
const send = useCallback(async (id: string, message: string) => {
await sendMessage(cfgRef.current!, id, message);
}, []);
const send = useCallback(async (id: string, message: string) => {
await sendMessage(cfgRef.current!, id, message);
}, []);
// Memoized so the provider doesn't hand every useApp() consumer a brand-new
// object (→ re-render) on each render. Re-renders now track real state changes.
const value = useMemo<AppState>(
() => ({
config,
configured: !!config && isConfigured(config),
projects,
sessions,
orchestrators,
orchestratorId,
stats,
activeProjectId,
connection,
loading,
error,
reloadConfig,
refresh: fetchAll,
setActiveProject,
spawn,
launchConductor,
merge,
kill,
restore,
send,
}),
[
config,
projects,
sessions,
orchestrators,
orchestratorId,
stats,
activeProjectId,
connection,
loading,
error,
reloadConfig,
fetchAll,
setActiveProject,
spawn,
launchConductor,
merge,
kill,
restore,
send,
],
);
// Memoized so the provider doesn't hand every useApp() consumer a brand-new
// object (→ re-render) on each render. Re-renders now track real state changes.
const value = useMemo<AppState>(
() => ({
config,
configured: !!config && isConfigured(config),
projects,
sessions,
orchestrators,
orchestratorId,
stats,
activeProjectId,
connection,
loading,
error,
reloadConfig,
refresh: fetchAll,
setActiveProject,
spawn,
launchConductor,
merge,
kill,
restore,
send,
}),
[
config,
projects,
sessions,
orchestrators,
orchestratorId,
stats,
activeProjectId,
connection,
loading,
error,
reloadConfig,
fetchAll,
setActiveProject,
spawn,
launchConductor,
merge,
kill,
restore,
send,
],
);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

View File

@ -6,70 +6,60 @@
// red = failing / stuck / crashed
// green = mergeable / passed / done
export const theme = {
// Surfaces (no box-in-box; the card is the only bordered surface)
bgBase: '#0a0b0d',
bgSide: '#08090b',
bgColumn: '#0e0f12',
bgSurface: '#121317', // headers, tab bar, key bar — flush chrome
bgElevated: '#15171b', // cards & inputs
bgElevatedHover: '#191b20',
bgSubtle: 'rgba(255,255,255,0.04)',
term: '#0c0d10',
// Surfaces (no box-in-box; the card is the only bordered surface)
bgBase: "#0a0b0d",
bgSide: "#08090b",
bgColumn: "#0e0f12",
bgSurface: "#121317", // headers, tab bar, key bar — flush chrome
bgElevated: "#15171b", // cards & inputs
bgElevatedHover: "#191b20",
bgSubtle: "rgba(255,255,255,0.04)",
term: "#0c0d10",
textPrimary: '#f4f5f7',
textSecondary: '#9ba1aa',
textTertiary: '#646a73',
textFaint: '#444951',
textPrimary: "#f4f5f7",
textSecondary: "#9ba1aa",
textTertiary: "#646a73",
textFaint: "#444951",
borderSubtle: 'rgba(255,255,255,0.06)',
borderDefault: 'rgba(255,255,255,0.10)',
borderStrong: 'rgba(255,255,255,0.16)',
borderSubtle: "rgba(255,255,255,0.06)",
borderDefault: "rgba(255,255,255,0.10)",
borderStrong: "rgba(255,255,255,0.16)",
// Semantic — rationed
blue: '#4d8dff',
orange: '#f59f4c',
amber: '#e8c14a',
red: '#ef6b6b',
green: '#74b98a',
// Semantic — rationed
blue: "#4d8dff",
orange: "#f59f4c",
amber: "#e8c14a",
red: "#ef6b6b",
green: "#74b98a",
// Faint tints for chip / glow backgrounds
tintBlue: 'rgba(77,141,255,0.14)',
tintOrange: 'rgba(245,159,76,0.14)',
tintAmber: 'rgba(232,193,74,0.14)',
tintRed: 'rgba(239,107,107,0.14)',
tintGreen: 'rgba(116,185,138,0.14)',
// Faint tints for chip / glow backgrounds
tintBlue: "rgba(77,141,255,0.14)",
tintOrange: "rgba(245,159,76,0.14)",
tintAmber: "rgba(232,193,74,0.14)",
tintRed: "rgba(239,107,107,0.14)",
tintGreen: "rgba(116,185,138,0.14)",
// Back-compat aliases (older screens referenced these names)
accent: '#4d8dff',
accentHover: '#6ba0ff',
accentTint: 'rgba(77,141,255,0.14)',
attention: '#e8c14a',
cyan: '#f59f4c',
// Back-compat aliases (older screens referenced these names)
accent: "#4d8dff",
accentHover: "#6ba0ff",
accentTint: "rgba(77,141,255,0.14)",
attention: "#e8c14a",
cyan: "#f59f4c",
fontMono: 'JetBrains Mono, Menlo, ui-monospace, monospace',
fontMono: "JetBrains Mono, Menlo, ui-monospace, monospace",
} as const;
// AO's attention levels, in urgency order. Drives the board sections.
export type AttentionLevel =
| 'merge'
| 'action'
| 'respond'
| 'review'
| 'pending'
| 'working'
| 'done';
export type AttentionLevel = "merge" | "action" | "respond" | "review" | "pending" | "working" | "done";
export const attentionMeta: Record<
string,
{ label: string; color: string; tint: string; order: number }
> = {
merge: { label: 'Ready to merge', color: theme.green, tint: theme.tintGreen, order: 0 },
action: { label: 'Needs you', color: theme.amber, tint: theme.tintAmber, order: 1 },
respond: { label: 'Needs you', color: theme.amber, tint: theme.tintAmber, order: 1 },
review: { label: 'Review', color: theme.red, tint: theme.tintRed, order: 2 },
pending: { label: 'In review', color: theme.textTertiary, tint: theme.bgSubtle, order: 3 },
working: { label: 'Working', color: theme.orange, tint: theme.tintOrange, order: 4 },
done: { label: 'Done', color: theme.textTertiary, tint: theme.bgSubtle, order: 5 },
export const attentionMeta: Record<string, { label: string; color: string; tint: string; order: number }> = {
merge: { label: "Ready to merge", color: theme.green, tint: theme.tintGreen, order: 0 },
action: { label: "Needs you", color: theme.amber, tint: theme.tintAmber, order: 1 },
respond: { label: "Needs you", color: theme.amber, tint: theme.tintAmber, order: 1 },
review: { label: "Review", color: theme.red, tint: theme.tintRed, order: 2 },
pending: { label: "In review", color: theme.textTertiary, tint: theme.bgSubtle, order: 3 },
working: { label: "Working", color: theme.orange, tint: theme.tintOrange, order: 4 },
done: { label: "Done", color: theme.textTertiary, tint: theme.bgSubtle, order: 5 },
};
export type StatusVisual = { color: string; label: string; breathing?: boolean };
@ -77,71 +67,69 @@ export type StatusVisual = { color: string; label: string; breathing?: boolean }
// One status → one dot color + short label. Mirrors AO's getStatusSpec so the
// phone speaks the same visual language as the dashboard.
export function statusVisual(status?: string | null): StatusVisual {
switch (status) {
case 'spawning':
return { color: theme.blue, label: 'Starting' };
case 'working':
return { color: theme.orange, label: 'Working', breathing: true };
case 'detecting':
return { color: theme.orange, label: 'Detecting', breathing: true };
case 'needs_input':
return { color: theme.amber, label: 'Needs input' };
case 'changes_requested':
return { color: theme.amber, label: 'Changes req.' };
case 'stuck':
return { color: theme.red, label: 'Stuck' };
case 'errored':
return { color: theme.red, label: 'Crashed' };
case 'ci_failed':
return { color: theme.red, label: 'CI failed' };
case 'pr_open':
return { color: theme.textSecondary, label: 'PR open' };
case 'review_pending':
return { color: theme.textSecondary, label: 'In review' };
case 'approved':
return { color: theme.green, label: 'Approved' };
case 'mergeable':
return { color: theme.green, label: 'Mergeable' };
case 'merged':
return { color: theme.green, label: 'Merged' };
case 'done':
return { color: theme.green, label: 'Done' };
case 'idle':
return { color: theme.textTertiary, label: 'Idle' };
case 'cleanup':
return { color: theme.textTertiary, label: 'Cleanup' };
case 'killed':
case 'terminated':
return { color: theme.textFaint, label: 'Terminated' };
default:
return { color: theme.textTertiary, label: status ?? 'unknown' };
}
switch (status) {
case "spawning":
return { color: theme.blue, label: "Starting" };
case "working":
return { color: theme.orange, label: "Working", breathing: true };
case "detecting":
return { color: theme.orange, label: "Detecting", breathing: true };
case "needs_input":
return { color: theme.amber, label: "Needs input" };
case "changes_requested":
return { color: theme.amber, label: "Changes req." };
case "stuck":
return { color: theme.red, label: "Stuck" };
case "errored":
return { color: theme.red, label: "Crashed" };
case "ci_failed":
return { color: theme.red, label: "CI failed" };
case "pr_open":
return { color: theme.textSecondary, label: "PR open" };
case "review_pending":
return { color: theme.textSecondary, label: "In review" };
case "approved":
return { color: theme.green, label: "Approved" };
case "mergeable":
return { color: theme.green, label: "Mergeable" };
case "merged":
return { color: theme.green, label: "Merged" };
case "done":
return { color: theme.green, label: "Done" };
case "idle":
return { color: theme.textTertiary, label: "Idle" };
case "cleanup":
return { color: theme.textTertiary, label: "Cleanup" };
case "killed":
case "terminated":
return { color: theme.textFaint, label: "Terminated" };
default:
return { color: theme.textTertiary, label: status ?? "unknown" };
}
}
// Back-compat: older screens import statusColor.
export function statusColor(status?: string | null): string {
return statusVisual(status).color;
return statusVisual(status).color;
}
// Single source of truth for CI status → color (used by the PR chip border and
// the PRs list). Keeps the SessionCard and PRs screen from forking the mapping.
export function ciColor(ci?: string | null): string {
if (ci === 'failing') return theme.red;
if (ci === 'passing') return theme.green;
return theme.textTertiary;
if (ci === "failing") return theme.red;
if (ci === "passing") return theme.green;
return theme.textTertiary;
}
export type CiVisual = {
color: string;
tint: string;
icon: 'check-circle' | 'x-circle' | 'clock';
label: string;
color: string;
tint: string;
icon: "check-circle" | "x-circle" | "clock";
label: string;
};
export function ciVisual(ci?: string | null): CiVisual {
if (ci === 'passing')
return { color: theme.green, tint: theme.tintGreen, icon: 'check-circle', label: 'CI passing' };
if (ci === 'failing')
return { color: theme.red, tint: theme.tintRed, icon: 'x-circle', label: 'CI failing' };
return { color: theme.textSecondary, tint: theme.bgSubtle, icon: 'clock', label: `CI ${ci}` };
if (ci === "passing") return { color: theme.green, tint: theme.tintGreen, icon: "check-circle", label: "CI passing" };
if (ci === "failing") return { color: theme.red, tint: theme.tintRed, icon: "x-circle", label: "CI failing" };
return { color: theme.textSecondary, tint: theme.bgSubtle, icon: "clock", label: `CI ${ci}` };
}

View File

@ -1,363 +1,339 @@
import { Feather } from '@expo/vector-icons';
import { memo, useEffect, useRef, type ReactNode } from 'react';
import { Feather } from "@expo/vector-icons";
import { memo, useEffect, useRef, type ReactNode } from "react";
import {
ActivityIndicator,
Animated,
Image,
Pressable,
StyleSheet,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import { statusVisual, theme } from './theme';
ActivityIndicator,
Animated,
Image,
Pressable,
StyleSheet,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { statusVisual, theme } from "./theme";
// AO mascot glyph (transparent) shown beside each screen heading.
import MASCOT from '../assets/mascot.png';
import MASCOT from "../assets/mascot.png";
// A gently breathing dot — the only motion in the UI, reserved for "working".
// Memoized so an unrelated parent re-render doesn't tear down and restart the
// Animated loop (which causes a visible flicker and per-tick allocations).
export const Dot = memo(function Dot({
color,
size = 9,
breathing = false,
color,
size = 9,
breathing = false,
}: {
color: string;
size?: number;
breathing?: boolean;
color: string;
size?: number;
breathing?: boolean;
}) {
const pulse = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (!breathing) return;
const loop = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 0.35, duration: 1200, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 1, duration: 1200, useNativeDriver: true }),
]),
);
loop.start();
return () => loop.stop();
}, [breathing, pulse]);
const pulse = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (!breathing) return;
const loop = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 0.35, duration: 1200, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 1, duration: 1200, useNativeDriver: true }),
]),
);
loop.start();
return () => loop.stop();
}, [breathing, pulse]);
return (
<Animated.View
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color,
opacity: breathing ? pulse : 1,
}}
/>
);
return (
<Animated.View
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color,
opacity: breathing ? pulse : 1,
}}
/>
);
});
// A selectable pill — used by the project switcher, PR filters, and spawn picker
// so the active/inactive color logic lives in exactly one place.
export function Pill({
label,
active,
onPress,
style,
textStyle,
label,
active,
onPress,
style,
textStyle,
}: {
label: string;
active: boolean;
onPress: () => void;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
label: string;
active: boolean;
onPress: () => void;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}) {
return (
<Pressable onPress={onPress} style={[s.pill, active && s.pillActive, style]}>
<Text style={[s.pillText, active && s.pillTextActive, textStyle]}>{label}</Text>
</Pressable>
);
return (
<Pressable onPress={onPress} style={[s.pill, active && s.pillActive, style]}>
<Text style={[s.pillText, active && s.pillTextActive, textStyle]}>{label}</Text>
</Pressable>
);
}
export function StatusBadge({ status }: { status?: string | null }) {
const v = statusVisual(status);
return (
<View style={s.badge}>
<Dot color={v.color} breathing={v.breathing} size={8} />
<Text style={[s.badgeText, { color: v.color }]}>{v.label}</Text>
</View>
);
const v = statusVisual(status);
return (
<View style={s.badge}>
<Dot color={v.color} breathing={v.breathing} size={8} />
<Text style={[s.badgeText, { color: v.color }]}>{v.label}</Text>
</View>
);
}
export function Chip({
label,
color = theme.textSecondary,
tint = theme.bgSubtle,
mono = false,
icon,
label,
color = theme.textSecondary,
tint = theme.bgSubtle,
mono = false,
icon,
}: {
label: string;
color?: string;
tint?: string;
mono?: boolean;
icon?: keyof typeof Feather.glyphMap;
label: string;
color?: string;
tint?: string;
mono?: boolean;
icon?: keyof typeof Feather.glyphMap;
}) {
return (
<View style={[s.chip, { backgroundColor: tint }]}>
{icon ? <Feather name={icon} size={11} color={color} style={{ marginRight: 4 }} /> : null}
<Text
style={[
s.chipText,
{ color },
mono && { fontFamily: theme.fontMono, fontSize: 11 },
]}
numberOfLines={1}
>
{label}
</Text>
</View>
);
return (
<View style={[s.chip, { backgroundColor: tint }]}>
{icon ? <Feather name={icon} size={11} color={color} style={{ marginRight: 4 }} /> : null}
<Text style={[s.chipText, { color }, mono && { fontFamily: theme.fontMono, fontSize: 11 }]} numberOfLines={1}>
{label}
</Text>
</View>
);
}
export function Card({
children,
onPress,
style,
children,
onPress,
style,
}: {
children: ReactNode;
onPress?: () => void;
style?: StyleProp<ViewStyle>;
children: ReactNode;
onPress?: () => void;
style?: StyleProp<ViewStyle>;
}) {
if (!onPress) return <View style={[s.card, style]}>{children}</View>;
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [s.card, pressed && s.cardPressed, style]}
>
{children}
</Pressable>
);
if (!onPress) return <View style={[s.card, style]}>{children}</View>;
return (
<Pressable onPress={onPress} style={({ pressed }) => [s.card, pressed && s.cardPressed, style]}>
{children}
</Pressable>
);
}
export function SectionHeader({
label,
color,
count,
}: {
label: string;
color: string;
count?: number;
}) {
return (
<View style={s.sectionHeader}>
<View style={[s.sectionBar, { backgroundColor: color }]} />
<Text style={s.sectionLabel}>{label.toUpperCase()}</Text>
{count !== undefined ? <Text style={s.sectionCount}>{count}</Text> : null}
</View>
);
export function SectionHeader({ label, color, count }: { label: string; color: string; count?: number }) {
return (
<View style={s.sectionHeader}>
<View style={[s.sectionBar, { backgroundColor: color }]} />
<Text style={s.sectionLabel}>{label.toUpperCase()}</Text>
{count !== undefined ? <Text style={s.sectionCount}>{count}</Text> : null}
</View>
);
}
export function ScreenHeader({
title,
subtitle,
right,
}: {
title: string;
subtitle?: string;
right?: ReactNode;
}) {
return (
<View style={s.screenHeader}>
<View style={{ flex: 1 }}>
<View style={s.titleRow}>
<Text style={s.screenTitle}>{title}</Text>
<Image source={MASCOT} style={s.mascot} resizeMode="contain" />
</View>
{subtitle ? <Text style={s.screenSubtitle} numberOfLines={1}>{subtitle}</Text> : null}
</View>
{right}
</View>
);
export function ScreenHeader({ title, subtitle, right }: { title: string; subtitle?: string; right?: ReactNode }) {
return (
<View style={s.screenHeader}>
<View style={{ flex: 1 }}>
<View style={s.titleRow}>
<Text style={s.screenTitle}>{title}</Text>
<Image source={MASCOT} style={s.mascot} resizeMode="contain" />
</View>
{subtitle ? (
<Text style={s.screenSubtitle} numberOfLines={1}>
{subtitle}
</Text>
) : null}
</View>
{right}
</View>
);
}
export function Button({
title,
onPress,
variant = 'primary',
loading = false,
disabled = false,
icon,
style,
title,
onPress,
variant = "primary",
loading = false,
disabled = false,
icon,
style,
}: {
title: string;
onPress: () => void;
variant?: 'primary' | 'ghost' | 'danger';
loading?: boolean;
disabled?: boolean;
icon?: keyof typeof Feather.glyphMap;
style?: StyleProp<ViewStyle>;
title: string;
onPress: () => void;
variant?: "primary" | "ghost" | "danger";
loading?: boolean;
disabled?: boolean;
icon?: keyof typeof Feather.glyphMap;
style?: StyleProp<ViewStyle>;
}) {
const isPrimary = variant === 'primary';
const isDanger = variant === 'danger';
const fg = isPrimary ? '#06101f' : isDanger ? theme.red : theme.blue;
return (
<Pressable
onPress={onPress}
disabled={disabled || loading}
style={({ pressed }) => [
s.btn,
isPrimary && s.btnPrimary,
!isPrimary && s.btnGhost,
isDanger && s.btnDanger,
(disabled || loading) && { opacity: 0.5 },
pressed && { opacity: 0.8 },
style,
]}
>
{loading ? (
<ActivityIndicator color={fg} size="small" />
) : (
<View style={s.btnInner}>
{icon ? <Feather name={icon} size={15} color={fg} style={{ marginRight: 7 }} /> : null}
<Text style={[s.btnText, { color: fg }]}>{title}</Text>
</View>
)}
</Pressable>
);
const isPrimary = variant === "primary";
const isDanger = variant === "danger";
const fg = isPrimary ? "#06101f" : isDanger ? theme.red : theme.blue;
return (
<Pressable
onPress={onPress}
disabled={disabled || loading}
style={({ pressed }) => [
s.btn,
isPrimary && s.btnPrimary,
!isPrimary && s.btnGhost,
isDanger && s.btnDanger,
(disabled || loading) && { opacity: 0.5 },
pressed && { opacity: 0.8 },
style,
]}
>
{loading ? (
<ActivityIndicator color={fg} size="small" />
) : (
<View style={s.btnInner}>
{icon ? <Feather name={icon} size={15} color={fg} style={{ marginRight: 7 }} /> : null}
<Text style={[s.btnText, { color: fg }]}>{title}</Text>
</View>
)}
</Pressable>
);
}
export function ConnectionPill({ status }: { status: string }) {
const color =
status === 'open' ? theme.green : status === 'connecting' ? theme.amber : theme.textFaint;
const label =
status === 'open' ? 'live' : status === 'connecting' ? 'connecting' : 'offline';
return (
<View style={s.connPill}>
<Dot color={color} size={6} breathing={status === 'connecting'} />
<Text style={s.connText}>{label}</Text>
</View>
);
const color = status === "open" ? theme.green : status === "connecting" ? theme.amber : theme.textFaint;
const label = status === "open" ? "live" : status === "connecting" ? "connecting" : "offline";
return (
<View style={s.connPill}>
<Dot color={color} size={6} breathing={status === "connecting"} />
<Text style={s.connText}>{label}</Text>
</View>
);
}
export function EmptyState({
icon = 'inbox',
title,
message,
action,
icon = "inbox",
title,
message,
action,
}: {
icon?: keyof typeof Feather.glyphMap;
title: string;
message?: string;
action?: ReactNode;
icon?: keyof typeof Feather.glyphMap;
title: string;
message?: string;
action?: ReactNode;
}) {
return (
<View style={s.empty}>
<View style={s.emptyIcon}>
<Feather name={icon} size={26} color={theme.textTertiary} />
</View>
<Text style={s.emptyTitle}>{title}</Text>
{message ? <Text style={s.emptyMsg}>{message}</Text> : null}
{action ? <View style={{ marginTop: 18 }}>{action}</View> : null}
</View>
);
return (
<View style={s.empty}>
<View style={s.emptyIcon}>
<Feather name={icon} size={26} color={theme.textTertiary} />
</View>
<Text style={s.emptyTitle}>{title}</Text>
{message ? <Text style={s.emptyMsg}>{message}</Text> : null}
{action ? <View style={{ marginTop: 18 }}>{action}</View> : null}
</View>
);
}
const s = StyleSheet.create({
badge: { flexDirection: 'row', alignItems: 'center', gap: 6 },
badgeText: { fontSize: 12, fontWeight: '600' },
badge: { flexDirection: "row", alignItems: "center", gap: 6 },
badgeText: { fontSize: 12, fontWeight: "600" },
pill: {
paddingHorizontal: 14,
paddingVertical: 7,
borderRadius: 20,
borderWidth: 1,
borderColor: theme.borderDefault,
backgroundColor: theme.bgElevated,
},
pillActive: { backgroundColor: theme.tintBlue, borderColor: theme.blue },
pillText: { color: theme.textSecondary, fontSize: 13, fontWeight: '600' },
pillTextActive: { color: theme.blue },
pill: {
paddingHorizontal: 14,
paddingVertical: 7,
borderRadius: 20,
borderWidth: 1,
borderColor: theme.borderDefault,
backgroundColor: theme.bgElevated,
},
pillActive: { backgroundColor: theme.tintBlue, borderColor: theme.blue },
pillText: { color: theme.textSecondary, fontSize: 13, fontWeight: "600" },
pillTextActive: { color: theme.blue },
chip: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
},
chipText: { fontSize: 11, fontWeight: '600' },
chip: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
},
chipText: { fontSize: 11, fontWeight: "600" },
card: {
backgroundColor: theme.bgElevated,
borderRadius: 12,
borderWidth: 1,
borderColor: theme.borderSubtle,
padding: 14,
},
cardPressed: { backgroundColor: theme.bgElevatedHover, borderColor: theme.borderDefault },
card: {
backgroundColor: theme.bgElevated,
borderRadius: 12,
borderWidth: 1,
borderColor: theme.borderSubtle,
padding: 14,
},
cardPressed: { backgroundColor: theme.bgElevatedHover, borderColor: theme.borderDefault },
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: 20,
paddingBottom: 10,
gap: 9,
},
sectionBar: { width: 3, height: 13, borderRadius: 2 },
sectionLabel: {
color: theme.textSecondary,
fontSize: 11,
letterSpacing: 1.2,
fontWeight: '700',
flex: 1,
},
sectionCount: {
color: theme.textTertiary,
fontSize: 12,
fontWeight: '700',
fontFamily: theme.fontMono,
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingTop: 20,
paddingBottom: 10,
gap: 9,
},
sectionBar: { width: 3, height: 13, borderRadius: 2 },
sectionLabel: {
color: theme.textSecondary,
fontSize: 11,
letterSpacing: 1.2,
fontWeight: "700",
flex: 1,
},
sectionCount: {
color: theme.textTertiary,
fontSize: 12,
fontWeight: "700",
fontFamily: theme.fontMono,
},
screenHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 10,
gap: 12,
},
titleRow: { flexDirection: 'row', alignItems: 'center', gap: 9 },
mascot: { width: 30, height: 26, marginTop: 3 },
screenTitle: { color: theme.textPrimary, fontSize: 26, fontWeight: '800', letterSpacing: -0.5 },
screenSubtitle: { color: theme.textTertiary, fontSize: 12, marginTop: 1 },
screenHeader: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 10,
gap: 12,
},
titleRow: { flexDirection: "row", alignItems: "center", gap: 9 },
mascot: { width: 30, height: 26, marginTop: 3 },
screenTitle: { color: theme.textPrimary, fontSize: 26, fontWeight: "800", letterSpacing: -0.5 },
screenSubtitle: { color: theme.textTertiary, fontSize: 12, marginTop: 1 },
btn: { borderRadius: 10, paddingVertical: 13, paddingHorizontal: 16, alignItems: 'center' },
btnInner: { flexDirection: 'row', alignItems: 'center' },
btnPrimary: { backgroundColor: theme.blue },
btnGhost: { borderWidth: 1, borderColor: theme.borderStrong, backgroundColor: theme.bgElevated },
btnDanger: { borderColor: theme.tintRed, backgroundColor: theme.tintRed },
btnText: { fontSize: 15, fontWeight: '700' },
btn: { borderRadius: 10, paddingVertical: 13, paddingHorizontal: 16, alignItems: "center" },
btnInner: { flexDirection: "row", alignItems: "center" },
btnPrimary: { backgroundColor: theme.blue },
btnGhost: { borderWidth: 1, borderColor: theme.borderStrong, backgroundColor: theme.bgElevated },
btnDanger: { borderColor: theme.tintRed, backgroundColor: theme.tintRed },
btnText: { fontSize: 15, fontWeight: "700" },
connPill: { flexDirection: 'row', alignItems: 'center', gap: 5 },
connText: { color: theme.textTertiary, fontSize: 11, fontWeight: '600' },
connPill: { flexDirection: "row", alignItems: "center", gap: 5 },
connText: { color: theme.textTertiary, fontSize: 11, fontWeight: "600" },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 40, minHeight: 320 },
emptyIcon: {
width: 64,
height: 64,
borderRadius: 18,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.borderSubtle,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 18,
},
emptyTitle: { color: theme.textPrimary, fontSize: 17, fontWeight: '700', textAlign: 'center' },
emptyMsg: {
color: theme.textSecondary,
fontSize: 13,
lineHeight: 20,
textAlign: 'center',
marginTop: 8,
maxWidth: 300,
},
empty: { flex: 1, alignItems: "center", justifyContent: "center", padding: 40, minHeight: 320 },
emptyIcon: {
width: 64,
height: 64,
borderRadius: 18,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.borderSubtle,
alignItems: "center",
justifyContent: "center",
marginBottom: 18,
},
emptyTitle: { color: theme.textPrimary, fontSize: 17, fontWeight: "700", textAlign: "center" },
emptyMsg: {
color: theme.textSecondary,
fontSize: 13,
lineHeight: 20,
textAlign: "center",
marginTop: 8,
maxWidth: 300,
},
});

View File

@ -1,38 +1,38 @@
{
"name": "ao-mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"packageManager": "npm@10.9.2",
"dependencies": {
"@fressh/react-native-xtermjs-webview": "^0.0.8",
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "^54.0.35",
"expo-build-properties": "~1.0.10",
"expo-constants": "~18.0.13",
"expo-linking": "~8.0.12",
"expo-router": "~6.0.24",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.2",
"react-native-webview": "13.15.0"
},
"devDependencies": {
"@types/react": "~19.1.10",
"typescript": "~5.9.2"
},
"overrides": {
"postcss": "^8.5.10",
"uuid": "^11.1.1"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"private": true
"name": "ao-mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"packageManager": "npm@10.9.2",
"dependencies": {
"@fressh/react-native-xtermjs-webview": "^0.0.8",
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "^54.0.35",
"expo-build-properties": "~1.0.10",
"expo-constants": "~18.0.13",
"expo-linking": "~8.0.12",
"expo-router": "~6.0.24",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.2",
"react-native-webview": "13.15.0"
},
"devDependencies": {
"@types/react": "~19.1.10",
"typescript": "~5.9.2"
},
"overrides": {
"postcss": "^8.5.10",
"uuid": "^11.1.1"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"private": true
}

View File

@ -1,6 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}