mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 09:46:24 +00:00
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdF2hHVQ4UUBFPtpeMQ81o
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
*.log
|
||||
.DS_Store
|
||||
+102
@@ -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 (
|
||||
<View style={{ flex: 1, backgroundColor: colors.surfacePage }}>
|
||||
<StatusBar style={scheme === "dark" ? "light" : "dark"} />
|
||||
<Screen />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SafeAreaProvider>
|
||||
{fontsLoaded ? (
|
||||
<AppStateProvider>
|
||||
<Router />
|
||||
</AppStateProvider>
|
||||
) : (
|
||||
<SplashPlaceholder />
|
||||
)}
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Blank page-colored screen while fonts load (avoids a font flash). */
|
||||
function SplashPlaceholder() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: colors.surfacePage,
|
||||
paddingTop: insets.top,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 `<select>` has no
|
||||
cross-platform styling in RN) — same field, real picking behavior.
|
||||
|
||||
All copy, spacing, colors, and interactions otherwise match the `2a` design.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Handler",
|
||||
"slug": "handler-mobile",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"scheme": "handler",
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "dev.wheaty.handler"
|
||||
},
|
||||
"android": {
|
||||
"package": "dev.wheaty.handler"
|
||||
},
|
||||
"newArchEnabled": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRootComponent } from "expo";
|
||||
import App from "./App";
|
||||
|
||||
registerRootComponent(App);
|
||||
Generated
+11068
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "handler-mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo start --android",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/figtree": "^0.2.3",
|
||||
"@expo-google-fonts/outfit": "^0.2.3",
|
||||
"@expo-google-fonts/spline-sans-mono": "^0.2.3",
|
||||
"expo": "~52.0.0",
|
||||
"expo-asset": "~11.0.1",
|
||||
"expo-font": "~13.0.0",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-safe-area-context": "^4.12.0",
|
||||
"react-native-svg": "^15.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~18.3.12",
|
||||
"typescript": "~5.3.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { fonts, radius } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import type { BadgeTone } from "../state/AppState";
|
||||
|
||||
/** Leeworks Badge, ported from components/display/Badge.jsx. */
|
||||
export function Badge({ tone, children }: { tone: BadgeTone; children: string }) {
|
||||
const { colors } = useTheme();
|
||||
const tones: Record<BadgeTone, { bg: string; fg: string }> = {
|
||||
neutral: { bg: colors.ink1, fg: colors.ink7 },
|
||||
positive: { bg: colors.positiveTint, fg: colors.positive },
|
||||
warning: { bg: colors.warningTint, fg: colors.warning },
|
||||
danger: { bg: colors.dangerTint, fg: colors.danger },
|
||||
};
|
||||
const t = tones[tone];
|
||||
return (
|
||||
<View style={[styles.badge, { backgroundColor: t.bg }]}>
|
||||
<Text style={[styles.text, { color: t.fg }]}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: 3,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: radius.pill,
|
||||
},
|
||||
text: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { fonts, radius } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
|
||||
/**
|
||||
* Leeworks Button, ported from components/forms/Button.jsx. Hover collapses
|
||||
* into press on touch; the primary/secondary/danger press tints are kept.
|
||||
*/
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
interface ButtonProps {
|
||||
children: string;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
onPress?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const HEIGHT: Record<ButtonSize, number> = { sm: 32, md: 40, lg: 48 };
|
||||
const PAD_X: Record<ButtonSize, number> = { sm: 12, md: 16, lg: 20 };
|
||||
const FONT: Record<ButtonSize, number> = { sm: 13, md: 14, lg: 15 };
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
onPress,
|
||||
style,
|
||||
}: ButtonProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => {
|
||||
const base: ViewStyle = {
|
||||
height: HEIGHT[size],
|
||||
paddingHorizontal: PAD_X[size],
|
||||
borderRadius: radius.md,
|
||||
};
|
||||
const byVariant: Record<ButtonVariant, ViewStyle> = {
|
||||
primary: {
|
||||
backgroundColor: pressed
|
||||
? colors.interactivePress
|
||||
: colors.interactive,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: pressed ? "rgba(20,20,19,0.05)" : colors.surfaceCard,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderDefault,
|
||||
},
|
||||
ghost: {
|
||||
backgroundColor: pressed ? "rgba(20,20,19,0.05)" : "transparent",
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: pressed ? "#8c2f25" : colors.danger,
|
||||
},
|
||||
};
|
||||
return [styles.base, base, byVariant[variant], style];
|
||||
}}
|
||||
>
|
||||
<View style={styles.inner}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
fontSize: FONT[size],
|
||||
color:
|
||||
variant === "primary"
|
||||
? colors.textInverse
|
||||
: variant === "danger"
|
||||
? "#ffffff"
|
||||
: variant === "secondary"
|
||||
? colors.textHeading
|
||||
: colors.ink9,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
inner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text } from "react-native";
|
||||
import { fonts, radius } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
|
||||
/**
|
||||
* Pill chip used for both the answer quick-replies and the log filters.
|
||||
* Ported from the `chip()` style helper in the DC script.
|
||||
*/
|
||||
export function Chip({
|
||||
label,
|
||||
selected,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
backgroundColor: selected ? colors.interactive : colors.surfacePage,
|
||||
borderColor: selected ? colors.borderStrong : colors.borderDefault,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{ color: selected ? "#ffffff" : colors.textBody },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
chip: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: radius.pill,
|
||||
borderWidth: 1,
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
lineHeight: 13,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import Svg, { Circle, Path } from "react-native-svg";
|
||||
|
||||
/**
|
||||
* Leeworks Icon — the Lucide subset used by the Handler screens, ported from
|
||||
* components/display/Icon.jsx. Monochrome, 1.75px stroke, `currentColor`
|
||||
* becomes the required `color` prop since RN SVG doesn't inherit it.
|
||||
*/
|
||||
|
||||
export type IconName =
|
||||
| "chevronRight"
|
||||
| "chevronDown"
|
||||
| "home"
|
||||
| "file"
|
||||
| "settings"
|
||||
| "x";
|
||||
|
||||
const paths: Record<IconName, React.ReactNode> = {
|
||||
chevronRight: <Path d="m9 18 6-6-6-6" />,
|
||||
chevronDown: <Path d="m6 9 6 6 6-6" />,
|
||||
home: (
|
||||
<>
|
||||
<Path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<Path d="M9 22V12h6v10" />
|
||||
</>
|
||||
),
|
||||
file: (
|
||||
<>
|
||||
<Path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<Path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<Path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<Circle cx="12" cy="12" r="3" />
|
||||
</>
|
||||
),
|
||||
x: <Path d="M18 6 6 18M6 6l12 12" />,
|
||||
};
|
||||
|
||||
interface IconProps {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
color: string;
|
||||
strokeWidth?: number;
|
||||
/** Degrees; the detail/answer back arrows reuse chevronRight rotated 180. */
|
||||
rotate?: number;
|
||||
}
|
||||
|
||||
export function Icon({
|
||||
name,
|
||||
size = 18,
|
||||
color,
|
||||
strokeWidth = 1.75,
|
||||
rotate,
|
||||
}: IconProps) {
|
||||
return (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={rotate ? { transform: [{ rotate: `${rotate}deg` }] } : undefined}
|
||||
>
|
||||
{paths[name]}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { fonts } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Badge } from "./Badge";
|
||||
import { Icon } from "./Icon";
|
||||
import { Mono } from "./primitives";
|
||||
import type { BadgeTone } from "../state/AppState";
|
||||
|
||||
/**
|
||||
* Header for pushed screens (detail / answer / spawn): a leading nav control
|
||||
* plus either an agent id + status badge, or a plain title.
|
||||
*/
|
||||
export function PageHeader({
|
||||
leading,
|
||||
onLeadingPress,
|
||||
agentId,
|
||||
badge,
|
||||
title,
|
||||
}: {
|
||||
leading: "back" | "close";
|
||||
onLeadingPress: () => void;
|
||||
agentId?: string;
|
||||
badge?: { tone: BadgeTone; label: string };
|
||||
title?: string;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
onPress={onLeadingPress}
|
||||
hitSlop={8}
|
||||
style={styles.control}
|
||||
>
|
||||
<Icon
|
||||
name={leading === "back" ? "chevronRight" : "x"}
|
||||
size={20}
|
||||
color={colors.textHeading}
|
||||
rotate={leading === "back" ? 180 : undefined}
|
||||
/>
|
||||
</Pressable>
|
||||
{agentId ? (
|
||||
<Mono style={[styles.agentId, { color: colors.textMuted }]}>
|
||||
{agentId}
|
||||
</Mono>
|
||||
) : (
|
||||
<Text style={[styles.title, { color: colors.textHeading }]}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{badge ? <Badge tone={badge.tone}>{badge.label}</Badge> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
control: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
marginLeft: -8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
agentId: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
lineHeight: 16,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { fonts, radius, shadows } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
|
||||
/**
|
||||
* Segmented control (the Checkmark / Log toggle on agent detail).
|
||||
* Ported from the `seg()` style helper in the DC script.
|
||||
*/
|
||||
export function SegmentedControl<T extends string>({
|
||||
segments,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
segments: { value: T; label: string }[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={[styles.track, { backgroundColor: colors.surfaceSunken }]}>
|
||||
{segments.map((s) => {
|
||||
const on = s.value === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={s.value}
|
||||
onPress={() => onChange(s.value)}
|
||||
style={[
|
||||
styles.segment,
|
||||
on && {
|
||||
backgroundColor: colors.surfacePage,
|
||||
...shadows.card,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{ color: on ? colors.textHeading : colors.textMuted },
|
||||
]}
|
||||
>
|
||||
{s.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
track: {
|
||||
flexDirection: "row",
|
||||
borderRadius: radius.md,
|
||||
padding: 3,
|
||||
},
|
||||
segment: {
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.sm,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
lineHeight: 13,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { fonts, radius, shadows, text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
/**
|
||||
* Leeworks Select, ported from components/forms/Select.jsx. Native <select>
|
||||
* 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 (
|
||||
<View style={{ gap: 6 }}>
|
||||
<Text style={[styles.label, { color: colors.textHeading }]}>{label}</Text>
|
||||
<Pressable
|
||||
onPress={() => setOpen(true)}
|
||||
style={[
|
||||
styles.field,
|
||||
{ backgroundColor: colors.surfaceCard, borderColor: colors.borderDefault },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.body, { color: colors.textHeading, flex: 1 }]}>
|
||||
{value}
|
||||
</Text>
|
||||
<Icon name="chevronDown" size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
|
||||
<Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
|
||||
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ backgroundColor: colors.surfaceCard, borderColor: colors.borderSubtle },
|
||||
shadows.raised,
|
||||
]}
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const on = opt === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={opt}
|
||||
onPress={() => {
|
||||
onChange(opt);
|
||||
setOpen(false);
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.option,
|
||||
pressed && { backgroundColor: colors.surfaceSunken },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
text.body,
|
||||
{
|
||||
color: colors.textHeading,
|
||||
fontFamily: on ? fonts.bodySemiBold : fonts.bodyRegular,
|
||||
flex: 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{opt}
|
||||
</Text>
|
||||
{on && <Icon name="chevronRight" size={16} color={colors.textMuted} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<Pressable onPress={() => onValueChange(!value)} hitSlop={8}>
|
||||
<Animated.View style={[styles.track, { backgroundColor: trackColor }]}>
|
||||
<Animated.View
|
||||
style={[styles.thumb, { transform: [{ translateX }] }]}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<View
|
||||
style={[
|
||||
styles.bar,
|
||||
{
|
||||
backgroundColor: colors.surfacePage,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
paddingBottom: Math.max(insets.bottom, 12),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{TABS.map((t) => {
|
||||
const on = t.key === active;
|
||||
const tint = on ? colors.textHeading : colors.ink4;
|
||||
return (
|
||||
<Pressable
|
||||
key={t.key}
|
||||
style={styles.tab}
|
||||
onPress={() => go(t.key)}
|
||||
>
|
||||
<Icon
|
||||
name={t.icon}
|
||||
size={22}
|
||||
color={tint}
|
||||
strokeWidth={on ? 2 : 1.75}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color: tint,
|
||||
fontFamily: on ? fonts.bodySemiBold : fonts.bodyMedium,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{t.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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<ViewStyle>;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
const [focus, setFocus] = useState(false);
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.wrap,
|
||||
{
|
||||
height,
|
||||
backgroundColor: colors.surfaceCard,
|
||||
borderColor: focus ? colors.borderStrong : colors.borderDefault,
|
||||
alignItems: multiline ? "stretch" : "center",
|
||||
paddingVertical: multiline ? 12 : 0,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
multiline={multiline}
|
||||
onFocus={() => setFocus(true)}
|
||||
onBlur={() => setFocus(false)}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: colors.textHeading,
|
||||
textAlignVertical: multiline ? "top" : "center",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, paddingRight: 12 }}>
|
||||
<Text style={[text.label, { color: colors.textHeading }]}>{title}</Text>
|
||||
<Text style={[text.caption, { color: colors.textMuted, marginTop: 2 }]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch value={value} onValueChange={onValueChange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
minHeight: 44,
|
||||
},
|
||||
});
|
||||
@@ -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<ViewStyle>;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: colors.surfaceCard,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radius.lg,
|
||||
},
|
||||
shadows.card,
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Uppercase, letter-spaced overline label (Outfit 600, 11px). */
|
||||
export function SectionLabel({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: string;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<Text style={[text.overline, { color: colors.textMuted }, style]}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hairline divider used between card rows. */
|
||||
export function Divider() {
|
||||
const { colors } = useTheme();
|
||||
return <View style={{ height: 1, backgroundColor: colors.borderSubtle }} />;
|
||||
}
|
||||
|
||||
/** 8px status dot. */
|
||||
export function StatusDot({ color, size = 8 }: { color: string; size?: number }) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Monospace text helper (Spline Sans Mono). */
|
||||
export function Mono({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}) {
|
||||
return <Text style={[monoStyles.mono, style]}>{children}</Text>;
|
||||
}
|
||||
|
||||
const monoStyles = StyleSheet.create({
|
||||
mono: { fontFamily: fonts.monoRegular },
|
||||
});
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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 (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{ paddingBottom: insets.bottom + 20 },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<PageHeader
|
||||
leading="back"
|
||||
onLeadingPress={() => go("fleet")}
|
||||
agentId="agt-7a1d"
|
||||
badge={{ tone: agentTone, label: agentStatus }}
|
||||
/>
|
||||
|
||||
<Text style={[text.h3, { color: colors.textHeading }]}>
|
||||
Migrate agent state to sqlite
|
||||
</Text>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 4, marginBottom: 16 }]}>
|
||||
handler · branch <Mono>agt/7a1d</Mono>
|
||||
</Text>
|
||||
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<SegmentedControl
|
||||
segments={[
|
||||
{ value: "state", label: "Checkmark" },
|
||||
{ value: "log", label: "Log" },
|
||||
]}
|
||||
value={detailTab}
|
||||
onChange={setDetailTab}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{detailTab === "state" ? (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
styles.sunkenCard,
|
||||
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
|
||||
]}
|
||||
>
|
||||
<SectionLabel style={{ marginBottom: 8 }}>Current state</SectionLabel>
|
||||
<Mono style={[styles.stateText, { color: colors.textBody }]}>
|
||||
{agentStateText}
|
||||
</Mono>
|
||||
<Text style={[text.caption, { color: colors.textMuted, marginTop: 10 }]}>
|
||||
updated 2m ago
|
||||
</Text>
|
||||
</View>
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
{detailMeta.map((m, i) => (
|
||||
<View key={m.label}>
|
||||
{i > 0 && <Divider />}
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
{m.label}
|
||||
</Text>
|
||||
<Mono style={{ fontSize: 13, color: colors.textHeading }}>
|
||||
{m.value}
|
||||
</Mono>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.sunkenCard,
|
||||
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
|
||||
]}
|
||||
>
|
||||
{detailLog.map((row) => (
|
||||
<View key={row.t} style={styles.logRow}>
|
||||
<Mono style={[styles.logMono, { color: colors.ink4 }]}>
|
||||
{row.t}
|
||||
</Mono>
|
||||
<Mono style={[styles.logMono, { color: colors[row.color] }]}>
|
||||
{row.msg}
|
||||
</Mono>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
{notAnswered && (
|
||||
<Button size="lg" style={{ flex: 1 }} onPress={() => go("answer")}>
|
||||
Answer
|
||||
</Button>
|
||||
)}
|
||||
<Button size="lg" variant="secondary" style={{ flex: 1 }}>
|
||||
Pause
|
||||
</Button>
|
||||
<Button size="lg" variant="danger">
|
||||
Kill
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<KeyboardAvoidingView
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<View style={[styles.content, { paddingBottom: insets.bottom + 20 }]}>
|
||||
<PageHeader
|
||||
leading="back"
|
||||
onLeadingPress={() => go("detail")}
|
||||
agentId="agt-7a1d"
|
||||
badge={{ tone: "warning", label: "Waiting" }}
|
||||
/>
|
||||
|
||||
<Text style={[text.h3, { color: colors.textHeading, marginBottom: 16 }]}>
|
||||
Agent needs input
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.questionCard,
|
||||
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
|
||||
]}
|
||||
>
|
||||
<SectionLabel style={{ marginBottom: 8 }}>Question · 2m ago</SectionLabel>
|
||||
<Mono style={[styles.questionText, { color: colors.textBody }]}>
|
||||
{QUESTION}
|
||||
</Mono>
|
||||
</View>
|
||||
|
||||
<SectionLabel style={{ marginBottom: 10 }}>Quick replies</SectionLabel>
|
||||
<View style={styles.chips}>
|
||||
{quickReplyLabels.map((label, i) => (
|
||||
<Chip
|
||||
key={label}
|
||||
label={label}
|
||||
selected={quickPick === i}
|
||||
onPress={() => setQuickPick(i)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TextField
|
||||
value={reply}
|
||||
onChangeText={setReply}
|
||||
placeholder="Or type a reply…"
|
||||
/>
|
||||
<Button size="lg" style={{ width: "100%" }} onPress={sendResume}>
|
||||
Send & resume
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
@@ -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 (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[text.h3, { color: colors.textHeading }]}>Fleet</Text>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 2 }]}>
|
||||
12 agents · 3 waiting on you
|
||||
</Text>
|
||||
</View>
|
||||
<Button size="sm" onPress={() => go("spawn")}>
|
||||
New
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View style={styles.statsRow}>
|
||||
{stats.map((s) => (
|
||||
<Card key={s.label} style={styles.statCard}>
|
||||
<Text style={[text.caption, { color: colors.textMuted }]}>
|
||||
{s.label}
|
||||
</Text>
|
||||
<Text style={[styles.statValue, { color: s.tint }]}>
|
||||
{s.value}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<SectionLabel style={styles.overline}>Waiting on you</SectionLabel>
|
||||
<Card style={styles.section}>
|
||||
{waiting.map((a, i) => (
|
||||
<View key={a.id}>
|
||||
{i > 0 && <Divider />}
|
||||
<WaitingRow agent={a} onAnswer={() => go("answer")} />
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<SectionLabel style={styles.overline}>Recent checkmarks</SectionLabel>
|
||||
<Card>
|
||||
{recentCheckmarks.map((c, i) => (
|
||||
<View key={c.title}>
|
||||
{i > 0 && <Divider />}
|
||||
<CheckmarkRow checkmark={c} onPress={() => go("detail")} />
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
</ScrollView>
|
||||
<TabBar active="fleet" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WaitingRow({
|
||||
agent,
|
||||
onAnswer,
|
||||
}: {
|
||||
agent: WaitingAgent;
|
||||
onAnswer: () => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={styles.rowPad}>
|
||||
<View style={styles.waitingTop}>
|
||||
<Mono style={{ fontSize: 12, color: colors.textMuted }}>{agent.id}</Mono>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[styles.rowTitle, { color: colors.textHeading, flex: 1 }]}
|
||||
>
|
||||
{agent.title}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.waitingBottom}>
|
||||
<Text
|
||||
style={[
|
||||
text.bodySm,
|
||||
{ color: colors.textBody, flex: 1, fontFamily: fonts.bodyItalic },
|
||||
]}
|
||||
>
|
||||
{agent.question}
|
||||
</Text>
|
||||
<Button size="sm" variant="secondary" onPress={onAnswer}>
|
||||
Answer
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckmarkRow({
|
||||
checkmark,
|
||||
onPress,
|
||||
}: {
|
||||
checkmark: Checkmark;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
const dot = checkmark.status === "positive" ? colors.positive : colors.danger;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.checkmarkRow,
|
||||
pressed && { backgroundColor: colors.surfaceSunken },
|
||||
]}
|
||||
>
|
||||
<StatusDot color={dot} />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[styles.rowTitle, { color: colors.textHeading }]}
|
||||
>
|
||||
{checkmark.title}
|
||||
</Text>
|
||||
<Text style={[text.caption, { color: colors.textMuted, marginTop: 2 }]}>
|
||||
{checkmark.meta}
|
||||
</Text>
|
||||
</View>
|
||||
<Icon name="chevronRight" size={16} color={colors.ink4} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
<View style={{ height: insets.top }} />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text style={[text.h3, { color: colors.textHeading }]}>Log</Text>
|
||||
<View style={styles.filters}>
|
||||
{FILTERS.map((f) => (
|
||||
<Chip
|
||||
key={f.key}
|
||||
label={f.label}
|
||||
selected={logFilter === f.key}
|
||||
onPress={() => setLogFilter(f.key)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.todayLabel}>
|
||||
<SectionLabel>Today</SectionLabel>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={[
|
||||
styles.feed,
|
||||
{ backgroundColor: colors.surfaceSunken, borderTopColor: colors.borderSubtle },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{entries.map((e, i) => (
|
||||
<View key={`${e.t}-${i}`} style={styles.logRow}>
|
||||
<Mono style={[styles.mono, { color: colors.ink4 }]}>{e.t}</Mono>
|
||||
<Mono style={[styles.mono, styles.idCol, { color: colors.ink6 }]}>
|
||||
{e.id}
|
||||
</Mono>
|
||||
<Mono style={[styles.mono, { color: colors[e.color], flex: 1 }]}>
|
||||
{e.msg}
|
||||
</Mono>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<TabBar active="log" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
@@ -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 (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text style={[text.h3, { color: colors.textHeading, marginVertical: 12, marginBottom: 20 }]}>
|
||||
Settings
|
||||
</Text>
|
||||
|
||||
<SectionLabel style={{ marginBottom: 10 }}>Server</SectionLabel>
|
||||
<Card style={{ marginBottom: 20 }}>
|
||||
<InfoRow label="Endpoint" value="https://handler.wheaty.dev" />
|
||||
<Divider />
|
||||
<InfoRow label="API key" value="hnd_••••••••4f2a" />
|
||||
<Divider />
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={[text.label, { color: colors.textHeading }]}>Status</Text>
|
||||
<View style={styles.statusValue}>
|
||||
<StatusDot color={colors.positive} size={7} />
|
||||
<Mono style={[styles.valueMono, { color: colors.positive }]}>
|
||||
connected · 38ms
|
||||
</Mono>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<SectionLabel style={{ marginBottom: 10 }}>Notifications</SectionLabel>
|
||||
<Card style={{ marginBottom: 20 }}>
|
||||
<ToggleRow
|
||||
title="Waiting on input"
|
||||
subtitle="Push when an agent pauses"
|
||||
value={swPushWait}
|
||||
onValueChange={setSwPushWait}
|
||||
/>
|
||||
<Divider />
|
||||
<ToggleRow
|
||||
title="Failures"
|
||||
subtitle="Push when a checkmark fails"
|
||||
value={swPushFail}
|
||||
onValueChange={setSwPushFail}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Button variant="secondary" size="lg" style={{ width: "100%" }}>
|
||||
Sign out
|
||||
</Button>
|
||||
</ScrollView>
|
||||
<TabBar active="settings" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={[text.label, { color: colors.textHeading }]}>{label}</Text>
|
||||
<Mono style={[styles.valueMono, { color: colors.textMuted }]}>{value}</Mono>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
@@ -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 (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<KeyboardAvoidingView
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<View style={[styles.content, { paddingBottom: insets.bottom + 20 }]}>
|
||||
<PageHeader
|
||||
leading="close"
|
||||
onLeadingPress={() => go("fleet")}
|
||||
title="New agent"
|
||||
/>
|
||||
|
||||
<View style={{ gap: 16 }}>
|
||||
<Select
|
||||
label="Project"
|
||||
options={projectOptions}
|
||||
value={project}
|
||||
onChange={setProject}
|
||||
/>
|
||||
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
Task
|
||||
</Text>
|
||||
<TextField
|
||||
value={task}
|
||||
onChangeText={setTask}
|
||||
placeholder="What should this agent do?"
|
||||
multiline
|
||||
height={120}
|
||||
/>
|
||||
<Text style={[text.caption, { color: colors.textMuted, marginTop: 6 }]}>
|
||||
Runs isolated on a fresh branch.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Card>
|
||||
<ToggleRow
|
||||
title="Auto-approve edits"
|
||||
subtitle="Skip file-edit confirmations"
|
||||
value={swEdits}
|
||||
onValueChange={setSwEdits}
|
||||
/>
|
||||
<Divider />
|
||||
<ToggleRow
|
||||
title="Run tests on done"
|
||||
subtitle="Checkmark fails if tests fail"
|
||||
value={swTests}
|
||||
onValueChange={setSwTests}
|
||||
/>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: "auto" }}>
|
||||
<Button size="lg" style={{ width: "100%" }} onPress={spawnGo}>
|
||||
Spawn agent
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
flex: { flex: 1 },
|
||||
content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 },
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { createContext, useContext, useMemo, useState } from "react";
|
||||
|
||||
/**
|
||||
* Central prototype state, ported 1:1 from the DC script's
|
||||
* `state` + `renderVals()` in project/Handler Mobile.dc.html (turn 2a).
|
||||
*
|
||||
* The prototype navigates by swapping a single `screen` value rather than a
|
||||
* stack, and answering agt-7a1d flips it Waiting -> Running everywhere. That
|
||||
* cross-screen behavior is exactly why this lives in one shared store.
|
||||
*/
|
||||
|
||||
export type Screen =
|
||||
| "fleet"
|
||||
| "detail"
|
||||
| "answer"
|
||||
| "spawn"
|
||||
| "log"
|
||||
| "settings";
|
||||
|
||||
export type DetailTab = "state" | "log";
|
||||
export type LogFilter = "all" | "handler" | "errors";
|
||||
export type BadgeTone = "neutral" | "positive" | "warning" | "danger";
|
||||
|
||||
const ANSWERED_STATE =
|
||||
"Answer received: keep the JSON store as a read-only fallback for one release. Deprecating writes now; removal ticket filed for next cycle.";
|
||||
const WAITING_STATE =
|
||||
"Schema written, migrations pass. Blocked on the legacy JSON store — drop it or keep as read fallback? Holding before deleting store/json.rs.";
|
||||
|
||||
interface AppStateValue {
|
||||
screen: Screen;
|
||||
detailTab: DetailTab;
|
||||
quickPick: number | null;
|
||||
logFilter: LogFilter;
|
||||
answered: boolean;
|
||||
swEdits: boolean;
|
||||
swTests: boolean;
|
||||
swPushWait: boolean;
|
||||
swPushFail: boolean;
|
||||
|
||||
// Derived (mirrors renderVals()).
|
||||
notAnswered: boolean;
|
||||
agentTone: BadgeTone;
|
||||
agentStatus: string;
|
||||
agentStateText: string;
|
||||
|
||||
go: (screen: Screen) => void;
|
||||
setDetailTab: (tab: DetailTab) => void;
|
||||
setQuickPick: (i: number) => void;
|
||||
setLogFilter: (f: LogFilter) => void;
|
||||
sendResume: () => void;
|
||||
spawnGo: () => void;
|
||||
setSwEdits: (v: boolean) => void;
|
||||
setSwTests: (v: boolean) => void;
|
||||
setSwPushWait: (v: boolean) => void;
|
||||
setSwPushFail: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const AppStateContext = createContext<AppStateValue | null>(null);
|
||||
|
||||
export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
||||
const [screen, setScreen] = useState<Screen>("fleet");
|
||||
const [detailTab, setDetailTab] = useState<DetailTab>("state");
|
||||
const [quickPick, setQuickPickState] = useState<number | null>(null);
|
||||
const [logFilter, setLogFilter] = useState<LogFilter>("all");
|
||||
const [answered, setAnswered] = useState(false);
|
||||
const [swEdits, setSwEdits] = useState(false);
|
||||
const [swTests, setSwTests] = useState(true);
|
||||
const [swPushWait, setSwPushWait] = useState(true);
|
||||
const [swPushFail, setSwPushFail] = useState(true);
|
||||
|
||||
const value = useMemo<AppStateValue>(
|
||||
() => ({
|
||||
screen,
|
||||
detailTab,
|
||||
quickPick,
|
||||
logFilter,
|
||||
answered,
|
||||
swEdits,
|
||||
swTests,
|
||||
swPushWait,
|
||||
swPushFail,
|
||||
|
||||
notAnswered: !answered,
|
||||
agentTone: answered ? "positive" : "warning",
|
||||
agentStatus: answered ? "Running" : "Waiting",
|
||||
agentStateText: answered ? ANSWERED_STATE : WAITING_STATE,
|
||||
|
||||
go: setScreen,
|
||||
setDetailTab,
|
||||
setQuickPick: setQuickPickState,
|
||||
setLogFilter,
|
||||
sendResume: () => {
|
||||
setAnswered(true);
|
||||
setDetailTab("state");
|
||||
setScreen("detail");
|
||||
},
|
||||
spawnGo: () => setScreen("fleet"),
|
||||
setSwEdits,
|
||||
setSwTests,
|
||||
setSwPushWait,
|
||||
setSwPushFail,
|
||||
}),
|
||||
[
|
||||
screen,
|
||||
detailTab,
|
||||
quickPick,
|
||||
logFilter,
|
||||
answered,
|
||||
swEdits,
|
||||
swTests,
|
||||
swPushWait,
|
||||
swPushFail,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<AppStateContext.Provider value={value}>
|
||||
{children}
|
||||
</AppStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAppState(): AppStateValue {
|
||||
const ctx = useContext(AppStateContext);
|
||||
if (!ctx) throw new Error("useAppState must be used within AppStateProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { TextStyle, ViewStyle } from "react-native";
|
||||
|
||||
/**
|
||||
* Leeworks design tokens, ported from the CSS token files in
|
||||
* project/_ds/.../tokens/*.css to typed React Native values.
|
||||
*
|
||||
* CSS `font:` shorthand and weight-by-number don't exist in RN, so each
|
||||
* weight maps to a concretely-loaded font family (see `fonts`), and the
|
||||
* composite `--type-*` styles become the `text` style objects below.
|
||||
*/
|
||||
|
||||
// ---- Fonts (families loaded via @expo-google-fonts) --------------------
|
||||
export const fonts = {
|
||||
displayRegular: "Outfit_400Regular",
|
||||
displayMedium: "Outfit_500Medium",
|
||||
displaySemiBold: "Outfit_600SemiBold",
|
||||
displayBold: "Outfit_700Bold",
|
||||
displayExtraBold: "Outfit_800ExtraBold",
|
||||
|
||||
bodyRegular: "Figtree_400Regular",
|
||||
bodyMedium: "Figtree_500Medium",
|
||||
bodySemiBold: "Figtree_600SemiBold",
|
||||
bodyBold: "Figtree_700Bold",
|
||||
bodyItalic: "Figtree_400Regular_Italic",
|
||||
|
||||
monoRegular: "SplineSansMono_400Regular",
|
||||
monoMedium: "SplineSansMono_500Medium",
|
||||
monoSemiBold: "SplineSansMono_600SemiBold",
|
||||
} as const;
|
||||
|
||||
// ---- Radii (effects.css) -----------------------------------------------
|
||||
export const radius = {
|
||||
sm: 6,
|
||||
md: 8,
|
||||
lg: 10,
|
||||
xl: 16,
|
||||
pill: 999,
|
||||
} as const;
|
||||
|
||||
// ---- Color palettes (colors.css) ---------------------------------------
|
||||
// The warm-neutral ink ramp is shared across themes; only the semantic
|
||||
// aliases and status colors flip between light and dark.
|
||||
const ink = {
|
||||
white: "#ffffff",
|
||||
ink0: "#fcfcfb",
|
||||
ink1: "#f6f6f4",
|
||||
ink2: "#ececea",
|
||||
ink3: "#dcdcd8",
|
||||
ink4: "#b8b8b3",
|
||||
ink5: "#8b8b86",
|
||||
ink6: "#62625e",
|
||||
ink7: "#3d3d3a",
|
||||
ink8: "#232321",
|
||||
ink9: "#141413",
|
||||
} as const;
|
||||
|
||||
export const lightColors = {
|
||||
...ink,
|
||||
|
||||
signal: "#1d6b45",
|
||||
signalTint: "#e8f2ec",
|
||||
positive: "#1d6b45",
|
||||
positiveTint: "#e8f2ec",
|
||||
warning: "#8a6116",
|
||||
warningTint: "#f7efdd",
|
||||
danger: "#a33a2f",
|
||||
dangerTint: "#f9e9e6",
|
||||
|
||||
surfacePage: ink.white,
|
||||
surfaceRaised: ink.white,
|
||||
surfaceSunken: ink.ink1,
|
||||
surfaceCard: ink.white,
|
||||
surfaceInverse: ink.ink9,
|
||||
|
||||
textHeading: ink.ink9,
|
||||
textBody: ink.ink7,
|
||||
textMuted: ink.ink5,
|
||||
textInverse: ink.ink0,
|
||||
|
||||
borderSubtle: ink.ink2,
|
||||
borderDefault: ink.ink3,
|
||||
borderStrong: ink.ink9,
|
||||
|
||||
interactive: ink.ink9,
|
||||
interactiveHover: ink.ink7,
|
||||
interactivePress: "#000000",
|
||||
} as const;
|
||||
|
||||
export type ThemeColors = Record<keyof typeof lightColors, string>;
|
||||
|
||||
export const darkColors: ThemeColors = {
|
||||
...ink,
|
||||
|
||||
signal: "#4fa377",
|
||||
signalTint: "#1d2f26",
|
||||
positive: "#4fa377",
|
||||
positiveTint: "#1d2f26",
|
||||
warning: "#c99a3f",
|
||||
warningTint: "#2f2818",
|
||||
danger: "#d0685c",
|
||||
dangerTint: "#331e1b",
|
||||
|
||||
surfacePage: "#161615",
|
||||
surfaceRaised: "#1e1e1d",
|
||||
surfaceSunken: "#111110",
|
||||
surfaceCard: "#1e1e1d",
|
||||
surfaceInverse: ink.ink0,
|
||||
|
||||
textHeading: "#f4f4f2",
|
||||
textBody: "#c9c9c4",
|
||||
textMuted: "#8b8b86",
|
||||
textInverse: ink.ink9,
|
||||
|
||||
borderSubtle: "#2b2b29",
|
||||
borderDefault: "#3a3a37",
|
||||
borderStrong: "#f4f4f2",
|
||||
|
||||
interactive: "#f4f4f2",
|
||||
interactiveHover: "#d8d8d4",
|
||||
interactivePress: "#ffffff",
|
||||
};
|
||||
|
||||
// ---- Composite text styles (typography.css `--type-*`) -----------------
|
||||
// Color is intentionally omitted — callers set it from the active palette.
|
||||
export const text = {
|
||||
// `--type-h3` + font-display + tracking-tight, as the screens use it.
|
||||
h3: {
|
||||
fontFamily: fonts.displaySemiBold,
|
||||
fontSize: 24,
|
||||
lineHeight: 31,
|
||||
letterSpacing: -0.48,
|
||||
} as TextStyle,
|
||||
body: {
|
||||
fontFamily: fonts.bodyRegular,
|
||||
fontSize: 15,
|
||||
lineHeight: 23,
|
||||
} as TextStyle,
|
||||
bodySm: {
|
||||
fontFamily: fonts.bodyRegular,
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
} as TextStyle,
|
||||
label: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
lineHeight: 16,
|
||||
} as TextStyle,
|
||||
caption: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
} as TextStyle,
|
||||
// Overlines are rendered at 11px with wide tracking + uppercase.
|
||||
overline: {
|
||||
fontFamily: fonts.displaySemiBold,
|
||||
fontSize: 11,
|
||||
lineHeight: 13,
|
||||
letterSpacing: 0.88,
|
||||
textTransform: "uppercase",
|
||||
} as TextStyle,
|
||||
} as const;
|
||||
|
||||
// ---- Shadows (effects.css) ---------------------------------------------
|
||||
export const shadows = {
|
||||
card: {
|
||||
shadowColor: "#141413",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
} as ViewStyle,
|
||||
raised: {
|
||||
shadowColor: "#141413",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.07,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
} as ViewStyle,
|
||||
} as const;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useColorScheme } from "react-native";
|
||||
import { darkColors, lightColors, type ThemeColors } from "./tokens";
|
||||
|
||||
export type Scheme = "light" | "dark";
|
||||
|
||||
/**
|
||||
* Resolves the active Leeworks palette from the OS appearance.
|
||||
*
|
||||
* The design exposed dark mode as a design-time prop (`data-theme`); on a
|
||||
* real iOS app the faithful equivalent is the system appearance, so the
|
||||
* theme follows `useColorScheme()` (app.json sets userInterfaceStyle
|
||||
* "automatic"). All six screens read colors through this hook.
|
||||
*/
|
||||
export function useTheme(): { colors: ThemeColors; scheme: Scheme } {
|
||||
const scheme: Scheme = useColorScheme() === "dark" ? "dark" : "light";
|
||||
return { colors: scheme === "dark" ? darkColors : lightColors, scheme };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user