/** * `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 { useEffect, useState } from "react"; import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared"; import { useI18n } from "@/i18n"; import { TerminalView } from "@/features/terminals/TerminalView"; import { AnnouncementsPreview } from "@/features/announcements"; import { useDrift } from "@/features/templates/useDrift"; import { templateProfileLabel } from "@/features/templates/templatePresentation"; import { useGateways } from "@/app/di"; import { useAgents } from "./useAgents"; import { publishAgentRosterChanged } from "./agentRosterEvents"; import { correlateModelServerStatus } from "./modelServerLaunch"; import { AgentLimitBadge } from "./AgentLimitBadge"; import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge"; import type { Agent, AgentProfile, EffortOption, EffortSelection, ResolvedAgentSystemPermissions, } from "@/domain"; 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 { tr } = useI18n(); const vm = useAgents(projectId); const drift = useDrift(projectId); const gateways = useGateways(); const templateGw = gateways.template ?? null; const permissionGw = gateways.permission ?? 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(() => {}); } // 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 [stopStateByAgent, setStopStateByAgent] = useState< Record >({}); 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>( {}, ); const [networkByAgent, setNetworkByAgent] = useState< Record >({}); useEffect(() => { if (!permissionGw || vm.agents.length === 0) { setNetworkByAgent({}); return; } let cancelled = false; void Promise.all( vm.agents.map(async (candidate) => { try { return [ candidate.id, await permissionGw.resolveAgentSystemPermissions( projectId, candidate.id, ), ] as const; } catch { return [candidate.id, null] as const; } }), ).then((pairs) => { if (cancelled) return; setNetworkByAgent( Object.fromEntries( pairs.filter( (pair): pair is readonly [string, ResolvedAgentSystemPermissions] => pair[1] !== null, ), ), ); }); return () => { cancelled = true; }; }, [permissionGw, projectId, vm.agents]); /** * 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); const [pendingWriterChange, setPendingWriterChange] = useState<{ agentId: string; agentName: string; currentWriterName: 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 })); } } function requestWriterChange(agent: Agent) { if (agent.isOrchestrator || vm.busy) return; const currentWriter = vm.agents.find((candidate) => candidate.isOrchestrator); if (currentWriter && currentWriter.id !== agent.id) { setPendingWriterChange({ agentId: agent.id, agentName: agent.name, currentWriterName: currentWriter.name, }); return; } void vm.updateProjectOrchestrator(agent.id); } async function confirmWriterChange() { if (!pendingWriterChange) return; const agentId = pendingWriterChange.agentId; setPendingWriterChange(null); await vm.updateProjectOrchestrator(agentId); } const selectedTemplate = templates.find((t) => t.id === newTemplateId) ?? null; const selectedTemplateMissingProfile = selectedTemplate !== null && selectedTemplate.defaultProfileId.trim().length === 0; const canCreate = newName.trim().length > 0 && !vm.busy && !selectedTemplateMissingProfile; 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, }); publishAgentRosterChanged(projectId); // 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 handleTemplateChange(templateId: string) { setNewTemplateId(templateId); const next = templates.find((template) => template.id === templateId); if (next?.defaultAgentName?.trim()) { setNewName(next.defaultAgentName.trim()); } } function handleLaunch(agentId: string) { setPanelNodeIds((prev) => ({ ...prev, [agentId]: prev[agentId] ?? crypto.randomUUID(), })); setActiveAgentId(agentId); } async function handleStop(agentId: string) { setStopStateByAgent((current) => ({ ...current, [agentId]: "stopping" })); const stopped = await vm.stopAgent(agentId); if (stopped) { setActiveAgentId((current) => (current === agentId ? null : current)); setStopStateByAgent((current) => { const next = { ...current }; delete next[agentId]; return next; }); } else { setStopStateByAgent((current) => ({ ...current, [agentId]: "incomplete" })); } } // Determine if a template is chosen → profile selector is hidden (template imposes it). const hasTemplate = newTemplateId !== ""; const selectedAgent = vm.agents.find((a) => a.id === vm.selectedAgentId) ?? null; const profileLabel = (profile: AgentProfile): string => { const model = profile.model ?? profile.opencode?.model ?? profile.opencodeProvider?.model ?? profile.chatHttp?.model; return model ? `${profile.name} · ${model}` : profile.name; }; return ( {vm.error && (
{tr("Mise à jour interrompue.", "Update interrupted.")} {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 */}
({ value: t.id, label: t.name })), ]} /> {selectedTemplate && (
{tr("Nom initial", "Initial name")}: {" "} {selectedTemplate.defaultAgentName || selectedTemplate.name}
{tr("Modèle appliqué", "Applied model")}: {" "} {templateProfileLabel(selectedTemplate, vm.profiles, tr)}
{selectedTemplateMissingProfile && (

{tr("Ce modèle n’a pas de profil IA par défaut. Choisissez un autre modèle ou modifiez-le avant de créer l’agent.", "This template has no default AI profile. Choose another template or edit it before creating the agent.")}

)}
)}
{/* Profile selector — hidden when a template is chosen */} {!hasTemplate && (
{vm.profiles.length > 0 ? ( ({ value: p.id, label: profileLabel(p), })), ]} /> ) : ( setNewProfileId(e.target.value)} className="min-w-32" /> )}
)}
{/* ── Agent list ── */}
{vm.agents.length === 0 ? (

{tr("Aucun agent pour le moment.", "No agents yet.")}

) : (
    {vm.agents.map((a) => { const isSelected = a.id === vm.selectedAgentId; const isRunning = a.id === activeAgentId; const stopState = stopStateByAgent[a.id]; const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id); const agentProfile = vm.profiles.find((p) => p.id === a.profileId) ?? null; const profileName = (agentProfile ? profileLabel(agentProfile) : null) ?? 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 modelServerStatus = correlateModelServerStatus( a, vm.profiles, vm.modelServerStatusByServer, ); const launchFailure = vm.launchFailureByAgent[a.id]; const network = networkByAgent[a.id] ?? null; return (
  • {isSelected && (

    {tr("Rôle dans le projet", "Role in project")}

    {tr("Peut modifier directement le contexte partagé du projet. Un seul agent peut avoir ce rôle.", "Can directly edit the project’s shared context. Only one agent can have this role.")}

    )} {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 && ( { if (profileId && profileId !== a.profileId) { setPendingProfileChange({ agentId: a.id, profileId }); } }} options={[ ...(vm.profiles.every((p) => p.id !== a.profileId) ? [{ value: a.profileId, label: a.profileId }] : []), ...vm.profiles.map((p) => ({ value: p.id, label: profileLabel(p), })), ]} /> )} void vm.updateAgentEffort(a.id, effort) } /> {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 })) } systemPermissions={networkByAgent[activeAgentId] ?? null} />
)} {/* ── Context editor ── */} {selectedAgent && (

{tr("Contexte", "Context")} — {selectedAgent.name}

{selectedAgent.contextPath}