mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-31 11:36:25 +00:00
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:
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
Reference in New Issue
Block a user