From 2bf137a4376171ff68eb70cce6d078741d7072ab Mon Sep 17 00:00:00 2001 From: Christopher Mayor Date: Sat, 7 Mar 2026 13:34:15 -0800 Subject: [PATCH] [taskboard] refactor tasks into full-page workspace --- app/dispatch/page.tsx | 14 +- app/tasks/dispatch/page.tsx | 9 + app/tasks/failures/page.tsx | 9 + app/tasks/layout.tsx | 14 ++ app/tasks/page.tsx | 7 +- components/app-shell.tsx | 55 ++++--- components/dispatch-history.tsx | 91 ++++------- components/failure-queue.tsx | 49 ++++++ components/task-intake-modal.tsx | 245 ++++++++++++++++++++++++++++ components/tasks-client.tsx | 272 +++---------------------------- components/tasks-subnav.tsx | 38 +++++ 11 files changed, 452 insertions(+), 351 deletions(-) create mode 100644 app/tasks/dispatch/page.tsx create mode 100644 app/tasks/failures/page.tsx create mode 100644 app/tasks/layout.tsx create mode 100644 components/failure-queue.tsx create mode 100644 components/task-intake-modal.tsx create mode 100644 components/tasks-subnav.tsx diff --git a/app/dispatch/page.tsx b/app/dispatch/page.tsx index c38089c..d303d71 100644 --- a/app/dispatch/page.tsx +++ b/app/dispatch/page.tsx @@ -1,13 +1,5 @@ -import { DispatchHistory } from "@/components/dispatch-history"; -import { listFailedTasks, listTaskEvents } from "@/lib/tasks"; +import { redirect } from "next/navigation"; -export const dynamic = "force-dynamic"; - -export default async function DispatchPage() { - const [events, failedTasks] = await Promise.all([ - listTaskEvents(undefined, 50), - listFailedTasks(), - ]); - - return ; +export default function DispatchPage() { + redirect("/tasks/dispatch"); } diff --git a/app/tasks/dispatch/page.tsx b/app/tasks/dispatch/page.tsx new file mode 100644 index 0000000..2973714 --- /dev/null +++ b/app/tasks/dispatch/page.tsx @@ -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 ; +} diff --git a/app/tasks/failures/page.tsx b/app/tasks/failures/page.tsx new file mode 100644 index 0000000..7a48574 --- /dev/null +++ b/app/tasks/failures/page.tsx @@ -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 ; +} diff --git a/app/tasks/layout.tsx b/app/tasks/layout.tsx new file mode 100644 index 0000000..d1ecebc --- /dev/null +++ b/app/tasks/layout.tsx @@ -0,0 +1,14 @@ +import { TasksSubnav } from "@/components/tasks-subnav"; + +export default function TasksLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+ + {children} +
+ ); +} diff --git a/app/tasks/page.tsx b/app/tasks/page.tsx index c28fa99..c5beb4f 100644 --- a/app/tasks/page.tsx +++ b/app/tasks/page.tsx @@ -1,15 +1,14 @@ import { TasksClient } from "@/components/tasks-client"; import { listFleetAgents } from "@/lib/agents"; -import { listTaskEvents, listTaskTemplates, listTasks } from "@/lib/tasks"; +import { listTaskTemplates, listTasks } from "@/lib/tasks"; export const dynamic = "force-dynamic"; export default async function TasksPage() { - const [tasks, agents, templates, events] = await Promise.all([ + const [tasks, agents, templates] = await Promise.all([ listTasks(), listFleetAgents(), listTaskTemplates(), - listTaskEvents(undefined, 12), ]); - return ; + return ; } diff --git a/components/app-shell.tsx b/components/app-shell.tsx index 2464a35..15a7d15 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -11,7 +11,6 @@ const navItems = [ { 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 }, @@ -27,8 +26,8 @@ export function AppShell({ return (
-
-
+
+

OpenClaw Taskboard @@ -40,33 +39,35 @@ export function AppShell({ Unified operations view for OpenClaw orchestration, ZeroClaw host runtimes, and deployed architecture.

+ +
-
- - +
{children}
diff --git a/components/dispatch-history.tsx b/components/dispatch-history.tsx index 6492a7f..a23b226 100644 --- a/components/dispatch-history.tsx +++ b/components/dispatch-history.tsx @@ -1,73 +1,44 @@ import { Badge } from "@/components/ui/badge"; 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({ events, - failedTasks, }: { events: TaskEvent[]; - failedTasks: TaskRecord[]; }) { return ( -
- - - 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} - -
+ + + 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. + +
+ +
+ +
+ + + setFormState((current) => ({ ...current, title: event.target.value }))} + /> + + 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 }))} + /> +
+