[taskboard] add dispatch control plane
This commit is contained in:
@@ -1,28 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDeferredValue, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import type { FleetAgent } from "@/lib/types";
|
||||
import type { AgentFamily, FleetAgent } from "@/lib/types";
|
||||
|
||||
export function AgentsClient({ agents }: { agents: FleetAgent[] }) {
|
||||
function heartbeatTone(agent: FleetAgent) {
|
||||
if (agent.heartbeatAgeMinutes === null) {
|
||||
return "warning";
|
||||
}
|
||||
if (agent.heartbeatAgeMinutes > 180) {
|
||||
return "warning";
|
||||
}
|
||||
return "success";
|
||||
}
|
||||
|
||||
export function AgentsClient({
|
||||
agents,
|
||||
defaultFamily = "",
|
||||
}: {
|
||||
agents: FleetAgent[];
|
||||
defaultFamily?: AgentFamily | "";
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [family, setFamily] = useState("");
|
||||
const [family, setFamily] = useState<AgentFamily | "">(defaultFamily);
|
||||
const deferredQuery = useDeferredValue(query);
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
return agents.filter((agent) => {
|
||||
const matchesQuery =
|
||||
query.length === 0 ||
|
||||
agent.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
agent.host.toLowerCase().includes(query.toLowerCase()) ||
|
||||
agent.role.toLowerCase().includes(query.toLowerCase());
|
||||
const matchesFamily = family.length === 0 || agent.family === family;
|
||||
return matchesQuery && matchesFamily;
|
||||
});
|
||||
}, [agents, family, query]);
|
||||
const filteredAgents = agents.filter((agent) => {
|
||||
const matchesQuery =
|
||||
deferredQuery.length === 0 ||
|
||||
agent.name.toLowerCase().includes(deferredQuery.toLowerCase()) ||
|
||||
agent.host.toLowerCase().includes(deferredQuery.toLowerCase()) ||
|
||||
agent.role.toLowerCase().includes(deferredQuery.toLowerCase());
|
||||
const matchesFamily = family.length === 0 || agent.family === family;
|
||||
return matchesQuery && matchesFamily;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -30,12 +45,16 @@ export function AgentsClient({ agents }: { agents: FleetAgent[] }) {
|
||||
<CardHeader>
|
||||
<CardTitle>Configured Agent Runtimes</CardTitle>
|
||||
<CardDescription>
|
||||
OpenClaw swarm members and ZeroClaw host runtimes are shown from the deployed fleet model.
|
||||
OpenClaw swarm members and ZeroClaw host runtimes from the tracked fleet model with heartbeat and dispatch overlays.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-[1fr_220px]">
|
||||
<Input placeholder="Search by name, host, or role" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<Select value={family} onChange={(event) => setFamily(event.target.value)}>
|
||||
<Input
|
||||
placeholder="Search by name, host, or role"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<Select value={family} onChange={(event) => setFamily(event.target.value as AgentFamily | "")}>
|
||||
<option value="">All families</option>
|
||||
<option value="openclaw">OpenClaw</option>
|
||||
<option value="zeroclaw">ZeroClaw</option>
|
||||
@@ -71,18 +90,30 @@ export function AgentsClient({ agents }: { agents: FleetAgent[] }) {
|
||||
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Model</dt>
|
||||
<dd>{agent.model || "Host-local/runtime-defined"}</dd>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Runtime</dt>
|
||||
<dd className="font-mono text-xs text-cyan-100">{agent.runtimePath}</dd>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Dispatch</dt>
|
||||
<dd>{agent.defaultDispatchMethod}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Workload</dt>
|
||||
<dd>{agent.workload} active</dd>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Runtime</dt>
|
||||
<dd className="font-mono text-xs text-cyan-100">{agent.runtimePath}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Current task</dt>
|
||||
<dd>{agent.currentTask || "No heartbeat task"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Heartbeat</dt>
|
||||
<dd>
|
||||
<Badge variant={heartbeatTone(agent)}>
|
||||
{agent.heartbeatAgeMinutes === null ? "No heartbeat" : `${agent.heartbeatAgeMinutes}m ago`}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div>
|
||||
@@ -96,10 +127,36 @@ export function AgentsClient({ agents }: { agents: FleetAgent[] }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Last dispatch event</p>
|
||||
{agent.lastEvent ? (
|
||||
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-3">
|
||||
<p className="font-medium text-white">{agent.lastEvent.summary}</p>
|
||||
<p className="mt-1 text-sm text-slate-300">{agent.lastEvent.detail || "No detail captured."}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Badge variant={agent.lastEvent.event_type === "dispatch_failed" ? "warning" : "secondary"}>
|
||||
{agent.lastEvent.event_type}
|
||||
</Badge>
|
||||
<Badge variant="outline">Failures: {agent.failureStreak}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">No audit events recorded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Tools</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agent.tools.length ? agent.tools.map((tool) => <Badge key={tool} variant="secondary">{tool}</Badge>) : <span className="text-sm text-slate-400">No parsed tools.</span>}
|
||||
{agent.tools.length ? (
|
||||
agent.tools.map((tool) => (
|
||||
<Badge key={tool} variant="secondary">
|
||||
{tool}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-slate-400">No parsed tools.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import { Network, NotebookTabs, PanelsTopLeft, ScrollText, Settings2, UsersRound } from "lucide-react";
|
||||
import { Network, NotebookTabs, PanelsTopLeft, ScrollText, Send, Settings2, ShieldEllipsis, UsersRound } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/tasks", label: "Tasks", icon: PanelsTopLeft },
|
||||
{ href: "/agents", label: "Agents", icon: UsersRound },
|
||||
{ href: "/openclaw", label: "OpenClaw", icon: ShieldEllipsis },
|
||||
{ href: "/zeroclaw", label: "ZeroClaw", icon: Send },
|
||||
{ href: "/dispatch", label: "Dispatch", icon: Send },
|
||||
{ href: "/architecture", label: "Architecture", icon: Network },
|
||||
{ href: "/wiki", label: "Wiki", icon: NotebookTabs },
|
||||
{ href: "/usage", label: "Usage", icon: ScrollText },
|
||||
|
||||
73
components/dispatch-history.tsx
Normal file
73
components/dispatch-history.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { TaskEvent, TaskRecord } from "@/lib/types";
|
||||
|
||||
export function DispatchHistory({
|
||||
events,
|
||||
failedTasks,
|
||||
}: {
|
||||
events: TaskEvent[];
|
||||
failedTasks: TaskRecord[];
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dispatch History</CardTitle>
|
||||
<CardDescription>
|
||||
Every dispatch request, success, failure, and acknowledgement recorded by the control plane.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{events.map((event) => (
|
||||
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4" key={event.id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-white">{event.summary}</p>
|
||||
<p className="text-sm text-slate-400">Task #{event.task_id} • {event.assignee || "unassigned"} • {event.host || "n/a"}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant={event.family === "zeroclaw" ? "success" : "default"}>
|
||||
{event.family || "manual"}
|
||||
</Badge>
|
||||
<Badge variant={event.event_type === "dispatch_failed" ? "warning" : "secondary"}>
|
||||
{event.event_type}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{event.detail ? <p className="mt-2 text-sm text-slate-300">{event.detail}</p> : null}
|
||||
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-slate-500">{event.created_at}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Failure Queue</CardTitle>
|
||||
<CardDescription>
|
||||
Tasks with a failed dispatch state that still require operator review or retry.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{failedTasks.length === 0 ? (
|
||||
<p className="text-sm text-slate-400">No failed dispatches recorded.</p>
|
||||
) : (
|
||||
failedTasks.map((task) => (
|
||||
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4" key={task.id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-white">{task.title}</p>
|
||||
<p className="text-sm text-slate-400">{task.assignee || "Unassigned"} • {task.target_host || "n/a"}</p>
|
||||
</div>
|
||||
<Badge variant="warning">{task.dispatch_state}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-300">{task.last_error || "No error text captured."}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,35 +8,93 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { FleetAgent, TaskPriority, TaskRecord, TaskStatus } from "@/lib/types";
|
||||
import type {
|
||||
FleetAgent,
|
||||
TaskEvent,
|
||||
TaskPriority,
|
||||
TaskRecord,
|
||||
TaskStatus,
|
||||
TaskTemplate,
|
||||
} from "@/lib/types";
|
||||
|
||||
const COLUMNS: TaskStatus[] = ["Backlog", "Todo", "In Progress", "Review", "Done"];
|
||||
const PRIORITIES: TaskPriority[] = ["Low", "Medium", "High", "Critical"];
|
||||
|
||||
function familyVariant(family: string | null) {
|
||||
return family === "zeroclaw" ? "success" : "default";
|
||||
}
|
||||
|
||||
function dispatchVariant(state: TaskRecord["dispatch_state"]) {
|
||||
return state === "failed" ? "warning" : state === "completed" ? "success" : "secondary";
|
||||
}
|
||||
|
||||
export function TasksClient({
|
||||
initialTasks,
|
||||
initialEvents,
|
||||
agents,
|
||||
templates,
|
||||
}: {
|
||||
initialTasks: TaskRecord[];
|
||||
initialEvents: TaskEvent[];
|
||||
agents: FleetAgent[];
|
||||
templates: TaskTemplate[];
|
||||
}) {
|
||||
const [tasks, setTasks] = useState(initialTasks);
|
||||
const [events, setEvents] = useState(initialEvents);
|
||||
const [formState, setFormState] = useState({
|
||||
templateKey: "",
|
||||
title: "",
|
||||
description: "",
|
||||
assignee: "",
|
||||
priority: "Medium" as TaskPriority,
|
||||
tags: "",
|
||||
repoSlug: "",
|
||||
baseBranch: "main",
|
||||
preferredAgent: "codex",
|
||||
reasoningEffort: "high",
|
||||
modelHint: "",
|
||||
});
|
||||
|
||||
async function refreshTasks() {
|
||||
const response = await fetch("/api/tasks");
|
||||
const nextTasks = (await response.json()) as TaskRecord[];
|
||||
setTasks(nextTasks);
|
||||
const selectedTemplate = templates.find((template) => template.key === formState.templateKey) || null;
|
||||
const selectedAgent = agents.find((agent) => agent.assignmentKey === formState.assignee) || null;
|
||||
const failedTasks = tasks.filter((task) => task.dispatch_state === "failed");
|
||||
|
||||
async function refreshData() {
|
||||
const [taskResponse, eventResponse] = await Promise.all([
|
||||
fetch("/api/tasks"),
|
||||
fetch("/api/dispatch-history"),
|
||||
]);
|
||||
setTasks((await taskResponse.json()) as TaskRecord[]);
|
||||
setEvents((await eventResponse.json()) as TaskEvent[]);
|
||||
}
|
||||
|
||||
function applyTemplate(templateKey: string) {
|
||||
const template = templates.find((entry) => entry.key === templateKey) || null;
|
||||
if (!template) {
|
||||
setFormState((current) => ({ ...current, templateKey }));
|
||||
return;
|
||||
}
|
||||
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
templateKey,
|
||||
title: current.title || template.title,
|
||||
priority: template.defaults.priority,
|
||||
tags: template.tags.join(", "),
|
||||
repoSlug: template.defaults.repoSlug || current.repoSlug,
|
||||
baseBranch: template.defaults.baseBranch || current.baseBranch,
|
||||
preferredAgent: template.defaults.preferredAgent || current.preferredAgent,
|
||||
reasoningEffort: template.defaults.reasoningEffort || current.reasoningEffort,
|
||||
}));
|
||||
}
|
||||
|
||||
async function createTask(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const tags = formState.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
await fetch("/api/tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -45,90 +103,206 @@ export function TasksClient({
|
||||
description: formState.description,
|
||||
assignee: formState.assignee,
|
||||
priority: formState.priority,
|
||||
tags: formState.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
tags,
|
||||
template_key: formState.templateKey || null,
|
||||
repo_slug: formState.repoSlug || null,
|
||||
base_branch: formState.baseBranch || null,
|
||||
preferred_agent: formState.preferredAgent || null,
|
||||
reasoning_effort: formState.reasoningEffort || null,
|
||||
model_hint: formState.modelHint || null,
|
||||
family: selectedAgent?.family || selectedTemplate?.family || null,
|
||||
target_host: selectedAgent?.host || selectedTemplate?.defaults.targetHost || "",
|
||||
target_channel: selectedAgent?.channels[0]?.value || selectedTemplate?.defaults.targetChannel || "",
|
||||
dispatch_method: selectedAgent?.defaultDispatchMethod || selectedTemplate?.defaults.dispatchMethod || "manual",
|
||||
}),
|
||||
});
|
||||
|
||||
setFormState({
|
||||
templateKey: "",
|
||||
title: "",
|
||||
description: "",
|
||||
assignee: "",
|
||||
priority: "Medium",
|
||||
tags: "",
|
||||
repoSlug: "",
|
||||
baseBranch: "main",
|
||||
preferredAgent: "codex",
|
||||
reasoningEffort: "high",
|
||||
modelHint: "",
|
||||
});
|
||||
await refreshTasks();
|
||||
await refreshData();
|
||||
}
|
||||
|
||||
async function moveToDone(taskId: number) {
|
||||
async function patchTask(taskId: number, payload: Partial<TaskRecord>) {
|
||||
await fetch(`/api/tasks/${taskId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "Done" }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await refreshTasks();
|
||||
await refreshData();
|
||||
}
|
||||
|
||||
async function dispatchTask(taskId: number) {
|
||||
await fetch(`/api/tasks/${taskId}/dispatch`, { method: "POST" });
|
||||
await refreshData();
|
||||
}
|
||||
|
||||
async function acknowledgeTask(taskId: number) {
|
||||
await fetch(`/api/tasks/${taskId}/ack`, { method: "POST" });
|
||||
await refreshData();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Unified Task Intake</CardTitle>
|
||||
<CardDescription>
|
||||
Create typed tasks, apply dispatch templates, and route work to OpenClaw or ZeroClaw.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="grid gap-3 md:grid-cols-2" onSubmit={createTask}>
|
||||
<Select value={formState.templateKey} onChange={(event) => applyTemplate(event.target.value)}>
|
||||
<option value="">Select template</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.key} value={template.key}>
|
||||
{template.title} • {template.family}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={formState.assignee}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, assignee: event.target.value }))}
|
||||
>
|
||||
<option value="">Select agent</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.slug} value={agent.assignmentKey}>
|
||||
{agent.name} • {agent.family} • {agent.host}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="Task title"
|
||||
required
|
||||
value={formState.title}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, title: event.target.value }))}
|
||||
/>
|
||||
<Select
|
||||
value={formState.priority}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({ ...current, priority: event.target.value as TaskPriority }))
|
||||
}
|
||||
>
|
||||
{PRIORITIES.map((priority) => (
|
||||
<option key={priority} value={priority}>
|
||||
{priority}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="Repo slug from repo-map.json (for example TopherMayor/openclaw-taskboard)"
|
||||
value={formState.repoSlug}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, repoSlug: event.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Base branch"
|
||||
value={formState.baseBranch}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, baseBranch: event.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Preferred swarm agent"
|
||||
value={formState.preferredAgent}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, preferredAgent: event.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Reasoning effort"
|
||||
value={formState.reasoningEffort}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, reasoningEffort: event.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
className="md:col-span-2"
|
||||
placeholder="Tags (comma-separated)"
|
||||
value={formState.tags}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, tags: event.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
className="md:col-span-2"
|
||||
placeholder="Model hint (optional)"
|
||||
value={formState.modelHint}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, modelHint: event.target.value }))}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<Textarea
|
||||
placeholder="Describe the task, target host, expected outcome, and any validation steps."
|
||||
value={formState.description}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, description: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-slate-400">
|
||||
{selectedAgent
|
||||
? `Dispatch target: ${selectedAgent.family} on ${selectedAgent.host}`
|
||||
: selectedTemplate
|
||||
? `Template dispatch: ${selectedTemplate.defaults.dispatchMethod}`
|
||||
: "Select an agent or template to prefill dispatch metadata."}
|
||||
</p>
|
||||
<Button type="submit">Create Task</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Failure Queue</CardTitle>
|
||||
<CardDescription>
|
||||
Failed dispatches stay visible until retried or completed manually.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{failedTasks.length === 0 ? (
|
||||
<p className="text-sm text-slate-400">No failed dispatches.</p>
|
||||
) : (
|
||||
failedTasks.map((task) => (
|
||||
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4" key={task.id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-medium text-white">{task.title}</p>
|
||||
<Badge variant="warning">{task.dispatch_state}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-300">{task.last_error || "No error text captured."}</p>
|
||||
<Button className="mt-3 w-full" size="sm" variant="outline" onClick={() => dispatchTask(task.id)}>
|
||||
Retry Dispatch
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Unified Task Intake</CardTitle>
|
||||
<CardTitle>Recent Dispatch Activity</CardTitle>
|
||||
<CardDescription>
|
||||
Assign work to OpenClaw swarm agents or ZeroClaw host runtimes from a single board.
|
||||
Latest control-plane events across both families.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="grid gap-3 md:grid-cols-2" onSubmit={createTask}>
|
||||
<Input
|
||||
placeholder="Task title"
|
||||
required
|
||||
value={formState.title}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, title: event.target.value }))}
|
||||
/>
|
||||
<Select
|
||||
value={formState.assignee}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, assignee: event.target.value }))}
|
||||
>
|
||||
<option value="">Select agent</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.slug} value={agent.assignmentKey}>
|
||||
{agent.name} • {agent.family} • {agent.host}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={formState.priority}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({ ...current, priority: event.target.value as TaskPriority }))
|
||||
}
|
||||
>
|
||||
{PRIORITIES.map((priority) => (
|
||||
<option key={priority} value={priority}>
|
||||
{priority}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="Tags (comma-separated)"
|
||||
value={formState.tags}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, tags: event.target.value }))}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<Textarea
|
||||
placeholder="Describe the task, host target, and expected outcome"
|
||||
value={formState.description}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({ ...current, description: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<CardContent className="grid gap-3 lg:grid-cols-3">
|
||||
{events.slice(0, 6).map((event) => (
|
||||
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4" key={event.id}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={familyVariant(event.family)}>{event.family || "manual"}</Badge>
|
||||
<Badge variant={event.event_type === "dispatch_failed" ? "warning" : "secondary"}>
|
||||
{event.event_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-3 font-medium text-white">{event.summary}</p>
|
||||
<p className="mt-2 text-sm text-slate-300">{event.detail || "No detail captured."}</p>
|
||||
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-slate-500">{event.created_at}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit">Create Task</Button>
|
||||
</div>
|
||||
</form>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -154,18 +328,42 @@ export function TasksClient({
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-300">{task.description || "No description"}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-400">
|
||||
<Badge variant={familyVariant(task.family)}>{task.family || "manual"}</Badge>
|
||||
<Badge variant="secondary">{task.assignee || "Unassigned"}</Badge>
|
||||
<Badge variant={dispatchVariant(task.dispatch_state)}>{task.dispatch_state}</Badge>
|
||||
{task.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{task.status !== "Done" ? (
|
||||
<Button className="mt-4 w-full" size="sm" variant="outline" onClick={() => moveToDone(task.id)}>
|
||||
Mark Done
|
||||
</Button>
|
||||
) : null}
|
||||
<dl className="mt-3 grid gap-1 text-xs text-slate-400">
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>Host</dt>
|
||||
<dd>{task.target_host || "n/a"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>Channel</dt>
|
||||
<dd className="text-right">{task.target_channel || "n/a"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="mt-4 space-y-2">
|
||||
{task.dispatch_state !== "dispatched" && task.dispatch_state !== "completed" ? (
|
||||
<Button className="w-full" size="sm" onClick={() => dispatchTask(task.id)}>
|
||||
Dispatch
|
||||
</Button>
|
||||
) : null}
|
||||
{task.dispatch_state === "dispatched" ? (
|
||||
<Button className="w-full" size="sm" variant="outline" onClick={() => acknowledgeTask(task.id)}>
|
||||
Mark Acknowledged
|
||||
</Button>
|
||||
) : null}
|
||||
{task.status !== "Done" ? (
|
||||
<Button className="w-full" size="sm" variant="outline" onClick={() => patchTask(task.id, { status: "Done" })}>
|
||||
Mark Done
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user