Files
IdeA/frontend/src/features/agents/AgentsPanel.tsx
Blomios 9b1ced50be feat(ui): uniformise les droplist dynamiques sur le style du picker d'agent
Introduit SmallDropdown comme composant partagé et le fait adopter par
AgentsPanel, ModelServerSelect, OpenCodeModeFields, TemplateEditor et
TicketViewportSelect, pour que toutes les listes déroulantes dynamiques
(agent, modèle, template, viewport) partagent la même apparence que le
sélecteur d'agent de la création de ticket.

Refs #114

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 12:55:57 +02:00

745 lines
27 KiB
TypeScript

/**
* `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 { 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 { correlateModelServerStatus } from "./modelServerLaunch";
import { AgentLimitBadge } from "./AgentLimitBadge";
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
import type { 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 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<import("@/domain").Template[]>([]);
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<import("@/domain").Skill[]>([]);
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<string | null>(null);
const [panelNodeIds, setPanelNodeIds] = useState<Record<string, string>>({});
/**
* 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<Record<string, string>>(
{},
);
const [networkByAgent, setNetworkByAgent] = useState<
Record<string, ResolvedAgentSystemPermissions>
>({});
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);
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 !== "";
const profileLabel = (profile: import("@/domain").AgentProfile): string => {
const model =
profile.model ??
profile.opencode?.model ??
profile.opencodeProvider?.model ??
profile.chatHttp?.model;
return model ? `${profile.name} · ${model}` : profile.name;
};
return (
<Panel title="Agents" className="flex flex-col gap-0">
{vm.error && (
<p
role="alert"
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{/* ── Create form ── */}
<form
onSubmit={(e) => void handleCreate(e)}
className="flex flex-col gap-3 border-b border-border p-4"
>
<div className="flex min-w-0 flex-col gap-1">
<label
htmlFor="agent-name-input"
className="text-xs font-medium text-muted"
>
Name
</label>
<Input
id="agent-name-input"
aria-label="agent name"
placeholder="My agent"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full"
/>
</div>
{/* Template selector */}
<div className="flex min-w-0 flex-col gap-1">
<label
htmlFor="agent-template-select"
className="text-xs font-medium text-muted"
>
Template
</label>
<SmallDropdown
id="agent-template-select"
aria-label="agent template"
value={newTemplateId}
onChange={setNewTemplateId}
className="w-full"
disabled={vm.busy}
size="md"
options={[
{ value: "", label: "(none / from scratch)" },
...templates.map((t) => ({ value: t.id, label: t.name })),
]}
/>
</div>
{/* Profile selector — hidden when a template is chosen */}
{!hasTemplate && (
<div className="flex min-w-0 flex-col gap-1">
<label
htmlFor="agent-profile-select"
className="text-xs font-medium text-muted"
>
Profile
{vm.profiles.length === 0 && (
<span className="ml-1 text-faint">(no profiles configured you can still enter an id)</span>
)}
</label>
{vm.profiles.length > 0 ? (
<SmallDropdown
id="agent-profile-select"
aria-label="agent profile"
value={newProfileId}
onChange={setNewProfileId}
className="w-full"
size="md"
options={[
{ value: "", label: "— select profile —" },
...vm.profiles.map((p) => ({
value: p.id,
label: profileLabel(p),
})),
]}
/>
) : (
<Input
id="agent-profile-select"
aria-label="agent profile"
placeholder="profile-id (optional)"
value={newProfileId}
onChange={(e) => setNewProfileId(e.target.value)}
className="min-w-32"
/>
)}
</div>
)}
<Button
type="submit"
variant="primary"
aria-label="create agent"
disabled={!canCreate}
loading={vm.busy}
className="self-end"
>
Create
</Button>
</form>
{/* ── Agent list ── */}
<div className="p-4">
{vm.agents.length === 0 ? (
<p className="text-sm text-muted">No agents yet.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{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 =
(() => {
const p = vm.profiles.find((p) => p.id === a.profileId);
return p ? profileLabel(p) : 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 (
<li
key={a.id}
className={cn(
"flex flex-col gap-2 py-3 first:pt-0 last:pb-0",
isSelected && "rounded-md bg-raised px-2",
)}
>
<button
type="button"
onClick={() => void vm.selectAgent(a.id)}
className="flex min-w-0 flex-col items-start gap-0.5 text-left"
aria-pressed={isSelected}
>
<span className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
<span className="font-medium text-content">{a.name}</span>
{agentDrift && (
<span
aria-label="update available"
className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary"
>
update available
</span>
)}
{delegationSource && (
<span
aria-label={`delegation source ${delegationSource}`}
title={
delegationSource === "mcp"
? "Last delegation arrived via the MCP server"
: "Last delegation arrived via .ideai/requests"
}
className="rounded-full bg-raised px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted"
>
{delegationSource}
</span>
)}
{network && <NetworkPermissionBadge state={network} />}
</span>
<span className="text-xs text-muted">{profileName}</span>
{live && (
<span className="block w-full min-w-0 truncate text-xs text-primary">
running in IdeA · {live.sessionId ?? live.nodeId}
</span>
)}
</button>
{limitState && (
<AgentLimitBadge
state={limitState}
busy={vm.busy}
onCancelResume={() => void vm.cancelResume(a.id)}
onSetResumeAt={(resetsAtMs) =>
void vm.setResumeAt(a.id, resetsAtMs)
}
/>
)}
{/* F35 — local model server launch state / actionable failure. */}
<ModelServerLaunchBadge
status={modelServerStatus}
failure={launchFailure}
/>
{/* 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. */}
<AnnouncementsPreview requester={a.id} />
<div className="flex flex-wrap items-center gap-1.5">
{/* Profile hot-swap selector (Chantier A). Changing the
engine abandons the conversation history → confirmation. */}
{vm.profiles.length > 0 && (
<SmallDropdown
aria-label={`profile for ${a.name}`}
value={a.profileId}
disabled={vm.busy}
onChange={(profileId) => {
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),
})),
]}
/>
)}
{agentDrift && (
<Button
size="sm"
variant="primary"
aria-label={`sync ${a.name}`}
disabled={vm.busy || drift.driftBusy}
onClick={() =>
void drift.syncAgent(a.id, vm.refresh)
}
>
Sync
</Button>
)}
{isRunning || live ? (
<Button
size="sm"
variant="ghost"
aria-label={`stop ${a.name}`}
onClick={() => handleStop(a.id)}
className="text-danger hover:text-danger"
>
Stop
</Button>
) : (
<Button
size="sm"
variant="primary"
aria-label={`launch ${a.name}`}
disabled={vm.busy || activeAgentId !== null}
onClick={() => handleLaunch(a.id)}
>
Launch
</Button>
)}
<Button
size="sm"
variant="ghost"
aria-label={`delete ${a.name}`}
disabled={vm.busy}
onClick={() => void vm.deleteAgent(a.id)}
className="text-danger hover:text-danger"
>
Delete
</Button>
</div>
</li>
);
})}
</ul>
)}
</div>
{/* ── Agent terminal ── */}
{activeAgentId !== null && (
<div className="border-t border-border p-4">
<div className="h-96 overflow-hidden rounded-lg border border-border bg-surface">
<TerminalView
key={activeAgentId}
cwd={projectRoot}
open={(opts, onData) =>
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}
/>
</div>
</div>
)}
{/* ── Skill assignment ── */}
{selectedAgent && skillGw && (
<div className="flex flex-col gap-2 border-t border-border p-4">
<h4 className="text-sm font-semibold text-content">
Skills {selectedAgent.name}
</h4>
{/* Assigned skills */}
{selectedAgent.skills.length === 0 ? (
<p className="text-xs text-muted">No skills assigned.</p>
) : (
<ul className="flex flex-wrap gap-1.5">
{selectedAgent.skills.map((ref) => {
const name =
skills.find((s) => s.id === ref.skillId)?.name ?? ref.skillId;
return (
<li
key={ref.skillId}
className="flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs text-content"
>
<span>{name}</span>
<button
type="button"
aria-label={`unassign ${name}`}
disabled={vm.busy}
onClick={() => void handleUnassignSkill(ref.skillId)}
className="text-faint hover:text-danger"
>
</button>
</li>
);
})}
</ul>
)}
{/* Assign selector */}
<div className="flex items-end gap-2">
<SmallDropdown
aria-label="skill to assign"
value={skillToAssign}
onChange={setSkillToAssign}
disabled={vm.busy}
className="min-w-0 flex-1"
size="md"
options={[
{ value: "", label: "— assign a skill —" },
...skills
.filter(
(s) =>
!selectedAgent.skills.some((r) => r.skillId === s.id),
)
.map((s) => ({
value: s.id,
label: `${s.name} (${s.scope})`,
})),
]}
/>
<Button
variant="primary"
aria-label="assign skill"
disabled={vm.busy || !skillToAssign}
onClick={() => void handleAssignSkill()}
>
Assign
</Button>
</div>
</div>
)}
{/* ── Context editor ── */}
{selectedAgent && (
<div className="flex flex-col gap-2 border-t border-border p-4">
<div className="flex items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-content">
Context {selectedAgent.name}
</h4>
<code className="text-xs text-muted">{selectedAgent.contextPath}</code>
</div>
<textarea
aria-label="agent context"
value={editedContext}
onChange={(e) => setEditedContext(e.target.value)}
rows={10}
className={cn(
"w-full rounded-md bg-raised px-3 py-2 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary placeholder:text-faint",
"disabled:cursor-not-allowed disabled:opacity-50 resize-y font-mono",
)}
disabled={vm.busy}
/>
<div className="flex items-center justify-end gap-2">
{vm.busy && <Spinner size={14} />}
<Button
variant="primary"
disabled={vm.busy}
onClick={() => void vm.saveContext(editedContext)}
>
Save
</Button>
</div>
</div>
)}
{/* ── Profile-change confirmation dialog ── */}
{pendingProfileChange && (
<div
role="dialog"
aria-modal="true"
aria-label="Confirm profile change"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
<h4 className="text-sm font-semibold text-content">
Changer le moteur IA
</h4>
<p className="mt-2 text-sm text-muted">
Changer le moteur abandonne l'historique de conversation (le
contexte et la mémoire sont conservés). Continuer&nbsp;?
</p>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
aria-label="cancel profile change"
disabled={vm.busy}
onClick={() => setPendingProfileChange(null)}
>
Annuler
</Button>
<Button
variant="primary"
aria-label="confirm profile change"
loading={vm.busy}
disabled={vm.busy}
onClick={() => void confirmProfileChange()}
>
Continuer
</Button>
</div>
</div>
</div>
)}
</Panel>
);
}
function NetworkPermissionBadge({
state,
}: {
state: ResolvedAgentSystemPermissions;
}) {
const locked = state.runtimeLock.state === "locked";
const label = locked
? "Verrouillé"
: state.effective === "allow"
? "Autorisé"
: state.effective === "deny"
? "Interdit"
: "Demande";
return (
<span
aria-label={`network ${label}`}
title={
locked
? (state.runtimeLock.reason ?? "Réseau verrouillé par le runtime")
: `État effectif : ${label}`
}
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
locked
? "bg-warning/15 text-warning"
: state.effective === "allow"
? "bg-success/15 text-success"
: state.effective === "deny"
? "bg-danger/15 text-danger"
: "bg-primary/15 text-primary",
)}
>
Réseau: {label}
</span>
);
}