feat(skills,mcp): menus Skills et MCP unifies avec selection cochable des tools #297 (SkillsPanel devient la surface unique d'assignation cochable des skills IdeA + plugins — cible agent, recherche, groupes IdeA/Plugins, tombstones retirables, CRUD catalogue natif conserve, stores separes SkillGateway/PluginGateway; nouveau panneau MCP dedie McpPanel desktop+web — tools MCP IdeA via PermissionGateway et tools MCP plugins catalogues via PluginGateway groupes par plugin/serveur, selection cochable, fin de la saisie manuelle du nom exact, serveurs sans catalogue en tombstones retirables uniquement; retrait complet de la surface Capacites de la fiche agent et suppression d'AgentPluginCapabilities.tsx; PermissionsPanel reserve aux permissions systeme/sandbox, onglet MCP retire; raccourci Parametres > Plugins ouvre Skills avec renvois vers Skills/MCP; integration ProjectsView/ViewPanelBody/viewPlacement/WebWorkspace/webSurfaceCatalog; QA verte: tsc+vite build OK, 28+24 tests cibles OK, suite complete 1223 OK / 32 echecs sur 6 fichiers web hors perimetre classes non bloquants; scripts/ et sync tickets #296 hors perimetre; aucun push)

This commit is contained in:
2026-09-09 20:38:39 +02:00
parent f78f0093e8
commit e6ce7d6e74
17 changed files with 452 additions and 631 deletions

View File

@ -9,15 +9,16 @@
* - "New skill" button that opens {@link SkillEditor} in create-mode
* - Per-skill "Edit" (opens the editor in edit-mode) and "Delete"
*
* Skills are assigned to agents from the `AgentsPanel`, not here — this panel is
* the authoring surface only ("no direct invoke": a skill is never run from the
* UI, only injected into an agent's convention file at activation).
* This is also the unified assignment surface for IdeA and plugin skills. A
* skill is never run from the UI; it is injected into an agent at activation.
*/
import { useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Skill, SkillScope } from "@/domain";
import { Button, Panel, Spinner } from "@/shared";
import { useGateways } from "@/app/di";
import type { Agent, PluginSkillRef, Skill, SkillRef, SkillScope } from "@/domain";
import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { SkillEditor } from "./SkillEditor";
import { useSkills } from "./useSkills";
@ -29,11 +30,101 @@ export interface SkillsPanelProps {
/** Editor open-state: "create" or edit-mode for a specific skill. */
type EditorState = { mode: "create" } | { mode: "edit"; skill: Skill };
const nativeSkillKey = (ref: SkillRef) => `${ref.scope}:${ref.skillId}`;
const pluginSkillKey = (ref: PluginSkillRef) => `${ref.pluginId}:${ref.skillId}`;
export function SkillsPanel({ projectId }: SkillsPanelProps) {
const vm = useSkills(projectId);
const { agent: agentGateway, skill: skillGateway } = useGateways();
const plugins = useProjectPlugins(projectId);
const [editorState, setEditorState] = useState<EditorState | null>(null);
const [editorBusy, setEditorBusy] = useState(false);
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [assignmentBusy, setAssignmentBusy] = useState(false);
const [assignmentError, setAssignmentError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const loadAgents = useCallback(async () => {
try {
const rows = await agentGateway.listAgents(projectId);
setAgents(rows);
setSelectedAgentId((current) =>
current && rows.some((agent) => agent.id === current)
? current
: (rows[0]?.id ?? null),
);
} catch (cause) {
setAssignmentError(cause instanceof Error ? cause.message : String(cause));
}
}, [agentGateway, projectId]);
useEffect(() => {
void loadAgents();
}, [loadAgents]);
const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
const nativeSkills = useMemo(
() => [...vm.projectSkills, ...vm.globalSkills],
[vm.globalSkills, vm.projectSkills],
);
const normalizedQuery = query.trim().toLocaleLowerCase();
const matches = (value: string) =>
!normalizedQuery || value.toLocaleLowerCase().includes(normalizedQuery);
const enabledPluginIds = new Set(
(plugins.settings?.plugins ?? [])
.filter((entry) => entry.enabled)
.map((entry) => entry.pluginId),
);
const pluginAssignments = plugins.settings?.agents.find(
(entry) => entry.agentId === selectedAgentId,
)?.skills ?? [];
const assignedPluginKeys = new Set(pluginAssignments.map(pluginSkillKey));
const knownPluginKeys = new Set(
plugins.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`),
),
);
const stalePluginSkills = pluginAssignments.filter(
(ref) => !knownPluginKeys.has(pluginSkillKey(ref)),
);
const assignedNativeKeys = new Set((selectedAgent?.skills ?? []).map(nativeSkillKey));
const knownNativeKeys = new Set(
nativeSkills.map((skill) => `${skill.scope}:${skill.id}`),
);
const staleNativeSkills = (selectedAgent?.skills ?? []).filter(
(ref) => !knownNativeKeys.has(nativeSkillKey(ref)),
);
async function setNativeSkill(skill: Skill, assigned: boolean) {
if (!selectedAgent) return;
setAssignmentBusy(true);
setAssignmentError(null);
try {
if (assigned) {
await skillGateway.assignSkill(projectId, selectedAgent.id, skill.id, skill.scope);
} else {
await skillGateway.unassignSkill(projectId, selectedAgent.id, skill.id);
}
await loadAgents();
} catch (cause) {
setAssignmentError(cause instanceof Error ? cause.message : String(cause));
} finally {
setAssignmentBusy(false);
}
}
async function removeNativeSkill(ref: SkillRef) {
if (!selectedAgent) return;
setAssignmentBusy(true);
try {
await skillGateway.unassignSkill(projectId, selectedAgent.id, ref.skillId);
await loadAgents();
} finally {
setAssignmentBusy(false);
}
}
async function handleSave(name: string, content: string, scope: SkillScope) {
if (!editorState) return;
@ -105,15 +196,105 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
)}
<Panel title="Skills" className="flex flex-col gap-0">
{vm.error && (
{(vm.error || plugins.error || assignmentError) && (
<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}
{vm.error ?? plugins.error ?? assignmentError}
</p>
)}
<div className="flex min-h-0 flex-col gap-3 border-b border-border p-4 md:flex-row">
<nav aria-label="cibles des skills" className="flex w-full shrink-0 flex-col gap-1.5 md:w-56">
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">Agents</h4>
{agents.length === 0 ? (
<p className="text-sm text-muted">Aucun agent dans ce projet.</p>
) : agents.map((agent) => (
<button
key={agent.id}
type="button"
aria-pressed={selectedAgentId === agent.id}
onClick={() => setSelectedAgentId(agent.id)}
className={cn(
"rounded-md border px-3 py-2 text-left text-sm font-medium transition-colors",
selectedAgentId === agent.id
? "border-primary bg-raised text-content"
: "border-border bg-surface text-muted hover:text-content",
)}
>
{agent.name}
</button>
))}
</nav>
<section className="min-w-0 flex-1" aria-label="attribution des skills">
<div className="mb-3 flex items-center justify-between gap-2">
<div>
<h4 className="text-sm font-semibold text-content">
{selectedAgent ? `Skills — ${selectedAgent.name}` : "Skills"}
</h4>
<p className="text-xs text-muted">Skills IdeA et plugins disponibles.</p>
</div>
{(assignmentBusy || plugins.busy || plugins.loading) && <Spinner size={14} />}
</div>
<Input
aria-label="rechercher un skill"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Rechercher un skill"
disabled={!selectedAgent}
/>
{selectedAgent && (
<div className="mt-3 flex flex-col gap-2">
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">IdeA</h5>
{nativeSkills.filter((skill) => matches(`${skill.name} ${skill.description ?? ""}`)).map((skill) => {
const assigned = assignedNativeKeys.has(`${skill.scope}:${skill.id}`);
return (
<label key={`${skill.scope}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={assigned} disabled={assignmentBusy || vm.busy} onChange={(event) => void setNativeSkill(skill, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span>
<span className="text-faint">{skill.scope === "project" ? "Projet" : "Global"}</span>
</label>
);
})}
{staleNativeSkills.filter((ref) => matches(ref.skillId)).map((ref) => (
<div key={`missing:${nativeSkillKey(ref)}`} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
<span><span className="font-medium">{ref.skillId}</span> <span className="text-warning">— Indisponible</span> <span className="text-faint">IdeA</span></span>
<Button size="sm" variant="ghost" disabled={assignmentBusy} onClick={() => void removeNativeSkill(ref)}>Retirer</Button>
</div>
))}
<h5 className="mt-2 text-xs font-semibold uppercase tracking-wide text-faint">Plugins</h5>
{plugins.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? [])
.filter((skill) => matches(`${skill.name} ${skill.description} ${plugin.displayName}`))
.map((skill) => {
const assigned = assignedPluginKeys.has(`${plugin.id}:${skill.id}`);
const enabled = enabledPluginIds.has(plugin.id);
return (
<label key={`${plugin.id}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={assigned} disabled={plugins.busy || (!enabled && !assigned)} onChange={(event) => void plugins.setSkillAssigned(selectedAgent.id, plugin.id, skill.id, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
<span className="text-faint">{plugin.displayName}</span>
</label>
);
}),
)}
{stalePluginSkills.filter((ref) => matches(`${ref.pluginId} ${ref.skillId}`)).map((ref) => (
<div key={`missing-plugin:${pluginSkillKey(ref)}`} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
<span><span className="font-medium">{ref.skillId}</span> <span className="text-warning">— Indisponible</span> <span className="text-faint">Plugin: {ref.pluginId}</span></span>
<Button size="sm" variant="ghost" disabled={plugins.busy} onClick={() => void plugins.setSkillAssigned(selectedAgent.id, ref.pluginId, ref.skillId, false)}>Retirer</Button>
</div>
))}
{nativeSkills.length === 0 && plugins.installed.every((plugin) => (plugin.contributes.skills ?? []).length === 0) && staleNativeSkills.length === 0 && stalePluginSkills.length === 0 && (
<p className="text-sm text-muted">Aucun skill disponible.</p>
)}
</div>
)}
</section>
</div>
{/* ── Create button ── */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">

View File

@ -6,7 +6,7 @@
* - createSkill (project + global) → skill appears under the right scope
* - updateSkill → content persisted in the gateway
* - deleteSkill → skill removed from the list
* - assignSkill / unassignSkill in AgentsPanel → reflected on the agent record
* - assignSkill / unassignSkill in SkillsPanel → reflected on the agent record
* - guardrail "no direct invoke": SkillsPanel never offers a run/launch action
*
* Also includes MockSkillGateway unit tests.
@ -17,22 +17,19 @@ import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import {
MockAgentGateway,
MockPermissionGateway,
MockPluginGateway,
MockProfileGateway,
MockSkillGateway,
} from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { SkillsPanel } from "./SkillsPanel";
import { AgentsPanel } from "@/features/agents/AgentsPanel";
const PROJECT_ID = "proj-skill-test";
function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) {
const a = agent ?? new MockAgentGateway();
const sk = skill ?? new MockSkillGateway(a);
const gateways = { agent: a, skill: sk } as unknown as Gateways;
const gateways = { agent: a, skill: sk, plugin: new MockPluginGateway() } as unknown as Gateways;
return {
agent: a,
skill: sk,
@ -44,22 +41,6 @@ function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) {
};
}
function renderAgentsPanel(agent: MockAgentGateway, skill: MockSkillGateway) {
const profile = new MockProfileGateway();
const gateways = {
agent,
profile,
skill,
permission: new MockPermissionGateway(),
plugin: new MockPluginGateway(),
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<AgentsPanel projectId={PROJECT_ID} projectRoot="/tmp/proj" />
</DIProvider>,
);
}
async function waitForSkillsIdle() {
await waitFor(() => {
expect(screen.getByRole("button", { name: "create skill" })).toBeTruthy();
@ -171,7 +152,7 @@ describe("SkillsPanel (with MockSkillGateway)", () => {
});
// ---------------------------------------------------------------------------
// Assignment integration (AgentsPanel)
// Assignment integration (SkillsPanel)
// ---------------------------------------------------------------------------
describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
@ -189,11 +170,7 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
scope: "project",
});
renderAgentsPanel(agent, skill);
await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
// Select the agent
fireEvent.click(screen.getByText("Worker"));
renderSkillsPanel(agent, skill);
const toggle = await screen.findByRole("checkbox", { name: /Deploy/ });
const sk = (await skill.listSkills(PROJECT_ID, "project"))[0];
fireEvent.click(toggle);
@ -222,9 +199,7 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
});
await skill.assignSkill(PROJECT_ID, a.id, sk.id, sk.scope);
renderAgentsPanel(agent, skill);
await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
fireEvent.click(screen.getByText("Worker2"));
renderSkillsPanel(agent, skill);
const toggle = await screen.findByRole("checkbox", { name: /Cleanup/ });
expect((toggle as HTMLInputElement).checked).toBe(true);