mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-05 14:46:25 +00:00
feat(app): replace mock data with live Handler API
Wire the mobile app to the real Handler API instead of the transcribed prototype data: - src/api/client.ts: typed client ported from frontend/lib/api.ts (bearer auth, AuthError/ApiError, base URL param, allow401 for admin-only resume) - src/api/format.ts: relative-time + status label/tone/color helpers - src/state/ServerConfig.tsx: endpoint+token persisted to AsyncStorage - src/screens/ConnectScreen.tsx: first-open config, validated via /health then /projects - src/state/AppState.tsx: data-driven store polling projects -> agents -> checkmarks -> logs every 10s with per-item failure isolation; derives fleet counts, waiting list, recent checkmarks, merged log; answer+resume, spawn, kill mutations; 401 routes back to ConnectScreen - screens render live data; detail meta is Started/Status/Tests/Build; Pause removed (no endpoint); Kill confirms; log filters are per-project - delete src/data/mock.ts tsc clean; Hermes bundle builds (200).
This commit is contained in:
+23
-5
@@ -28,6 +28,8 @@ import {
|
|||||||
|
|
||||||
import { useTheme } from "./src/theme/useTheme";
|
import { useTheme } from "./src/theme/useTheme";
|
||||||
import { AppStateProvider, useAppState } from "./src/state/AppState";
|
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 { FleetScreen } from "./src/screens/FleetScreen";
|
||||||
import { AgentDetailScreen } from "./src/screens/AgentDetailScreen";
|
import { AgentDetailScreen } from "./src/screens/AgentDetailScreen";
|
||||||
import { AnswerScreen } from "./src/screens/AnswerScreen";
|
import { AnswerScreen } from "./src/screens/AnswerScreen";
|
||||||
@@ -37,9 +39,9 @@ import { SettingsScreen } from "./src/screens/SettingsScreen";
|
|||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
const { screen } = useAppState();
|
const { screen } = useAppState();
|
||||||
const { scheme, colors } = useTheme();
|
|
||||||
|
|
||||||
const Screen = {
|
const Screen = {
|
||||||
|
connect: ConnectScreen,
|
||||||
fleet: FleetScreen,
|
fleet: FleetScreen,
|
||||||
detail: AgentDetailScreen,
|
detail: AgentDetailScreen,
|
||||||
answer: AnswerScreen,
|
answer: AnswerScreen,
|
||||||
@@ -48,10 +50,26 @@ function Router() {
|
|||||||
settings: SettingsScreen,
|
settings: SettingsScreen,
|
||||||
}[screen];
|
}[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 (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: colors.surfacePage }}>
|
<View style={{ flex: 1, backgroundColor: colors.surfacePage }}>
|
||||||
<StatusBar style={scheme === "dark" ? "light" : "dark"} />
|
<StatusBar style={scheme === "dark" ? "light" : "dark"} />
|
||||||
<Screen />
|
{loading ? (
|
||||||
|
<SplashPlaceholder />
|
||||||
|
) : config ? (
|
||||||
|
<AppStateProvider>
|
||||||
|
<Router />
|
||||||
|
</AppStateProvider>
|
||||||
|
) : (
|
||||||
|
<ConnectScreen />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -76,9 +94,9 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
{fontsLoaded ? (
|
{fontsLoaded ? (
|
||||||
<AppStateProvider>
|
<ServerConfigProvider>
|
||||||
<Router />
|
<Gate />
|
||||||
</AppStateProvider>
|
</ServerConfigProvider>
|
||||||
) : (
|
) : (
|
||||||
<SplashPlaceholder />
|
<SplashPlaceholder />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
|
type KeyboardTypeOptions,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
|
type TextInputProps,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { fonts, radius } from "../theme/tokens";
|
import { fonts, radius } from "../theme/tokens";
|
||||||
@@ -21,6 +23,10 @@ export function TextField({
|
|||||||
multiline = false,
|
multiline = false,
|
||||||
height = 48,
|
height = 48,
|
||||||
style,
|
style,
|
||||||
|
secureTextEntry = false,
|
||||||
|
autoCapitalize,
|
||||||
|
autoCorrect,
|
||||||
|
keyboardType,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChangeText: (t: string) => void;
|
onChangeText: (t: string) => void;
|
||||||
@@ -29,6 +35,10 @@ export function TextField({
|
|||||||
/** For single-line this is the control height; for multiline, the box height. */
|
/** For single-line this is the control height; for multiline, the box height. */
|
||||||
height?: number;
|
height?: number;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
|
secureTextEntry?: boolean;
|
||||||
|
autoCapitalize?: TextInputProps["autoCapitalize"];
|
||||||
|
autoCorrect?: boolean;
|
||||||
|
keyboardType?: KeyboardTypeOptions;
|
||||||
}) {
|
}) {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [focus, setFocus] = useState(false);
|
const [focus, setFocus] = useState(false);
|
||||||
@@ -52,6 +62,10 @@ export function TextField({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={colors.textMuted}
|
||||||
multiline={multiline}
|
multiline={multiline}
|
||||||
|
secureTextEntry={secureTextEntry}
|
||||||
|
autoCapitalize={autoCapitalize}
|
||||||
|
autoCorrect={autoCorrect}
|
||||||
|
keyboardType={keyboardType}
|
||||||
onFocus={() => setFocus(true)}
|
onFocus={() => setFocus(true)}
|
||||||
onBlur={() => setFocus(false)}
|
onBlur={() => setFocus(false)}
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -77,11 +77,17 @@ export function StatusDot({ color, size = 8 }: { color: string; size?: number })
|
|||||||
export function Mono({
|
export function Mono({
|
||||||
children,
|
children,
|
||||||
style,
|
style,
|
||||||
|
numberOfLines,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
style?: StyleProp<TextStyle>;
|
style?: StyleProp<TextStyle>;
|
||||||
|
numberOfLines?: number;
|
||||||
}) {
|
}) {
|
||||||
return <Text style={[monoStyles.mono, style]}>{children}</Text>;
|
return (
|
||||||
|
<Text numberOfLines={numberOfLines} style={[monoStyles.mono, style]}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const monoStyles = StyleSheet.create({
|
const monoStyles = StyleSheet.create({
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import type { ThemeColors } from "../theme/tokens";
|
|
||||||
|
|
||||||
/** Fixed prototype content, transcribed from the 2a design. */
|
|
||||||
|
|
||||||
export interface WaitingAgent {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
question: string;
|
|
||||||
/** agt-7a1d is the one that clears from the list once answered. */
|
|
||||||
clearsOnAnswer?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const waitingAgents: WaitingAgent[] = [
|
|
||||||
{
|
|
||||||
id: "agt-7a1d",
|
|
||||||
title: "handler · migrate state to sqlite",
|
|
||||||
question: '"Drop the legacy JSON store, or keep it as a read fallback?"',
|
|
||||||
clearsOnAnswer: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "agt-3e90",
|
|
||||||
title: "wheatsite · fix build on node 22",
|
|
||||||
question: '"Pin node 20 in CI, or patch esbuild?"',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "agt-b241",
|
|
||||||
title: "api-gateway · add rate limiting",
|
|
||||||
question: '"429 body: JSON or plain text?"',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export type CheckmarkStatus = "positive" | "danger";
|
|
||||||
|
|
||||||
export interface Checkmark {
|
|
||||||
title: string;
|
|
||||||
meta: string;
|
|
||||||
status: CheckmarkStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const recentCheckmarks: Checkmark[] = [
|
|
||||||
{
|
|
||||||
title: "handler · add /agents endpoint",
|
|
||||||
meta: "done — tests pass · 14m ago",
|
|
||||||
status: "positive",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "wheatsite · refactor router",
|
|
||||||
meta: "failed — 2 tests · 1h ago",
|
|
||||||
status: "danger",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "dotfiles · port zsh config",
|
|
||||||
meta: "done — 12 turns · 3h ago",
|
|
||||||
status: "positive",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const quickReplyLabels = ["Drop it", "Keep as fallback", "Ask me later"];
|
|
||||||
|
|
||||||
export const projectOptions = ["handler", "wheatsite", "dotfiles", "api-gateway"];
|
|
||||||
|
|
||||||
/** Agent-detail log tab (fixed 7 rows). `color` picks a palette key. */
|
|
||||||
export interface DetailLogRow {
|
|
||||||
t: string;
|
|
||||||
msg: string;
|
|
||||||
color: keyof ThemeColors;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const detailLog: DetailLogRow[] = [
|
|
||||||
{ t: "14:02", msg: "paused — waiting for input", color: "warning" },
|
|
||||||
{ t: "13:57", msg: "checkmark updated", color: "textBody" },
|
|
||||||
{ t: "13:52", msg: "tool: bash — sqlite3 .schema", color: "textMuted" },
|
|
||||||
{ t: "13:48", msg: "tool: edit — store/sqlite.rs", color: "textMuted" },
|
|
||||||
{ t: "13:40", msg: "tool: bash — cargo test store", color: "textMuted" },
|
|
||||||
{ t: "13:29", msg: "checkmark updated", color: "textBody" },
|
|
||||||
{ t: "13:21", msg: "tool: read — store/json.rs", color: "textMuted" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface DetailMetaRow {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const detailMeta: DetailMetaRow[] = [
|
|
||||||
{ label: "Started", value: "41m ago" },
|
|
||||||
{ label: "Model", value: "claude-sonnet-4" },
|
|
||||||
{ label: "Turns", value: "21" },
|
|
||||||
{ label: "Tokens", value: "348k" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Global log feed. `err` and `p` drive the All / handler / Errors filters. */
|
|
||||||
export interface LogEntry {
|
|
||||||
t: string;
|
|
||||||
id: string;
|
|
||||||
p: string;
|
|
||||||
msg: string;
|
|
||||||
color: keyof ThemeColors;
|
|
||||||
err: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const allLog: LogEntry[] = [
|
|
||||||
{ t: "14:02:11", id: "agt-7a1d", p: "handler", msg: "paused — waiting for input", color: "warning", err: false },
|
|
||||||
{ t: "13:58:40", id: "agt-9c77", p: "handler", msg: "checkmark updated", color: "textBody", err: false },
|
|
||||||
{ t: "13:51:02", id: "agt-2d08", p: "handler", msg: "done — 34 turns, tests pass", color: "positive", err: false },
|
|
||||||
{ t: "13:44:19", id: "agt-e33a", p: "wheatsite", msg: "error — 2 tests failed", color: "danger", err: true },
|
|
||||||
{ t: "13:39:55", id: "agt-51f0", p: "dotfiles", msg: "spawned → dotfiles", color: "textBody", err: false },
|
|
||||||
{ t: "13:31:07", id: "agt-b241", p: "api-gateway", msg: "paused — waiting for input", color: "warning", err: false },
|
|
||||||
{ t: "13:18:44", id: "agt-9c77", p: "handler", msg: "tool: bash — cargo test", color: "textMuted", err: false },
|
|
||||||
{ t: "13:02:30", id: "agt-90bc", p: "dotfiles", msg: "done — 12 turns", color: "positive", err: false },
|
|
||||||
{ t: "12:57:12", id: "agt-51f0", p: "dotfiles", msg: "tool: edit — .zshrc", color: "textMuted", err: false },
|
|
||||||
{ t: "12:49:03", id: "agt-e33a", p: "wheatsite", msg: "checkmark updated", color: "textBody", err: false },
|
|
||||||
{ t: "12:40:38", id: "agt-b241", p: "api-gateway", msg: "spawned → api-gateway", color: "textBody", err: false },
|
|
||||||
];
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { fonts, radius, text } from "../theme/tokens";
|
import { fonts, radius, text } from "../theme/tokens";
|
||||||
import { useTheme } from "../theme/useTheme";
|
import { useTheme } from "../theme/useTheme";
|
||||||
@@ -8,43 +8,93 @@ import { PageHeader } from "../components/PageHeader";
|
|||||||
import { SegmentedControl } from "../components/SegmentedControl";
|
import { SegmentedControl } from "../components/SegmentedControl";
|
||||||
import { Card, Divider, Mono, SectionLabel } from "../components/primitives";
|
import { Card, Divider, Mono, SectionLabel } from "../components/primitives";
|
||||||
import { useAppState } from "../state/AppState";
|
import { useAppState } from "../state/AppState";
|
||||||
import { detailLog, detailMeta } from "../data/mock";
|
import {
|
||||||
|
clockTime,
|
||||||
|
statusColor,
|
||||||
|
statusLabel,
|
||||||
|
statusTone,
|
||||||
|
timeAgo,
|
||||||
|
} from "../api/format";
|
||||||
|
|
||||||
export function AgentDetailScreen() {
|
export function AgentDetailScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const {
|
const {
|
||||||
go,
|
go,
|
||||||
|
openAnswer,
|
||||||
detailTab,
|
detailTab,
|
||||||
setDetailTab,
|
setDetailTab,
|
||||||
notAnswered,
|
selectedAgent,
|
||||||
agentTone,
|
selectedCheckmark,
|
||||||
agentStatus,
|
selectedLog,
|
||||||
agentStateText,
|
kill,
|
||||||
} = useAppState();
|
} = 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 (
|
return (
|
||||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||||
<View style={{ height: insets.top }} />
|
<View style={{ height: insets.top }} />
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 20 }]}
|
||||||
styles.content,
|
|
||||||
{ paddingBottom: insets.bottom + 20 },
|
|
||||||
]}
|
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
leading="back"
|
leading="back"
|
||||||
onLeadingPress={() => go("fleet")}
|
onLeadingPress={() => go("fleet")}
|
||||||
agentId="agt-7a1d"
|
agentId={agent.name}
|
||||||
badge={{ tone: agentTone, label: agentStatus }}
|
badge={{ tone: statusTone(agent.status), label: statusLabel(agent.status) }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={[text.h3, { color: colors.textHeading }]}>
|
<Text style={[text.h3, { color: colors.textHeading }]}>{agent.name}</Text>
|
||||||
Migrate agent state to sqlite
|
|
||||||
</Text>
|
|
||||||
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 4, marginBottom: 16 }]}>
|
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 4, marginBottom: 16 }]}>
|
||||||
handler · branch <Mono>agt/7a1d</Mono>
|
{agent.project_id}
|
||||||
|
{agent.role ? " · " : ""}
|
||||||
|
{agent.role ? <Mono>{agent.role}</Mono> : null}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={{ marginBottom: 16 }}>
|
<View style={{ marginBottom: 16 }}>
|
||||||
@@ -66,25 +116,63 @@ export function AgentDetailScreen() {
|
|||||||
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
|
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<SectionLabel style={{ marginBottom: 8 }}>Current state</SectionLabel>
|
{cm ? (
|
||||||
<Mono style={[styles.stateText, { color: colors.textBody }]}>
|
<>
|
||||||
{agentStateText}
|
<SectionLabel style={{ marginBottom: 8 }}>Where it stopped</SectionLabel>
|
||||||
</Mono>
|
<Mono style={[styles.stateText, { color: colors.textBody }]}>
|
||||||
<Text style={[text.caption, { color: colors.textMuted, marginTop: 10 }]}>
|
{cm.where_it_stopped?.trim() || "—"}
|
||||||
updated 2m ago
|
</Mono>
|
||||||
</Text>
|
|
||||||
|
{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>
|
</View>
|
||||||
|
|
||||||
<Card style={{ marginTop: 16 }}>
|
<Card style={{ marginTop: 16 }}>
|
||||||
{detailMeta.map((m, i) => (
|
{meta.map((m, i) => (
|
||||||
<View key={m.label}>
|
<View key={m.label}>
|
||||||
{i > 0 && <Divider />}
|
{i > 0 && <Divider />}
|
||||||
<View style={styles.metaRow}>
|
<View style={styles.metaRow}>
|
||||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
<Text style={[text.bodySm, { color: colors.textMuted }]}>{m.label}</Text>
|
||||||
{m.label}
|
<Mono style={{ fontSize: 13, color: colors.textHeading }}>{m.value}</Mono>
|
||||||
</Text>
|
|
||||||
<Mono style={{ fontSize: 13, color: colors.textHeading }}>
|
|
||||||
{m.value}
|
|
||||||
</Mono>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -97,29 +185,34 @@ export function AgentDetailScreen() {
|
|||||||
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
|
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{detailLog.map((row) => (
|
{logRows.length === 0 ? (
|
||||||
<View key={row.t} style={styles.logRow}>
|
<Text style={[text.bodySm, { color: colors.textMuted }]}>No log entries yet.</Text>
|
||||||
<Mono style={[styles.logMono, { color: colors.ink4 }]}>
|
) : (
|
||||||
{row.t}
|
logRows.map((row) => (
|
||||||
</Mono>
|
<View key={row.id} style={styles.logRow}>
|
||||||
<Mono style={[styles.logMono, { color: colors[row.color] }]}>
|
<Mono style={[styles.logMono, { color: colors.ink4 }]}>
|
||||||
{row.msg}
|
{clockTime(row.created_at)}
|
||||||
</Mono>
|
</Mono>
|
||||||
</View>
|
<Mono style={[styles.logMono, { color: colors[statusColor(row.status)], flex: 1 }]}>
|
||||||
))}
|
{row.summary?.trim() || statusLabel(row.status)}
|
||||||
|
</Mono>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{notAnswered && (
|
{openQuestion ? (
|
||||||
<Button size="lg" style={{ flex: 1 }} onPress={() => go("answer")}>
|
<Button
|
||||||
|
size="lg"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onPress={() => openAnswer(agent.project_id, agent.name)}
|
||||||
|
>
|
||||||
Answer
|
Answer
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
<Button size="lg" variant="secondary" style={{ flex: 1 }}>
|
<Button size="lg" variant="danger" style={{ flex: 1 }} onPress={confirmKill}>
|
||||||
Pause
|
|
||||||
</Button>
|
|
||||||
<Button size="lg" variant="danger">
|
|
||||||
Kill
|
Kill
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -140,6 +233,7 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
},
|
},
|
||||||
|
stepRow: { flexDirection: "row" },
|
||||||
metaRow: {
|
metaRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -10,21 +11,62 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { radius, text } from "../theme/tokens";
|
import { radius, text } from "../theme/tokens";
|
||||||
import { useTheme } from "../theme/useTheme";
|
import { useTheme } from "../theme/useTheme";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import { Chip } from "../components/Chip";
|
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import { TextField } from "../components/TextField";
|
import { TextField } from "../components/TextField";
|
||||||
import { Mono, SectionLabel } from "../components/primitives";
|
import { Mono, SectionLabel } from "../components/primitives";
|
||||||
import { useAppState } from "../state/AppState";
|
import { useAppState } from "../state/AppState";
|
||||||
import { quickReplyLabels } from "../data/mock";
|
import { timeAgo } from "../api/format";
|
||||||
|
|
||||||
const QUESTION =
|
|
||||||
"Migrations pass on the new sqlite store. Should I drop the legacy JSON store entirely, or keep it as a read-only fallback for one release?";
|
|
||||||
|
|
||||||
export function AnswerScreen() {
|
export function AnswerScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { go, quickPick, setQuickPick, sendResume } = useAppState();
|
const { go, selectedAgent, selectedCheckmark, sendAnswer } = useAppState();
|
||||||
const [reply, setReply] = useState("");
|
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 (
|
return (
|
||||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||||
@@ -37,7 +79,7 @@ export function AnswerScreen() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
leading="back"
|
leading="back"
|
||||||
onLeadingPress={() => go("detail")}
|
onLeadingPress={() => go("detail")}
|
||||||
agentId="agt-7a1d"
|
agentId={agent.name}
|
||||||
badge={{ tone: "warning", label: "Waiting" }}
|
badge={{ tone: "warning", label: "Waiting" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -51,33 +93,53 @@ export function AnswerScreen() {
|
|||||||
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
|
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<SectionLabel style={{ marginBottom: 8 }}>Question · 2m ago</SectionLabel>
|
<SectionLabel style={{ marginBottom: 8 }}>
|
||||||
|
{`Question${askedAt ? ` · ${timeAgo(askedAt)} ago` : ""}`}
|
||||||
|
</SectionLabel>
|
||||||
<Mono style={[styles.questionText, { color: colors.textBody }]}>
|
<Mono style={[styles.questionText, { color: colors.textBody }]}>
|
||||||
{QUESTION}
|
{question}
|
||||||
</Mono>
|
</Mono>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SectionLabel style={{ marginBottom: 10 }}>Quick replies</SectionLabel>
|
{error ? (
|
||||||
<View style={styles.chips}>
|
<View
|
||||||
{quickReplyLabels.map((label, i) => (
|
style={[
|
||||||
<Chip
|
styles.notice,
|
||||||
key={label}
|
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
|
||||||
label={label}
|
]}
|
||||||
selected={quickPick === i}
|
>
|
||||||
onPress={() => setQuickPick(i)}
|
<Text style={[text.bodySm, { color: colors.danger }]}>{error}</Text>
|
||||||
/>
|
</View>
|
||||||
))}
|
) : null}
|
||||||
</View>
|
{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}>
|
<View style={styles.footer}>
|
||||||
<TextField
|
<TextField
|
||||||
value={reply}
|
value={reply}
|
||||||
onChangeText={setReply}
|
onChangeText={setReply}
|
||||||
placeholder="Or type a reply…"
|
placeholder="Type a reply…"
|
||||||
|
multiline
|
||||||
|
height={100}
|
||||||
/>
|
/>
|
||||||
<Button size="lg" style={{ width: "100%" }} onPress={sendResume}>
|
<Button
|
||||||
Send & resume
|
size="lg"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
onPress={busy ? undefined : send}
|
||||||
|
>
|
||||||
|
{busy ? "Sending…" : "Send & resume"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator color={colors.textMuted} style={{ marginTop: 4 }} />
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
@@ -93,14 +155,14 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: radius.lg,
|
borderRadius: radius.lg,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
marginBottom: 20,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
questionText: { fontSize: 13, lineHeight: 22 },
|
questionText: { fontSize: 13, lineHeight: 22 },
|
||||||
chips: {
|
notice: {
|
||||||
flexDirection: "row",
|
borderWidth: 1,
|
||||||
flexWrap: "wrap",
|
borderRadius: radius.md,
|
||||||
gap: 8,
|
padding: 12,
|
||||||
marginBottom: 20,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
footer: { marginTop: "auto", gap: 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { fonts, text } from "../theme/tokens";
|
import { fonts, text } from "../theme/tokens";
|
||||||
import { useTheme } from "../theme/useTheme";
|
import { useTheme } from "../theme/useTheme";
|
||||||
@@ -13,25 +20,24 @@ import {
|
|||||||
StatusDot,
|
StatusDot,
|
||||||
} from "../components/primitives";
|
} from "../components/primitives";
|
||||||
import { TabBar } from "../components/TabBar";
|
import { TabBar } from "../components/TabBar";
|
||||||
import { useAppState } from "../state/AppState";
|
|
||||||
import {
|
import {
|
||||||
recentCheckmarks,
|
useAppState,
|
||||||
waitingAgents,
|
type RecentItem,
|
||||||
type Checkmark,
|
type WaitingItem,
|
||||||
type WaitingAgent,
|
} from "../state/AppState";
|
||||||
} from "../data/mock";
|
|
||||||
|
|
||||||
export function FleetScreen() {
|
export function FleetScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { go, notAnswered } = useAppState();
|
const { go, openAnswer, openDetail, waiting, recent, counts, loading, error, refresh } =
|
||||||
|
useAppState();
|
||||||
|
|
||||||
const waiting = waitingAgents.filter((a) => !(a.clearsOnAnswer && !notAnswered));
|
const empty = waiting.length === 0 && recent.length === 0;
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "Running", value: "6", tint: colors.textHeading },
|
{ label: "Running", value: counts.running, tint: colors.textHeading },
|
||||||
{ label: "Waiting", value: "3", tint: colors.warning },
|
{ label: "Waiting", value: counts.waiting, tint: colors.warning },
|
||||||
{ label: "Done", value: "42", tint: colors.textHeading },
|
{ label: "Done", value: counts.done, tint: colors.textHeading },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,7 +51,7 @@ export function FleetScreen() {
|
|||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={[text.h3, { color: colors.textHeading }]}>Fleet</Text>
|
<Text style={[text.h3, { color: colors.textHeading }]}>Fleet</Text>
|
||||||
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 2 }]}>
|
<Text style={[text.bodySm, { color: colors.textMuted, marginTop: 2 }]}>
|
||||||
12 agents · 3 waiting on you
|
{counts.running} running · {counts.waiting} waiting on you
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Button size="sm" onPress={() => go("spawn")}>
|
<Button size="sm" onPress={() => go("spawn")}>
|
||||||
@@ -66,25 +72,65 @@ export function FleetScreen() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SectionLabel style={styles.overline}>Waiting on you</SectionLabel>
|
{loading && empty && !error ? (
|
||||||
<Card style={styles.section}>
|
<View style={styles.centered}>
|
||||||
{waiting.map((a, i) => (
|
<ActivityIndicator color={colors.textMuted} />
|
||||||
<View key={a.id}>
|
</View>
|
||||||
{i > 0 && <Divider />}
|
) : error && empty ? (
|
||||||
<WaitingRow agent={a} onAnswer={() => go("answer")} />
|
<Card style={styles.errorCard}>
|
||||||
</View>
|
<SectionLabel style={{ marginBottom: 6 }}>Couldn’t load fleet</SectionLabel>
|
||||||
))}
|
<Text style={[text.bodySm, { color: colors.textBody, marginBottom: 14 }]}>
|
||||||
</Card>
|
{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>
|
<SectionLabel style={styles.overline}>Recent checkmarks</SectionLabel>
|
||||||
<Card>
|
<Card>
|
||||||
{recentCheckmarks.map((c, i) => (
|
{recent.length === 0 ? (
|
||||||
<View key={c.title}>
|
<View style={styles.rowPad}>
|
||||||
{i > 0 && <Divider />}
|
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||||
<CheckmarkRow checkmark={c} onPress={() => go("detail")} />
|
No checkmarks yet.
|
||||||
</View>
|
</Text>
|
||||||
))}
|
</View>
|
||||||
</Card>
|
) : (
|
||||||
|
recent.map((c, i) => (
|
||||||
|
<View key={c.key}>
|
||||||
|
{i > 0 && <Divider />}
|
||||||
|
<CheckmarkRow
|
||||||
|
checkmark={c}
|
||||||
|
onPress={() => openDetail(c.project, c.name)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<TabBar active="fleet" />
|
<TabBar active="fleet" />
|
||||||
</View>
|
</View>
|
||||||
@@ -95,23 +141,24 @@ function WaitingRow({
|
|||||||
agent,
|
agent,
|
||||||
onAnswer,
|
onAnswer,
|
||||||
}: {
|
}: {
|
||||||
agent: WaitingAgent;
|
agent: WaitingItem;
|
||||||
onAnswer: () => void;
|
onAnswer: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
return (
|
return (
|
||||||
<View style={styles.rowPad}>
|
<View style={styles.rowPad}>
|
||||||
<View style={styles.waitingTop}>
|
<View style={styles.waitingTop}>
|
||||||
<Mono style={{ fontSize: 12, color: colors.textMuted }}>{agent.id}</Mono>
|
<Mono style={{ fontSize: 12, color: colors.textMuted }}>{agent.name}</Mono>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={[styles.rowTitle, { color: colors.textHeading, flex: 1 }]}
|
style={[styles.rowTitle, { color: colors.textHeading, flex: 1 }]}
|
||||||
>
|
>
|
||||||
{agent.title}
|
{agent.project}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.waitingBottom}>
|
<View style={styles.waitingBottom}>
|
||||||
<Text
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
style={[
|
style={[
|
||||||
text.bodySm,
|
text.bodySm,
|
||||||
{ color: colors.textBody, flex: 1, fontFamily: fonts.bodyItalic },
|
{ color: colors.textBody, flex: 1, fontFamily: fonts.bodyItalic },
|
||||||
@@ -131,11 +178,11 @@ function CheckmarkRow({
|
|||||||
checkmark,
|
checkmark,
|
||||||
onPress,
|
onPress,
|
||||||
}: {
|
}: {
|
||||||
checkmark: Checkmark;
|
checkmark: RecentItem;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const dot = checkmark.status === "positive" ? colors.positive : colors.danger;
|
const dot = checkmark.tone === "positive" ? colors.positive : colors.danger;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
@@ -179,6 +226,8 @@ const styles = StyleSheet.create({
|
|||||||
lineHeight: 28,
|
lineHeight: 28,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
|
centered: { paddingVertical: 48, alignItems: "center", justifyContent: "center" },
|
||||||
|
errorCard: { padding: 16, alignItems: "flex-start" },
|
||||||
overline: { marginBottom: 10 },
|
overline: { marginBottom: 10 },
|
||||||
section: { marginBottom: 20 },
|
section: { marginBottom: 20 },
|
||||||
rowPad: { paddingVertical: 14, paddingHorizontal: 16 },
|
rowPad: { paddingVertical: 14, paddingHorizontal: 16 },
|
||||||
|
|||||||
@@ -6,22 +6,22 @@ import { useTheme } from "../theme/useTheme";
|
|||||||
import { Chip } from "../components/Chip";
|
import { Chip } from "../components/Chip";
|
||||||
import { Mono, SectionLabel } from "../components/primitives";
|
import { Mono, SectionLabel } from "../components/primitives";
|
||||||
import { TabBar } from "../components/TabBar";
|
import { TabBar } from "../components/TabBar";
|
||||||
import { useAppState, type LogFilter } from "../state/AppState";
|
import { useAppState } from "../state/AppState";
|
||||||
import { allLog } from "../data/mock";
|
import { clockTime, statusColor } from "../api/format";
|
||||||
|
|
||||||
const FILTERS: { key: LogFilter; label: string }[] = [
|
|
||||||
{ key: "all", label: "All" },
|
|
||||||
{ key: "handler", label: "handler" },
|
|
||||||
{ key: "errors", label: "Errors" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function LogScreen() {
|
export function LogScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { logFilter, setLogFilter } = useAppState();
|
const { logFilter, setLogFilter, globalLog, projects } = useAppState();
|
||||||
|
|
||||||
const entries = allLog.filter((e) =>
|
const filters: { key: string; label: string }[] = [
|
||||||
logFilter === "all" ? true : logFilter === "errors" ? e.err : e.p === "handler"
|
{ 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 (
|
return (
|
||||||
@@ -30,8 +30,12 @@ export function LogScreen() {
|
|||||||
|
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={[text.h3, { color: colors.textHeading }]}>Log</Text>
|
<Text style={[text.h3, { color: colors.textHeading }]}>Log</Text>
|
||||||
<View style={styles.filters}>
|
<ScrollView
|
||||||
{FILTERS.map((f) => (
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.filters}
|
||||||
|
>
|
||||||
|
{filters.map((f) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={f.key}
|
key={f.key}
|
||||||
label={f.label}
|
label={f.label}
|
||||||
@@ -39,11 +43,11 @@ export function LogScreen() {
|
|||||||
onPress={() => setLogFilter(f.key)}
|
onPress={() => setLogFilter(f.key)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.todayLabel}>
|
<View style={styles.todayLabel}>
|
||||||
<SectionLabel>Today</SectionLabel>
|
<SectionLabel>Activity</SectionLabel>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -54,17 +58,26 @@ export function LogScreen() {
|
|||||||
]}
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{entries.map((e, i) => (
|
{entries.length === 0 ? (
|
||||||
<View key={`${e.t}-${i}`} style={styles.logRow}>
|
<Text style={[text.bodySm, { color: colors.textMuted }]}>No activity yet.</Text>
|
||||||
<Mono style={[styles.mono, { color: colors.ink4 }]}>{e.t}</Mono>
|
) : (
|
||||||
<Mono style={[styles.mono, styles.idCol, { color: colors.ink6 }]}>
|
entries.map((e) => (
|
||||||
{e.id}
|
<View key={e.key} style={styles.logRow}>
|
||||||
</Mono>
|
<Mono style={[styles.mono, { color: colors.ink4 }]}>
|
||||||
<Mono style={[styles.mono, { color: colors[e.color], flex: 1 }]}>
|
{clockTime(e.createdAt)}
|
||||||
{e.msg}
|
</Mono>
|
||||||
</Mono>
|
<Mono
|
||||||
</View>
|
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>
|
</ScrollView>
|
||||||
|
|
||||||
<TabBar active="log" />
|
<TabBar active="log" />
|
||||||
@@ -75,7 +88,7 @@ export function LogScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
header: { paddingHorizontal: 20, paddingTop: 20, paddingBottom: 16 },
|
header: { paddingHorizontal: 20, paddingTop: 20, paddingBottom: 16 },
|
||||||
filters: { flexDirection: "row", gap: 8, marginTop: 14 },
|
filters: { flexDirection: "row", gap: 8, marginTop: 14, paddingRight: 20 },
|
||||||
todayLabel: { paddingHorizontal: 20, paddingBottom: 8 },
|
todayLabel: { paddingHorizontal: 20, paddingBottom: 8 },
|
||||||
feed: {
|
feed: {
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
@@ -85,5 +98,5 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
logRow: { flexDirection: "row", gap: 10 },
|
logRow: { flexDirection: "row", gap: 10 },
|
||||||
mono: { fontSize: 12.5, lineHeight: 26 },
|
mono: { fontSize: 12.5, lineHeight: 26 },
|
||||||
idCol: { width: 66 },
|
idCol: { width: 86 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { text } from "../theme/tokens";
|
import { text } from "../theme/tokens";
|
||||||
@@ -13,12 +13,58 @@ import {
|
|||||||
StatusDot,
|
StatusDot,
|
||||||
} from "../components/primitives";
|
} from "../components/primitives";
|
||||||
import { TabBar } from "../components/TabBar";
|
import { TabBar } from "../components/TabBar";
|
||||||
import { useAppState } from "../state/AppState";
|
import { useServerConfig } from "../state/ServerConfig";
|
||||||
|
import { createClient } from "../api/client";
|
||||||
|
|
||||||
|
type Ping =
|
||||||
|
| { state: "checking" }
|
||||||
|
| { state: "ok"; latencyMs: number }
|
||||||
|
| { state: "error" };
|
||||||
|
|
||||||
export function SettingsScreen() {
|
export function SettingsScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { swPushWait, setSwPushWait, swPushFail, setSwPushFail } = useAppState();
|
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 (
|
return (
|
||||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||||
@@ -33,16 +79,16 @@ export function SettingsScreen() {
|
|||||||
|
|
||||||
<SectionLabel style={{ marginBottom: 10 }}>Server</SectionLabel>
|
<SectionLabel style={{ marginBottom: 10 }}>Server</SectionLabel>
|
||||||
<Card style={{ marginBottom: 20 }}>
|
<Card style={{ marginBottom: 20 }}>
|
||||||
<InfoRow label="Endpoint" value="https://handler.wheaty.dev" />
|
<InfoRow label="Endpoint" value={config?.endpoint ?? "—"} />
|
||||||
<Divider />
|
<Divider />
|
||||||
<InfoRow label="API key" value="hnd_••••••••4f2a" />
|
<InfoRow label="API token" value={maskedToken} />
|
||||||
<Divider />
|
<Divider />
|
||||||
<View style={styles.infoRow}>
|
<View style={styles.infoRow}>
|
||||||
<Text style={[text.label, { color: colors.textHeading }]}>Status</Text>
|
<Text style={[text.label, { color: colors.textHeading }]}>Status</Text>
|
||||||
<View style={styles.statusValue}>
|
<View style={styles.statusValue}>
|
||||||
<StatusDot color={colors.positive} size={7} />
|
<StatusDot color={status.color} size={7} />
|
||||||
<Mono style={[styles.valueMono, { color: colors.positive }]}>
|
<Mono style={[styles.valueMono, { color: status.color }]}>
|
||||||
connected · 38ms
|
{status.label}
|
||||||
</Mono>
|
</Mono>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -53,19 +99,24 @@ export function SettingsScreen() {
|
|||||||
<ToggleRow
|
<ToggleRow
|
||||||
title="Waiting on input"
|
title="Waiting on input"
|
||||||
subtitle="Push when an agent pauses"
|
subtitle="Push when an agent pauses"
|
||||||
value={swPushWait}
|
value={pushWait}
|
||||||
onValueChange={setSwPushWait}
|
onValueChange={setPushWait}
|
||||||
/>
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
title="Failures"
|
title="Failures"
|
||||||
subtitle="Push when a checkmark fails"
|
subtitle="Push when a checkmark fails"
|
||||||
value={swPushFail}
|
value={pushFail}
|
||||||
onValueChange={setSwPushFail}
|
onValueChange={setPushFail}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Button variant="secondary" size="lg" style={{ width: "100%" }}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
onPress={() => void clear()}
|
||||||
|
>
|
||||||
Sign out
|
Sign out
|
||||||
</Button>
|
</Button>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -79,7 +130,12 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.infoRow}>
|
<View style={styles.infoRow}>
|
||||||
<Text style={[text.label, { color: colors.textHeading }]}>{label}</Text>
|
<Text style={[text.label, { color: colors.textHeading }]}>{label}</Text>
|
||||||
<Mono style={[styles.valueMono, { color: colors.textMuted }]}>{value}</Mono>
|
<Mono
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[styles.valueMono, styles.valueFlex, { color: colors.textMuted }]}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Mono>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,4 +154,5 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
statusValue: { flexDirection: "row", alignItems: "center", gap: 6 },
|
statusValue: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||||
valueMono: { fontSize: 12.5 },
|
valueMono: { fontSize: 12.5 },
|
||||||
|
valueFlex: { flex: 1, textAlign: "right" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
@@ -7,23 +7,52 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { text } from "../theme/tokens";
|
import { radius, text } from "../theme/tokens";
|
||||||
import { useTheme } from "../theme/useTheme";
|
import { useTheme } from "../theme/useTheme";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import { Select } from "../components/Select";
|
import { Select } from "../components/Select";
|
||||||
import { TextField } from "../components/TextField";
|
import { TextField } from "../components/TextField";
|
||||||
import { ToggleRow } from "../components/ToggleRow";
|
|
||||||
import { Card, Divider } from "../components/primitives";
|
|
||||||
import { useAppState } from "../state/AppState";
|
import { useAppState } from "../state/AppState";
|
||||||
import { projectOptions } from "../data/mock";
|
|
||||||
|
|
||||||
export function SpawnScreen() {
|
export function SpawnScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { go, spawnGo, swEdits, setSwEdits, swTests, setSwTests } = useAppState();
|
const { go, projects, spawn } = useAppState();
|
||||||
const [project, setProject] = useState("handler");
|
|
||||||
|
const projectIds = projects.map((p) => p.id);
|
||||||
|
const [project, setProject] = useState("");
|
||||||
const [task, setTask] = 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 (
|
return (
|
||||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||||
@@ -40,12 +69,23 @@ export function SpawnScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={{ gap: 16 }}>
|
<View style={{ gap: 16 }}>
|
||||||
<Select
|
{projectIds.length > 0 ? (
|
||||||
label="Project"
|
<Select
|
||||||
options={projectOptions}
|
label="Project"
|
||||||
value={project}
|
options={projectIds}
|
||||||
onChange={setProject}
|
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>
|
<View>
|
||||||
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||||
@@ -63,26 +103,25 @@ export function SpawnScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Card>
|
{error ? (
|
||||||
<ToggleRow
|
<View
|
||||||
title="Auto-approve edits"
|
style={[
|
||||||
subtitle="Skip file-edit confirmations"
|
styles.notice,
|
||||||
value={swEdits}
|
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
|
||||||
onValueChange={setSwEdits}
|
]}
|
||||||
/>
|
>
|
||||||
<Divider />
|
<Text style={[text.bodySm, { color: colors.danger }]}>{error}</Text>
|
||||||
<ToggleRow
|
</View>
|
||||||
title="Run tests on done"
|
) : null}
|
||||||
subtitle="Checkmark fails if tests fail"
|
|
||||||
value={swTests}
|
|
||||||
onValueChange={setSwTests}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ marginTop: "auto" }}>
|
<View style={{ marginTop: "auto" }}>
|
||||||
<Button size="lg" style={{ width: "100%" }} onPress={spawnGo}>
|
<Button
|
||||||
Spawn agent
|
size="lg"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
onPress={busy ? undefined : submit}
|
||||||
|
>
|
||||||
|
{busy ? "Spawning…" : "Spawn agent"}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -95,4 +134,9 @@ const styles = StyleSheet.create({
|
|||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
flex: { flex: 1 },
|
flex: { flex: 1 },
|
||||||
content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 },
|
content: { flex: 1, paddingTop: 8, paddingHorizontal: 20 },
|
||||||
|
notice: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+454
-70
@@ -1,15 +1,38 @@
|
|||||||
import React, { createContext, useContext, useMemo, useState } from "react";
|
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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Central prototype state, ported 1:1 from the DC script's
|
* Data-driven fleet store. Keeps the prototype's screen-swap navigation (a single
|
||||||
* `state` + `renderVals()` in project/Handler Mobile.dc.html (turn 2a).
|
* `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 prototype navigates by swapping a single `screen` value rather than a
|
* The store polls /projects → agents → checkmarks → logs on a 10s cadence, derives the
|
||||||
* stack, and answering agt-7a1d flips it Waiting -> Running everywhere. That
|
* fleet view-models (waiting list, recent checkmarks, counts, merged log), and exposes the
|
||||||
* cross-screen behavior is exactly why this lives in one shared store.
|
* 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 =
|
export type Screen =
|
||||||
|
| "connect"
|
||||||
| "fleet"
|
| "fleet"
|
||||||
| "detail"
|
| "detail"
|
||||||
| "answer"
|
| "answer"
|
||||||
@@ -18,99 +41,460 @@ export type Screen =
|
|||||||
| "settings";
|
| "settings";
|
||||||
|
|
||||||
export type DetailTab = "state" | "log";
|
export type DetailTab = "state" | "log";
|
||||||
export type LogFilter = "all" | "handler" | "errors";
|
|
||||||
export type BadgeTone = "neutral" | "positive" | "warning" | "danger";
|
export type BadgeTone = "neutral" | "positive" | "warning" | "danger";
|
||||||
|
export type RecentTone = "positive" | "danger";
|
||||||
|
|
||||||
const ANSWERED_STATE =
|
/** An agent waiting on the operator — either an open checkmark question or a paused status. */
|
||||||
"Answer received: keep the JSON store as a read-only fallback for one release. Deprecating writes now; removal ticket filed for next cycle.";
|
export interface WaitingItem {
|
||||||
const WAITING_STATE =
|
project: string;
|
||||||
"Schema written, migrations pass. Blocked on the legacy JSON store — drop it or keep as read fallback? Holding before deleting store/json.rs.";
|
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 {
|
interface AppStateValue {
|
||||||
|
// Navigation.
|
||||||
screen: Screen;
|
screen: Screen;
|
||||||
detailTab: DetailTab;
|
detailTab: DetailTab;
|
||||||
quickPick: number | null;
|
logFilter: string;
|
||||||
logFilter: LogFilter;
|
|
||||||
answered: boolean;
|
|
||||||
swEdits: boolean;
|
|
||||||
swTests: boolean;
|
|
||||||
swPushWait: boolean;
|
|
||||||
swPushFail: boolean;
|
|
||||||
|
|
||||||
// Derived (mirrors renderVals()).
|
|
||||||
notAnswered: boolean;
|
|
||||||
agentTone: BadgeTone;
|
|
||||||
agentStatus: string;
|
|
||||||
agentStateText: string;
|
|
||||||
|
|
||||||
go: (screen: Screen) => void;
|
go: (screen: Screen) => void;
|
||||||
setDetailTab: (tab: DetailTab) => void;
|
setDetailTab: (tab: DetailTab) => void;
|
||||||
setQuickPick: (i: number) => void;
|
setLogFilter: (f: string) => void;
|
||||||
setLogFilter: (f: LogFilter) => void;
|
openDetail: (project: string, name: string) => void;
|
||||||
sendResume: () => void;
|
openAnswer: (project: string, name: string) => void;
|
||||||
spawnGo: () => void;
|
|
||||||
setSwEdits: (v: boolean) => void;
|
// Fleet data.
|
||||||
setSwTests: (v: boolean) => void;
|
loading: boolean;
|
||||||
setSwPushWait: (v: boolean) => void;
|
error: string | null;
|
||||||
setSwPushFail: (v: boolean) => void;
|
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 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 }) {
|
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 [screen, setScreen] = useState<Screen>("fleet");
|
||||||
const [detailTab, setDetailTab] = useState<DetailTab>("state");
|
const [detailTab, setDetailTab] = useState<DetailTab>("state");
|
||||||
const [quickPick, setQuickPickState] = useState<number | null>(null);
|
const [logFilter, setLogFilter] = useState<string>("all");
|
||||||
const [logFilter, setLogFilter] = useState<LogFilter>("all");
|
const [selected, setSelected] = useState<Selected | null>(null);
|
||||||
const [answered, setAnswered] = useState(false);
|
|
||||||
const [swEdits, setSwEdits] = useState(false);
|
const resetData = useCallback(() => {
|
||||||
const [swTests, setSwTests] = useState(true);
|
setProjects([]);
|
||||||
const [swPushWait, setSwPushWait] = useState(true);
|
setAgentsByProject({});
|
||||||
const [swPushFail, setSwPushFail] = useState(true);
|
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>(
|
const value = useMemo<AppStateValue>(
|
||||||
() => ({
|
() => ({
|
||||||
screen,
|
screen,
|
||||||
detailTab,
|
detailTab,
|
||||||
quickPick,
|
|
||||||
logFilter,
|
logFilter,
|
||||||
answered,
|
|
||||||
swEdits,
|
|
||||||
swTests,
|
|
||||||
swPushWait,
|
|
||||||
swPushFail,
|
|
||||||
|
|
||||||
notAnswered: !answered,
|
|
||||||
agentTone: answered ? "positive" : "warning",
|
|
||||||
agentStatus: answered ? "Running" : "Waiting",
|
|
||||||
agentStateText: answered ? ANSWERED_STATE : WAITING_STATE,
|
|
||||||
|
|
||||||
go: setScreen,
|
go: setScreen,
|
||||||
setDetailTab,
|
setDetailTab,
|
||||||
setQuickPick: setQuickPickState,
|
|
||||||
setLogFilter,
|
setLogFilter,
|
||||||
sendResume: () => {
|
openDetail,
|
||||||
setAnswered(true);
|
openAnswer,
|
||||||
setDetailTab("state");
|
|
||||||
setScreen("detail");
|
loading,
|
||||||
},
|
error,
|
||||||
spawnGo: () => setScreen("fleet"),
|
projects,
|
||||||
setSwEdits,
|
waiting,
|
||||||
setSwTests,
|
recent,
|
||||||
setSwPushWait,
|
counts,
|
||||||
setSwPushFail,
|
globalLog,
|
||||||
|
refresh,
|
||||||
|
|
||||||
|
selectedAgent,
|
||||||
|
selectedCheckmark,
|
||||||
|
selectedLog,
|
||||||
|
|
||||||
|
sendAnswer,
|
||||||
|
spawn,
|
||||||
|
kill,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
screen,
|
screen,
|
||||||
detailTab,
|
detailTab,
|
||||||
quickPick,
|
|
||||||
logFilter,
|
logFilter,
|
||||||
answered,
|
openDetail,
|
||||||
swEdits,
|
openAnswer,
|
||||||
swTests,
|
loading,
|
||||||
swPushWait,
|
error,
|
||||||
swPushFail,
|
projects,
|
||||||
]
|
waiting,
|
||||||
|
recent,
|
||||||
|
counts,
|
||||||
|
globalLog,
|
||||||
|
refresh,
|
||||||
|
selectedAgent,
|
||||||
|
selectedCheckmark,
|
||||||
|
selectedLog,
|
||||||
|
sendAnswer,
|
||||||
|
spawn,
|
||||||
|
kill,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user