Menu
popagent
publicLatest change aae5334a8232931db2dad7a987306d228e7e8de6 - Add live task Kanban board by AkurAI Build
import { AlertTriangle, CheckCircle2, CircleDashed, LoaderCircle, RotateCcw, XCircle } from "lucide-react";
import type { AgentTask, AgentTaskStatus } from "../api-types";
export type TaskKanbanLane = "queued" | "active" | "attention" | "done";
export function taskKanbanLane(status: AgentTaskStatus): TaskKanbanLane {
if (status === "queued") return "queued";
if (status === "running" || status === "cancelling") return "active";
if (status === "completed") return "done";
return "attention";
}
const LANES: Array<{
id: TaskKanbanLane;
title: string;
empty: string;
accent: string;
icon: typeof CircleDashed;
}> = [
{ id: "queued", title: "Queued", empty: "No work waiting.", accent: "bg-sky-400", icon: CircleDashed },
{ id: "active", title: "In progress", empty: "No agents working.", accent: "bg-accent", icon: LoaderCircle },
{ id: "attention", title: "Needs attention", empty: "No blocked or failed work.", accent: "bg-amber-400", icon: AlertTriangle },
{ id: "done", title: "Done", empty: "No finished work yet.", accent: "bg-emerald-500", icon: CheckCircle2 },
];
function taskTitle(prompt: string): string {
const firstLine = prompt.split("\n").find((line) => line.trim())?.trim() ?? "Untitled task";
return firstLine.length > 120 ? `${firstLine.slice(0, 117)}…` : firstLine;
}
function statusLabel(status: AgentTaskStatus): string {
return status === "dead-letter" ? "Dead-lettered" : status[0]!.toUpperCase() + status.slice(1);
}
function TaskCard({ task, stepLimit, onCancel }: { task: AgentTask; stepLimit: number; onCancel: (task: AgentTask) => void }) {
const active = task.status === "running" || task.status === "cancelling";
const cancellable = task.status === "queued" || task.status === "running";
const failed = task.status === "failed" || task.status === "dead-letter";
const timestamp = task.completedAt ?? task.startedAt ?? task.createdAt;
return <article className="group rounded-lg border border-line bg-bg p-3.5 shadow-[0_1px_0_rgba(255,255,255,0.03)] transition-colors hover:border-ink-dim/40" aria-label={`${statusLabel(task.status)} task: ${taskTitle(task.prompt)}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="line-clamp-2 text-sm font-medium leading-snug text-ink">{taskTitle(task.prompt)}</h3>
<p className="mt-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-ink-dim">{statusLabel(task.status)} · attempt {task.attemptCount}/{task.maxAttempts}</p>
</div>
{task.status === "running" ? <LoaderCircle aria-hidden="true" className="mt-0.5 size-4 shrink-0 animate-spin text-accent motion-reduce:animate-none" /> : task.status === "completed" ? <CheckCircle2 aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-emerald-500" /> : failed ? <XCircle aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-red-400" /> : null}
</div>
{active ? <div className="mt-3 rounded-md border border-accent/15 bg-accent-soft/40 p-2.5">
<div className="flex items-start justify-between gap-3 text-xs">
<span className="min-w-0 leading-relaxed text-ink-muted">{task.progress ?? (task.status === "cancelling" ? "Stopping safely…" : "Starting work…")}</span>
<span className="shrink-0 font-mono text-[10px] text-ink-dim">{task.stepsCompleted} of {stepLimit} steps</span>
</div>
<progress aria-label={`Progress for ${taskTitle(task.prompt)}`} max={stepLimit} value={Math.min(task.stepsCompleted, stepLimit)} className="mt-2 h-1.5 w-full accent-accent" />
</div> : null}
{task.nextAttemptAt ? <div className="mt-3 flex gap-2 rounded-md border border-amber-500/20 bg-amber-500/5 p-2.5 text-xs text-amber-500"><RotateCcw aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" /><span>Retry {new Date(task.nextAttemptAt).toLocaleString()}{task.lastErrorClass ? ` · ${task.lastErrorClass}` : ""}</span></div> : null}
{task.error ? <p role="alert" className="mt-3 rounded-md border border-red-500/20 bg-red-500/5 p-2.5 text-xs leading-relaxed text-red-400">{task.error}</p> : null}
<div className="mt-3 flex items-center justify-between gap-3 border-t border-line pt-2.5 text-[10px] text-ink-dim">
<span>{new Date(timestamp).toLocaleString()}</span>
<span className="uppercase tracking-wider">{task.source === "self-update" ? "Self-update" : "User"}</span>
</div>
<details className="mt-2 border-t border-line pt-2 text-xs">
<summary className="min-h-10 cursor-pointer select-none py-2 font-medium text-ink-muted outline-none hover:text-ink focus-visible:text-accent">Open full task</summary>
<div className="space-y-3 pb-1">
<div><h4 className="text-[10px] font-semibold uppercase tracking-widest text-ink-dim">Request</h4><p className="mt-1 whitespace-pre-wrap leading-relaxed text-ink-muted">{task.prompt}</p></div>
{task.output ? <div><h4 className="text-[10px] font-semibold uppercase tracking-widest text-ink-dim">Evidence and output</h4><pre className="mt-1 max-h-72 overflow-auto whitespace-pre-wrap rounded-md bg-panel-2 p-3 font-sans text-xs leading-relaxed text-ink-muted">{task.output}</pre></div> : null}
{task.recoveryCount ? <p className="text-amber-500">Recovered {task.recoveryCount} time{task.recoveryCount === 1 ? "" : "s"} after restart.</p> : null}
{task.lastErrorClass && !task.error ? <p className="text-ink-dim">Last error class: {task.lastErrorClass}</p> : null}
</div>
</details>
{cancellable ? <button type="button" onClick={() => onCancel(task)} className="mt-2 min-h-10 w-full rounded-md border border-line px-3 text-xs text-ink-muted hover:border-red-500/30 hover:text-red-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50">Cancel task</button> : null}
</article>;
}
export function TaskKanbanBoard({ tasks, stepLimit, onCancel }: { tasks: AgentTask[]; stepLimit: number; onCancel: (task: AgentTask) => void }) {
return <section aria-label="Task Kanban board" className="mt-5">
<div className="mb-3 flex items-end justify-between gap-4">
<div><h2 className="font-medium">Work board</h2><p className="mt-1 text-xs text-ink-dim">Live agent work, grouped by execution state.</p></div>
<span className="text-xs tabular-nums text-ink-dim">{tasks.length} task{tasks.length === 1 ? "" : "s"}</span>
</div>
<div className="kanban-scroll -mx-4 flex snap-x snap-mandatory gap-3 overflow-x-auto px-4 pb-3 md:mx-0 md:grid md:snap-none md:grid-cols-2 md:overflow-visible md:px-0 xl:grid-cols-4">
{LANES.map((lane) => {
const laneTasks = tasks.filter((task) => taskKanbanLane(task.status) === lane.id);
const Icon = lane.icon;
return <section key={lane.id} aria-labelledby={`task-lane-${lane.id}`} className="w-[85vw] max-w-[22rem] shrink-0 snap-start rounded-xl border border-line bg-panel/70 p-2.5 md:w-auto md:max-w-none">
<header className="mb-2.5 flex items-center gap-2 px-1 py-1">
<span className={`h-5 w-1 rounded-full ${lane.accent}`} aria-hidden="true" />
<Icon aria-hidden="true" className={`size-4 text-ink-dim ${lane.id === "active" && laneTasks.length ? "animate-pulse motion-reduce:animate-none" : ""}`} />
<h3 id={`task-lane-${lane.id}`} className="text-xs font-semibold uppercase tracking-[0.12em] text-ink-muted">{lane.title}</h3>
<span className="ml-auto rounded-full border border-line bg-bg px-2 py-0.5 text-[10px] tabular-nums text-ink-dim">{laneTasks.length}</span>
</header>
<div className="space-y-2.5">{laneTasks.length ? laneTasks.map((task) => <TaskCard key={task.id} task={task} stepLimit={stepLimit} onCancel={onCancel} />) : <p className="rounded-lg border border-dashed border-line px-3 py-8 text-center text-xs text-ink-dim">{lane.empty}</p>}</div>
</section>;
})}
</div>
</section>;
}