Menu
popagent
publicLatest change 7cecf6a89b6f39dae8c5678f5b369014199aeb3c - Add self-hosted Mastra observability by AkurAI Build
import { useCallback, useEffect, useRef, useState } from "react";
import { Activity, Bot, Brain, Clock3, Globe, Moon, Palette, Plus, Settings2, SlidersHorizontal, Sun, Trash2, X } from "lucide-react";
import { apiFetch } from "./api";
import { useFocusTrap } from "./use-focus-trap";
import {
AGENT_TOOL_NAMES,
MAX_AGENT_INSTRUCTIONS_CHARACTERS,
type AgentListResponse,
type AgentBrowserAccess,
type AgentRuntimeSettings,
type AgentWorkspace,
type AgentSchedule,
type AgentSettings,
type AgentSkill,
type AgentSkillListResponse,
type AgentToolName,
type AgentTask,
type BrowserSettings,
type MemorySettings,
type ModelCatalogResponse,
type ScheduleListResponse,
type TaskListResponse,
type AgentWorkspaceAccess,
} from "../api-types";
import { describeSchedule, scheduleCron, WEEKDAYS, type ScheduleFrequency } from "./schedules";
import { preferredTheme, saveTheme, type AppearanceTheme } from "./appearance";
import type { SettingsSection } from "./routes";
import { ObservabilityPage } from "./ObservabilityPage";
function RuntimeNumberField({
label,
value,
min,
max,
step = 1,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
}) {
return <label className="text-sm font-medium">{label}<input aria-label={label} type="number" min={min} max={max} step={step} value={value} onChange={(event) => onChange(event.currentTarget.valueAsNumber)} className="mt-2 min-h-11 w-full rounded border border-line bg-bg px-3 text-ink outline-none focus:border-accent/50" /></label>;
}
export function AgentSettingsPage({
open,
section,
onSectionChange,
onClose,
workspaces,
workspaceId,
}: {
open: boolean;
section: SettingsSection;
onSectionChange: (section: SettingsSection) => void;
onClose: () => void;
workspaces: AgentWorkspace[];
workspaceId?: string;
}) {
const [theme, setTheme] = useState<AppearanceTheme>(preferredTheme);
const [agents, setAgents] = useState<AgentSettings[]>([]);
const [selectedId, setSelectedId] = useState<string>();
const [agentName, setAgentName] = useState("");
const [agentDescription, setAgentDescription] = useState("");
const [instructions, setInstructions] = useState("");
const [agentWorkspaceAccess, setAgentWorkspaceAccess] = useState<AgentWorkspaceAccess>("none");
const [agentBrowserAccess, setAgentBrowserAccess] = useState<AgentBrowserAccess>("none");
const [agentDelegationEnabled, setAgentDelegationEnabled] = useState(false);
const [agentTools, setAgentTools] = useState<AgentToolName[]>([]);
const [agentPanel, setAgentPanel] = useState<"instructions" | "skills">("instructions");
const [skills, setSkills] = useState<AgentSkill[]>([]);
const [editingSkill, setEditingSkill] = useState<AgentSkill>();
const [skillName, setSkillName] = useState("");
const [skillDescription, setSkillDescription] = useState("");
const [skillInstructions, setSkillInstructions] = useState("");
const [skillReferences, setSkillReferences] = useState("{}");
const [skillEnabled, setSkillEnabled] = useState(true);
const [skillUserInvocable, setSkillUserInvocable] = useState(true);
const [skillError, setSkillError] = useState("");
const [browserEnabled, setBrowserEnabled] = useState<boolean>();
const [memorySettings, setMemorySettings] = useState<MemorySettings>();
const [runtimeSettings, setRuntimeSettings] = useState<AgentRuntimeSettings>();
const [runtimeModels, setRuntimeModels] = useState<string[]>([]);
const [tasks, setTasks] = useState<AgentTask[]>([]);
const [schedules, setSchedules] = useState<AgentSchedule[]>([]);
const [taskWorkspaceId, setTaskWorkspaceId] = useState(workspaceId ?? workspaces[0]?.id ?? "default");
const [taskPrompt, setTaskPrompt] = useState("");
const [scheduleName, setScheduleName] = useState("");
const [schedulePrompt, setSchedulePrompt] = useState("");
const [scheduleFrequency, setScheduleFrequency] = useState<ScheduleFrequency>("daily");
const [scheduleTime, setScheduleTime] = useState("09:00");
const [scheduleWeekday, setScheduleWeekday] = useState("1");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const closeRef = useRef<HTMLButtonElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const loadSkills = useCallback(async (agentId: string) => {
const response = await apiFetch(`/api/agents/${agentId}/skills`);
if (response.ok) setSkills(((await response.json()) as AgentSkillListResponse).skills);
}, []);
const loadTasks = useCallback(async (selectedWorkspaceId: string) => {
const query = `?workspaceId=${encodeURIComponent(selectedWorkspaceId)}`;
const [taskResponse, scheduleResponse] = await Promise.all([apiFetch(`/api/tasks${query}`), apiFetch(`/api/schedules${query}`)]);
if (taskResponse.ok) setTasks(((await taskResponse.json()) as TaskListResponse).tasks);
if (scheduleResponse.ok) setSchedules(((await scheduleResponse.json()) as ScheduleListResponse).schedules);
}, []);
useEffect(() => {
if (!open) return;
const selectedWorkspaceId = workspaceId ?? workspaces[0]?.id ?? "default";
setTaskWorkspaceId(selectedWorkspaceId);
void loadTasks(selectedWorkspaceId);
}, [loadTasks, open, workspaceId, workspaces]);
useEffect(() => {
if (!open) return;
void Promise.all([
apiFetch("/api/agents"),
apiFetch("/api/settings/browser"),
apiFetch("/api/settings/memory"),
apiFetch("/api/settings/agent-runtime"),
apiFetch("/api/models"),
]).then(async ([agentResponse, browserResponse, memoryResponse, runtimeResponse, modelResponse]) => {
if (agentResponse.ok) {
const loaded = ((await agentResponse.json()) as AgentListResponse).agents;
const flowOrder: Record<string, number> = { popagent: 0, researcher: 1, implementer: 2, reviewer: 3 };
const ordered = [...loaded].sort((a, b) => (flowOrder[a.id] ?? 99) - (flowOrder[b.id] ?? 99));
setAgents(ordered);
if (ordered[0]) {
setSelectedId(ordered[0].id);
setAgentName(ordered[0].name);
setAgentDescription(ordered[0].description);
setInstructions(ordered[0].instructions);
setAgentWorkspaceAccess(ordered[0].workspaceAccess);
setAgentBrowserAccess(ordered[0].browserAccess);
setAgentDelegationEnabled(ordered[0].delegationEnabled);
setAgentTools(ordered[0].tools);
void loadSkills(ordered[0].id);
}
}
if (browserResponse.ok) setBrowserEnabled(((await browserResponse.json()) as BrowserSettings).enabled);
if (memoryResponse.ok) setMemorySettings(await memoryResponse.json() as MemorySettings);
if (runtimeResponse.ok) setRuntimeSettings(await runtimeResponse.json() as AgentRuntimeSettings);
if (modelResponse.ok) setRuntimeModels(((await modelResponse.json()) as ModelCatalogResponse).models);
});
}, [loadSkills, open]);
const selected = agents.find((item) => item.id === selectedId);
const choose = (item: AgentSettings) => {
setSelectedId(item.id);
setAgentName(item.name);
setAgentDescription(item.description);
setInstructions(item.instructions);
setAgentWorkspaceAccess(item.workspaceAccess);
setAgentBrowserAccess(item.browserAccess);
setAgentDelegationEnabled(item.delegationEnabled);
setAgentTools(item.tools);
setSaved(false);
void loadSkills(item.id);
};
const saveInstructions = async () => {
if (!selected) return;
setSaving(true);
const response = await apiFetch(`/api/agents/${selected.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: agentName,
description: agentDescription,
instructions,
workspaceAccess: agentWorkspaceAccess,
browserAccess: agentBrowserAccess,
delegationEnabled: agentDelegationEnabled,
tools: agentTools,
}),
});
if (response.ok) {
const updated = (await response.json()) as AgentSettings;
setAgents((current) => current.map((item) => item.id === updated.id ? updated : item));
setAgentName(updated.name);
setAgentDescription(updated.description);
setAgentWorkspaceAccess(updated.workspaceAccess);
setAgentBrowserAccess(updated.browserAccess);
setAgentDelegationEnabled(updated.delegationEnabled);
setAgentTools(updated.tools);
setSaved(true);
}
setSaving(false);
};
const toggleAgentTool = (tool: AgentToolName) => {
setAgentTools((current) => current.includes(tool)
? current.filter((candidate) => candidate !== tool)
: [...current, tool]);
setSaved(false);
};
const editSkill = (skill?: AgentSkill) => {
setEditingSkill(skill);
setSkillName(skill?.name ?? "");
setSkillDescription(skill?.description ?? "");
setSkillInstructions(skill?.instructions ?? "");
setSkillReferences(JSON.stringify(skill?.references ?? {}, null, 2));
setSkillEnabled(skill?.enabled ?? true);
setSkillUserInvocable(skill?.userInvocable ?? true);
setSkillError("");
setSaved(false);
};
const saveSkill = async () => {
if (!selected) return;
let references: Record<string, string>;
try {
const parsed: unknown = JSON.parse(skillReferences);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.values(parsed).some((value) => typeof value !== "string")) throw new Error();
references = Object.fromEntries(Object.entries(parsed).map(([key, value]) => [key, String(value)]));
} catch {
setSkillError("References must be a JSON object whose values are strings.");
return;
}
setSaving(true);
setSaved(false);
const response = await apiFetch(editingSkill ? `/api/agents/${selected.id}/skills/${editingSkill.id}` : `/api/agents/${selected.id}/skills`, {
method: editingSkill ? "PATCH" : "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: skillName, description: skillDescription, instructions: skillInstructions, references, enabled: skillEnabled, userInvocable: skillUserInvocable }),
});
if (response.ok) {
await loadSkills(selected.id);
setEditingSkill(await response.json() as AgentSkill);
setSaved(true);
} else {
setSkillError("Unable to save skill. Names must be unique lowercase words separated by hyphens.");
}
setSaving(false);
};
const deleteSkill = async () => {
if (!selected || !editingSkill || !confirm(`Delete the “${editingSkill.name}” skill?`)) return;
const response = await apiFetch(`/api/agents/${selected.id}/skills/${editingSkill.id}`, { method: "DELETE" });
if (response.ok) {
await loadSkills(selected.id);
editSkill();
}
};
const saveBrowser = async () => {
if (browserEnabled === undefined) return;
setSaving(true);
const response = await apiFetch("/api/settings/browser", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ enabled: browserEnabled }),
});
if (response.ok) setSaved(true);
setSaving(false);
};
const createTask = async () => {
if (!taskPrompt.trim()) return;
const response = await apiFetch("/api/tasks", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: taskPrompt, workspaceId: taskWorkspaceId }) });
if (response.ok) { setTaskPrompt(""); await loadTasks(taskWorkspaceId); }
};
const createSchedule = async () => {
if (!scheduleName.trim() || !schedulePrompt.trim()) return;
const cron = scheduleCron(scheduleFrequency, scheduleTime, scheduleWeekday);
const response = await apiFetch("/api/schedules", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: scheduleName, prompt: schedulePrompt, cron, enabled: true, workspaceId: taskWorkspaceId }) });
if (response.ok) { setScheduleName(""); setSchedulePrompt(""); await loadTasks(taskWorkspaceId); }
};
const saveMemory = async () => {
if (!memorySettings) return;
setSaving(true);
const response = await apiFetch("/api/settings/memory", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(memorySettings),
});
if (response.ok) {
setMemorySettings(await response.json() as MemorySettings);
setSaved(true);
}
setSaving(false);
};
const saveRuntime = async () => {
if (!runtimeSettings) return;
setSaving(true);
const { updatedAt, ...input } = runtimeSettings;
void updatedAt;
const response = await apiFetch("/api/settings/agent-runtime", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
});
if (response.ok) {
setRuntimeSettings(await response.json() as AgentRuntimeSettings);
setSaved(true);
}
setSaving(false);
};
const changeRuntime = <Key extends keyof AgentRuntimeSettings>(
key: Key,
value: AgentRuntimeSettings[Key],
) => {
setRuntimeSettings((current) => current ? { ...current, [key]: value } : current);
setSaved(false);
};
const toggleSchedule = async (schedule: AgentSchedule) => {
await apiFetch(`/api/schedules/${schedule.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...schedule, enabled: !schedule.enabled }) });
await loadTasks(taskWorkspaceId);
};
const deleteSchedule = async (id: string) => {
await apiFetch(`/api/schedules/${id}`, { method: "DELETE" });
await loadTasks(taskWorkspaceId);
};
useEffect(() => {
if (!open || section !== "tasks") return;
const timer = setInterval(() => void loadTasks(taskWorkspaceId), 2000);
return () => clearInterval(timer);
}, [loadTasks, open, section, taskWorkspaceId]);
useEffect(() => {
if (!open) return;
closeRef.current?.focus();
const close = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [onClose, open]);
useFocusTrap(pageRef, open);
if (!open) return null;
const taskStepLimit = runtimeSettings?.supervisorMaxSteps ?? 1;
return (
<div ref={pageRef} role="dialog" aria-modal="true" aria-labelledby="agent-settings-title" className="fixed inset-0 z-50 flex flex-col bg-bg">
<header className="flex min-h-14 items-center border-b border-line bg-panel px-4">
<div id="agent-settings-title" className="flex items-center gap-2 font-semibold"><Settings2 className="size-4 text-accent" />Settings</div>
<button ref={closeRef} type="button" aria-label="Close settings" onClick={onClose} className="ml-auto flex size-11 items-center justify-center rounded hover:bg-panel-2"><X className="size-4" /></button>
</header>
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<nav aria-label="Settings sections" className="grid shrink-0 grid-cols-4 border-b border-line bg-panel p-2 sm:grid-cols-7 md:block md:w-56 md:border-b-0 md:border-r md:p-3">
<button type="button" onClick={() => { onSectionChange("appearance"); setSaved(false); }} className={`flex min-h-11 w-full items-center justify-center gap-1 rounded px-1 text-xs sm:gap-2 sm:px-3 sm:text-sm md:justify-start ${section === "appearance" ? "bg-accent-soft font-medium text-ink" : "text-ink-muted hover:bg-panel-2 hover:text-ink"}`}><Palette className="size-4 text-accent" />Appearance</button>
<button type="button" onClick={() => { onSectionChange("agents"); setSaved(false); }} className={`flex min-h-11 w-full items-center justify-center gap-1 rounded px-1 text-xs sm:gap-2 sm:px-3 sm:text-sm md:justify-start ${section === "agents" ? "bg-accent-soft font-medium text-ink" : "text-ink-muted hover:bg-panel-2 hover:text-ink"}`}><Bot className="size-4 text-accent" />Agents</button>
<button type="button" onClick={() => { onSectionChange("browser"); setSaved(false); }} className={`flex min-h-11 w-full items-center justify-center gap-1 rounded px-1 text-xs sm:gap-2 sm:px-3 sm:text-sm md:justify-start ${section === "browser" ? "bg-accent-soft font-medium text-ink" : "text-ink-muted hover:bg-panel-2 hover:text-ink"}`}><Globe className="size-4 text-accent" />Browser</button>
<button type="button" onClick={() => { onSectionChange("memory"); setSaved(false); }} className={`flex min-h-11 w-full items-center justify-center gap-1 rounded px-1 text-xs sm:gap-2 sm:px-3 sm:text-sm md:justify-start ${section === "memory" ? "bg-accent-soft font-medium text-ink" : "text-ink-muted hover:bg-panel-2 hover:text-ink"}`}><Brain className="size-4 text-accent" />Memory</button>
<button type="button" onClick={() => { onSectionChange("runtime"); setSaved(false); }} className={`flex min-h-11 w-full items-center justify-center gap-1 rounded px-1 text-xs sm:gap-2 sm:px-3 sm:text-sm md:justify-start ${section === "runtime" ? "bg-accent-soft font-medium text-ink" : "text-ink-muted hover:bg-panel-2 hover:text-ink"}`}><SlidersHorizontal className="size-4 text-accent" />Runtime</button>
<button type="button" onClick={() => { onSectionChange("tasks"); setSaved(false); }} className={`flex min-h-11 w-full items-center justify-center gap-1 rounded px-1 text-xs sm:gap-2 sm:px-3 sm:text-sm md:justify-start ${section === "tasks" ? "bg-accent-soft font-medium text-ink" : "text-ink-muted hover:bg-panel-2 hover:text-ink"}`}><Clock3 className="size-4 text-accent" />Tasks</button>
<button type="button" onClick={() => { onSectionChange("observability"); setSaved(false); }} className={`flex min-h-11 w-full items-center justify-center gap-1 rounded px-1 text-xs sm:gap-2 sm:px-3 sm:text-sm md:justify-start ${section === "observability" ? "bg-accent-soft font-medium text-ink" : "text-ink-muted hover:bg-panel-2 hover:text-ink"}`}><Activity className="size-4 text-accent" />Observe</button>
</nav>
<main className="min-w-0 flex-1 overflow-y-auto p-4 md:p-8">
{section === "appearance" ? <div className="w-full max-w-3xl">
<h1 className="text-xl font-semibold">Appearance</h1>
<p className="mt-1 text-sm text-ink-dim">Choose how Popagent looks on this device. Your selection is saved in this browser.</p>
<section className="mt-6 border border-line bg-panel p-5">
<h2 className="font-medium">Theme</h2>
<div className="mt-4 grid gap-4 sm:grid-cols-2" role="radiogroup" aria-label="Color theme">
{(["light", "dark"] as const).map((option) => {
const selected = theme === option;
return <button key={option} type="button" role="radio" aria-checked={selected} onClick={() => { setTheme(option); saveTheme(option); }} className={`theme-choice theme-choice--${option} ${selected ? "is-selected" : ""}`}>
<span className="theme-preview" aria-hidden="true"><span className="theme-preview-sidebar" /><span className="theme-preview-content"><span /><span /><span /></span></span>
<span className="flex items-center gap-2 px-3 py-3 text-left text-sm font-medium">{option === "light" ? <Sun className="size-4" /> : <Moon className="size-4" />}<span className="capitalize">{option}</span><span className="theme-check ml-auto" /></span>
</button>;
})}
</div>
</section>
</div> : section === "agents" ? <div className="w-full">
<h1 className="text-xl font-semibold">Agents</h1>
<p className="mt-1 text-sm text-ink-dim">The orchestrator owns each request and delegates focused work to enabled specialists.</p>
{agents.length ? <section className="agent-flow mt-6" aria-label="Agent handoff flow">
{agents.map((item, index) => <div className="agent-flow-step" key={item.id}>
{index > 0 ? <span className="agent-flow-arrow" aria-hidden="true">→</span> : null}
<button type="button" onClick={() => choose(item)} className={`agent-flow-node ${item.id === selectedId ? "is-selected" : ""} ${item.id !== "popagent" && !item.delegationEnabled ? "is-disabled" : ""}`}>
<span className={`text-[10px] font-semibold uppercase tracking-widest ${item.id === "popagent" ? "text-accent" : "text-ink-dim"}`}>{item.id === "popagent" ? "Orchestrator" : "Specialist"}</span>
<strong>{item.name}</strong>
<span>{item.id === "popagent" ? "Coordinates" : item.delegationEnabled ? "Enabled" : "Disabled"}</span>
</button>
</div>)}
</section> : null}
<div className="mt-5">
{selected ? <section className="rounded border border-line bg-panel p-4">
<div className="mb-4 flex gap-1 border-b border-line pb-3">
<button type="button" onClick={() => { setAgentPanel("instructions"); setSaved(false); }} className={`min-h-11 rounded px-3 text-sm ${agentPanel === "instructions" ? "bg-accent-soft text-ink" : "text-ink-muted"}`}>Instructions</button>
<button type="button" onClick={() => { setAgentPanel("skills"); setSaved(false); }} className={`min-h-11 rounded px-3 text-sm ${agentPanel === "skills" ? "bg-accent-soft text-ink" : "text-ink-muted"}`}>Skills</button>
</div>
{agentPanel === "instructions" ? <>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm font-medium">Display name<input aria-label="Agent display name" maxLength={120} value={agentName} onChange={(event) => { setAgentName(event.currentTarget.value); setSaved(false); }} className="mt-2 min-h-11 w-full rounded border border-line bg-bg px-3 text-ink outline-none focus:border-accent/50" /></label>
<label className="text-sm font-medium">Delegation description<input aria-label="Agent delegation description" maxLength={1024} value={agentDescription} onChange={(event) => { setAgentDescription(event.currentTarget.value); setSaved(false); }} className="mt-2 min-h-11 w-full rounded border border-line bg-bg px-3 text-ink outline-none focus:border-accent/50" /></label>
</div>
<div className="mt-4 rounded border border-line bg-bg p-4">
<h3 className="text-sm font-medium">Capabilities</h3>
<p className="mt-1 text-xs leading-relaxed text-ink-dim">Capability changes apply on the next run. Writable workspace, interactive browser, and secret tools grant broader access.</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<label className="text-xs font-medium text-ink-dim">Workspace access<select aria-label="Agent workspace access" value={agentWorkspaceAccess} onChange={(event) => { setAgentWorkspaceAccess(event.currentTarget.value as AgentWorkspaceAccess); setSaved(false); }} className="mt-1 min-h-11 w-full rounded border border-line bg-panel px-3 text-sm text-ink outline-none"><option value="none">None</option><option value="read-only">Read only</option><option value="read-write">Read and write</option></select></label>
<label className="text-xs font-medium text-ink-dim">Browser access<select aria-label="Agent browser access" value={agentBrowserAccess} onChange={(event) => { setAgentBrowserAccess(event.currentTarget.value as AgentBrowserAccess); setSaved(false); }} className="mt-1 min-h-11 w-full rounded border border-line bg-panel px-3 text-sm text-ink outline-none"><option value="none">None</option><option value="read-only">Read only</option><option value="interactive">Interactive</option></select></label>
</div>
{selected.id === "popagent" ? null : <label className="mt-3 flex items-center gap-2 text-sm"><input aria-label="Enable agent delegation" type="checkbox" checked={agentDelegationEnabled} onChange={(event) => { setAgentDelegationEnabled(event.currentTarget.checked); setSaved(false); }} className="size-4 accent-accent" />Available for delegation</label>}
<fieldset className="mt-4"><legend className="text-xs font-medium text-ink-dim">Tools</legend><div className="mt-2 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">{AGENT_TOOL_NAMES.map((tool) => <label key={tool} className="flex min-h-10 items-center gap-2 rounded border border-line bg-panel px-3 text-sm"><input type="checkbox" checked={agentTools.includes(tool)} onChange={() => toggleAgentTool(tool)} className="size-4 accent-accent" />{tool}</label>)}</div></fieldset>
</div>
<label htmlFor="agent-instructions" className="mt-4 block text-sm font-medium">Instructions</label>
<p className="mt-1 text-xs text-ink-dim">Identity and instructions are stored in PostgreSQL and used on new runs. Maximum {MAX_AGENT_INSTRUCTIONS_CHARACTERS.toLocaleString()} instruction characters.</p>
{selected.sourceUrls.length ? <p className="mt-2 text-xs text-ink-dim">Adapted from {selected.sourceUrls.map((url, index) => <span key={url}>{index ? ", " : ""}<a href={url} target="_blank" rel="noreferrer" className="text-accent hover:underline">source {index + 1}</a></span>)}.</p> : null}
<textarea id="agent-instructions" maxLength={MAX_AGENT_INSTRUCTIONS_CHARACTERS} value={instructions} onChange={(event) => { setInstructions(event.currentTarget.value); setSaved(false); }} rows={16} className="mt-3 w-full resize-y rounded border border-line bg-bg p-3 text-sm leading-relaxed outline-none focus:border-accent/50" />
<div className="mt-3 flex items-center justify-end gap-3">{saved ? <span role="status" className="text-xs text-emerald-500">Saved</span> : null}<button type="button" disabled={saving || !agentName.trim() || !agentDescription.trim() || !instructions.trim()} onClick={() => void saveInstructions()} className="min-h-11 rounded bg-accent px-4 text-sm font-medium text-white disabled:opacity-60">{saving ? "Saving…" : "Save agent"}</button></div>
</> : <div className="grid gap-4 lg:grid-cols-[13rem_1fr]">
<div><button type="button" onClick={() => editSkill()} className="mb-2 min-h-11 w-full rounded border border-line bg-panel-2 px-3 text-left text-sm"><Plus className="mr-2 inline size-4 text-accent" />New skill</button>{skills.map((skill) => <button key={skill.id} type="button" onClick={() => editSkill(skill)} className={`mb-2 w-full rounded border px-3 py-2 text-left ${editingSkill?.id === skill.id ? "border-accent/40 bg-accent-soft" : "border-line bg-bg"}`}><span className="block truncate text-sm font-medium">{skill.name}</span><span className="text-xs text-ink-dim">{skill.enabled ? "Enabled" : "Disabled"}</span></button>)}</div>
<div>
{editingSkill?.sourceUrls.length ? <p className="mb-3 text-xs text-ink-dim">Adapted from {editingSkill.sourceUrls.map((url, index) => <span key={url}>{index ? ", " : ""}<a href={url} target="_blank" rel="noreferrer" className="text-accent hover:underline">source {index + 1}</a></span>)}.</p> : null}
<label className="text-xs font-medium text-ink-dim">Name<input aria-label="Skill name" value={skillName} onChange={(event) => setSkillName(event.currentTarget.value)} placeholder="release-checklist" className="mt-1 block w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink outline-none" /></label>
<label className="mt-3 block text-xs font-medium text-ink-dim">Description<input aria-label="Skill description" value={skillDescription} onChange={(event) => setSkillDescription(event.currentTarget.value)} placeholder="When this skill should be used" className="mt-1 block w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink outline-none" /></label>
<label className="mt-3 block text-xs font-medium text-ink-dim">Instructions<textarea aria-label="Skill instructions" value={skillInstructions} onChange={(event) => setSkillInstructions(event.currentTarget.value)} rows={10} className="mt-1 block w-full resize-y rounded border border-line bg-bg p-3 text-sm text-ink outline-none" /></label>
<label className="mt-3 block text-xs font-medium text-ink-dim">References JSON<textarea aria-label="Skill references" value={skillReferences} onChange={(event) => setSkillReferences(event.currentTarget.value)} rows={4} className="mt-1 block w-full resize-y rounded border border-line bg-bg p-3 font-mono text-xs text-ink outline-none" /></label>
<div className="mt-3 flex flex-wrap gap-5 text-sm"><label className="flex items-center gap-2"><input type="checkbox" checked={skillEnabled} onChange={(event) => setSkillEnabled(event.currentTarget.checked)} className="size-4 accent-accent" />Enabled</label><label className="flex items-center gap-2"><input type="checkbox" checked={skillUserInvocable} onChange={(event) => setSkillUserInvocable(event.currentTarget.checked)} className="size-4 accent-accent" />User invocable</label></div>
{skillError ? <p role="alert" className="mt-3 text-sm text-red-400">{skillError}</p> : null}
<div className="mt-4 flex items-center justify-end gap-2">{editingSkill ? <button type="button" onClick={() => void deleteSkill()} className="min-h-11 rounded border border-red-500/30 px-4 text-sm text-red-400">Delete</button> : null}{saved ? <span role="status" className="text-xs text-emerald-500">Saved</span> : null}<button type="button" disabled={saving || !skillName || !skillDescription || !skillInstructions} onClick={() => void saveSkill()} className="min-h-11 rounded bg-accent px-4 text-sm font-medium text-white disabled:opacity-50">{saving ? "Saving…" : editingSkill ? "Save skill" : "Create skill"}</button></div>
</div>
</div>}
</section> : <div className="text-sm text-ink-dim">Loading agents…</div>}
</div>
</div> : section === "browser" ? <div className="w-full">
<h1 className="text-xl font-semibold">Browser</h1>
<p className="mt-1 text-sm text-ink-dim">Control popagent’s isolated browser automation capability.</p>
{browserEnabled === undefined ? <p className="mt-6 text-sm text-ink-dim">Loading browser settings…</p> : <section className="mt-6 rounded border border-line bg-panel p-5">
<label className="flex items-start justify-between gap-4">
<span><span className="block font-medium">Enable browser tools</span><span className="mt-1 block text-sm leading-relaxed text-ink-dim">Allows navigation, page snapshots, element interaction, form entry, screenshots, and structured extraction. Browser sessions run headlessly on this server.</span></span>
<input aria-label="Enable browser tools" type="checkbox" checked={browserEnabled} onChange={(event) => { setBrowserEnabled(event.currentTarget.checked); setSaved(false); }} className="mt-1 size-5 accent-accent" />
</label>
<div className="mt-5 flex items-center justify-end gap-3">{saved ? <span role="status" className="text-xs text-emerald-500">Saved</span> : null}<button type="button" disabled={saving} onClick={() => void saveBrowser()} className="min-h-11 rounded bg-accent px-4 text-sm font-medium text-white disabled:opacity-60">{saving ? "Saving…" : "Save browser settings"}</button></div>
</section>}
</div> : section === "memory" ? <div className="w-full max-w-3xl">
<h1 className="text-xl font-semibold">Memory</h1>
<p className="mt-1 text-sm text-ink-dim">Control automatic conversation compaction. Changes apply to the next agent turn.</p>
{memorySettings ? <section className="mt-6 space-y-6 rounded border border-line bg-panel p-5">
<label className="flex items-start justify-between gap-4">
<span><span className="block font-medium">Automatically compact long conversations</span><span className="mt-1 block text-sm leading-relaxed text-ink-dim">Mastra converts older messages and tool results into dense observations while preserving the original thread records in PostgreSQL.</span></span>
<input aria-label="Automatically compact conversations" type="checkbox" checked={memorySettings.autoCompact} onChange={(event) => { setMemorySettings((current) => current ? { ...current, autoCompact: event.currentTarget.checked } : current); setSaved(false); }} className="mt-1 size-5 accent-accent" />
</label>
<div className={`grid gap-4 sm:grid-cols-2 ${memorySettings.autoCompact ? "" : "opacity-50"}`}>
<label className="text-sm font-medium">Compact after
<input aria-label="Conversation compaction threshold" type="number" min={4000} max={200000} step={1000} disabled={!memorySettings.autoCompact} value={memorySettings.observationTokens} onChange={(event) => { setMemorySettings((current) => current ? { ...current, observationTokens: event.currentTarget.valueAsNumber } : current); setSaved(false); }} className="mt-2 min-h-11 w-full rounded border border-line bg-bg px-3 text-ink outline-none focus:border-accent/50" />
<span className="mt-1 block text-xs font-normal text-ink-dim">Uncompacted message tokens</span>
</label>
<label className="text-sm font-medium">Reflect after
<input aria-label="Observation reflection threshold" type="number" min={4000} max={400000} step={1000} disabled={!memorySettings.autoCompact} value={memorySettings.reflectionTokens} onChange={(event) => { setMemorySettings((current) => current ? { ...current, reflectionTokens: event.currentTarget.valueAsNumber } : current); setSaved(false); }} className="mt-2 min-h-11 w-full rounded border border-line bg-bg px-3 text-ink outline-none focus:border-accent/50" />
<span className="mt-1 block text-xs font-normal text-ink-dim">Observation-log tokens</span>
</label>
<label className="text-sm font-medium">Recent context to retain
<input aria-label="Recent conversation percent" type="number" min={5} max={75} step={5} disabled={!memorySettings.autoCompact} value={memorySettings.recentMessagePercent} onChange={(event) => { setMemorySettings((current) => current ? { ...current, recentMessagePercent: event.currentTarget.valueAsNumber } : current); setSaved(false); }} className="mt-2 min-h-11 w-full rounded border border-line bg-bg px-3 text-ink outline-none focus:border-accent/50" />
<span className="mt-1 block text-xs font-normal text-ink-dim">Percent kept as raw recent messages</span>
</label>
<label className="flex items-start gap-3 rounded border border-line bg-bg p-3">
<input aria-label="Buffer observations while idle" type="checkbox" disabled={!memorySettings.autoCompact} checked={memorySettings.bufferOnIdle} onChange={(event) => { setMemorySettings((current) => current ? { ...current, bufferOnIdle: event.currentTarget.checked } : current); setSaved(false); }} className="mt-0.5 size-5 accent-accent" />
<span><span className="block text-sm font-medium">Buffer while idle</span><span className="mt-1 block text-xs leading-relaxed text-ink-dim">Prepare observations after turns to reduce blocking when compaction activates.</span></span>
</label>
</div>
<div className="flex items-center justify-end gap-3">{saved ? <span role="status" className="text-xs text-emerald-500">Saved</span> : null}<button type="button" disabled={saving} onClick={() => void saveMemory()} className="min-h-11 rounded bg-accent px-4 text-sm font-medium text-white disabled:opacity-60">{saving ? "Saving…" : "Save memory settings"}</button></div>
</section> : <p className="mt-6 text-sm text-ink-dim">Loading memory settings…</p>}
</div> : section === "runtime" ? <div className="w-full max-w-4xl">
<h1 className="text-xl font-semibold">Runtime</h1>
<p className="mt-1 text-sm text-ink-dim">Control model execution, delegation bounds, and background task scheduling. Changes apply to new turns and tasks.</p>
{runtimeSettings ? <div className="mt-6 space-y-5">
<section className="rounded border border-line bg-panel p-5">
<h2 className="font-medium">Model and execution</h2>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="text-sm font-medium">Default model
<select aria-label="Default model" value={runtimeSettings.defaultModel} onChange={(event) => changeRuntime("defaultModel", event.currentTarget.value)} className="mt-2 min-h-11 w-full rounded border border-line bg-bg px-3 text-ink outline-none focus:border-accent/50">
{!runtimeModels.includes(runtimeSettings.defaultModel) ? <option value={runtimeSettings.defaultModel}>{runtimeSettings.defaultModel}</option> : null}
{runtimeModels.map((model) => <option key={model} value={model}>{model}</option>)}
</select>
</label>
<RuntimeNumberField label="Supervisor max steps" value={runtimeSettings.supervisorMaxSteps} min={1} max={128} onChange={(value) => changeRuntime("supervisorMaxSteps", value)} />
<RuntimeNumberField label="Specialist max steps" value={runtimeSettings.specialistMaxSteps} min={1} max={128} onChange={(value) => changeRuntime("specialistMaxSteps", value)} />
<RuntimeNumberField label="Concurrent tool calls" value={runtimeSettings.toolConcurrency} min={1} max={16} onChange={(value) => changeRuntime("toolConcurrency", value)} />
<RuntimeNumberField label="Processor retries" value={runtimeSettings.maxProcessorRetries} min={0} max={10} onChange={(value) => changeRuntime("maxProcessorRetries", value)} />
</div>
<label className="mt-4 block text-sm font-medium">Final-response feedback<textarea aria-label="Final-response feedback" maxLength={4000} rows={3} value={runtimeSettings.finalResponseFeedback} onChange={(event) => changeRuntime("finalResponseFeedback", event.currentTarget.value)} className="mt-2 w-full resize-y rounded border border-line bg-bg p-3 text-sm outline-none focus:border-accent/50" /></label>
</section>
<section className="rounded border border-line bg-panel p-5">
<h2 className="font-medium">Delegation</h2>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<RuntimeNumberField label="Context messages" value={runtimeSettings.delegationContextMessages} min={1} max={100} onChange={(value) => changeRuntime("delegationContextMessages", value)} />
<RuntimeNumberField label="Result character limit" value={runtimeSettings.delegationResultCharacters} min={1000} max={100000} step={1000} onChange={(value) => changeRuntime("delegationResultCharacters", value)} />
</div>
<label className="mt-4 block text-sm font-medium">Failure feedback<textarea aria-label="Delegation failure feedback" maxLength={4000} rows={3} value={runtimeSettings.delegationFailureFeedback} onChange={(event) => changeRuntime("delegationFailureFeedback", event.currentTarget.value)} className="mt-2 w-full resize-y rounded border border-line bg-bg p-3 text-sm outline-none focus:border-accent/50" /></label>
<label className="mt-4 block text-sm font-medium">Result truncation marker<textarea aria-label="Delegation result truncation marker" maxLength={500} rows={2} value={runtimeSettings.delegationResultTruncationMarker} onChange={(event) => changeRuntime("delegationResultTruncationMarker", event.currentTarget.value)} className="mt-2 w-full resize-y rounded border border-line bg-bg p-3 font-mono text-sm outline-none focus:border-accent/50" /></label>
</section>
<section className="rounded border border-line bg-panel p-5">
<h2 className="font-medium">Background tasks</h2>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<RuntimeNumberField label="Concurrent tasks" value={runtimeSettings.taskConcurrency} min={1} max={16} onChange={(value) => changeRuntime("taskConcurrency", value)} />
<RuntimeNumberField label="Poll interval (ms)" value={runtimeSettings.taskPollIntervalMs} min={250} max={300000} step={250} onChange={(value) => changeRuntime("taskPollIntervalMs", value)} />
<RuntimeNumberField label="Task timeout (ms)" value={runtimeSettings.taskTimeoutMs} min={1000} max={86400000} step={1000} onChange={(value) => changeRuntime("taskTimeoutMs", value)} />
<RuntimeNumberField label="Restart stale threshold (ms)" value={runtimeSettings.taskStaleAfterMs} min={60000} max={604800000} step={60000} onChange={(value) => changeRuntime("taskStaleAfterMs", value)} />
</div>
</section>
<div className="flex items-center justify-end gap-3">{saved ? <span role="status" className="text-xs text-emerald-500">Saved</span> : null}<button type="button" disabled={saving} onClick={() => void saveRuntime()} className="min-h-11 rounded bg-accent px-4 text-sm font-medium text-white disabled:opacity-60">{saving ? "Saving…" : "Save runtime settings"}</button></div>
</div> : <p className="mt-6 text-sm text-ink-dim">Loading runtime settings…</p>}
</div> : section === "observability" ? <ObservabilityPage /> : <div className="w-full">
<h1 className="text-xl font-semibold">Tasks</h1>
<p className="mt-1 text-sm text-ink-dim">Run popagent in the background or on a recurring schedule.</p>
<label className="mt-4 block max-w-sm text-xs font-medium text-ink-dim">Workspace
<select aria-label="Task workspace" value={taskWorkspaceId} onChange={(event) => { const id = event.currentTarget.value; setTaskWorkspaceId(id); void loadTasks(id); }} className="mt-1 min-h-11 w-full rounded border border-line bg-bg px-3 text-sm text-ink outline-none">
{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}
</select>
</label>
<div className="mt-6 grid gap-5 xl:grid-cols-2">
<section className="rounded border border-line bg-panel p-5">
<h2 className="font-medium">New background task</h2>
<textarea aria-label="Task prompt" value={taskPrompt} onChange={(event) => setTaskPrompt(event.currentTarget.value)} rows={5} placeholder="Describe the work popagent should complete…" className="mt-3 w-full rounded border border-line bg-bg p-3 text-sm outline-none" />
<button type="button" disabled={!taskPrompt.trim()} onClick={() => void createTask()} className="mt-3 min-h-11 rounded bg-accent px-4 text-sm font-medium text-white disabled:opacity-50">Start task</button>
</section>
<section className="rounded border border-line bg-panel p-5">
<h2 className="font-medium">New schedule</h2>
<input aria-label="Schedule name" value={scheduleName} onChange={(event) => setScheduleName(event.currentTarget.value)} placeholder="Daily summary" className="mt-3 w-full rounded border border-line bg-bg px-3 py-2 text-sm outline-none" />
<textarea aria-label="Schedule prompt" value={schedulePrompt} onChange={(event) => setSchedulePrompt(event.currentTarget.value)} rows={3} placeholder="Task prompt" className="mt-2 w-full rounded border border-line bg-bg p-3 text-sm outline-none" />
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<label className="text-xs text-ink-dim">Repeat<select aria-label="Schedule frequency" value={scheduleFrequency} onChange={(event) => setScheduleFrequency(event.currentTarget.value as ScheduleFrequency)} className="mt-1 min-h-11 w-full rounded border border-line bg-bg px-3 text-sm text-ink outline-none"><option value="daily">Every day</option><option value="weekdays">Weekdays</option><option value="weekly">Every week</option></select></label>
<label className="text-xs text-ink-dim">Time<input type="time" aria-label="Schedule time" value={scheduleTime} onChange={(event) => setScheduleTime(event.currentTarget.value)} className="mt-1 min-h-11 w-full rounded border border-line bg-bg px-3 text-sm text-ink outline-none" /></label>
</div>
{scheduleFrequency === "weekly" ? <label className="mt-2 block text-xs text-ink-dim">Day<select aria-label="Schedule weekday" value={scheduleWeekday} onChange={(event) => setScheduleWeekday(event.currentTarget.value)} className="mt-1 min-h-11 w-full rounded border border-line bg-bg px-3 text-sm text-ink outline-none">{WEEKDAYS.map((day) => <option key={day.value} value={day.value}>{day.label}</option>)}</select></label> : null}
<button type="button" disabled={!scheduleName.trim() || !schedulePrompt.trim() || !scheduleTime} onClick={() => void createSchedule()} className="mt-3 min-h-11 rounded bg-accent px-4 text-sm font-medium text-white disabled:opacity-50">Create schedule</button>
</section>
</div>
<section className="mt-5 rounded border border-line bg-panel p-5"><h2 className="font-medium">Schedules</h2><div className="mt-3 space-y-2">{schedules.length ? schedules.map((schedule) => <article key={schedule.id} className="flex items-center gap-3 rounded border border-line bg-bg p-3"><div className="min-w-0 flex-1"><div className="font-medium">{schedule.name}</div><div className="truncate text-xs text-ink-dim">{describeSchedule(schedule.cron)}{schedule.nextRunAt ? ` · next ${new Date(schedule.nextRunAt).toLocaleString()}` : ""}</div></div><button type="button" onClick={() => void toggleSchedule(schedule)} className="min-h-11 rounded border border-line px-3 text-xs">{schedule.enabled ? "Pause" : "Resume"}</button><button type="button" aria-label={`Delete schedule ${schedule.name}`} onClick={() => void deleteSchedule(schedule.id)} className="flex size-11 items-center justify-center rounded text-red-400"><Trash2 className="size-4" /></button></article>) : <p className="text-sm text-ink-dim">No schedules.</p>}</div></section>
<section className="mt-5 rounded border border-line bg-panel p-5"><h2 className="font-medium">Recent tasks</h2><div className="mt-3 space-y-2">{tasks.length ? tasks.map((task) => <article key={task.id} className="rounded border border-line bg-bg p-3"><div className="flex items-start gap-3"><div className="min-w-0 flex-1"><div className="line-clamp-2 text-sm">{task.prompt}</div><><div className="mt-1 text-xs uppercase text-ink-dim">{task.status} · {new Date(task.createdAt).toLocaleString()}{task.recoveryCount ? ` · recovered ${task.recoveryCount}×` : ""}</div>{task.status === "running" ? <><div className="mt-2 flex items-center justify-between gap-3 text-xs text-ink-dim"><span>{task.progress ?? "Working"}</span><span>Step {task.stepsCompleted}/{taskStepLimit}</span></div><progress aria-label="Task progress" max={taskStepLimit} value={task.stepsCompleted} className="mt-1 h-1.5 w-full accent-accent" /></> : null}</></div>{task.status === "queued" || task.status === "running" ? <button type="button" onClick={async () => { await apiFetch(`/api/tasks/${task.id}/cancel`, { method: "POST" }); await loadTasks(taskWorkspaceId); }} className="min-h-11 rounded border border-line px-3 text-xs">Cancel</button> : null}</div>{task.output ? <pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-panel-2 p-3 text-xs">{task.output}</pre> : null}{task.error ? <p className="mt-2 text-xs text-red-400">{task.error}</p> : null}</article>) : <p className="text-sm text-ink-dim">No tasks.</p>}</div></section>
</div>}
</main>
</div>
</div>
);
}