[taskboard] refactor tasks into full-page workspace
This commit is contained in:
@@ -1,13 +1,5 @@
|
|||||||
import { DispatchHistory } from "@/components/dispatch-history";
|
import { redirect } from "next/navigation";
|
||||||
import { listFailedTasks, listTaskEvents } from "@/lib/tasks";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export default function DispatchPage() {
|
||||||
|
redirect("/tasks/dispatch");
|
||||||
export default async function DispatchPage() {
|
|
||||||
const [events, failedTasks] = await Promise.all([
|
|
||||||
listTaskEvents(undefined, 50),
|
|
||||||
listFailedTasks(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return <DispatchHistory events={events} failedTasks={failedTasks} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
9
app/tasks/dispatch/page.tsx
Normal file
9
app/tasks/dispatch/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { DispatchHistory } from "@/components/dispatch-history";
|
||||||
|
import { listTaskEvents } from "@/lib/tasks";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function TasksDispatchPage() {
|
||||||
|
const events = await listTaskEvents(undefined, 50);
|
||||||
|
return <DispatchHistory events={events} />;
|
||||||
|
}
|
||||||
9
app/tasks/failures/page.tsx
Normal file
9
app/tasks/failures/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { FailureQueue } from "@/components/failure-queue";
|
||||||
|
import { listFailedTasks } from "@/lib/tasks";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function TasksFailuresPage() {
|
||||||
|
const failedTasks = await listFailedTasks();
|
||||||
|
return <FailureQueue failedTasks={failedTasks} />;
|
||||||
|
}
|
||||||
14
app/tasks/layout.tsx
Normal file
14
app/tasks/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { TasksSubnav } from "@/components/tasks-subnav";
|
||||||
|
|
||||||
|
export default function TasksLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<TasksSubnav />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import { TasksClient } from "@/components/tasks-client";
|
import { TasksClient } from "@/components/tasks-client";
|
||||||
import { listFleetAgents } from "@/lib/agents";
|
import { listFleetAgents } from "@/lib/agents";
|
||||||
import { listTaskEvents, listTaskTemplates, listTasks } from "@/lib/tasks";
|
import { listTaskTemplates, listTasks } from "@/lib/tasks";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function TasksPage() {
|
export default async function TasksPage() {
|
||||||
const [tasks, agents, templates, events] = await Promise.all([
|
const [tasks, agents, templates] = await Promise.all([
|
||||||
listTasks(),
|
listTasks(),
|
||||||
listFleetAgents(),
|
listFleetAgents(),
|
||||||
listTaskTemplates(),
|
listTaskTemplates(),
|
||||||
listTaskEvents(undefined, 12),
|
|
||||||
]);
|
]);
|
||||||
return <TasksClient initialTasks={tasks} initialEvents={events} agents={agents} templates={templates} />;
|
return <TasksClient initialTasks={tasks} agents={agents} templates={templates} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const navItems = [
|
|||||||
{ href: "/agents", label: "Agents", icon: UsersRound },
|
{ href: "/agents", label: "Agents", icon: UsersRound },
|
||||||
{ href: "/openclaw", label: "OpenClaw", icon: ShieldEllipsis },
|
{ href: "/openclaw", label: "OpenClaw", icon: ShieldEllipsis },
|
||||||
{ href: "/zeroclaw", label: "ZeroClaw", icon: Send },
|
{ href: "/zeroclaw", label: "ZeroClaw", icon: Send },
|
||||||
{ href: "/dispatch", label: "Dispatch", icon: Send },
|
|
||||||
{ href: "/architecture", label: "Architecture", icon: Network },
|
{ href: "/architecture", label: "Architecture", icon: Network },
|
||||||
{ href: "/wiki", label: "Wiki", icon: NotebookTabs },
|
{ href: "/wiki", label: "Wiki", icon: NotebookTabs },
|
||||||
{ href: "/usage", label: "Usage", icon: ScrollText },
|
{ href: "/usage", label: "Usage", icon: ScrollText },
|
||||||
@@ -27,8 +26,8 @@ export function AppShell({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(34,211,238,0.18),_transparent_30%),radial-gradient(circle_at_top_right,_rgba(245,158,11,0.14),_transparent_28%),linear-gradient(180deg,#07111f_0%,#091321_44%,#0f172a_100%)] text-foreground">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(34,211,238,0.18),_transparent_30%),radial-gradient(circle_at_top_right,_rgba(245,158,11,0.14),_transparent_28%),linear-gradient(180deg,#07111f_0%,#091321_44%,#0f172a_100%)] text-foreground">
|
||||||
<header className="border-b border-white/10 bg-slate-950/60 backdrop-blur-xl">
|
<header className="sticky top-0 z-40 border-b border-white/10 bg-slate-950/75 backdrop-blur-xl">
|
||||||
<div className="mx-auto flex w-full max-w-[1760px] items-center justify-between px-6 py-6">
|
<div className="mx-auto flex w-full max-w-[1760px] flex-col gap-5 px-6 py-5">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-mono text-xs uppercase tracking-[0.3em] text-cyan-300/80">
|
<p className="font-mono text-xs uppercase tracking-[0.3em] text-cyan-300/80">
|
||||||
OpenClaw Taskboard
|
OpenClaw Taskboard
|
||||||
@@ -40,19 +39,18 @@ export function AppShell({
|
|||||||
Unified operations view for OpenClaw orchestration, ZeroClaw host runtimes, and deployed architecture.
|
Unified operations view for OpenClaw orchestration, ZeroClaw host runtimes, and deployed architecture.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="mx-auto grid w-full max-w-[1760px] gap-6 px-6 py-8 lg:grid-cols-[240px_minmax(0,1fr)] xl:grid-cols-[252px_minmax(0,1fr)]">
|
<nav className="flex flex-wrap gap-2">
|
||||||
<nav className="space-y-2 lg:sticky lg:top-8 lg:self-start">
|
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname === item.href || (item.href !== "/tasks" && pathname.startsWith(`${item.href}/`));
|
pathname === item.href ||
|
||||||
|
pathname.startsWith(`${item.href}/`) ||
|
||||||
|
(item.href === "/tasks" && pathname.startsWith("/tasks"));
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-xl border px-4 py-3 text-sm transition",
|
"inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm transition",
|
||||||
isActive
|
isActive
|
||||||
? "border-cyan-300/30 bg-cyan-300/10 text-cyan-100 shadow-panel"
|
? "border-cyan-300/30 bg-cyan-300/10 text-cyan-100 shadow-panel"
|
||||||
: "border-white/10 bg-slate-950/35 text-slate-300 hover:border-white/20 hover:bg-white/5 hover:text-white",
|
: "border-white/10 bg-slate-950/35 text-slate-300 hover:border-white/20 hover:bg-white/5 hover:text-white",
|
||||||
@@ -66,7 +64,10 @@ export function AppShell({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-[1760px] px-6 py-8">
|
||||||
<main className="min-w-0">{children}</main>
|
<main className="min-w-0">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import type { TaskEvent, TaskRecord } from "@/lib/types";
|
import type { TaskEvent } from "@/lib/types";
|
||||||
|
|
||||||
export function DispatchHistory({
|
export function DispatchHistory({
|
||||||
events,
|
events,
|
||||||
failedTasks,
|
|
||||||
}: {
|
}: {
|
||||||
events: TaskEvent[];
|
events: TaskEvent[];
|
||||||
failedTasks: TaskRecord[];
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Dispatch History</CardTitle>
|
<CardTitle>Dispatch History</CardTitle>
|
||||||
@@ -22,9 +19,11 @@ export function DispatchHistory({
|
|||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4" key={event.id}>
|
<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 className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-white">{event.summary}</p>
|
<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>
|
<p className="text-sm text-slate-400">
|
||||||
|
Task #{event.task_id} • {event.assignee || "unassigned"} • {event.host || "n/a"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Badge variant={event.family === "zeroclaw" ? "success" : event.family === "direct" ? "warning" : "default"}>
|
<Badge variant={event.family === "zeroclaw" ? "success" : event.family === "direct" ? "warning" : "default"}>
|
||||||
@@ -35,39 +34,11 @@ export function DispatchHistory({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{event.detail ? <p className="mt-2 text-sm text-slate-300">{event.detail}</p> : null}
|
{event.detail ? <p className="mt-2 break-words 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>
|
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-slate-500">{event.created_at}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
49
components/failure-queue.tsx
Normal file
49
components/failure-queue.tsx
Normal file
@@ -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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Failure Queue</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Tasks with failed dispatch state that still need 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 className="min-w-0">
|
||||||
|
<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 break-words text-sm text-slate-300">
|
||||||
|
{task.last_error || "No error text captured."}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{task.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
245
components/task-intake-modal.tsx
Normal file
245
components/task-intake-modal.tsx
Normal file
@@ -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<void>;
|
||||||
|
}) {
|
||||||
|
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<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 onCreated();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-slate-950/70 px-4 py-10 backdrop-blur-sm">
|
||||||
|
<div className="absolute inset-0" onClick={onClose} />
|
||||||
|
<div className="relative w-full max-w-3xl rounded-3xl border border-white/10 bg-slate-950/95 shadow-2xl">
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-6 py-5">
|
||||||
|
<div>
|
||||||
|
<CardTitle>New Task</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create a typed task and route it to the right execution family without leaving the board.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="grid gap-3 p-6 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"
|
||||||
|
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 flex-wrap items-center justify-between gap-3 border-t border-white/10 pt-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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Create Task</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,20 +5,10 @@ import { useState } from "react";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { TaskIntakeModal } from "@/components/task-intake-modal";
|
||||||
import { Select } from "@/components/ui/select";
|
import type { FleetAgent, TaskRecord, TaskStatus, TaskTemplate } from "@/lib/types";
|
||||||
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 COLUMNS: TaskStatus[] = ["Backlog", "Todo", "In Progress", "Review", "Done"];
|
||||||
const PRIORITIES: TaskPriority[] = ["Low", "Medium", "High", "Critical"];
|
|
||||||
|
|
||||||
function familyVariant(family: string | null) {
|
function familyVariant(family: string | null) {
|
||||||
if (family === "zeroclaw") {
|
if (family === "zeroclaw") {
|
||||||
@@ -36,107 +26,19 @@ function dispatchVariant(state: TaskRecord["dispatch_state"]) {
|
|||||||
|
|
||||||
export function TasksClient({
|
export function TasksClient({
|
||||||
initialTasks,
|
initialTasks,
|
||||||
initialEvents,
|
|
||||||
agents,
|
agents,
|
||||||
templates,
|
templates,
|
||||||
}: {
|
}: {
|
||||||
initialTasks: TaskRecord[];
|
initialTasks: TaskRecord[];
|
||||||
initialEvents: TaskEvent[];
|
|
||||||
agents: FleetAgent[];
|
agents: FleetAgent[];
|
||||||
templates: TaskTemplate[];
|
templates: TaskTemplate[];
|
||||||
}) {
|
}) {
|
||||||
const [tasks, setTasks] = useState(initialTasks);
|
const [tasks, setTasks] = useState(initialTasks);
|
||||||
const [events, setEvents] = useState(initialEvents);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
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() {
|
async function refreshData() {
|
||||||
const [taskResponse, eventResponse] = await Promise.all([
|
const taskResponse = await fetch("/api/tasks");
|
||||||
fetch("/api/tasks"),
|
|
||||||
fetch("/api/dispatch-history"),
|
|
||||||
]);
|
|
||||||
setTasks((await taskResponse.json()) as TaskRecord[]);
|
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>) {
|
async function patchTask(taskId: number, payload: Partial<TaskRecord>) {
|
||||||
@@ -160,156 +62,20 @@ export function TasksClient({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-4 2xl:grid-cols-[minmax(0,1.35fr)_380px]">
|
<Card className="border-white/10 bg-slate-950/35">
|
||||||
<Card>
|
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<CardHeader>
|
<div>
|
||||||
<CardTitle>Unified Task Intake</CardTitle>
|
<CardTitle>Taskboard</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
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.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">{tasks.length} total</Badge>
|
||||||
|
<Badge variant="secondary">{tasks.filter((task) => task.status === "In Progress").length} active</Badge>
|
||||||
|
<Button onClick={() => setIsModalOpen(true)}>New Task</Button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</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>
|
</Card>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -402,6 +168,14 @@ export function TasksClient({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TaskIntakeModal
|
||||||
|
agents={agents}
|
||||||
|
templates={templates}
|
||||||
|
open={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onCreated={refreshData}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
38
components/tasks-subnav.tsx
Normal file
38
components/tasks-subnav.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ href: "/tasks", label: "Board" },
|
||||||
|
{ href: "/tasks/dispatch", label: "Dispatch" },
|
||||||
|
{ href: "/tasks/failures", label: "Failure Queue" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TasksSubnav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex flex-wrap gap-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-4 py-2 text-sm transition",
|
||||||
|
isActive
|
||||||
|
? "border-cyan-300/30 bg-cyan-300/10 text-cyan-100"
|
||||||
|
: "border-white/10 bg-slate-950/35 text-slate-300 hover:border-white/20 hover:bg-white/5 hover:text-white",
|
||||||
|
)}
|
||||||
|
href={item.href}
|
||||||
|
key={item.href}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user