Menu
popagent
publicLatest change 6edc7e928c57dab23c0973ed3f7ee70b3ebb72d5 - Initial popagent baseline by AkurAI Build
import { useEffect, useRef, useState } from "react";
import { Activity, FolderGit2, LayoutDashboard, Menu, MessageSquare, Plus, X } from "lucide-react";
import type { AgentTask, AgentWorkspace, SessionSummary } from "../api-types";
import { apiFetch } from "./api";
import { useFocusTrap } from "./use-focus-trap";
type WorkspaceOverviewProps = {
workspaces: AgentWorkspace[];
sessions: SessionSummary[];
tasks: AgentTask[];
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";
}
export function WorkspaceOverview({
workspaces,
sessions,
tasks,
onOpen,
onManage,
onOpenNavigation,
}: WorkspaceOverviewProps) {
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-5xl">
<div className="flex flex-wrap items-end justify-between gap-4">
<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>
<LayoutDashboard className="size-4" />Master view
</div>
<h1 className="text-3xl font-semibold tracking-[-0.04em]">Workspace overview</h1>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-ink-muted">
Repositories, chats, and autonomous work in one place. Open a workspace to continue its conversations.
</p>
</div>
<button type="button" onClick={() => onManage()} className="liquid-control flex min-h-11 items-center gap-2 rounded px-4 text-sm text-ink">
<Plus className="size-4 text-accent" />New workspace
</button>
</div>
<div className="mt-7 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 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">
<h2 className="truncate font-semibold tracking-[-0.02em]">{workspace.name}</h2>
<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>
</div>
</main>
);
}
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>
);
}