diff --git a/app/App.tsx b/app/App.tsx
index 5c80c5b..879a267 100644
--- a/app/App.tsx
+++ b/app/App.tsx
@@ -28,6 +28,8 @@ import {
import { useTheme } from "./src/theme/useTheme";
import { AppStateProvider, useAppState } from "./src/state/AppState";
+import { ServerConfigProvider, useServerConfig } from "./src/state/ServerConfig";
+import { ConnectScreen } from "./src/screens/ConnectScreen";
import { FleetScreen } from "./src/screens/FleetScreen";
import { AgentDetailScreen } from "./src/screens/AgentDetailScreen";
import { AnswerScreen } from "./src/screens/AnswerScreen";
@@ -37,9 +39,9 @@ import { SettingsScreen } from "./src/screens/SettingsScreen";
function Router() {
const { screen } = useAppState();
- const { scheme, colors } = useTheme();
const Screen = {
+ connect: ConnectScreen,
fleet: FleetScreen,
detail: AgentDetailScreen,
answer: AnswerScreen,
@@ -48,10 +50,26 @@ function Router() {
settings: SettingsScreen,
}[screen];
+ return ;
+}
+
+/** Gate: splash while config loads, ConnectScreen when unconfigured, else the fleet app. */
+function Gate() {
+ const { config, loading } = useServerConfig();
+ const { scheme, colors } = useTheme();
+
return (
-
+ {loading ? (
+
+ ) : config ? (
+
+
+
+ ) : (
+
+ )}
);
}
@@ -76,9 +94,9 @@ export default function App() {
return (
{fontsLoaded ? (
-
-
-
+
+
+
) : (
)}
diff --git a/app/src/api/client.ts b/app/src/api/client.ts
new file mode 100644
index 0000000..1707202
--- /dev/null
+++ b/app/src/api/client.ts
@@ -0,0 +1,224 @@
+/* Typed client for the Handler API + the row shapes it returns (mirrors the FastAPI
+ * pydantic schemas in src/handler/api/schemas.py). Ported from frontend/lib/api.ts; the
+ * one adaptation for mobile is that the base URL is passed in (the phone talks to a
+ * user-configured endpoint rather than same-origin), and `api()` takes an `allow401`
+ * escape hatch so the admin-only /resume call can handle its own 401/403 without
+ * tripping the global sign-out. */
+
+export type CommandStatus = "queued" | "running" | "done" | "failed";
+
+export interface Project {
+ id: string;
+ root_dir: string;
+ git_remote?: string | null;
+ credential_ref?: string | null;
+ created_at: string;
+ /* Present on the registration response in git-server mode: the enqueued clone. */
+ sync_command_id?: number | null;
+ /* Present on the registration response when "Initialize mise" was ticked: the
+ * enqueued bootstrap agent that writes + commits + pushes a .mise.toml. */
+ mise_init_command_id?: number | null;
+}
+
+export interface Agent {
+ id: number;
+ project_id: string;
+ name: string;
+ working_dir: string;
+ status: string;
+ role?: string | null;
+ /* Latest tmux pane-tail snapshot from the worker, so the UI can show what a running
+ * agent is actually doing (and expose one wedged on an interactive prompt). */
+ last_output?: string | null;
+ output_at?: string | null;
+ created_at: string;
+}
+
+export interface Checkmark {
+ agent_id: number;
+ checkpoint_at: string;
+ status: string;
+ where_it_stopped?: string | null;
+ next_steps?: string[] | null;
+ open_question?: string | null;
+ log_entry_id?: number | null;
+ tests_status: string;
+ tested_at?: string | null;
+ build_status: string;
+ built_at?: string | null;
+}
+
+export interface LogEntry {
+ id: number;
+ agent_id: number;
+ created_at: string;
+ session_id?: string | null;
+ status: string;
+ summary?: string | null;
+ decisions?: string | null;
+ question?: string | null;
+ answer?: string | null;
+ visibility: string;
+ push_sha?: string | null;
+ ci_status: string;
+ ci_checked_at?: string | null;
+}
+
+export interface Approval {
+ id: number;
+ project_id: string;
+ branch: string;
+ approved_sha?: string | null;
+ pr_ref?: string | null;
+ status: string;
+ approved_by_agent_id?: number | null;
+ actor?: string | null;
+ note?: string | null;
+ created_at: string;
+}
+
+export interface Host {
+ hostname: string;
+ forge_type: string;
+ token_env_var?: string | null;
+ base_url?: string | null;
+ ssh_public_key?: string | null;
+ has_token: boolean;
+ created_at: string;
+}
+
+export interface Command {
+ id: number;
+ project_id?: string | null;
+ agent_name?: string | null;
+ type: string;
+ payload?: Record | null;
+ status: CommandStatus;
+ result?: Record | null;
+ error?: string | null;
+ requested_by?: string | null;
+ claimed_by?: string | null;
+ created_at: string;
+ claimed_at?: string | null;
+ finished_at?: string | null;
+}
+
+export interface Schedule {
+ id: number;
+ project_id: string;
+ name_prefix: string;
+ task: string;
+ role?: string | null;
+ worktree?: string | null;
+ subdir?: string | null;
+ interval_seconds: number;
+ enabled: boolean;
+ next_run_at: string;
+ last_run_at?: string | null;
+ last_command_id?: number | null;
+ created_at: string;
+}
+
+export interface SharedContext {
+ key: string;
+ value: string;
+ set_by_agent_id?: number | null;
+ updated_at: string;
+}
+
+/* Thrown on a 401 so callers can distinguish "token rejected" from real errors and stay
+ * quiet while the app re-prompts for a token. */
+export class AuthError extends Error {
+ constructor(message = "unauthorized") {
+ super(message);
+ this.name = "AuthError";
+ }
+}
+
+/* Any non-2xx (other than 401); carries the HTTP status so callers can branch on 404 etc. */
+export interface ApiError extends Error {
+ status: number;
+}
+
+interface ApiOptions {
+ method?: string;
+ body?: unknown;
+ /* When set, a 401 throws an ApiError(status 401) like any other error instead of firing
+ * onUnauthorized — used by the admin-only /resume so a missing admin grant surfaces
+ * inline rather than signing the whole session out. */
+ allow401?: boolean;
+}
+
+interface TrackOptions {
+ attempts?: number;
+ intervalMs?: number;
+}
+
+export interface ApiClient {
+ baseUrl: string;
+ api: (path: string, opts?: ApiOptions) => Promise;
+ /* Poll GET /commands/{id} until it reaches done/failed; null if still running after the
+ * budget (worker down or a very slow command). */
+ trackCommand: (id: number, opts?: TrackOptions) => Promise;
+}
+
+/* Strip a trailing slash so `baseUrl + "/projects"` never double-slashes. */
+function normalizeBaseUrl(baseUrl: string): string {
+ return baseUrl.trim().replace(/\/+$/, "");
+}
+
+export function createClient(
+ baseUrl: string,
+ token: string,
+ onUnauthorized: () => void,
+): ApiClient {
+ const base = normalizeBaseUrl(baseUrl);
+
+ async function api(path: string, opts?: ApiOptions): Promise {
+ const hasBody = opts?.body !== undefined && opts?.body !== null;
+ const res = await fetch(base + path, {
+ method: opts?.method ?? (hasBody ? "POST" : "GET"),
+ headers: {
+ Authorization: `Bearer ${token}`,
+ ...(hasBody ? { "Content-Type": "application/json" } : {}),
+ },
+ body: hasBody ? JSON.stringify(opts!.body) : undefined,
+ });
+
+ if (res.status === 401 && !opts?.allow401) {
+ onUnauthorized();
+ throw new AuthError();
+ }
+ if (!res.ok) {
+ let detail: string = res.statusText;
+ try {
+ const j = await res.json();
+ if (j && typeof j.detail !== "undefined") {
+ detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
+ }
+ } catch {
+ /* non-JSON error body; keep statusText */
+ }
+ const err = new Error(detail) as ApiError;
+ err.status = res.status;
+ throw err;
+ }
+
+ if (res.status === 204) return undefined as T;
+ const text = await res.text();
+ return (text ? JSON.parse(text) : undefined) as T;
+ }
+
+ async function trackCommand(id: number, opts?: TrackOptions): Promise {
+ const attempts = opts?.attempts ?? 60;
+ const intervalMs = opts?.intervalMs ?? 500;
+ for (let i = 0; i < attempts; i++) {
+ const cmd = await api(`/commands/${id}`);
+ if (cmd.status === "done" || cmd.status === "failed") return cmd;
+ await new Promise((r) => setTimeout(r, intervalMs));
+ }
+ return null;
+ }
+
+ return { baseUrl: base, api, trackCommand };
+}
diff --git a/app/src/api/format.ts b/app/src/api/format.ts
new file mode 100644
index 0000000..e3b0a4d
--- /dev/null
+++ b/app/src/api/format.ts
@@ -0,0 +1,94 @@
+/* Formatting + status helpers shared by the screens. Pure functions, no API access.
+ * timeAgo is ported from frontend/lib/format.ts; the tone/colour mappers translate a raw
+ * handler status string into the app's design-system vocabulary (BadgeTone for pills,
+ * a ThemeColors key for log lines). */
+
+import type { ThemeColors } from "../theme/tokens";
+import type { BadgeTone } from "../state/AppState";
+
+/* Compact relative time, e.g. "3m", "2h", "5d". "—" for empty. Timestamps from the API are
+ * ISO UTC strings; new Date() parses them. */
+export function timeAgo(iso: string | null | undefined): string {
+ if (!iso) return "—";
+ const then = new Date(iso).getTime();
+ if (Number.isNaN(then)) return "—";
+ const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
+ if (secs < 60) return `${secs}s`;
+ const mins = Math.floor(secs / 60);
+ if (mins < 60) return `${mins}m`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h`;
+ const days = Math.floor(hours / 24);
+ if (days < 30) return `${days}d`;
+ const months = Math.floor(days / 30);
+ if (months < 12) return `${months}mo`;
+ return `${Math.floor(months / 12)}y`;
+}
+
+/* Local clock time (HH:MM:SS) for a log line. "—" for empty. */
+export function clockTime(iso: string | null | undefined): string {
+ if (!iso) return "—";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "—";
+ return d.toLocaleTimeString(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ });
+}
+
+const LABELS: Record = {
+ paused_for_input: "Waiting",
+ not_applicable: "N/A",
+};
+
+/* A tidy, human-readable label for a status string. */
+export function statusLabel(status: string | null | undefined): string {
+ const raw = (status ?? "").trim();
+ if (!raw) return "—";
+ const key = raw.toLowerCase();
+ if (LABELS[key]) return LABELS[key];
+ return key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+/* Map a raw handler status (agent status, checkmark status, CI status) to a badge tone in
+ * the app's four-tone vocabulary. */
+export function statusTone(status: string | null | undefined): BadgeTone {
+ switch ((status ?? "").toLowerCase()) {
+ case "pass":
+ case "done":
+ case "completed":
+ case "approved":
+ case "success":
+ return "positive";
+ case "fail":
+ case "failed":
+ case "blocked":
+ case "rejected":
+ case "error":
+ return "danger";
+ case "pending":
+ case "queued":
+ case "running":
+ case "working":
+ case "paused_for_input":
+ return "warning";
+ default:
+ return "neutral";
+ }
+}
+
+/* Pick a ThemeColors key for a log line, given its status. */
+export function statusColor(status: string | null | undefined): keyof ThemeColors {
+ switch (statusTone(status)) {
+ case "positive":
+ return "positive";
+ case "danger":
+ return "danger";
+ case "warning":
+ return "warning";
+ default:
+ return "textBody";
+ }
+}
diff --git a/app/src/components/TextField.tsx b/app/src/components/TextField.tsx
index a47317c..58960aa 100644
--- a/app/src/components/TextField.tsx
+++ b/app/src/components/TextField.tsx
@@ -3,7 +3,9 @@ import {
StyleSheet,
TextInput,
View,
+ type KeyboardTypeOptions,
type StyleProp,
+ type TextInputProps,
type ViewStyle,
} from "react-native";
import { fonts, radius } from "../theme/tokens";
@@ -21,6 +23,10 @@ export function TextField({
multiline = false,
height = 48,
style,
+ secureTextEntry = false,
+ autoCapitalize,
+ autoCorrect,
+ keyboardType,
}: {
value: string;
onChangeText: (t: string) => void;
@@ -29,6 +35,10 @@ export function TextField({
/** For single-line this is the control height; for multiline, the box height. */
height?: number;
style?: StyleProp;
+ secureTextEntry?: boolean;
+ autoCapitalize?: TextInputProps["autoCapitalize"];
+ autoCorrect?: boolean;
+ keyboardType?: KeyboardTypeOptions;
}) {
const { colors } = useTheme();
const [focus, setFocus] = useState(false);
@@ -52,6 +62,10 @@ export function TextField({
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
multiline={multiline}
+ secureTextEntry={secureTextEntry}
+ autoCapitalize={autoCapitalize}
+ autoCorrect={autoCorrect}
+ keyboardType={keyboardType}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
style={[
diff --git a/app/src/components/primitives.tsx b/app/src/components/primitives.tsx
index ae2cdec..3e5fe4c 100644
--- a/app/src/components/primitives.tsx
+++ b/app/src/components/primitives.tsx
@@ -77,11 +77,17 @@ export function StatusDot({ color, size = 8 }: { color: string; size?: number })
export function Mono({
children,
style,
+ numberOfLines,
}: {
children: React.ReactNode;
style?: StyleProp;
+ numberOfLines?: number;
}) {
- return {children};
+ return (
+
+ {children}
+
+ );
}
const monoStyles = StyleSheet.create({
diff --git a/app/src/data/mock.ts b/app/src/data/mock.ts
deleted file mode 100644
index d5fd2a9..0000000
--- a/app/src/data/mock.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import type { ThemeColors } from "../theme/tokens";
-
-/** Fixed prototype content, transcribed from the 2a design. */
-
-export interface WaitingAgent {
- id: string;
- title: string;
- question: string;
- /** agt-7a1d is the one that clears from the list once answered. */
- clearsOnAnswer?: boolean;
-}
-
-export const waitingAgents: WaitingAgent[] = [
- {
- id: "agt-7a1d",
- title: "handler · migrate state to sqlite",
- question: '"Drop the legacy JSON store, or keep it as a read fallback?"',
- clearsOnAnswer: true,
- },
- {
- id: "agt-3e90",
- title: "wheatsite · fix build on node 22",
- question: '"Pin node 20 in CI, or patch esbuild?"',
- },
- {
- id: "agt-b241",
- title: "api-gateway · add rate limiting",
- question: '"429 body: JSON or plain text?"',
- },
-];
-
-export type CheckmarkStatus = "positive" | "danger";
-
-export interface Checkmark {
- title: string;
- meta: string;
- status: CheckmarkStatus;
-}
-
-export const recentCheckmarks: Checkmark[] = [
- {
- title: "handler · add /agents endpoint",
- meta: "done — tests pass · 14m ago",
- status: "positive",
- },
- {
- title: "wheatsite · refactor router",
- meta: "failed — 2 tests · 1h ago",
- status: "danger",
- },
- {
- title: "dotfiles · port zsh config",
- meta: "done — 12 turns · 3h ago",
- status: "positive",
- },
-];
-
-export const quickReplyLabels = ["Drop it", "Keep as fallback", "Ask me later"];
-
-export const projectOptions = ["handler", "wheatsite", "dotfiles", "api-gateway"];
-
-/** Agent-detail log tab (fixed 7 rows). `color` picks a palette key. */
-export interface DetailLogRow {
- t: string;
- msg: string;
- color: keyof ThemeColors;
-}
-
-export const detailLog: DetailLogRow[] = [
- { t: "14:02", msg: "paused — waiting for input", color: "warning" },
- { t: "13:57", msg: "checkmark updated", color: "textBody" },
- { t: "13:52", msg: "tool: bash — sqlite3 .schema", color: "textMuted" },
- { t: "13:48", msg: "tool: edit — store/sqlite.rs", color: "textMuted" },
- { t: "13:40", msg: "tool: bash — cargo test store", color: "textMuted" },
- { t: "13:29", msg: "checkmark updated", color: "textBody" },
- { t: "13:21", msg: "tool: read — store/json.rs", color: "textMuted" },
-];
-
-export interface DetailMetaRow {
- label: string;
- value: string;
-}
-
-export const detailMeta: DetailMetaRow[] = [
- { label: "Started", value: "41m ago" },
- { label: "Model", value: "claude-sonnet-4" },
- { label: "Turns", value: "21" },
- { label: "Tokens", value: "348k" },
-];
-
-/** Global log feed. `err` and `p` drive the All / handler / Errors filters. */
-export interface LogEntry {
- t: string;
- id: string;
- p: string;
- msg: string;
- color: keyof ThemeColors;
- err: boolean;
-}
-
-export const allLog: LogEntry[] = [
- { t: "14:02:11", id: "agt-7a1d", p: "handler", msg: "paused — waiting for input", color: "warning", err: false },
- { t: "13:58:40", id: "agt-9c77", p: "handler", msg: "checkmark updated", color: "textBody", err: false },
- { t: "13:51:02", id: "agt-2d08", p: "handler", msg: "done — 34 turns, tests pass", color: "positive", err: false },
- { t: "13:44:19", id: "agt-e33a", p: "wheatsite", msg: "error — 2 tests failed", color: "danger", err: true },
- { t: "13:39:55", id: "agt-51f0", p: "dotfiles", msg: "spawned → dotfiles", color: "textBody", err: false },
- { t: "13:31:07", id: "agt-b241", p: "api-gateway", msg: "paused — waiting for input", color: "warning", err: false },
- { t: "13:18:44", id: "agt-9c77", p: "handler", msg: "tool: bash — cargo test", color: "textMuted", err: false },
- { t: "13:02:30", id: "agt-90bc", p: "dotfiles", msg: "done — 12 turns", color: "positive", err: false },
- { t: "12:57:12", id: "agt-51f0", p: "dotfiles", msg: "tool: edit — .zshrc", color: "textMuted", err: false },
- { t: "12:49:03", id: "agt-e33a", p: "wheatsite", msg: "checkmark updated", color: "textBody", err: false },
- { t: "12:40:38", id: "agt-b241", p: "api-gateway", msg: "spawned → api-gateway", color: "textBody", err: false },
-];
diff --git a/app/src/screens/AgentDetailScreen.tsx b/app/src/screens/AgentDetailScreen.tsx
index e64a59c..55aac94 100644
--- a/app/src/screens/AgentDetailScreen.tsx
+++ b/app/src/screens/AgentDetailScreen.tsx
@@ -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 (
+
+
+
+ go("fleet")} title="Agent" />
+
+ This agent is no longer in the fleet.
+
+
+
+ );
+ }
+
+ 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 (
go("fleet")}
- agentId="agt-7a1d"
- badge={{ tone: agentTone, label: agentStatus }}
+ agentId={agent.name}
+ badge={{ tone: statusTone(agent.status), label: statusLabel(agent.status) }}
/>
-
- Migrate agent state to sqlite
-
+ {agent.name}
- handler · branch agt/7a1d
+ {agent.project_id}
+ {agent.role ? " · " : ""}
+ {agent.role ? {agent.role} : null}
@@ -66,25 +116,63 @@ export function AgentDetailScreen() {
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
]}
>
- Current state
-
- {agentStateText}
-
-
- updated 2m ago
-
+ {cm ? (
+ <>
+ Where it stopped
+
+ {cm.where_it_stopped?.trim() || "—"}
+
+
+ {cm.next_steps && cm.next_steps.length > 0 ? (
+ <>
+
+ Next steps
+
+ {cm.next_steps.map((step, i) => (
+
+ •
+
+ {step}
+
+
+ ))}
+ >
+ ) : null}
+
+ {openQuestion ? (
+ <>
+
+ Open question
+
+
+ {openQuestion}
+
+ >
+ ) : null}
+
+
+ updated {timeAgo(cm.checkpoint_at)}
+
+ >
+ ) : (
+
+ No checkmark yet — this agent hasn’t reported a checkpoint.
+
+ )}
+
- {detailMeta.map((m, i) => (
+ {meta.map((m, i) => (
{i > 0 && }
-
- {m.label}
-
-
- {m.value}
-
+ {m.label}
+ {m.value}
))}
@@ -97,29 +185,34 @@ export function AgentDetailScreen() {
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
]}
>
- {detailLog.map((row) => (
-
-
- {row.t}
-
-
- {row.msg}
-
-
- ))}
+ {logRows.length === 0 ? (
+ No log entries yet.
+ ) : (
+ logRows.map((row) => (
+
+
+ {clockTime(row.created_at)}
+
+
+ {row.summary?.trim() || statusLabel(row.status)}
+
+
+ ))
+ )}
)}
- {notAnswered && (
-
@@ -140,6 +233,7 @@ const styles = StyleSheet.create({
fontSize: 13,
lineHeight: 22,
},
+ stepRow: { flexDirection: "row" },
metaRow: {
flexDirection: "row",
justifyContent: "space-between",
diff --git a/app/src/screens/AnswerScreen.tsx b/app/src/screens/AnswerScreen.tsx
index 981dd96..626779d 100644
--- a/app/src/screens/AnswerScreen.tsx
+++ b/app/src/screens/AnswerScreen.tsx
@@ -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(null);
+ const [note, setNote] = useState(null);
+
+ if (!selectedAgent) {
+ return (
+
+
+
+ go("fleet")} title="Answer" />
+
+ This agent is no longer in the fleet.
+
+
+
+ );
+ }
+
+ 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 (
@@ -37,7 +79,7 @@ export function AnswerScreen() {
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 },
]}
>
- Question · 2m ago
+
+ {`Question${askedAt ? ` · ${timeAgo(askedAt)} ago` : ""}`}
+
- {QUESTION}
+ {question}
- Quick replies
-
- {quickReplyLabels.map((label, i) => (
- setQuickPick(i)}
- />
- ))}
-
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {note ? (
+
+ {note}
+
+ ) : null}
-
- Send & resume
+
+ {busy ? "Sending…" : "Send & resume"}
+ {busy ? (
+
+ ) : null}
@@ -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 },
});
diff --git a/app/src/screens/ConnectScreen.tsx b/app/src/screens/ConnectScreen.tsx
new file mode 100644
index 0000000..5fb5a27
--- /dev/null
+++ b/app/src/screens/ConnectScreen.tsx
@@ -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(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("/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 (
+
+
+
+
+
+ Connect
+
+ Point Handler at your control server.
+
+
+
+
+
+
+ Endpoint
+
+
+
+
+
+
+ API token
+
+
+
+
+ {error ? (
+
+
+ Couldn’t connect
+
+ {error}
+
+ ) : null}
+
+
+
+
+ {busy ? "Connecting…" : "Connect"}
+
+
+
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/app/src/screens/FleetScreen.tsx b/app/src/screens/FleetScreen.tsx
index eabb3a4..074a9cb 100644
--- a/app/src/screens/FleetScreen.tsx
+++ b/app/src/screens/FleetScreen.tsx
@@ -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() {
Fleet
- 12 agents · 3 waiting on you
+ {counts.running} running · {counts.waiting} waiting on you
go("spawn")}>
@@ -66,25 +72,65 @@ export function FleetScreen() {
))}
- Waiting on you
-
- {waiting.map((a, i) => (
-
- {i > 0 && }
- go("answer")} />
-
- ))}
-
+ {loading && empty && !error ? (
+
+
+
+ ) : error && empty ? (
+
+ Couldn’t load fleet
+
+ {error}
+
+ refresh()}>
+ Retry
+
+
+ ) : (
+ <>
+ Waiting on you
+
+ {waiting.length === 0 ? (
+
+
+ Nothing waiting on you.
+
+
+ ) : (
+ waiting.map((a, i) => (
+
+ {i > 0 && }
+ openAnswer(a.project, a.name)}
+ />
+
+ ))
+ )}
+
- Recent checkmarks
-
- {recentCheckmarks.map((c, i) => (
-
- {i > 0 && }
- go("detail")} />
-
- ))}
-
+ Recent checkmarks
+
+ {recent.length === 0 ? (
+
+
+ No checkmarks yet.
+
+
+ ) : (
+ recent.map((c, i) => (
+
+ {i > 0 && }
+ openDetail(c.project, c.name)}
+ />
+
+ ))
+ )}
+
+ >
+ )}
@@ -95,23 +141,24 @@ function WaitingRow({
agent,
onAnswer,
}: {
- agent: WaitingAgent;
+ agent: WaitingItem;
onAnswer: () => void;
}) {
const { colors } = useTheme();
return (
- {agent.id}
+ {agent.name}
- {agent.title}
+ {agent.project}
void;
}) {
const { colors } = useTheme();
- const dot = checkmark.status === "positive" ? colors.positive : colors.danger;
+ const dot = checkmark.tone === "positive" ? colors.positive : colors.danger;
return (
- 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() {
Log
-
- {FILTERS.map((f) => (
+
+ {filters.map((f) => (
setLogFilter(f.key)}
/>
))}
-
+
- Today
+ Activity
- {entries.map((e, i) => (
-
- {e.t}
-
- {e.id}
-
-
- {e.msg}
-
-
- ))}
+ {entries.length === 0 ? (
+ No activity yet.
+ ) : (
+ entries.map((e) => (
+
+
+ {clockTime(e.createdAt)}
+
+
+ {e.name}
+
+
+ {e.msg}
+
+
+ ))
+ )}
@@ -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 },
});
diff --git a/app/src/screens/SettingsScreen.tsx b/app/src/screens/SettingsScreen.tsx
index 2dcf0e3..1e31a91 100644
--- a/app/src/screens/SettingsScreen.tsx
+++ b/app/src/screens/SettingsScreen.tsx
@@ -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({ 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 (
@@ -33,16 +79,16 @@ export function SettingsScreen() {
Server
-
+
-
+
Status
-
-
- connected · 38ms
+
+
+ {status.label}
@@ -53,19 +99,24 @@ export function SettingsScreen() {
-
+ void clear()}
+ >
Sign out
@@ -79,7 +130,12 @@ function InfoRow({ label, value }: { label: string; value: string }) {
return (
{label}
- {value}
+
+ {value}
+
);
}
@@ -98,4 +154,5 @@ const styles = StyleSheet.create({
},
statusValue: { flexDirection: "row", alignItems: "center", gap: 6 },
valueMono: { fontSize: 12.5 },
+ valueFlex: { flex: 1, textAlign: "right" },
});
diff --git a/app/src/screens/SpawnScreen.tsx b/app/src/screens/SpawnScreen.tsx
index 2721c45..26fe176 100644
--- a/app/src/screens/SpawnScreen.tsx
+++ b/app/src/screens/SpawnScreen.tsx
@@ -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(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 (
@@ -40,12 +69,23 @@ export function SpawnScreen() {
/>
-
+ {projectIds.length > 0 ? (
+
+ ) : (
+
+
+ Project
+
+
+ No projects registered yet.
+
+
+ )}
@@ -63,26 +103,25 @@ export function SpawnScreen() {
-
-
-
-
-
+ {error ? (
+
+ {error}
+
+ ) : null}
-
- Spawn agent
+
+ {busy ? "Spawning…" : "Spawn agent"}
@@ -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,
+ },
});
diff --git a/app/src/state/AppState.tsx b/app/src/state/AppState.tsx
index 96a1b73..68e187f 100644
--- a/app/src/state/AppState.tsx
+++ b/app/src/state/AppState.tsx
@@ -1,15 +1,38 @@
-import React, { createContext, useContext, useMemo, useState } from "react";
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ AuthError,
+ createClient,
+ type Agent,
+ type ApiClient,
+ type ApiError,
+ type Checkmark,
+ type LogEntry,
+ type Project,
+} from "../api/client";
+import { statusLabel, statusTone, timeAgo } from "../api/format";
+import { useServerConfig } from "./ServerConfig";
/**
- * Central prototype state, ported 1:1 from the DC script's
- * `state` + `renderVals()` in project/Handler Mobile.dc.html (turn 2a).
+ * Data-driven fleet store. Keeps the prototype's screen-swap navigation (a single
+ * `screen` value rather than a nav stack) so the screens change minimally, but every
+ * value now comes from the live Handler API via the client built from ServerConfig.
*
- * The prototype navigates by swapping a single `screen` value rather than a
- * stack, and answering agt-7a1d flips it Waiting -> Running everywhere. That
- * cross-screen behavior is exactly why this lives in one shared store.
+ * The store polls /projects → agents → checkmarks → logs on a 10s cadence, derives the
+ * fleet view-models (waiting list, recent checkmarks, counts, merged log), and exposes the
+ * three mutations the UI needs (answer+resume, spawn, kill). A 401 anywhere clears the
+ * stored config (routing back to ConnectScreen) via the client's onUnauthorized hook.
*/
export type Screen =
+ | "connect"
| "fleet"
| "detail"
| "answer"
@@ -18,99 +41,460 @@ export type Screen =
| "settings";
export type DetailTab = "state" | "log";
-export type LogFilter = "all" | "handler" | "errors";
export type BadgeTone = "neutral" | "positive" | "warning" | "danger";
+export type RecentTone = "positive" | "danger";
-const ANSWERED_STATE =
- "Answer received: keep the JSON store as a read-only fallback for one release. Deprecating writes now; removal ticket filed for next cycle.";
-const WAITING_STATE =
- "Schema written, migrations pass. Blocked on the legacy JSON store — drop it or keep as read fallback? Holding before deleting store/json.rs.";
+/** An agent waiting on the operator — either an open checkmark question or a paused status. */
+export interface WaitingItem {
+ project: string;
+ name: string;
+ question: string;
+ logEntryId: number | null;
+}
+
+/** A recent checkmark row on the fleet screen. */
+export interface RecentItem {
+ key: string;
+ project: string;
+ name: string;
+ title: string;
+ meta: string;
+ tone: RecentTone;
+ checkpointAt: string;
+}
+
+/** One merged global-log line. */
+export interface GlobalLogItem {
+ key: string;
+ project: string;
+ name: string;
+ createdAt: string;
+ msg: string;
+ status: string;
+ ciStatus: string;
+ err: boolean;
+}
+
+interface Selected {
+ project: string;
+ name: string;
+}
interface AppStateValue {
+ // Navigation.
screen: Screen;
detailTab: DetailTab;
- quickPick: number | null;
- logFilter: LogFilter;
- answered: boolean;
- swEdits: boolean;
- swTests: boolean;
- swPushWait: boolean;
- swPushFail: boolean;
-
- // Derived (mirrors renderVals()).
- notAnswered: boolean;
- agentTone: BadgeTone;
- agentStatus: string;
- agentStateText: string;
-
+ logFilter: string;
go: (screen: Screen) => void;
setDetailTab: (tab: DetailTab) => void;
- setQuickPick: (i: number) => void;
- setLogFilter: (f: LogFilter) => void;
- sendResume: () => void;
- spawnGo: () => void;
- setSwEdits: (v: boolean) => void;
- setSwTests: (v: boolean) => void;
- setSwPushWait: (v: boolean) => void;
- setSwPushFail: (v: boolean) => void;
+ setLogFilter: (f: string) => void;
+ openDetail: (project: string, name: string) => void;
+ openAnswer: (project: string, name: string) => void;
+
+ // Fleet data.
+ loading: boolean;
+ error: string | null;
+ projects: Project[];
+ waiting: WaitingItem[];
+ recent: RecentItem[];
+ counts: { running: number; waiting: number; done: number };
+ globalLog: GlobalLogItem[];
+ refresh: () => Promise;
+
+ // Selected agent (detail / answer screens).
+ selectedAgent: Agent | null;
+ selectedCheckmark: Checkmark | null;
+ selectedLog: LogEntry[];
+
+ // Mutations.
+ sendAnswer: (text: string) => Promise<{ resumed: boolean; note?: string }>;
+ spawn: (project: string, task: string) => Promise;
+ kill: (project: string, name: string) => Promise;
}
const AppStateContext = createContext(null);
+const enc = encodeURIComponent;
+const agentKey = (project: string, name: string) => `${project}/${name}`;
+
+function isApiError(e: unknown): e is ApiError {
+ return e instanceof Error && typeof (e as ApiError).status === "number";
+}
+
+function errMessage(e: unknown): string {
+ if (e instanceof Error) return e.message || "request failed";
+ return String(e);
+}
+
+function isErrorStatus(status: string | null | undefined): boolean {
+ const s = (status ?? "").toLowerCase();
+ return s === "failed" || s === "error" || s === "fail";
+}
+
+/** Derive an agent name: a slug of the first few task words + 4 random hex chars. */
+function deriveAgentName(task: string): string {
+ const words = task
+ .toLowerCase()
+ .replace(/[^a-z0-9\s]/g, " ")
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 4);
+ const slug = words.join("-") || "agent";
+ const hex = Math.floor(Math.random() * 0x10000)
+ .toString(16)
+ .padStart(4, "0");
+ return `${slug}-${hex}`;
+}
+
export function AppStateProvider({ children }: { children: React.ReactNode }) {
+ const { config, clear } = useServerConfig();
+
+ // Fleet data.
+ const [projects, setProjects] = useState([]);
+ const [agentsByProject, setAgentsByProject] = useState>({});
+ const [checkmarks, setCheckmarks] = useState>({});
+ const [logsByAgent, setLogsByAgent] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Navigation.
const [screen, setScreen] = useState("fleet");
const [detailTab, setDetailTab] = useState("state");
- const [quickPick, setQuickPickState] = useState(null);
- const [logFilter, setLogFilter] = useState("all");
- const [answered, setAnswered] = useState(false);
- const [swEdits, setSwEdits] = useState(false);
- const [swTests, setSwTests] = useState(true);
- const [swPushWait, setSwPushWait] = useState(true);
- const [swPushFail, setSwPushFail] = useState(true);
+ const [logFilter, setLogFilter] = useState("all");
+ const [selected, setSelected] = useState(null);
+
+ const resetData = useCallback(() => {
+ setProjects([]);
+ setAgentsByProject({});
+ setCheckmarks({});
+ setLogsByAgent({});
+ setSelected(null);
+ setError(null);
+ }, []);
+
+ // The client is rebuilt whenever the endpoint/token change. A stale 401 clears local
+ // data and drops the stored config, which routes the app back to ConnectScreen.
+ const client = useMemo(() => {
+ if (!config) return null;
+ return createClient(config.endpoint, config.token, () => {
+ resetData();
+ setScreen("fleet");
+ void clear();
+ });
+ }, [config, clear, resetData]);
+
+ const refresh = useCallback(async () => {
+ if (!client) return;
+ try {
+ setError(null);
+ const projs = await client.api("/projects");
+
+ // Per-agent/per-project sub-requests are isolated: one flaky agent (a 500 on
+ // its log, say) must not blank the whole fleet. A rejected AuthError still
+ // propagates via onUnauthorized inside the client; here we just record the
+ // failure for that one item and keep the rest of the fleet rendering.
+ const agentLists = await Promise.all(
+ projs.map((p) =>
+ client
+ .api(`/projects/${enc(p.id)}/agents`)
+ .then((list) => [p.id, list] as const)
+ .catch((e) => {
+ if (e instanceof AuthError) throw e;
+ return [p.id, [] as Agent[]] as const;
+ }),
+ ),
+ );
+
+ const flat: { project: string; agent: Agent }[] = [];
+ const abp: Record = {};
+ for (const [pid, list] of agentLists) {
+ abp[pid] = list;
+ for (const a of list) flat.push({ project: pid, agent: a });
+ }
+
+ const cmEntries = await Promise.all(
+ flat.map(async ({ project, agent }) => {
+ const key = agentKey(project, agent.name);
+ try {
+ const cm = await client.api(
+ `/projects/${enc(project)}/agents/${enc(agent.name)}/checkmark`,
+ );
+ return [key, cm] as const;
+ } catch (e) {
+ if (e instanceof AuthError) throw e;
+ // 404 = no checkmark yet; any other error = leave it absent this cycle.
+ return [key, null] as const;
+ }
+ }),
+ );
+ const cmMap: Record = {};
+ for (const [k, v] of cmEntries) cmMap[k] = v;
+
+ const logEntries = await Promise.all(
+ flat.map(async ({ project, agent }) => {
+ const key = agentKey(project, agent.name);
+ try {
+ const log = await client.api(
+ `/projects/${enc(project)}/agents/${enc(agent.name)}/log`,
+ );
+ return [key, log] as const;
+ } catch (e) {
+ if (e instanceof AuthError) throw e;
+ return [key, [] as LogEntry[]] as const;
+ }
+ }),
+ );
+ const logMap: Record = {};
+ for (const [k, v] of logEntries) logMap[k] = v;
+
+ setProjects(projs);
+ setAgentsByProject(abp);
+ setCheckmarks(cmMap);
+ setLogsByAgent(logMap);
+ } catch (e) {
+ if (e instanceof AuthError) return; // handled by onUnauthorized
+ setError(errMessage(e));
+ } finally {
+ setLoading(false);
+ }
+ }, [client]);
+
+ // Initial load + 10s poll while mounted; re-runs when the client (endpoint/token) changes.
+ const refreshRef = useRef(refresh);
+ refreshRef.current = refresh;
+ useEffect(() => {
+ if (!client) return;
+ setLoading(true);
+ void refreshRef.current();
+ const id = setInterval(() => {
+ void refreshRef.current();
+ }, 10000);
+ return () => clearInterval(id);
+ }, [client]);
+
+ // ---- Derived view-models -------------------------------------------------
+ const waiting = useMemo(() => {
+ const out: WaitingItem[] = [];
+ for (const [pid, list] of Object.entries(agentsByProject)) {
+ for (const a of list) {
+ const cm = checkmarks[agentKey(pid, a.name)] ?? null;
+ const hasQuestion = !!(cm && cm.open_question);
+ const paused = a.status.toLowerCase() === "paused_for_input";
+ if (hasQuestion || paused) {
+ out.push({
+ project: pid,
+ name: a.name,
+ question:
+ cm?.open_question?.trim() || "Agent is paused, waiting for input.",
+ logEntryId: cm?.log_entry_id ?? null,
+ });
+ }
+ }
+ }
+ return out;
+ }, [agentsByProject, checkmarks]);
+
+ const recent = useMemo(() => {
+ const rows: RecentItem[] = [];
+ for (const [pid, list] of Object.entries(agentsByProject)) {
+ for (const a of list) {
+ const cm = checkmarks[agentKey(pid, a.name)];
+ if (!cm) continue;
+ rows.push({
+ key: agentKey(pid, a.name),
+ project: pid,
+ name: a.name,
+ title: `${pid} · ${a.name}`,
+ meta: `${statusLabel(cm.status).toLowerCase()} — tests ${statusLabel(
+ cm.tests_status,
+ ).toLowerCase()} · ${timeAgo(cm.checkpoint_at)}`,
+ tone: statusTone(cm.status) === "danger" ? "danger" : "positive",
+ checkpointAt: cm.checkpoint_at,
+ });
+ }
+ }
+ rows.sort(
+ (a, b) =>
+ new Date(b.checkpointAt).getTime() - new Date(a.checkpointAt).getTime(),
+ );
+ return rows;
+ }, [agentsByProject, checkmarks]);
+
+ const counts = useMemo(() => {
+ let running = 0;
+ let done = 0;
+ for (const list of Object.values(agentsByProject)) {
+ for (const a of list) {
+ const s = a.status.toLowerCase();
+ if (s === "working" || s === "running") running++;
+ else if (s === "done" || s === "failed") done++;
+ }
+ }
+ return { running, waiting: waiting.length, done };
+ }, [agentsByProject, waiting]);
+
+ const globalLog = useMemo(() => {
+ const rows: GlobalLogItem[] = [];
+ for (const [pid, list] of Object.entries(agentsByProject)) {
+ for (const a of list) {
+ const entries = logsByAgent[agentKey(pid, a.name)] ?? [];
+ for (const e of entries) {
+ rows.push({
+ key: `${pid}/${a.name}/${e.id}`,
+ project: pid,
+ name: a.name,
+ createdAt: e.created_at,
+ msg: e.summary?.trim() || statusLabel(e.status),
+ status: e.status,
+ ciStatus: e.ci_status,
+ err: isErrorStatus(e.status) || isErrorStatus(e.ci_status),
+ });
+ }
+ }
+ }
+ rows.sort(
+ (a, b) =>
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+ );
+ return rows;
+ }, [agentsByProject, logsByAgent]);
+
+ // ---- Selected agent ------------------------------------------------------
+ const selectedAgent = useMemo(() => {
+ if (!selected) return null;
+ return (
+ (agentsByProject[selected.project] ?? []).find(
+ (a) => a.name === selected.name,
+ ) ?? null
+ );
+ }, [selected, agentsByProject]);
+
+ const selectedCheckmark = selected
+ ? checkmarks[agentKey(selected.project, selected.name)] ?? null
+ : null;
+ const selectedLog = selected
+ ? logsByAgent[agentKey(selected.project, selected.name)] ?? []
+ : [];
+
+ // ---- Navigation helpers --------------------------------------------------
+ const openDetail = useCallback((project: string, name: string) => {
+ setSelected({ project, name });
+ setDetailTab("state");
+ setScreen("detail");
+ }, []);
+
+ const openAnswer = useCallback((project: string, name: string) => {
+ setSelected({ project, name });
+ setScreen("answer");
+ }, []);
+
+ // ---- Mutations -----------------------------------------------------------
+ const sendAnswer = useCallback(
+ async (text: string): Promise<{ resumed: boolean; note?: string }> => {
+ if (!client || !selected) throw new Error("no agent selected");
+ const cm = checkmarks[agentKey(selected.project, selected.name)] ?? null;
+ const base = `/projects/${enc(selected.project)}/agents/${enc(selected.name)}`;
+
+ await client.api(`${base}/answer`, {
+ body: {
+ answer: text,
+ ...(cm?.log_entry_id ? { log_entry_id: cm.log_entry_id } : {}),
+ },
+ });
+
+ // Resume is admin-only: a valid non-admin token 403s (or 401 with allow401 set) but
+ // the answer is already saved, so surface a soft note instead of failing.
+ try {
+ await client.api(`${base}/resume`, { body: {}, allow401: true });
+ } catch (e) {
+ if (isApiError(e) && (e.status === 401 || e.status === 403)) {
+ await refresh();
+ return {
+ resumed: false,
+ note: "answer saved — resume needs the admin token",
+ };
+ }
+ throw e;
+ }
+ await refresh();
+ return { resumed: true };
+ },
+ [client, selected, checkmarks, refresh],
+ );
+
+ const spawn = useCallback(
+ async (project: string, task: string) => {
+ if (!client) throw new Error("not connected");
+ const name = deriveAgentName(task);
+ await client.api(`/projects/${enc(project)}/agents/spawn`, {
+ body: { name, ...(task.trim() ? { task: task.trim() } : {}) },
+ });
+ await refresh();
+ },
+ [client, refresh],
+ );
+
+ const kill = useCallback(
+ async (project: string, name: string) => {
+ if (!client) throw new Error("not connected");
+ await client.api(`/projects/${enc(project)}/agents/${enc(name)}/kill`, {
+ method: "POST",
+ });
+ await refresh();
+ },
+ [client, refresh],
+ );
const value = useMemo(
() => ({
screen,
detailTab,
- quickPick,
logFilter,
- answered,
- swEdits,
- swTests,
- swPushWait,
- swPushFail,
-
- notAnswered: !answered,
- agentTone: answered ? "positive" : "warning",
- agentStatus: answered ? "Running" : "Waiting",
- agentStateText: answered ? ANSWERED_STATE : WAITING_STATE,
-
go: setScreen,
setDetailTab,
- setQuickPick: setQuickPickState,
setLogFilter,
- sendResume: () => {
- setAnswered(true);
- setDetailTab("state");
- setScreen("detail");
- },
- spawnGo: () => setScreen("fleet"),
- setSwEdits,
- setSwTests,
- setSwPushWait,
- setSwPushFail,
+ openDetail,
+ openAnswer,
+
+ loading,
+ error,
+ projects,
+ waiting,
+ recent,
+ counts,
+ globalLog,
+ refresh,
+
+ selectedAgent,
+ selectedCheckmark,
+ selectedLog,
+
+ sendAnswer,
+ spawn,
+ kill,
}),
[
screen,
detailTab,
- quickPick,
logFilter,
- answered,
- swEdits,
- swTests,
- swPushWait,
- swPushFail,
- ]
+ openDetail,
+ openAnswer,
+ loading,
+ error,
+ projects,
+ waiting,
+ recent,
+ counts,
+ globalLog,
+ refresh,
+ selectedAgent,
+ selectedCheckmark,
+ selectedLog,
+ sendAnswer,
+ spawn,
+ kill,
+ ],
);
return (
diff --git a/app/src/state/ServerConfig.tsx b/app/src/state/ServerConfig.tsx
new file mode 100644
index 0000000..04904ce
--- /dev/null
+++ b/app/src/state/ServerConfig.tsx
@@ -0,0 +1,92 @@
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+
+/**
+ * Persists the one thing the app needs to talk to a Handler server — the base
+ * endpoint and the bearer token — to AsyncStorage under a single versioned key.
+ *
+ * `config` is null until either the stored value loads (once `loading` flips
+ * false) or the operator connects. A persistent 401 calls `clear()`, dropping
+ * back to the ConnectScreen while `lastEndpoint` keeps the URL prefilled so the
+ * operator only re-enters the token.
+ */
+
+const STORAGE_KEY = "handler.server.v1";
+export const DEFAULT_ENDPOINT = "https://handler.home.leeworks.dev";
+
+export interface ServerConfig {
+ endpoint: string;
+ token: string;
+}
+
+interface ServerConfigValue {
+ config: ServerConfig | null;
+ loading: boolean;
+ /** The last endpoint we saw, for prefilling ConnectScreen after a sign-out / 401. */
+ lastEndpoint: string;
+ save: (config: ServerConfig) => Promise;
+ clear: () => Promise;
+}
+
+const ServerConfigContext = createContext(null);
+
+export function ServerConfigProvider({ children }: { children: React.ReactNode }) {
+ const [config, setConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [lastEndpoint, setLastEndpoint] = useState(DEFAULT_ENDPOINT);
+
+ useEffect(() => {
+ let active = true;
+ (async () => {
+ try {
+ const raw = await AsyncStorage.getItem(STORAGE_KEY);
+ if (active && raw) {
+ const parsed = JSON.parse(raw) as ServerConfig;
+ if (parsed && parsed.endpoint && parsed.token) {
+ setConfig(parsed);
+ setLastEndpoint(parsed.endpoint);
+ }
+ }
+ } catch {
+ /* corrupt/unreadable storage — treat as unconfigured */
+ } finally {
+ if (active) setLoading(false);
+ }
+ })();
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ const save = useCallback(async (next: ServerConfig) => {
+ await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
+ setConfig(next);
+ setLastEndpoint(next.endpoint);
+ }, []);
+
+ const clear = useCallback(async () => {
+ await AsyncStorage.removeItem(STORAGE_KEY);
+ setConfig(null);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useServerConfig(): ServerConfigValue {
+ const ctx = useContext(ServerConfigContext);
+ if (!ctx)
+ throw new Error("useServerConfig must be used within ServerConfigProvider");
+ return ctx;
+}