-
-
-
{event.summary}
-
Task #{event.task_id} • {event.assignee || "unassigned"} • {event.host || "n/a"}
-
-
-
- {event.family || "manual"}
-
-
- {event.event_type}
-
-
+
+
+ Dispatch History
+
+ Every dispatch request, success, failure, and acknowledgement recorded by the control plane.
+
+
+
+ {events.map((event) => (
+
+
+
+
{event.summary}
+
+ Task #{event.task_id} • {event.assignee || "unassigned"} • {event.host || "n/a"}
+
+
+
+
+ {event.family || "manual"}
+
+
+ {event.event_type}
+
- {event.detail ?
{event.detail}
: null}
-
{event.created_at}
- ))}
-
-
-
-
-
- Failure Queue
-
- Tasks with a failed dispatch state that still require operator review or retry.
-
-
-
- {failedTasks.length === 0 ? (
- No failed dispatches recorded.
- ) : (
- failedTasks.map((task) => (
-
-
-
-
{task.title}
-
{task.assignee || "Unassigned"} • {task.target_host || "n/a"}
-
-
{task.dispatch_state}
-
-
{task.last_error || "No error text captured."}
-
- ))
- )}
-
-
-
+ {event.detail ? {event.detail}
: null}
+ {event.created_at}
+
+ ))}
+
+
);
}
diff --git a/components/failure-queue.tsx b/components/failure-queue.tsx
new file mode 100644
index 0000000..d8d7c90
--- /dev/null
+++ b/components/failure-queue.tsx
@@ -0,0 +1,49 @@
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import type { TaskRecord } from "@/lib/types";
+
+export function FailureQueue({
+ failedTasks,
+}: {
+ failedTasks: TaskRecord[];
+}) {
+ return (
+
+
+ Failure Queue
+
+ Tasks with failed dispatch state that still need operator review or retry.
+
+
+
+ {failedTasks.length === 0 ? (
+ No failed dispatches recorded.
+ ) : (
+ failedTasks.map((task) => (
+
+
+
+
{task.title}
+
+ {task.assignee || "Unassigned"} • {task.target_host || "n/a"}
+
+
+
{task.dispatch_state}
+
+
+ {task.last_error || "No error text captured."}
+
+
+ {task.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/components/task-intake-modal.tsx b/components/task-intake-modal.tsx
new file mode 100644
index 0000000..6500bb8
--- /dev/null
+++ b/components/task-intake-modal.tsx
@@ -0,0 +1,245 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { CardDescription, 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, TaskPriority, TaskTemplate } from "@/lib/types";
+
+const PRIORITIES: TaskPriority[] = ["Low", "Medium", "High", "Critical"];
+
+export function TaskIntakeModal({
+ agents,
+ templates,
+ open,
+ onClose,
+ onCreated,
+}: {
+ agents: FleetAgent[];
+ templates: TaskTemplate[];
+ open: boolean;
+ onClose: () => void;
+ onCreated: () => Promise
;
+}) {
+ 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;
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ onClose();
+ }
+ }
+
+ window.addEventListener("keydown", onKeyDown);
+ document.body.style.overflow = "hidden";
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ document.body.style.overflow = "";
+ };
+ }, [onClose, open]);
+
+ 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) {
+ 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 onCreated();
+ onClose();
+ }
+
+ if (!open) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ New Task
+
+ Create a typed task and route it to the right execution family without leaving the board.
+
+
+
+ Close
+
+
+
+
+
+
+ );
+}
diff --git a/components/tasks-client.tsx b/components/tasks-client.tsx
index bf363ad..f9ea9e8 100644
--- a/components/tasks-client.tsx
+++ b/components/tasks-client.tsx
@@ -5,20 +5,10 @@ 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";
+import { TaskIntakeModal } from "@/components/task-intake-modal";
+import type { FleetAgent, 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") {
@@ -36,107 +26,19 @@ function dispatchVariant(state: TaskRecord["dispatch_state"]) {
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");
+ const [isModalOpen, setIsModalOpen] = useState(false);
async function refreshData() {
- const [taskResponse, eventResponse] = await Promise.all([
- fetch("/api/tasks"),
- fetch("/api/dispatch-history"),
- ]);
+ const taskResponse = await fetch("/api/tasks");
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) {
- 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) {
@@ -160,156 +62,20 @@ export function TasksClient({
return (
-
-
-
- Unified Task Intake
+
+
+
+
Taskboard
- Create typed tasks, apply dispatch templates, and route work to OpenClaw, ZeroClaw, or direct host targets.
+ The board is the primary workspace. Task intake opens as a modal so the board keeps its full visual width.
-
-
-
- applyTemplate(event.target.value)}>
- Select template
- {templates.map((template) => (
-
- {template.title} • {template.family}
-
- ))}
-
- setFormState((current) => ({ ...current, assignee: event.target.value }))}
- >
- Select agent
- {agents.map((agent) => (
-
- {agent.name} • {agent.family} • {agent.host}
-
- ))}
-
- setFormState((current) => ({ ...current, title: event.target.value }))}
- />
-
- setFormState((current) => ({ ...current, priority: event.target.value as TaskPriority }))
- }
- >
- {PRIORITIES.map((priority) => (
-
- {priority}
-
- ))}
-
- setFormState((current) => ({ ...current, repoSlug: event.target.value }))}
- />
- setFormState((current) => ({ ...current, baseBranch: event.target.value }))}
- />
- setFormState((current) => ({ ...current, preferredAgent: event.target.value }))}
- />
- setFormState((current) => ({ ...current, reasoningEffort: event.target.value }))}
- />
- setFormState((current) => ({ ...current, tags: event.target.value }))}
- />
- setFormState((current) => ({ ...current, modelHint: event.target.value }))}
- />
-
- setFormState((current) => ({ ...current, description: event.target.value }))}
- />
-
-
-
- {selectedAgent
- ? `Dispatch target: ${selectedAgent.family} on ${selectedAgent.host}`
- : selectedTemplate
- ? `Template dispatch: ${selectedTemplate.defaults.dispatchMethod}`
- : "Select an agent or template to prefill dispatch metadata."}
-
-
Create Task
-
-
-
-
-
-
-
- Failure Queue
-
- Failed dispatches stay visible until retried or completed manually.
-
-
-
- {failedTasks.length === 0 ? (
- No failed dispatches.
- ) : (
- failedTasks.map((task) => (
-
-
-
{task.title}
-
{task.dispatch_state}
-
-
{task.last_error || "No error text captured."}
-
dispatchTask(task.id)}>
- Retry Dispatch
-
-
- ))
- )}
-
-
-
-
-
-
- Recent Dispatch Activity
-
- Latest control-plane events across all configured task families.
-
+
+
+ {tasks.length} total
+ {tasks.filter((task) => task.status === "In Progress").length} active
+ setIsModalOpen(true)}>New Task
+
-
- {events.slice(0, 6).map((event) => (
-
-
- {event.family || "manual"}
-
- {event.event_type}
-
-
-
{event.summary}
-
{event.detail || "No detail captured."}
-
{event.created_at}
-
- ))}
-
@@ -402,6 +168,14 @@ export function TasksClient({
+
+