Menu
popagent
publicLatest change ba0eef027b1997348abe58363914b9dc48c763dc - Handle workspace polling aborts by Ólafur Búi Ólafsson
import { useEffect, useRef, useState } from "react";
import { Activity, AlertTriangle, Bot, CheckCircle2, CircleDashed, FolderGit2, LoaderCircle, Menu, MessageSquare, Plus, Trash2, X } from "lucide-react";
import type { AgentTask, AgentWorkspace, AutomationStatus, SessionSummary } from "../api-types";
import { apiFetch } from "./api";
import { useFocusTrap } from "./use-focus-trap";
import { TaskKanbanBoard } from "./TaskKanban";
type WorkspaceOverviewProps = {
workspaces: AgentWorkspace[];
sessions: SessionSummary[];
tasks: AgentTask[];
taskStepLimit?: number;
onCreateTask: (workspaceId: string, prompt: string) => Promise<boolean>;
onCancelTask: (task: AgentTask) => void;
onRemoveTask: (task: AgentTask) => Promise<boolean>;
onOpen: (workspaceId: string) => void;
onManage: (workspace?: AgentWorkspace) => void;
onOpenNavigation: () => void;
};
function latestActivity(workspace: AgentWorkspace, sessions: SessionSummary[], tasks: AgentTask[]): string {
const timestamps = [
workspace.updatedAt,
...sessions.filter((session) => session.workspaceId === workspace.id).map((session) => session.updatedAt),
...tasks.filter((task) => task.workspaceId === workspace.id).map((task) => task.completedAt ?? task.startedAt ?? task.createdAt),
].filter(Boolean).map((value) => new Date(value).getTime());
const latest = Math.max(...timestamps);
return Number.isFinite(latest) ? new Date(latest).toLocaleString() : "No activity";
}
function TaskCreateDialog({
open,
workspaces,
workspaceId,
prompt,
creating,
onWorkspaceChange,
onPromptChange,
onCancel,
onSubmit,
}: {
open: boolean;
workspaces: AgentWorkspace[];
workspaceId: string;
prompt: string;
creating: boolean;
onWorkspaceChange: (workspaceId: string) => void;
onPromptChange: (prompt: string) => void;
onCancel: () => void;
onSubmit: () => void;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, open);
useEffect(() => {
if (!open) return;
dialogRef.current?.querySelector<HTMLSelectElement>("select")?.focus();
const close = (event: KeyboardEvent) => {
if (event.key === "Escape" && !creating) onCancel();
};
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [creating, onCancel, open]);
if (!open) return null;
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="start-task-title" className="w-full max-w-xl rounded-xl border border-line bg-bg p-5 shadow-2xl md:p-6">
<div className="flex items-start justify-between gap-4">
<div><h2 id="start-task-title" className="text-xl font-semibold tracking-[-0.03em]">Start a task</h2><p className="mt-1 text-sm leading-relaxed text-ink-muted">Choose where the agents should work, then describe the outcome and evidence you expect.</p></div>
<button type="button" aria-label="Close task dialog" disabled={creating} onClick={onCancel} className="flex size-10 shrink-0 items-center justify-center rounded-md text-ink-dim hover:bg-panel-2 hover:text-ink disabled:opacity-50"><X className="size-4" /></button>
</div>
<div className="mt-6 space-y-4">
<label className="block text-xs font-medium text-ink-dim">Repository workspace
<select aria-label="Task workspace" value={workspaceId} onChange={(event) => onWorkspaceChange(event.currentTarget.value)} className="mt-1.5 min-h-11 w-full rounded-md border border-line bg-panel px-3 text-sm text-ink outline-none focus:border-accent/50">
{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}
</select>
</label>
<label className="block text-xs font-medium text-ink-dim">Outcome and evidence
<textarea aria-label="Task prompt" rows={6} value={prompt} onChange={(event) => onPromptChange(event.currentTarget.value)} placeholder="What should change, and what proof should the agents return?" className="mt-1.5 block w-full resize-y rounded-md border border-line bg-panel p-3 text-sm leading-relaxed text-ink outline-none focus:border-accent/50" />
</label>
</div>
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" disabled={creating} onClick={onCancel} className="min-h-11 rounded-md border border-line px-4 text-sm text-ink-muted hover:bg-panel-2 hover:text-ink disabled:opacity-50">Cancel</button>
<button type="button" disabled={creating || !workspaceId || !prompt.trim()} onClick={onSubmit} className="min-h-11 rounded-md bg-accent px-5 text-sm font-medium text-white disabled:opacity-50">{creating ? "Starting…" : "Start task"}</button>
</div>
</div>
</div>;
}
export function TaskRemoveDialog({ task, removing, onCancel, onRemove }: { task?: AgentTask; removing: boolean; onCancel: () => void; onRemove: () => 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" && !removing) onCancel();
};
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [onCancel, removing, task]);
if (!task) return null;
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div ref={dialogRef} role="alertdialog" aria-modal="true" aria-labelledby="remove-task-title" className="w-full max-w-md rounded-xl border border-line bg-bg p-5 shadow-2xl">
<h2 id="remove-task-title" className="text-lg font-semibold">Remove task from board?</h2>
<p className="mt-2 text-sm leading-relaxed text-ink-muted">This permanently removes the task history and evidence from Popagent. It does not undo repository changes.</p>
<p className="mt-3 line-clamp-3 rounded-md bg-panel p-3 text-xs text-ink-muted">{task.prompt}</p>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" disabled={removing} onClick={onCancel} className="min-h-11 rounded-md border border-line px-4 text-sm text-ink-muted disabled:opacity-50">Keep task</button>
<button type="button" disabled={removing} onClick={onRemove} className="min-h-11 rounded-md bg-red-500 px-4 text-sm font-medium text-white disabled:opacity-50">{removing ? "Removing…" : "Remove task"}</button>
</div>
</div>
</div>;
}
export function WorkspaceOverview({
workspaces,
sessions,
tasks,
taskStepLimit,
onCreateTask,
onCancelTask,
onRemoveTask,
onOpen,
onManage,
onOpenNavigation,
}: WorkspaceOverviewProps) {
const startTaskButtonRef = useRef<HTMLButtonElement>(null);
const [taskDialogOpen, setTaskDialogOpen] = useState(false);
const [taskWorkspaceId, setTaskWorkspaceId] = useState(workspaces[0]?.id ?? "");
const [taskPrompt, setTaskPrompt] = useState("");
const [creatingTask, setCreatingTask] = useState(false);
const [removeTarget, setRemoveTarget] = useState<AgentTask>();
const [removingTask, setRemovingTask] = useState(false);
const [automation, setAutomation] = useState<AutomationStatus>();
useEffect(() => {
const controller = new AbortController();
const load = async () => {
const response = await apiFetch("/api/autonomy/status", { signal: controller.signal });
if (response.ok) setAutomation(await response.json() as AutomationStatus);
};
void load().catch(() => undefined);
const timer = setInterval(() => void load().catch(() => undefined), 2_000);
return () => { controller.abort(); clearInterval(timer); };
}, []);
useEffect(() => {
if (!workspaces.some((workspace) => workspace.id === taskWorkspaceId)) setTaskWorkspaceId(workspaces[0]?.id ?? "");
}, [taskWorkspaceId, workspaces]);
const workspaceNames = Object.fromEntries(workspaces.map((workspace) => [workspace.id, workspace.name]));
const statusCounts = {
queued: tasks.filter((task) => task.status === "queued").length,
running: tasks.filter((task) => task.status === "running" || task.status === "cancelling").length,
attention: tasks.filter((task) => task.status === "failed" || task.status === "cancelled" || task.status === "dead-letter").length,
completed: tasks.filter((task) => task.status === "completed").length,
};
const statusCards = [
{ label: "Queued", aria: "queued", count: statusCounts.queued, detail: "Waiting to start", icon: CircleDashed, color: "text-sky-400" },
{ label: "Running", aria: "running", count: statusCounts.running, detail: "Agents working now", icon: LoaderCircle, color: "text-accent" },
{ label: "Needs attention", aria: "needing attention", count: statusCounts.attention, detail: "Failed or stopped", icon: AlertTriangle, color: "text-amber-400" },
{ label: "Completed", aria: "completed", count: statusCounts.completed, detail: "Finished tasks", icon: CheckCircle2, color: "text-emerald-500" },
];
const closeTaskDialog = () => {
if (creatingTask) return;
setTaskDialogOpen(false);
requestAnimationFrame(() => startTaskButtonRef.current?.focus());
};
const submitTask = async () => {
if (!taskWorkspaceId || !taskPrompt.trim() || creatingTask) return;
setCreatingTask(true);
try {
if (await onCreateTask(taskWorkspaceId, taskPrompt.trim())) {
setTaskPrompt("");
setTaskDialogOpen(false);
requestAnimationFrame(() => startTaskButtonRef.current?.focus());
}
} finally {
setCreatingTask(false);
}
};
const removeTask = async () => {
if (!removeTarget || removingTask) return;
setRemovingTask(true);
try {
if (await onRemoveTask(removeTarget)) setRemoveTarget(undefined);
} finally {
setRemovingTask(false);
}
};
return (
<main className="min-w-0 flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
<div className="mx-auto max-w-[90rem]">
<header className="flex flex-wrap items-end justify-between gap-5">
<div>
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-accent">
<button type="button" aria-label="Open workspace navigation" onClick={onOpenNavigation} className="liquid-control flex size-10 items-center justify-center rounded lg:hidden"><Menu className="size-4" /></button>
<Bot className="size-4" />Operations
</div>
<h1 className="text-3xl font-semibold tracking-[-0.04em]">Agent command center</h1>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-ink-muted">See what is running, resolve work that needs attention, and start agent work in any repository.</p>
</div>
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row">
<button type="button" onClick={() => onManage()} className="liquid-control flex min-h-11 items-center justify-center gap-2 rounded px-4 text-sm text-ink"><Plus className="size-4 text-accent" />New workspace</button>
<button ref={startTaskButtonRef} type="button" aria-haspopup="dialog" disabled={!workspaces.length} onClick={() => setTaskDialogOpen(true)} className="flex min-h-11 items-center justify-center gap-2 rounded-md bg-accent px-5 text-sm font-medium text-white disabled:opacity-50"><Plus className="size-4" />Start task</button>
</div>
</header>
<section aria-label="Work status" className="mt-7 grid grid-cols-2 gap-3 xl:grid-cols-4">
{statusCards.map(({ label, aria, count, detail, icon: Icon, color }) => <article key={label} aria-label={`${count} ${count === 1 ? "task" : "tasks"} ${aria}`} className="rounded-xl border border-line bg-panel p-4">
<div className="flex items-center justify-between gap-3"><span className="text-xs font-medium text-ink-muted">{label}</span><Icon aria-hidden="true" className={`size-4 ${color} ${label === "Running" && count ? "animate-pulse motion-reduce:animate-none" : ""}`} /></div>
<div className="mt-3 text-2xl font-semibold tabular-nums tracking-[-0.04em]">{count}</div>
<p className="mt-1 text-[11px] text-ink-dim">{detail}</p>
</article>)}
</section>
{automation ? <section aria-label="Autonomous improvement status" className="mt-4 rounded-xl border border-line bg-panel p-4"><div className="flex flex-wrap items-center justify-between gap-3"><div><p className="text-xs font-medium uppercase tracking-wide text-accent">Autonomous improvement</p><p className="mt-1 text-sm text-ink-muted">{automation.state} · {automation.selectedWorkspaceIds.length} selected {automation.selectedWorkspaceIds.length === 1 ? "repository" : "repositories"}</p></div><span className="text-xs text-ink-dim">Runs continuously</span></div></section> : null}
<TaskKanbanBoard tasks={tasks} stepLimit={taskStepLimit} workspaceNames={workspaceNames} heading="Live work" onCancel={onCancelTask} onRemove={setRemoveTarget} />
<section className="mt-10" aria-labelledby="repositories-title">
<div className="flex items-end justify-between gap-4">
<div><h2 id="repositories-title" className="text-lg font-semibold tracking-[-0.02em]">Repositories</h2><p className="mt-1 text-xs text-ink-dim">Open a workspace for its chats, documentation, and repository context.</p></div>
<span className="text-xs tabular-nums text-ink-dim">{workspaces.length} workspace{workspaces.length === 1 ? "" : "s"}</span>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{workspaces.map((workspace) => {
const workspaceSessions = sessions.filter((session) => session.workspaceId === workspace.id);
const workspaceTasks = tasks.filter((task) => task.workspaceId === workspace.id);
const activeTasks = workspaceTasks.filter((task) => task.status === "queued" || task.status === "running" || task.status === "cancelling");
return (
<article key={workspace.id} className="group rounded-xl border border-line bg-panel p-5 transition-colors hover:border-accent/30">
<div className="flex items-start gap-3">
<div className="brand-orb flex size-10 shrink-0 items-center justify-center rounded"><FolderGit2 className="size-5" /></div>
<div className="min-w-0 flex-1"><h3 className="truncate font-semibold tracking-[-0.02em]">{workspace.name}</h3><code className="mt-1 block truncate text-xs text-ink-dim" title={workspace.repositoryPath}>{workspace.repositoryPath}</code></div>
<button type="button" onClick={() => onManage(workspace)} className="rounded px-2 py-1 text-xs text-ink-dim hover:bg-panel-2 hover:text-ink">Edit</button>
</div>
<div className="mt-5 grid grid-cols-2 gap-2">
<div className="rounded border border-line bg-bg/40 p-3"><div className="flex items-center gap-1.5 text-xs text-ink-dim"><MessageSquare className="size-3.5" />Chats</div><div className="mt-1 text-xl font-semibold">{workspaceSessions.length}</div></div>
<div className="rounded border border-line bg-bg/40 p-3"><div className="flex items-center gap-1.5 text-xs text-ink-dim"><Activity className="size-3.5" />Active work</div><div className="mt-1 text-xl font-semibold">{activeTasks.length}</div></div>
</div>
<div className="mt-4 text-[11px] text-ink-dim">Last activity · {latestActivity(workspace, workspaceSessions, workspaceTasks)}</div>
<button type="button" onClick={() => onOpen(workspace.id)} className="mt-4 min-h-11 w-full rounded bg-accent-soft px-4 text-sm font-medium text-ink transition-colors hover:bg-accent/25">Open workspace</button>
</article>
);
})}
</div>
</section>
</div>
<TaskCreateDialog
open={taskDialogOpen}
workspaces={workspaces}
workspaceId={taskWorkspaceId}
prompt={taskPrompt}
creating={creatingTask}
onWorkspaceChange={setTaskWorkspaceId}
onPromptChange={setTaskPrompt}
onCancel={closeTaskDialog}
onSubmit={() => void submitTask()}
/>
<TaskRemoveDialog task={removeTarget} removing={removingTask} onCancel={() => setRemoveTarget(undefined)} onRemove={() => void removeTask()} />
</main>
);
}
export function WorkspaceChatDeleteDialog({
workspace,
deleting,
error,
onCancel,
onDelete,
}: {
workspace?: AgentWorkspace;
deleting: boolean;
error: string;
onCancel: () => void;
onDelete: () => void;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, Boolean(workspace));
useEffect(() => {
if (!workspace) return;
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
const close = (event: KeyboardEvent) => {
if (event.key === "Escape" && !deleting) onCancel();
};
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [deleting, onCancel, workspace]);
if (!workspace) return null;
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="workspace-chat-delete-title" className="w-full max-w-md rounded border border-line bg-bg p-5 shadow-2xl">
<div className="flex items-center gap-2 text-red-300"><Trash2 className="size-5" /><h2 id="workspace-chat-delete-title" className="text-lg font-semibold text-ink">Delete all chats in “{workspace.name}”?</h2></div>
<p className="mt-3 text-sm leading-relaxed text-ink-dim">This permanently deletes all active and archived chats in this workspace, including their transcripts and Mastra thread data. Independent long-term memories are retained.</p>
{error ? <p role="alert" className="mt-3 text-sm text-red-300">{error}</p> : null}
<div className="mt-5 flex justify-end gap-2"><button type="button" disabled={deleting} onClick={onCancel} className="min-h-11 rounded border border-line px-4 text-sm disabled:opacity-50">Cancel</button><button type="button" disabled={deleting} onClick={onDelete} className="min-h-11 rounded bg-red-500/90 px-4 text-sm font-medium text-white disabled:opacity-50">{deleting ? "Deleting…" : "Delete all chats"}</button></div>
</div>
</div>;
}
type WorkspaceDialogProps = {
workspace?: AgentWorkspace;
open: boolean;
onClose: () => void;
onSaved: (workspace?: AgentWorkspace) => void;
};
export function WorkspaceDialog({ workspace, open, onClose, onSaved }: WorkspaceDialogProps) {
const [name, setName] = useState("");
const [repositoryPath, setRepositoryPath] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, open);
useEffect(() => {
if (!open) return;
setName(workspace?.name ?? "");
setRepositoryPath(workspace?.repositoryPath ?? "");
setError(undefined);
const close = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [onClose, open, workspace]);
if (!open) return null;
const save = async () => {
setSaving(true);
setError(undefined);
const response = await apiFetch(workspace ? `/api/workspaces/${workspace.id}` : "/api/workspaces", {
method: workspace ? "PATCH" : "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name, repositoryPath }),
});
if (response.ok) {
onSaved((await response.json()) as AgentWorkspace);
} else {
const body = await response.json().catch(() => ({})) as { error?: string };
setError(body.error ?? `Unable to save workspace: HTTP ${response.status}`);
}
setSaving(false);
};
const remove = async () => {
if (!workspace || !confirm(`Delete the “${workspace.name}” workspace?`)) return;
setSaving(true);
setError(undefined);
const response = await apiFetch(`/api/workspaces/${workspace.id}`, { method: "DELETE" });
if (response.ok) onSaved();
else {
const body = await response.json().catch(() => ({})) as { error?: string };
setError(body.error ?? `Unable to delete workspace: HTTP ${response.status}`);
}
setSaving(false);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="workspace-dialog-title" className="w-full max-w-lg rounded border border-line bg-panel p-5 shadow-2xl">
<div className="flex items-center gap-3">
<div className="brand-orb flex size-9 items-center justify-center rounded"><FolderGit2 className="size-4" /></div>
<div className="min-w-0 flex-1"><h2 id="workspace-dialog-title" className="font-semibold">{workspace ? "Edit workspace" : "New workspace"}</h2><p className="text-xs text-ink-dim">Repository paths are relative to the configured workspace root.</p></div>
<button type="button" aria-label="Close workspace editor" onClick={onClose} className="liquid-control flex size-10 items-center justify-center rounded"><X className="size-4" /></button>
</div>
<label className="mt-5 block text-xs font-medium text-ink-dim">Name<input autoFocus value={name} onChange={(event) => setName(event.currentTarget.value)} className="mt-1.5 min-h-11 w-full rounded border border-line bg-bg px-3 text-sm text-ink outline-none focus:border-accent/40" /></label>
<label className="mt-4 block text-xs font-medium text-ink-dim">Repository path<input value={repositoryPath} onChange={(event) => setRepositoryPath(event.currentTarget.value)} placeholder="repository-name" className="mt-1.5 min-h-11 w-full rounded border border-line bg-bg px-3 font-mono text-sm text-ink outline-none focus:border-accent/40" /></label>
{error ? <p className="mt-3 text-sm text-red-300">{error}</p> : null}
<div className="mt-5 flex items-center justify-between gap-3">
{workspace && workspace.id !== "default" ? <button type="button" disabled={saving} onClick={() => void remove()} className="min-h-11 rounded px-3 text-sm text-red-300 hover:bg-red-500/10 disabled:opacity-50">Delete workspace</button> : <span />}
<div className="flex gap-2"><button type="button" disabled={saving} onClick={onClose} className="min-h-11 rounded border border-line px-4 text-sm disabled:opacity-50">Cancel</button><button type="button" disabled={saving || !name.trim() || !repositoryPath.trim()} onClick={() => void save()} className="min-h-11 rounded bg-accent px-4 text-sm font-medium text-white disabled:opacity-40">{saving ? "Saving…" : "Save workspace"}</button></div>
</div>
</div>
</div>
);
}