diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml new file mode 100644 index 0000000..c583b14 --- /dev/null +++ b/.github/workflows/mobile-release.yml @@ -0,0 +1,102 @@ +name: Mobile Release + +# Builds the Handler mobile app (app/) with EAS Build. +# +# Secrets required for the `build` job: +# EXPO_TOKEN - an Expo access token (https://expo.dev/settings/access-tokens) +# +# Apple credentials: +# - The `preview` profile builds an iOS *simulator* app and needs NO Apple account. +# - The `production` profile builds a signed .ipa and requires a paid Apple +# Developer Program membership. Those certificates are managed and stored by +# EAS (authorize once with `eas credentials`) — they are NOT uploaded here. + +on: + workflow_dispatch: + inputs: + platform: + description: Platform to build + type: choice + options: [ios, android, all] + default: ios + profile: + description: EAS build profile + type: choice + options: [preview, production] + default: preview + push: + # Cut a release by pushing a tag like `mobile-v1.0.0`. + tags: + - 'mobile-v*' + +defaults: + run: + working-directory: app + +jobs: + # Always-on validation. Needs no secrets and no Apple account. + validate: + name: Validate (install + typecheck) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: app/package-lock.json + - name: Install dependencies + run: npm ci + - name: Typecheck + run: npx tsc --noEmit + + # Cloud build via EAS. Requires the EXPO_TOKEN secret. + build: + name: EAS Build + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Ensure EXPO_TOKEN is configured + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + if [ -z "$EXPO_TOKEN" ]; then + echo "::error::EXPO_TOKEN secret is not set. Add it under" \ + "Settings → Secrets and variables → Actions before running a build." >&2 + exit 1 + fi + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: app/package-lock.json + + - uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Install dependencies + run: npm ci + + - name: Resolve build parameters + id: params + run: | + if [ "${{ github.event_name }}" = "push" ]; then + echo "platform=all" >> "$GITHUB_OUTPUT" + echo "profile=production" >> "$GITHUB_OUTPUT" + else + echo "platform=${{ github.event.inputs.platform }}" >> "$GITHUB_OUTPUT" + echo "profile=${{ github.event.inputs.profile }}" >> "$GITHUB_OUTPUT" + fi + + - name: EAS build + run: | + eas build \ + --platform "${{ steps.params.outputs.platform }}" \ + --profile "${{ steps.params.outputs.profile }}" \ + --non-interactive \ + --no-wait diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..541173c --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.expo/ +dist/ +web-build/ +*.log +.DS_Store diff --git a/app/App.tsx b/app/App.tsx new file mode 100644 index 0000000..879a267 --- /dev/null +++ b/app/App.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { View } from "react-native"; +import { StatusBar } from "expo-status-bar"; +import { + SafeAreaProvider, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { + useFonts, + Outfit_400Regular, + Outfit_500Medium, + Outfit_600SemiBold, + Outfit_700Bold, + Outfit_800ExtraBold, +} from "@expo-google-fonts/outfit"; +import { + Figtree_400Regular, + Figtree_500Medium, + Figtree_600SemiBold, + Figtree_700Bold, + Figtree_400Regular_Italic, +} from "@expo-google-fonts/figtree"; +import { + SplineSansMono_400Regular, + SplineSansMono_500Medium, + SplineSansMono_600SemiBold, +} from "@expo-google-fonts/spline-sans-mono"; + +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"; +import { SpawnScreen } from "./src/screens/SpawnScreen"; +import { LogScreen } from "./src/screens/LogScreen"; +import { SettingsScreen } from "./src/screens/SettingsScreen"; + +function Router() { + const { screen } = useAppState(); + + const Screen = { + connect: ConnectScreen, + fleet: FleetScreen, + detail: AgentDetailScreen, + answer: AnswerScreen, + spawn: SpawnScreen, + log: LogScreen, + 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 ? ( + + + + ) : ( + + )} + + ); +} + +export default function App() { + const [fontsLoaded] = useFonts({ + Outfit_400Regular, + Outfit_500Medium, + Outfit_600SemiBold, + Outfit_700Bold, + Outfit_800ExtraBold, + Figtree_400Regular, + Figtree_500Medium, + Figtree_600SemiBold, + Figtree_700Bold, + Figtree_400Regular_Italic, + SplineSansMono_400Regular, + SplineSansMono_500Medium, + SplineSansMono_600SemiBold, + }); + + return ( + + {fontsLoaded ? ( + + + + ) : ( + + )} + + ); +} + +/** Blank page-colored screen while fonts load (avoids a font flash). */ +function SplashPlaceholder() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + return ( + + ); +} diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..a2e5c65 --- /dev/null +++ b/app/README.md @@ -0,0 +1,63 @@ +# Handler — mobile app + +A remote control for [Handler](https://github.com/0xWheatyz/handler): run many +Claude Code agents across many projects, each isolated, each leaving a +checkmark (current state) and an entry in the global log. + +This is the **React Native + Expo (iOS)** implementation of the +`Handler Mobile.dc.html` design (turn `2a`, the version the user committed to: +*"Commit to 1a, wire-up the real screens"*). It reproduces the interactive +prototype as a real app — six wired screens with the exact state logic from +the design. + +## Run it + +```bash +cd app +npm install +npm run ios # opens the iOS simulator (requires Xcode) +# or: npm start then scan the QR code with Expo Go on a device +``` + +## Screens + +| Screen | File | What it does | +| --- | --- | --- | +| Fleet (home) | `src/screens/FleetScreen.tsx` | Stat cards, "Waiting on you" list → Answer, "Recent checkmarks" → detail | +| Agent detail | `src/screens/AgentDetailScreen.tsx` | Checkmark / Log segmented control, meta table, Answer / Pause / Kill | +| Answer | `src/screens/AnswerScreen.tsx` | Question, tappable quick replies, reply field + **Send & resume** | +| Spawn | `src/screens/SpawnScreen.tsx` | Project select, task field, two toggles, Spawn | +| Log | `src/screens/LogScreen.tsx` | All / handler / Errors filters over the global feed | +| Settings | `src/screens/SettingsScreen.tsx` | Server info, notification toggles, Sign out | + +The prototype navigates by swapping a single `screen` value (with working back +/ close controls) rather than a native stack, mirroring the design. Answering +`agt-7a1d` flips it **Waiting → Running** everywhere and clears it from the +Fleet waiting list — that cross-screen behavior lives in one shared store +(`src/state/AppState.tsx`, a direct port of the design's `renderVals()`). + +## Design system + +The Leeworks tokens (`project/_ds/.../tokens/*.css`) are ported to typed RN +values in `src/theme/tokens.ts`; the components used by these screens +(`Button`, `Badge`, `Icon`, `Switch`, `Select`, `Input`, segmented control, +chip) are reimplemented in `src/components/` from the design-system bundle. + +- **Fonts:** Outfit (display), Figtree (body), Spline Sans Mono (data) via + `@expo-google-fonts/*`. +- **Colors:** the warm-neutral ink ramp + muted status colors, light and dark. + +## Intentional deviations from the HTML prototype + +The prototype drew a phone frame to make an HTML mock look like a device. A +real iOS app *is* the device, so: + +- The fake status bar (`9:41`, signal, battery) and the home-indicator pill are + dropped — the OS draws those. Screens use safe-area insets and + `expo-status-bar` instead. +- **Dark mode** was a design-time prop; here it follows the system appearance + (`useColorScheme`, `userInterfaceStyle: "automatic"`). +- The `Select` uses a bottom-sheet picker (native ` + * has no cross-platform styling in RN, so the field opens a bottom sheet of + * options — same visual field (value + chevron), real picking behavior. + */ +export function Select({ + label, + options, + value, + onChange, +}: { + label: string; + options: string[]; + value: string; + onChange: (v: string) => void; +}) { + const { colors } = useTheme(); + const [open, setOpen] = useState(false); + + return ( + + {label} + setOpen(true)} + style={[ + styles.field, + { backgroundColor: colors.surfaceCard, borderColor: colors.borderDefault }, + ]} + > + + {value} + + + + + setOpen(false)}> + setOpen(false)}> + + {options.map((opt) => { + const on = opt === value; + return ( + { + onChange(opt); + setOpen(false); + }} + style={({ pressed }) => [ + styles.option, + pressed && { backgroundColor: colors.surfaceSunken }, + ]} + > + + {opt} + + {on && } + + ); + })} + + + + + ); +} + +const styles = StyleSheet.create({ + label: { + fontFamily: fonts.bodySemiBold, + fontSize: 13, + lineHeight: 16, + }, + field: { + height: 48, + flexDirection: "row", + alignItems: "center", + borderWidth: 1, + borderRadius: radius.md, + paddingHorizontal: 12, + gap: 8, + }, + backdrop: { + flex: 1, + backgroundColor: "rgba(20,20,19,0.4)", + justifyContent: "flex-end", + padding: 20, + }, + sheet: { + borderWidth: 1, + borderRadius: radius.xl, + overflow: "hidden", + marginBottom: 24, + }, + option: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 16, + paddingHorizontal: 20, + minHeight: 44, + }, +}); diff --git a/app/src/components/Switch.tsx b/app/src/components/Switch.tsx new file mode 100644 index 0000000..58c480e --- /dev/null +++ b/app/src/components/Switch.tsx @@ -0,0 +1,66 @@ +import React, { useEffect, useRef } from "react"; +import { Animated, Pressable, StyleSheet } from "react-native"; +import { radius } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; + +/** + * Leeworks Switch, ported from components/forms/Switch.jsx. + * 36×22 track, 18px thumb, 14px travel, 180ms ease. + */ +export function Switch({ + value, + onValueChange, +}: { + value: boolean; + onValueChange: (v: boolean) => void; +}) { + const { colors } = useTheme(); + const anim = useRef(new Animated.Value(value ? 1 : 0)).current; + + useEffect(() => { + Animated.timing(anim, { + toValue: value ? 1 : 0, + duration: 180, + useNativeDriver: false, + }).start(); + }, [value, anim]); + + const trackColor = anim.interpolate({ + inputRange: [0, 1], + outputRange: [colors.ink3, colors.interactive], + }); + const translateX = anim.interpolate({ + inputRange: [0, 1], + outputRange: [0, 14], + }); + + return ( + onValueChange(!value)} hitSlop={8}> + + + + + ); +} + +const styles = StyleSheet.create({ + track: { + width: 36, + height: 22, + borderRadius: radius.pill, + padding: 2, + }, + thumb: { + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: "#ffffff", + shadowColor: "#141413", + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.2, + shadowRadius: 2, + elevation: 2, + }, +}); diff --git a/app/src/components/TabBar.tsx b/app/src/components/TabBar.tsx new file mode 100644 index 0000000..490973e --- /dev/null +++ b/app/src/components/TabBar.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { fonts } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Icon, type IconName } from "./Icon"; +import { useAppState, type Screen } from "../state/AppState"; + +/** + * Bottom tab bar shown on the three primary screens (Fleet / Log / Settings). + * The design's fixed 24px bottom inset is replaced by the real home-indicator + * safe-area inset. + */ + +const TABS: { key: Screen; label: string; icon: IconName }[] = [ + { key: "fleet", label: "Fleet", icon: "home" }, + { key: "log", label: "Log", icon: "file" }, + { key: "settings", label: "Settings", icon: "settings" }, +]; + +export function TabBar({ active }: { active: Screen }) { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { go } = useAppState(); + + return ( + + {TABS.map((t) => { + const on = t.key === active; + const tint = on ? colors.textHeading : colors.ink4; + return ( + go(t.key)} + > + + + {t.label} + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + bar: { + flexDirection: "row", + borderTopWidth: 1, + paddingTop: 8, + paddingHorizontal: 12, + }, + tab: { + flex: 1, + alignItems: "center", + gap: 4, + paddingVertical: 6, + }, + label: { + fontSize: 12, + lineHeight: 14, + }, +}); diff --git a/app/src/components/TextField.tsx b/app/src/components/TextField.tsx new file mode 100644 index 0000000..58960aa --- /dev/null +++ b/app/src/components/TextField.tsx @@ -0,0 +1,97 @@ +import React, { useState } from "react"; +import { + StyleSheet, + TextInput, + View, + type KeyboardTypeOptions, + type StyleProp, + type TextInputProps, + type ViewStyle, +} from "react-native"; +import { fonts, radius } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; + +/** + * Text field covering both the Leeworks single-line Input (answer reply) and + * the multiline task textarea on the spawn screen. Focus swaps the border to + * `--border-strong` per the Input component. + */ +export function TextField({ + value, + onChangeText, + placeholder, + multiline = false, + height = 48, + style, + secureTextEntry = false, + autoCapitalize, + autoCorrect, + keyboardType, +}: { + value: string; + onChangeText: (t: string) => void; + placeholder?: string; + multiline?: boolean; + /** 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); + return ( + + setFocus(true)} + onBlur={() => setFocus(false)} + style={[ + styles.input, + { + color: colors.textHeading, + textAlignVertical: multiline ? "top" : "center", + }, + ]} + /> + + ); +} + +const styles = StyleSheet.create({ + wrap: { + flexDirection: "row", + borderWidth: 1, + borderRadius: radius.md, + paddingHorizontal: 14, + }, + input: { + flex: 1, + alignSelf: "stretch", + fontFamily: fonts.bodyRegular, + fontSize: 15, + padding: 0, + }, +}); diff --git a/app/src/components/ToggleRow.tsx b/app/src/components/ToggleRow.tsx new file mode 100644 index 0000000..04b4bf2 --- /dev/null +++ b/app/src/components/ToggleRow.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Switch } from "./Switch"; + +/** + * A labelled settings row with a trailing Switch — used for the spawn options + * and the notification settings. Matches the `min-height:44px` tap target. + */ +export function ToggleRow({ + title, + subtitle, + value, + onValueChange, +}: { + title: string; + subtitle: string; + value: boolean; + onValueChange: (v: boolean) => void; +}) { + const { colors } = useTheme(); + return ( + + + {title} + + {subtitle} + + + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: 14, + paddingHorizontal: 16, + minHeight: 44, + }, +}); diff --git a/app/src/components/primitives.tsx b/app/src/components/primitives.tsx new file mode 100644 index 0000000..3e5fe4c --- /dev/null +++ b/app/src/components/primitives.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { + StyleSheet, + Text, + View, + type StyleProp, + type TextStyle, + type ViewStyle, +} from "react-native"; +import { fonts, radius, shadows, text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; + +/** White card: 1px subtle border, 10px radius, whisper-quiet shadow. */ +export function Card({ + children, + style, +}: { + children: React.ReactNode; + style?: StyleProp; +}) { + const { colors } = useTheme(); + return ( + + {children} + + ); +} + +/** Uppercase, letter-spaced overline label (Outfit 600, 11px). */ +export function SectionLabel({ + children, + style, +}: { + children: string; + style?: StyleProp; +}) { + const { colors } = useTheme(); + return ( + + {children} + + ); +} + +/** Hairline divider used between card rows. */ +export function Divider() { + const { colors } = useTheme(); + return ; +} + +/** 8px status dot. */ +export function StatusDot({ color, size = 8 }: { color: string; size?: number }) { + return ( + + ); +} + +/** Monospace text helper (Spline Sans Mono). */ +export function Mono({ + children, + style, + numberOfLines, +}: { + children: React.ReactNode; + style?: StyleProp; + numberOfLines?: number; +}) { + return ( + + {children} + + ); +} + +const monoStyles = StyleSheet.create({ + mono: { fontFamily: fonts.monoRegular }, +}); diff --git a/app/src/screens/AgentDetailScreen.tsx b/app/src/screens/AgentDetailScreen.tsx new file mode 100644 index 0000000..55aac94 --- /dev/null +++ b/app/src/screens/AgentDetailScreen.tsx @@ -0,0 +1,254 @@ +import React from "react"; +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"; +import { Button } from "../components/Button"; +import { PageHeader } from "../components/PageHeader"; +import { SegmentedControl } from "../components/SegmentedControl"; +import { Card, Divider, Mono, SectionLabel } from "../components/primitives"; +import { useAppState } from "../state/AppState"; +import { + clockTime, + statusColor, + statusLabel, + statusTone, + timeAgo, +} from "../api/format"; + +export function AgentDetailScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { + go, + openAnswer, + detailTab, + setDetailTab, + 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={agent.name} + badge={{ tone: statusTone(agent.status), label: statusLabel(agent.status) }} + /> + + {agent.name} + + {agent.project_id} + {agent.role ? " · " : ""} + {agent.role ? {agent.role} : null} + + + + + + + {detailTab === "state" ? ( + <> + + {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. + + )} + + + + {meta.map((m, i) => ( + + {i > 0 && } + + {m.label} + {m.value} + + + ))} + + + ) : ( + + {logRows.length === 0 ? ( + No log entries yet. + ) : ( + logRows.map((row) => ( + + + {clockTime(row.created_at)} + + + {row.summary?.trim() || statusLabel(row.status)} + + + )) + )} + + )} + + + {openQuestion ? ( + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create({ + page: { flex: 1 }, + content: { paddingTop: 8, paddingHorizontal: 20 }, + sunkenCard: { + borderWidth: 1, + borderRadius: radius.lg, + padding: 16, + }, + stateText: { + fontSize: 13, + lineHeight: 22, + }, + stepRow: { flexDirection: "row" }, + metaRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 12, + paddingHorizontal: 16, + }, + logRow: { flexDirection: "row", gap: 10 }, + logMono: { + fontSize: 12.5, + lineHeight: 26, + }, + actions: { + flexDirection: "row", + gap: 10, + marginTop: 20, + }, +}); diff --git a/app/src/screens/AnswerScreen.tsx b/app/src/screens/AnswerScreen.tsx new file mode 100644 index 0000000..626779d --- /dev/null +++ b/app/src/screens/AnswerScreen.tsx @@ -0,0 +1,168 @@ +import React, { useState } from "react"; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + StyleSheet, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { radius, text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Button } from "../components/Button"; +import { PageHeader } from "../components/PageHeader"; +import { TextField } from "../components/TextField"; +import { Mono, SectionLabel } from "../components/primitives"; +import { useAppState } from "../state/AppState"; +import { timeAgo } from "../api/format"; + +export function AnswerScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + 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 ( + + + + + go("detail")} + agentId={agent.name} + badge={{ tone: "warning", label: "Waiting" }} + /> + + + Agent needs input + + + + + {`Question${askedAt ? ` · ${timeAgo(askedAt)} ago` : ""}`} + + + {question} + + + + {error ? ( + + {error} + + ) : null} + {note ? ( + + {note} + + ) : null} + + + + + {busy ? ( + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create({ + page: { flex: 1 }, + flex: { flex: 1 }, + content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 }, + questionCard: { + borderWidth: 1, + borderRadius: radius.lg, + padding: 16, + marginBottom: 16, + }, + questionText: { fontSize: 13, lineHeight: 22 }, + 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 new file mode 100644 index 0000000..074a9cb --- /dev/null +++ b/app/src/screens/FleetScreen.tsx @@ -0,0 +1,254 @@ +import React from "react"; +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"; +import { Button } from "../components/Button"; +import { Icon } from "../components/Icon"; +import { + Card, + Divider, + Mono, + SectionLabel, + StatusDot, +} from "../components/primitives"; +import { TabBar } from "../components/TabBar"; +import { + useAppState, + type RecentItem, + type WaitingItem, +} from "../state/AppState"; + +export function FleetScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { go, openAnswer, openDetail, waiting, recent, counts, loading, error, refresh } = + useAppState(); + + const empty = waiting.length === 0 && recent.length === 0; + + const stats = [ + { 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 ( + + + + + + Fleet + + {counts.running} running · {counts.waiting} waiting on you + + + + + + + {stats.map((s) => ( + + + {s.label} + + + {s.value} + + + ))} + + + {loading && empty && !error ? ( + + + + ) : error && empty ? ( + + Couldn’t load fleet + + {error} + + + + ) : ( + <> + Waiting on you + + {waiting.length === 0 ? ( + + + Nothing waiting on you. + + + ) : ( + waiting.map((a, i) => ( + + {i > 0 && } + openAnswer(a.project, a.name)} + /> + + )) + )} + + + Recent checkmarks + + {recent.length === 0 ? ( + + + No checkmarks yet. + + + ) : ( + recent.map((c, i) => ( + + {i > 0 && } + openDetail(c.project, c.name)} + /> + + )) + )} + + + )} + + + + ); +} + +function WaitingRow({ + agent, + onAnswer, +}: { + agent: WaitingItem; + onAnswer: () => void; +}) { + const { colors } = useTheme(); + return ( + + + {agent.name} + + {agent.project} + + + + + {agent.question} + + + + + ); +} + +function CheckmarkRow({ + checkmark, + onPress, +}: { + checkmark: RecentItem; + onPress: () => void; +}) { + const { colors } = useTheme(); + const dot = checkmark.tone === "positive" ? colors.positive : colors.danger; + return ( + [ + styles.checkmarkRow, + pressed && { backgroundColor: colors.surfaceSunken }, + ]} + > + + + + {checkmark.title} + + + {checkmark.meta} + + + + + ); +} + +const styles = StyleSheet.create({ + page: { flex: 1 }, + content: { paddingTop: 8, paddingHorizontal: 20, paddingBottom: 20 }, + headerRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + marginTop: 12, + marginBottom: 20, + }, + statsRow: { flexDirection: "row", gap: 12, marginBottom: 20 }, + statCard: { flex: 1, paddingVertical: 14, paddingHorizontal: 16 }, + statValue: { + fontFamily: fonts.monoSemiBold, + fontSize: 24, + lineHeight: 28, + marginTop: 4, + }, + centered: { paddingVertical: 48, alignItems: "center", justifyContent: "center" }, + errorCard: { padding: 16, alignItems: "flex-start" }, + overline: { marginBottom: 10 }, + section: { marginBottom: 20 }, + rowPad: { paddingVertical: 14, paddingHorizontal: 16 }, + waitingTop: { flexDirection: "row", alignItems: "center", gap: 8 }, + waitingBottom: { + flexDirection: "row", + alignItems: "center", + gap: 10, + marginTop: 8, + }, + rowTitle: { + fontFamily: fonts.bodySemiBold, + fontSize: 13, + lineHeight: 16, + }, + checkmarkRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingVertical: 14, + paddingHorizontal: 16, + minHeight: 44, + }, +}); diff --git a/app/src/screens/LogScreen.tsx b/app/src/screens/LogScreen.tsx new file mode 100644 index 0000000..3c90e86 --- /dev/null +++ b/app/src/screens/LogScreen.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Chip } from "../components/Chip"; +import { Mono, SectionLabel } from "../components/primitives"; +import { TabBar } from "../components/TabBar"; +import { useAppState } from "../state/AppState"; +import { clockTime, statusColor } from "../api/format"; + +export function LogScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { logFilter, setLogFilter, globalLog, projects } = useAppState(); + + 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 ( + + + + + Log + + {filters.map((f) => ( + setLogFilter(f.key)} + /> + ))} + + + + + Activity + + + + {entries.length === 0 ? ( + No activity yet. + ) : ( + entries.map((e) => ( + + + {clockTime(e.createdAt)} + + + {e.name} + + + {e.msg} + + + )) + )} + + + + + ); +} + +const styles = StyleSheet.create({ + page: { flex: 1 }, + header: { paddingHorizontal: 20, paddingTop: 20, paddingBottom: 16 }, + filters: { flexDirection: "row", gap: 8, marginTop: 14, paddingRight: 20 }, + todayLabel: { paddingHorizontal: 20, paddingBottom: 8 }, + feed: { + borderTopWidth: 1, + paddingHorizontal: 20, + paddingVertical: 16, + flexGrow: 1, + }, + logRow: { flexDirection: "row", gap: 10 }, + mono: { fontSize: 12.5, lineHeight: 26 }, + idCol: { width: 86 }, +}); diff --git a/app/src/screens/SettingsScreen.tsx b/app/src/screens/SettingsScreen.tsx new file mode 100644 index 0000000..1e31a91 --- /dev/null +++ b/app/src/screens/SettingsScreen.tsx @@ -0,0 +1,158 @@ +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"; +import { useTheme } from "../theme/useTheme"; +import { Button } from "../components/Button"; +import { ToggleRow } from "../components/ToggleRow"; +import { + Card, + Divider, + Mono, + SectionLabel, + StatusDot, +} from "../components/primitives"; +import { TabBar } from "../components/TabBar"; +import { 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 { 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 ( + + + + + Settings + + + Server + + + + + + + Status + + + + {status.label} + + + + + + Notifications + + + + + + + + + + + ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + const { colors } = useTheme(); + return ( + + {label} + + {value} + + + ); +} + +const styles = StyleSheet.create({ + page: { flex: 1 }, + content: { paddingTop: 8, paddingHorizontal: 20, paddingBottom: 20 }, + infoRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: 14, + paddingHorizontal: 16, + minHeight: 44, + gap: 12, + }, + statusValue: { flexDirection: "row", alignItems: "center", gap: 6 }, + valueMono: { fontSize: 12.5 }, + valueFlex: { flex: 1, textAlign: "right" }, +}); diff --git a/app/src/screens/SpawnScreen.tsx b/app/src/screens/SpawnScreen.tsx new file mode 100644 index 0000000..26fe176 --- /dev/null +++ b/app/src/screens/SpawnScreen.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useState } from "react"; +import { + KeyboardAvoidingView, + Platform, + StyleSheet, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { radius, text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Button } from "../components/Button"; +import { PageHeader } from "../components/PageHeader"; +import { Select } from "../components/Select"; +import { TextField } from "../components/TextField"; +import { useAppState } from "../state/AppState"; + +export function SpawnScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + 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 ( + + + + + go("fleet")} + title="New agent" + /> + + + {projectIds.length > 0 ? ( + +
Runs isolated on a fresh branch.
+ +
+
Auto-approve edits
Skip file-edit confirmations
+
Run tests on done
Checkmark fails if tests fail
+
+ +
+ Spawn agent +
+ +
+ + + + +
+
+
Log
+
+ + + +
+
+
Today
+
+ +
{{ e.t }}{{ e.id }}{{ e.msg }}
+
+
+
+
+
Fleet
+
Log
+
Settings
+
+
+ + + +
+
Settings
+
Server
+
+
Endpointhttps://handler.wheaty.dev
+
API keyhnd_••••••••4f2a
+
Statusconnected · 38ms
+
+
Notifications
+
+
Waiting on input
Push when an agent pauses
+
Failures
Push when a checkmark fails
+
+ Sign out +
+
+
Fleet
+
Log
+
Settings
+
+
+ +
+ + +

Answering agt-7a1d flips it to Running and clears it from the waiting list. Turn 1 explorations kept below.

+ +
+
1Handler — iOS app, first pass
+

Assumptions from the repo: entities are projects → agents; each agent carries a checkmark (current state) and writes to a global log; agents can be running, waiting on input, done, or failed. Tabs: Fleet / Log / Settings. Copy is terse, mono for ids & data per Leeworks. Three directions for the main screen below, then one shared flow.

+ +
Main screen — 3 directions
+
+ + +
1aAttention-first — what needs you, then everything else
+
+
9:41
+
+
+
+
Fleet
+
12 agents · 3 waiting on you
+
+ New +
+
+
Running
6
+
Waiting
3
+
Done
42
+
+
Waiting on you
+
+
+
agt-7a1dhandler · migrate state to sqlite
+
"Drop the legacy JSON store, or keep it as a read fallback?"Answer
+
+
+
agt-3e90wheatsite · fix build on node 22
+
"Pin node 20 in CI, or patch esbuild?"Answer
+
+
+
agt-b241api-gateway · add rate limiting
+
"429 body: JSON or plain text?"Answer
+
+
+
Recent checkmarks
+
+
handler · add /agents endpoint
done — tests pass · 14m ago
+
wheatsite · refactor router
failed — 2 tests · 1h ago
+
dotfiles · port zsh config
done — 12 turns · 3h ago
+
+
+
+
Fleet
+
Log
+
Settings
+
+
+
+ + +
1bProject-grouped — fleet organized by repo
+
+
9:41
+
+
+
+
Projects
+
4 projects · 12 agents
+
+ New +
+
+
+
handler3 agents
+
migrate state to sqlite
waiting on input · 2m
Waiting
+
write API docs
running · turn 21
Running
+
add /agents endpoint
done · 14m ago
Done
+
+
+
wheatsite2 agents
+
fix build on node 22
waiting on input · 18m
Waiting
+
refactor router
failed — 2 tests · 1h
Failed
+
+
+
dotfiles1 agent
+
port zsh config
running · turn 6
Running
+
+
+
+
+
Fleet
+
Log
+
Settings
+
+
+
+ + +
1cLog-first — the fleet as a live terminal feed
+
+
9:41
+
+
+
+
handler
+ live +
+
+ 6 running + 3 waiting + 42 done +
+
+
+
14:02:11agt-7a1d paused — waiting for input
+
13:58:40agt-9c77 checkmark updated
+
13:51:02agt-2d08 done — 34 turns, tests pass
+
13:44:19agt-e33a error — 2 tests failed
+
13:39:55spawn agt-51f0 → dotfiles
+
13:31:07agt-b241 paused — waiting for input
+
13:18:44agt-9c77 tool: bash — cargo test
+
13:02:30agt-90bc done — 12 turns
+
12:57:12agt-51f0 tool: edit — .zshrc
+
12:49:03agt-e33a checkmark updated
+
12:40:38spawn agt-b241 → api-gateway
+
+
+
+
Fleet
+
Log
+
Settings
+
+
+
+
+ +
The flow — agent detail → answer → spawn → global log
+
+ + +
1dAgent detail — checkmark + log (segmented control works)
+
+
9:41
+
+
+ + agt-7a1d + Waiting +
+
Migrate agent state to sqlite
+
handler · branch agt/7a1d
+
+ + +
+ +
+
Current state
+
Schema written, migrations pass. Blocked on the legacy JSON store — drop it or keep as read fallback? Holding before deleting store/json.rs.
+
updated 2m ago
+
+
+
Started41m ago
+
Modelclaude-sonnet-4
+
Turns21
+
Tokens348k
+
+
+ +
+
14:02paused — waiting for input
+
13:57checkmark updated
+
13:52tool: bash — sqlite3 .schema
+
13:48tool: edit — store/sqlite.rs
+
13:40tool: bash — cargo test store
+
13:29checkmark updated
+
13:21tool: read — store/json.rs
+
+
+
+ Answer + Pause + Kill +
+
+
+
+
+ + +
1eAnswer & resume — quick replies are tappable
+
+
9:41
+
+
+ + agt-7a1d + Waiting +
+
Agent needs input
+
+
Question · 2m ago
+
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?
+
+
Quick replies
+
+ + + +
+
+ + Send & resume +
+
+
+
+
+ + +
1fSpawn agent — toggles work
+
+
9:41
+
+
+ + New agent +
+
+ +
+
Task
+ +
Runs isolated on a fresh branch.
+
+
+
Auto-approve edits
Skip file-edit confirmations
+
Run tests on done
Checkmark fails if tests fail
+
+
+
+ Spawn agent +
+
+
+
+
+ + +
1gGlobal log — filters work
+
+
9:41
+
+
+
Log
+
+ + + +
+
+
Today
+
+ +
{{ e.t }}{{ e.id }}{{ e.msg }}
+
+
+
+
+
Fleet
+
Log
+
Settings
+
+
+
+
+ +

Try next: "commit to 1a and wire the screens into one tappable prototype" · "merge 1b's project cards into 1a" · "show a dark-mode pass" (there's a Dark mode tweak too)

+
+ + + + diff --git a/project/_ds/leeworks-design-system-b3e560db-22c8-4dea-b1ef-d19ecb5f5b6e/_adherence.oxlintrc.json b/project/_ds/leeworks-design-system-b3e560db-22c8-4dea-b1ef-d19ecb5f5b6e/_adherence.oxlintrc.json new file mode 100644 index 0000000..f53eb1d --- /dev/null +++ b/project/_ds/leeworks-design-system-b3e560db-22c8-4dea-b1ef-d19ecb5f5b6e/_adherence.oxlintrc.json @@ -0,0 +1,432 @@ +{ + "plugins": [ + "react", + "import" + ], + "rules": { + "react/forbid-elements": [ + "warn", + { + "forbid": [] + } + ], + "no-restricted-imports": [ + "warn", + { + "patterns": [ + { + "group": [ + "components/display/**", + "components/feedback/**", + "components/forms/**", + "components/navigation/**", + "ui_kits/dashboard/**", + "ui_kits/mobile/**", + "ui_kits/website/**" + ], + "message": "Import design-system components from 'index.js', not component internals." + } + ] + } + ], + "no-restricted-syntax": [ + "warn", + { + "selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]", + "message": "Raw hex color — use a design-system color token via var()." + }, + { + "selector": "Literal[value=/\\b\\d+px\\b/]", + "message": "Raw px value — use a design-system spacing token via var()." + }, + { + "selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Outfit|Figtree|Spline Sans Mono))/i]", + "message": "Font not provided by the design system. Available: Outfit, Figtree, Spline Sans Mono." + }, + { + "selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute > JSXIdentifier[name!=/^(?:tone|children|style|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: tone, children, style." + }, + { + "selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|positive|warning|danger|inverse)$/]", + "message": " tone must be one of 'neutral' | 'positive' | 'warning' | 'danger' | 'inverse'." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:variant|size|disabled|icon|children|onClick|style|key|ref|className|style|children)$/]", + "message": "