From e6ce7d6e7433833ae8c324a2c6346aa3c07dea20 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 9 Sep 2026 20:38:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(skills,mcp):=20menus=20Skills=20et=20MCP?= =?UTF-8?q?=20unifies=20avec=20selection=20cochable=20des=20tools=20#297?= =?UTF-8?q?=20(SkillsPanel=20devient=20la=20surface=20unique=20d'assignati?= =?UTF-8?q?on=20cochable=20des=20skills=20IdeA=20+=20plugins=20=E2=80=94?= =?UTF-8?q?=20cible=20agent,=20recherche,=20groupes=20IdeA/Plugins,=20tomb?= =?UTF-8?q?stones=20retirables,=20CRUD=20catalogue=20natif=20conserve,=20s?= =?UTF-8?q?tores=20separes=20SkillGateway/PluginGateway;=20nouveau=20panne?= =?UTF-8?q?au=20MCP=20dedie=20McpPanel=20desktop+web=20=E2=80=94=20tools?= =?UTF-8?q?=20MCP=20IdeA=20via=20PermissionGateway=20et=20tools=20MCP=20pl?= =?UTF-8?q?ugins=20catalogues=20via=20PluginGateway=20groupes=20par=20plug?= =?UTF-8?q?in/serveur,=20selection=20cochable,=20fin=20de=20la=20saisie=20?= =?UTF-8?q?manuelle=20du=20nom=20exact,=20serveurs=20sans=20catalogue=20en?= =?UTF-8?q?=20tombstones=20retirables=20uniquement;=20retrait=20complet=20?= =?UTF-8?q?de=20la=20surface=20Capacites=20de=20la=20fiche=20agent=20et=20?= =?UTF-8?q?suppression=20d'AgentPluginCapabilities.tsx;=20PermissionsPanel?= =?UTF-8?q?=20reserve=20aux=20permissions=20systeme/sandbox,=20onglet=20MC?= =?UTF-8?q?P=20retire;=20raccourci=20Parametres=20>=20Plugins=20ouvre=20Sk?= =?UTF-8?q?ills=20avec=20renvois=20vers=20Skills/MCP;=20integration=20Proj?= =?UTF-8?q?ectsView/ViewPanelBody/viewPlacement/WebWorkspace/webSurfaceCat?= =?UTF-8?q?alog;=20QA=20verte:=20tsc+vite=20build=20OK,=2028+24=20tests=20?= =?UTF-8?q?cibles=20OK,=20suite=20complete=201223=20OK=20/=2032=20echecs?= =?UTF-8?q?=20sur=206=20fichiers=20web=20hors=20perimetre=20classes=20non?= =?UTF-8?q?=20bloquants;=20scripts/=20et=20sync=20tickets=20#296=20hors=20?= =?UTF-8?q?perimetre;=20aucun=20push)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/agents/AgentsPanel.tsx | 15 - .../src/features/permissions/McpPanel.tsx | 51 +++ .../McpToolPermissionsPanel.test.tsx | 31 +- .../permissions/McpToolPermissionsPanel.tsx | 156 ++++++- .../features/permissions/PermissionsPanel.tsx | 54 --- frontend/src/features/permissions/index.ts | 1 + .../plugins/AgentPluginCapabilities.tsx | 411 ------------------ .../plugins/ProjectPluginSettings.test.tsx | 98 ++--- .../features/plugins/ProjectPluginsPanel.tsx | 4 +- frontend/src/features/plugins/index.ts | 1 - .../src/features/projects/ProjectsView.tsx | 6 +- .../src/features/projects/ViewPanelBody.tsx | 4 +- .../src/features/projects/viewPlacement.ts | 2 + frontend/src/features/skills/SkillsPanel.tsx | 197 ++++++++- frontend/src/features/skills/skills.test.tsx | 35 +- frontend/src/features/web/WebWorkspace.tsx | 6 +- .../src/features/web/webSurfaceCatalog.ts | 11 +- 17 files changed, 452 insertions(+), 631 deletions(-) create mode 100644 frontend/src/features/permissions/McpPanel.tsx delete mode 100644 frontend/src/features/plugins/AgentPluginCapabilities.tsx diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index dcee13ed..eec05702 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -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) { )} - {selectedAgent && gateways.plugin && ( -
- live.agentId === selectedAgent.id) - } - onAgentChanged={vm.refresh} - /> -
- )} - {/* ── Context editor ── */} {selectedAgent && (
diff --git a/frontend/src/features/permissions/McpPanel.tsx b/frontend/src/features/permissions/McpPanel.tsx new file mode 100644 index 00000000..fdf7cf68 --- /dev/null +++ b/frontend/src/features/permissions/McpPanel.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + : } + className="flex min-h-0 flex-1 flex-col" + flush + > + {error ? ( +
+

{error}

+ +
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx b/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx index 00d96653..f6e76397 100644 --- a/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx +++ b/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx @@ -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( - + , ); @@ -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( - + , ); @@ -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) diff --git a/frontend/src/features/permissions/McpToolPermissionsPanel.tsx b/frontend/src/features/permissions/McpToolPermissionsPanel.tsx index 511700be..e895e881 100644 --- a/frontend/src/features/permissions/McpToolPermissionsPanel.tsx +++ b/frontend/src/features/permissions/McpToolPermissionsPanel.tsx @@ -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({ 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({ }} /> ) : ( - { - dirtyRef.current = dirty; - }} - onSaveAgentOverride={async (allowed) => { - await vm.saveAgentOverride(target.agentId, { allowedTools: allowed }); - }} - onResetOverride={async () => { - await vm.resetAgentOverride(target.agentId); - }} - /> +
+ { + dirtyRef.current = dirty; + }} + onSaveAgentOverride={async (allowed) => { + await vm.saveAgentOverride(target.agentId, { allowedTools: allowed }); + }} + onResetOverride={async () => { + await vm.resetAgentOverride(target.agentId); + }} + /> + +
)}
); } +const pluginToolKey = (ref: PluginToolRef) => + `${ref.pluginId}:${ref.serverId}:${ref.toolName}`; + +function PluginMcpToolsEditor({ + agentId, + agentName, + plugins, +}: { + agentId: string; + agentName: string; + plugins: ReturnType; +}) { + 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 ( + +
+ setQuery(event.target.value)} + placeholder="Rechercher un tool ou un plugin" + /> + {plugins.error &&

{plugins.error}

} + {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 ( +
+

{plugin.displayName}

+ {rows.map(({ server, tool }) => { + const ref = { pluginId: plugin.id, serverId: server.id, toolName: tool.name }; + const assigned = assignedKeys.has(pluginToolKey(ref)); + return ( + + ); + })} +
+ ); + })} + {tombstones.filter((ref) => matches(`${ref.pluginId} ${ref.serverId} ${ref.toolName}`)).map((ref) => ( +
+ {ref.toolName} · {ref.serverId} — Indisponible Plugin: {ref.pluginId} + +
+ ))} + {!plugins.loading && cataloguedCount === 0 && tombstones.length === 0 && ( +

Aucun tool MCP plugin catalogué.

+ )} +
+
+ ); +} + interface McpToolEditorProps { targetKind: "project" | "agent"; title: string; diff --git a/frontend/src/features/permissions/PermissionsPanel.tsx b/frontend/src/features/permissions/PermissionsPanel.tsx index eaf98e52..ddee46fa 100644 --- a/frontend/src/features/permissions/PermissionsPanel.tsx +++ b/frontend/src/features/permissions/PermissionsPanel.tsx @@ -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({ type: "project" }); - const [tab, setTab] = useState("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 > -
- {TABS.map((t) => ( - - ))} -
- {vm.error && ( ); } diff --git a/frontend/src/features/permissions/index.ts b/frontend/src/features/permissions/index.ts index 22c6cc52..4b513ae5 100644 --- a/frontend/src/features/permissions/index.ts +++ b/frontend/src/features/permissions/index.ts @@ -1 +1,2 @@ export * from "./PermissionsPanel"; +export * from "./McpPanel"; diff --git a/frontend/src/features/plugins/AgentPluginCapabilities.tsx b/frontend/src/features/plugins/AgentPluginCapabilities.tsx deleted file mode 100644 index b3a5305f..00000000 --- a/frontend/src/features/plugins/AgentPluginCapabilities.tsx +++ /dev/null @@ -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; -} - -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 {children}; -} - -function Tombstone({ - label, - origin, - onRemove, - disabled, -}: { - label: string; - origin: string; - onRemove: () => void; - disabled: boolean; -}) { - return ( -
- - {label}{" "} - — Indisponible{" "} - {origin} - - -
- ); -} - -export function AgentPluginCapabilities({ - projectId, - agent, - active, - onAgentChanged, -}: AgentPluginCapabilitiesProps) { - const { skill: skillGateway, permission: permissionGateway } = useGateways(); - const plugins = useProjectPlugins(projectId); - const [nativeSkills, setNativeSkills] = useState([]); - const [nativeAssignments, setNativeAssignments] = useState(agent.skills); - const [permissions, setPermissions] = useState(null); - const [loadingNative, setLoadingNative] = useState(true); - const [nativeBusy, setNativeBusy] = useState(false); - const [nativeError, setNativeError] = useState(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 ( -
- Chargement des capacités de l’agent… -
- ); - } - - return ( -
-
-

- Capacités de l'agent — {agent.name} -

-

- Attribuez les skills et tools MCP disponibles pour cet agent. -

-
- - {restartRequired && ( -

- Relance requise pour appliquer ces changements à l’agent actif. -

- )} - {(nativeError || plugins.error) && ( -

{nativeError ?? plugins.error}

- )} - -
-
Skills
- {nativeSkills.length === 0 && - plugins.installed.every( - (plugin) => (plugin.contributes.skills ?? []).length === 0, - ) && - staleNativeSkills.length === 0 && - stalePluginSkills.length === 0 && ( -

Aucun skill disponible.

- )} - {nativeSkills.map((skill) => { - const assigned = nativeAssignedKeys.has(`${skill.scope}:${skill.id}`); - return ( - - ); - })} - {staleNativeSkills.map((ref) => ( - 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 ( - - ); - }), - )} - {stalePluginSkills.map((ref) => ( - void setPluginSkill(ref.pluginId, ref.skillId, false)} /> - ))} -
- -
-
Tools MCP
- {nativeTools.length === 0 && - staticPluginToolKeys.size === 0 && - advancedAssignedTools.length === 0 && - stalePluginTools.length === 0 && - advancedServers.length === 0 && ( -

Aucun tool MCP disponible.

- )} - {nativeTools.map((tool) => ( - - ))} - {staleNativeTools.map((name) => ( - 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 ( - - ); - }), - ), - )} - {advancedAssignedTools.map((ref) => { - const plugin = plugins.installed.find((candidate) => candidate.id === ref.pluginId); - const enabled = enabledPluginIds.has(ref.pluginId); - return ( -
- {ref.toolName} · {ref.serverId}{!enabled && — Plugin désactivé} Plugin: {plugin?.displayName ?? ref.pluginId} - -
- ); - })} - {stalePluginTools.map((ref) => ( - void setPluginTool(ref, false)} /> - ))} - {advancedServers.length > 0 && ( -
- Ajout avancé d’un tool MCP -
- ({ value: server.value, label: `${server.label}${server.enabled ? "" : " — désactivé"}`, disabled: !server.enabled }))]} /> - setToolName(event.target.value)} placeholder="Nom exact du tool" disabled={busy} /> - -
-
- )} -
-
- ); -} diff --git a/frontend/src/features/plugins/ProjectPluginSettings.test.tsx b/frontend/src/features/plugins/ProjectPluginSettings.test.tsx index 25816950..e8042819 100644 --- a/frontend/src/features/plugins/ProjectPluginSettings.test.tsx +++ b/frontend/src/features/plugins/ProjectPluginSettings.test.tsx @@ -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", () => { , ); - 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( - + , ); - 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( - + , ); - 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( - + , ); + 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 () => { diff --git a/frontend/src/features/plugins/ProjectPluginsPanel.tsx b/frontend/src/features/plugins/ProjectPluginsPanel.tsx index 38c13864..5c439447 100644 --- a/frontend/src/features/plugins/ProjectPluginsPanel.tsx +++ b/frontend/src/features/plugins/ProjectPluginsPanel.tsx @@ -37,12 +37,12 @@ export function ProjectPluginsPanel({

Plugins du projet

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.

{onAssignAgents && ( )} diff --git a/frontend/src/features/plugins/index.ts b/frontend/src/features/plugins/index.ts index 9761c798..4e659ac8 100644 --- a/frontend/src/features/plugins/index.ts +++ b/frontend/src/features/plugins/index.ts @@ -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"; diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index a43c4476..2a86b7b2 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -96,6 +96,7 @@ const PANEL_SIZE: Record = { 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)} diff --git a/frontend/src/features/projects/ViewPanelBody.tsx b/frontend/src/features/projects/ViewPanelBody.tsx index bcfead12..036b70e7 100644 --- a/frontend/src/features/projects/ViewPanelBody.tsx +++ b/frontend/src/features/projects/ViewPanelBody.tsx @@ -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 ; case "skills": return ; + case "mcp": + return ; case "permissions": return ; case "memory": diff --git a/frontend/src/features/projects/viewPlacement.ts b/frontend/src/features/projects/viewPlacement.ts index e469ce2e..5ba4a492 100644 --- a/frontend/src/features/projects/viewPlacement.ts +++ b/frontend/src/features/projects/viewPlacement.ts @@ -28,6 +28,7 @@ export type PanelId = | "agents" | "templates" | "skills" + | "mcp" | "permissions" | "memory" | "git"; @@ -54,6 +55,7 @@ export const PANEL_TITLE: Record = { agents: "Agents", templates: "Templates", skills: "Skills", + mcp: "MCP", permissions: "Permissions", memory: "Memory", git: "Git", diff --git a/frontend/src/features/skills/SkillsPanel.tsx b/frontend/src/features/skills/SkillsPanel.tsx index 21d62fdc..93c36c42 100644 --- a/frontend/src/features/skills/SkillsPanel.tsx +++ b/frontend/src/features/skills/SkillsPanel.tsx @@ -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(null); const [editorBusy, setEditorBusy] = useState(false); + const [agents, setAgents] = useState([]); + const [selectedAgentId, setSelectedAgentId] = useState(null); + const [assignmentBusy, setAssignmentBusy] = useState(false); + const [assignmentError, setAssignmentError] = useState(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) { )} - {vm.error && ( + {(vm.error || plugins.error || assignmentError) && (

- {vm.error} + {vm.error ?? plugins.error ?? assignmentError}

)} +
+ + +
+
+
+

+ {selectedAgent ? `Skills — ${selectedAgent.name}` : "Skills"} +

+

Skills IdeA et plugins disponibles.

+
+ {(assignmentBusy || plugins.busy || plugins.loading) && } +
+ setQuery(event.target.value)} + placeholder="Rechercher un skill" + disabled={!selectedAgent} + /> + {selectedAgent && ( +
+
IdeA
+ {nativeSkills.filter((skill) => matches(`${skill.name} ${skill.description ?? ""}`)).map((skill) => { + const assigned = assignedNativeKeys.has(`${skill.scope}:${skill.id}`); + return ( + + ); + })} + {staleNativeSkills.filter((ref) => matches(ref.skillId)).map((ref) => ( +
+ {ref.skillId} — Indisponible IdeA + +
+ ))} + +
Plugins
+ {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 ( + + ); + }), + )} + {stalePluginSkills.filter((ref) => matches(`${ref.pluginId} ${ref.skillId}`)).map((ref) => ( +
+ {ref.skillId} — Indisponible Plugin: {ref.pluginId} + +
+ ))} + {nativeSkills.length === 0 && plugins.installed.every((plugin) => (plugin.contributes.skills ?? []).length === 0) && staleNativeSkills.length === 0 && stalePluginSkills.length === 0 && ( +

Aucun skill disponible.

+ )} +
+ )} +
+
+ {/* ── Create button ── */}

diff --git a/frontend/src/features/skills/skills.test.tsx b/frontend/src/features/skills/skills.test.tsx index 5dae0072..69d4070f 100644 --- a/frontend/src/features/skills/skills.test.tsx +++ b/frontend/src/features/skills/skills.test.tsx @@ -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( - - - , - ); -} - 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); diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index 581416d7..d2adbefb 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -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 = { 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 ; case "skills": return ; + case "mcp": + return ; case "permissions": return ; case "memory": diff --git a/frontend/src/features/web/webSurfaceCatalog.ts b/frontend/src/features/web/webSurfaceCatalog.ts index dabf2ec9..51baa833 100644 --- a/frontend/src/features/web/webSurfaceCatalog.ts +++ b/frontend/src/features/web/webSurfaceCatalog.ts @@ -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 = { 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: {