Implement Handler mobile app (React Native + Expo iOS)

Build the committed 2a interactive prototype from Handler Mobile.dc.html as
a real Expo app: six wired screens (Fleet, Agent detail, Answer, Spawn, Log,
Settings) with the exact state logic ported from the design's renderVals().

- Port Leeworks tokens (colors light/dark, typography, spacing, radii,
  shadows) to typed RN values in src/theme.
- Reimplement the design-system components used by the screens (Button,
  Badge, Icon, Switch, Select, TextField, segmented control, chip, tab bar).
- Shared store mirrors the prototype's single-screen navigation and the
  Waiting -> Running flip when agt-7a1d is answered.
- Real OS status bar / home indicator (safe-area insets) replace the mock
  phone chrome; dark mode follows system appearance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdF2hHVQ4UUBFPtpeMQ81o
This commit is contained in:
Claude
2026-07-20 18:48:30 +00:00
parent f143959b52
commit daecaa7e6b
31 changed files with 13423 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
import React from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { fonts, radius, text } from "../theme/tokens";
import { useTheme } from "../theme/useTheme";
import { Button } from "../components/Button";
import { PageHeader } from "../components/PageHeader";
import { SegmentedControl } from "../components/SegmentedControl";
import { Card, Divider, Mono, SectionLabel } from "../components/primitives";
import { useAppState } from "../state/AppState";
import { detailLog, detailMeta } from "../data/mock";
export function AgentDetailScreen() {
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const {
go,
detailTab,
setDetailTab,
notAnswered,
agentTone,
agentStatus,
agentStateText,
} = useAppState();
return (
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
<View style={{ height: insets.top }} />
<ScrollView
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + 20 },
]}
showsVerticalScrollIndicator={false}
>
<PageHeader
leading="back"
onLeadingPress={() => go("fleet")}
agentId="agt-7a1d"
badge={{ tone: agentTone, label: agentStatus }}
/>
<Text style={[text.h3, { color: colors.textHeading }]}>
Migrate agent state to sqlite
</Text>
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 4, marginBottom: 16 }]}>
handler · branch <Mono>agt/7a1d</Mono>
</Text>
<View style={{ marginBottom: 16 }}>
<SegmentedControl
segments={[
{ value: "state", label: "Checkmark" },
{ value: "log", label: "Log" },
]}
value={detailTab}
onChange={setDetailTab}
/>
</View>
{detailTab === "state" ? (
<>
<View
style={[
styles.sunkenCard,
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
]}
>
<SectionLabel style={{ marginBottom: 8 }}>Current state</SectionLabel>
<Mono style={[styles.stateText, { color: colors.textBody }]}>
{agentStateText}
</Mono>
<Text style={[text.caption, { color: colors.textMuted, marginTop: 10 }]}>
updated 2m ago
</Text>
</View>
<Card style={{ marginTop: 16 }}>
{detailMeta.map((m, i) => (
<View key={m.label}>
{i > 0 && <Divider />}
<View style={styles.metaRow}>
<Text style={[text.bodySm, { color: colors.textMuted }]}>
{m.label}
</Text>
<Mono style={{ fontSize: 13, color: colors.textHeading }}>
{m.value}
</Mono>
</View>
</View>
))}
</Card>
</>
) : (
<View
style={[
styles.sunkenCard,
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
]}
>
{detailLog.map((row) => (
<View key={row.t} style={styles.logRow}>
<Mono style={[styles.logMono, { color: colors.ink4 }]}>
{row.t}
</Mono>
<Mono style={[styles.logMono, { color: colors[row.color] }]}>
{row.msg}
</Mono>
</View>
))}
</View>
)}
<View style={styles.actions}>
{notAnswered && (
<Button size="lg" style={{ flex: 1 }} onPress={() => go("answer")}>
Answer
</Button>
)}
<Button size="lg" variant="secondary" style={{ flex: 1 }}>
Pause
</Button>
<Button size="lg" variant="danger">
Kill
</Button>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { paddingTop: 8, paddingHorizontal: 20 },
sunkenCard: {
borderWidth: 1,
borderRadius: radius.lg,
padding: 16,
},
stateText: {
fontSize: 13,
lineHeight: 22,
},
metaRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 16,
},
logRow: { flexDirection: "row", gap: 10 },
logMono: {
fontSize: 12.5,
lineHeight: 26,
},
actions: {
flexDirection: "row",
gap: 10,
marginTop: 20,
},
});
+106
View File
@@ -0,0 +1,106 @@
import React, { useState } from "react";
import {
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { radius, text } from "../theme/tokens";
import { useTheme } from "../theme/useTheme";
import { Button } from "../components/Button";
import { Chip } from "../components/Chip";
import { PageHeader } from "../components/PageHeader";
import { TextField } from "../components/TextField";
import { Mono, SectionLabel } from "../components/primitives";
import { useAppState } from "../state/AppState";
import { quickReplyLabels } from "../data/mock";
const QUESTION =
"Migrations pass on the new sqlite store. Should I drop the legacy JSON store entirely, or keep it as a read-only fallback for one release?";
export function AnswerScreen() {
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const { go, quickPick, setQuickPick, sendResume } = useAppState();
const [reply, setReply] = useState("");
return (
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
<View style={{ height: insets.top }} />
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={[styles.content, { paddingBottom: insets.bottom + 20 }]}>
<PageHeader
leading="back"
onLeadingPress={() => go("detail")}
agentId="agt-7a1d"
badge={{ tone: "warning", label: "Waiting" }}
/>
<Text style={[text.h3, { color: colors.textHeading, marginBottom: 16 }]}>
Agent needs input
</Text>
<View
style={[
styles.questionCard,
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
]}
>
<SectionLabel style={{ marginBottom: 8 }}>Question · 2m ago</SectionLabel>
<Mono style={[styles.questionText, { color: colors.textBody }]}>
{QUESTION}
</Mono>
</View>
<SectionLabel style={{ marginBottom: 10 }}>Quick replies</SectionLabel>
<View style={styles.chips}>
{quickReplyLabels.map((label, i) => (
<Chip
key={label}
label={label}
selected={quickPick === i}
onPress={() => setQuickPick(i)}
/>
))}
</View>
<View style={styles.footer}>
<TextField
value={reply}
onChangeText={setReply}
placeholder="Or type a reply…"
/>
<Button size="lg" style={{ width: "100%" }} onPress={sendResume}>
Send & resume
</Button>
</View>
</View>
</KeyboardAvoidingView>
</View>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
flex: { flex: 1 },
content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 },
questionCard: {
borderWidth: 1,
borderRadius: radius.lg,
padding: 16,
marginBottom: 20,
},
questionText: { fontSize: 13, lineHeight: 22 },
chips: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
marginBottom: 20,
},
footer: { marginTop: "auto", gap: 12 },
});
+205
View File
@@ -0,0 +1,205 @@
import React from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { fonts, text } from "../theme/tokens";
import { useTheme } from "../theme/useTheme";
import { Button } from "../components/Button";
import { Icon } from "../components/Icon";
import {
Card,
Divider,
Mono,
SectionLabel,
StatusDot,
} from "../components/primitives";
import { TabBar } from "../components/TabBar";
import { useAppState } from "../state/AppState";
import {
recentCheckmarks,
waitingAgents,
type Checkmark,
type WaitingAgent,
} from "../data/mock";
export function FleetScreen() {
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const { go, notAnswered } = useAppState();
const waiting = waitingAgents.filter((a) => !(a.clearsOnAnswer && !notAnswered));
const stats = [
{ label: "Running", value: "6", tint: colors.textHeading },
{ label: "Waiting", value: "3", tint: colors.warning },
{ label: "Done", value: "42", tint: colors.textHeading },
];
return (
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
<View style={{ height: insets.top }} />
<ScrollView
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<View style={styles.headerRow}>
<View style={{ flex: 1 }}>
<Text style={[text.h3, { color: colors.textHeading }]}>Fleet</Text>
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 2 }]}>
12 agents · 3 waiting on you
</Text>
</View>
<Button size="sm" onPress={() => go("spawn")}>
New
</Button>
</View>
<View style={styles.statsRow}>
{stats.map((s) => (
<Card key={s.label} style={styles.statCard}>
<Text style={[text.caption, { color: colors.textMuted }]}>
{s.label}
</Text>
<Text style={[styles.statValue, { color: s.tint }]}>
{s.value}
</Text>
</Card>
))}
</View>
<SectionLabel style={styles.overline}>Waiting on you</SectionLabel>
<Card style={styles.section}>
{waiting.map((a, i) => (
<View key={a.id}>
{i > 0 && <Divider />}
<WaitingRow agent={a} onAnswer={() => go("answer")} />
</View>
))}
</Card>
<SectionLabel style={styles.overline}>Recent checkmarks</SectionLabel>
<Card>
{recentCheckmarks.map((c, i) => (
<View key={c.title}>
{i > 0 && <Divider />}
<CheckmarkRow checkmark={c} onPress={() => go("detail")} />
</View>
))}
</Card>
</ScrollView>
<TabBar active="fleet" />
</View>
);
}
function WaitingRow({
agent,
onAnswer,
}: {
agent: WaitingAgent;
onAnswer: () => void;
}) {
const { colors } = useTheme();
return (
<View style={styles.rowPad}>
<View style={styles.waitingTop}>
<Mono style={{ fontSize: 12, color: colors.textMuted }}>{agent.id}</Mono>
<Text
numberOfLines={1}
style={[styles.rowTitle, { color: colors.textHeading, flex: 1 }]}
>
{agent.title}
</Text>
</View>
<View style={styles.waitingBottom}>
<Text
style={[
text.bodySm,
{ color: colors.textBody, flex: 1, fontFamily: fonts.bodyItalic },
]}
>
{agent.question}
</Text>
<Button size="sm" variant="secondary" onPress={onAnswer}>
Answer
</Button>
</View>
</View>
);
}
function CheckmarkRow({
checkmark,
onPress,
}: {
checkmark: Checkmark;
onPress: () => void;
}) {
const { colors } = useTheme();
const dot = checkmark.status === "positive" ? colors.positive : colors.danger;
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.checkmarkRow,
pressed && { backgroundColor: colors.surfaceSunken },
]}
>
<StatusDot color={dot} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text
numberOfLines={1}
style={[styles.rowTitle, { color: colors.textHeading }]}
>
{checkmark.title}
</Text>
<Text style={[text.caption, { color: colors.textMuted, marginTop: 2 }]}>
{checkmark.meta}
</Text>
</View>
<Icon name="chevronRight" size={16} color={colors.ink4} />
</Pressable>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { paddingTop: 8, paddingHorizontal: 20, paddingBottom: 20 },
headerRow: {
flexDirection: "row",
alignItems: "center",
gap: 12,
marginTop: 12,
marginBottom: 20,
},
statsRow: { flexDirection: "row", gap: 12, marginBottom: 20 },
statCard: { flex: 1, paddingVertical: 14, paddingHorizontal: 16 },
statValue: {
fontFamily: fonts.monoSemiBold,
fontSize: 24,
lineHeight: 28,
marginTop: 4,
},
overline: { marginBottom: 10 },
section: { marginBottom: 20 },
rowPad: { paddingVertical: 14, paddingHorizontal: 16 },
waitingTop: { flexDirection: "row", alignItems: "center", gap: 8 },
waitingBottom: {
flexDirection: "row",
alignItems: "center",
gap: 10,
marginTop: 8,
},
rowTitle: {
fontFamily: fonts.bodySemiBold,
fontSize: 13,
lineHeight: 16,
},
checkmarkRow: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingVertical: 14,
paddingHorizontal: 16,
minHeight: 44,
},
});
+89
View File
@@ -0,0 +1,89 @@
import React from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { text } from "../theme/tokens";
import { useTheme } from "../theme/useTheme";
import { Chip } from "../components/Chip";
import { Mono, SectionLabel } from "../components/primitives";
import { TabBar } from "../components/TabBar";
import { useAppState, type LogFilter } from "../state/AppState";
import { allLog } from "../data/mock";
const FILTERS: { key: LogFilter; label: string }[] = [
{ key: "all", label: "All" },
{ key: "handler", label: "handler" },
{ key: "errors", label: "Errors" },
];
export function LogScreen() {
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const { logFilter, setLogFilter } = useAppState();
const entries = allLog.filter((e) =>
logFilter === "all" ? true : logFilter === "errors" ? e.err : e.p === "handler"
);
return (
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
<View style={{ height: insets.top }} />
<View style={styles.header}>
<Text style={[text.h3, { color: colors.textHeading }]}>Log</Text>
<View style={styles.filters}>
{FILTERS.map((f) => (
<Chip
key={f.key}
label={f.label}
selected={logFilter === f.key}
onPress={() => setLogFilter(f.key)}
/>
))}
</View>
</View>
<View style={styles.todayLabel}>
<SectionLabel>Today</SectionLabel>
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={[
styles.feed,
{ backgroundColor: colors.surfaceSunken, borderTopColor: colors.borderSubtle },
]}
showsVerticalScrollIndicator={false}
>
{entries.map((e, i) => (
<View key={`${e.t}-${i}`} style={styles.logRow}>
<Mono style={[styles.mono, { color: colors.ink4 }]}>{e.t}</Mono>
<Mono style={[styles.mono, styles.idCol, { color: colors.ink6 }]}>
{e.id}
</Mono>
<Mono style={[styles.mono, { color: colors[e.color], flex: 1 }]}>
{e.msg}
</Mono>
</View>
))}
</ScrollView>
<TabBar active="log" />
</View>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { paddingHorizontal: 20, paddingTop: 20, paddingBottom: 16 },
filters: { flexDirection: "row", gap: 8, marginTop: 14 },
todayLabel: { paddingHorizontal: 20, paddingBottom: 8 },
feed: {
borderTopWidth: 1,
paddingHorizontal: 20,
paddingVertical: 16,
flexGrow: 1,
},
logRow: { flexDirection: "row", gap: 10 },
mono: { fontSize: 12.5, lineHeight: 26 },
idCol: { width: 66 },
});
+101
View File
@@ -0,0 +1,101 @@
import React from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { text } from "../theme/tokens";
import { useTheme } from "../theme/useTheme";
import { Button } from "../components/Button";
import { ToggleRow } from "../components/ToggleRow";
import {
Card,
Divider,
Mono,
SectionLabel,
StatusDot,
} from "../components/primitives";
import { TabBar } from "../components/TabBar";
import { useAppState } from "../state/AppState";
export function SettingsScreen() {
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const { swPushWait, setSwPushWait, swPushFail, setSwPushFail } = useAppState();
return (
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
<View style={{ height: insets.top }} />
<ScrollView
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<Text style={[text.h3, { color: colors.textHeading, marginVertical: 12, marginBottom: 20 }]}>
Settings
</Text>
<SectionLabel style={{ marginBottom: 10 }}>Server</SectionLabel>
<Card style={{ marginBottom: 20 }}>
<InfoRow label="Endpoint" value="https://handler.wheaty.dev" />
<Divider />
<InfoRow label="API key" value="hnd_••••••••4f2a" />
<Divider />
<View style={styles.infoRow}>
<Text style={[text.label, { color: colors.textHeading }]}>Status</Text>
<View style={styles.statusValue}>
<StatusDot color={colors.positive} size={7} />
<Mono style={[styles.valueMono, { color: colors.positive }]}>
connected · 38ms
</Mono>
</View>
</View>
</Card>
<SectionLabel style={{ marginBottom: 10 }}>Notifications</SectionLabel>
<Card style={{ marginBottom: 20 }}>
<ToggleRow
title="Waiting on input"
subtitle="Push when an agent pauses"
value={swPushWait}
onValueChange={setSwPushWait}
/>
<Divider />
<ToggleRow
title="Failures"
subtitle="Push when a checkmark fails"
value={swPushFail}
onValueChange={setSwPushFail}
/>
</Card>
<Button variant="secondary" size="lg" style={{ width: "100%" }}>
Sign out
</Button>
</ScrollView>
<TabBar active="settings" />
</View>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
const { colors } = useTheme();
return (
<View style={styles.infoRow}>
<Text style={[text.label, { color: colors.textHeading }]}>{label}</Text>
<Mono style={[styles.valueMono, { color: colors.textMuted }]}>{value}</Mono>
</View>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { paddingTop: 8, paddingHorizontal: 20, paddingBottom: 20 },
infoRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 14,
paddingHorizontal: 16,
minHeight: 44,
gap: 12,
},
statusValue: { flexDirection: "row", alignItems: "center", gap: 6 },
valueMono: { fontSize: 12.5 },
});
+98
View File
@@ -0,0 +1,98 @@
import React, { useState } from "react";
import {
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { text } from "../theme/tokens";
import { useTheme } from "../theme/useTheme";
import { Button } from "../components/Button";
import { PageHeader } from "../components/PageHeader";
import { Select } from "../components/Select";
import { TextField } from "../components/TextField";
import { ToggleRow } from "../components/ToggleRow";
import { Card, Divider } from "../components/primitives";
import { useAppState } from "../state/AppState";
import { projectOptions } from "../data/mock";
export function SpawnScreen() {
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const { go, spawnGo, swEdits, setSwEdits, swTests, setSwTests } = useAppState();
const [project, setProject] = useState("handler");
const [task, setTask] = useState("");
return (
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
<View style={{ height: insets.top }} />
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={[styles.content, { paddingBottom: insets.bottom + 20 }]}>
<PageHeader
leading="close"
onLeadingPress={() => go("fleet")}
title="New agent"
/>
<View style={{ gap: 16 }}>
<Select
label="Project"
options={projectOptions}
value={project}
onChange={setProject}
/>
<View>
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
Task
</Text>
<TextField
value={task}
onChangeText={setTask}
placeholder="What should this agent do?"
multiline
height={120}
/>
<Text style={[text.caption, { color: colors.textMuted, marginTop: 6 }]}>
Runs isolated on a fresh branch.
</Text>
</View>
<Card>
<ToggleRow
title="Auto-approve edits"
subtitle="Skip file-edit confirmations"
value={swEdits}
onValueChange={setSwEdits}
/>
<Divider />
<ToggleRow
title="Run tests on done"
subtitle="Checkmark fails if tests fail"
value={swTests}
onValueChange={setSwTests}
/>
</Card>
</View>
<View style={{ marginTop: "auto" }}>
<Button size="lg" style={{ width: "100%" }} onPress={spawnGo}>
Spawn agent
</Button>
</View>
</View>
</KeyboardAvoidingView>
</View>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
flex: { flex: 1 },
content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 },
});