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..5c80c5b --- /dev/null +++ b/app/App.tsx @@ -0,0 +1,102 @@ +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 { 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 { scheme, colors } = useTheme(); + + const Screen = { + fleet: FleetScreen, + detail: AgentDetailScreen, + answer: AnswerScreen, + spawn: SpawnScreen, + log: LogScreen, + settings: SettingsScreen, + }[screen]; + + return ( + + + + + ); +} + +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..a47317c --- /dev/null +++ b/app/src/components/TextField.tsx @@ -0,0 +1,83 @@ +import React, { useState } from "react"; +import { + StyleSheet, + TextInput, + View, + type StyleProp, + 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, +}: { + 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; +}) { + 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..ae2cdec --- /dev/null +++ b/app/src/components/primitives.tsx @@ -0,0 +1,89 @@ +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, +}: { + children: React.ReactNode; + style?: StyleProp; +}) { + return {children}; +} + +const monoStyles = StyleSheet.create({ + mono: { fontFamily: fonts.monoRegular }, +}); diff --git a/app/src/data/mock.ts b/app/src/data/mock.ts new file mode 100644 index 0000000..d5fd2a9 --- /dev/null +++ b/app/src/data/mock.ts @@ -0,0 +1,113 @@ +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 new file mode 100644 index 0000000..e64a59c --- /dev/null +++ b/app/src/screens/AgentDetailScreen.tsx @@ -0,0 +1,160 @@ +import React from "react"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { fonts, radius, text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Button } from "../components/Button"; +import { PageHeader } from "../components/PageHeader"; +import { SegmentedControl } from "../components/SegmentedControl"; +import { Card, Divider, Mono, SectionLabel } from "../components/primitives"; +import { useAppState } from "../state/AppState"; +import { detailLog, detailMeta } from "../data/mock"; + +export function AgentDetailScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { + go, + detailTab, + setDetailTab, + notAnswered, + agentTone, + agentStatus, + agentStateText, + } = useAppState(); + + return ( + + + + go("fleet")} + agentId="agt-7a1d" + badge={{ tone: agentTone, label: agentStatus }} + /> + + + Migrate agent state to sqlite + + + handler · branch agt/7a1d + + + + + + + {detailTab === "state" ? ( + <> + + Current state + + {agentStateText} + + + updated 2m ago + + + + {detailMeta.map((m, i) => ( + + {i > 0 && } + + + {m.label} + + + {m.value} + + + + ))} + + + ) : ( + + {detailLog.map((row) => ( + + + {row.t} + + + {row.msg} + + + ))} + + )} + + + {notAnswered && ( + + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + page: { flex: 1 }, + content: { paddingTop: 8, paddingHorizontal: 20 }, + sunkenCard: { + borderWidth: 1, + borderRadius: radius.lg, + padding: 16, + }, + stateText: { + fontSize: 13, + lineHeight: 22, + }, + metaRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 12, + paddingHorizontal: 16, + }, + logRow: { flexDirection: "row", gap: 10 }, + logMono: { + fontSize: 12.5, + lineHeight: 26, + }, + actions: { + flexDirection: "row", + gap: 10, + marginTop: 20, + }, +}); diff --git a/app/src/screens/AnswerScreen.tsx b/app/src/screens/AnswerScreen.tsx new file mode 100644 index 0000000..981dd96 --- /dev/null +++ b/app/src/screens/AnswerScreen.tsx @@ -0,0 +1,106 @@ +import React, { useState } from "react"; +import { + KeyboardAvoidingView, + Platform, + StyleSheet, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { radius, text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Button } from "../components/Button"; +import { Chip } from "../components/Chip"; +import { PageHeader } from "../components/PageHeader"; +import { TextField } from "../components/TextField"; +import { Mono, SectionLabel } from "../components/primitives"; +import { useAppState } from "../state/AppState"; +import { quickReplyLabels } from "../data/mock"; + +const QUESTION = + "Migrations pass on the new sqlite store. Should I drop the legacy JSON store entirely, or keep it as a read-only fallback for one release?"; + +export function AnswerScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { go, quickPick, setQuickPick, sendResume } = useAppState(); + const [reply, setReply] = useState(""); + + return ( + + + + + go("detail")} + agentId="agt-7a1d" + badge={{ tone: "warning", label: "Waiting" }} + /> + + + Agent needs input + + + + Question · 2m ago + + {QUESTION} + + + + Quick replies + + {quickReplyLabels.map((label, i) => ( + setQuickPick(i)} + /> + ))} + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + page: { flex: 1 }, + flex: { flex: 1 }, + content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 }, + questionCard: { + borderWidth: 1, + borderRadius: radius.lg, + padding: 16, + marginBottom: 20, + }, + questionText: { fontSize: 13, lineHeight: 22 }, + chips: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + marginBottom: 20, + }, + footer: { marginTop: "auto", gap: 12 }, +}); diff --git a/app/src/screens/FleetScreen.tsx b/app/src/screens/FleetScreen.tsx new file mode 100644 index 0000000..eabb3a4 --- /dev/null +++ b/app/src/screens/FleetScreen.tsx @@ -0,0 +1,205 @@ +import React from "react"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { fonts, text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Button } from "../components/Button"; +import { Icon } from "../components/Icon"; +import { + Card, + Divider, + Mono, + SectionLabel, + StatusDot, +} from "../components/primitives"; +import { TabBar } from "../components/TabBar"; +import { useAppState } from "../state/AppState"; +import { + recentCheckmarks, + waitingAgents, + type Checkmark, + type WaitingAgent, +} from "../data/mock"; + +export function FleetScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { go, notAnswered } = useAppState(); + + const waiting = waitingAgents.filter((a) => !(a.clearsOnAnswer && !notAnswered)); + + const stats = [ + { label: "Running", value: "6", tint: colors.textHeading }, + { label: "Waiting", value: "3", tint: colors.warning }, + { label: "Done", value: "42", tint: colors.textHeading }, + ]; + + return ( + + + + + + Fleet + + 12 agents · 3 waiting on you + + + + + + + {stats.map((s) => ( + + + {s.label} + + + {s.value} + + + ))} + + + Waiting on you + + {waiting.map((a, i) => ( + + {i > 0 && } + go("answer")} /> + + ))} + + + Recent checkmarks + + {recentCheckmarks.map((c, i) => ( + + {i > 0 && } + go("detail")} /> + + ))} + + + + + ); +} + +function WaitingRow({ + agent, + onAnswer, +}: { + agent: WaitingAgent; + onAnswer: () => void; +}) { + const { colors } = useTheme(); + return ( + + + {agent.id} + + {agent.title} + + + + + {agent.question} + + + + + ); +} + +function CheckmarkRow({ + checkmark, + onPress, +}: { + checkmark: Checkmark; + onPress: () => void; +}) { + const { colors } = useTheme(); + const dot = checkmark.status === "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, + }, + 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..7f5e316 --- /dev/null +++ b/app/src/screens/LogScreen.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Chip } from "../components/Chip"; +import { Mono, SectionLabel } from "../components/primitives"; +import { TabBar } from "../components/TabBar"; +import { useAppState, type LogFilter } from "../state/AppState"; +import { allLog } from "../data/mock"; + +const FILTERS: { key: LogFilter; label: string }[] = [ + { key: "all", label: "All" }, + { key: "handler", label: "handler" }, + { key: "errors", label: "Errors" }, +]; + +export function LogScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { logFilter, setLogFilter } = useAppState(); + + const entries = allLog.filter((e) => + logFilter === "all" ? true : logFilter === "errors" ? e.err : e.p === "handler" + ); + + return ( + + + + + Log + + {FILTERS.map((f) => ( + setLogFilter(f.key)} + /> + ))} + + + + + Today + + + + {entries.map((e, i) => ( + + {e.t} + + {e.id} + + + {e.msg} + + + ))} + + + + + ); +} + +const styles = StyleSheet.create({ + page: { flex: 1 }, + header: { paddingHorizontal: 20, paddingTop: 20, paddingBottom: 16 }, + filters: { flexDirection: "row", gap: 8, marginTop: 14 }, + todayLabel: { paddingHorizontal: 20, paddingBottom: 8 }, + feed: { + borderTopWidth: 1, + paddingHorizontal: 20, + paddingVertical: 16, + flexGrow: 1, + }, + logRow: { flexDirection: "row", gap: 10 }, + mono: { fontSize: 12.5, lineHeight: 26 }, + idCol: { width: 66 }, +}); diff --git a/app/src/screens/SettingsScreen.tsx b/app/src/screens/SettingsScreen.tsx new file mode 100644 index 0000000..2dcf0e3 --- /dev/null +++ b/app/src/screens/SettingsScreen.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Button } from "../components/Button"; +import { ToggleRow } from "../components/ToggleRow"; +import { + Card, + Divider, + Mono, + SectionLabel, + StatusDot, +} from "../components/primitives"; +import { TabBar } from "../components/TabBar"; +import { useAppState } from "../state/AppState"; + +export function SettingsScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { swPushWait, setSwPushWait, swPushFail, setSwPushFail } = useAppState(); + + return ( + + + + + Settings + + + Server + + + + + + + Status + + + + connected · 38ms + + + + + + 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 }, +}); diff --git a/app/src/screens/SpawnScreen.tsx b/app/src/screens/SpawnScreen.tsx new file mode 100644 index 0000000..2721c45 --- /dev/null +++ b/app/src/screens/SpawnScreen.tsx @@ -0,0 +1,98 @@ +import React, { useState } from "react"; +import { + KeyboardAvoidingView, + Platform, + StyleSheet, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Button } from "../components/Button"; +import { PageHeader } from "../components/PageHeader"; +import { Select } from "../components/Select"; +import { TextField } from "../components/TextField"; +import { ToggleRow } from "../components/ToggleRow"; +import { Card, Divider } from "../components/primitives"; +import { useAppState } from "../state/AppState"; +import { projectOptions } from "../data/mock"; + +export function SpawnScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { go, spawnGo, swEdits, setSwEdits, swTests, setSwTests } = useAppState(); + const [project, setProject] = useState("handler"); + const [task, setTask] = useState(""); + + return ( + + + + + go("fleet")} + title="New agent" + /> + + + +
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": "