[taskboard] add task detail pages
This commit is contained in:
@@ -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
336
app/tasks/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user