Files
openclaw-taskboard/components/tasks-client.tsx

392 lines
16 KiB
TypeScript

"use client";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
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) {
if (family === "zeroclaw") {
return "success";
}
if (family === "direct") {
return "warning";
}
return "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: "",
});
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" },
body: JSON.stringify({
title: formState.title,
description: formState.description,
assignee: formState.assignee,
priority: formState.priority,
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 refreshData();
}
async function patchTask(taskId: number, payload: Partial<TaskRecord>) {
await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
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, ZeroClaw, or direct host targets.
</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>Recent Dispatch Activity</CardTitle>
<CardDescription>
Latest control-plane events across all configured task families.
</CardDescription>
</CardHeader>
<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>
))}
</CardContent>
</Card>
<div className="grid gap-4 xl:grid-cols-5">
{COLUMNS.map((column) => {
const columnTasks = tasks.filter((task) => task.status === column);
return (
<Card className="min-h-[420px]" key={column}>
<CardHeader className="pb-4">
<CardTitle className="flex items-center justify-between text-base">
<span>{column}</span>
<Badge variant="secondary">{columnTasks.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{columnTasks.map((task) => (
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4" key={task.id}>
<div className="flex items-start justify-between gap-3">
<h3 className="font-medium text-white">{task.title}</h3>
<Badge variant={task.priority === "Critical" ? "warning" : "outline"}>
{task.priority}
</Badge>
</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>
<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>
{task.result_summary ? (
<div className="mt-3 rounded-lg border border-emerald-400/20 bg-emerald-400/5 p-3">
<p className="text-xs uppercase tracking-[0.2em] text-emerald-300/80">Latest Result</p>
<p className="mt-1 text-sm text-slate-200">{task.result_summary}</p>
{task.result_detail ? (
<p className="mt-1 text-xs text-slate-400">{task.result_detail}</p>
) : null}
</div>
) : null}
<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>
</Card>
);
})}
</div>
</div>
);
}