Menu
popagent
publicLatest change fadf21d1cd584745f6f92eaa60509e0bef19d242 - fix task orchestration and compact overview cards by AkurAI Build
import { useEffect, useRef, useState } from "react";
import { AlertTriangle, Bot, CheckCircle2, CircleDashed, LoaderCircle, MoreHorizontal, RotateCcw, TerminalSquare, Wrench, X, XCircle } from "lucide-react";
import type { AgentActivity, AgentTask, AgentTaskStatus, AgentToolActivity } 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);
}
export function formatTaskTimestamp(timestamp: string, now = new Date()): string {
const elapsedSeconds = Math.round((new Date(timestamp).getTime() - now.getTime()) / 1_000);
const units = [
["year", 31_536_000],
["month", 2_592_000],
["day", 86_400],
["hour", 3_600],
["minute", 60],
] as const;
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
for (const [unit, seconds] of units) {
if (Math.abs(elapsedSeconds) >= seconds) return formatter.format(Math.round(elapsedSeconds / seconds), unit);
}
return formatter.format(elapsedSeconds, "second");
}
function taskCardActivity(task: AgentTask): string {
if (task.status === "cancelling") return "Stopping safely…";
const agentName = task.progress?.match(/^([^:]{1,40}):/)?.[1]?.trim();
return agentName ? `${agentName} is working` : "Agent is working";
}
function TaskCard({ task, workspaceName, onCancel, onRemove, onOpen }: { task: AgentTask; 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">
<button type="button" aria-label={`Open task run: ${taskTitle(task.prompt)}`} onClick={() => onOpen(task)} className="text-left text-ink hover:text-accent focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50">{taskTitle(task.prompt)}</button>
</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>
<div className="flex shrink-0 items-start gap-1">
{task.status === "running" ? <LoaderCircle aria-hidden="true" className="mt-2 size-4 animate-spin text-accent motion-reduce:animate-none" /> : task.status === "completed" ? <CheckCircle2 aria-hidden="true" className="mt-2 size-4 text-emerald-500" /> : failed ? <XCircle aria-hidden="true" className="mt-2 size-4 text-red-400" /> : null}
<details className="relative">
<summary aria-label="Task actions" className="flex size-8 cursor-pointer list-none items-center justify-center rounded-md text-ink-dim hover:bg-surface hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"><MoreHorizontal aria-hidden="true" className="size-4" /></summary>
<div className="absolute right-0 z-20 mt-1 w-44 overflow-hidden rounded-lg border border-line bg-surface p-1 shadow-xl">
<button type="button" onClick={() => onOpen(task)} className="flex min-h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-xs text-ink-muted hover:bg-surface-hover 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="min-h-9 w-full rounded-md px-2.5 text-left text-xs text-red-400 hover:bg-red-500/10 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="min-h-9 w-full rounded-md px-2.5 text-left text-xs text-red-400 hover:bg-red-500/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50">Remove from board</button> : null}
</div>
</details>
</div>
</div>
{active ? <div className="mt-3 rounded-md border border-accent/15 bg-accent-soft/40 p-2.5 text-xs text-ink-muted">
{taskCardActivity(task)}
</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">
<time dateTime={new Date(timestamp).toISOString()} title={new Date(timestamp).toLocaleString()}>{formatTaskTimestamp(timestamp)}</time>
<span className="uppercase tracking-wider">{task.remediationForTaskId ? "Remediation" : task.source === "build-maintenance" ? "Build maintenance" : task.source === "self-update" ? "Self-update" : "User"}</span>
</div>
</article>;
}
function jsonPreview(value: unknown): string {
if (value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function ActivityLine({ activity }: { activity: AgentActivity }) {
const toolCalls: AgentToolActivity[] = activity.toolCalls ?? activity.tools.map((name, index) => ({
id: `${activity.runId}-${activity.iteration}-${index}`,
name,
args: {},
status: "complete",
}));
const failed = activity.finishReason === "error";
return <li className="flex gap-3 px-4 py-4 sm:px-6">
<div className={`mt-0.5 flex size-8 shrink-0 items-center justify-center rounded border ${failed ? "border-red-500/30 bg-red-500/10 text-red-400" : "border-accent/25 bg-accent-soft text-accent"}`}><Bot aria-hidden="true" className="size-4" /></div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-xs">
<strong className="text-ink">{activity.agentName}</strong>
<span className="text-ink-dim">iteration {activity.iteration}{activity.maxIterations ? ` of ${activity.maxIterations}` : ""}</span>
<span className={`rounded-sm px-1.5 py-0.5 text-[10px] ${failed ? "bg-red-500/10 text-red-400" : activity.isFinal ? "bg-emerald-500/10 text-emerald-500" : "bg-accent-soft text-accent"}`}>{activity.isFinal ? activity.finishReason : activity.text.trim() ? "thinking" : activity.finishReason}</span>
</div>
{activity.text.trim() ? <div className="md-content mt-2 whitespace-pre-wrap break-words text-sm leading-relaxed text-ink-muted">{activity.text}</div> : null}
{toolCalls.map((tool) => {
const details = [
["Arguments", jsonPreview(tool.args)],
["Result", jsonPreview(tool.result)],
["Error", tool.error ?? ""],
].filter((entry) => entry[1]);
return <details key={tool.id} className={`mt-2 rounded border bg-panel-2 text-xs ${tool.status === "error" ? "border-red-500/30" : "border-line"}`} open={tool.status === "error"}>
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-ink-dim focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/50">
<Wrench aria-hidden="true" className="size-3.5 text-accent" />
<code className="min-w-0 flex-1 truncate text-ink">{tool.name}</code>
<span className={tool.status === "error" ? "text-red-400" : ""}>{tool.status}</span>
</summary>
{details.length ? <div className="space-y-2 border-t border-line px-3 py-2">{details.map(([label, value]) => <div key={label}><div className="mb-1 font-medium text-ink-dim">{label}</div><pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all rounded bg-bg p-2 text-[11px] text-ink">{value}</pre></div>)}</div> : null}
</details>;
})}
</div>
</li>;
}
export function TaskTerminalDialog({ task, stepLimit, onClose }: { task?: AgentTask; stepLimit?: number; onClose: () => void }) {
const dialogRef = useRef<HTMLDivElement>(null);
const transcriptRef = useRef<HTMLElement>(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]);
useEffect(() => {
if (task?.status === "running" || task?.status === "cancelling") transcriptRef.current?.scrollTo({ top: transcriptRef.current.scrollHeight, behavior: "smooth" });
}, [task?.activity?.length, task?.status]);
if (!task) return null;
const activity = task.activity ?? [];
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-2 backdrop-blur-sm sm:p-5">
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="task-run-title" className="flex h-[min(92vh,58rem)] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-line bg-bg text-ink shadow-2xl">
<header className="liquid-header flex items-center gap-3 border-b border-line px-4 py-3">
<Bot aria-hidden="true" className="size-4 text-accent" />
<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-ink-dim">{taskTitle(task.prompt)}</p></div>
<span className="hidden text-xs text-ink-dim 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="liquid-control flex size-10 items-center justify-center rounded text-ink-muted hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"><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-line bg-panel/60 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-ink-dim">Status</dt><dd className="mt-1 text-ink">{statusLabel(task.status)}</dd></div><div><dt className="text-ink-dim">Model</dt><dd className="mt-1 break-all font-mono text-ink-muted">{task.model}</dd></div><div><dt className="text-ink-dim">Current activity</dt><dd className="mt-1 text-ink-muted">{task.progress ?? "Waiting for activity"}</dd></div><div><dt className="text-ink-dim">Started</dt><dd className="mt-1 text-ink-muted">{new Date(task.startedAt ?? task.createdAt).toLocaleString()}</dd></div></dl>
</aside>
<section ref={transcriptRef} aria-label="Run activity" aria-live="polite" className="min-h-0 overflow-y-auto bg-bg">
<div className="flex justify-end px-4 py-4 sm:px-6"><div className="user-bubble max-w-[85%] rounded rounded-br-sm border px-4 py-2.5"><div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-ink-dim">You</div><div className="whitespace-pre-wrap text-sm leading-relaxed">{task.prompt}</div></div></div>
{activity.length ? <ol className="divide-y divide-line">{activity.map((entry, index) => <ActivityLine key={`${entry.runId}-${entry.iteration}-${index}`} activity={entry} />)}</ol> : <div className="flex min-h-48 items-center justify-center px-6 text-center text-sm text-ink-dim">{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-500/30 bg-red-500/5 p-3 text-xs text-red-400">{task.error}</div> : null}
{task.output ? <div className="flex gap-3 border-t border-line px-4 py-4 sm:px-6"><div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded border border-accent/25 bg-accent-soft"><Bot className="size-4 text-accent" /></div><div className="min-w-0 flex-1"><strong className="text-xs">Orchistrator</strong><div className="md-content mt-2 whitespace-pre-wrap break-words text-sm leading-relaxed text-ink-muted">{task.output}</div></div></div> : null}
{(task.status === "running" || task.status === "cancelling") ? <div className="flex items-center gap-2 border-t border-line px-6 py-3 text-xs text-ink-dim"><LoaderCircle className="size-3.5 animate-spin text-accent motion-reduce:animate-none" />Thinking and tool activity update live</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} 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>;
}