/** * `AgentsPanel` — feature component for the agents panel (L6). * * Pure presentation: all behaviour comes from {@link useAgents}. Styled with * `@/shared` design system tokens; no inline styles, no classes invented outside * the theme. * * When the user clicks Launch, an agent terminal is mounted below the agent * list using a {@link TerminalView} with a custom `open` prop that delegates * to `agent.launchAgent`. A Stop button unmounts it. * * Feature #3: the create form includes a template selector. Choosing a template * calls `createAgentFromTemplate`; leaving it at "(none / from scratch)" uses * the existing `createAgent` path with the selected profile. */ import { useState } from "react"; import { Button, Input, Panel, Spinner, cn } from "@/shared"; import { TerminalView } from "@/features/terminals/TerminalView"; import { AnnouncementsPreview } from "@/features/announcements"; import { useDrift } from "@/features/templates/useDrift"; import { useGateways } from "@/app/di"; import { useAgents } from "./useAgents"; import { AgentLimitBadge } from "./AgentLimitBadge"; import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge"; export interface AgentsPanelProps { /** The project whose agents to manage. */ projectId: string; /** * The filesystem root of the project. Passed as the `cwd` to the agent * terminal. Supplied by `ProjectsView` which has `active.root`. */ projectRoot?: string; } export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { const vm = useAgents(projectId); const drift = useDrift(projectId); const gateways = useGateways(); const templateGw = gateways.template ?? null; // Create form state const [newName, setNewName] = useState(""); const [newProfileId, setNewProfileId] = useState(""); const [newTemplateId, setNewTemplateId] = useState(""); // Templates available for the selector (loaded lazily on first render via a // separate effect handled inline via hook state) const [templates, setTemplates] = useState([]); const [templatesLoaded, setTemplatesLoaded] = useState(false); // Load templates once on mount (best-effort — if it fails the selector just stays empty) if (!templatesLoaded && templateGw) { setTemplatesLoaded(true); void templateGw.listTemplates().then(setTemplates).catch(() => {}); } // Skills available for assignment (global + project), loaded lazily once. const skillGw = gateways.skill ?? null; const [skills, setSkills] = useState([]); const [skillsLoaded, setSkillsLoaded] = useState(false); const [skillToAssign, setSkillToAssign] = useState(""); async function refreshSkills() { if (!skillGw) return; try { const [globals, projects] = await Promise.all([ skillGw.listSkills(projectId, "global"), skillGw.listSkills(projectId, "project"), ]); setSkills([...projects, ...globals]); } catch { // Skills are optional — leave the list empty on failure. } } if (!skillsLoaded && skillGw) { setSkillsLoaded(true); void refreshSkills(); } async function handleAssignSkill() { if (!skillGw || !vm.selectedAgentId || !skillToAssign) return; const target = skills.find((s) => s.id === skillToAssign); if (!target) return; await skillGw.assignSkill( projectId, vm.selectedAgentId, target.id, target.scope, ); setSkillToAssign(""); await vm.refresh(); } async function handleUnassignSkill(skillId: string) { if (!skillGw || !vm.selectedAgentId) return; await skillGw.unassignSkill(projectId, vm.selectedAgentId, skillId); await vm.refresh(); } // Context editor state — local copy before Save const [editedContext, setEditedContext] = useState(vm.context); // When the hook loads the context for a newly selected agent, mirror it. // We use a derived check: if the hook's context changed externally we reset. const [lastLoadedContext, setLastLoadedContext] = useState(vm.context); if (vm.context !== lastLoadedContext) { setLastLoadedContext(vm.context); setEditedContext(vm.context); } /** * Id of the agent currently being launched / running in the terminal pane. * Kept in local state so the terminal container can appear immediately when * the user clicks Launch (before the gateway resolves). vm.runningAgentId is * set by the hook once launchAgent resolves. */ const [activeAgentId, setActiveAgentId] = useState(null); const [panelNodeIds, setPanelNodeIds] = useState>({}); /** * Live PTY session id of the running agent terminal, keyed by agent id. Lets * the terminal re-attach (rather than re-launch) when the panel re-mounts the * view, so navigating away never kills the agent. */ const [agentSessions, setAgentSessions] = useState>( {}, ); /** * Pending profile change awaiting confirmation: the target agent + the chosen * profile id. `null` when no dialog is open. Switching the engine abandons the * conversation history, so it is gated behind an explicit confirmation. */ const [pendingProfileChange, setPendingProfileChange] = useState<{ agentId: string; profileId: string; } | null>(null); async function confirmProfileChange() { if (!pendingProfileChange) return; const { agentId, profileId } = pendingProfileChange; setPendingProfileChange(null); // Use the running agent terminal's size when available; fall back to a sane // default (the backend only needs these for a possible hot relaunch). const relaunched = await vm.changeAgentProfile(agentId, profileId, 24, 80); // On a hot relaunch the backend mints a fresh session; rebind the panel's // terminal to it and drop any persisted conversation id for this cell (the // history is gone — the front reflects the swap for the current session). if (relaunched && agentId === activeAgentId) { setAgentSessions((prev) => ({ ...prev, [agentId]: relaunched.sessionId })); } } const canCreate = newName.trim().length > 0 && !vm.busy; async function handleCreate(e: React.FormEvent) { e.preventDefault(); if (!canCreate) return; if (newTemplateId && templateGw) { // Create from template — the template imposes its default profile. await templateGw.createAgentFromTemplate(projectId, newTemplateId, { name: newName.trim(), synchronized: true, }); // Refresh the agents list since we bypassed the hook's createAgent path. await vm.refresh(); } else { const profileId = newProfileId.trim() || ""; await vm.createAgent(newName.trim(), profileId); } setNewName(""); setNewProfileId(""); setNewTemplateId(""); } function handleLaunch(agentId: string) { setPanelNodeIds((prev) => ({ ...prev, [agentId]: prev[agentId] ?? crypto.randomUUID(), })); setActiveAgentId(agentId); } function handleStop(agentId?: string) { void vm.stopAgent(agentId); setActiveAgentId(null); } const selectedAgent = vm.agents.find((a) => a.id === vm.selectedAgentId) ?? null; // Determine if a template is chosen → profile selector is hidden (template imposes it). const hasTemplate = newTemplateId !== ""; return ( {vm.error && (

{vm.error}

)} {/* ── Create form ── */}
void handleCreate(e)} className="flex flex-col gap-3 border-b border-border p-4" >
setNewName(e.target.value)} className="w-full" />
{/* Template selector */}
{/* Profile selector — hidden when a template is chosen */} {!hasTemplate && (
{vm.profiles.length > 0 ? ( ) : ( setNewProfileId(e.target.value)} className="min-w-32" /> )}
)}
{/* ── Agent list ── */}
{vm.agents.length === 0 ? (

No agents yet.

) : (
    {vm.agents.map((a) => { const isSelected = a.id === vm.selectedAgentId; const isRunning = a.id === activeAgentId; const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id); const profileName = vm.profiles.find((p) => p.id === a.profileId)?.name ?? a.profileId; const agentDrift = drift.driftByAgentId.get(a.id); // Source of this agent's last orchestration delegation (mcp vs // file), if any has been observed. Absent ⇒ no badge. const delegationSource = vm.delegationSourceByRequester[a.id]; // Session-limit state (ARCHITECTURE §21), if the agent is limited. const limitState = vm.limitByAgent[a.id]; // F35 — local model server launch state, correlated from the // agent's profile binding (`opencode.localModelServerId`), plus the // agent's last launch failure (if any). const boundServerId = vm.profiles.find((p) => p.id === a.profileId) ?.opencode?.localModelServerId; const modelServerStatus = boundServerId ? vm.modelServerStatusByServer[boundServerId] : undefined; const launchFailure = vm.launchFailureByAgent[a.id]; return (
  • {limitState && ( void vm.cancelResume(a.id)} onSetResumeAt={(resetsAtMs) => void vm.setResumeAt(a.id, resetsAtMs) } /> )} {/* F35 — local model server launch state / actionable failure. */} {/* F2 (ticket #4): announcements this agent is waiting on while it talks to another agent (requester == this row's id). Sits just above the agent drop-list. */}
    {/* Profile hot-swap selector (Chantier A). Changing the engine abandons the conversation history → confirmation. */} {vm.profiles.length > 0 && ( )} {agentDrift && ( )} {isRunning || live ? ( ) : ( )}
  • ); })}
)}
{/* ── Agent terminal ── */} {activeAgentId !== null && (
vm.launchAgent( activeAgentId, { ...opts, nodeId: panelNodeIds[activeAgentId] }, onData, ) } reattach={(sessionId, onData) => gateways.agent.reattach(sessionId, onData) } sessionId={agentSessions[activeAgentId] ?? null} onSessionId={(sid) => setAgentSessions((prev) => ({ ...prev, [activeAgentId]: sid })) } />
)} {/* ── Skill assignment ── */} {selectedAgent && skillGw && (

Skills — {selectedAgent.name}

{/* Assigned skills */} {selectedAgent.skills.length === 0 ? (

No skills assigned.

) : (
    {selectedAgent.skills.map((ref) => { const name = skills.find((s) => s.id === ref.skillId)?.name ?? ref.skillId; return (
  • {name}
  • ); })}
)} {/* Assign selector */}
)} {/* ── Context editor ── */} {selectedAgent && (

Context — {selectedAgent.name}

{selectedAgent.contextPath}