Menu
popagent
publicLatest change c74b92a26f0dc7d069fb77eb84d77238de3de412 - Show newest task activity first by Ólafur Búi Ólafsson
import { useEffect, useRef, useState } from "react";
import { AlertTriangle, Bot, CheckCircle2, CircleDashed, LoaderCircle, RotateCcw, TerminalSquare, Wrench, X, XCircle } from "lucide-react";
import type { AgentActivity, AgentTask, AgentTaskStatus } from "../api-types";
import { useFocusTrap } from "./use-focus-trap";
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, workspaceName, onCancel, onRemove, onOpen }: { task: AgentTask; stepLimit?: number; workspaceName?: string; onCancel: (task: AgentTask) => void; onRemove: (task: AgentTask) => void; onOpen: (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 removable = task.status === "completed" || task.status === "failed" || task.status === "cancelled" || 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>
{workspaceName ? <p className="mt-1 truncate text-[11px] text-accent">{workspaceName}</p> : null}
</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">{stepLimit ? `${task.stepsCompleted} of ${stepLimit} steps` : `${task.stepsCompleted} steps`}</span>
</div>
{stepLimit ? <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" /> : null}
</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>
<button type="button" onClick={() => onOpen(task)} className="mt-2 flex min-h-10 w-full items-center justify-center gap-2 rounded-md border border-line px-3 text-xs text-ink-muted hover:border-accent/30 hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"><TerminalSquare aria-hidden="true" className="size-3.5" />Open task run</button>
{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}
{removable ? <button type="button" onClick={() => onRemove(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">Remove from board</button> : null}
</article>;
}
function ActivityLine({ activity }: { activity: AgentActivity }) {
const tools = Array.isArray(activity.tools) ? activity.tools : [];
return <li className="border-b border-white/5 px-4 py-3 last:border-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
<span className="flex items-center gap-1.5 font-semibold text-emerald-300"><Bot aria-hidden="true" className="size-3.5" />{activity.agentName}</span>
<span className="text-slate-500">step {activity.iteration}{activity.maxIterations ? `/${activity.maxIterations}` : ""}</span>
<span className="text-slate-400">{activity.isFinal ? "complete" : activity.text.trim() ? "thinking" : "reasoning"}</span>
</div>
{tools.length ? <div className="mt-2 flex flex-wrap gap-1.5">{tools.map((tool) => <code key={tool} className="flex items-center gap-1 rounded bg-slate-800 px-2 py-1 text-[11px] text-cyan-300"><Wrench aria-hidden="true" className="size-3" />{tool}</code>)}</div> : null}
{activity.text.trim() ? <pre className="mt-2 whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-slate-300">{activity.text}</pre> : null}
</li>;
}
export function TaskTerminalDialog({ task, stepLimit, onClose }: { task?: AgentTask; stepLimit?: number; onClose: () => void }) {
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, Boolean(task));
useEffect(() => {
if (!task) return;
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
const close = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); };
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [onClose, task]);
if (!task) return null;
const activity = task.activity ?? [];
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-2 sm:p-5">
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="task-run-title" className="flex h-[min(88vh,52rem)] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-slate-700 bg-slate-950 text-slate-100 shadow-2xl">
<header className="flex items-center gap-3 border-b border-slate-800 px-4 py-3">
<TerminalSquare aria-hidden="true" className="size-4 text-emerald-400" />
<div className="min-w-0 flex-1"><h2 id="task-run-title" className="truncate text-sm font-semibold">Task run</h2><p className="truncate text-[11px] text-slate-500">{taskTitle(task.prompt)}</p></div>
<span className="hidden text-xs text-slate-400 sm:block">{task.stepsCompleted}{stepLimit ? ` of ${stepLimit}` : ""} steps · attempt {task.attemptCount}/{task.maxAttempts}</span>
<button type="button" aria-label="Close task run" onClick={onClose} className="flex size-10 items-center justify-center rounded text-slate-400 hover:bg-slate-800 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400"><X className="size-4" /></button>
</header>
<div className="flex min-h-0 flex-1 flex-col md:grid md:grid-cols-[17rem_1fr]">
<aside className="border-b border-slate-800 p-4 text-xs md:border-b-0 md:border-r">
<dl className="grid grid-cols-2 gap-3 md:grid-cols-1"><div><dt className="text-slate-500">Status</dt><dd className="mt-1 text-slate-200">{statusLabel(task.status)}</dd></div><div><dt className="text-slate-500">Model</dt><dd className="mt-1 break-all font-mono text-slate-300">{task.model}</dd></div><div><dt className="text-slate-500">Current activity</dt><dd className="mt-1 text-slate-300">{task.progress ?? "Waiting for activity"}</dd></div><div><dt className="text-slate-500">Started</dt><dd className="mt-1 text-slate-300">{new Date(task.startedAt ?? task.createdAt).toLocaleString()}</dd></div></dl>
</aside>
<section aria-label="Run activity" aria-live="polite" className="min-h-0 overflow-y-auto bg-[#070b10]">
{activity.length ? <ol>{activity.toReversed().map((entry, index) => <ActivityLine key={`${entry.runId}-${entry.iteration}-${index}`} activity={entry} />)}</ol> : <div className="flex h-full min-h-48 items-center justify-center px-6 text-center text-sm text-slate-500">{task.status === "queued" ? "Waiting for an agent…" : "No detailed activity was recorded for this run."}</div>}
{task.error ? <div role="alert" className="m-4 rounded border border-red-900 bg-red-950/50 p-3 text-xs text-red-300">{task.error}</div> : null}
{task.output ? <div className="m-4"><h3 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-slate-500">Final output</h3><pre className="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-slate-300">{task.output}</pre></div> : null}
</section>
</div>
</div>
</div>;
}
export function TaskKanbanBoard({
tasks,
stepLimit,
workspaceNames,
heading = "Work board",
onCancel,
onRemove,
}: {
tasks: AgentTask[];
stepLimit?: number;
workspaceNames?: Record<string, string>;
heading?: string;
onCancel: (task: AgentTask) => void;
onRemove: (task: AgentTask) => void;
}) {
const [openTask, setOpenTask] = useState<AgentTask>();
const currentOpenTask = openTask ? tasks.find((task) => task.id === openTask.id) ?? openTask : undefined;
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">{heading}</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 items-start 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="max-h-[32rem] space-y-2.5 overflow-y-auto pr-1">{laneTasks.length ? laneTasks.map((task) => <TaskCard key={task.id} task={task} stepLimit={stepLimit} workspaceName={workspaceNames?.[task.workspaceId]} onCancel={onCancel} onRemove={onRemove} onOpen={setOpenTask} />) : <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>
<TaskTerminalDialog task={currentOpenTask} stepLimit={stepLimit} onClose={() => setOpenTask(undefined)} />
</section>;
}