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:
@ -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">
|
||||
|
||||
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user