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

@ -19,7 +19,6 @@ 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 { AgentPluginCapabilities } from "@/features/plugins/AgentPluginCapabilities";
import { useDrift } from "@/features/templates/useDrift";
import { templateProfileLabel } from "@/features/templates/templatePresentation";
import { useGateways } from "@/app/di";
@ -624,20 +623,6 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
</div>
)}
{selectedAgent && gateways.plugin && (
<div className="border-t border-border p-4">
<AgentPluginCapabilities
projectId={projectId}
agent={selectedAgent}
active={
activeAgentId === selectedAgent.id ||
vm.liveAgents.some((live) => live.agentId === selectedAgent.id)
}
onAgentChanged={vm.refresh}
/>
</div>
)}
{/* ── Context editor ── */}
{selectedAgent && (
<div className="flex flex-col gap-2 border-t border-border p-4">

View File

@ -0,0 +1,51 @@
import { useCallback, useEffect, useState } from "react";
import { useGateways } from "@/app/di";
import type { Agent } from "@/domain";
import { Button, Panel, Spinner } from "@/shared";
import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel";
export interface McpPanelProps {
projectId: string;
}
export function McpPanel({ projectId }: McpPanelProps) {
const { agent } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
setAgents(await agent.listAgents(projectId));
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoading(false);
}
}, [agent, projectId]);
useEffect(() => {
void refresh();
}, [refresh]);
return (
<Panel
title="MCP"
actions={loading ? <Spinner size={14} /> : <Button size="sm" variant="ghost" onClick={() => void refresh()}>Refresh</Button>}
className="flex min-h-0 flex-1 flex-col"
flush
>
{error ? (
<div className="flex flex-col items-start gap-2 p-4">
<p role="alert" className="text-sm text-danger">{error}</p>
<Button size="sm" onClick={() => void refresh()}>Réessayer</Button>
</div>
) : (
<McpToolPermissionsPanel projectId={projectId} agents={agents} />
)}
</Panel>
);
}

View File

@ -1,5 +1,5 @@
/**
* Ticket #82 — the "Tools MCP IdeA" tab of the project `Permissions` panel.
* Ticket #82/#297 — the dedicated project `MCP` panel.
*
* Pins the UX contract from carnet #82 (Conception UX/F): an agent without an
* override shows it inherits the project default with non-editable controls
@ -14,11 +14,12 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
import {
MockAgentGateway,
MockPermissionGateway,
MockPluginGateway,
MockProfileGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import type { Gateways } from "@/ports";
import { PermissionsPanel } from "./PermissionsPanel";
import { McpPanel } from "./McpPanel";
const PROJECT_ID = "proj-mcp-permissions-test";
@ -32,12 +33,13 @@ async function renderPanel() {
const gateways = {
agent,
permission,
plugin: new MockPluginGateway(),
profile: new MockProfileGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<PermissionsPanel projectId={PROJECT_ID} />
<McpPanel projectId={PROJECT_ID} />
</DIProvider>,
);
@ -48,9 +50,7 @@ async function renderPanel() {
return { agent, permission, agentId: created.id };
}
/** Switches to the "Tools MCP IdeA" tab and waits past the doc load. */
async function openMcpToolsTab() {
fireEvent.click(screen.getByRole("tab", { name: "Tools MCP IdeA" }));
await screen.findByRole("navigation", { name: "cibles des permissions MCP" });
}
@ -58,20 +58,16 @@ async function selectAgentTarget(name: string) {
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
fireEvent.click(within(nav).getByRole("button", { name: new RegExp(name) }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: `Tools MCP IdeA — ${name}` })).toBeTruthy();
expect(screen.getByRole("heading", { name: `MCP IdeA — ${name}` })).toBeTruthy();
});
}
describe("PermissionsPanel — Tools MCP IdeA tab", () => {
it("has an accessible tablist and defaults to Système", async () => {
describe("McpPanel", () => {
it("is exposed as a dedicated surface", async () => {
await renderPanel();
const tablist = screen.getByRole("tablist", { name: "Permissions" });
const tabs = within(tablist).getAllByRole("tab");
expect(tabs.map((t) => t.textContent)).toEqual(["Système", "Tools MCP IdeA"]);
expect(screen.getByRole("tab", { name: "Système" }).getAttribute("aria-selected")).toBe(
"true",
);
await openMcpToolsTab();
expect(screen.getByRole("heading", { name: "MCP" })).toBeTruthy();
expect(screen.queryByRole("tablist", { name: "Permissions" })).toBeNull();
});
it("an agent without an override shows it inherits the project default, read-only", async () => {
@ -131,11 +127,12 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => {
const gateways = {
agent,
permission,
plugin: new MockPluginGateway(),
profile: new MockProfileGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<PermissionsPanel projectId={PROJECT_ID} />
<McpPanel projectId={PROJECT_ID} />
</DIProvider>,
);
@ -262,7 +259,7 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => {
expect(window.confirm).toHaveBeenCalled();
// Still on the agent editor, with the unsaved edit intact.
expect(
screen.getByRole("heading", { name: "Tools MCP IdeA — DevFrontend" }),
screen.getByRole("heading", { name: "MCP IdeA — DevFrontend" }),
).toBeTruthy();
expect(
(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }) as HTMLInputElement)

View File

@ -1,6 +1,6 @@
/**
* `McpToolPermissionsPanel` — the "Tools MCP IdeA" tab of the project
* `Permissions` panel (ticket #82). Lets the user grant/revoke MCP tool
* `McpToolPermissionsPanel` — the editor hosted by the dedicated `MCP` panel
* (tickets #82/#297). Lets the user grant/revoke MCP tool
* capabilities per agent (or project-wide default), grouped by domain rather
* than as a flat 25-checkbox list (carnet #82 — Conception UX/F).
*
@ -15,8 +15,9 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { Agent, McpToolCatalogue } from "@/domain";
import { Button, Panel, cn } from "@/shared";
import type { Agent, McpToolCatalogue, PluginToolRef } from "@/domain";
import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
import { Button, Input, Panel, cn } from "@/shared";
import {
mcpToolGroupsForCatalogue,
mcpToolLabel,
@ -49,6 +50,7 @@ export function McpToolPermissionsPanel({
agents,
}: McpToolPermissionsPanelProps) {
const vm = useMcpToolPermissions(projectId);
const plugins = useProjectPlugins(projectId);
const [target, setTarget] = useState<McpTarget>({ type: "project" });
// Read imperatively (not via re-render) by `selectTarget` so an unsaved
// draft in the currently-mounted editor blocks a target switch without
@ -176,32 +178,138 @@ export function McpToolPermissionsPanel({
}}
/>
) : (
<McpToolEditor
key={target.agentId}
targetKind="agent"
title={`Tools MCP IdeA — ${target.agentName}`}
catalogue={doc.catalogue}
storedAllowed={selectedAgentOverride?.policy.allowedTools ?? null}
comparisonBaseline={
doc.projectDefault?.allowedTools ?? doc.catalogue.readOnlyTools
}
busy={vm.busy}
onDirtyChange={(dirty) => {
dirtyRef.current = dirty;
}}
onSaveAgentOverride={async (allowed) => {
await vm.saveAgentOverride(target.agentId, { allowedTools: allowed });
}}
onResetOverride={async () => {
await vm.resetAgentOverride(target.agentId);
}}
/>
<div className="flex flex-col gap-4">
<McpToolEditor
key={target.agentId}
targetKind="agent"
title={`MCP IdeA — ${target.agentName}`}
catalogue={doc.catalogue}
storedAllowed={selectedAgentOverride?.policy.allowedTools ?? null}
comparisonBaseline={
doc.projectDefault?.allowedTools ?? doc.catalogue.readOnlyTools
}
busy={vm.busy}
onDirtyChange={(dirty) => {
dirtyRef.current = dirty;
}}
onSaveAgentOverride={async (allowed) => {
await vm.saveAgentOverride(target.agentId, { allowedTools: allowed });
}}
onResetOverride={async () => {
await vm.resetAgentOverride(target.agentId);
}}
/>
<PluginMcpToolsEditor
agentId={target.agentId}
agentName={target.agentName}
plugins={plugins}
/>
</div>
)}
</div>
</div>
);
}
const pluginToolKey = (ref: PluginToolRef) =>
`${ref.pluginId}:${ref.serverId}:${ref.toolName}`;
function PluginMcpToolsEditor({
agentId,
agentName,
plugins,
}: {
agentId: string;
agentName: string;
plugins: ReturnType<typeof useProjectPlugins>;
}) {
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLocaleLowerCase();
const matches = (value: string) =>
!normalizedQuery || value.toLocaleLowerCase().includes(normalizedQuery);
const assignments = plugins.settings?.agents.find(
(entry) => entry.agentId === agentId,
)?.tools ?? [];
const assignedKeys = new Set(assignments.map(pluginToolKey));
const enabledPluginIds = new Set(
(plugins.settings?.plugins ?? [])
.filter((entry) => entry.enabled)
.map((entry) => entry.pluginId),
);
const knownKeys = new Set(
plugins.installed.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? []).flatMap((server) =>
(server.tools ?? []).map(
(tool) => `${plugin.id}:${server.id}:${tool.name}`,
),
),
),
);
const tombstones = assignments.filter((ref) => !knownKeys.has(pluginToolKey(ref)));
const cataloguedCount = plugins.installed.reduce(
(total, plugin) =>
total + (plugin.contributes.mcpServers ?? []).reduce(
(serverTotal, server) => serverTotal + (server.tools ?? []).length,
0,
),
0,
);
return (
<Panel title={`MCP plugins — ${agentName}`} flush>
<div className="flex flex-col gap-3 p-4">
<Input
aria-label="rechercher un tool MCP plugin"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Rechercher un tool ou un plugin"
/>
{plugins.error && <p role="alert" className="text-sm text-danger">{plugins.error}</p>}
{plugins.installed.map((plugin) => {
const rows = (plugin.contributes.mcpServers ?? []).flatMap((server) =>
(server.tools ?? [])
.filter((tool) =>
matches(`${tool.name} ${tool.description ?? ""} ${server.displayName} ${plugin.displayName}`),
)
.map((tool) => ({ server, tool })),
);
if (rows.length === 0) return null;
const enabled = enabledPluginIds.has(plugin.id);
return (
<section key={plugin.id} className="flex flex-col gap-2" aria-label={`Plugin ${plugin.displayName}`}>
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">{plugin.displayName}</h4>
{rows.map(({ server, tool }) => {
const ref = { pluginId: plugin.id, serverId: server.id, toolName: tool.name };
const assigned = assignedKeys.has(pluginToolKey(ref));
return (
<label key={pluginToolKey(ref)} 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.setToolAssigned({ agentId, ...ref }, event.target.checked)}
/>
<span className="min-w-0 flex-1"><span className="font-medium">{tool.name}</span>{tool.description ? ` — ${tool.description}` : ""}<span className="text-faint"> · {server.displayName}</span>{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
</label>
);
})}
</section>
);
})}
{tombstones.filter((ref) => matches(`${ref.pluginId} ${ref.serverId} ${ref.toolName}`)).map((ref) => (
<div key={`missing:${pluginToolKey(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.toolName}</span> · {ref.serverId} <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.setToolAssigned({ agentId, ...ref }, false)}>Retirer</Button>
</div>
))}
{!plugins.loading && cataloguedCount === 0 && tombstones.length === 0 && (
<p className="text-sm text-muted">Aucun tool MCP plugin catalogué.</p>
)}
</div>
</Panel>
);
}
interface McpToolEditorProps {
targetKind: "project" | "agent";
title: string;

View File

@ -8,7 +8,6 @@ import type {
ResolvedAgentSystemPermissions,
SystemPermissionSet,
} from "@/domain";
import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel";
import {
type CapabilityChoice,
type PolicyDraft,
@ -21,13 +20,6 @@ export interface PermissionsPanelProps {
projectId: string;
}
type PermissionsTab = "system" | "mcpTools";
const TABS: { id: PermissionsTab; label: string }[] = [
{ id: "system", label: "Système" },
{ id: "mcpTools", label: "Tools MCP IdeA" },
];
type EditorTarget =
| { type: "project" }
| { type: "agent"; agentId: string; agentName: string };
@ -66,7 +58,6 @@ function systemPermissionsFromDraft(network: NetworkPolicy | ""): SystemPermissi
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
const vm = usePermissions(projectId);
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
const [tab, setTab] = useState<PermissionsTab>("system");
const selectedAgent = target.type === "agent"
? vm.rows.find((row) => row.agent.id === target.agentId) ?? null
@ -143,36 +134,6 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
className="flex min-h-0 flex-1 flex-col"
flush
>
<div role="tablist" aria-label="Permissions" className="flex gap-1 border-b border-border px-4 pt-2">
{TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`permissions-tab-${t.id}`}
aria-selected={tab === t.id}
aria-controls={`permissions-tabpanel-${t.id}`}
tabIndex={tab === t.id ? 0 : -1}
onClick={() => setTab(t.id)}
onKeyDown={(e) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const i = TABS.findIndex((x) => x.id === tab);
const next = e.key === "ArrowRight" ? (i + 1) % TABS.length : (i - 1 + TABS.length) % TABS.length;
setTab(TABS[next].id);
}}
className={cn(
"min-h-[32px] rounded-t-md border-b-2 px-3 py-1.5 text-sm font-medium transition-colors",
tab === t.id
? "border-primary text-content"
: "border-transparent text-muted hover:text-content",
)}
>
{t.label}
</button>
))}
</div>
{vm.error && (
<p
role="alert"
@ -183,10 +144,6 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
)}
<div
role="tabpanel"
id="permissions-tabpanel-system"
aria-labelledby="permissions-tab-system"
hidden={tab !== "system"}
className="flex flex-col gap-4 p-4"
>
<PolicyCard
@ -278,17 +235,6 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
/>
</div>
<div
role="tabpanel"
id="permissions-tabpanel-mcpTools"
aria-labelledby="permissions-tab-mcpTools"
hidden={tab !== "mcpTools"}
className="flex min-h-0 flex-1 flex-col"
>
{tab === "mcpTools" && (
<McpToolPermissionsPanel projectId={projectId} agents={vm.agents} />
)}
</div>
</Panel>
);
}

View File

@ -1 +1,2 @@
export * from "./PermissionsPanel";
export * from "./McpPanel";

View File

@ -1,411 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useGateways } from "@/app/di";
import type {
Agent,
McpToolCatalogueEntry,
PluginSkillRef,
PluginToolRef,
ProjectMcpToolPermissions,
Skill,
SkillRef,
} from "@/domain";
import { Button, Input, SmallDropdown, Spinner } from "@/shared";
import { useProjectPlugins } from "./useProjectPlugins";
interface AgentPluginCapabilitiesProps {
projectId: string;
agent: Agent;
active: boolean;
onAgentChanged?: () => Promise<void> | void;
}
const pluginSkillKey = (ref: PluginSkillRef) => `${ref.pluginId}:${ref.skillId}`;
const pluginToolKey = (ref: PluginToolRef) =>
`${ref.pluginId}:${ref.serverId}:${ref.toolName}`;
const nativeSkillKey = (ref: SkillRef) => `${ref.scope}:${ref.skillId}`;
function nativeCatalogue(doc: ProjectMcpToolPermissions | null): McpToolCatalogueEntry[] {
if (!doc) return [];
if (doc.catalogue.tools?.length) return doc.catalogue.tools;
return [
...doc.catalogue.readOnlyTools.map((name) => ({
name,
description: "",
access: "read" as const,
})),
...doc.catalogue.writeActionTools.map((name) => ({
name,
description: "",
access: "writeAction" as const,
})),
];
}
function Origin({ children }: { children: ReactNode }) {
return <span className="shrink-0 text-[0.65rem] text-faint">{children}</span>;
}
function Tombstone({
label,
origin,
onRemove,
disabled,
}: {
label: string;
origin: string;
onRemove: () => void;
disabled: boolean;
}) {
return (
<div className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
<span className="min-w-0">
<span className="font-medium">{label}</span>{" "}
<span className="text-warning">— Indisponible</span>{" "}
<Origin>{origin}</Origin>
</span>
<Button size="sm" variant="ghost" disabled={disabled} onClick={onRemove}>
Retirer
</Button>
</div>
);
}
export function AgentPluginCapabilities({
projectId,
agent,
active,
onAgentChanged,
}: AgentPluginCapabilitiesProps) {
const { skill: skillGateway, permission: permissionGateway } = useGateways();
const plugins = useProjectPlugins(projectId);
const [nativeSkills, setNativeSkills] = useState<Skill[]>([]);
const [nativeAssignments, setNativeAssignments] = useState<SkillRef[]>(agent.skills);
const [permissions, setPermissions] = useState<ProjectMcpToolPermissions | null>(null);
const [loadingNative, setLoadingNative] = useState(true);
const [nativeBusy, setNativeBusy] = useState(false);
const [nativeError, setNativeError] = useState<string | null>(null);
const [serverChoice, setServerChoice] = useState("");
const [toolName, setToolName] = useState("");
const [restartRequired, setRestartRequired] = useState(false);
useEffect(() => setNativeAssignments(agent.skills), [agent.skills]);
const loadNative = useCallback(async () => {
setLoadingNative(true);
setNativeError(null);
try {
const [globals, projects, permissionDoc] = await Promise.all([
skillGateway?.listSkills(projectId, "global") ?? Promise.resolve([]),
skillGateway?.listSkills(projectId, "project") ?? Promise.resolve([]),
permissionGateway?.getMcpToolPermissions(projectId) ?? Promise.resolve(null),
]);
setNativeSkills([...projects, ...globals]);
setPermissions(permissionDoc);
} catch (cause) {
setNativeError(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoadingNative(false);
}
}, [permissionGateway, projectId, skillGateway]);
useEffect(() => {
void loadNative();
}, [loadNative]);
function markRestartRequired() {
if (active) setRestartRequired(true);
}
async function setNativeSkill(skill: Skill, assigned: boolean) {
if (!skillGateway) return;
setNativeBusy(true);
setNativeError(null);
try {
if (assigned) {
await skillGateway.assignSkill(projectId, agent.id, skill.id, skill.scope);
setNativeAssignments((current) => [
...current.filter((ref) => ref.skillId !== skill.id),
{ skillId: skill.id, scope: skill.scope },
]);
} else {
await skillGateway.unassignSkill(projectId, agent.id, skill.id);
setNativeAssignments((current) =>
current.filter((ref) => ref.skillId !== skill.id),
);
}
await onAgentChanged?.();
markRestartRequired();
} catch (cause) {
setNativeError(cause instanceof Error ? cause.message : String(cause));
} finally {
setNativeBusy(false);
}
}
async function removeMissingNativeSkill(ref: SkillRef) {
if (!skillGateway) return;
setNativeBusy(true);
try {
await skillGateway.unassignSkill(projectId, agent.id, ref.skillId);
setNativeAssignments((current) =>
current.filter((candidate) => nativeSkillKey(candidate) !== nativeSkillKey(ref)),
);
await onAgentChanged?.();
markRestartRequired();
} finally {
setNativeBusy(false);
}
}
async function setNativeTool(name: string, allowed: boolean) {
if (!permissionGateway) return;
setNativeBusy(true);
setNativeError(null);
try {
setPermissions(
await permissionGateway.setAgentMcpToolAllowed(
projectId,
agent.id,
name,
allowed,
),
);
markRestartRequired();
} catch (cause) {
setNativeError(cause instanceof Error ? cause.message : String(cause));
} finally {
setNativeBusy(false);
}
}
const assignments = plugins.settings?.agents.find((entry) => entry.agentId === agent.id);
const assignedPluginSkills = new Set((assignments?.skills ?? []).map(pluginSkillKey));
const assignedPluginTools = assignments?.tools ?? [];
const enabledPluginIds = new Set(
(plugins.settings?.plugins ?? [])
.filter((entry) => entry.enabled)
.map((entry) => entry.pluginId),
);
const knownPluginSkillKeys = new Set(
plugins.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`),
),
);
const knownServerKeys = new Set(
plugins.installed.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? []).map((server) => `${plugin.id}:${server.id}`),
),
);
const staticPluginToolKeys = new Set(
plugins.installed.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? []).flatMap((server) =>
(server.tools ?? []).map((tool) => `${plugin.id}:${server.id}:${tool.name}`),
),
),
);
const noCatalogueServerKeys = new Set(
plugins.installed.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? [])
.filter((server) => (server.tools ?? []).length === 0)
.map((server) => `${plugin.id}:${server.id}`),
),
);
const stalePluginSkills = (assignments?.skills ?? []).filter(
(ref) => !knownPluginSkillKeys.has(pluginSkillKey(ref)),
);
const stalePluginTools = assignedPluginTools.filter(
(ref) => {
const serverKey = `${ref.pluginId}:${ref.serverId}`;
return (
!knownServerKeys.has(serverKey) ||
(!noCatalogueServerKeys.has(serverKey) &&
!staticPluginToolKeys.has(pluginToolKey(ref)))
);
},
);
const advancedAssignedTools = assignedPluginTools.filter(
(ref) =>
noCatalogueServerKeys.has(`${ref.pluginId}:${ref.serverId}`),
);
const advancedServers = useMemo(
() =>
plugins.installed.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? [])
.filter((server) => (server.tools ?? []).length === 0)
.map((server) => ({
value: `${plugin.id}\u0000${server.id}`,
label: `${plugin.displayName} — ${server.displayName}`,
enabled: enabledPluginIds.has(plugin.id),
})),
),
[plugins.installed, plugins.settings],
);
async function setPluginSkill(pluginId: string, skillId: string, assigned: boolean) {
await plugins.setSkillAssigned(agent.id, pluginId, skillId, assigned);
markRestartRequired();
}
async function setPluginTool(ref: PluginToolRef, assigned: boolean) {
await plugins.setToolAssigned({ agentId: agent.id, ...ref }, assigned);
markRestartRequired();
}
async function assignAdvancedTool() {
const [pluginId, serverId] = serverChoice.split("\u0000");
const name = toolName.trim();
if (!pluginId || !serverId || !name) return;
await setPluginTool({ pluginId, serverId, toolName: name }, true);
setToolName("");
}
const nativeAssignedKeys = new Set(nativeAssignments.map(nativeSkillKey));
const knownNativeSkillKeys = new Set(
nativeSkills.map((skill) => `${skill.scope}:${skill.id}`),
);
const staleNativeSkills = nativeAssignments.filter(
(ref) => !knownNativeSkillKeys.has(nativeSkillKey(ref)),
);
const nativeTools = nativeCatalogue(permissions);
const explicitNativeTools = new Set(
permissions?.agents.find((entry) => entry.agentId === agent.id)?.policy.allowedTools ?? [],
);
const knownNativeTools = new Set(nativeTools.map((tool) => tool.name));
const staleNativeTools = [...explicitNativeTools].filter(
(name) => !knownNativeTools.has(name),
);
const busy = nativeBusy || plugins.busy;
if ((loadingNative || plugins.loading) && !permissions && !plugins.settings) {
return (
<div className="flex items-center gap-2 text-xs text-muted">
<Spinner size={12} /> Chargement des capacités de l’agent…
</div>
);
}
return (
<section aria-label={`Capacités de l'agent — ${agent.name}`} className="flex flex-col gap-4">
<div>
<h4 className="text-sm font-semibold text-content">
Capacités de l&apos;agent — {agent.name}
</h4>
<p className="mt-1 text-xs text-muted">
Attribuez les skills et tools MCP disponibles pour cet agent.
</p>
</div>
{restartRequired && (
<p role="status" className="rounded border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
Relance requise pour appliquer ces changements à l’agent actif.
</p>
)}
{(nativeError || plugins.error) && (
<p role="alert" className="text-xs text-danger">{nativeError ?? plugins.error}</p>
)}
<section aria-label={`Skills — ${agent.name}`} className="flex flex-col gap-2">
<h5 className="text-sm font-semibold text-content">Skills</h5>
{nativeSkills.length === 0 &&
plugins.installed.every(
(plugin) => (plugin.contributes.skills ?? []).length === 0,
) &&
staleNativeSkills.length === 0 &&
stalePluginSkills.length === 0 && (
<p className="text-xs text-muted">Aucun skill disponible.</p>
)}
{nativeSkills.map((skill) => {
const assigned = nativeAssignedKeys.has(`${skill.scope}:${skill.id}`);
return (
<label key={`native:${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={busy || !skillGateway} 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>
<Origin>IdeA</Origin>
</label>
);
})}
{staleNativeSkills.map((ref) => (
<Tombstone key={`stale-native:${nativeSkillKey(ref)}`} label={ref.skillId} origin="IdeA" disabled={busy} onRemove={() => void removeMissingNativeSkill(ref)} />
))}
{plugins.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? []).map((skill) => {
const assigned = assignedPluginSkills.has(`${plugin.id}:${skill.id}`);
const enabled = enabledPluginIds.has(plugin.id);
return (
<label key={`plugin:${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={busy || (!enabled && !assigned)} onChange={(event) => void setPluginSkill(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>
<Origin>Plugin: {plugin.displayName}</Origin>
</label>
);
}),
)}
{stalePluginSkills.map((ref) => (
<Tombstone key={`stale-plugin:${pluginSkillKey(ref)}`} label={ref.skillId} origin={`Plugin: ${ref.pluginId}`} disabled={busy} onRemove={() => void setPluginSkill(ref.pluginId, ref.skillId, false)} />
))}
</section>
<section aria-label={`Tools MCP — ${agent.name}`} className="flex flex-col gap-2">
<h5 className="text-sm font-semibold text-content">Tools MCP</h5>
{nativeTools.length === 0 &&
staticPluginToolKeys.size === 0 &&
advancedAssignedTools.length === 0 &&
stalePluginTools.length === 0 &&
advancedServers.length === 0 && (
<p className="text-xs text-muted">Aucun tool MCP disponible.</p>
)}
{nativeTools.map((tool) => (
<label key={`native-tool:${tool.name}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={explicitNativeTools.has(tool.name)} disabled={busy || !permissionGateway} onChange={(event) => void setNativeTool(tool.name, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{tool.name}</span>{tool.description ? ` — ${tool.description}` : ""}<span className="ml-1 text-faint">({tool.access === "read" ? "lecture" : "action"})</span></span>
<Origin>IdeA</Origin>
</label>
))}
{staleNativeTools.map((name) => (
<Tombstone key={`stale-native-tool:${name}`} label={name} origin="IdeA" disabled={busy} onRemove={() => void setNativeTool(name, false)} />
))}
{plugins.installed.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? []).flatMap((server) =>
(server.tools ?? []).map((tool) => {
const ref = { pluginId: plugin.id, serverId: server.id, toolName: tool.name };
const assigned = assignedPluginTools.some((candidate) => pluginToolKey(candidate) === pluginToolKey(ref));
const enabled = enabledPluginIds.has(plugin.id);
return (
<label key={`plugin-tool:${pluginToolKey(ref)}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={assigned} disabled={busy || (!enabled && !assigned)} onChange={(event) => void setPluginTool(ref, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{tool.name}</span>{tool.description ? ` — ${tool.description}` : ""}<span className="text-faint"> · {server.displayName}</span>{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
<Origin>Plugin: {plugin.displayName}</Origin>
</label>
);
}),
),
)}
{advancedAssignedTools.map((ref) => {
const plugin = plugins.installed.find((candidate) => candidate.id === ref.pluginId);
const enabled = enabledPluginIds.has(ref.pluginId);
return (
<div key={`advanced:${pluginToolKey(ref)}`} className="flex items-center justify-between gap-2 rounded bg-raised px-3 py-2 text-xs">
<span><span className="font-medium">{ref.toolName}</span> · {ref.serverId}{!enabled && <span className="text-warning"> — Plugin désactivé</span>} <Origin>Plugin: {plugin?.displayName ?? ref.pluginId}</Origin></span>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void setPluginTool(ref, false)}>Retirer</Button>
</div>
);
})}
{stalePluginTools.map((ref) => (
<Tombstone key={`stale-tool:${pluginToolKey(ref)}`} label={`${ref.serverId} · ${ref.toolName}`} origin={`Plugin: ${ref.pluginId}`} disabled={busy} onRemove={() => void setPluginTool(ref, false)} />
))}
{advancedServers.length > 0 && (
<details>
<summary className="cursor-pointer text-xs font-medium text-primary">Ajout avancé d’un tool MCP</summary>
<div className="mt-2 flex items-end gap-2">
<SmallDropdown aria-label="serveur MCP sans catalogue statique" value={serverChoice} onChange={setServerChoice} disabled={busy} className="min-w-0 flex-1" size="md" options={[{ value: "", label: "— choisir un serveur MCP —" }, ...advancedServers.map((server) => ({ value: server.value, label: `${server.label}${server.enabled ? "" : " — désactivé"}`, disabled: !server.enabled }))]} />
<Input aria-label="nom avancé du tool MCP" value={toolName} onChange={(event) => setToolName(event.target.value)} placeholder="Nom exact du tool" disabled={busy} />
<Button variant="primary" disabled={busy || !serverChoice || !toolName.trim()} onClick={() => void assignAdvancedTool()}>Attribuer</Button>
</div>
</details>
)}
</section>
</section>
);
}

View File

@ -4,11 +4,11 @@ import { describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di";
import {
createMockGateways,
MockPermissionGateway,
MockPluginGateway,
} from "@/adapters/mock";
import type { ProjectPluginManifest } from "@/domain";
import { AgentPluginCapabilities } from "./AgentPluginCapabilities";
import { SkillsPanel } from "@/features/skills";
import { McpPanel } from "@/features/permissions";
import { ProjectPluginsPanel } from "./ProjectPluginsPanel";
function manifest(): ProjectPluginManifest {
@ -87,11 +87,11 @@ describe("project plugin settings #282", () => {
</DIProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "Capacités des agents" }));
fireEvent.click(await screen.findByRole("button", { name: "Gérer les skills" }));
expect(onAssignAgents).toHaveBeenCalledOnce();
});
it("assigns plugin skills and MCP tools to an agent and keeps stale references removable", async () => {
it("assigns plugin skills from the unified Skills panel and keeps tombstones removable", async () => {
const gateways = gatewaysWithPlugin();
const agent = await gateways.agent.createAgent("project-1", {
name: "Dev",
@ -108,91 +108,48 @@ describe("project plugin settings #282", () => {
});
render(
<DIProvider gateways={gateways}>
<AgentPluginCapabilities projectId="project-1" agent={agent} active />
<SkillsPanel projectId="project-1" />
</DIProvider>,
);
expect(await screen.findByText("Capacités de l'agent — Dev")).toBeTruthy();
const skill = await screen.findByRole("checkbox", { name: /unity-debug/ });
fireEvent.click(skill);
await waitFor(() => expect((skill as HTMLInputElement).checked).toBe(true));
expect(screen.getByText(/removed-skill/).closest("div")?.textContent).toContain(
"Indisponible",
);
const staticTool = screen.getByRole("checkbox", { name: /unity_build/ });
fireEvent.click(staticTool);
await waitFor(() => expect((staticTool as HTMLInputElement).checked).toBe(true));
fireEvent.click(screen.getByText("Ajout avancé d’un tool MCP"));
fireEvent.click(screen.getByLabelText("serveur MCP sans catalogue statique"));
fireEvent.click(screen.getByRole("option", { name: "Unity Plugin — Legacy Tools" }));
fireEvent.change(screen.getByLabelText("nom avancé du tool MCP"), {
target: { value: "legacy_ping" },
});
fireEvent.click(screen.getByRole("button", { name: "Attribuer" }));
expect(await screen.findByText(/legacy_ping/)).toBeTruthy();
expect(screen.getByRole("status").textContent).toContain("Relance requise");
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
const assigned = settings.agents.find((entry) => entry.agentId === agent.id);
expect(assigned?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" });
expect(assigned?.tools).toContainEqual({
pluginId: "dev.acme.unity",
serverId: "unity-tools",
toolName: "unity_build",
});
expect(assigned?.tools).toContainEqual({
pluginId: "dev.acme.unity",
serverId: "legacy-tools",
toolName: "legacy_ping",
});
});
it("aggregates native capabilities and uses the granular MCP tool toggle", async () => {
it("lists catalogued plugin tools in MCP and removes free-form assignment", async () => {
const gateways = gatewaysWithPlugin();
const agent = await gateways.agent.createAgent("project-1", {
name: "Main",
profileId: "profile-1",
});
await gateways.skill.createSkill({
projectId: "project-1",
scope: "project",
name: "release-check",
description: "Vérifier une release.",
content: "# Release",
gateways.plugin._seedProjectPluginSettings("project-1", {
version: 1,
plugins: [{ pluginId: "dev.acme.unity", enabled: true }],
agents: [{ agentId: agent.id, skills: [], tools: [] }],
});
(gateways.permission as MockPermissionGateway).setMcpToolCatalogue({
readOnlyTools: ["idea_ticket_read"],
writeActionTools: ["idea_ticket_update"],
tools: [
{ name: "idea_ticket_read", description: "Lire un ticket.", access: "read" },
{ name: "idea_ticket_update", description: "Modifier un ticket.", access: "writeAction" },
],
});
const toggleSpy = vi.spyOn(gateways.permission, "setAgentMcpToolAllowed");
render(
<DIProvider gateways={gateways}>
<AgentPluginCapabilities projectId="project-1" agent={agent} active />
<McpPanel projectId="project-1" />
</DIProvider>,
);
const nativeSkill = await screen.findByRole("checkbox", { name: /release-check/ });
const nativeTool = screen.getByRole("checkbox", { name: /idea_ticket_update/ });
expect(screen.getAllByText("IdeA").length).toBeGreaterThanOrEqual(2);
fireEvent.click(nativeSkill);
fireEvent.click(nativeTool);
await waitFor(() =>
expect(toggleSpy).toHaveBeenCalledWith(
"project-1",
agent.id,
"idea_ticket_update",
true,
),
);
expect(screen.getByRole("status").textContent).toContain("Relance requise");
fireEvent.click(await screen.findByRole("button", { name: /Main/ }));
const staticTool = await screen.findByRole("checkbox", { name: /unity_build/ });
fireEvent.click(staticTool);
await waitFor(() => expect((staticTool as HTMLInputElement).checked).toBe(true));
expect(screen.queryByPlaceholderText("Nom exact du tool")).toBeNull();
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
expect(settings.agents.find((entry) => entry.agentId === agent.id)?.tools).toContainEqual({
pluginId: "dev.acme.unity", serverId: "unity-tools", toolName: "unity_build",
});
});
it("keeps disabled and missing plugin assignments removable", async () => {
@ -209,6 +166,7 @@ describe("project plugin settings #282", () => {
skills: [{ pluginId: "missing.plugin", skillId: "gone-skill" }],
tools: [
{ pluginId: "dev.acme.unity", serverId: "unity-tools", toolName: "unity_build" },
{ pluginId: "dev.acme.unity", serverId: "legacy-tools", toolName: "legacy_ping" },
{ pluginId: "missing.plugin", serverId: "gone-server", toolName: "gone_tool" },
],
}],
@ -216,16 +174,28 @@ describe("project plugin settings #282", () => {
render(
<DIProvider gateways={gateways}>
<AgentPluginCapabilities projectId="project-1" agent={agent} active={false} />
<McpPanel projectId="project-1" />
</DIProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: /QA/ }));
const disabledAssigned = await screen.findByRole("checkbox", { name: /unity_build/ });
expect((disabledAssigned as HTMLInputElement).checked).toBe(true);
expect((disabledAssigned as HTMLInputElement).disabled).toBe(false);
fireEvent.click(disabledAssigned);
await waitFor(() => expect((disabledAssigned as HTMLInputElement).checked).toBe(false));
const runtimeOnly = screen.getByText(/legacy_ping/).closest("div")!;
expect(runtimeOnly.textContent).toContain("Indisponible");
expect(screen.queryByRole("checkbox", { name: /legacy_ping/ })).toBeNull();
fireEvent.click(runtimeOnly.querySelector("button")!);
await waitFor(async () => {
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
expect(settings.agents[0]?.tools).not.toContainEqual(
expect.objectContaining({ toolName: "legacy_ping" }),
);
});
const stale = screen.getByText(/gone_tool/).closest("div")!;
fireEvent.click(stale.querySelector("button")!);
await waitFor(async () => {

View File

@ -37,12 +37,12 @@ export function ProjectPluginsPanel({
<h2 className="text-sm font-semibold text-content">Plugins du projet</h2>
<p className="mt-1 text-xs text-muted">
Activez les plugins utilisés par ce projet. Les attributions se gèrent
depuis la fiche de chaque agent.
dans les panneaux Skills et MCP.
</p>
</div>
{onAssignAgents && (
<Button size="sm" variant="ghost" onClick={onAssignAgents}>
Capacités des agents
Gérer les skills
</Button>
)}
</div>

View File

@ -1,7 +1,6 @@
export { PluginRuntimeProvider, usePluginRuntime, type PluginRuntimeContextValue } from "./PluginRuntimeProvider";
export { PluginsPanel } from "./PluginsPanel";
export { ProjectPluginsPanel } from "./ProjectPluginsPanel";
export { AgentPluginCapabilities } from "./AgentPluginCapabilities";
export { usePlugins, type PluginsViewModel } from "./usePlugins";
export { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus";
export { usePluginMenus, type UsePluginMenusResult } from "./usePluginMenus";

View File

@ -96,6 +96,7 @@ const PANEL_SIZE: Record<PanelId, FloatingWindowSize> = {
agents: "lg",
templates: "lg",
skills: "lg",
mcp: "lg",
permissions: "md",
memory: "lg",
git: "lg",
@ -549,6 +550,7 @@ export function ProjectsView() {
"agents",
"templates",
"skills",
"mcp",
"permissions",
"memory",
"git",
@ -914,8 +916,8 @@ export function ProjectsView() {
onSectionChange={setSettingsSection}
onAssignPluginCapabilities={() => {
setSettingsSection(null);
if (placementOf(placements, "agents") === "closed") {
setPlacement("agents", "floating");
if (placementOf(placements, "skills") === "closed") {
setPlacement("skills", "floating");
}
}}
onClose={() => setSettingsSection(null)}

View File

@ -16,7 +16,7 @@ import { TemplatesPanel } from "@/features/templates";
import { SkillsPanel } from "@/features/skills";
import { MemoryPanel } from "@/features/memory";
import { EmbedderSettings } from "@/features/embedder";
import { PermissionsPanel } from "@/features/permissions";
import { McpPanel, PermissionsPanel } from "@/features/permissions";
import { ProjectWorkStatePanel } from "@/features/workstate";
import { TicketsView } from "@/features/tickets";
import { GitPanel } from "@/features/git";
@ -60,6 +60,8 @@ export function ViewPanelBody({
return <TemplatesPanel projectId={projectId} />;
case "skills":
return <SkillsPanel projectId={projectId} />;
case "mcp":
return <McpPanel projectId={projectId} />;
case "permissions":
return <PermissionsPanel projectId={projectId} />;
case "memory":

View File

@ -28,6 +28,7 @@ export type PanelId =
| "agents"
| "templates"
| "skills"
| "mcp"
| "permissions"
| "memory"
| "git";
@ -54,6 +55,7 @@ export const PANEL_TITLE: Record<PanelId, string> = {
agents: "Agents",
templates: "Templates",
skills: "Skills",
mcp: "MCP",
permissions: "Permissions",
memory: "Memory",
git: "Git",

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);

View File

@ -35,7 +35,7 @@ import { isDockedTo, type ViewPlacement } from "@/features/projects/viewPlacemen
import { AgentsPanel } from "@/features/agents";
import { TemplatesPanel } from "@/features/templates";
import { SkillsPanel } from "@/features/skills";
import { PermissionsPanel } from "@/features/permissions";
import { McpPanel, PermissionsPanel } from "@/features/permissions";
import { MemoryPanel } from "@/features/memory";
import { EmbedderSettings } from "@/features/embedder";
import { GitGraphView, GitPanel } from "@/features/git";
@ -209,6 +209,7 @@ const WEB_PANEL_SIZE: Record<WebDesktopPanelId, FloatingWindowSize> = {
agents: "lg",
templates: "lg",
skills: "lg",
mcp: "lg",
permissions: "md",
memory: "lg",
git: "lg",
@ -336,6 +337,7 @@ function WebDesktopWorkspace({
"agents",
"templates",
"skills",
"mcp",
"permissions",
"memory",
"git",
@ -877,6 +879,8 @@ export function WebProjectPanelBody({
return <TemplatesPanel projectId={projectId} />;
case "skills":
return <SkillsPanel projectId={projectId} />;
case "mcp":
return <McpPanel projectId={projectId} />;
case "permissions":
return <PermissionsPanel projectId={projectId} />;
case "memory":

View File

@ -8,6 +8,7 @@ export type WebProjectPanelId =
| "agents"
| "templates"
| "skills"
| "mcp"
| "permissions"
| "memory"
| "git"
@ -35,6 +36,7 @@ export const WEB_PANEL_ORDER: WebProjectPanelId[] = [
"agents",
"templates",
"skills",
"mcp",
"permissions",
"memory",
"git",
@ -92,11 +94,18 @@ export const WEB_PANEL_META: Record<WebProjectPanelId, WebPanelMeta> = {
description: "Skills assignés et éditables",
placement: "secondary",
},
mcp: {
id: "mcp",
label: "MCP",
shortLabel: "MCP",
description: "Tools MCP IdeA et plugins",
placement: "secondary",
},
permissions: {
id: "permissions",
label: "Permissions",
shortLabel: "Perms",
description: "Permissions MCP et sandbox",
description: "Permissions système et sandbox",
placement: "secondary",
},
memory: {