From 01c9a206abe8457cf3e27a974f2387b200c80dd5 Mon Sep 17 00:00:00 2001 From: Christopher Mayor Date: Sat, 7 Mar 2026 14:13:32 -0800 Subject: [PATCH] [taskboard] add task detail pages --- app/api/tasks/[id]/route.ts | 20 ++- app/tasks/[id]/page.tsx | 336 ++++++++++++++++++++++++++++++++++++ components/tasks-client.tsx | 49 +++++- 3 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 app/tasks/[id]/page.tsx diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index acef7d5..0356d6e 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -1,6 +1,24 @@ import { NextResponse } from "next/server"; -import { updateTask, validateTaskPayload } from "@/lib/tasks"; +import { findTask, updateTask, validateTaskPayload } from "@/lib/tasks"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + return NextResponse.json({ error: "invalid_task_id" }, { status: 400 }); + } + + const task = await findTask(numericId); + if (!task) { + return NextResponse.json({ error: "task_not_found" }, { status: 404 }); + } + + return NextResponse.json(task); +} export async function PATCH( request: Request, diff --git a/app/tasks/[id]/page.tsx b/app/tasks/[id]/page.tsx new file mode 100644 index 0000000..6faa8fc --- /dev/null +++ b/app/tasks/[id]/page.tsx @@ -0,0 +1,336 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { listFleetAgents } from "@/lib/agents"; +import { findTask, listTaskEvents } from "@/lib/tasks"; +import { formatDateTime } from "@/lib/utils"; +import type { FleetAgent, TaskEvent, TaskRecord } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +function familyVariant(family: TaskRecord["family"]) { + 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"; +} + +function collectDependencyRows(task: TaskRecord) { + const rows = [ + { label: "Template", value: task.template_key }, + { label: "Repository", value: task.repo_slug }, + { label: "Base Branch", value: task.base_branch }, + { label: "Preferred Agent", value: task.preferred_agent }, + { label: "Model Hint", value: task.model_hint }, + { label: "Reasoning Effort", value: task.reasoning_effort }, + ]; + + return rows.filter((row) => row.value); +} + +function collectRequirementRows(task: TaskRecord) { + const rows = [ + { label: "Assignee", value: task.assignee || "Unassigned" }, + { label: "Family", value: task.family || "manual" }, + { label: "Target Host", value: task.target_host || "n/a" }, + { label: "Target Channel", value: task.target_channel || "n/a" }, + { label: "Dispatch Method", value: task.dispatch_method }, + { label: "Dispatch State", value: task.dispatch_state }, + { label: "Priority", value: task.priority }, + { label: "Status", value: task.status }, + ]; + + return rows; +} + +function findAssignedAgent(task: TaskRecord, agents: FleetAgent[]) { + return agents.find( + (agent) => + agent.assignmentKey === task.assignee || + agent.slug === task.assignee || + agent.aliases.includes(task.assignee), + ); +} + +function renderEventTone(event: TaskEvent["event_type"]) { + if (event === "dispatch_failed") { + return "border-amber-400/20 bg-amber-500/5"; + } + if (event === "dispatch_succeeded" || event === "acknowledged") { + return "border-emerald-400/20 bg-emerald-500/5"; + } + return "border-white/10 bg-slate-950/40"; +} + +export default async function TaskDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + notFound(); + } + + const [task, events, agents] = await Promise.all([ + findTask(numericId), + listTaskEvents(numericId, 100), + listFleetAgents(), + ]); + + if (!task) { + notFound(); + } + + const requirementRows = collectRequirementRows(task); + const dependencyRows = collectDependencyRows(task); + const assignedAgent = findAssignedAgent(task, agents); + + return ( +
+ + +
+
+ Task #{task.id} + {task.family || "manual"} + {task.dispatch_state} + {task.priority} + {task.status} +
+
+ {task.title} + + {task.description || "No description was captured for this task."} + +
+
+ + Back to Board + +
+
+ +
+
+ + + Work Done + Latest execution output, result summary, and completion metadata. + + +
+
+

Latest Result

+

{task.result_summary || "No result has been posted yet."}

+
+
+

Completion

+
+
+
Completed By
+
{task.completed_by || "Pending"}
+
+
+
Completed At
+
{formatDateTime(task.completed_at)}
+
+
+
Acknowledged
+
{formatDateTime(task.acknowledged_at)}
+
+
+
+
+ +
+

Execution Detail

+

+ {task.result_detail || task.last_error || "No detailed execution transcript has been recorded yet."} +

+
+ + {task.last_error ? ( +
+

Latest Failure

+

{task.last_error}

+
+ ) : null} +
+
+ + + + Task History + Chronological events for creation, dispatch, acknowledgement, retries, and completion. + + + {events.length > 0 ? ( + events.map((event) => ( +
+
+
+

{event.summary}

+

+ {event.event_type.replace(/_/g, " ")} + {event.state ? ` • ${event.state}` : ""} +

+
+

{formatDateTime(event.created_at)}

+
+ {event.detail ? ( +

{event.detail}

+ ) : null} +
+ )) + ) : ( +
+ No event history has been recorded for this task yet. +
+ )} +
+
+
+ +
+ + + Requirements + Assignment, routing, and execution constraints that define this task. + + +
+ {requirementRows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
+
+ + + + Dependencies + Tracked repo context, templates, and execution hints tied to this task. + + + {dependencyRows.length > 0 ? ( +
+ {dependencyRows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ ) : ( +

No explicit dependencies or repository context were captured for this task.

+ )} + +
+

Tags

+
+ {task.tags.length > 0 ? ( + task.tags.map((tag) => ( + + {tag} + + )) + ) : ( + No tags + )} +
+
+
+
+ + + + Assigned Agent + Resolved fleet agent context for the current assignee. + + + {assignedAgent ? ( + <> +
+
+
+

{assignedAgent.name}

+

{assignedAgent.role}

+
+ {assignedAgent.family} +
+
+
+
Host
+
{assignedAgent.host}
+
+
+
Runtime
+
{assignedAgent.runtimePath}
+
+
+
Last Heartbeat
+
{formatDateTime(assignedAgent.heartbeatAt)}
+
+
+
+ + Open Agent Details + + + ) : ( +

No configured fleet agent matched this task assignee.

+ )} +
+
+ + + + Timeline + Primary timestamps for audit and review. + + +
+
+
Created
+
{formatDateTime(task.created_at)}
+
+
+
Updated
+
{formatDateTime(task.updated_at)}
+
+
+
Last Dispatch
+
{formatDateTime(task.last_dispatch_at)}
+
+
+
Completed
+
{formatDateTime(task.completed_at)}
+
+
+
+
+
+
+
+ ); +} diff --git a/components/tasks-client.tsx b/components/tasks-client.tsx index b1151c0..960925c 100644 --- a/components/tasks-client.tsx +++ b/components/tasks-client.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { useState } from "react"; import { Badge } from "@/components/ui/badge"; @@ -35,6 +36,7 @@ export function TasksClient({ }) { const [tasks, setTasks] = useState(initialTasks); const [isModalOpen, setIsModalOpen] = useState(false); + const router = useRouter(); async function refreshData() { const taskResponse = await fetch("/api/tasks"); @@ -60,6 +62,10 @@ export function TasksClient({ await refreshData(); } + function openTask(taskId: number) { + router.push(`/tasks/${taskId}`); + } + return (
@@ -102,7 +108,19 @@ export function TasksClient({ {columnTasks.map((task) => ( -
+
openTask(task.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openTask(task.id); + } + }} + role="link" + tabIndex={0} + >

{task.title}

@@ -143,17 +161,40 @@ export function TasksClient({ ) : null}
{task.dispatch_state !== "dispatched" && task.dispatch_state !== "completed" ? ( - ) : null} {task.dispatch_state === "dispatched" ? ( - ) : null} {task.status !== "Done" ? ( - ) : null}