mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 04:36:24 +00:00
feat(app): schedules screen - recurring agent spawns on mobile
A new Schedules tab lists every schedule across projects with its interval, prompt, next/last run, and role/model badges; the operator can create one (project, name prefix, interval, role, model backend, prompt), pause/resume with a switch, and delete with confirmation. Mirrors the web dashboard's Schedules page, including the model picker added there. New clock/brain icons join the Lucide subset and the tab bar grows to four tabs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48
This commit is contained in:
@@ -34,6 +34,7 @@ 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";
|
||||||
import { SpawnScreen } from "./src/screens/SpawnScreen";
|
import { SpawnScreen } from "./src/screens/SpawnScreen";
|
||||||
|
import { SchedulesScreen } from "./src/screens/SchedulesScreen";
|
||||||
import { LogScreen } from "./src/screens/LogScreen";
|
import { LogScreen } from "./src/screens/LogScreen";
|
||||||
import { SettingsScreen } from "./src/screens/SettingsScreen";
|
import { SettingsScreen } from "./src/screens/SettingsScreen";
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ function Router() {
|
|||||||
detail: AgentDetailScreen,
|
detail: AgentDetailScreen,
|
||||||
answer: AnswerScreen,
|
answer: AnswerScreen,
|
||||||
spawn: SpawnScreen,
|
spawn: SpawnScreen,
|
||||||
|
schedules: SchedulesScreen,
|
||||||
log: LogScreen,
|
log: LogScreen,
|
||||||
settings: SettingsScreen,
|
settings: SettingsScreen,
|
||||||
}[screen];
|
}[screen];
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type IconName =
|
|||||||
| "chevronDown"
|
| "chevronDown"
|
||||||
| "home"
|
| "home"
|
||||||
| "file"
|
| "file"
|
||||||
|
| "clock"
|
||||||
|
| "brain"
|
||||||
| "settings"
|
| "settings"
|
||||||
| "x";
|
| "x";
|
||||||
|
|
||||||
@@ -30,6 +32,24 @@ const paths: Record<IconName, React.ReactNode> = {
|
|||||||
<Path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
<Path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
clock: (
|
||||||
|
<>
|
||||||
|
<Circle cx="12" cy="12" r="10" />
|
||||||
|
<Path d="M12 6v6l4 2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
/* Lucide waypoints — the memory note graph. */
|
||||||
|
brain: (
|
||||||
|
<>
|
||||||
|
<Circle cx="12" cy="4.5" r="2.5" />
|
||||||
|
<Path d="m10.2 6.3-3.9 3.9" />
|
||||||
|
<Circle cx="4.5" cy="12" r="2.5" />
|
||||||
|
<Path d="M7 12h10" />
|
||||||
|
<Circle cx="19.5" cy="12" r="2.5" />
|
||||||
|
<Path d="m13.8 17.7 3.9-3.9" />
|
||||||
|
<Circle cx="12" cy="19.5" r="2.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
settings: (
|
settings: (
|
||||||
<>
|
<>
|
||||||
<Path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
<Path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import { Icon, type IconName } from "./Icon";
|
|||||||
import { useAppState, type Screen } from "../state/AppState";
|
import { useAppState, type Screen } from "../state/AppState";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bottom tab bar shown on the three primary screens (Fleet / Log / Settings).
|
* Bottom tab bar shown on the primary screens (Fleet / Schedules / Log /
|
||||||
* The design's fixed 24px bottom inset is replaced by the real home-indicator
|
* Settings). The design's fixed 24px bottom inset is replaced by the real
|
||||||
* safe-area inset.
|
* home-indicator safe-area inset.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const TABS: { key: Screen; label: string; icon: IconName }[] = [
|
const TABS: { key: Screen; label: string; icon: IconName }[] = [
|
||||||
{ key: "fleet", label: "Fleet", icon: "home" },
|
{ key: "fleet", label: "Fleet", icon: "home" },
|
||||||
|
{ key: "schedules", label: "Schedules", icon: "clock" },
|
||||||
{ key: "log", label: "Log", icon: "file" },
|
{ key: "log", label: "Log", icon: "file" },
|
||||||
{ key: "settings", label: "Settings", icon: "settings" },
|
{ key: "settings", label: "Settings", icon: "settings" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { radius, text } from "../theme/tokens";
|
||||||
|
import { useTheme } from "../theme/useTheme";
|
||||||
|
import { Badge } from "../components/Badge";
|
||||||
|
import { Button } from "../components/Button";
|
||||||
|
import { Select } from "../components/Select";
|
||||||
|
import { Switch } from "../components/Switch";
|
||||||
|
import { TabBar } from "../components/TabBar";
|
||||||
|
import { TextField } from "../components/TextField";
|
||||||
|
import { Card, Divider, Mono, SectionLabel } from "../components/primitives";
|
||||||
|
import { useAppState } from "../state/AppState";
|
||||||
|
import { timeAgo } from "../api/format";
|
||||||
|
import type { Schedule } from "../api/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules — recurring agent spawns, the mobile counterpart of the web
|
||||||
|
* dashboard's Schedules page. Every interval the worker starts a fresh,
|
||||||
|
* stateless agent named <prefix>-<timestamp> with the stored prompt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = [
|
||||||
|
{ value: "", label: "Role — none" },
|
||||||
|
{ value: "junior", label: "junior" },
|
||||||
|
{ value: "senior", label: "senior" },
|
||||||
|
{ value: "deploy", label: "deploy" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const INTERVAL_OPTIONS = [
|
||||||
|
{ value: "900", label: "every 15 minutes" },
|
||||||
|
{ value: "1800", label: "every 30 minutes" },
|
||||||
|
{ value: "3600", label: "every hour" },
|
||||||
|
{ value: "21600", label: "every 6 hours" },
|
||||||
|
{ value: "86400", label: "every day" },
|
||||||
|
{ value: "604800", label: "every week" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function intervalLabel(seconds: number): string {
|
||||||
|
const opt = INTERVAL_OPTIONS.find((o) => Number(o.value) === seconds);
|
||||||
|
if (opt) return opt.label;
|
||||||
|
if (seconds % 3600 === 0) return `every ${seconds / 3600}h`;
|
||||||
|
if (seconds % 60 === 0) return `every ${seconds / 60}m`;
|
||||||
|
return `every ${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchedulesScreen() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const {
|
||||||
|
projects,
|
||||||
|
models,
|
||||||
|
schedules,
|
||||||
|
createSchedule,
|
||||||
|
toggleSchedule,
|
||||||
|
deleteSchedule,
|
||||||
|
} = useAppState();
|
||||||
|
|
||||||
|
const projectIds = projects.map((p) => p.id);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [project, setProject] = useState("");
|
||||||
|
const [prefix, setPrefix] = useState("");
|
||||||
|
const [task, setTask] = useState("");
|
||||||
|
const [interval, setIntervalStr] = useState("3600");
|
||||||
|
const [role, setRole] = useState("");
|
||||||
|
const [model, setModel] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectIds.length > 0 && !projectIds.includes(project)) {
|
||||||
|
setProject(projectIds[0]);
|
||||||
|
}
|
||||||
|
}, [projectIds, project]);
|
||||||
|
|
||||||
|
const modelOptions = [
|
||||||
|
{ value: "", label: "Claude (subscription)" },
|
||||||
|
...models
|
||||||
|
.filter((m) => m.enabled)
|
||||||
|
.map((m) => ({ value: String(m.id), label: `${m.name} (${m.model})` })),
|
||||||
|
];
|
||||||
|
const modelName = (id: number | null | undefined) =>
|
||||||
|
id == null ? null : models.find((m) => m.id === id)?.name ?? `#${id}`;
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!project || !prefix.trim() || !task.trim()) {
|
||||||
|
setError("Project, name prefix, and prompt are all required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await createSchedule(project, {
|
||||||
|
name_prefix: prefix.trim(),
|
||||||
|
task: task.trim(),
|
||||||
|
interval_seconds: Number(interval),
|
||||||
|
role: role || null,
|
||||||
|
model_id: model ? Number(model) : null,
|
||||||
|
});
|
||||||
|
setPrefix("");
|
||||||
|
setTask("");
|
||||||
|
setShowForm(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Couldn’t create the schedule.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(sc: Schedule) {
|
||||||
|
Alert.alert(
|
||||||
|
"Delete schedule?",
|
||||||
|
`Stop spawning ${sc.name_prefix}-* runs in ${sc.project_id}. This can’t be undone.`,
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
deleteSchedule(sc.id).catch((e) =>
|
||||||
|
setError(e instanceof Error ? e.message : "Delete failed."),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||||
|
<View style={{ height: insets.top }} />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.headRow}>
|
||||||
|
<Text style={[text.h3, { color: colors.textHeading }]}>Schedules</Text>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={showForm ? "secondary" : "primary"}
|
||||||
|
onPress={() => setShowForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showForm ? "Cancel" : "New"}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<Text style={[text.bodySm, { color: colors.textMuted, marginBottom: 16 }]}>
|
||||||
|
Spawn a fresh agent on an interval. Each run is stateless — keep
|
||||||
|
continuity in a file the prompt reads and overwrites.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{showForm ? (
|
||||||
|
<Card style={{ padding: 16, marginBottom: 16, gap: 14 }}>
|
||||||
|
{projectIds.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
label="Project"
|
||||||
|
options={projectIds}
|
||||||
|
value={project || projectIds[0]}
|
||||||
|
onChange={setProject}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||||
|
No projects registered yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View>
|
||||||
|
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||||
|
Name prefix
|
||||||
|
</Text>
|
||||||
|
<TextField
|
||||||
|
value={prefix}
|
||||||
|
onChangeText={setPrefix}
|
||||||
|
placeholder="nightly"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<Text style={[text.caption, { color: colors.textMuted, marginTop: 6 }]}>
|
||||||
|
Runs are named {prefix.trim() || "prefix"}-YYYYMMDD-HHMMSS.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Select
|
||||||
|
label="Interval"
|
||||||
|
options={INTERVAL_OPTIONS}
|
||||||
|
value={interval}
|
||||||
|
onChange={setIntervalStr}
|
||||||
|
/>
|
||||||
|
<Select label="Role" options={ROLE_OPTIONS} value={role} onChange={setRole} />
|
||||||
|
{modelOptions.length > 1 ? (
|
||||||
|
<Select
|
||||||
|
label="Model"
|
||||||
|
options={modelOptions}
|
||||||
|
value={model}
|
||||||
|
onChange={setModel}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<View>
|
||||||
|
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
|
||||||
|
Prompt
|
||||||
|
</Text>
|
||||||
|
<TextField
|
||||||
|
value={task}
|
||||||
|
onChangeText={setTask}
|
||||||
|
placeholder="What should every run do?"
|
||||||
|
multiline
|
||||||
|
height={100}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Button size="lg" style={{ width: "100%" }} onPress={busy ? undefined : create}>
|
||||||
|
{busy ? "Creating…" : "Create schedule"}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.notice,
|
||||||
|
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[text.bodySm, { color: colors.danger }]}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SectionLabel style={{ marginBottom: 8 }}>
|
||||||
|
{`${schedules.length} schedule${schedules.length === 1 ? "" : "s"}`}
|
||||||
|
</SectionLabel>
|
||||||
|
|
||||||
|
{schedules.length === 0 ? (
|
||||||
|
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||||
|
No schedules yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
{schedules.map((sc, i) => (
|
||||||
|
<View key={sc.id}>
|
||||||
|
{i > 0 && <Divider />}
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={{ flex: 1, gap: 4 }}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Mono style={{ fontSize: 14, color: colors.textHeading }}>
|
||||||
|
{sc.name_prefix}
|
||||||
|
</Mono>
|
||||||
|
{sc.role ? <Badge tone="neutral">{sc.role}</Badge> : null}
|
||||||
|
{sc.model_id != null ? (
|
||||||
|
<Badge tone="warning">{modelName(sc.model_id) ?? ""}</Badge>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text style={[text.caption, { color: colors.textMuted }]}>
|
||||||
|
{sc.project_id} · {intervalLabel(sc.interval_seconds)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={[text.caption, { color: colors.textMuted }]}
|
||||||
|
>
|
||||||
|
{sc.task}
|
||||||
|
</Text>
|
||||||
|
<Text style={[text.caption, { color: colors.textMuted }]}>
|
||||||
|
{sc.enabled
|
||||||
|
? new Date(sc.next_run_at).getTime() <= Date.now()
|
||||||
|
? "next run due now"
|
||||||
|
: `next run in ${nextIn(sc.next_run_at)}`
|
||||||
|
: "paused"}
|
||||||
|
{sc.last_run_at ? ` · last ${timeAgo(sc.last_run_at)} ago` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.rowActions}>
|
||||||
|
<Switch
|
||||||
|
value={sc.enabled}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
toggleSchedule(sc.id, v).catch((e) =>
|
||||||
|
setError(e instanceof Error ? e.message : "Update failed."),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="danger" onPress={() => confirmDelete(sc)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<TabBar active="schedules" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact time-until, reusing timeAgo's units for a future timestamp. */
|
||||||
|
function nextIn(iso: string): string {
|
||||||
|
const secs = Math.max(0, Math.floor((new Date(iso).getTime() - Date.now()) / 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`;
|
||||||
|
return `${Math.floor(hours / 24)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: { flex: 1 },
|
||||||
|
content: { paddingTop: 20, paddingHorizontal: 20, paddingBottom: 24 },
|
||||||
|
headRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
notice: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
},
|
||||||
|
rowActions: {
|
||||||
|
alignItems: "flex-end",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type ClaudeModel,
|
type ClaudeModel,
|
||||||
type LogEntry,
|
type LogEntry,
|
||||||
type Project,
|
type Project,
|
||||||
|
type Schedule,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
import { statusLabel, statusTone, timeAgo } from "../api/format";
|
import { statusLabel, statusTone, timeAgo } from "../api/format";
|
||||||
import { useServerConfig } from "./ServerConfig";
|
import { useServerConfig } from "./ServerConfig";
|
||||||
@@ -39,6 +40,7 @@ export type Screen =
|
|||||||
| "detail"
|
| "detail"
|
||||||
| "answer"
|
| "answer"
|
||||||
| "spawn"
|
| "spawn"
|
||||||
|
| "schedules"
|
||||||
| "log"
|
| "log"
|
||||||
| "settings";
|
| "settings";
|
||||||
|
|
||||||
@@ -99,6 +101,8 @@ interface AppStateValue {
|
|||||||
projects: Project[];
|
projects: Project[];
|
||||||
/* Registered model backends (the spawn/schedule dropdown next to the subscription). */
|
/* Registered model backends (the spawn/schedule dropdown next to the subscription). */
|
||||||
models: ClaudeModel[];
|
models: ClaudeModel[];
|
||||||
|
/* Recurring agent spawns, across all projects. */
|
||||||
|
schedules: Schedule[];
|
||||||
waiting: WaitingItem[];
|
waiting: WaitingItem[];
|
||||||
recent: RecentItem[];
|
recent: RecentItem[];
|
||||||
counts: { running: number; waiting: number; done: number };
|
counts: { running: number; waiting: number; done: number };
|
||||||
@@ -117,6 +121,18 @@ interface AppStateValue {
|
|||||||
sendAnswer: (text: string) => Promise<{ resumed: boolean; note?: string }>;
|
sendAnswer: (text: string) => Promise<{ resumed: boolean; note?: string }>;
|
||||||
spawn: (project: string, task: string, modelId?: number | null) => Promise<void>;
|
spawn: (project: string, task: string, modelId?: number | null) => Promise<void>;
|
||||||
kill: (project: string, name: string) => Promise<void>;
|
kill: (project: string, name: string) => Promise<void>;
|
||||||
|
createSchedule: (
|
||||||
|
project: string,
|
||||||
|
input: {
|
||||||
|
name_prefix: string;
|
||||||
|
task: string;
|
||||||
|
interval_seconds: number;
|
||||||
|
role?: string | null;
|
||||||
|
model_id?: number | null;
|
||||||
|
},
|
||||||
|
) => Promise<void>;
|
||||||
|
toggleSchedule: (id: number, enabled: boolean) => Promise<void>;
|
||||||
|
deleteSchedule: (id: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppStateContext = createContext<AppStateValue | null>(null);
|
const AppStateContext = createContext<AppStateValue | null>(null);
|
||||||
@@ -160,6 +176,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Fleet data.
|
// Fleet data.
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [models, setModels] = useState<ClaudeModel[]>([]);
|
const [models, setModels] = useState<ClaudeModel[]>([]);
|
||||||
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||||
const [agentsByProject, setAgentsByProject] = useState<Record<string, Agent[]>>({});
|
const [agentsByProject, setAgentsByProject] = useState<Record<string, Agent[]>>({});
|
||||||
const [checkmarks, setCheckmarks] = useState<Record<string, Checkmark | null>>({});
|
const [checkmarks, setCheckmarks] = useState<Record<string, Checkmark | null>>({});
|
||||||
const [logsByAgent, setLogsByAgent] = useState<Record<string, LogEntry[]>>({});
|
const [logsByAgent, setLogsByAgent] = useState<Record<string, LogEntry[]>>({});
|
||||||
@@ -175,6 +192,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const resetData = useCallback(() => {
|
const resetData = useCallback(() => {
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
setModels([]);
|
setModels([]);
|
||||||
|
setSchedules([]);
|
||||||
setAgentsByProject({});
|
setAgentsByProject({});
|
||||||
setCheckmarks({});
|
setCheckmarks({});
|
||||||
setLogsByAgent({});
|
setLogsByAgent({});
|
||||||
@@ -208,6 +226,13 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return [] as ClaudeModel[];
|
return [] as ClaudeModel[];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const scheduleList = await client
|
||||||
|
.api<Schedule[]>("/schedules")
|
||||||
|
.catch((e) => {
|
||||||
|
if (e instanceof AuthError) throw e;
|
||||||
|
return [] as Schedule[];
|
||||||
|
});
|
||||||
|
|
||||||
// Per-agent/per-project sub-requests are isolated: one flaky agent (a 500 on
|
// 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
|
// its log, say) must not blank the whole fleet. A rejected AuthError still
|
||||||
// propagates via onUnauthorized inside the client; here we just record the
|
// propagates via onUnauthorized inside the client; here we just record the
|
||||||
@@ -268,6 +293,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
setProjects(projs);
|
setProjects(projs);
|
||||||
setModels(modelList);
|
setModels(modelList);
|
||||||
|
setSchedules(scheduleList);
|
||||||
setAgentsByProject(abp);
|
setAgentsByProject(abp);
|
||||||
setCheckmarks(cmMap);
|
setCheckmarks(cmMap);
|
||||||
setLogsByAgent(logMap);
|
setLogsByAgent(logMap);
|
||||||
@@ -506,6 +532,53 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
[client, refresh],
|
[client, refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const createSchedule = useCallback(
|
||||||
|
async (
|
||||||
|
project: string,
|
||||||
|
input: {
|
||||||
|
name_prefix: string;
|
||||||
|
task: string;
|
||||||
|
interval_seconds: number;
|
||||||
|
role?: string | null;
|
||||||
|
model_id?: number | null;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (!client) throw new Error("not connected");
|
||||||
|
await client.api(`/projects/${enc(project)}/schedules`, {
|
||||||
|
body: {
|
||||||
|
name_prefix: input.name_prefix,
|
||||||
|
task: input.task,
|
||||||
|
interval_seconds: input.interval_seconds,
|
||||||
|
...(input.role ? { role: input.role } : {}),
|
||||||
|
...(input.model_id != null ? { model_id: input.model_id } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[client, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleSchedule = useCallback(
|
||||||
|
async (id: number, enabled: boolean) => {
|
||||||
|
if (!client) throw new Error("not connected");
|
||||||
|
await client.api(`/schedules/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: { enabled },
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[client, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteSchedule = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
if (!client) throw new Error("not connected");
|
||||||
|
await client.api(`/schedules/${id}`, { method: "DELETE" });
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[client, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
const value = useMemo<AppStateValue>(
|
const value = useMemo<AppStateValue>(
|
||||||
() => ({
|
() => ({
|
||||||
screen,
|
screen,
|
||||||
@@ -521,6 +594,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
error,
|
error,
|
||||||
projects,
|
projects,
|
||||||
models,
|
models,
|
||||||
|
schedules,
|
||||||
waiting,
|
waiting,
|
||||||
recent,
|
recent,
|
||||||
counts,
|
counts,
|
||||||
@@ -535,6 +609,9 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
sendAnswer,
|
sendAnswer,
|
||||||
spawn,
|
spawn,
|
||||||
kill,
|
kill,
|
||||||
|
createSchedule,
|
||||||
|
toggleSchedule,
|
||||||
|
deleteSchedule,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
screen,
|
screen,
|
||||||
@@ -546,6 +623,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
error,
|
error,
|
||||||
projects,
|
projects,
|
||||||
models,
|
models,
|
||||||
|
schedules,
|
||||||
waiting,
|
waiting,
|
||||||
recent,
|
recent,
|
||||||
counts,
|
counts,
|
||||||
@@ -558,6 +636,9 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
sendAnswer,
|
sendAnswer,
|
||||||
spawn,
|
spawn,
|
||||||
kill,
|
kill,
|
||||||
|
createSchedule,
|
||||||
|
toggleSchedule,
|
||||||
|
deleteSchedule,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user