mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-03 15:16:26 +00:00
feat(app): replace mock data with live Handler API
Wire the mobile app to the real Handler API instead of the transcribed prototype data: - src/api/client.ts: typed client ported from frontend/lib/api.ts (bearer auth, AuthError/ApiError, base URL param, allow401 for admin-only resume) - src/api/format.ts: relative-time + status label/tone/color helpers - src/state/ServerConfig.tsx: endpoint+token persisted to AsyncStorage - src/screens/ConnectScreen.tsx: first-open config, validated via /health then /projects - src/state/AppState.tsx: data-driven store polling projects -> agents -> checkmarks -> logs every 10s with per-item failure isolation; derives fleet counts, waiting list, recent checkmarks, merged log; answer+resume, spawn, kill mutations; 401 routes back to ConnectScreen - screens render live data; detail meta is Started/Status/Tests/Build; Pause removed (no endpoint); Kill confirms; log filters are per-project - delete src/data/mock.ts tsc clean; Hermes bundle builds (200).
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { Alert, 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";
|
||||
@@ -8,43 +8,93 @@ 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";
|
||||
import {
|
||||
clockTime,
|
||||
statusColor,
|
||||
statusLabel,
|
||||
statusTone,
|
||||
timeAgo,
|
||||
} from "../api/format";
|
||||
|
||||
export function AgentDetailScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const {
|
||||
go,
|
||||
openAnswer,
|
||||
detailTab,
|
||||
setDetailTab,
|
||||
notAnswered,
|
||||
agentTone,
|
||||
agentStatus,
|
||||
agentStateText,
|
||||
selectedAgent,
|
||||
selectedCheckmark,
|
||||
selectedLog,
|
||||
kill,
|
||||
} = useAppState();
|
||||
|
||||
if (!selectedAgent) {
|
||||
return (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<View style={styles.content}>
|
||||
<PageHeader leading="back" onLeadingPress={() => go("fleet")} title="Agent" />
|
||||
<Text style={[text.body, { color: colors.textMuted }]}>
|
||||
This agent is no longer in the fleet.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const agent = selectedAgent;
|
||||
const cm = selectedCheckmark;
|
||||
const openQuestion = cm?.open_question?.trim();
|
||||
|
||||
const meta = [
|
||||
{ label: "Started", value: timeAgo(agent.created_at) },
|
||||
{ label: "Status", value: statusLabel(agent.status) },
|
||||
{ label: "Tests", value: cm ? statusLabel(cm.tests_status) : "—" },
|
||||
{ label: "Build", value: cm ? statusLabel(cm.build_status) : "—" },
|
||||
];
|
||||
|
||||
const logRows = [...selectedLog].sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
|
||||
function confirmKill() {
|
||||
Alert.alert(
|
||||
"Kill agent?",
|
||||
`Stop ${agent.name} and end its session. This can’t be undone.`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Kill",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
void kill(agent.project_id, agent.name).finally(() => go("fleet"));
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{ paddingBottom: insets.bottom + 20 },
|
||||
]}
|
||||
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 20 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<PageHeader
|
||||
leading="back"
|
||||
onLeadingPress={() => go("fleet")}
|
||||
agentId="agt-7a1d"
|
||||
badge={{ tone: agentTone, label: agentStatus }}
|
||||
agentId={agent.name}
|
||||
badge={{ tone: statusTone(agent.status), label: statusLabel(agent.status) }}
|
||||
/>
|
||||
|
||||
<Text style={[text.h3, { color: colors.textHeading }]}>
|
||||
Migrate agent state to sqlite
|
||||
</Text>
|
||||
<Text style={[text.h3, { color: colors.textHeading }]}>{agent.name}</Text>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 4, marginBottom: 16 }]}>
|
||||
handler · branch <Mono>agt/7a1d</Mono>
|
||||
{agent.project_id}
|
||||
{agent.role ? " · " : ""}
|
||||
{agent.role ? <Mono>{agent.role}</Mono> : null}
|
||||
</Text>
|
||||
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
@@ -66,25 +116,63 @@ export function AgentDetailScreen() {
|
||||
{ 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>
|
||||
{cm ? (
|
||||
<>
|
||||
<SectionLabel style={{ marginBottom: 8 }}>Where it stopped</SectionLabel>
|
||||
<Mono style={[styles.stateText, { color: colors.textBody }]}>
|
||||
{cm.where_it_stopped?.trim() || "—"}
|
||||
</Mono>
|
||||
|
||||
{cm.next_steps && cm.next_steps.length > 0 ? (
|
||||
<>
|
||||
<SectionLabel style={{ marginTop: 14, marginBottom: 8 }}>
|
||||
Next steps
|
||||
</SectionLabel>
|
||||
{cm.next_steps.map((step, i) => (
|
||||
<View key={i} style={styles.stepRow}>
|
||||
<Text style={[styles.stateText, { color: colors.textMuted }]}>• </Text>
|
||||
<Text style={[styles.stateText, { color: colors.textBody, flex: 1 }]}>
|
||||
{step}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{openQuestion ? (
|
||||
<>
|
||||
<SectionLabel style={{ marginTop: 14, marginBottom: 8, color: colors.warning }}>
|
||||
Open question
|
||||
</SectionLabel>
|
||||
<Text
|
||||
style={[
|
||||
text.bodySm,
|
||||
{ color: colors.textBody, fontFamily: fonts.bodyItalic },
|
||||
]}
|
||||
>
|
||||
{openQuestion}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Text style={[text.caption, { color: colors.textMuted, marginTop: 12 }]}>
|
||||
updated {timeAgo(cm.checkpoint_at)}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
No checkmark yet — this agent hasn’t reported a checkpoint.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
{detailMeta.map((m, i) => (
|
||||
{meta.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>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>{m.label}</Text>
|
||||
<Mono style={{ fontSize: 13, color: colors.textHeading }}>{m.value}</Mono>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
@@ -97,29 +185,34 @@ export function AgentDetailScreen() {
|
||||
{ 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>
|
||||
))}
|
||||
{logRows.length === 0 ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>No log entries yet.</Text>
|
||||
) : (
|
||||
logRows.map((row) => (
|
||||
<View key={row.id} style={styles.logRow}>
|
||||
<Mono style={[styles.logMono, { color: colors.ink4 }]}>
|
||||
{clockTime(row.created_at)}
|
||||
</Mono>
|
||||
<Mono style={[styles.logMono, { color: colors[statusColor(row.status)], flex: 1 }]}>
|
||||
{row.summary?.trim() || statusLabel(row.status)}
|
||||
</Mono>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
{notAnswered && (
|
||||
<Button size="lg" style={{ flex: 1 }} onPress={() => go("answer")}>
|
||||
{openQuestion ? (
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ flex: 1 }}
|
||||
onPress={() => openAnswer(agent.project_id, agent.name)}
|
||||
>
|
||||
Answer
|
||||
</Button>
|
||||
)}
|
||||
<Button size="lg" variant="secondary" style={{ flex: 1 }}>
|
||||
Pause
|
||||
</Button>
|
||||
<Button size="lg" variant="danger">
|
||||
) : null}
|
||||
<Button size="lg" variant="danger" style={{ flex: 1 }} onPress={confirmKill}>
|
||||
Kill
|
||||
</Button>
|
||||
</View>
|
||||
@@ -140,6 +233,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
lineHeight: 22,
|
||||
},
|
||||
stepRow: { flexDirection: "row" },
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
@@ -10,21 +11,62 @@ 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?";
|
||||
import { timeAgo } from "../api/format";
|
||||
|
||||
export function AnswerScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { go, quickPick, setQuickPick, sendResume } = useAppState();
|
||||
const { go, selectedAgent, selectedCheckmark, sendAnswer } = useAppState();
|
||||
const [reply, setReply] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
|
||||
if (!selectedAgent) {
|
||||
return (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<View style={styles.content}>
|
||||
<PageHeader leading="back" onLeadingPress={() => go("fleet")} title="Answer" />
|
||||
<Text style={[text.body, { color: colors.textMuted }]}>
|
||||
This agent is no longer in the fleet.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const agent = selectedAgent;
|
||||
const question =
|
||||
selectedCheckmark?.open_question?.trim() ||
|
||||
"This agent is paused and waiting for input.";
|
||||
const askedAt = selectedCheckmark?.checkpoint_at;
|
||||
|
||||
async function send() {
|
||||
if (!reply.trim()) {
|
||||
setError("Enter a reply.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setNote(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await sendAnswer(reply.trim());
|
||||
if (res.resumed) {
|
||||
go("detail");
|
||||
} else {
|
||||
setNote(res.note ?? "Answer saved.");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn’t send answer.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
@@ -37,7 +79,7 @@ export function AnswerScreen() {
|
||||
<PageHeader
|
||||
leading="back"
|
||||
onLeadingPress={() => go("detail")}
|
||||
agentId="agt-7a1d"
|
||||
agentId={agent.name}
|
||||
badge={{ tone: "warning", label: "Waiting" }}
|
||||
/>
|
||||
|
||||
@@ -51,33 +93,53 @@ export function AnswerScreen() {
|
||||
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
|
||||
]}
|
||||
>
|
||||
<SectionLabel style={{ marginBottom: 8 }}>Question · 2m ago</SectionLabel>
|
||||
<SectionLabel style={{ marginBottom: 8 }}>
|
||||
{`Question${askedAt ? ` · ${timeAgo(askedAt)} ago` : ""}`}
|
||||
</SectionLabel>
|
||||
<Mono style={[styles.questionText, { color: colors.textBody }]}>
|
||||
{QUESTION}
|
||||
{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>
|
||||
{error ? (
|
||||
<View
|
||||
style={[
|
||||
styles.notice,
|
||||
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.bodySm, { color: colors.danger }]}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{note ? (
|
||||
<View
|
||||
style={[
|
||||
styles.notice,
|
||||
{ backgroundColor: colors.warningTint, borderColor: colors.warning },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.bodySm, { color: colors.warning }]}>{note}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TextField
|
||||
value={reply}
|
||||
onChangeText={setReply}
|
||||
placeholder="Or type a reply…"
|
||||
placeholder="Type a reply…"
|
||||
multiline
|
||||
height={100}
|
||||
/>
|
||||
<Button size="lg" style={{ width: "100%" }} onPress={sendResume}>
|
||||
Send & resume
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ width: "100%" }}
|
||||
onPress={busy ? undefined : send}
|
||||
>
|
||||
{busy ? "Sending…" : "Send & resume"}
|
||||
</Button>
|
||||
{busy ? (
|
||||
<ActivityIndicator color={colors.textMuted} style={{ marginTop: 4 }} />
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
@@ -93,14 +155,14 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.lg,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
questionText: { fontSize: 13, lineHeight: 22 },
|
||||
chips: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
notice: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.md,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
footer: { marginTop: "auto", gap: 12 },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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 { TextField } from "../components/TextField";
|
||||
import { SectionLabel } from "../components/primitives";
|
||||
import {
|
||||
DEFAULT_ENDPOINT,
|
||||
useServerConfig,
|
||||
type ServerConfig,
|
||||
} from "../state/ServerConfig";
|
||||
import { AuthError, createClient, type Project } from "../api/client";
|
||||
|
||||
/**
|
||||
* First-open configuration screen, shown whenever no server config is stored (and after a
|
||||
* Sign out or a persistent 401). Verifies connectivity (GET /health) and the token
|
||||
* (GET /projects) before persisting, distinguishing an unreachable endpoint from a bad
|
||||
* token in the inline error. Matches the SpawnScreen layout.
|
||||
*/
|
||||
export function ConnectScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { lastEndpoint, save } = useServerConfig();
|
||||
|
||||
const [endpoint, setEndpoint] = useState(lastEndpoint || DEFAULT_ENDPOINT);
|
||||
const [token, setToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function connect() {
|
||||
const ep = endpoint.trim();
|
||||
const tok = token.trim();
|
||||
if (!ep) {
|
||||
setError("Enter an endpoint.");
|
||||
return;
|
||||
}
|
||||
if (!tok) {
|
||||
setError("Enter an API token.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const client = createClient(ep, tok, () => {});
|
||||
|
||||
// 1. Connectivity — /health needs no auth, so a failure here is the endpoint.
|
||||
try {
|
||||
await client.api<{ status: string }>("/health");
|
||||
} catch {
|
||||
setError("Couldn't reach that endpoint. Check the URL and your connection.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Auth — a 401 on /projects is the token, not the endpoint.
|
||||
try {
|
||||
await client.api<Project[]>("/projects");
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) {
|
||||
setError("Token rejected. Check your API token.");
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : "Couldn't load projects.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg: ServerConfig = { endpoint: ep, token: tok };
|
||||
await save(cfg);
|
||||
// The gate in App.tsx swaps this screen for the fleet once config is set.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
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 }]}>
|
||||
<View style={styles.heading}>
|
||||
<Text style={[text.h3, { color: colors.textHeading }]}>Connect</Text>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 2 }]}>
|
||||
Point Handler at your control server.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: 16 }}>
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
Endpoint
|
||||
</Text>
|
||||
<TextField
|
||||
value={endpoint}
|
||||
onChangeText={setEndpoint}
|
||||
placeholder="https://handler.example.dev"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
API token
|
||||
</Text>
|
||||
<TextField
|
||||
value={token}
|
||||
onChangeText={setToken}
|
||||
placeholder="Bearer token"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<View
|
||||
style={[
|
||||
styles.errorBox,
|
||||
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
|
||||
]}
|
||||
>
|
||||
<SectionLabel style={{ color: colors.danger, marginBottom: 4 }}>
|
||||
Couldn’t connect
|
||||
</SectionLabel>
|
||||
<Text style={[text.bodySm, { color: colors.danger }]}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: "auto" }}>
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ width: "100%" }}
|
||||
onPress={busy ? undefined : connect}
|
||||
>
|
||||
{busy ? "Connecting…" : "Connect"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
flex: { flex: 1 },
|
||||
content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 },
|
||||
heading: { marginTop: 12, marginBottom: 24 },
|
||||
errorBox: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import React from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
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";
|
||||
@@ -13,25 +20,24 @@ import {
|
||||
StatusDot,
|
||||
} from "../components/primitives";
|
||||
import { TabBar } from "../components/TabBar";
|
||||
import { useAppState } from "../state/AppState";
|
||||
import {
|
||||
recentCheckmarks,
|
||||
waitingAgents,
|
||||
type Checkmark,
|
||||
type WaitingAgent,
|
||||
} from "../data/mock";
|
||||
useAppState,
|
||||
type RecentItem,
|
||||
type WaitingItem,
|
||||
} from "../state/AppState";
|
||||
|
||||
export function FleetScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { go, notAnswered } = useAppState();
|
||||
const { go, openAnswer, openDetail, waiting, recent, counts, loading, error, refresh } =
|
||||
useAppState();
|
||||
|
||||
const waiting = waitingAgents.filter((a) => !(a.clearsOnAnswer && !notAnswered));
|
||||
const empty = waiting.length === 0 && recent.length === 0;
|
||||
|
||||
const stats = [
|
||||
{ label: "Running", value: "6", tint: colors.textHeading },
|
||||
{ label: "Waiting", value: "3", tint: colors.warning },
|
||||
{ label: "Done", value: "42", tint: colors.textHeading },
|
||||
{ label: "Running", value: counts.running, tint: colors.textHeading },
|
||||
{ label: "Waiting", value: counts.waiting, tint: colors.warning },
|
||||
{ label: "Done", value: counts.done, tint: colors.textHeading },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -45,7 +51,7 @@ export function FleetScreen() {
|
||||
<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
|
||||
{counts.running} running · {counts.waiting} waiting on you
|
||||
</Text>
|
||||
</View>
|
||||
<Button size="sm" onPress={() => go("spawn")}>
|
||||
@@ -66,25 +72,65 @@ export function FleetScreen() {
|
||||
))}
|
||||
</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>
|
||||
{loading && empty && !error ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator color={colors.textMuted} />
|
||||
</View>
|
||||
) : error && empty ? (
|
||||
<Card style={styles.errorCard}>
|
||||
<SectionLabel style={{ marginBottom: 6 }}>Couldn’t load fleet</SectionLabel>
|
||||
<Text style={[text.bodySm, { color: colors.textBody, marginBottom: 14 }]}>
|
||||
{error}
|
||||
</Text>
|
||||
<Button size="md" variant="secondary" onPress={() => refresh()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<SectionLabel style={styles.overline}>Waiting on you</SectionLabel>
|
||||
<Card style={styles.section}>
|
||||
{waiting.length === 0 ? (
|
||||
<View style={styles.rowPad}>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
Nothing waiting on you.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
waiting.map((a, i) => (
|
||||
<View key={`${a.project}/${a.name}`}>
|
||||
{i > 0 && <Divider />}
|
||||
<WaitingRow
|
||||
agent={a}
|
||||
onAnswer={() => openAnswer(a.project, a.name)}
|
||||
/>
|
||||
</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>
|
||||
<SectionLabel style={styles.overline}>Recent checkmarks</SectionLabel>
|
||||
<Card>
|
||||
{recent.length === 0 ? (
|
||||
<View style={styles.rowPad}>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
No checkmarks yet.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
recent.map((c, i) => (
|
||||
<View key={c.key}>
|
||||
{i > 0 && <Divider />}
|
||||
<CheckmarkRow
|
||||
checkmark={c}
|
||||
onPress={() => openDetail(c.project, c.name)}
|
||||
/>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
<TabBar active="fleet" />
|
||||
</View>
|
||||
@@ -95,23 +141,24 @@ function WaitingRow({
|
||||
agent,
|
||||
onAnswer,
|
||||
}: {
|
||||
agent: WaitingAgent;
|
||||
agent: WaitingItem;
|
||||
onAnswer: () => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={styles.rowPad}>
|
||||
<View style={styles.waitingTop}>
|
||||
<Mono style={{ fontSize: 12, color: colors.textMuted }}>{agent.id}</Mono>
|
||||
<Mono style={{ fontSize: 12, color: colors.textMuted }}>{agent.name}</Mono>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[styles.rowTitle, { color: colors.textHeading, flex: 1 }]}
|
||||
>
|
||||
{agent.title}
|
||||
{agent.project}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.waitingBottom}>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
text.bodySm,
|
||||
{ color: colors.textBody, flex: 1, fontFamily: fonts.bodyItalic },
|
||||
@@ -131,11 +178,11 @@ function CheckmarkRow({
|
||||
checkmark,
|
||||
onPress,
|
||||
}: {
|
||||
checkmark: Checkmark;
|
||||
checkmark: RecentItem;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
const dot = checkmark.status === "positive" ? colors.positive : colors.danger;
|
||||
const dot = checkmark.tone === "positive" ? colors.positive : colors.danger;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
@@ -179,6 +226,8 @@ const styles = StyleSheet.create({
|
||||
lineHeight: 28,
|
||||
marginTop: 4,
|
||||
},
|
||||
centered: { paddingVertical: 48, alignItems: "center", justifyContent: "center" },
|
||||
errorCard: { padding: 16, alignItems: "flex-start" },
|
||||
overline: { marginBottom: 10 },
|
||||
section: { marginBottom: 20 },
|
||||
rowPad: { paddingVertical: 14, paddingHorizontal: 16 },
|
||||
|
||||
@@ -6,22 +6,22 @@ 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" },
|
||||
];
|
||||
import { useAppState } from "../state/AppState";
|
||||
import { clockTime, statusColor } from "../api/format";
|
||||
|
||||
export function LogScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { logFilter, setLogFilter } = useAppState();
|
||||
const { logFilter, setLogFilter, globalLog, projects } = useAppState();
|
||||
|
||||
const entries = allLog.filter((e) =>
|
||||
logFilter === "all" ? true : logFilter === "errors" ? e.err : e.p === "handler"
|
||||
const filters: { key: string; label: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
...projects.map((p) => ({ key: p.id, label: p.id })),
|
||||
{ key: "errors", label: "Errors" },
|
||||
];
|
||||
|
||||
const entries = globalLog.filter((e) =>
|
||||
logFilter === "all" ? true : logFilter === "errors" ? e.err : e.project === logFilter,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -30,8 +30,12 @@ export function LogScreen() {
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text style={[text.h3, { color: colors.textHeading }]}>Log</Text>
|
||||
<View style={styles.filters}>
|
||||
{FILTERS.map((f) => (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filters}
|
||||
>
|
||||
{filters.map((f) => (
|
||||
<Chip
|
||||
key={f.key}
|
||||
label={f.label}
|
||||
@@ -39,11 +43,11 @@ export function LogScreen() {
|
||||
onPress={() => setLogFilter(f.key)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<View style={styles.todayLabel}>
|
||||
<SectionLabel>Today</SectionLabel>
|
||||
<SectionLabel>Activity</SectionLabel>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
@@ -54,17 +58,26 @@ export function LogScreen() {
|
||||
]}
|
||||
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>
|
||||
))}
|
||||
{entries.length === 0 ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>No activity yet.</Text>
|
||||
) : (
|
||||
entries.map((e) => (
|
||||
<View key={e.key} style={styles.logRow}>
|
||||
<Mono style={[styles.mono, { color: colors.ink4 }]}>
|
||||
{clockTime(e.createdAt)}
|
||||
</Mono>
|
||||
<Mono
|
||||
numberOfLines={1}
|
||||
style={[styles.mono, styles.idCol, { color: colors.ink6 }]}
|
||||
>
|
||||
{e.name}
|
||||
</Mono>
|
||||
<Mono style={[styles.mono, { color: colors[statusColor(e.status)], flex: 1 }]}>
|
||||
{e.msg}
|
||||
</Mono>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<TabBar active="log" />
|
||||
@@ -75,7 +88,7 @@ export function LogScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
header: { paddingHorizontal: 20, paddingTop: 20, paddingBottom: 16 },
|
||||
filters: { flexDirection: "row", gap: 8, marginTop: 14 },
|
||||
filters: { flexDirection: "row", gap: 8, marginTop: 14, paddingRight: 20 },
|
||||
todayLabel: { paddingHorizontal: 20, paddingBottom: 8 },
|
||||
feed: {
|
||||
borderTopWidth: 1,
|
||||
@@ -85,5 +98,5 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
logRow: { flexDirection: "row", gap: 10 },
|
||||
mono: { fontSize: 12.5, lineHeight: 26 },
|
||||
idCol: { width: 66 },
|
||||
idCol: { width: 86 },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { text } from "../theme/tokens";
|
||||
@@ -13,12 +13,58 @@ import {
|
||||
StatusDot,
|
||||
} from "../components/primitives";
|
||||
import { TabBar } from "../components/TabBar";
|
||||
import { useAppState } from "../state/AppState";
|
||||
import { useServerConfig } from "../state/ServerConfig";
|
||||
import { createClient } from "../api/client";
|
||||
|
||||
type Ping =
|
||||
| { state: "checking" }
|
||||
| { state: "ok"; latencyMs: number }
|
||||
| { state: "error" };
|
||||
|
||||
export function SettingsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { swPushWait, setSwPushWait, swPushFail, setSwPushFail } = useAppState();
|
||||
const { config, clear } = useServerConfig();
|
||||
|
||||
// Notification toggles stay local (no server-side counterpart yet).
|
||||
const [pushWait, setPushWait] = useState(true);
|
||||
const [pushFail, setPushFail] = useState(true);
|
||||
|
||||
const [ping, setPing] = useState<Ping>({ state: "checking" });
|
||||
|
||||
const client = useMemo(
|
||||
() => (config ? createClient(config.endpoint, config.token, () => {}) : null),
|
||||
[config],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client) return;
|
||||
let active = true;
|
||||
setPing({ state: "checking" });
|
||||
const started = Date.now();
|
||||
client
|
||||
.api<{ status: string }>("/health")
|
||||
.then(() => {
|
||||
if (active) setPing({ state: "ok", latencyMs: Date.now() - started });
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setPing({ state: "error" });
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
const maskedToken = config
|
||||
? `••••••••${config.token.slice(-4)}`
|
||||
: "—";
|
||||
|
||||
const status =
|
||||
ping.state === "ok"
|
||||
? { color: colors.positive, label: `connected · ${ping.latencyMs}ms` }
|
||||
: ping.state === "error"
|
||||
? { color: colors.danger, label: "unreachable" }
|
||||
: { color: colors.textMuted, label: "checking…" };
|
||||
|
||||
return (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
@@ -33,16 +79,16 @@ export function SettingsScreen() {
|
||||
|
||||
<SectionLabel style={{ marginBottom: 10 }}>Server</SectionLabel>
|
||||
<Card style={{ marginBottom: 20 }}>
|
||||
<InfoRow label="Endpoint" value="https://handler.wheaty.dev" />
|
||||
<InfoRow label="Endpoint" value={config?.endpoint ?? "—"} />
|
||||
<Divider />
|
||||
<InfoRow label="API key" value="hnd_••••••••4f2a" />
|
||||
<InfoRow label="API token" value={maskedToken} />
|
||||
<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
|
||||
<StatusDot color={status.color} size={7} />
|
||||
<Mono style={[styles.valueMono, { color: status.color }]}>
|
||||
{status.label}
|
||||
</Mono>
|
||||
</View>
|
||||
</View>
|
||||
@@ -53,19 +99,24 @@ export function SettingsScreen() {
|
||||
<ToggleRow
|
||||
title="Waiting on input"
|
||||
subtitle="Push when an agent pauses"
|
||||
value={swPushWait}
|
||||
onValueChange={setSwPushWait}
|
||||
value={pushWait}
|
||||
onValueChange={setPushWait}
|
||||
/>
|
||||
<Divider />
|
||||
<ToggleRow
|
||||
title="Failures"
|
||||
subtitle="Push when a checkmark fails"
|
||||
value={swPushFail}
|
||||
onValueChange={setSwPushFail}
|
||||
value={pushFail}
|
||||
onValueChange={setPushFail}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Button variant="secondary" size="lg" style={{ width: "100%" }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
style={{ width: "100%" }}
|
||||
onPress={() => void clear()}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</ScrollView>
|
||||
@@ -79,7 +130,12 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={[text.label, { color: colors.textHeading }]}>{label}</Text>
|
||||
<Mono style={[styles.valueMono, { color: colors.textMuted }]}>{value}</Mono>
|
||||
<Mono
|
||||
numberOfLines={1}
|
||||
style={[styles.valueMono, styles.valueFlex, { color: colors.textMuted }]}
|
||||
>
|
||||
{value}
|
||||
</Mono>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -98,4 +154,5 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
statusValue: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
valueMono: { fontSize: 12.5 },
|
||||
valueFlex: { flex: 1, textAlign: "right" },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
@@ -7,23 +7,52 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { text } from "../theme/tokens";
|
||||
import { radius, 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 { go, projects, spawn } = useAppState();
|
||||
|
||||
const projectIds = projects.map((p) => p.id);
|
||||
const [project, setProject] = useState("");
|
||||
const [task, setTask] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Default to the first project once they load (or if the current pick vanished).
|
||||
useEffect(() => {
|
||||
if (projectIds.length > 0 && !projectIds.includes(project)) {
|
||||
setProject(projectIds[0]);
|
||||
}
|
||||
}, [projectIds, project]);
|
||||
|
||||
async function submit() {
|
||||
if (!project) {
|
||||
setError("Pick a project first.");
|
||||
return;
|
||||
}
|
||||
if (!task.trim()) {
|
||||
setError("Describe the task.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await spawn(project, task.trim());
|
||||
go("fleet");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn’t spawn the agent.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
@@ -40,12 +69,23 @@ export function SpawnScreen() {
|
||||
/>
|
||||
|
||||
<View style={{ gap: 16 }}>
|
||||
<Select
|
||||
label="Project"
|
||||
options={projectOptions}
|
||||
value={project}
|
||||
onChange={setProject}
|
||||
/>
|
||||
{projectIds.length > 0 ? (
|
||||
<Select
|
||||
label="Project"
|
||||
options={projectIds}
|
||||
value={project || projectIds[0]}
|
||||
onChange={setProject}
|
||||
/>
|
||||
) : (
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
Project
|
||||
</Text>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
No projects registered yet.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
@@ -63,26 +103,25 @@ export function SpawnScreen() {
|
||||
</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>
|
||||
{error ? (
|
||||
<View
|
||||
style={[
|
||||
styles.notice,
|
||||
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.bodySm, { color: colors.danger }]}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: "auto" }}>
|
||||
<Button size="lg" style={{ width: "100%" }} onPress={spawnGo}>
|
||||
Spawn agent
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ width: "100%" }}
|
||||
onPress={busy ? undefined : submit}
|
||||
>
|
||||
{busy ? "Spawning…" : "Spawn agent"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -95,4 +134,9 @@ const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
flex: { flex: 1 },
|
||||
content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 },
|
||||
notice: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.md,
|
||||
padding: 12,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user