mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-31 12:56:24 +00:00
Merge pull request #18 from 0xWheatyz/claude/handler-mobile-app-design-w1dqny
Add Handler mobile app (React Native/Expo) + Leeworks design system
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
name: Mobile Release
|
||||
|
||||
# Builds the Handler mobile app (app/) with EAS Build.
|
||||
#
|
||||
# Secrets required for the `build` job:
|
||||
# EXPO_TOKEN - an Expo access token (https://expo.dev/settings/access-tokens)
|
||||
#
|
||||
# Apple credentials:
|
||||
# - The `preview` profile builds an iOS *simulator* app and needs NO Apple account.
|
||||
# - The `production` profile builds a signed .ipa and requires a paid Apple
|
||||
# Developer Program membership. Those certificates are managed and stored by
|
||||
# EAS (authorize once with `eas credentials`) — they are NOT uploaded here.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
description: Platform to build
|
||||
type: choice
|
||||
options: [ios, android, all]
|
||||
default: ios
|
||||
profile:
|
||||
description: EAS build profile
|
||||
type: choice
|
||||
options: [preview, production]
|
||||
default: preview
|
||||
push:
|
||||
# Cut a release by pushing a tag like `mobile-v1.0.0`.
|
||||
tags:
|
||||
- 'mobile-v*'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: app
|
||||
|
||||
jobs:
|
||||
# Always-on validation. Needs no secrets and no Apple account.
|
||||
validate:
|
||||
name: Validate (install + typecheck)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: app/package-lock.json
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Cloud build via EAS. Requires the EXPO_TOKEN secret.
|
||||
build:
|
||||
name: EAS Build
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure EXPO_TOKEN is configured
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$EXPO_TOKEN" ]; then
|
||||
echo "::error::EXPO_TOKEN secret is not set. Add it under" \
|
||||
"Settings → Secrets and variables → Actions before running a build." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: app/package-lock.json
|
||||
|
||||
- uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Resolve build parameters
|
||||
id: params
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
echo "platform=all" >> "$GITHUB_OUTPUT"
|
||||
echo "profile=production" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "platform=${{ github.event.inputs.platform }}" >> "$GITHUB_OUTPUT"
|
||||
echo "profile=${{ github.event.inputs.profile }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: EAS build
|
||||
run: |
|
||||
eas build \
|
||||
--platform "${{ steps.params.outputs.platform }}" \
|
||||
--profile "${{ steps.params.outputs.profile }}" \
|
||||
--non-interactive \
|
||||
--no-wait
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
*.log
|
||||
.DS_Store
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import {
|
||||
SafeAreaProvider,
|
||||
useSafeAreaInsets,
|
||||
} from "react-native-safe-area-context";
|
||||
import {
|
||||
useFonts,
|
||||
Outfit_400Regular,
|
||||
Outfit_500Medium,
|
||||
Outfit_600SemiBold,
|
||||
Outfit_700Bold,
|
||||
Outfit_800ExtraBold,
|
||||
} from "@expo-google-fonts/outfit";
|
||||
import {
|
||||
Figtree_400Regular,
|
||||
Figtree_500Medium,
|
||||
Figtree_600SemiBold,
|
||||
Figtree_700Bold,
|
||||
Figtree_400Regular_Italic,
|
||||
} from "@expo-google-fonts/figtree";
|
||||
import {
|
||||
SplineSansMono_400Regular,
|
||||
SplineSansMono_500Medium,
|
||||
SplineSansMono_600SemiBold,
|
||||
} from "@expo-google-fonts/spline-sans-mono";
|
||||
|
||||
import { useTheme } from "./src/theme/useTheme";
|
||||
import { AppStateProvider, useAppState } from "./src/state/AppState";
|
||||
import { ServerConfigProvider, useServerConfig } from "./src/state/ServerConfig";
|
||||
import { ConnectScreen } from "./src/screens/ConnectScreen";
|
||||
import { FleetScreen } from "./src/screens/FleetScreen";
|
||||
import { AgentDetailScreen } from "./src/screens/AgentDetailScreen";
|
||||
import { AnswerScreen } from "./src/screens/AnswerScreen";
|
||||
import { SpawnScreen } from "./src/screens/SpawnScreen";
|
||||
import { LogScreen } from "./src/screens/LogScreen";
|
||||
import { SettingsScreen } from "./src/screens/SettingsScreen";
|
||||
|
||||
function Router() {
|
||||
const { screen } = useAppState();
|
||||
|
||||
const Screen = {
|
||||
connect: ConnectScreen,
|
||||
fleet: FleetScreen,
|
||||
detail: AgentDetailScreen,
|
||||
answer: AnswerScreen,
|
||||
spawn: SpawnScreen,
|
||||
log: LogScreen,
|
||||
settings: SettingsScreen,
|
||||
}[screen];
|
||||
|
||||
return <Screen />;
|
||||
}
|
||||
|
||||
/** Gate: splash while config loads, ConnectScreen when unconfigured, else the fleet app. */
|
||||
function Gate() {
|
||||
const { config, loading } = useServerConfig();
|
||||
const { scheme, colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.surfacePage }}>
|
||||
<StatusBar style={scheme === "dark" ? "light" : "dark"} />
|
||||
{loading ? (
|
||||
<SplashPlaceholder />
|
||||
) : config ? (
|
||||
<AppStateProvider>
|
||||
<Router />
|
||||
</AppStateProvider>
|
||||
) : (
|
||||
<ConnectScreen />
|
||||
)}
|
||||
</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 ? (
|
||||
<ServerConfigProvider>
|
||||
<Gate />
|
||||
</ServerConfigProvider>
|
||||
) : (
|
||||
<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,24 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 12.0.0",
|
||||
"appVersionSource": "local"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRootComponent } from "expo";
|
||||
import App from "./App";
|
||||
|
||||
registerRootComponent(App);
|
||||
Generated
+8501
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"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",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"babel-preset-expo": "~54.0.10",
|
||||
"expo": "^54.0.36",
|
||||
"expo-asset": "~12.0.13",
|
||||
"expo-font": "~14.0.12",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-svg": "15.12.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/* Typed client for the Handler API + the row shapes it returns (mirrors the FastAPI
|
||||
* pydantic schemas in src/handler/api/schemas.py). Ported from frontend/lib/api.ts; the
|
||||
* one adaptation for mobile is that the base URL is passed in (the phone talks to a
|
||||
* user-configured endpoint rather than same-origin), and `api()` takes an `allow401`
|
||||
* escape hatch so the admin-only /resume call can handle its own 401/403 without
|
||||
* tripping the global sign-out. */
|
||||
|
||||
export type CommandStatus = "queued" | "running" | "done" | "failed";
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
root_dir: string;
|
||||
git_remote?: string | null;
|
||||
credential_ref?: string | null;
|
||||
created_at: string;
|
||||
/* Present on the registration response in git-server mode: the enqueued clone. */
|
||||
sync_command_id?: number | null;
|
||||
/* Present on the registration response when "Initialize mise" was ticked: the
|
||||
* enqueued bootstrap agent that writes + commits + pushes a .mise.toml. */
|
||||
mise_init_command_id?: number | null;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: number;
|
||||
project_id: string;
|
||||
name: string;
|
||||
working_dir: string;
|
||||
status: string;
|
||||
role?: string | null;
|
||||
/* Latest tmux pane-tail snapshot from the worker, so the UI can show what a running
|
||||
* agent is actually doing (and expose one wedged on an interactive prompt). */
|
||||
last_output?: string | null;
|
||||
output_at?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Checkmark {
|
||||
agent_id: number;
|
||||
checkpoint_at: string;
|
||||
status: string;
|
||||
where_it_stopped?: string | null;
|
||||
next_steps?: string[] | null;
|
||||
open_question?: string | null;
|
||||
log_entry_id?: number | null;
|
||||
tests_status: string;
|
||||
tested_at?: string | null;
|
||||
build_status: string;
|
||||
built_at?: string | null;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
id: number;
|
||||
agent_id: number;
|
||||
created_at: string;
|
||||
session_id?: string | null;
|
||||
status: string;
|
||||
summary?: string | null;
|
||||
decisions?: string | null;
|
||||
question?: string | null;
|
||||
answer?: string | null;
|
||||
visibility: string;
|
||||
push_sha?: string | null;
|
||||
ci_status: string;
|
||||
ci_checked_at?: string | null;
|
||||
}
|
||||
|
||||
export interface Approval {
|
||||
id: number;
|
||||
project_id: string;
|
||||
branch: string;
|
||||
approved_sha?: string | null;
|
||||
pr_ref?: string | null;
|
||||
status: string;
|
||||
approved_by_agent_id?: number | null;
|
||||
actor?: string | null;
|
||||
note?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Host {
|
||||
hostname: string;
|
||||
forge_type: string;
|
||||
token_env_var?: string | null;
|
||||
base_url?: string | null;
|
||||
ssh_public_key?: string | null;
|
||||
has_token: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
id: number;
|
||||
project_id?: string | null;
|
||||
agent_name?: string | null;
|
||||
type: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
status: CommandStatus;
|
||||
result?: Record<string, unknown> | null;
|
||||
error?: string | null;
|
||||
requested_by?: string | null;
|
||||
claimed_by?: string | null;
|
||||
created_at: string;
|
||||
claimed_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
id: number;
|
||||
project_id: string;
|
||||
name_prefix: string;
|
||||
task: string;
|
||||
role?: string | null;
|
||||
worktree?: string | null;
|
||||
subdir?: string | null;
|
||||
interval_seconds: number;
|
||||
enabled: boolean;
|
||||
next_run_at: string;
|
||||
last_run_at?: string | null;
|
||||
last_command_id?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SharedContext {
|
||||
key: string;
|
||||
value: string;
|
||||
set_by_agent_id?: number | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/* Thrown on a 401 so callers can distinguish "token rejected" from real errors and stay
|
||||
* quiet while the app re-prompts for a token. */
|
||||
export class AuthError extends Error {
|
||||
constructor(message = "unauthorized") {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
}
|
||||
}
|
||||
|
||||
/* Any non-2xx (other than 401); carries the HTTP status so callers can branch on 404 etc. */
|
||||
export interface ApiError extends Error {
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface ApiOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
/* When set, a 401 throws an ApiError(status 401) like any other error instead of firing
|
||||
* onUnauthorized — used by the admin-only /resume so a missing admin grant surfaces
|
||||
* inline rather than signing the whole session out. */
|
||||
allow401?: boolean;
|
||||
}
|
||||
|
||||
interface TrackOptions {
|
||||
attempts?: number;
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
export interface ApiClient {
|
||||
baseUrl: string;
|
||||
api: <T>(path: string, opts?: ApiOptions) => Promise<T>;
|
||||
/* Poll GET /commands/{id} until it reaches done/failed; null if still running after the
|
||||
* budget (worker down or a very slow command). */
|
||||
trackCommand: (id: number, opts?: TrackOptions) => Promise<Command | null>;
|
||||
}
|
||||
|
||||
/* Strip a trailing slash so `baseUrl + "/projects"` never double-slashes. */
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function createClient(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
onUnauthorized: () => void,
|
||||
): ApiClient {
|
||||
const base = normalizeBaseUrl(baseUrl);
|
||||
|
||||
async function api<T>(path: string, opts?: ApiOptions): Promise<T> {
|
||||
const hasBody = opts?.body !== undefined && opts?.body !== null;
|
||||
const res = await fetch(base + path, {
|
||||
method: opts?.method ?? (hasBody ? "POST" : "GET"),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: hasBody ? JSON.stringify(opts!.body) : undefined,
|
||||
});
|
||||
|
||||
if (res.status === 401 && !opts?.allow401) {
|
||||
onUnauthorized();
|
||||
throw new AuthError();
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail: string = res.statusText;
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j && typeof j.detail !== "undefined") {
|
||||
detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON error body; keep statusText */
|
||||
}
|
||||
const err = new Error(detail) as ApiError;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
|
||||
async function trackCommand(id: number, opts?: TrackOptions): Promise<Command | null> {
|
||||
const attempts = opts?.attempts ?? 60;
|
||||
const intervalMs = opts?.intervalMs ?? 500;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const cmd = await api<Command>(`/commands/${id}`);
|
||||
if (cmd.status === "done" || cmd.status === "failed") return cmd;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return { baseUrl: base, api, trackCommand };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* Formatting + status helpers shared by the screens. Pure functions, no API access.
|
||||
* timeAgo is ported from frontend/lib/format.ts; the tone/colour mappers translate a raw
|
||||
* handler status string into the app's design-system vocabulary (BadgeTone for pills,
|
||||
* a ThemeColors key for log lines). */
|
||||
|
||||
import type { ThemeColors } from "../theme/tokens";
|
||||
import type { BadgeTone } from "../state/AppState";
|
||||
|
||||
/* Compact relative time, e.g. "3m", "2h", "5d". "—" for empty. Timestamps from the API are
|
||||
* ISO UTC strings; new Date() parses them. */
|
||||
export function timeAgo(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return "—";
|
||||
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months}mo`;
|
||||
return `${Math.floor(months / 12)}y`;
|
||||
}
|
||||
|
||||
/* Local clock time (HH:MM:SS) for a log line. "—" for empty. */
|
||||
export function clockTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
paused_for_input: "Waiting",
|
||||
not_applicable: "N/A",
|
||||
};
|
||||
|
||||
/* A tidy, human-readable label for a status string. */
|
||||
export function statusLabel(status: string | null | undefined): string {
|
||||
const raw = (status ?? "").trim();
|
||||
if (!raw) return "—";
|
||||
const key = raw.toLowerCase();
|
||||
if (LABELS[key]) return LABELS[key];
|
||||
return key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/* Map a raw handler status (agent status, checkmark status, CI status) to a badge tone in
|
||||
* the app's four-tone vocabulary. */
|
||||
export function statusTone(status: string | null | undefined): BadgeTone {
|
||||
switch ((status ?? "").toLowerCase()) {
|
||||
case "pass":
|
||||
case "done":
|
||||
case "completed":
|
||||
case "approved":
|
||||
case "success":
|
||||
return "positive";
|
||||
case "fail":
|
||||
case "failed":
|
||||
case "blocked":
|
||||
case "rejected":
|
||||
case "error":
|
||||
return "danger";
|
||||
case "pending":
|
||||
case "queued":
|
||||
case "running":
|
||||
case "working":
|
||||
case "paused_for_input":
|
||||
return "warning";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
/* Pick a ThemeColors key for a log line, given its status. */
|
||||
export function statusColor(status: string | null | undefined): keyof ThemeColors {
|
||||
switch (statusTone(status)) {
|
||||
case "positive":
|
||||
return "positive";
|
||||
case "danger":
|
||||
return "danger";
|
||||
case "warning":
|
||||
return "warning";
|
||||
default:
|
||||
return "textBody";
|
||||
}
|
||||
}
|
||||
@@ -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,97 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
View,
|
||||
type KeyboardTypeOptions,
|
||||
type StyleProp,
|
||||
type TextInputProps,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { fonts, radius } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
|
||||
/**
|
||||
* Text field covering both the Leeworks single-line Input (answer reply) and
|
||||
* the multiline task textarea on the spawn screen. Focus swaps the border to
|
||||
* `--border-strong` per the Input component.
|
||||
*/
|
||||
export function TextField({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
multiline = false,
|
||||
height = 48,
|
||||
style,
|
||||
secureTextEntry = false,
|
||||
autoCapitalize,
|
||||
autoCorrect,
|
||||
keyboardType,
|
||||
}: {
|
||||
value: string;
|
||||
onChangeText: (t: string) => void;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
/** For single-line this is the control height; for multiline, the box height. */
|
||||
height?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
secureTextEntry?: boolean;
|
||||
autoCapitalize?: TextInputProps["autoCapitalize"];
|
||||
autoCorrect?: boolean;
|
||||
keyboardType?: KeyboardTypeOptions;
|
||||
}) {
|
||||
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}
|
||||
secureTextEntry={secureTextEntry}
|
||||
autoCapitalize={autoCapitalize}
|
||||
autoCorrect={autoCorrect}
|
||||
keyboardType={keyboardType}
|
||||
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,95 @@
|
||||
import React from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { fonts, radius, shadows, text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
|
||||
/** White card: 1px subtle border, 10px radius, whisper-quiet shadow. */
|
||||
export function Card({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
style?: StyleProp<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,
|
||||
numberOfLines,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
style?: StyleProp<TextStyle>;
|
||||
numberOfLines?: number;
|
||||
}) {
|
||||
return (
|
||||
<Text numberOfLines={numberOfLines} style={[monoStyles.mono, style]}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const monoStyles = StyleSheet.create({
|
||||
mono: { fontFamily: fonts.monoRegular },
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import React from "react";
|
||||
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { fonts, radius, text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Button } from "../components/Button";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { SegmentedControl } from "../components/SegmentedControl";
|
||||
import { Card, Divider, Mono, SectionLabel } from "../components/primitives";
|
||||
import { useAppState } from "../state/AppState";
|
||||
import {
|
||||
clockTime,
|
||||
statusColor,
|
||||
statusLabel,
|
||||
statusTone,
|
||||
timeAgo,
|
||||
} from "../api/format";
|
||||
|
||||
export function AgentDetailScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const {
|
||||
go,
|
||||
openAnswer,
|
||||
detailTab,
|
||||
setDetailTab,
|
||||
selectedAgent,
|
||||
selectedCheckmark,
|
||||
selectedLog,
|
||||
kill,
|
||||
} = useAppState();
|
||||
|
||||
if (!selectedAgent) {
|
||||
return (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<View style={styles.content}>
|
||||
<PageHeader leading="back" onLeadingPress={() => go("fleet")} title="Agent" />
|
||||
<Text style={[text.body, { color: colors.textMuted }]}>
|
||||
This agent is no longer in the fleet.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const agent = selectedAgent;
|
||||
const cm = selectedCheckmark;
|
||||
const openQuestion = cm?.open_question?.trim();
|
||||
|
||||
const meta = [
|
||||
{ label: "Started", value: timeAgo(agent.created_at) },
|
||||
{ label: "Status", value: statusLabel(agent.status) },
|
||||
{ label: "Tests", value: cm ? statusLabel(cm.tests_status) : "—" },
|
||||
{ label: "Build", value: cm ? statusLabel(cm.build_status) : "—" },
|
||||
];
|
||||
|
||||
const logRows = [...selectedLog].sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
|
||||
function confirmKill() {
|
||||
Alert.alert(
|
||||
"Kill agent?",
|
||||
`Stop ${agent.name} and end its session. This can’t be undone.`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Kill",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
void kill(agent.project_id, agent.name).finally(() => go("fleet"));
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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={agent.name}
|
||||
badge={{ tone: statusTone(agent.status), label: statusLabel(agent.status) }}
|
||||
/>
|
||||
|
||||
<Text style={[text.h3, { color: colors.textHeading }]}>{agent.name}</Text>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 4, marginBottom: 16 }]}>
|
||||
{agent.project_id}
|
||||
{agent.role ? " · " : ""}
|
||||
{agent.role ? <Mono>{agent.role}</Mono> : null}
|
||||
</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 },
|
||||
]}
|
||||
>
|
||||
{cm ? (
|
||||
<>
|
||||
<SectionLabel style={{ marginBottom: 8 }}>Where it stopped</SectionLabel>
|
||||
<Mono style={[styles.stateText, { color: colors.textBody }]}>
|
||||
{cm.where_it_stopped?.trim() || "—"}
|
||||
</Mono>
|
||||
|
||||
{cm.next_steps && cm.next_steps.length > 0 ? (
|
||||
<>
|
||||
<SectionLabel style={{ marginTop: 14, marginBottom: 8 }}>
|
||||
Next steps
|
||||
</SectionLabel>
|
||||
{cm.next_steps.map((step, i) => (
|
||||
<View key={i} style={styles.stepRow}>
|
||||
<Text style={[styles.stateText, { color: colors.textMuted }]}>• </Text>
|
||||
<Text style={[styles.stateText, { color: colors.textBody, flex: 1 }]}>
|
||||
{step}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{openQuestion ? (
|
||||
<>
|
||||
<SectionLabel style={{ marginTop: 14, marginBottom: 8, color: colors.warning }}>
|
||||
Open question
|
||||
</SectionLabel>
|
||||
<Text
|
||||
style={[
|
||||
text.bodySm,
|
||||
{ color: colors.textBody, fontFamily: fonts.bodyItalic },
|
||||
]}
|
||||
>
|
||||
{openQuestion}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Text style={[text.caption, { color: colors.textMuted, marginTop: 12 }]}>
|
||||
updated {timeAgo(cm.checkpoint_at)}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
No checkmark yet — this agent hasn’t reported a checkpoint.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
{meta.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 },
|
||||
]}
|
||||
>
|
||||
{logRows.length === 0 ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>No log entries yet.</Text>
|
||||
) : (
|
||||
logRows.map((row) => (
|
||||
<View key={row.id} style={styles.logRow}>
|
||||
<Mono style={[styles.logMono, { color: colors.ink4 }]}>
|
||||
{clockTime(row.created_at)}
|
||||
</Mono>
|
||||
<Mono style={[styles.logMono, { color: colors[statusColor(row.status)], flex: 1 }]}>
|
||||
{row.summary?.trim() || statusLabel(row.status)}
|
||||
</Mono>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
{openQuestion ? (
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ flex: 1 }}
|
||||
onPress={() => openAnswer(agent.project_id, agent.name)}
|
||||
>
|
||||
Answer
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="lg" variant="danger" style={{ flex: 1 }} onPress={confirmKill}>
|
||||
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,
|
||||
},
|
||||
stepRow: { flexDirection: "row" },
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
logRow: { flexDirection: "row", gap: 10 },
|
||||
logMono: {
|
||||
fontSize: 12.5,
|
||||
lineHeight: 26,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
marginTop: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { radius, text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Button } from "../components/Button";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { TextField } from "../components/TextField";
|
||||
import { Mono, SectionLabel } from "../components/primitives";
|
||||
import { useAppState } from "../state/AppState";
|
||||
import { timeAgo } from "../api/format";
|
||||
|
||||
export function AnswerScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { go, selectedAgent, selectedCheckmark, sendAnswer } = useAppState();
|
||||
const [reply, setReply] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
|
||||
if (!selectedAgent) {
|
||||
return (
|
||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<View style={styles.content}>
|
||||
<PageHeader leading="back" onLeadingPress={() => go("fleet")} title="Answer" />
|
||||
<Text style={[text.body, { color: colors.textMuted }]}>
|
||||
This agent is no longer in the fleet.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const agent = selectedAgent;
|
||||
const question =
|
||||
selectedCheckmark?.open_question?.trim() ||
|
||||
"This agent is paused and waiting for input.";
|
||||
const askedAt = selectedCheckmark?.checkpoint_at;
|
||||
|
||||
async function send() {
|
||||
if (!reply.trim()) {
|
||||
setError("Enter a reply.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setNote(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await sendAnswer(reply.trim());
|
||||
if (res.resumed) {
|
||||
go("detail");
|
||||
} else {
|
||||
setNote(res.note ?? "Answer saved.");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn’t send answer.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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={agent.name}
|
||||
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${askedAt ? ` · ${timeAgo(askedAt)} ago` : ""}`}
|
||||
</SectionLabel>
|
||||
<Mono style={[styles.questionText, { color: colors.textBody }]}>
|
||||
{question}
|
||||
</Mono>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<View
|
||||
style={[
|
||||
styles.notice,
|
||||
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.bodySm, { color: colors.danger }]}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{note ? (
|
||||
<View
|
||||
style={[
|
||||
styles.notice,
|
||||
{ backgroundColor: colors.warningTint, borderColor: colors.warning },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.bodySm, { color: colors.warning }]}>{note}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TextField
|
||||
value={reply}
|
||||
onChangeText={setReply}
|
||||
placeholder="Type a reply…"
|
||||
multiline
|
||||
height={100}
|
||||
/>
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ width: "100%" }}
|
||||
onPress={busy ? undefined : send}
|
||||
>
|
||||
{busy ? "Sending…" : "Send & resume"}
|
||||
</Button>
|
||||
{busy ? (
|
||||
<ActivityIndicator color={colors.textMuted} style={{ marginTop: 4 }} />
|
||||
) : null}
|
||||
</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: 16,
|
||||
},
|
||||
questionText: { fontSize: 13, lineHeight: 22 },
|
||||
notice: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.md,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
footer: { marginTop: "auto", gap: 12 },
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Button } from "../components/Button";
|
||||
import { TextField } from "../components/TextField";
|
||||
import { SectionLabel } from "../components/primitives";
|
||||
import {
|
||||
DEFAULT_ENDPOINT,
|
||||
useServerConfig,
|
||||
type ServerConfig,
|
||||
} from "../state/ServerConfig";
|
||||
import { AuthError, createClient, type Project } from "../api/client";
|
||||
|
||||
/**
|
||||
* First-open configuration screen, shown whenever no server config is stored (and after a
|
||||
* Sign out or a persistent 401). Verifies connectivity (GET /health) and the token
|
||||
* (GET /projects) before persisting, distinguishing an unreachable endpoint from a bad
|
||||
* token in the inline error. Matches the SpawnScreen layout.
|
||||
*/
|
||||
export function ConnectScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { lastEndpoint, save } = useServerConfig();
|
||||
|
||||
const [endpoint, setEndpoint] = useState(lastEndpoint || DEFAULT_ENDPOINT);
|
||||
const [token, setToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function connect() {
|
||||
const ep = endpoint.trim();
|
||||
const tok = token.trim();
|
||||
if (!ep) {
|
||||
setError("Enter an endpoint.");
|
||||
return;
|
||||
}
|
||||
if (!tok) {
|
||||
setError("Enter an API token.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const client = createClient(ep, tok, () => {});
|
||||
|
||||
// 1. Connectivity — /health needs no auth, so a failure here is the endpoint.
|
||||
try {
|
||||
await client.api<{ status: string }>("/health");
|
||||
} catch {
|
||||
setError("Couldn't reach that endpoint. Check the URL and your connection.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Auth — a 401 on /projects is the token, not the endpoint.
|
||||
try {
|
||||
await client.api<Project[]>("/projects");
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) {
|
||||
setError("Token rejected. Check your API token.");
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : "Couldn't load projects.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg: ServerConfig = { endpoint: ep, token: tok };
|
||||
await save(cfg);
|
||||
// The gate in App.tsx swaps this screen for the fleet once config is set.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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 }]}>
|
||||
<View style={styles.heading}>
|
||||
<Text style={[text.h3, { color: colors.textHeading }]}>Connect</Text>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 2 }]}>
|
||||
Point Handler at your control server.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: 16 }}>
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
Endpoint
|
||||
</Text>
|
||||
<TextField
|
||||
value={endpoint}
|
||||
onChangeText={setEndpoint}
|
||||
placeholder="https://handler.example.dev"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
API token
|
||||
</Text>
|
||||
<TextField
|
||||
value={token}
|
||||
onChangeText={setToken}
|
||||
placeholder="Bearer token"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<View
|
||||
style={[
|
||||
styles.errorBox,
|
||||
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
|
||||
]}
|
||||
>
|
||||
<SectionLabel style={{ color: colors.danger, marginBottom: 4 }}>
|
||||
Couldn’t connect
|
||||
</SectionLabel>
|
||||
<Text style={[text.bodySm, { color: colors.danger }]}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: "auto" }}>
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ width: "100%" }}
|
||||
onPress={busy ? undefined : connect}
|
||||
>
|
||||
{busy ? "Connecting…" : "Connect"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
flex: { flex: 1 },
|
||||
content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 },
|
||||
heading: { marginTop: 12, marginBottom: 24 },
|
||||
errorBox: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import React from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { fonts, text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Button } from "../components/Button";
|
||||
import { Icon } from "../components/Icon";
|
||||
import {
|
||||
Card,
|
||||
Divider,
|
||||
Mono,
|
||||
SectionLabel,
|
||||
StatusDot,
|
||||
} from "../components/primitives";
|
||||
import { TabBar } from "../components/TabBar";
|
||||
import {
|
||||
useAppState,
|
||||
type RecentItem,
|
||||
type WaitingItem,
|
||||
} from "../state/AppState";
|
||||
|
||||
export function FleetScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { go, openAnswer, openDetail, waiting, recent, counts, loading, error, refresh } =
|
||||
useAppState();
|
||||
|
||||
const empty = waiting.length === 0 && recent.length === 0;
|
||||
|
||||
const stats = [
|
||||
{ label: "Running", value: counts.running, tint: colors.textHeading },
|
||||
{ label: "Waiting", value: counts.waiting, tint: colors.warning },
|
||||
{ label: "Done", value: counts.done, tint: colors.textHeading },
|
||||
];
|
||||
|
||||
return (
|
||||
<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 }]}>
|
||||
{counts.running} running · {counts.waiting} 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>
|
||||
|
||||
{loading && empty && !error ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator color={colors.textMuted} />
|
||||
</View>
|
||||
) : error && empty ? (
|
||||
<Card style={styles.errorCard}>
|
||||
<SectionLabel style={{ marginBottom: 6 }}>Couldn’t load fleet</SectionLabel>
|
||||
<Text style={[text.bodySm, { color: colors.textBody, marginBottom: 14 }]}>
|
||||
{error}
|
||||
</Text>
|
||||
<Button size="md" variant="secondary" onPress={() => refresh()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<SectionLabel style={styles.overline}>Waiting on you</SectionLabel>
|
||||
<Card style={styles.section}>
|
||||
{waiting.length === 0 ? (
|
||||
<View style={styles.rowPad}>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
Nothing waiting on you.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
waiting.map((a, i) => (
|
||||
<View key={`${a.project}/${a.name}`}>
|
||||
{i > 0 && <Divider />}
|
||||
<WaitingRow
|
||||
agent={a}
|
||||
onAnswer={() => openAnswer(a.project, a.name)}
|
||||
/>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<SectionLabel style={styles.overline}>Recent checkmarks</SectionLabel>
|
||||
<Card>
|
||||
{recent.length === 0 ? (
|
||||
<View style={styles.rowPad}>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
No checkmarks yet.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
recent.map((c, i) => (
|
||||
<View key={c.key}>
|
||||
{i > 0 && <Divider />}
|
||||
<CheckmarkRow
|
||||
checkmark={c}
|
||||
onPress={() => openDetail(c.project, c.name)}
|
||||
/>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
<TabBar active="fleet" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WaitingRow({
|
||||
agent,
|
||||
onAnswer,
|
||||
}: {
|
||||
agent: WaitingItem;
|
||||
onAnswer: () => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={styles.rowPad}>
|
||||
<View style={styles.waitingTop}>
|
||||
<Mono style={{ fontSize: 12, color: colors.textMuted }}>{agent.name}</Mono>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[styles.rowTitle, { color: colors.textHeading, flex: 1 }]}
|
||||
>
|
||||
{agent.project}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.waitingBottom}>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
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: RecentItem;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
const dot = checkmark.tone === "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,
|
||||
},
|
||||
centered: { paddingVertical: 48, alignItems: "center", justifyContent: "center" },
|
||||
errorCard: { padding: 16, alignItems: "flex-start" },
|
||||
overline: { marginBottom: 10 },
|
||||
section: { marginBottom: 20 },
|
||||
rowPad: { paddingVertical: 14, paddingHorizontal: 16 },
|
||||
waitingTop: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
waitingBottom: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
marginTop: 8,
|
||||
},
|
||||
rowTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
lineHeight: 16,
|
||||
},
|
||||
checkmarkRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
minHeight: 44,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Chip } from "../components/Chip";
|
||||
import { Mono, SectionLabel } from "../components/primitives";
|
||||
import { TabBar } from "../components/TabBar";
|
||||
import { useAppState } from "../state/AppState";
|
||||
import { clockTime, statusColor } from "../api/format";
|
||||
|
||||
export function LogScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { logFilter, setLogFilter, globalLog, projects } = useAppState();
|
||||
|
||||
const filters: { key: string; label: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
...projects.map((p) => ({ key: p.id, label: p.id })),
|
||||
{ key: "errors", label: "Errors" },
|
||||
];
|
||||
|
||||
const entries = globalLog.filter((e) =>
|
||||
logFilter === "all" ? true : logFilter === "errors" ? e.err : e.project === logFilter,
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filters}
|
||||
>
|
||||
{filters.map((f) => (
|
||||
<Chip
|
||||
key={f.key}
|
||||
label={f.label}
|
||||
selected={logFilter === f.key}
|
||||
onPress={() => setLogFilter(f.key)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<View style={styles.todayLabel}>
|
||||
<SectionLabel>Activity</SectionLabel>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={[
|
||||
styles.feed,
|
||||
{ backgroundColor: colors.surfaceSunken, borderTopColor: colors.borderSubtle },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>No activity yet.</Text>
|
||||
) : (
|
||||
entries.map((e) => (
|
||||
<View key={e.key} style={styles.logRow}>
|
||||
<Mono style={[styles.mono, { color: colors.ink4 }]}>
|
||||
{clockTime(e.createdAt)}
|
||||
</Mono>
|
||||
<Mono
|
||||
numberOfLines={1}
|
||||
style={[styles.mono, styles.idCol, { color: colors.ink6 }]}
|
||||
>
|
||||
{e.name}
|
||||
</Mono>
|
||||
<Mono style={[styles.mono, { color: colors[statusColor(e.status)], 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, paddingRight: 20 },
|
||||
todayLabel: { paddingHorizontal: 20, paddingBottom: 8 },
|
||||
feed: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
logRow: { flexDirection: "row", gap: 10 },
|
||||
mono: { fontSize: 12.5, lineHeight: 26 },
|
||||
idCol: { width: 86 },
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Button } from "../components/Button";
|
||||
import { ToggleRow } from "../components/ToggleRow";
|
||||
import {
|
||||
Card,
|
||||
Divider,
|
||||
Mono,
|
||||
SectionLabel,
|
||||
StatusDot,
|
||||
} from "../components/primitives";
|
||||
import { TabBar } from "../components/TabBar";
|
||||
import { useServerConfig } from "../state/ServerConfig";
|
||||
import { createClient } from "../api/client";
|
||||
|
||||
type Ping =
|
||||
| { state: "checking" }
|
||||
| { state: "ok"; latencyMs: number }
|
||||
| { state: "error" };
|
||||
|
||||
export function SettingsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { config, clear } = useServerConfig();
|
||||
|
||||
// Notification toggles stay local (no server-side counterpart yet).
|
||||
const [pushWait, setPushWait] = useState(true);
|
||||
const [pushFail, setPushFail] = useState(true);
|
||||
|
||||
const [ping, setPing] = useState<Ping>({ state: "checking" });
|
||||
|
||||
const client = useMemo(
|
||||
() => (config ? createClient(config.endpoint, config.token, () => {}) : null),
|
||||
[config],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client) return;
|
||||
let active = true;
|
||||
setPing({ state: "checking" });
|
||||
const started = Date.now();
|
||||
client
|
||||
.api<{ status: string }>("/health")
|
||||
.then(() => {
|
||||
if (active) setPing({ state: "ok", latencyMs: Date.now() - started });
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setPing({ state: "error" });
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
const maskedToken = config
|
||||
? `••••••••${config.token.slice(-4)}`
|
||||
: "—";
|
||||
|
||||
const status =
|
||||
ping.state === "ok"
|
||||
? { color: colors.positive, label: `connected · ${ping.latencyMs}ms` }
|
||||
: ping.state === "error"
|
||||
? { color: colors.danger, label: "unreachable" }
|
||||
: { color: colors.textMuted, label: "checking…" };
|
||||
|
||||
return (
|
||||
<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={config?.endpoint ?? "—"} />
|
||||
<Divider />
|
||||
<InfoRow label="API token" value={maskedToken} />
|
||||
<Divider />
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={[text.label, { color: colors.textHeading }]}>Status</Text>
|
||||
<View style={styles.statusValue}>
|
||||
<StatusDot color={status.color} size={7} />
|
||||
<Mono style={[styles.valueMono, { color: status.color }]}>
|
||||
{status.label}
|
||||
</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={pushWait}
|
||||
onValueChange={setPushWait}
|
||||
/>
|
||||
<Divider />
|
||||
<ToggleRow
|
||||
title="Failures"
|
||||
subtitle="Push when a checkmark fails"
|
||||
value={pushFail}
|
||||
onValueChange={setPushFail}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
style={{ width: "100%" }}
|
||||
onPress={() => void clear()}
|
||||
>
|
||||
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
|
||||
numberOfLines={1}
|
||||
style={[styles.valueMono, styles.valueFlex, { 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 },
|
||||
valueFlex: { flex: 1, textAlign: "right" },
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { radius, text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Button } from "../components/Button";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { Select } from "../components/Select";
|
||||
import { TextField } from "../components/TextField";
|
||||
import { useAppState } from "../state/AppState";
|
||||
|
||||
export function SpawnScreen() {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { go, projects, spawn } = useAppState();
|
||||
|
||||
const projectIds = projects.map((p) => p.id);
|
||||
const [project, setProject] = useState("");
|
||||
const [task, setTask] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Default to the first project once they load (or if the current pick vanished).
|
||||
useEffect(() => {
|
||||
if (projectIds.length > 0 && !projectIds.includes(project)) {
|
||||
setProject(projectIds[0]);
|
||||
}
|
||||
}, [projectIds, project]);
|
||||
|
||||
async function submit() {
|
||||
if (!project) {
|
||||
setError("Pick a project first.");
|
||||
return;
|
||||
}
|
||||
if (!task.trim()) {
|
||||
setError("Describe the task.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await spawn(project, task.trim());
|
||||
go("fleet");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn’t spawn the agent.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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 }}>
|
||||
{projectIds.length > 0 ? (
|
||||
<Select
|
||||
label="Project"
|
||||
options={projectIds}
|
||||
value={project || projectIds[0]}
|
||||
onChange={setProject}
|
||||
/>
|
||||
) : (
|
||||
<View>
|
||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||
Project
|
||||
</Text>
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
No projects registered yet.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
{error ? (
|
||||
<View
|
||||
style={[
|
||||
styles.notice,
|
||||
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.bodySm, { color: colors.danger }]}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: "auto" }}>
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ width: "100%" }}
|
||||
onPress={busy ? undefined : submit}
|
||||
>
|
||||
{busy ? "Spawning…" : "Spawn agent"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
flex: { flex: 1 },
|
||||
content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 },
|
||||
notice: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.md,
|
||||
padding: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
AuthError,
|
||||
createClient,
|
||||
type Agent,
|
||||
type ApiClient,
|
||||
type ApiError,
|
||||
type Checkmark,
|
||||
type LogEntry,
|
||||
type Project,
|
||||
} from "../api/client";
|
||||
import { statusLabel, statusTone, timeAgo } from "../api/format";
|
||||
import { useServerConfig } from "./ServerConfig";
|
||||
|
||||
/**
|
||||
* Data-driven fleet store. Keeps the prototype's screen-swap navigation (a single
|
||||
* `screen` value rather than a nav stack) so the screens change minimally, but every
|
||||
* value now comes from the live Handler API via the client built from ServerConfig.
|
||||
*
|
||||
* The store polls /projects → agents → checkmarks → logs on a 10s cadence, derives the
|
||||
* fleet view-models (waiting list, recent checkmarks, counts, merged log), and exposes the
|
||||
* three mutations the UI needs (answer+resume, spawn, kill). A 401 anywhere clears the
|
||||
* stored config (routing back to ConnectScreen) via the client's onUnauthorized hook.
|
||||
*/
|
||||
|
||||
export type Screen =
|
||||
| "connect"
|
||||
| "fleet"
|
||||
| "detail"
|
||||
| "answer"
|
||||
| "spawn"
|
||||
| "log"
|
||||
| "settings";
|
||||
|
||||
export type DetailTab = "state" | "log";
|
||||
export type BadgeTone = "neutral" | "positive" | "warning" | "danger";
|
||||
export type RecentTone = "positive" | "danger";
|
||||
|
||||
/** An agent waiting on the operator — either an open checkmark question or a paused status. */
|
||||
export interface WaitingItem {
|
||||
project: string;
|
||||
name: string;
|
||||
question: string;
|
||||
logEntryId: number | null;
|
||||
}
|
||||
|
||||
/** A recent checkmark row on the fleet screen. */
|
||||
export interface RecentItem {
|
||||
key: string;
|
||||
project: string;
|
||||
name: string;
|
||||
title: string;
|
||||
meta: string;
|
||||
tone: RecentTone;
|
||||
checkpointAt: string;
|
||||
}
|
||||
|
||||
/** One merged global-log line. */
|
||||
export interface GlobalLogItem {
|
||||
key: string;
|
||||
project: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
msg: string;
|
||||
status: string;
|
||||
ciStatus: string;
|
||||
err: boolean;
|
||||
}
|
||||
|
||||
interface Selected {
|
||||
project: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AppStateValue {
|
||||
// Navigation.
|
||||
screen: Screen;
|
||||
detailTab: DetailTab;
|
||||
logFilter: string;
|
||||
go: (screen: Screen) => void;
|
||||
setDetailTab: (tab: DetailTab) => void;
|
||||
setLogFilter: (f: string) => void;
|
||||
openDetail: (project: string, name: string) => void;
|
||||
openAnswer: (project: string, name: string) => void;
|
||||
|
||||
// Fleet data.
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
projects: Project[];
|
||||
waiting: WaitingItem[];
|
||||
recent: RecentItem[];
|
||||
counts: { running: number; waiting: number; done: number };
|
||||
globalLog: GlobalLogItem[];
|
||||
refresh: () => Promise<void>;
|
||||
|
||||
// Selected agent (detail / answer screens).
|
||||
selectedAgent: Agent | null;
|
||||
selectedCheckmark: Checkmark | null;
|
||||
selectedLog: LogEntry[];
|
||||
|
||||
// Mutations.
|
||||
sendAnswer: (text: string) => Promise<{ resumed: boolean; note?: string }>;
|
||||
spawn: (project: string, task: string) => Promise<void>;
|
||||
kill: (project: string, name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const AppStateContext = createContext<AppStateValue | null>(null);
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
const agentKey = (project: string, name: string) => `${project}/${name}`;
|
||||
|
||||
function isApiError(e: unknown): e is ApiError {
|
||||
return e instanceof Error && typeof (e as ApiError).status === "number";
|
||||
}
|
||||
|
||||
function errMessage(e: unknown): string {
|
||||
if (e instanceof Error) return e.message || "request failed";
|
||||
return String(e);
|
||||
}
|
||||
|
||||
function isErrorStatus(status: string | null | undefined): boolean {
|
||||
const s = (status ?? "").toLowerCase();
|
||||
return s === "failed" || s === "error" || s === "fail";
|
||||
}
|
||||
|
||||
/** Derive an agent name: a slug of the first few task words + 4 random hex chars. */
|
||||
function deriveAgentName(task: string): string {
|
||||
const words = task
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 4);
|
||||
const slug = words.join("-") || "agent";
|
||||
const hex = Math.floor(Math.random() * 0x10000)
|
||||
.toString(16)
|
||||
.padStart(4, "0");
|
||||
return `${slug}-${hex}`;
|
||||
}
|
||||
|
||||
export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
||||
const { config, clear } = useServerConfig();
|
||||
|
||||
// Fleet data.
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [agentsByProject, setAgentsByProject] = useState<Record<string, Agent[]>>({});
|
||||
const [checkmarks, setCheckmarks] = useState<Record<string, Checkmark | null>>({});
|
||||
const [logsByAgent, setLogsByAgent] = useState<Record<string, LogEntry[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Navigation.
|
||||
const [screen, setScreen] = useState<Screen>("fleet");
|
||||
const [detailTab, setDetailTab] = useState<DetailTab>("state");
|
||||
const [logFilter, setLogFilter] = useState<string>("all");
|
||||
const [selected, setSelected] = useState<Selected | null>(null);
|
||||
|
||||
const resetData = useCallback(() => {
|
||||
setProjects([]);
|
||||
setAgentsByProject({});
|
||||
setCheckmarks({});
|
||||
setLogsByAgent({});
|
||||
setSelected(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// The client is rebuilt whenever the endpoint/token change. A stale 401 clears local
|
||||
// data and drops the stored config, which routes the app back to ConnectScreen.
|
||||
const client = useMemo<ApiClient | null>(() => {
|
||||
if (!config) return null;
|
||||
return createClient(config.endpoint, config.token, () => {
|
||||
resetData();
|
||||
setScreen("fleet");
|
||||
void clear();
|
||||
});
|
||||
}, [config, clear, resetData]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!client) return;
|
||||
try {
|
||||
setError(null);
|
||||
const projs = await client.api<Project[]>("/projects");
|
||||
|
||||
// Per-agent/per-project sub-requests are isolated: one flaky agent (a 500 on
|
||||
// its log, say) must not blank the whole fleet. A rejected AuthError still
|
||||
// propagates via onUnauthorized inside the client; here we just record the
|
||||
// failure for that one item and keep the rest of the fleet rendering.
|
||||
const agentLists = await Promise.all(
|
||||
projs.map((p) =>
|
||||
client
|
||||
.api<Agent[]>(`/projects/${enc(p.id)}/agents`)
|
||||
.then((list) => [p.id, list] as const)
|
||||
.catch((e) => {
|
||||
if (e instanceof AuthError) throw e;
|
||||
return [p.id, [] as Agent[]] as const;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const flat: { project: string; agent: Agent }[] = [];
|
||||
const abp: Record<string, Agent[]> = {};
|
||||
for (const [pid, list] of agentLists) {
|
||||
abp[pid] = list;
|
||||
for (const a of list) flat.push({ project: pid, agent: a });
|
||||
}
|
||||
|
||||
const cmEntries = await Promise.all(
|
||||
flat.map(async ({ project, agent }) => {
|
||||
const key = agentKey(project, agent.name);
|
||||
try {
|
||||
const cm = await client.api<Checkmark>(
|
||||
`/projects/${enc(project)}/agents/${enc(agent.name)}/checkmark`,
|
||||
);
|
||||
return [key, cm] as const;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) throw e;
|
||||
// 404 = no checkmark yet; any other error = leave it absent this cycle.
|
||||
return [key, null] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const cmMap: Record<string, Checkmark | null> = {};
|
||||
for (const [k, v] of cmEntries) cmMap[k] = v;
|
||||
|
||||
const logEntries = await Promise.all(
|
||||
flat.map(async ({ project, agent }) => {
|
||||
const key = agentKey(project, agent.name);
|
||||
try {
|
||||
const log = await client.api<LogEntry[]>(
|
||||
`/projects/${enc(project)}/agents/${enc(agent.name)}/log`,
|
||||
);
|
||||
return [key, log] as const;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) throw e;
|
||||
return [key, [] as LogEntry[]] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const logMap: Record<string, LogEntry[]> = {};
|
||||
for (const [k, v] of logEntries) logMap[k] = v;
|
||||
|
||||
setProjects(projs);
|
||||
setAgentsByProject(abp);
|
||||
setCheckmarks(cmMap);
|
||||
setLogsByAgent(logMap);
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return; // handled by onUnauthorized
|
||||
setError(errMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
// Initial load + 10s poll while mounted; re-runs when the client (endpoint/token) changes.
|
||||
const refreshRef = useRef(refresh);
|
||||
refreshRef.current = refresh;
|
||||
useEffect(() => {
|
||||
if (!client) return;
|
||||
setLoading(true);
|
||||
void refreshRef.current();
|
||||
const id = setInterval(() => {
|
||||
void refreshRef.current();
|
||||
}, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, [client]);
|
||||
|
||||
// ---- Derived view-models -------------------------------------------------
|
||||
const waiting = useMemo<WaitingItem[]>(() => {
|
||||
const out: WaitingItem[] = [];
|
||||
for (const [pid, list] of Object.entries(agentsByProject)) {
|
||||
for (const a of list) {
|
||||
const cm = checkmarks[agentKey(pid, a.name)] ?? null;
|
||||
const hasQuestion = !!(cm && cm.open_question);
|
||||
const paused = a.status.toLowerCase() === "paused_for_input";
|
||||
if (hasQuestion || paused) {
|
||||
out.push({
|
||||
project: pid,
|
||||
name: a.name,
|
||||
question:
|
||||
cm?.open_question?.trim() || "Agent is paused, waiting for input.",
|
||||
logEntryId: cm?.log_entry_id ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [agentsByProject, checkmarks]);
|
||||
|
||||
const recent = useMemo<RecentItem[]>(() => {
|
||||
const rows: RecentItem[] = [];
|
||||
for (const [pid, list] of Object.entries(agentsByProject)) {
|
||||
for (const a of list) {
|
||||
const cm = checkmarks[agentKey(pid, a.name)];
|
||||
if (!cm) continue;
|
||||
rows.push({
|
||||
key: agentKey(pid, a.name),
|
||||
project: pid,
|
||||
name: a.name,
|
||||
title: `${pid} · ${a.name}`,
|
||||
meta: `${statusLabel(cm.status).toLowerCase()} — tests ${statusLabel(
|
||||
cm.tests_status,
|
||||
).toLowerCase()} · ${timeAgo(cm.checkpoint_at)}`,
|
||||
tone: statusTone(cm.status) === "danger" ? "danger" : "positive",
|
||||
checkpointAt: cm.checkpoint_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
rows.sort(
|
||||
(a, b) =>
|
||||
new Date(b.checkpointAt).getTime() - new Date(a.checkpointAt).getTime(),
|
||||
);
|
||||
return rows;
|
||||
}, [agentsByProject, checkmarks]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
let running = 0;
|
||||
let done = 0;
|
||||
for (const list of Object.values(agentsByProject)) {
|
||||
for (const a of list) {
|
||||
const s = a.status.toLowerCase();
|
||||
if (s === "working" || s === "running") running++;
|
||||
else if (s === "done" || s === "failed") done++;
|
||||
}
|
||||
}
|
||||
return { running, waiting: waiting.length, done };
|
||||
}, [agentsByProject, waiting]);
|
||||
|
||||
const globalLog = useMemo<GlobalLogItem[]>(() => {
|
||||
const rows: GlobalLogItem[] = [];
|
||||
for (const [pid, list] of Object.entries(agentsByProject)) {
|
||||
for (const a of list) {
|
||||
const entries = logsByAgent[agentKey(pid, a.name)] ?? [];
|
||||
for (const e of entries) {
|
||||
rows.push({
|
||||
key: `${pid}/${a.name}/${e.id}`,
|
||||
project: pid,
|
||||
name: a.name,
|
||||
createdAt: e.created_at,
|
||||
msg: e.summary?.trim() || statusLabel(e.status),
|
||||
status: e.status,
|
||||
ciStatus: e.ci_status,
|
||||
err: isErrorStatus(e.status) || isErrorStatus(e.ci_status),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return rows;
|
||||
}, [agentsByProject, logsByAgent]);
|
||||
|
||||
// ---- Selected agent ------------------------------------------------------
|
||||
const selectedAgent = useMemo<Agent | null>(() => {
|
||||
if (!selected) return null;
|
||||
return (
|
||||
(agentsByProject[selected.project] ?? []).find(
|
||||
(a) => a.name === selected.name,
|
||||
) ?? null
|
||||
);
|
||||
}, [selected, agentsByProject]);
|
||||
|
||||
const selectedCheckmark = selected
|
||||
? checkmarks[agentKey(selected.project, selected.name)] ?? null
|
||||
: null;
|
||||
const selectedLog = selected
|
||||
? logsByAgent[agentKey(selected.project, selected.name)] ?? []
|
||||
: [];
|
||||
|
||||
// ---- Navigation helpers --------------------------------------------------
|
||||
const openDetail = useCallback((project: string, name: string) => {
|
||||
setSelected({ project, name });
|
||||
setDetailTab("state");
|
||||
setScreen("detail");
|
||||
}, []);
|
||||
|
||||
const openAnswer = useCallback((project: string, name: string) => {
|
||||
setSelected({ project, name });
|
||||
setScreen("answer");
|
||||
}, []);
|
||||
|
||||
// ---- Mutations -----------------------------------------------------------
|
||||
const sendAnswer = useCallback(
|
||||
async (text: string): Promise<{ resumed: boolean; note?: string }> => {
|
||||
if (!client || !selected) throw new Error("no agent selected");
|
||||
const cm = checkmarks[agentKey(selected.project, selected.name)] ?? null;
|
||||
const base = `/projects/${enc(selected.project)}/agents/${enc(selected.name)}`;
|
||||
|
||||
await client.api(`${base}/answer`, {
|
||||
body: {
|
||||
answer: text,
|
||||
...(cm?.log_entry_id ? { log_entry_id: cm.log_entry_id } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Resume is admin-only: a valid non-admin token 403s (or 401 with allow401 set) but
|
||||
// the answer is already saved, so surface a soft note instead of failing.
|
||||
try {
|
||||
await client.api(`${base}/resume`, { body: {}, allow401: true });
|
||||
} catch (e) {
|
||||
if (isApiError(e) && (e.status === 401 || e.status === 403)) {
|
||||
await refresh();
|
||||
return {
|
||||
resumed: false,
|
||||
note: "answer saved — resume needs the admin token",
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await refresh();
|
||||
return { resumed: true };
|
||||
},
|
||||
[client, selected, checkmarks, refresh],
|
||||
);
|
||||
|
||||
const spawn = useCallback(
|
||||
async (project: string, task: string) => {
|
||||
if (!client) throw new Error("not connected");
|
||||
const name = deriveAgentName(task);
|
||||
await client.api(`/projects/${enc(project)}/agents/spawn`, {
|
||||
body: { name, ...(task.trim() ? { task: task.trim() } : {}) },
|
||||
});
|
||||
await refresh();
|
||||
},
|
||||
[client, refresh],
|
||||
);
|
||||
|
||||
const kill = useCallback(
|
||||
async (project: string, name: string) => {
|
||||
if (!client) throw new Error("not connected");
|
||||
await client.api(`/projects/${enc(project)}/agents/${enc(name)}/kill`, {
|
||||
method: "POST",
|
||||
});
|
||||
await refresh();
|
||||
},
|
||||
[client, refresh],
|
||||
);
|
||||
|
||||
const value = useMemo<AppStateValue>(
|
||||
() => ({
|
||||
screen,
|
||||
detailTab,
|
||||
logFilter,
|
||||
go: setScreen,
|
||||
setDetailTab,
|
||||
setLogFilter,
|
||||
openDetail,
|
||||
openAnswer,
|
||||
|
||||
loading,
|
||||
error,
|
||||
projects,
|
||||
waiting,
|
||||
recent,
|
||||
counts,
|
||||
globalLog,
|
||||
refresh,
|
||||
|
||||
selectedAgent,
|
||||
selectedCheckmark,
|
||||
selectedLog,
|
||||
|
||||
sendAnswer,
|
||||
spawn,
|
||||
kill,
|
||||
}),
|
||||
[
|
||||
screen,
|
||||
detailTab,
|
||||
logFilter,
|
||||
openDetail,
|
||||
openAnswer,
|
||||
loading,
|
||||
error,
|
||||
projects,
|
||||
waiting,
|
||||
recent,
|
||||
counts,
|
||||
globalLog,
|
||||
refresh,
|
||||
selectedAgent,
|
||||
selectedCheckmark,
|
||||
selectedLog,
|
||||
sendAnswer,
|
||||
spawn,
|
||||
kill,
|
||||
],
|
||||
);
|
||||
|
||||
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,92 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
/**
|
||||
* Persists the one thing the app needs to talk to a Handler server — the base
|
||||
* endpoint and the bearer token — to AsyncStorage under a single versioned key.
|
||||
*
|
||||
* `config` is null until either the stored value loads (once `loading` flips
|
||||
* false) or the operator connects. A persistent 401 calls `clear()`, dropping
|
||||
* back to the ConnectScreen while `lastEndpoint` keeps the URL prefilled so the
|
||||
* operator only re-enters the token.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "handler.server.v1";
|
||||
export const DEFAULT_ENDPOINT = "https://handler.home.leeworks.dev";
|
||||
|
||||
export interface ServerConfig {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface ServerConfigValue {
|
||||
config: ServerConfig | null;
|
||||
loading: boolean;
|
||||
/** The last endpoint we saw, for prefilling ConnectScreen after a sign-out / 401. */
|
||||
lastEndpoint: string;
|
||||
save: (config: ServerConfig) => Promise<void>;
|
||||
clear: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ServerConfigContext = createContext<ServerConfigValue | null>(null);
|
||||
|
||||
export function ServerConfigProvider({ children }: { children: React.ReactNode }) {
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastEndpoint, setLastEndpoint] = useState(DEFAULT_ENDPOINT);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (active && raw) {
|
||||
const parsed = JSON.parse(raw) as ServerConfig;
|
||||
if (parsed && parsed.endpoint && parsed.token) {
|
||||
setConfig(parsed);
|
||||
setLastEndpoint(parsed.endpoint);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* corrupt/unreadable storage — treat as unconfigured */
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async (next: ServerConfig) => {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
setConfig(next);
|
||||
setLastEndpoint(next.endpoint);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
setConfig(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ServerConfigContext.Provider
|
||||
value={{ config, loading, lastEndpoint, save, clear }}
|
||||
>
|
||||
{children}
|
||||
</ServerConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useServerConfig(): ServerConfigValue {
|
||||
const ctx = useContext(ServerConfigContext);
|
||||
if (!ctx)
|
||||
throw new Error("useServerConfig must be used within ServerConfigProvider");
|
||||
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"]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Handler mobile app design
|
||||
|
||||
_Started 2026-07-20 14:45 UTC_
|
||||
|
||||
---
|
||||
|
||||
## User
|
||||
|
||||
Design a mobile app for my "handler" project.
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: list_files]_
|
||||
|
||||
_[tool: questions_v2]_
|
||||
|
||||
## User
|
||||
|
||||
Questions answered:
|
||||
- what_is_handler: A remote control wrapper for Claude Code agents. Run many claude agents across many projects — each isolated, each leaving behind a checkmark (its current state) and an entry in a big log (the complete history) — all backed by a centralized database, driven entirely through an HTTP API.
|
||||
See https://github.com/0xWheatyz/handler
|
||||
- core_object: Agents with long running tasks
|
||||
- key_screens: Home / dashboard, List + detail of the core object
|
||||
- scope: A full flow (5–7 screens)
|
||||
- interactivity: Light interactions (tabs, toggles)
|
||||
- audience: Just me — exploring the idea
|
||||
- tone: Utilitarian and terse
|
||||
- platform: iOS
|
||||
- variations: Yes — show 2–3 layout directions for the main screen
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
_[tool: web_fetch]_
|
||||
|
||||
_[tool: grep]_
|
||||
|
||||
_[tool: list_files]_
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
_[tool: grep]_
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
_[tool: read_file]_
|
||||
|
||||
_[tool: dc_write]_
|
||||
|
||||
_[tool: ready_for_verification]_
|
||||
|
||||
## User
|
||||
|
||||
Commit to 1a, wire-up the real screens.
|
||||
|
||||
## Assistant
|
||||
|
||||
_[tool: dc_js_str_replace]_
|
||||
|
||||
_[tool: dc_js_str_replace]_
|
||||
|
||||
_[tool: dc_html_str_replace]_
|
||||
|
||||
_[tool: ready_for_verification]_
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,25 @@
|
||||
# CODING AGENTS: READ THIS FIRST
|
||||
|
||||
This is a **handoff bundle** from Claude Design (claude.ai/design).
|
||||
|
||||
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
|
||||
|
||||
## What you should do — IMPORTANT
|
||||
|
||||
**Read the chat transcripts first.** There are 1 chat transcript(s) in `chats/`. The transcripts show the full back-and-forth between the user and the design assistant — they tell you **what the user actually wants** and **where they landed** after iterating. Don't skip them. The final HTML files are the output, but the chat is where the intent lives.
|
||||
|
||||
**Read `project/Handler Mobile.dc.html` in full.** The user had this file open when they triggered the handoff, so it's almost certainly the primary design they want built. Read it top to bottom — don't skim. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
|
||||
|
||||
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
|
||||
|
||||
## About the design files
|
||||
|
||||
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
|
||||
|
||||
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
|
||||
|
||||
## Bundle contents
|
||||
|
||||
- `README.md` — this file
|
||||
- `chats/` — conversation transcripts (read these!)
|
||||
- `project/` — the `Handler mobile app design` project files (HTML prototypes, assets, components)
|
||||
@@ -0,0 +1,572 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="design_doc_mode" content="canvas">
|
||||
<link rel="stylesheet" href="_ds/leeworks-design-system-b3e560db-22c8-4dea-b1ef-d19ecb5f5b6e/tokens/fonts.css">
|
||||
<link rel="stylesheet" href="_ds/leeworks-design-system-b3e560db-22c8-4dea-b1ef-d19ecb5f5b6e/tokens/colors.css">
|
||||
<link rel="stylesheet" href="_ds/leeworks-design-system-b3e560db-22c8-4dea-b1ef-d19ecb5f5b6e/tokens/typography.css">
|
||||
<link rel="stylesheet" href="_ds/leeworks-design-system-b3e560db-22c8-4dea-b1ef-d19ecb5f5b6e/tokens/spacing.css">
|
||||
<link rel="stylesheet" href="_ds/leeworks-design-system-b3e560db-22c8-4dea-b1ef-d19ecb5f5b6e/tokens/effects.css">
|
||||
<link rel="stylesheet" href="_ds/leeworks-design-system-b3e560db-22c8-4dea-b1ef-d19ecb5f5b6e/styles.css">
|
||||
<script src="_ds/leeworks-design-system-b3e560db-22c8-4dea-b1ef-d19ecb5f5b6e/_ds_bundle.js"></script>
|
||||
<style>
|
||||
body{margin:0;background:#f0eee9;font-family:system-ui,sans-serif}
|
||||
a{color:#141413}a:hover{color:#555}
|
||||
@keyframes lwPulse{0%,100%{opacity:1}50%{opacity:.35}}
|
||||
.dv-turn{padding:40px 44px 32px;border-bottom:1px solid rgba(0,0,0,.08);scroll-margin-top:16px}
|
||||
.dv-thd{display:flex;align-items:baseline;gap:10px;margin:0 0 8px}
|
||||
.dv-tid{font:600 10px ui-monospace,Menlo,monospace;padding:3px 7px;background:#1a1a1a;color:#fff;border-radius:4px;text-decoration:none}
|
||||
.dv-tname{font:600 13px/1.2 system-ui,sans-serif;color:#1a1a1a}
|
||||
.dv-note{max-width:760px;font:12px/1.55 system-ui,sans-serif;color:rgba(0,0,0,.55);margin:0 0 24px}
|
||||
.dv-row-label{font:600 11px/1 ui-monospace,Menlo,monospace;letter-spacing:.06em;text-transform:uppercase;color:rgba(0,0,0,.45);margin:28px 0 14px}
|
||||
.dv-opts{display:flex;flex-wrap:wrap;gap:28px;align-items:flex-start}
|
||||
.dv-opt{flex:none;display:flex;flex-direction:column;gap:9px;scroll-margin-top:16px}
|
||||
.dv-oid{font:600 10.5px ui-monospace,Menlo,monospace;padding:3px 7px;background:rgba(0,0,0,.08);color:#1a1a1a;border-radius:5px;text-decoration:none}
|
||||
.dv-olabel{display:flex;align-items:baseline;gap:8px;font:400 11px/1.3 system-ui,sans-serif;color:rgba(0,0,0,.55)}
|
||||
.dv-opt:target .dv-oid{background:#2a78d6;color:#fff}
|
||||
.dv-next{margin:26px 0 0;font:12px/1.5 system-ui,sans-serif;color:rgba(0,0,0,.5)}
|
||||
</style>
|
||||
</helmet>
|
||||
<section class="dv-turn" id="t2">
|
||||
<div class="dv-thd"><a class="dv-tid" href="#t2">2</a><span class="dv-tname">Committed: 1a wired into one tappable prototype</span></div>
|
||||
<p class="dv-note">One phone, all screens live. Tap: Answer buttons → answer flow (quick replies + send & resume flips the agent to Running), checkmark rows → agent detail, New → spawn, tab bar → Log / Settings. Back arrows work.</p>
|
||||
<div class="dv-opts">
|
||||
<div class="dv-opt" id="2a"><div class="dv-olabel"><a class="dv-oid" href="#2a">2a</a>Handler — interactive prototype</div>
|
||||
<div data-theme="{{ theme }}" data-screen-label="2a Interactive prototype" style="width:390px;height:844px;background:var(--surface-page);border-radius:48px;border:1px solid var(--border-default);box-shadow:var(--shadow-overlay);overflow:hidden;display:flex;flex-direction:column;font:var(--type-body);color:var(--text-body);position:relative;flex-shrink:0">
|
||||
<div style="height:54px;flex-shrink:0;display:flex;align-items:flex-end;justify-content:space-between;padding:0 28px 6px;font:600 14px/1 var(--font-body);color:var(--text-heading)"><span>9:41</span><span style="display:inline-flex;gap:5px;align-items:center"><svg width="16" height="12" viewBox="0 0 16 12" fill="currentColor"><rect x="0" y="7" width="3" height="5" rx="1"></rect><rect x="4.5" y="5" width="3" height="7" rx="1"></rect><rect x="9" y="2.5" width="3" height="9.5" rx="1"></rect><rect x="13" y="0" width="3" height="12" rx="1" opacity=".35"></rect></svg><svg width="24" height="12" viewBox="0 0 24 12" fill="none"><rect x="0.5" y="0.5" width="20" height="11" rx="3" stroke="currentColor" opacity=".4"></rect><rect x="2" y="2" width="14" height="8" rx="1.5" fill="currentColor"></rect><path d="M22.5 4v4a2 2 0 0 0 0-4z" fill="currentColor" opacity=".4"></path></svg></span></div>
|
||||
|
||||
<!-- FLEET -->
|
||||
<sc-if value="{{ isFleet }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="flex:1;overflow:auto;min-height:0;padding:8px 20px 20px">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:12px 0 20px">
|
||||
<div style="flex:1">
|
||||
<div style="font:var(--type-h3);font-family:var(--font-display);letter-spacing:var(--tracking-tight);color:var(--text-heading)">Fleet</div>
|
||||
<div style="font:var(--type-body-sm);color:var(--text-muted);margin-top:2px">12 agents · 3 waiting on you</div>
|
||||
</div>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="sm" onClick="{{ goSpawn }}" hint-size="64px,32px">New</x-import>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;margin-bottom:20px">
|
||||
<div style="flex:1;background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card);padding:14px 16px"><div style="font:var(--type-caption);color:var(--text-muted)">Running</div><div style="font-family:var(--font-mono);font-size:24px;font-weight:600;color:var(--text-heading);margin-top:4px">6</div></div>
|
||||
<div style="flex:1;background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card);padding:14px 16px"><div style="font:var(--type-caption);color:var(--text-muted)">Waiting</div><div style="font-family:var(--font-mono);font-size:24px;font-weight:600;color:var(--warning);margin-top:4px">3</div></div>
|
||||
<div style="flex:1;background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card);padding:14px 16px"><div style="font:var(--type-caption);color:var(--text-muted)">Done</div><div style="font-family:var(--font-mono);font-size:24px;font-weight:600;color:var(--text-heading);margin-top:4px">42</div></div>
|
||||
</div>
|
||||
<div style="font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px;margin-bottom:10px">Waiting on you</div>
|
||||
<div style="background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card);margin-bottom:20px">
|
||||
<sc-if value="{{ notAnswered }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border-subtle)">
|
||||
<div style="display:flex;align-items:center;gap:8px"><span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted)">agt-7a1d</span><span style="font:var(--type-label);color:var(--text-heading);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">handler · migrate state to sqlite</span></div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-top:8px"><span style="flex:1;font:var(--type-body-sm);color:var(--text-body);font-style:italic">"Drop the legacy JSON store, or keep it as a read fallback?"</span><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="sm" variant="secondary" onClick="{{ goAnswer }}" hint-size="76px,32px">Answer</x-import></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border-subtle)">
|
||||
<div style="display:flex;align-items:center;gap:8px"><span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted)">agt-3e90</span><span style="font:var(--type-label);color:var(--text-heading);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">wheatsite · fix build on node 22</span></div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-top:8px"><span style="flex:1;font:var(--type-body-sm);color:var(--text-body);font-style:italic">"Pin node 20 in CI, or patch esbuild?"</span><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="sm" variant="secondary" onClick="{{ goAnswer }}" hint-size="76px,32px">Answer</x-import></div>
|
||||
</div>
|
||||
<div style="padding:14px 16px">
|
||||
<div style="display:flex;align-items:center;gap:8px"><span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted)">agt-b241</span><span style="font:var(--type-label);color:var(--text-heading);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">api-gateway · add rate limiting</span></div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-top:8px"><span style="flex:1;font:var(--type-body-sm);color:var(--text-body);font-style:italic">"429 body: JSON or plain text?"</span><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="sm" variant="secondary" onClick="{{ goAnswer }}" hint-size="76px,32px">Answer</x-import></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px;margin-bottom:10px">Recent checkmarks</div>
|
||||
<div style="background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card)">
|
||||
<div onClick="{{ goDetail }}" style="display:flex;align-items:center;gap:12px;padding:14px 16px;min-height:44px;border-bottom:1px solid var(--border-subtle);cursor:pointer" style-hover="background:var(--surface-sunken)"><span style="width:8px;height:8px;border-radius:4px;background:var(--positive);flex-shrink:0"></span><div style="flex:1;min-width:0"><div style="font:var(--type-label);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">handler · add /agents endpoint</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">done — tests pass · 14m ago</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="chevronRight" size="16" style="color:var(--ink-4)" hint-size="16px,16px"></x-import></div>
|
||||
<div onClick="{{ goDetail }}" style="display:flex;align-items:center;gap:12px;padding:14px 16px;min-height:44px;border-bottom:1px solid var(--border-subtle);cursor:pointer" style-hover="background:var(--surface-sunken)"><span style="width:8px;height:8px;border-radius:4px;background:var(--danger);flex-shrink:0"></span><div style="flex:1;min-width:0"><div style="font:var(--type-label);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">wheatsite · refactor router</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">failed — 2 tests · 1h ago</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="chevronRight" size="16" style="color:var(--ink-4)" hint-size="16px,16px"></x-import></div>
|
||||
<div onClick="{{ goDetail }}" style="display:flex;align-items:center;gap:12px;padding:14px 16px;min-height:44px;cursor:pointer" style-hover="background:var(--surface-sunken)"><span style="width:8px;height:8px;border-radius:4px;background:var(--positive);flex-shrink:0"></span><div style="flex:1;min-width:0"><div style="font:var(--type-label);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">dotfiles · port zsh config</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">done — 12 turns · 3h ago</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="chevronRight" size="16" style="color:var(--ink-4)" hint-size="16px,16px"></x-import></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:84px;flex-shrink:0;border-top:1px solid var(--border-subtle);background:var(--surface-page);display:flex;padding:8px 12px 24px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--text-heading);cursor:pointer"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="home" size="22" strokeWidth="2" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:600">Fleet</span></div>
|
||||
<div onClick="{{ goLog }}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4);cursor:pointer"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="file" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Log</span></div>
|
||||
<div onClick="{{ goSettings }}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4);cursor:pointer"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="settings" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Settings</span></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- AGENT DETAIL -->
|
||||
<sc-if value="{{ isDetail }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="flex:1;overflow:auto;min-height:0;padding:8px 20px 20px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin:8px 0 16px">
|
||||
<span onClick="{{ goFleet }}" style="display:inline-flex;width:32px;height:32px;align-items:center;justify-content:center;margin-left:-8px;color:var(--text-heading);cursor:pointer"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="chevronRight" size="20" style="transform:rotate(180deg)" hint-size="20px,20px"></x-import></span>
|
||||
<span style="font-family:var(--font-mono);font-size:13px;color:var(--text-muted);flex:1">agt-7a1d</span>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Badge" tone="{{ agentTone }}" hint-size="auto,22px">{{ agentStatus }}</x-import>
|
||||
</div>
|
||||
<div style="font:var(--type-h3);font-family:var(--font-display);letter-spacing:var(--tracking-tight);color:var(--text-heading)">Migrate agent state to sqlite</div>
|
||||
<div style="font:var(--type-body-sm);color:var(--text-muted);margin:4px 0 16px">handler · branch <span style="font-family:var(--font-mono)">agt/7a1d</span></div>
|
||||
<div style="display:flex;background:var(--surface-sunken);border-radius:8px;padding:3px;margin-bottom:16px">
|
||||
<button onClick="{{ showState }}" style="{{ segStateStyle }}">Checkmark</button>
|
||||
<button onClick="{{ showLog }}" style="{{ segLogStyle }}">Log</button>
|
||||
</div>
|
||||
<sc-if value="{{ isState }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="background:var(--surface-sunken);border:1px solid var(--border-subtle);border-radius:10px;padding:16px">
|
||||
<div style="font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px;margin-bottom:8px">Current state</div>
|
||||
<div style="font-family:var(--font-mono);font-size:13px;line-height:1.7;color:var(--text-body)">{{ agentStateText }}</div>
|
||||
<div style="font:var(--type-caption);color:var(--text-muted);margin-top:10px">updated 2m ago</div>
|
||||
</div>
|
||||
<div style="margin-top:16px;background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card)">
|
||||
<div style="display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border-subtle)"><span style="font:var(--type-body-sm);color:var(--text-muted)">Started</span><span style="font-family:var(--font-mono);font-size:13px;color:var(--text-heading)">41m ago</span></div>
|
||||
<div style="display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border-subtle)"><span style="font:var(--type-body-sm);color:var(--text-muted)">Model</span><span style="font-family:var(--font-mono);font-size:13px;color:var(--text-heading)">claude-sonnet-4</span></div>
|
||||
<div style="display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border-subtle)"><span style="font:var(--type-body-sm);color:var(--text-muted)">Turns</span><span style="font-family:var(--font-mono);font-size:13px;color:var(--text-heading)">21</span></div>
|
||||
<div style="display:flex;justify-content:space-between;padding:12px 16px"><span style="font:var(--type-body-sm);color:var(--text-muted)">Tokens</span><span style="font-family:var(--font-mono);font-size:13px;color:var(--text-heading)">348k</span></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ isLog }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="background:var(--surface-sunken);border:1px solid var(--border-subtle);border-radius:10px;padding:16px;font-family:var(--font-mono);font-size:12.5px;line-height:2.1">
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">14:02</span><span style="color:var(--warning)">paused — waiting for input</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:57</span><span style="color:var(--text-body)">checkmark updated</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:52</span><span style="color:var(--text-muted)">tool: bash — sqlite3 .schema</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:48</span><span style="color:var(--text-muted)">tool: edit — store/sqlite.rs</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:40</span><span style="color:var(--text-muted)">tool: bash — cargo test store</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:29</span><span style="color:var(--text-body)">checkmark updated</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:21</span><span style="color:var(--text-muted)">tool: read — store/json.rs</span></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<div style="display:flex;gap:10px;margin-top:20px">
|
||||
<sc-if value="{{ notAnswered }}" hint-placeholder-val="{{ true }}"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="lg" style="flex:1" onClick="{{ goAnswer }}" hint-size="100%,48px">Answer</x-import></sc-if>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="lg" variant="secondary" style="flex:1" hint-size="80px,48px">Pause</x-import>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="lg" variant="danger" hint-size="70px,48px">Kill</x-import>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:34px;flex-shrink:0"></div>
|
||||
</sc-if>
|
||||
|
||||
<!-- ANSWER -->
|
||||
<sc-if value="{{ isAnswer }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="flex:1;overflow:auto;min-height:0;padding:8px 20px 20px;display:flex;flex-direction:column">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin:8px 0 16px">
|
||||
<span onClick="{{ goDetail }}" style="display:inline-flex;width:32px;height:32px;align-items:center;justify-content:center;margin-left:-8px;color:var(--text-heading);cursor:pointer"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="chevronRight" size="20" style="transform:rotate(180deg)" hint-size="20px,20px"></x-import></span>
|
||||
<span style="font-family:var(--font-mono);font-size:13px;color:var(--text-muted);flex:1">agt-7a1d</span>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Badge" tone="warning" hint-size="auto,22px">Waiting</x-import>
|
||||
</div>
|
||||
<div style="font:var(--type-h3);font-family:var(--font-display);letter-spacing:var(--tracking-tight);color:var(--text-heading);margin-bottom:16px">Agent needs input</div>
|
||||
<div style="background:var(--surface-sunken);border:1px solid var(--border-subtle);border-radius:10px;padding:16px;margin-bottom:20px">
|
||||
<div style="font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px;margin-bottom:8px">Question · 2m ago</div>
|
||||
<div style="font-family:var(--font-mono);font-size:13px;line-height:1.7;color:var(--text-body)">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?</div>
|
||||
</div>
|
||||
<div style="font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px;margin-bottom:10px">Quick replies</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px">
|
||||
<sc-for list="{{ quickReplies }}" as="qr" hint-placeholder-count="3">
|
||||
<button onClick="{{ qr.pick }}" style="{{ qr.style }}">{{ qr.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="margin-top:auto;display:flex;flex-direction:column;gap:12px">
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Input" placeholder="Or type a reply…" size="lg" hint-size="100%,48px"></x-import>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="lg" style="width:100%" onClick="{{ sendResume }}" hint-size="100%,48px">Send & resume</x-import>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:34px;flex-shrink:0"></div>
|
||||
</sc-if>
|
||||
|
||||
<!-- SPAWN -->
|
||||
<sc-if value="{{ isSpawn }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="flex:1;overflow:auto;min-height:0;padding:8px 20px 20px;display:flex;flex-direction:column">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin:8px 0 16px">
|
||||
<span onClick="{{ goFleet }}" style="display:inline-flex;width:32px;height:32px;align-items:center;justify-content:center;margin-left:-8px;color:var(--text-heading);cursor:pointer"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="x" size="20" hint-size="20px,20px"></x-import></span>
|
||||
<span style="font:var(--type-label);font-weight:600;color:var(--text-heading);flex:1">New agent</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Select" label="Project" options="{{ projectOpts }}" value="handler" size="lg" hint-size="100%,72px"></x-import>
|
||||
<div>
|
||||
<div style="font:var(--type-label);color:var(--text-heading);margin-bottom:6px">Task</div>
|
||||
<textarea placeholder="What should this agent do?" style="width:100%;box-sizing:border-box;height:120px;resize:none;padding:12px 14px;border:1px solid var(--border-default);border-radius:8px;background:var(--surface-page);font:var(--type-body);font-family:var(--font-body);color:var(--text-body);outline:none"></textarea>
|
||||
<div style="font:var(--type-caption);color:var(--text-muted);margin-top:6px">Runs isolated on a fresh branch.</div>
|
||||
</div>
|
||||
<div style="background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;min-height:44px;border-bottom:1px solid var(--border-subtle)"><div><div style="font:var(--type-label);color:var(--text-heading)">Auto-approve edits</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">Skip file-edit confirmations</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Switch" checked="{{ swEdits }}" onChange="{{ toggleEdits }}" hint-size="44px,24px"></x-import></div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;min-height:44px"><div><div style="font:var(--type-label);color:var(--text-heading)">Run tests on done</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">Checkmark fails if tests fail</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Switch" checked="{{ swTests }}" onChange="{{ toggleTests }}" hint-size="44px,24px"></x-import></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:auto">
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="lg" style="width:100%" onClick="{{ spawnGo }}" hint-size="100%,48px">Spawn agent</x-import>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:34px;flex-shrink:0"></div>
|
||||
</sc-if>
|
||||
|
||||
<!-- LOG -->
|
||||
<sc-if value="{{ isLogScreen }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="flex:1;overflow:auto;min-height:0;display:flex;flex-direction:column">
|
||||
<div style="padding:20px 20px 16px">
|
||||
<div style="font:var(--type-h3);font-family:var(--font-display);letter-spacing:var(--tracking-tight);color:var(--text-heading)">Log</div>
|
||||
<div style="display:flex;gap:8px;margin-top:14px">
|
||||
<button onClick="{{ setFilterAll }}" style="{{ fAllStyle }}">All</button>
|
||||
<button onClick="{{ setFilterHandler }}" style="{{ fHandlerStyle }}">handler</button>
|
||||
<button onClick="{{ setFilterErrors }}" style="{{ fErrStyle }}">Errors</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 20px 8px;font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px">Today</div>
|
||||
<div style="flex:1;background:var(--surface-sunken);border-top:1px solid var(--border-subtle);padding:16px 20px;font-family:var(--font-mono);font-size:12.5px;line-height:2.1">
|
||||
<sc-for list="{{ logEntries }}" as="e" hint-placeholder-count="8">
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">{{ e.t }}</span><span style="color:var(--ink-6);width:66px;flex-shrink:0">{{ e.id }}</span><span style="color: {{ e.color }}; flex:1">{{ e.msg }}</span></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:84px;flex-shrink:0;border-top:1px solid var(--border-subtle);background:var(--surface-page);display:flex;padding:8px 12px 24px">
|
||||
<div onClick="{{ goFleet }}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4);cursor:pointer"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="home" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Fleet</span></div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--text-heading);cursor:pointer"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="file" size="22" strokeWidth="2" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:600">Log</span></div>
|
||||
<div onClick="{{ goSettings }}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4);cursor:pointer"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="settings" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Settings</span></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- SETTINGS -->
|
||||
<sc-if value="{{ isSettings }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="flex:1;overflow:auto;min-height:0;padding:8px 20px 20px">
|
||||
<div style="font:var(--type-h3);font-family:var(--font-display);letter-spacing:var(--tracking-tight);color:var(--text-heading);margin:12px 0 20px">Settings</div>
|
||||
<div style="font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px;margin-bottom:10px">Server</div>
|
||||
<div style="background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card);margin-bottom:20px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:14px 16px;min-height:44px;border-bottom:1px solid var(--border-subtle)"><span style="font:var(--type-label);color:var(--text-heading)">Endpoint</span><span style="font-family:var(--font-mono);font-size:12.5px;color:var(--text-muted)">https://handler.wheaty.dev</span></div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:14px 16px;min-height:44px;border-bottom:1px solid var(--border-subtle)"><span style="font:var(--type-label);color:var(--text-heading)">API key</span><span style="font-family:var(--font-mono);font-size:12.5px;color:var(--text-muted)">hnd_••••••••4f2a</span></div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:14px 16px;min-height:44px"><span style="font:var(--type-label);color:var(--text-heading)">Status</span><span style="display:inline-flex;align-items:center;gap:6px;font-family:var(--font-mono);font-size:12.5px;color:var(--positive)"><span style="width:7px;height:7px;border-radius:4px;background:var(--positive)"></span>connected · 38ms</span></div>
|
||||
</div>
|
||||
<div style="font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px;margin-bottom:10px">Notifications</div>
|
||||
<div style="background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card);margin-bottom:20px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;min-height:44px;border-bottom:1px solid var(--border-subtle)"><div><div style="font:var(--type-label);color:var(--text-heading)">Waiting on input</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">Push when an agent pauses</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Switch" checked="{{ swPushWait }}" onChange="{{ togglePushWait }}" hint-size="44px,24px"></x-import></div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;min-height:44px"><div><div style="font:var(--type-label);color:var(--text-heading)">Failures</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">Push when a checkmark fails</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Switch" checked="{{ swPushFail }}" onChange="{{ togglePushFail }}" hint-size="44px,24px"></x-import></div>
|
||||
</div>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" variant="secondary" size="lg" style="width:100%" hint-size="100%,48px">Sign out</x-import>
|
||||
</div>
|
||||
<div style="height:84px;flex-shrink:0;border-top:1px solid var(--border-subtle);background:var(--surface-page);display:flex;padding:8px 12px 24px">
|
||||
<div onClick="{{ goFleet }}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4);cursor:pointer"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="home" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Fleet</span></div>
|
||||
<div onClick="{{ goLog }}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4);cursor:pointer"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="file" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Log</span></div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--text-heading);cursor:pointer"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="settings" size="22" strokeWidth="2" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:600">Settings</span></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);width:134px;height:5px;border-radius:3px;background:var(--ink-9);opacity:.9"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
<p class="dv-next">Answering agt-7a1d flips it to Running and clears it from the waiting list. Turn <a class="dv-tid" href="#t1">1</a> explorations kept below.</p>
|
||||
</section>
|
||||
<section class="dv-turn" id="t1">
|
||||
<div class="dv-thd"><a class="dv-tid" href="#t1">1</a><span class="dv-tname">Handler — iOS app, first pass</span></div>
|
||||
<p class="dv-note">Assumptions from the repo: entities are <b>projects → agents</b>; each agent carries a <b>checkmark</b> (current state) and writes to a <b>global log</b>; agents can be running, waiting on input, done, or failed. Tabs: Fleet / Log / Settings. Copy is terse, mono for ids & data per Leeworks. Three directions for the main screen below, then one shared flow.</p>
|
||||
|
||||
<div class="dv-row-label">Main screen — 3 directions</div>
|
||||
<div class="dv-opts">
|
||||
|
||||
<!-- ============ 1a Attention-first ============ -->
|
||||
<div class="dv-opt" id="1a"><div class="dv-olabel"><a class="dv-oid" href="#1a">1a</a>Attention-first — what needs you, then everything else</div>
|
||||
<div data-theme="{{ theme }}" data-screen-label="1a Fleet attention-first" style="width:390px;height:844px;background:var(--surface-page);border-radius:48px;border:1px solid var(--border-default);box-shadow:var(--shadow-overlay);overflow:hidden;display:flex;flex-direction:column;font:var(--type-body);color:var(--text-body);position:relative;flex-shrink:0">
|
||||
<div style="height:54px;flex-shrink:0;display:flex;align-items:flex-end;justify-content:space-between;padding:0 28px 6px;font:600 14px/1 var(--font-body);color:var(--text-heading)"><span>9:41</span><span style="display:inline-flex;gap:5px;align-items:center"><svg width="16" height="12" viewBox="0 0 16 12" fill="currentColor"><rect x="0" y="7" width="3" height="5" rx="1"></rect><rect x="4.5" y="5" width="3" height="7" rx="1"></rect><rect x="9" y="2.5" width="3" height="9.5" rx="1"></rect><rect x="13" y="0" width="3" height="12" rx="1" opacity=".35"></rect></svg><svg width="24" height="12" viewBox="0 0 24 12" fill="none"><rect x="0.5" y="0.5" width="20" height="11" rx="3" stroke="currentColor" opacity=".4"></rect><rect x="2" y="2" width="14" height="8" rx="1.5" fill="currentColor"></rect><path d="M22.5 4v4a2 2 0 0 0 0-4z" fill="currentColor" opacity=".4"></path></svg></span></div>
|
||||
<div style="flex:1;overflow:auto;min-height:0;padding:8px 20px 20px">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:12px 0 20px">
|
||||
<div style="flex:1">
|
||||
<div style="font:var(--type-h3);font-family:var(--font-display);letter-spacing:var(--tracking-tight);color:var(--text-heading)">Fleet</div>
|
||||
<div style="font:var(--type-body-sm);color:var(--text-muted);margin-top:2px">12 agents · 3 waiting on you</div>
|
||||
</div>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="sm" hint-size="64px,32px">New</x-import>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;margin-bottom:20px">
|
||||
<div style="flex:1;background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card);padding:14px 16px"><div style="font:var(--type-caption);color:var(--text-muted)">Running</div><div style="font-family:var(--font-mono);font-size:24px;font-weight:600;color:var(--text-heading);margin-top:4px">6</div></div>
|
||||
<div style="flex:1;background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card);padding:14px 16px"><div style="font:var(--type-caption);color:var(--text-muted)">Waiting</div><div style="font-family:var(--font-mono);font-size:24px;font-weight:600;color:var(--warning);margin-top:4px">3</div></div>
|
||||
<div style="flex:1;background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card);padding:14px 16px"><div style="font:var(--type-caption);color:var(--text-muted)">Done</div><div style="font-family:var(--font-mono);font-size:24px;font-weight:600;color:var(--text-heading);margin-top:4px">42</div></div>
|
||||
</div>
|
||||
<div style="font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px;margin-bottom:10px">Waiting on you</div>
|
||||
<div style="background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card);margin-bottom:20px">
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border-subtle)">
|
||||
<div style="display:flex;align-items:center;gap:8px"><sc-if value="{{ showIds }}" hint-placeholder-val="{{ true }}"><span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted)">agt-7a1d</span></sc-if><span style="font:var(--type-label);color:var(--text-heading);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">handler · migrate state to sqlite</span></div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-top:8px"><span style="flex:1;font:var(--type-body-sm);color:var(--text-body);font-style:italic">"Drop the legacy JSON store, or keep it as a read fallback?"</span><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="sm" variant="secondary" hint-size="76px,32px">Answer</x-import></div>
|
||||
</div>
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border-subtle)">
|
||||
<div style="display:flex;align-items:center;gap:8px"><sc-if value="{{ showIds }}" hint-placeholder-val="{{ true }}"><span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted)">agt-3e90</span></sc-if><span style="font:var(--type-label);color:var(--text-heading);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">wheatsite · fix build on node 22</span></div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-top:8px"><span style="flex:1;font:var(--type-body-sm);color:var(--text-body);font-style:italic">"Pin node 20 in CI, or patch esbuild?"</span><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="sm" variant="secondary" hint-size="76px,32px">Answer</x-import></div>
|
||||
</div>
|
||||
<div style="padding:14px 16px">
|
||||
<div style="display:flex;align-items:center;gap:8px"><sc-if value="{{ showIds }}" hint-placeholder-val="{{ true }}"><span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted)">agt-b241</span></sc-if><span style="font:var(--type-label);color:var(--text-heading);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">api-gateway · add rate limiting</span></div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-top:8px"><span style="flex:1;font:var(--type-body-sm);color:var(--text-body);font-style:italic">"429 body: JSON or plain text?"</span><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="sm" variant="secondary" hint-size="76px,32px">Answer</x-import></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px;margin-bottom:10px">Recent checkmarks</div>
|
||||
<div style="background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card)">
|
||||
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;min-height:44px;border-bottom:1px solid var(--border-subtle)"><span style="width:8px;height:8px;border-radius:4px;background:var(--positive);flex-shrink:0"></span><div style="flex:1;min-width:0"><div style="font:var(--type-label);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">handler · add /agents endpoint</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">done — tests pass · 14m ago</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="chevronRight" size="16" style="color:var(--ink-4)" hint-size="16px,16px"></x-import></div>
|
||||
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;min-height:44px;border-bottom:1px solid var(--border-subtle)"><span style="width:8px;height:8px;border-radius:4px;background:var(--danger);flex-shrink:0"></span><div style="flex:1;min-width:0"><div style="font:var(--type-label);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">wheatsite · refactor router</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">failed — 2 tests · 1h ago</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="chevronRight" size="16" style="color:var(--ink-4)" hint-size="16px,16px"></x-import></div>
|
||||
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;min-height:44px"><span style="width:8px;height:8px;border-radius:4px;background:var(--positive);flex-shrink:0"></span><div style="flex:1;min-width:0"><div style="font:var(--type-label);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">dotfiles · port zsh config</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">done — 12 turns · 3h ago</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="chevronRight" size="16" style="color:var(--ink-4)" hint-size="16px,16px"></x-import></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:84px;flex-shrink:0;border-top:1px solid var(--border-subtle);background:var(--surface-page);display:flex;padding:8px 12px 24px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--text-heading)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="home" size="22" strokeWidth="2" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:600">Fleet</span></div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="file" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Log</span></div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="settings" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Settings</span></div>
|
||||
</div>
|
||||
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);width:134px;height:5px;border-radius:3px;background:var(--ink-9);opacity:.9"></div>
|
||||
</div></div>
|
||||
|
||||
<!-- ============ 1b Projects-grouped ============ -->
|
||||
<div class="dv-opt" id="1b"><div class="dv-olabel"><a class="dv-oid" href="#1b">1b</a>Project-grouped — fleet organized by repo</div>
|
||||
<div data-theme="{{ theme }}" data-screen-label="1b Fleet by project" style="width:390px;height:844px;background:var(--surface-page);border-radius:48px;border:1px solid var(--border-default);box-shadow:var(--shadow-overlay);overflow:hidden;display:flex;flex-direction:column;font:var(--type-body);color:var(--text-body);position:relative;flex-shrink:0">
|
||||
<div style="height:54px;flex-shrink:0;display:flex;align-items:flex-end;justify-content:space-between;padding:0 28px 6px;font:600 14px/1 var(--font-body);color:var(--text-heading)"><span>9:41</span><span style="display:inline-flex;gap:5px;align-items:center"><svg width="16" height="12" viewBox="0 0 16 12" fill="currentColor"><rect x="0" y="7" width="3" height="5" rx="1"></rect><rect x="4.5" y="5" width="3" height="7" rx="1"></rect><rect x="9" y="2.5" width="3" height="9.5" rx="1"></rect><rect x="13" y="0" width="3" height="12" rx="1" opacity=".35"></rect></svg><svg width="24" height="12" viewBox="0 0 24 12" fill="none"><rect x="0.5" y="0.5" width="20" height="11" rx="3" stroke="currentColor" opacity=".4"></rect><rect x="2" y="2" width="14" height="8" rx="1.5" fill="currentColor"></rect><path d="M22.5 4v4a2 2 0 0 0 0-4z" fill="currentColor" opacity=".4"></path></svg></span></div>
|
||||
<div style="flex:1;overflow:auto;min-height:0;padding:8px 20px 20px">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:12px 0 16px">
|
||||
<div style="flex:1">
|
||||
<div style="font:var(--type-h3);font-family:var(--font-display);letter-spacing:var(--tracking-tight);color:var(--text-heading)">Projects</div>
|
||||
<div style="font:var(--type-body-sm);color:var(--text-muted);margin-top:2px">4 projects · 12 agents</div>
|
||||
</div>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="sm" hint-size="64px,32px">New</x-import>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card)">
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--border-subtle)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="folder" size="16" style="color:var(--ink-5)" hint-size="16px,16px"></x-import><span style="font:var(--type-label);font-weight:600;color:var(--text-heading);flex:1">handler</span><span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted)">3 agents</span><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="chevronRight" size="16" style="color:var(--ink-4)" hint-size="16px,16px"></x-import></div>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:12px 16px;min-height:44px;border-bottom:1px solid var(--border-subtle)"><span style="width:8px;height:8px;border-radius:4px;background:var(--warning);flex-shrink:0"></span><div style="flex:1;min-width:0"><div style="font:var(--type-label);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">migrate state to sqlite</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">waiting on input · 2m</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Badge" tone="warning" hint-size="auto,22px">Waiting</x-import></div>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:12px 16px;min-height:44px;border-bottom:1px solid var(--border-subtle)"><span style="width:8px;height:8px;border-radius:4px;background:var(--positive);flex-shrink:0;animation:lwPulse 2s infinite"></span><div style="flex:1;min-width:0"><div style="font:var(--type-label);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">write API docs</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">running · turn 21</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Badge" tone="positive" hint-size="auto,22px">Running</x-import></div>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:12px 16px;min-height:44px"><span style="width:8px;height:8px;border-radius:4px;background:var(--ink-4);flex-shrink:0"></span><div style="flex:1;min-width:0"><div style="font:var(--type-label);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">add /agents endpoint</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">done · 14m ago</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Badge" tone="neutral" hint-size="auto,22px">Done</x-import></div>
|
||||
</div>
|
||||
<div style="background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card)">
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--border-subtle)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="folder" size="16" style="color:var(--ink-5)" hint-size="16px,16px"></x-import><span style="font:var(--type-label);font-weight:600;color:var(--text-heading);flex:1">wheatsite</span><span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted)">2 agents</span><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="chevronRight" size="16" style="color:var(--ink-4)" hint-size="16px,16px"></x-import></div>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:12px 16px;min-height:44px;border-bottom:1px solid var(--border-subtle)"><span style="width:8px;height:8px;border-radius:4px;background:var(--warning);flex-shrink:0"></span><div style="flex:1;min-width:0"><div style="font:var(--type-label);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">fix build on node 22</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">waiting on input · 18m</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Badge" tone="warning" hint-size="auto,22px">Waiting</x-import></div>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:12px 16px;min-height:44px"><span style="width:8px;height:8px;border-radius:4px;background:var(--danger);flex-shrink:0"></span><div style="flex:1;min-width:0"><div style="font:var(--type-label);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">refactor router</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">failed — 2 tests · 1h</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Badge" tone="danger" hint-size="auto,22px">Failed</x-import></div>
|
||||
</div>
|
||||
<div style="background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card)">
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--border-subtle)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="folder" size="16" style="color:var(--ink-5)" hint-size="16px,16px"></x-import><span style="font:var(--type-label);font-weight:600;color:var(--text-heading);flex:1">dotfiles</span><span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted)">1 agent</span><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="chevronRight" size="16" style="color:var(--ink-4)" hint-size="16px,16px"></x-import></div>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:12px 16px;min-height:44px"><span style="width:8px;height:8px;border-radius:4px;background:var(--positive);flex-shrink:0;animation:lwPulse 2s infinite"></span><div style="flex:1;min-width:0"><div style="font:var(--type-label);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">port zsh config</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">running · turn 6</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Badge" tone="positive" hint-size="auto,22px">Running</x-import></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:84px;flex-shrink:0;border-top:1px solid var(--border-subtle);background:var(--surface-page);display:flex;padding:8px 12px 24px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--text-heading)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="home" size="22" strokeWidth="2" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:600">Fleet</span></div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="file" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Log</span></div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="settings" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Settings</span></div>
|
||||
</div>
|
||||
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);width:134px;height:5px;border-radius:3px;background:var(--ink-9);opacity:.9"></div>
|
||||
</div></div>
|
||||
|
||||
<!-- ============ 1c Live-log-first ============ -->
|
||||
<div class="dv-opt" id="1c"><div class="dv-olabel"><a class="dv-oid" href="#1c">1c</a>Log-first — the fleet as a live terminal feed</div>
|
||||
<div data-theme="{{ theme }}" data-screen-label="1c Fleet live feed" style="width:390px;height:844px;background:var(--surface-page);border-radius:48px;border:1px solid var(--border-default);box-shadow:var(--shadow-overlay);overflow:hidden;display:flex;flex-direction:column;font:var(--type-body);color:var(--text-body);position:relative;flex-shrink:0">
|
||||
<div style="height:54px;flex-shrink:0;display:flex;align-items:flex-end;justify-content:space-between;padding:0 28px 6px;font:600 14px/1 var(--font-body);color:var(--text-heading)"><span>9:41</span><span style="display:inline-flex;gap:5px;align-items:center"><svg width="16" height="12" viewBox="0 0 16 12" fill="currentColor"><rect x="0" y="7" width="3" height="5" rx="1"></rect><rect x="4.5" y="5" width="3" height="7" rx="1"></rect><rect x="9" y="2.5" width="3" height="9.5" rx="1"></rect><rect x="13" y="0" width="3" height="12" rx="1" opacity=".35"></rect></svg><svg width="24" height="12" viewBox="0 0 24 12" fill="none"><rect x="0.5" y="0.5" width="20" height="11" rx="3" stroke="currentColor" opacity=".4"></rect><rect x="2" y="2" width="14" height="8" rx="1.5" fill="currentColor"></rect><path d="M22.5 4v4a2 2 0 0 0 0-4z" fill="currentColor" opacity=".4"></path></svg></span></div>
|
||||
<div style="flex:1;overflow:auto;min-height:0;display:flex;flex-direction:column">
|
||||
<div style="padding:20px 20px 16px">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="font:var(--type-h3);font-family:var(--font-display);letter-spacing:var(--tracking-tight);color:var(--text-heading);flex:1">handler</div>
|
||||
<span style="display:inline-flex;align-items:center;gap:6px;font-family:var(--font-mono);font-size:12px;color:var(--positive)"><span style="width:7px;height:7px;border-radius:4px;background:var(--positive);animation:lwPulse 1.6s infinite"></span>live</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:14px">
|
||||
<span style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;border:1px solid var(--border-default);font-family:var(--font-mono);font-size:12px;color:var(--text-heading)"><span style="width:7px;height:7px;border-radius:4px;background:var(--positive)"></span>6 running</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;border:1px solid var(--border-default);font-family:var(--font-mono);font-size:12px;color:var(--text-heading)"><span style="width:7px;height:7px;border-radius:4px;background:var(--warning)"></span>3 waiting</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;border:1px solid var(--border-default);font-family:var(--font-mono);font-size:12px;color:var(--text-heading)"><span style="width:7px;height:7px;border-radius:4px;background:var(--ink-4)"></span>42 done</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;background:var(--surface-sunken);border-top:1px solid var(--border-subtle);padding:16px 20px;font-family:var(--font-mono);font-size:12.5px;line-height:2.1">
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">14:02:11</span><span style="color:var(--warning);flex:1">agt-7a1d paused — waiting for input</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:58:40</span><span style="color:var(--text-body);flex:1">agt-9c77 checkmark updated</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:51:02</span><span style="color:var(--positive);flex:1">agt-2d08 done — 34 turns, tests pass</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:44:19</span><span style="color:var(--danger);flex:1">agt-e33a error — 2 tests failed</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:39:55</span><span style="color:var(--text-body);flex:1">spawn agt-51f0 → dotfiles</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:31:07</span><span style="color:var(--warning);flex:1">agt-b241 paused — waiting for input</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:18:44</span><span style="color:var(--text-muted);flex:1">agt-9c77 tool: bash — cargo test</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:02:30</span><span style="color:var(--positive);flex:1">agt-90bc done — 12 turns</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">12:57:12</span><span style="color:var(--text-muted);flex:1">agt-51f0 tool: edit — .zshrc</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">12:49:03</span><span style="color:var(--text-body);flex:1">agt-e33a checkmark updated</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">12:40:38</span><span style="color:var(--text-body);flex:1">spawn agt-b241 → api-gateway</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:84px;flex-shrink:0;border-top:1px solid var(--border-subtle);background:var(--surface-page);display:flex;padding:8px 12px 24px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--text-heading)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="home" size="22" strokeWidth="2" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:600">Fleet</span></div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="file" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Log</span></div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="settings" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Settings</span></div>
|
||||
</div>
|
||||
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);width:134px;height:5px;border-radius:3px;background:var(--ink-9);opacity:.9"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="dv-row-label">The flow — agent detail → answer → spawn → global log</div>
|
||||
<div class="dv-opts">
|
||||
|
||||
<!-- ============ 1d Agent detail ============ -->
|
||||
<div class="dv-opt" id="1d"><div class="dv-olabel"><a class="dv-oid" href="#1d">1d</a>Agent detail — checkmark + log (segmented control works)</div>
|
||||
<div data-theme="{{ theme }}" data-screen-label="1d Agent detail" style="width:390px;height:844px;background:var(--surface-page);border-radius:48px;border:1px solid var(--border-default);box-shadow:var(--shadow-overlay);overflow:hidden;display:flex;flex-direction:column;font:var(--type-body);color:var(--text-body);position:relative;flex-shrink:0">
|
||||
<div style="height:54px;flex-shrink:0;display:flex;align-items:flex-end;justify-content:space-between;padding:0 28px 6px;font:600 14px/1 var(--font-body);color:var(--text-heading)"><span>9:41</span><span style="display:inline-flex;gap:5px;align-items:center"><svg width="16" height="12" viewBox="0 0 16 12" fill="currentColor"><rect x="0" y="7" width="3" height="5" rx="1"></rect><rect x="4.5" y="5" width="3" height="7" rx="1"></rect><rect x="9" y="2.5" width="3" height="9.5" rx="1"></rect><rect x="13" y="0" width="3" height="12" rx="1" opacity=".35"></rect></svg><svg width="24" height="12" viewBox="0 0 24 12" fill="none"><rect x="0.5" y="0.5" width="20" height="11" rx="3" stroke="currentColor" opacity=".4"></rect><rect x="2" y="2" width="14" height="8" rx="1.5" fill="currentColor"></rect><path d="M22.5 4v4a2 2 0 0 0 0-4z" fill="currentColor" opacity=".4"></path></svg></span></div>
|
||||
<div style="flex:1;overflow:auto;min-height:0;padding:8px 20px 20px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin:8px 0 16px">
|
||||
<span style="display:inline-flex;width:32px;height:32px;align-items:center;justify-content:center;margin-left:-8px;color:var(--text-heading)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="chevronRight" size="20" style="transform:rotate(180deg)" hint-size="20px,20px"></x-import></span>
|
||||
<span style="font-family:var(--font-mono);font-size:13px;color:var(--text-muted);flex:1">agt-7a1d</span>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Badge" tone="warning" hint-size="auto,22px">Waiting</x-import>
|
||||
</div>
|
||||
<div style="font:var(--type-h3);font-family:var(--font-display);letter-spacing:var(--tracking-tight);color:var(--text-heading)">Migrate agent state to sqlite</div>
|
||||
<div style="font:var(--type-body-sm);color:var(--text-muted);margin:4px 0 16px">handler · branch <span style="font-family:var(--font-mono)">agt/7a1d</span></div>
|
||||
<div style="display:flex;background:var(--surface-sunken);border-radius:8px;padding:3px;margin-bottom:16px">
|
||||
<button onClick="{{ showState }}" style="{{ segStateStyle }}">Checkmark</button>
|
||||
<button onClick="{{ showLog }}" style="{{ segLogStyle }}">Log</button>
|
||||
</div>
|
||||
<sc-if value="{{ isState }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="background:var(--surface-sunken);border:1px solid var(--border-subtle);border-radius:10px;padding:16px">
|
||||
<div style="font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px;margin-bottom:8px">Current state</div>
|
||||
<div style="font-family:var(--font-mono);font-size:13px;line-height:1.7;color:var(--text-body)">Schema written, migrations pass. Blocked on the legacy JSON store — drop it or keep as read fallback? Holding before deleting store/json.rs.</div>
|
||||
<div style="font:var(--type-caption);color:var(--text-muted);margin-top:10px">updated 2m ago</div>
|
||||
</div>
|
||||
<div style="margin-top:16px;background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card)">
|
||||
<div style="display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border-subtle)"><span style="font:var(--type-body-sm);color:var(--text-muted)">Started</span><span style="font-family:var(--font-mono);font-size:13px;color:var(--text-heading)">41m ago</span></div>
|
||||
<div style="display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border-subtle)"><span style="font:var(--type-body-sm);color:var(--text-muted)">Model</span><span style="font-family:var(--font-mono);font-size:13px;color:var(--text-heading)">claude-sonnet-4</span></div>
|
||||
<div style="display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border-subtle)"><span style="font:var(--type-body-sm);color:var(--text-muted)">Turns</span><span style="font-family:var(--font-mono);font-size:13px;color:var(--text-heading)">21</span></div>
|
||||
<div style="display:flex;justify-content:space-between;padding:12px 16px"><span style="font:var(--type-body-sm);color:var(--text-muted)">Tokens</span><span style="font-family:var(--font-mono);font-size:13px;color:var(--text-heading)">348k</span></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ isLog }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="background:var(--surface-sunken);border:1px solid var(--border-subtle);border-radius:10px;padding:16px;font-family:var(--font-mono);font-size:12.5px;line-height:2.1">
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">14:02</span><span style="color:var(--warning)">paused — waiting for input</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:57</span><span style="color:var(--text-body)">checkmark updated</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:52</span><span style="color:var(--text-muted)">tool: bash — sqlite3 .schema</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:48</span><span style="color:var(--text-muted)">tool: edit — store/sqlite.rs</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:40</span><span style="color:var(--text-muted)">tool: bash — cargo test store</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:29</span><span style="color:var(--text-body)">checkmark updated</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">13:21</span><span style="color:var(--text-muted)">tool: read — store/json.rs</span></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<div style="display:flex;gap:10px;margin-top:20px">
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="lg" style="flex:1" hint-size="100%,48px">Answer</x-import>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="lg" variant="secondary" hint-size="80px,48px">Pause</x-import>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="lg" variant="danger" hint-size="70px,48px">Kill</x-import>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:34px;flex-shrink:0"></div>
|
||||
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);width:134px;height:5px;border-radius:3px;background:var(--ink-9);opacity:.9"></div>
|
||||
</div></div>
|
||||
|
||||
<!-- ============ 1e Answer & resume ============ -->
|
||||
<div class="dv-opt" id="1e"><div class="dv-olabel"><a class="dv-oid" href="#1e">1e</a>Answer & resume — quick replies are tappable</div>
|
||||
<div data-theme="{{ theme }}" data-screen-label="1e Answer and resume" style="width:390px;height:844px;background:var(--surface-page);border-radius:48px;border:1px solid var(--border-default);box-shadow:var(--shadow-overlay);overflow:hidden;display:flex;flex-direction:column;font:var(--type-body);color:var(--text-body);position:relative;flex-shrink:0">
|
||||
<div style="height:54px;flex-shrink:0;display:flex;align-items:flex-end;justify-content:space-between;padding:0 28px 6px;font:600 14px/1 var(--font-body);color:var(--text-heading)"><span>9:41</span><span style="display:inline-flex;gap:5px;align-items:center"><svg width="16" height="12" viewBox="0 0 16 12" fill="currentColor"><rect x="0" y="7" width="3" height="5" rx="1"></rect><rect x="4.5" y="5" width="3" height="7" rx="1"></rect><rect x="9" y="2.5" width="3" height="9.5" rx="1"></rect><rect x="13" y="0" width="3" height="12" rx="1" opacity=".35"></rect></svg><svg width="24" height="12" viewBox="0 0 24 12" fill="none"><rect x="0.5" y="0.5" width="20" height="11" rx="3" stroke="currentColor" opacity=".4"></rect><rect x="2" y="2" width="14" height="8" rx="1.5" fill="currentColor"></rect><path d="M22.5 4v4a2 2 0 0 0 0-4z" fill="currentColor" opacity=".4"></path></svg></span></div>
|
||||
<div style="flex:1;overflow:auto;min-height:0;padding:8px 20px 20px;display:flex;flex-direction:column">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin:8px 0 16px">
|
||||
<span style="display:inline-flex;width:32px;height:32px;align-items:center;justify-content:center;margin-left:-8px;color:var(--text-heading)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="chevronRight" size="20" style="transform:rotate(180deg)" hint-size="20px,20px"></x-import></span>
|
||||
<span style="font-family:var(--font-mono);font-size:13px;color:var(--text-muted);flex:1">agt-7a1d</span>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Badge" tone="warning" hint-size="auto,22px">Waiting</x-import>
|
||||
</div>
|
||||
<div style="font:var(--type-h3);font-family:var(--font-display);letter-spacing:var(--tracking-tight);color:var(--text-heading);margin-bottom:16px">Agent needs input</div>
|
||||
<div style="background:var(--surface-sunken);border:1px solid var(--border-subtle);border-radius:10px;padding:16px;margin-bottom:20px">
|
||||
<div style="font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px;margin-bottom:8px">Question · 2m ago</div>
|
||||
<div style="font-family:var(--font-mono);font-size:13px;line-height:1.7;color:var(--text-body)">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?</div>
|
||||
</div>
|
||||
<div style="font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px;margin-bottom:10px">Quick replies</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px">
|
||||
<sc-for list="{{ quickReplies }}" as="qr" hint-placeholder-count="3">
|
||||
<button onClick="{{ qr.pick }}" style="{{ qr.style }}">{{ qr.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="margin-top:auto;display:flex;flex-direction:column;gap:12px">
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Input" placeholder="Or type a reply…" size="lg" hint-size="100%,48px"></x-import>
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="lg" style="width:100%" hint-size="100%,48px">Send & resume</x-import>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:34px;flex-shrink:0"></div>
|
||||
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);width:134px;height:5px;border-radius:3px;background:var(--ink-9);opacity:.9"></div>
|
||||
</div></div>
|
||||
|
||||
<!-- ============ 1f Spawn agent ============ -->
|
||||
<div class="dv-opt" id="1f"><div class="dv-olabel"><a class="dv-oid" href="#1f">1f</a>Spawn agent — toggles work</div>
|
||||
<div data-theme="{{ theme }}" data-screen-label="1f Spawn agent" style="width:390px;height:844px;background:var(--surface-page);border-radius:48px;border:1px solid var(--border-default);box-shadow:var(--shadow-overlay);overflow:hidden;display:flex;flex-direction:column;font:var(--type-body);color:var(--text-body);position:relative;flex-shrink:0">
|
||||
<div style="height:54px;flex-shrink:0;display:flex;align-items:flex-end;justify-content:space-between;padding:0 28px 6px;font:600 14px/1 var(--font-body);color:var(--text-heading)"><span>9:41</span><span style="display:inline-flex;gap:5px;align-items:center"><svg width="16" height="12" viewBox="0 0 16 12" fill="currentColor"><rect x="0" y="7" width="3" height="5" rx="1"></rect><rect x="4.5" y="5" width="3" height="7" rx="1"></rect><rect x="9" y="2.5" width="3" height="9.5" rx="1"></rect><rect x="13" y="0" width="3" height="12" rx="1" opacity=".35"></rect></svg><svg width="24" height="12" viewBox="0 0 24 12" fill="none"><rect x="0.5" y="0.5" width="20" height="11" rx="3" stroke="currentColor" opacity=".4"></rect><rect x="2" y="2" width="14" height="8" rx="1.5" fill="currentColor"></rect><path d="M22.5 4v4a2 2 0 0 0 0-4z" fill="currentColor" opacity=".4"></path></svg></span></div>
|
||||
<div style="flex:1;overflow:auto;min-height:0;padding:8px 20px 20px;display:flex;flex-direction:column">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin:8px 0 16px">
|
||||
<span style="display:inline-flex;width:32px;height:32px;align-items:center;justify-content:center;margin-left:-8px;color:var(--text-heading)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="x" size="20" hint-size="20px,20px"></x-import></span>
|
||||
<span style="font:var(--type-label);font-weight:600;color:var(--text-heading);flex:1">New agent</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Select" label="Project" options="{{ projectOpts }}" value="handler" size="lg" hint-size="100%,72px"></x-import>
|
||||
<div>
|
||||
<div style="font:var(--type-label);color:var(--text-heading);margin-bottom:6px">Task</div>
|
||||
<textarea placeholder="What should this agent do?" style="width:100%;box-sizing:border-box;height:120px;resize:none;padding:12px 14px;border:1px solid var(--border-default);border-radius:8px;background:var(--surface-page);font:var(--type-body);font-family:var(--font-body);color:var(--text-body);outline:none">Add a /agents/:id/resume endpoint. Follow the existing route patterns. Update the OpenAPI spec.</textarea>
|
||||
<div style="font:var(--type-caption);color:var(--text-muted);margin-top:6px">Runs isolated on a fresh branch.</div>
|
||||
</div>
|
||||
<div style="background:var(--surface-page);border:1px solid var(--border-subtle);border-radius:10px;box-shadow:var(--shadow-card)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;min-height:44px;border-bottom:1px solid var(--border-subtle)"><div><div style="font:var(--type-label);color:var(--text-heading)">Auto-approve edits</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">Skip file-edit confirmations</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Switch" checked="{{ swEdits }}" onChange="{{ toggleEdits }}" hint-size="44px,24px"></x-import></div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;min-height:44px"><div><div style="font:var(--type-label);color:var(--text-heading)">Run tests on done</div><div style="font:var(--type-caption);color:var(--text-muted);margin-top:2px">Checkmark fails if tests fail</div></div><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Switch" checked="{{ swTests }}" onChange="{{ toggleTests }}" hint-size="44px,24px"></x-import></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:auto">
|
||||
<x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Button" size="lg" style="width:100%" hint-size="100%,48px">Spawn agent</x-import>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:34px;flex-shrink:0"></div>
|
||||
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);width:134px;height:5px;border-radius:3px;background:var(--ink-9);opacity:.9"></div>
|
||||
</div></div>
|
||||
|
||||
<!-- ============ 1g Global log ============ -->
|
||||
<div class="dv-opt" id="1g"><div class="dv-olabel"><a class="dv-oid" href="#1g">1g</a>Global log — filters work</div>
|
||||
<div data-theme="{{ theme }}" data-screen-label="1g Global log" style="width:390px;height:844px;background:var(--surface-page);border-radius:48px;border:1px solid var(--border-default);box-shadow:var(--shadow-overlay);overflow:hidden;display:flex;flex-direction:column;font:var(--type-body);color:var(--text-body);position:relative;flex-shrink:0">
|
||||
<div style="height:54px;flex-shrink:0;display:flex;align-items:flex-end;justify-content:space-between;padding:0 28px 6px;font:600 14px/1 var(--font-body);color:var(--text-heading)"><span>9:41</span><span style="display:inline-flex;gap:5px;align-items:center"><svg width="16" height="12" viewBox="0 0 16 12" fill="currentColor"><rect x="0" y="7" width="3" height="5" rx="1"></rect><rect x="4.5" y="5" width="3" height="7" rx="1"></rect><rect x="9" y="2.5" width="3" height="9.5" rx="1"></rect><rect x="13" y="0" width="3" height="12" rx="1" opacity=".35"></rect></svg><svg width="24" height="12" viewBox="0 0 24 12" fill="none"><rect x="0.5" y="0.5" width="20" height="11" rx="3" stroke="currentColor" opacity=".4"></rect><rect x="2" y="2" width="14" height="8" rx="1.5" fill="currentColor"></rect><path d="M22.5 4v4a2 2 0 0 0 0-4z" fill="currentColor" opacity=".4"></path></svg></span></div>
|
||||
<div style="flex:1;overflow:auto;min-height:0;display:flex;flex-direction:column">
|
||||
<div style="padding:20px 20px 16px">
|
||||
<div style="font:var(--type-h3);font-family:var(--font-display);letter-spacing:var(--tracking-tight);color:var(--text-heading)">Log</div>
|
||||
<div style="display:flex;gap:8px;margin-top:14px">
|
||||
<button onClick="{{ setFilterAll }}" style="{{ fAllStyle }}">All</button>
|
||||
<button onClick="{{ setFilterHandler }}" style="{{ fHandlerStyle }}">handler</button>
|
||||
<button onClick="{{ setFilterErrors }}" style="{{ fErrStyle }}">Errors</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 20px 8px;font:var(--type-overline);letter-spacing:var(--tracking-wide);text-transform:uppercase;color:var(--text-muted);font-size:11px">Today</div>
|
||||
<div style="flex:1;background:var(--surface-sunken);border-top:1px solid var(--border-subtle);padding:16px 20px;font-family:var(--font-mono);font-size:12.5px;line-height:2.1">
|
||||
<sc-for list="{{ logEntries }}" as="e" hint-placeholder-count="8">
|
||||
<div style="display:flex;gap:10px"><span style="color:var(--ink-4)">{{ e.t }}</span><span style="color:var(--ink-6);width:66px;flex-shrink:0">{{ e.id }}</span><span style="color: {{ e.color }}; flex:1">{{ e.msg }}</span></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:84px;flex-shrink:0;border-top:1px solid var(--border-subtle);background:var(--surface-page);display:flex;padding:8px 12px 24px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="home" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Fleet</span></div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--text-heading)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="file" size="22" strokeWidth="2" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:600">Log</span></div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0;color:var(--ink-4)"><x-import component-from-global-scope="LeeworksDesignSystem_b3e560.Icon" name="settings" size="22" hint-size="22px,22px"></x-import><span style="font:var(--type-caption);font-weight:500">Settings</span></div>
|
||||
</div>
|
||||
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);width:134px;height:5px;border-radius:3px;background:var(--ink-9);opacity:.9"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<p class="dv-next">Try next: "commit to <a class="dv-oid" href="#1a">1a</a> and wire the screens into one tappable prototype" · "merge <a class="dv-oid" href="#1b">1b</a>'s project cards into <a class="dv-oid" href="#1a">1a</a>" · "show a dark-mode pass" (there's a Dark mode tweak too)</p>
|
||||
</section>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"darkMode":{"editor":"boolean","default":false,"tsType":"boolean","section":"Theme"},"showAgentIds":{"editor":"boolean","default":true,"tsType":"boolean","section":"Content"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { detailTab: "state", quickPick: null, swEdits: false, swTests: true, logFilter: "all", screen: "fleet", answered: false, swPushWait: true, swPushFail: true };
|
||||
renderVals() {
|
||||
const s = this.state;
|
||||
const seg = (on) => ({ flex: 1, border: "none", cursor: "pointer", padding: "8px 0", borderRadius: "6px", font: "600 13px/1 var(--font-body)", background: on ? "var(--surface-page)" : "transparent", color: on ? "var(--text-heading)" : "var(--text-muted)", boxShadow: on ? "var(--shadow-card)" : "none", transition: "all 140ms ease-out" });
|
||||
const chip = (on) => ({ display: "inline-flex", alignItems: "center", padding: "8px 14px", borderRadius: "999px", cursor: "pointer", font: "500 13px/1 var(--font-body)", border: on ? "1px solid var(--border-strong)" : "1px solid var(--border-default)", background: on ? "var(--interactive)" : "var(--surface-page)", color: on ? "#fff" : "var(--text-body)", transition: "all 140ms ease-out" });
|
||||
const allLog = [
|
||||
{ t: "14:02:11", id: "agt-7a1d", p: "handler", msg: "paused — waiting for input", color: "var(--warning)", err: false },
|
||||
{ t: "13:58:40", id: "agt-9c77", p: "handler", msg: "checkmark updated", color: "var(--text-body)", err: false },
|
||||
{ t: "13:51:02", id: "agt-2d08", p: "handler", msg: "done — 34 turns, tests pass", color: "var(--positive)", err: false },
|
||||
{ t: "13:44:19", id: "agt-e33a", p: "wheatsite", msg: "error — 2 tests failed", color: "var(--danger)", err: true },
|
||||
{ t: "13:39:55", id: "agt-51f0", p: "dotfiles", msg: "spawned → dotfiles", color: "var(--text-body)", err: false },
|
||||
{ t: "13:31:07", id: "agt-b241", p: "api-gateway", msg: "paused — waiting for input", color: "var(--warning)", err: false },
|
||||
{ t: "13:18:44", id: "agt-9c77", p: "handler", msg: "tool: bash — cargo test", color: "var(--text-muted)", err: false },
|
||||
{ t: "13:02:30", id: "agt-90bc", p: "dotfiles", msg: "done — 12 turns", color: "var(--positive)", err: false },
|
||||
{ t: "12:57:12", id: "agt-51f0", p: "dotfiles", msg: "tool: edit — .zshrc", color: "var(--text-muted)", err: false },
|
||||
{ t: "12:49:03", id: "agt-e33a", p: "wheatsite", msg: "checkmark updated", color: "var(--text-body)", err: false },
|
||||
{ t: "12:40:38", id: "agt-b241", p: "api-gateway", msg: "spawned → api-gateway", color: "var(--text-body)", err: false }
|
||||
];
|
||||
const logEntries = allLog.filter(e => s.logFilter === "all" ? true : s.logFilter === "errors" ? e.err : e.p === "handler");
|
||||
const qrLabels = ["Drop it", "Keep as fallback", "Ask me later"];
|
||||
const quickReplies = qrLabels.map((label, i) => ({ label, pick: () => this.setState({ quickPick: i }), style: chip(s.quickPick === i) }));
|
||||
const go = (screen) => () => this.setState({ screen });
|
||||
return {
|
||||
theme: this.props.darkMode ? "dark" : "light",
|
||||
isFleet: s.screen === "fleet", isDetail: s.screen === "detail", isAnswer: s.screen === "answer", isSpawn: s.screen === "spawn", isLogScreen: s.screen === "log", isSettings: s.screen === "settings",
|
||||
goFleet: go("fleet"), goDetail: go("detail"), goAnswer: go("answer"), goSpawn: go("spawn"), goLog: go("log"), goSettings: go("settings"),
|
||||
sendResume: () => this.setState({ answered: true, screen: "detail", detailTab: "state" }),
|
||||
spawnGo: () => this.setState({ screen: "fleet" }),
|
||||
notAnswered: !s.answered,
|
||||
agentTone: s.answered ? "positive" : "warning",
|
||||
agentStatus: s.answered ? "Running" : "Waiting",
|
||||
agentStateText: s.answered ? "Answer received: keep the JSON store as a read-only fallback for one release. Deprecating writes now; removal ticket filed for next cycle." : "Schema written, migrations pass. Blocked on the legacy JSON store — drop it or keep as read fallback? Holding before deleting store/json.rs.",
|
||||
swPushWait: s.swPushWait, swPushFail: s.swPushFail,
|
||||
togglePushWait: (v) => this.setState({ swPushWait: !!v }), togglePushFail: (v) => this.setState({ swPushFail: !!v }),
|
||||
showIds: this.props.showAgentIds ?? true,
|
||||
isState: s.detailTab === "state", isLog: s.detailTab === "log",
|
||||
showState: () => this.setState({ detailTab: "state" }), showLog: () => this.setState({ detailTab: "log" }),
|
||||
segStateStyle: seg(s.detailTab === "state"), segLogStyle: seg(s.detailTab === "log"),
|
||||
quickReplies,
|
||||
swEdits: s.swEdits, swTests: s.swTests,
|
||||
toggleEdits: (v) => this.setState({ swEdits: !!v }), toggleTests: (v) => this.setState({ swTests: !!v }),
|
||||
projectOpts: ["handler", "wheatsite", "dotfiles", "api-gateway"],
|
||||
setFilterAll: () => this.setState({ logFilter: "all" }), setFilterHandler: () => this.setState({ logFilter: "handler" }), setFilterErrors: () => this.setState({ logFilter: "errors" }),
|
||||
fAllStyle: chip(s.logFilter === "all"), fHandlerStyle: chip(s.logFilter === "handler"), fErrStyle: chip(s.logFilter === "errors"),
|
||||
logEntries
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
{
|
||||
"plugins": [
|
||||
"react",
|
||||
"import"
|
||||
],
|
||||
"rules": {
|
||||
"react/forbid-elements": [
|
||||
"warn",
|
||||
{
|
||||
"forbid": []
|
||||
}
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"warn",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"components/display/**",
|
||||
"components/feedback/**",
|
||||
"components/forms/**",
|
||||
"components/navigation/**",
|
||||
"ui_kits/dashboard/**",
|
||||
"ui_kits/mobile/**",
|
||||
"ui_kits/website/**"
|
||||
],
|
||||
"message": "Import design-system components from 'index.js', not component internals."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-restricted-syntax": [
|
||||
"warn",
|
||||
{
|
||||
"selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]",
|
||||
"message": "Raw hex color — use a design-system color token via var()."
|
||||
},
|
||||
{
|
||||
"selector": "Literal[value=/\\b\\d+px\\b/]",
|
||||
"message": "Raw px value — use a design-system spacing token via var()."
|
||||
},
|
||||
{
|
||||
"selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Outfit|Figtree|Spline Sans Mono))/i]",
|
||||
"message": "Font not provided by the design system. Available: Outfit, Figtree, Spline Sans Mono."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute > JSXIdentifier[name!=/^(?:tone|children|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Badge> doesn't accept that prop. Declared props: tone, children, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|positive|warning|danger|inverse)$/]",
|
||||
"message": "<Badge> tone must be one of 'neutral' | 'positive' | 'warning' | 'danger' | 'inverse'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:variant|size|disabled|icon|children|onClick|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Button> doesn't accept that prop. Declared props: variant, size, disabled, icon, children, onClick, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:primary|secondary|ghost|danger)$/]",
|
||||
"message": "<Button> variant must be one of 'primary' | 'secondary' | 'ghost' | 'danger'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md|lg)$/]",
|
||||
"message": "<Button> size must be one of 'sm' | 'md' | 'lg'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Card'] > JSXAttribute > JSXIdentifier[name!=/^(?:title|subtitle|actions|footer|variant|padding|children|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Card> doesn't accept that prop. Declared props: title, subtitle, actions, footer, variant, padding, children, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Card'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:default|sunken|outline)$/]",
|
||||
"message": "<Card> variant must be one of 'default' | 'sunken' | 'outline'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Checkbox'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|checked|defaultChecked|onChange|disabled|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Checkbox> doesn't accept that prop. Declared props: label, checked, defaultChecked, onChange, disabled, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Dialog'] > JSXAttribute > JSXIdentifier[name!=/^(?:open|title|description|actions|onClose|width|children|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Dialog> doesn't accept that prop. Declared props: open, title, description, actions, onClose, width, children, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Icon'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|size|strokeWidth|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Icon> doesn't accept that prop. Declared props: name, size, strokeWidth, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Icon'] > JSXAttribute[name.name='name'] > Literal[value!=/^(?:dashboard|file|users|settings|search|bell|plus|check|chart|folder|chevronDown|chevronRight|more|arrowUpRight|x|menu|home|inbox|user)$/]",
|
||||
"message": "<Icon> name must be one of 'dashboard' | 'file' | 'users' | 'settings' | 'search' | 'bell' | 'plus' | 'check' | 'chart' | 'folder' | 'chevronDown' | 'chevronRight' | 'more' | 'arrowUpRight' | 'x' | 'menu' | 'home' | 'inbox' | 'user'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute > JSXIdentifier[name!=/^(?:size|variant|disabled|label|children|onClick|style|key|ref|className|style|children)$/]",
|
||||
"message": "<IconButton> doesn't accept that prop. Declared props: size, variant, disabled, label, children, onClick, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md|lg)$/]",
|
||||
"message": "<IconButton> size must be one of 'sm' | 'md' | 'lg'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:ghost|outline|primary)$/]",
|
||||
"message": "<IconButton> variant must be one of 'ghost' | 'outline' | 'primary'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Input'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|hint|error|size|prefix|suffix|placeholder|value|onChange|type|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Input> doesn't accept that prop. Declared props: label, hint, error, size, prefix, suffix, placeholder, value, onChange, type, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Input'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md|lg)$/]",
|
||||
"message": "<Input> size must be one of 'sm' | 'md' | 'lg'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Radio'] > JSXAttribute > JSXIdentifier[name!=/^(?:options|value|defaultValue|onChange|name|disabled|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Radio> doesn't accept that prop. Declared props: options, value, defaultValue, onChange, name, disabled, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Select'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|hint|options|size|value|onChange|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Select> doesn't accept that prop. Declared props: label, hint, options, size, value, onChange, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Select'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md|lg)$/]",
|
||||
"message": "<Select> size must be one of 'sm' | 'md' | 'lg'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Switch'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|checked|defaultChecked|onChange|disabled|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Switch> doesn't accept that prop. Declared props: label, checked, defaultChecked, onChange, disabled, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tabs'] > JSXAttribute > JSXIdentifier[name!=/^(?:tabs|value|defaultValue|onChange|variant|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Tabs> doesn't accept that prop. Declared props: tabs, value, defaultValue, onChange, variant, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tabs'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:underline|segmented)$/]",
|
||||
"message": "<Tabs> variant must be one of 'underline' | 'segmented'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute > JSXIdentifier[name!=/^(?:onRemove|children|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Tag> doesn't accept that prop. Declared props: onRemove, children, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Toast'] > JSXAttribute > JSXIdentifier[name!=/^(?:tone|title|description|onDismiss|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Toast> doesn't accept that prop. Declared props: tone, title, description, onDismiss, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Toast'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|positive|danger)$/]",
|
||||
"message": "<Toast> tone must be one of 'neutral' | 'positive' | 'danger'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tooltip'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|side|children|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Tooltip> doesn't accept that prop. Declared props: label, side, children, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tooltip'] > JSXAttribute[name.name='side'] > Literal[value!=/^(?:top|bottom|left|right)$/]",
|
||||
"message": "<Tooltip> side must be one of 'top' | 'bottom' | 'left' | 'right'."
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/index.js"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-omelette": {
|
||||
"components": {
|
||||
"Badge": {
|
||||
"replaces": []
|
||||
},
|
||||
"Button": {
|
||||
"replaces": []
|
||||
},
|
||||
"Card": {
|
||||
"replaces": []
|
||||
},
|
||||
"Checkbox": {
|
||||
"replaces": []
|
||||
},
|
||||
"Dialog": {
|
||||
"replaces": []
|
||||
},
|
||||
"Icon": {
|
||||
"replaces": []
|
||||
},
|
||||
"IconButton": {
|
||||
"replaces": []
|
||||
},
|
||||
"Input": {
|
||||
"replaces": []
|
||||
},
|
||||
"Radio": {
|
||||
"replaces": []
|
||||
},
|
||||
"Select": {
|
||||
"replaces": []
|
||||
},
|
||||
"Switch": {
|
||||
"replaces": []
|
||||
},
|
||||
"Tabs": {
|
||||
"replaces": []
|
||||
},
|
||||
"Tag": {
|
||||
"replaces": []
|
||||
},
|
||||
"Toast": {
|
||||
"replaces": []
|
||||
},
|
||||
"Tooltip": {
|
||||
"replaces": []
|
||||
}
|
||||
},
|
||||
"tokens": [
|
||||
"--border-default",
|
||||
"--border-strong",
|
||||
"--border-subtle",
|
||||
"--content-max",
|
||||
"--control-h-lg",
|
||||
"--control-h-md",
|
||||
"--control-h-sm",
|
||||
"--control-pad-x-lg",
|
||||
"--control-pad-x-md",
|
||||
"--control-pad-x-sm",
|
||||
"--danger",
|
||||
"--danger-tint",
|
||||
"--dur-base",
|
||||
"--dur-fast",
|
||||
"--dur-slow",
|
||||
"--ease-out",
|
||||
"--focus-ring",
|
||||
"--font-body",
|
||||
"--font-display",
|
||||
"--font-mono",
|
||||
"--gutter",
|
||||
"--ink-0",
|
||||
"--ink-1",
|
||||
"--ink-2",
|
||||
"--ink-3",
|
||||
"--ink-4",
|
||||
"--ink-5",
|
||||
"--ink-6",
|
||||
"--ink-7",
|
||||
"--ink-8",
|
||||
"--ink-9",
|
||||
"--interactive",
|
||||
"--interactive-hover",
|
||||
"--interactive-press",
|
||||
"--leading-normal",
|
||||
"--leading-snug",
|
||||
"--leading-tight",
|
||||
"--page-max",
|
||||
"--positive",
|
||||
"--positive-tint",
|
||||
"--radius-lg",
|
||||
"--radius-md",
|
||||
"--radius-pill",
|
||||
"--radius-sm",
|
||||
"--radius-xl",
|
||||
"--shadow-card",
|
||||
"--shadow-focus",
|
||||
"--shadow-overlay",
|
||||
"--shadow-raised",
|
||||
"--signal",
|
||||
"--signal-strong",
|
||||
"--signal-tint",
|
||||
"--space-1",
|
||||
"--space-10",
|
||||
"--space-11",
|
||||
"--space-2",
|
||||
"--space-3",
|
||||
"--space-4",
|
||||
"--space-5",
|
||||
"--space-6",
|
||||
"--space-7",
|
||||
"--space-8",
|
||||
"--space-9",
|
||||
"--surface-card",
|
||||
"--surface-inverse",
|
||||
"--surface-page",
|
||||
"--surface-raised",
|
||||
"--surface-sunken",
|
||||
"--text-2xl",
|
||||
"--text-3xl",
|
||||
"--text-4xl",
|
||||
"--text-base",
|
||||
"--text-body",
|
||||
"--text-heading",
|
||||
"--text-inverse",
|
||||
"--text-lg",
|
||||
"--text-link",
|
||||
"--text-md",
|
||||
"--text-muted",
|
||||
"--text-sm",
|
||||
"--text-xl",
|
||||
"--text-xs",
|
||||
"--tracking-normal",
|
||||
"--tracking-tight",
|
||||
"--tracking-wide",
|
||||
"--type-body",
|
||||
"--type-body-sm",
|
||||
"--type-caption",
|
||||
"--type-display",
|
||||
"--type-h1",
|
||||
"--type-h2",
|
||||
"--type-h3",
|
||||
"--type-h4",
|
||||
"--type-label",
|
||||
"--type-mono",
|
||||
"--type-overline",
|
||||
"--warning",
|
||||
"--warning-tint",
|
||||
"--weight-bold",
|
||||
"--weight-heavy",
|
||||
"--weight-medium",
|
||||
"--weight-regular",
|
||||
"--weight-semibold",
|
||||
"--white"
|
||||
],
|
||||
"tokenKinds": {
|
||||
"--white": "color",
|
||||
"--ink-0": "color",
|
||||
"--ink-1": "color",
|
||||
"--ink-2": "color",
|
||||
"--ink-3": "color",
|
||||
"--ink-4": "color",
|
||||
"--ink-5": "color",
|
||||
"--ink-6": "color",
|
||||
"--ink-7": "color",
|
||||
"--ink-8": "color",
|
||||
"--ink-9": "color",
|
||||
"--signal": "color",
|
||||
"--signal-strong": "color",
|
||||
"--signal-tint": "color",
|
||||
"--positive": "color",
|
||||
"--positive-tint": "color",
|
||||
"--warning": "color",
|
||||
"--warning-tint": "color",
|
||||
"--danger": "color",
|
||||
"--danger-tint": "color",
|
||||
"--surface-page": "color",
|
||||
"--surface-raised": "color",
|
||||
"--surface-sunken": "color",
|
||||
"--surface-card": "color",
|
||||
"--surface-inverse": "color",
|
||||
"--text-heading": "font",
|
||||
"--text-body": "font",
|
||||
"--text-muted": "font",
|
||||
"--text-inverse": "font",
|
||||
"--text-link": "font",
|
||||
"--border-subtle": "color",
|
||||
"--border-default": "color",
|
||||
"--border-strong": "color",
|
||||
"--interactive": "color",
|
||||
"--interactive-hover": "color",
|
||||
"--interactive-press": "color",
|
||||
"--focus-ring": "color",
|
||||
"--font-display": "font",
|
||||
"--font-body": "font",
|
||||
"--font-mono": "font",
|
||||
"--text-xs": "font",
|
||||
"--text-sm": "font",
|
||||
"--text-base": "font",
|
||||
"--text-md": "font",
|
||||
"--text-lg": "font",
|
||||
"--text-xl": "font",
|
||||
"--text-2xl": "font",
|
||||
"--text-3xl": "font",
|
||||
"--text-4xl": "font",
|
||||
"--leading-tight": "font",
|
||||
"--leading-snug": "font",
|
||||
"--leading-normal": "font",
|
||||
"--tracking-tight": "font",
|
||||
"--tracking-normal": "font",
|
||||
"--tracking-wide": "font",
|
||||
"--weight-regular": "font",
|
||||
"--weight-medium": "font",
|
||||
"--weight-semibold": "font",
|
||||
"--weight-bold": "font",
|
||||
"--weight-heavy": "font",
|
||||
"--type-display": "font",
|
||||
"--type-h1": "font",
|
||||
"--type-h2": "font",
|
||||
"--type-h3": "font",
|
||||
"--type-h4": "font",
|
||||
"--type-body": "font",
|
||||
"--type-body-sm": "font",
|
||||
"--type-label": "font",
|
||||
"--type-caption": "font",
|
||||
"--type-overline": "font",
|
||||
"--type-mono": "font",
|
||||
"--space-1": "spacing",
|
||||
"--space-2": "spacing",
|
||||
"--space-3": "spacing",
|
||||
"--space-4": "spacing",
|
||||
"--space-5": "spacing",
|
||||
"--space-6": "spacing",
|
||||
"--space-7": "spacing",
|
||||
"--space-8": "spacing",
|
||||
"--space-9": "spacing",
|
||||
"--space-10": "spacing",
|
||||
"--space-11": "spacing",
|
||||
"--control-h-sm": "spacing",
|
||||
"--control-h-md": "spacing",
|
||||
"--control-h-lg": "spacing",
|
||||
"--control-pad-x-sm": "spacing",
|
||||
"--control-pad-x-md": "spacing",
|
||||
"--control-pad-x-lg": "spacing",
|
||||
"--page-max": "spacing",
|
||||
"--content-max": "spacing",
|
||||
"--gutter": "spacing",
|
||||
"--radius-sm": "radius",
|
||||
"--radius-md": "radius",
|
||||
"--radius-lg": "radius",
|
||||
"--radius-xl": "radius",
|
||||
"--radius-pill": "radius",
|
||||
"--shadow-card": "shadow",
|
||||
"--shadow-raised": "shadow",
|
||||
"--shadow-overlay": "shadow",
|
||||
"--shadow-focus": "shadow",
|
||||
"--ease-out": "other",
|
||||
"--dur-fast": "other",
|
||||
"--dur-base": "other",
|
||||
"--dur-slow": "other"
|
||||
},
|
||||
"fontFamilies": [
|
||||
"Figtree",
|
||||
"Outfit",
|
||||
"Spline Sans Mono"
|
||||
]
|
||||
}
|
||||
}
|
||||
+2759
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,57 @@
|
||||
# Leeworks Design System
|
||||
|
||||
**Leeworks Systems** — a from-scratch design system built July 2026. No existing codebase, Figma, or brand assets were provided; everything here was authored fresh from the brief: *modern, bright whites, near-monochrome, explicitly avoiding common "AI" color themes (no bluish-purple gradients, no glassmorphism)*.
|
||||
|
||||
Personality: **crisp & professional**, with a **geometric & friendly** typographic voice. Softly rounded shapes (6–10px). Light-first with full dark tokens. Comfortable density.
|
||||
|
||||
## Sources
|
||||
None provided. Fonts are Google Fonts substitutes (see Caveats). No logo was provided — the brand renders as plain type ("Leeworks") wherever a mark would go.
|
||||
|
||||
## CONTENT FUNDAMENTALS
|
||||
- **Voice:** plain, confident, specific. Short sentences. Verbs first in CTAs ("Create a workspace", "Invite your team", "View report").
|
||||
- **Casing:** Sentence case everywhere — headings, buttons, labels, nav. Never Title Case, never ALL CAPS except tiny overline labels (letter-spaced, e.g. "OVERVIEW").
|
||||
- **Person:** "you/your" to the user; "we" only when Leeworks itself acts ("We'll email you a receipt"). Never "I".
|
||||
- **Numbers & data:** tabular figures in mono (`--font-mono`) for metrics, IDs, timestamps. Units spelled tight: "12.4k", "3m ago".
|
||||
- **Emoji:** never in product UI. None.
|
||||
- **Microcopy vibe:** helpful, unhurried, no exclamation marks. Empty states state the fact + one action: "No reports yet. Create your first report."
|
||||
- Examples: "Everything your team ships, in one place." / "Save changes" / "This can't be undone." / "Last synced 2m ago".
|
||||
|
||||
## VISUAL FOUNDATIONS
|
||||
- **Color:** bright white pages (`--surface-page: #fff`), a warm-neutral ink ramp (`--ink-0…9`) doing nearly all the work. Primary interactive color is **ink black** (`--interactive: #141413`) — buttons, links, focus. One restrained non-neutral: a deep leaf green `--signal` (#1d6b45), used sparingly for positive signals and small moments of life. Status colors are muted (green/ochre/brick), always paired with a tint surface. No gradients anywhere.
|
||||
- **Type:** Outfit for display/headings (geometric, friendly terminals), Figtree for body/UI, Spline Sans Mono for data & code. Headings tight (-0.02em, 1.1 leading); body 15px/1.55. Overlines are 12px Outfit semibold, +0.08em, uppercase.
|
||||
- **Spacing:** 4px base scale (`--space-1…11`), comfortable density (~4/10). Controls: 32/40/48px heights. Page max 1200px, prose max 720px, 24px gutters.
|
||||
- **Backgrounds:** flat bright white; sections separated by hairline borders (`--border-subtle`) or a faint sunken panel (`--surface-sunken: #f6f6f4`) — never gradients, textures, or imagery washes. Marketing hero = white with big ink type.
|
||||
- **Borders:** 1px hairlines, `--border-subtle` (#ececea) inside cards/dividers, `--border-default` (#dcdcd8) on inputs. `--border-strong` (ink) marks selected states.
|
||||
- **Shadows:** whisper-quiet. Cards: `--shadow-card` (1px). Popovers/menus: `--shadow-raised`. Dialogs: `--shadow-overlay`. No inner shadows, no colored shadows.
|
||||
- **Radii:** 6px small (badges, inputs' inner bits), 8px controls, 10px cards, 16px dialogs/large panels, pill for tags & switches. Never fully-square, never blob-round on containers.
|
||||
- **Cards:** white, 1px `--border-subtle`, 10px radius, `--shadow-card`, 20–24px padding. Sunken variant swaps white for `--ink-1` and drops the shadow.
|
||||
- **Animation:** brief and functional — 120–180ms, `--ease-out`, opacity + small translate (4–8px). No bounces, no spring theatrics. Dialogs fade+scale from .98.
|
||||
- **Hover:** ink surfaces lighten (`--interactive-hover`), quiet elements gain `--ink-1` wash. **Press:** darken to true black + no shrink transforms. **Focus:** 3px soft ring (`--shadow-focus`), visible on keyboard focus.
|
||||
- **Transparency/blur:** essentially none. Scrims are flat `rgba(20,20,19,.4)`. No backdrop blur.
|
||||
- **Imagery:** when photography appears (marketing), it is neutral-warm, high-key, plenty of white in frame; slight desaturation OK; no duotones or heavy grade.
|
||||
- **Dark mode:** `[data-theme="dark"]` scope; warm near-black (#161615) pages, same geometry, inverted ink roles.
|
||||
|
||||
## ICONOGRAPHY
|
||||
- No proprietary icon set was provided. The system uses **Lucide** (CDN, `lucide.dev`) as its icon language — 1.75px stroke weight at 16–20px sizes matches the geometric-friendly type. Load via `https://unpkg.com/lucide@latest` and `lucide.createIcons()`, or inline copied SVGs.
|
||||
- Icons are always monochrome `currentColor`, never multicolor, never filled variants.
|
||||
- No emoji, no unicode-glyph icons. **Substitution flag:** if Leeworks adopts a bespoke icon set later, drop the SVGs into `assets/icons/` and update this section.
|
||||
|
||||
## Index
|
||||
- `styles.css` — global entry (imports everything under `tokens/`).
|
||||
- `tokens/` — `fonts.css`, `colors.css`, `typography.css`, `spacing.css`, `effects.css`.
|
||||
- `guidelines/` — foundation specimen cards (Design System tab).
|
||||
- `components/forms/` — Button, IconButton, Input, Select, Checkbox, Radio, Switch.
|
||||
- `components/display/` — Card, Badge, Tag, Icon.
|
||||
- `components/navigation/` — Tabs.
|
||||
- `components/feedback/` — Dialog, Toast, Tooltip.
|
||||
- `ui_kits/dashboard/` — Leeworks web app (analytics/workspace dashboard).
|
||||
- `ui_kits/website/` — marketing site (homepage).
|
||||
- `ui_kits/mobile/` — mobile app screens.
|
||||
- `SKILL.md` — agent-skill entry point.
|
||||
|
||||
## Intentional additions
|
||||
Standard component set authored from scratch (no source inventory existed). Lucide adopted as icon set (flagged above). `Icon` component wraps an inlined Lucide subset so kits never hand-roll SVG.
|
||||
|
||||
## Caveats
|
||||
- **Fonts are CDN-served Google Fonts** (Outfit, Figtree, Spline Sans Mono) — no binaries in-project. Provide licensed font files to ship offline.
|
||||
- No logo exists; wordmark is plain Outfit type. Provide a real mark if one exists.
|
||||
@@ -0,0 +1,5 @@
|
||||
@import "tokens/fonts.css";
|
||||
@import "tokens/colors.css";
|
||||
@import "tokens/typography.css";
|
||||
@import "tokens/spacing.css";
|
||||
@import "tokens/effects.css";
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/* Leeworks color system — bright white base, ink-led near-monochrome. */
|
||||
:root{
|
||||
/* Base scale (warm-neutral ink ramp) */
|
||||
--white:#ffffff;
|
||||
--ink-0:#fcfcfb;
|
||||
--ink-1:#f6f6f4;
|
||||
--ink-2:#ececea;
|
||||
--ink-3:#dcdcd8;
|
||||
--ink-4:#b8b8b3;
|
||||
--ink-5:#8b8b86;
|
||||
--ink-6:#62625e;
|
||||
--ink-7:#3d3d3a;
|
||||
--ink-8:#232321;
|
||||
--ink-9:#141413;
|
||||
|
||||
/* Signal (the one non-neutral: a deep leaf green used sparingly) */
|
||||
--signal:#1d6b45;
|
||||
--signal-strong:#155236;
|
||||
--signal-tint:#e8f2ec;
|
||||
|
||||
/* Semantic status (muted, low-saturation) */
|
||||
--positive:#1d6b45;
|
||||
--positive-tint:#e8f2ec;
|
||||
--warning:#8a6116;
|
||||
--warning-tint:#f7efdd;
|
||||
--danger:#a33a2f;
|
||||
--danger-tint:#f9e9e6;
|
||||
|
||||
/* Semantic aliases — light */
|
||||
--surface-page:var(--white);
|
||||
--surface-raised:var(--white);
|
||||
--surface-sunken:var(--ink-1);
|
||||
--surface-card:var(--white);
|
||||
--surface-inverse:var(--ink-9);
|
||||
--text-heading:var(--ink-9);
|
||||
--text-body:var(--ink-7);
|
||||
--text-muted:var(--ink-5);
|
||||
--text-inverse:var(--ink-0);
|
||||
--text-link:var(--ink-9);
|
||||
--border-subtle:var(--ink-2);
|
||||
--border-default:var(--ink-3);
|
||||
--border-strong:var(--ink-9);
|
||||
--interactive:var(--ink-9);
|
||||
--interactive-hover:var(--ink-7);
|
||||
--interactive-press:#000000;
|
||||
--focus-ring:rgba(20,20,19,.28);
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--surface-page:#161615;
|
||||
--surface-raised:#1e1e1d;
|
||||
--surface-sunken:#111110;
|
||||
--surface-card:#1e1e1d;
|
||||
--surface-inverse:var(--ink-0);
|
||||
--text-heading:#f4f4f2;
|
||||
--text-body:#c9c9c4;
|
||||
--text-muted:#8b8b86;
|
||||
--text-inverse:var(--ink-9);
|
||||
--text-link:#f4f4f2;
|
||||
--border-subtle:#2b2b29;
|
||||
--border-default:#3a3a37;
|
||||
--border-strong:#f4f4f2;
|
||||
--interactive:#f4f4f2;
|
||||
--interactive-hover:#d8d8d4;
|
||||
--interactive-press:#ffffff;
|
||||
--focus-ring:rgba(244,244,242,.35);
|
||||
--signal:#4fa377;
|
||||
--signal-tint:#1d2f26;
|
||||
--positive:#4fa377;
|
||||
--positive-tint:#1d2f26;
|
||||
--warning:#c99a3f;
|
||||
--warning-tint:#2f2818;
|
||||
--danger:#d0685c;
|
||||
--danger-tint:#331e1b;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/* Leeworks effects — soft rounding, hairline borders, restrained shadows. */
|
||||
:root{
|
||||
--radius-sm:6px; --radius-md:8px; --radius-lg:10px; --radius-xl:16px; --radius-pill:999px;
|
||||
|
||||
--shadow-card:0 1px 2px rgba(20,20,19,.05);
|
||||
--shadow-raised:0 2px 8px rgba(20,20,19,.07),0 1px 2px rgba(20,20,19,.05);
|
||||
--shadow-overlay:0 12px 40px rgba(20,20,19,.14),0 2px 8px rgba(20,20,19,.06);
|
||||
--shadow-focus:0 0 0 3px var(--focus-ring);
|
||||
|
||||
--ease-out:cubic-bezier(.2,.7,.3,1); /* @kind other */
|
||||
--dur-fast:120ms; /* @kind other */
|
||||
--dur-base:180ms; /* @kind other */
|
||||
--dur-slow:280ms; /* @kind other */
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--shadow-card:0 1px 2px rgba(0,0,0,.4);
|
||||
--shadow-raised:0 2px 8px rgba(0,0,0,.5);
|
||||
--shadow-overlay:0 12px 40px rgba(0,0,0,.6);
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
/* Leeworks webfonts — served from Google Fonts (no local binaries provided). */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=Figtree:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Spline+Sans+Mono:wght@400;500;600&display=swap");
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/* Leeworks spacing — 4px base, comfortable (not dense) rhythm. */
|
||||
:root{
|
||||
--space-1:4px; --space-2:8px; --space-3:12px; --space-4:16px;
|
||||
--space-5:20px; --space-6:24px; --space-7:32px; --space-8:40px;
|
||||
--space-9:56px; --space-10:80px; --space-11:120px;
|
||||
|
||||
--control-h-sm:32px; --control-h-md:40px; --control-h-lg:48px;
|
||||
--control-pad-x-sm:12px; --control-pad-x-md:16px; --control-pad-x-lg:20px;
|
||||
|
||||
--page-max:1200px; --content-max:720px; --gutter:24px;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/* Leeworks typography — Outfit (display), Figtree (body), Spline Sans Mono (data/code). */
|
||||
:root{
|
||||
--font-display:"Outfit",system-ui,sans-serif;
|
||||
--font-body:"Figtree",system-ui,sans-serif;
|
||||
--font-mono:"Spline Sans Mono",ui-monospace,monospace;
|
||||
|
||||
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-md:17px;
|
||||
--text-lg:20px; --text-xl:24px; --text-2xl:32px; --text-3xl:44px; --text-4xl:60px;
|
||||
|
||||
--leading-tight:1.1; --leading-snug:1.3; --leading-normal:1.55;
|
||||
--tracking-tight:-0.02em; --tracking-normal:0; --tracking-wide:0.08em;
|
||||
|
||||
--weight-regular:400; --weight-medium:500; --weight-semibold:600; --weight-bold:700; --weight-heavy:800;
|
||||
|
||||
/* Composite styles */
|
||||
--type-display:var(--weight-bold) var(--text-4xl)/var(--leading-tight) var(--font-display);
|
||||
--type-h1:var(--weight-bold) var(--text-3xl)/var(--leading-tight) var(--font-display);
|
||||
--type-h2:var(--weight-semibold) var(--text-2xl)/1.2 var(--font-display);
|
||||
--type-h3:var(--weight-semibold) var(--text-xl)/var(--leading-snug) var(--font-display);
|
||||
--type-h4:var(--weight-semibold) var(--text-md)/var(--leading-snug) var(--font-display);
|
||||
--type-body:var(--weight-regular) var(--text-base)/var(--leading-normal) var(--font-body);
|
||||
--type-body-sm:var(--weight-regular) var(--text-sm)/var(--leading-normal) var(--font-body);
|
||||
--type-label:var(--weight-semibold) var(--text-sm)/1.2 var(--font-body);
|
||||
--type-caption:var(--weight-medium) var(--text-xs)/1.4 var(--font-body);
|
||||
--type-overline:var(--weight-semibold) var(--text-xs)/1.2 var(--font-display);
|
||||
--type-mono:var(--weight-regular) var(--text-sm)/1.5 var(--font-mono);
|
||||
}
|
||||
+1841
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user