From daecaa7e6be900069014fa69f5a2fa2e0504f54a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:48:30 +0000 Subject: [PATCH] Implement Handler mobile app (React Native + Expo iOS) Build the committed 2a interactive prototype from Handler Mobile.dc.html as a real Expo app: six wired screens (Fleet, Agent detail, Answer, Spawn, Log, Settings) with the exact state logic ported from the design's renderVals(). - Port Leeworks tokens (colors light/dark, typography, spacing, radii, shadows) to typed RN values in src/theme. - Reimplement the design-system components used by the screens (Button, Badge, Icon, Switch, Select, TextField, segmented control, chip, tab bar). - Shared store mirrors the prototype's single-screen navigation and the Waiting -> Running flip when agt-7a1d is answered. - Real OS status bar / home indicator (safe-area insets) replace the mock phone chrome; dark mode follows system appearance. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PdF2hHVQ4UUBFPtpeMQ81o --- app/.gitignore | 6 + app/App.tsx | 102 + app/README.md | 63 + app/app.json | 18 + app/babel.config.js | 6 + app/index.ts | 4 + app/package-lock.json | 11068 ++++++++++++++++++++++ app/package.json | 29 + app/src/components/Badge.tsx | 36 + app/src/components/Button.tsx | 111 + app/src/components/Chip.tsx | 55 + app/src/components/Icon.tsx | 73 + app/src/components/PageHeader.tsx | 81 + app/src/components/SegmentedControl.tsx | 69 + app/src/components/Select.tsx | 128 + app/src/components/Switch.tsx | 66 + app/src/components/TabBar.tsx | 87 + app/src/components/TextField.tsx | 83 + app/src/components/ToggleRow.tsx | 45 + app/src/components/primitives.tsx | 89 + app/src/data/mock.ts | 113 + app/src/screens/AgentDetailScreen.tsx | 160 + app/src/screens/AnswerScreen.tsx | 106 + app/src/screens/FleetScreen.tsx | 205 + app/src/screens/LogScreen.tsx | 89 + app/src/screens/SettingsScreen.tsx | 101 + app/src/screens/SpawnScreen.tsx | 98 + app/src/state/AppState.tsx | 127 + app/src/theme/tokens.ts | 179 + app/src/theme/useTheme.ts | 17 + app/tsconfig.json | 9 + 31 files changed, 13423 insertions(+) create mode 100644 app/.gitignore create mode 100644 app/App.tsx create mode 100644 app/README.md create mode 100644 app/app.json create mode 100644 app/babel.config.js create mode 100644 app/index.ts create mode 100644 app/package-lock.json create mode 100644 app/package.json create mode 100644 app/src/components/Badge.tsx create mode 100644 app/src/components/Button.tsx create mode 100644 app/src/components/Chip.tsx create mode 100644 app/src/components/Icon.tsx create mode 100644 app/src/components/PageHeader.tsx create mode 100644 app/src/components/SegmentedControl.tsx create mode 100644 app/src/components/Select.tsx create mode 100644 app/src/components/Switch.tsx create mode 100644 app/src/components/TabBar.tsx create mode 100644 app/src/components/TextField.tsx create mode 100644 app/src/components/ToggleRow.tsx create mode 100644 app/src/components/primitives.tsx create mode 100644 app/src/data/mock.ts create mode 100644 app/src/screens/AgentDetailScreen.tsx create mode 100644 app/src/screens/AnswerScreen.tsx create mode 100644 app/src/screens/FleetScreen.tsx create mode 100644 app/src/screens/LogScreen.tsx create mode 100644 app/src/screens/SettingsScreen.tsx create mode 100644 app/src/screens/SpawnScreen.tsx create mode 100644 app/src/state/AppState.tsx create mode 100644 app/src/theme/tokens.ts create mode 100644 app/src/theme/useTheme.ts create mode 100644 app/tsconfig.json 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" + /> + + +