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} - + {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} + + + + + + + + + ); +} + +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 + + ) : ( + <> + 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() { - @@ -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() { /> - + ) : ( + + + Project + + + No projects registered yet. + + + )} @@ -63,26 +103,25 @@ export function SpawnScreen() { - - - - - + {error ? ( + + {error} + + ) : null} - @@ -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; +}