Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/mobile/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
node_modules/
.expo/
dist/
web-build/

# Native
ios/
android/

# Metro
.metro-health-check*

# Misc
*.orig.*
*.log
.DS_Store
60 changes: 60 additions & 0 deletions packages/mobile/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# @axiom-labs/arc-mobile

Expo (managed workflow) scaffold for the ARC mobile companion app.

This is Phase 12 of the v3 daemon pivot — the initial scaffold only. It has
three screens (Agents list, Agent detail placeholder, Settings) and talks to
the ARC daemon over WebSocket using a React-Native-compatible shim around the
shared `@axiom-labs/arc-client` protocol.

## Dev loop

```bash
pnpm install # from repo root
pnpm --filter @axiom-labs/arc-mobile start # launches Expo dev server
```

From the Expo dev server you can:

- Press `i` / `a` to open iOS / Android simulators
- Scan the QR with **Expo Go** (iOS App Store / Google Play) on a real device

Web is intentionally disabled for this scaffold (would require
`react-native-web`); a web preview is a follow-up if/when we need it.

On first launch the app is unpaired. Open **Settings**, enter your daemon
host (`127.0.0.1:7272` or `ws://host:7272`) and the shared token from
`arc daemon token`, then save. The Agents list then connects and shows
what's running on the daemon.

## Typecheck

```bash
pnpm --filter @axiom-labs/arc-mobile typecheck
```

The root `pnpm typecheck` (`tsc --noEmit`) intentionally excludes
`packages/mobile/**` — React Native has its own type graph that conflicts
with the Node-flavoured root config, so mobile is typechecked by its own
`tsconfig.json`.

## WebSocket shim

`@axiom-labs/arc-client` imports the Node `ws` package, which is not
available in React Native. The mobile app therefore uses
[`src/arc-client-rn.ts`](./src/arc-client-rn.ts) — a small shim that
re-implements `connect / call / subscribe / attachTerminal` on top of
`global.WebSocket` (built into RN) while reusing the shared `protocol.ts`
and `frame.ts` exports from the SDK.

Proper cross-runtime packaging of the SDK (browser build + conditional
exports) is a Phase-4+ follow-up; the shim is intentionally small.

## Scope

**In:** boot, three screens, daemon WebSocket connection, token-based pairing,
AsyncStorage persistence.

**Out (follow-ups):** QR-code pairing, voice input, push notifications, App
Store / Play Store signing & submission, `expo prebuild` / native folders,
live terminal streaming.
27 changes: 27 additions & 0 deletions packages/mobile/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"expo": {
"name": "ARC",
"slug": "arc-mobile",
"version": "1.0.0",
"orientation": "portrait",
"scheme": "arc",
"userInterfaceStyle": "automatic",
"newArchEnabled": false,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "sh.arc.mobile"
},
"android": {
"package": "sh.arc.mobile",
"adaptiveIcon": {
"backgroundColor": "#000000"
}
},
"plugins": [
"expo-router"
],
"experiments": {
"typedRoutes": true
}
}
}
22 changes: 22 additions & 0 deletions packages/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { ClientProvider } from "../src/client-provider";

export default function RootLayout() {
return (
<ClientProvider>
<StatusBar style="auto" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: "#000" },
headerTintColor: "#fff",
contentStyle: { backgroundColor: "#000" },
}}
>
<Stack.Screen name="index" options={{ title: "Agents" }} />
<Stack.Screen name="agent/[id]" options={{ title: "Agent" }} />
<Stack.Screen name="settings" options={{ title: "Settings" }} />
</Stack>
</ClientProvider>
);
}
58 changes: 58 additions & 0 deletions packages/mobile/app/agent/[id].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { useArcClient } from "../../src/client-provider";

export default function AgentDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { client, status } = useArcClient();
const [agentStatus, setAgentStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
async function load() {
if (!client || status !== "connected" || !id) return;
try {
const result = await client.agents.list();
if (cancelled) return;
const match = result.agents.find((a) => a.id === id);
setAgentStatus(match?.status ?? "unknown");
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
}
}
void load();
return () => {
cancelled = true;
};
}, [client, status, id]);

return (
<View style={styles.container}>
<Text style={styles.label}>Agent ID</Text>
<Text style={styles.value}>{id ?? "(missing)"}</Text>

<Text style={styles.label}>Status</Text>
<Text style={styles.value}>{error ?? agentStatus ?? "…"}</Text>

<Text style={styles.placeholder}>
Live output, input, and controls coming in a later phase.
</Text>
</View>
);
}

const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#000", padding: 20 },
label: {
color: "#666",
fontSize: 12,
textTransform: "uppercase",
letterSpacing: 1,
marginTop: 16,
},
value: { color: "#fff", fontSize: 16, marginTop: 4 },
placeholder: { color: "#555", marginTop: 32, fontStyle: "italic" },
});
164 changes: 164 additions & 0 deletions packages/mobile/app/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Link, useFocusEffect } from "expo-router";
import { useCallback, useState } from "react";
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import { useArcClient } from "../src/client-provider";

type Agent = Awaited<
ReturnType<NonNullable<ReturnType<typeof useArcClient>["client"]>["agents"]["list"]>
>["agents"][number];

export default function AgentsScreen() {
const { client, status, error } = useArcClient();
const [agents, setAgents] = useState<Agent[] | null>(null);
const [fetchError, setFetchError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

const load = useCallback(async () => {
if (!client || status !== "connected") {
setAgents(null);
return;
}
setLoading(true);
setFetchError(null);
try {
const result = await client.agents.list();
setAgents(result.agents);
} catch (err) {
setFetchError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [client, status]);

useFocusEffect(
useCallback(() => {
void load();
}, [load]),
);

return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.statusText}>
{status === "connected"
? "Connected"
: status === "connecting"
? "Connecting…"
: status === "error"
? `Error: ${error ?? "unknown"}`
: "Not paired"}
</Text>
<Link href="/settings" asChild>
<Pressable style={styles.settingsButton}>
<Text style={styles.settingsButtonText}>Settings</Text>
</Pressable>
</Link>
</View>

{status !== "connected" ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>No daemon connection</Text>
<Text style={styles.emptyBody}>
Pair this device with your ARC daemon to view running agents.
</Text>
<Link href="/settings" asChild>
<Pressable style={styles.primaryButton}>
<Text style={styles.primaryButtonText}>Pair with daemon</Text>
</Pressable>
</Link>
</View>
) : loading && !agents ? (
<View style={styles.empty}>
<ActivityIndicator color="#fff" />
</View>
) : fetchError ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>Failed to load agents</Text>
<Text style={styles.emptyBody}>{fetchError}</Text>
<Pressable style={styles.primaryButton} onPress={load}>
<Text style={styles.primaryButtonText}>Retry</Text>
</Pressable>
</View>
) : !agents || agents.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>No active agents</Text>
<Text style={styles.emptyBody}>
Start an agent from the ARC CLI or dashboard; it will show up here.
</Text>
</View>
) : (
<FlatList
data={agents}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Link href={`/agent/${item.id}`} asChild>
<Pressable style={styles.row}>
<Text style={styles.rowTitle}>{item.profile}</Text>
<Text style={styles.rowSubtitle}>
{item.id} · {item.status}
</Text>
</Pressable>
</Link>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
refreshing={loading}
onRefresh={load}
/>
)}
</View>
);
}

const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#000" },
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomColor: "#222",
borderBottomWidth: 1,
},
statusText: { color: "#aaa", fontSize: 13 },
settingsButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
backgroundColor: "#111",
borderColor: "#333",
borderWidth: 1,
},
settingsButtonText: { color: "#fff", fontSize: 13 },
empty: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 24,
},
emptyTitle: { color: "#fff", fontSize: 18, fontWeight: "600", marginBottom: 8 },
emptyBody: {
color: "#888",
fontSize: 14,
textAlign: "center",
marginBottom: 16,
},
primaryButton: {
backgroundColor: "#fff",
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 6,
},
primaryButtonText: { color: "#000", fontWeight: "600" },
row: { paddingHorizontal: 16, paddingVertical: 14 },
rowTitle: { color: "#fff", fontSize: 16, fontWeight: "500" },
rowSubtitle: { color: "#888", fontSize: 12, marginTop: 2 },
separator: { height: 1, backgroundColor: "#1a1a1a" },
});
Loading
Loading