[taskboard] add task detail pages

This commit is contained in:
2026-03-07 14:13:32 -08:00
parent 2ec17712c9
commit 01c9a206ab
3 changed files with 400 additions and 5 deletions

View File

@@ -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,

336
app/tasks/[id]/page.tsx Normal file
View File

@@ -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 (
<div className="space-y-6">
<Card className="border-white/10 bg-slate-950/35">
<CardHeader className="gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">Task #{task.id}</Badge>
<Badge variant={familyVariant(task.family)}>{task.family || "manual"}</Badge>
<Badge variant={dispatchVariant(task.dispatch_state)}>{task.dispatch_state}</Badge>
<Badge variant={task.priority === "Critical" ? "warning" : "outline"}>{task.priority}</Badge>
<Badge variant="secondary">{task.status}</Badge>
</div>
<div>
<CardTitle className="text-2xl text-white">{task.title}</CardTitle>
<CardDescription className="mt-2 max-w-3xl text-sm leading-6 text-slate-300">
{task.description || "No description was captured for this task."}
</CardDescription>
</div>
</div>
<Link
className="inline-flex h-10 items-center justify-center rounded-md border border-border bg-transparent px-4 py-2 text-sm font-medium transition-colors hover:bg-secondary/40"
href="/tasks"
>
Back to Board
</Link>
</CardHeader>
</Card>
<div className="grid gap-6 xl:grid-cols-[1.35fr_0.95fr]">
<div className="space-y-6">
<Card className="border-white/10 bg-slate-950/35">
<CardHeader>
<CardTitle>Work Done</CardTitle>
<CardDescription>Latest execution output, result summary, and completion metadata.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Latest Result</p>
<p className="mt-2 text-sm text-slate-100">{task.result_summary || "No result has been posted yet."}</p>
</div>
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Completion</p>
<dl className="mt-2 space-y-2 text-sm text-slate-200">
<div className="flex justify-between gap-3">
<dt className="text-slate-400">Completed By</dt>
<dd className="text-right">{task.completed_by || "Pending"}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-slate-400">Completed At</dt>
<dd className="text-right">{formatDateTime(task.completed_at)}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-slate-400">Acknowledged</dt>
<dd className="text-right">{formatDateTime(task.acknowledged_at)}</dd>
</div>
</dl>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Execution Detail</p>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-200">
{task.result_detail || task.last_error || "No detailed execution transcript has been recorded yet."}
</p>
</div>
{task.last_error ? (
<div className="rounded-xl border border-amber-400/20 bg-amber-500/5 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-amber-300/80">Latest Failure</p>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-200">{task.last_error}</p>
</div>
) : null}
</CardContent>
</Card>
<Card className="border-white/10 bg-slate-950/35">
<CardHeader>
<CardTitle>Task History</CardTitle>
<CardDescription>Chronological events for creation, dispatch, acknowledgement, retries, and completion.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{events.length > 0 ? (
events.map((event) => (
<div className={`rounded-xl border p-4 ${renderEventTone(event.event_type)}`} key={event.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-medium capitalize text-white">{event.summary}</p>
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-400">
{event.event_type.replace(/_/g, " ")}
{event.state ? `${event.state}` : ""}
</p>
</div>
<p className="text-xs text-slate-400">{formatDateTime(event.created_at)}</p>
</div>
{event.detail ? (
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-slate-300">{event.detail}</p>
) : null}
</div>
))
) : (
<div className="rounded-xl border border-dashed border-white/10 bg-slate-950/30 p-6 text-sm text-slate-400">
No event history has been recorded for this task yet.
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card className="border-white/10 bg-slate-950/35">
<CardHeader>
<CardTitle>Requirements</CardTitle>
<CardDescription>Assignment, routing, and execution constraints that define this task.</CardDescription>
</CardHeader>
<CardContent>
<dl className="space-y-3 text-sm">
{requirementRows.map((row) => (
<div className="flex items-start justify-between gap-4 border-b border-white/5 pb-3 last:border-b-0 last:pb-0" key={row.label}>
<dt className="text-slate-400">{row.label}</dt>
<dd className="max-w-[60%] break-words text-right text-slate-100">{row.value}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
<Card className="border-white/10 bg-slate-950/35">
<CardHeader>
<CardTitle>Dependencies</CardTitle>
<CardDescription>Tracked repo context, templates, and execution hints tied to this task.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{dependencyRows.length > 0 ? (
<dl className="space-y-3 text-sm">
{dependencyRows.map((row) => (
<div className="flex items-start justify-between gap-4 border-b border-white/5 pb-3 last:border-b-0 last:pb-0" key={row.label}>
<dt className="text-slate-400">{row.label}</dt>
<dd className="max-w-[60%] break-words text-right text-slate-100">{row.value}</dd>
</div>
))}
</dl>
) : (
<p className="text-sm text-slate-400">No explicit dependencies or repository context were captured for this task.</p>
)}
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Tags</p>
<div className="mt-2 flex flex-wrap gap-2">
{task.tags.length > 0 ? (
task.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))
) : (
<span className="text-sm text-slate-400">No tags</span>
)}
</div>
</div>
</CardContent>
</Card>
<Card className="border-white/10 bg-slate-950/35">
<CardHeader>
<CardTitle>Assigned Agent</CardTitle>
<CardDescription>Resolved fleet agent context for the current assignee.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{assignedAgent ? (
<>
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-base font-medium text-white">{assignedAgent.name}</p>
<p className="mt-1 text-sm text-slate-400">{assignedAgent.role}</p>
</div>
<Badge variant={familyVariant(assignedAgent.family)}>{assignedAgent.family}</Badge>
</div>
<dl className="mt-4 space-y-2 text-sm text-slate-200">
<div className="flex justify-between gap-3">
<dt className="text-slate-400">Host</dt>
<dd>{assignedAgent.host}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-slate-400">Runtime</dt>
<dd className="max-w-[65%] break-words text-right">{assignedAgent.runtimePath}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-slate-400">Last Heartbeat</dt>
<dd>{formatDateTime(assignedAgent.heartbeatAt)}</dd>
</div>
</dl>
</div>
<Link
className="inline-flex h-10 w-full items-center justify-center rounded-md border border-border bg-transparent px-4 py-2 text-sm font-medium transition-colors hover:bg-secondary/40"
href={`/agents/${assignedAgent.slug}`}
>
Open Agent Details
</Link>
</>
) : (
<p className="text-sm text-slate-400">No configured fleet agent matched this task assignee.</p>
)}
</CardContent>
</Card>
<Card className="border-white/10 bg-slate-950/35">
<CardHeader>
<CardTitle>Timeline</CardTitle>
<CardDescription>Primary timestamps for audit and review.</CardDescription>
</CardHeader>
<CardContent>
<dl className="space-y-3 text-sm">
<div className="flex justify-between gap-4 border-b border-white/5 pb-3">
<dt className="text-slate-400">Created</dt>
<dd className="text-right text-slate-100">{formatDateTime(task.created_at)}</dd>
</div>
<div className="flex justify-between gap-4 border-b border-white/5 pb-3">
<dt className="text-slate-400">Updated</dt>
<dd className="text-right text-slate-100">{formatDateTime(task.updated_at)}</dd>
</div>
<div className="flex justify-between gap-4 border-b border-white/5 pb-3">
<dt className="text-slate-400">Last Dispatch</dt>
<dd className="text-right text-slate-100">{formatDateTime(task.last_dispatch_at)}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-slate-400">Completed</dt>
<dd className="text-right text-slate-100">{formatDateTime(task.completed_at)}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<Card className="border-white/10 bg-slate-950/35">
@@ -102,7 +108,19 @@ export function TasksClient({
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
{columnTasks.map((task) => (
<div className="min-w-0 rounded-xl border border-white/10 bg-slate-950/40 p-4" key={task.id}>
<div
className="min-w-0 cursor-pointer rounded-xl border border-white/10 bg-slate-950/40 p-4 transition-colors hover:border-cyan-400/30 hover:bg-slate-950/60"
key={task.id}
onClick={() => openTask(task.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openTask(task.id);
}
}}
role="link"
tabIndex={0}
>
<div className="flex items-start justify-between gap-3">
<h3 className="min-w-0 break-words font-medium text-white">{task.title}</h3>
<Badge variant={task.priority === "Critical" ? "warning" : "outline"}>
@@ -143,17 +161,40 @@ export function TasksClient({
) : 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)}>
<Button
className="w-full"
size="sm"
onClick={(event) => {
event.stopPropagation();
void dispatchTask(task.id);
}}
>
Dispatch
</Button>
) : null}
{task.dispatch_state === "dispatched" ? (
<Button className="w-full" size="sm" variant="outline" onClick={() => acknowledgeTask(task.id)}>
<Button
className="w-full"
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation();
void acknowledgeTask(task.id);
}}
>
Mark Acknowledged
</Button>
) : null}
{task.status !== "Done" ? (
<Button className="w-full" size="sm" variant="outline" onClick={() => patchTask(task.id, { status: "Done" })}>
<Button
className="w-full"
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation();
void patchTask(task.id, { status: "Done" });
}}
>
Mark Done
</Button>
) : null}