diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 50aea3b6..a711cae3 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -92,3 +92,4 @@ - [ticket285-sdk-plugin-root-runtime-api](ticket285-sdk-plugin-root-runtime-api.md) — memory note ticket285-sdk-plugin-root-runtime-api - [ticket287-cross-project-rendezvous-live-activity-fix](ticket287-cross-project-rendezvous-live-activity-fix.md) — memory note ticket287-cross-project-rendezvous-live-activity-fix - [ticket288-agent-self-restart-architecture](ticket288-agent-self-restart-architecture.md) — memory note ticket288-agent-self-restart-architecture +- [ticket297-skills-mcp-unified-surfaces](ticket297-skills-mcp-unified-surfaces.md) — memory note ticket297-skills-mcp-unified-surfaces diff --git a/.ideai/memory/ticket297-skills-mcp-unified-surfaces.md b/.ideai/memory/ticket297-skills-mcp-unified-surfaces.md new file mode 100644 index 00000000..b6a98cdf --- /dev/null +++ b/.ideai/memory/ticket297-skills-mcp-unified-surfaces.md @@ -0,0 +1,19 @@ +--- +name: ticket297-skills-mcp-unified-surfaces +description: memory note ticket297-skills-mcp-unified-surfaces +metadata: + type: project +--- +# Ticket #297 - Skills/MCP + +Decision: #297 remplace la surface `Agent > Capacites` par deux surfaces humaines distinctes: `Skills` pour tous les skills assignables, et `MCP` pour tous les tools MCP assignables. Ne pas fusionner les stores metier. + +Persistance a preserver: +- skills IdeA natifs: `Agent.skills` via `SkillGateway`; +- skills plugin: `.ideai/project-plugins.json` / `AgentPluginAssignments.skills`; +- tools MCP IdeA: `.ideai/mcp-tool-permissions.json` via `PermissionGateway`; +- tools MCP plugin: `.ideai/project-plugins.json` / `AgentPluginAssignments.tools`. + +La saisie manuelle d'un nom de tool MCP plugin doit disparaitre. Si le manifeste `mcpServers[].tools` ne suffit pas, ajouter un read-model backend read-only de catalogue plugin MCP, alimente par manifeste et/ou `tools/list` runtime. Les assignations orphelines doivent rester visibles comme tombstones retirables. + +UX: navigation projet avec `Skills`, `MCP`, `Permissions`; `Permissions` reste reserve aux permissions systeme/sandbox. Les listes sont cochables, groupees par origine/plugin, avec recherche, etat vide/chargement/erreur, mutations par ligne, et sans action globale `Tout selectionner` par defaut. \ No newline at end of file diff --git a/.ideai/tickets/297/carnet.md b/.ideai/tickets/297/carnet.md new file mode 100644 index 00000000..0f98ee35 --- /dev/null +++ b/.ideai/tickets/297/carnet.md @@ -0,0 +1,145 @@ +--- +issueRef: "#297" +version: 6 +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedAt: 1788979147782 +--- +# Ticket #297 - Unifier Skills et MCP tools + +## Demande +- Supprimer la saisie manuelle du nom exact pour les tools MCP de plugins. +- Fournir une liste cochable des tools MCP de plugins, comme pour les skills. +- Regrouper tous les skills dans un menu `Skills` unique. +- Creer un menu `MCP` dedie regroupant tools MCP IdeA et plugins. +- Retirer skills plugin et tools MCP du menu agent. + +## Cadrage UX +- Navigation projet: `Skills`, `MCP`, `Permissions`. +- `Permissions` reste reserve aux permissions systeme/sandbox. +- `Skills`: liste cochable unique, groupee par origine/projet/global/plugin. +- `MCP`: liste cochable unique, sections `IdeA` et `Plugins`, groupes par plugin/serveur si utile. +- Les items orphelins/indisponibles restent visibles et retirables. + +## Cadrage architecture +- Ne pas fusionner les stores persistants. +- Skills IdeA: `Agent.skills` via `SkillGateway`. +- Skills plugin/tools plugin: `.ideai/project-plugins.json` via `PluginGateway`. +- Tools MCP IdeA: `.ideai/mcp-tool-permissions.json` via `PermissionGateway`. +- UI: ne plus appeler l'assignation plugin tool avec un nom non catalogue. + +## Etat +- Ticket cree et passe `inProgress` par Main. +- Cadrage UX et Architect obtenu. +- Memoire durable: `ticket297-skills-mcp-unified-surfaces`. + +## Implementation DevFrontend +- Retire la surface de capacites de la fiche agent et supprime `AgentPluginCapabilities.tsx`, y compris l'ajout libre d'un nom exact de tool MCP. +- Etend le panneau `Skills` existant avec une cible agent, une recherche et une liste cochable unique groupee `IdeA` / `Plugins`; conserve l'edition CRUD du catalogue natif en dessous. +- Ajoute un panneau projet `MCP`, disponible dans les menus desktop et web, avec le defaut projet et les agents comme cibles. +- Conserve l'editeur des tools MCP IdeA via `PermissionGateway` et ajoute les tools MCP plugins catalogues via `PluginGateway`, groupes par plugin/serveur. +- Les references natives/plugin absentes du catalogue restent affichees comme `Indisponible` et sont retirables. Un serveur plugin sans `mcpServers[].tools` ne propose aucune saisie libre; ses anciennes assignations deviennent des tombstones retirables. +- `Permissions` est maintenant reserve aux permissions systeme/sandbox. +- Le raccourci de `Parametres > Plugins` ouvre `Skills` et le texte renvoie vers les panneaux `Skills` et `MCP`. + +## Fichiers frontend modifies +- `frontend/src/features/agents/AgentsPanel.tsx` +- `frontend/src/features/skills/SkillsPanel.tsx` +- `frontend/src/features/skills/skills.test.tsx` +- `frontend/src/features/permissions/McpPanel.tsx` +- `frontend/src/features/permissions/McpToolPermissionsPanel.tsx` +- `frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx` +- `frontend/src/features/permissions/PermissionsPanel.tsx` +- `frontend/src/features/permissions/index.ts` +- `frontend/src/features/plugins/AgentPluginCapabilities.tsx` (supprime) +- `frontend/src/features/plugins/ProjectPluginSettings.test.tsx` +- `frontend/src/features/plugins/ProjectPluginsPanel.tsx` +- `frontend/src/features/plugins/index.ts` +- `frontend/src/features/projects/ProjectsView.tsx` +- `frontend/src/features/projects/ViewPanelBody.tsx` +- `frontend/src/features/projects/viewPlacement.ts` +- `frontend/src/features/web/WebWorkspace.tsx` +- `frontend/src/features/web/webSurfaceCatalog.ts` + +## Verification DevFrontend +- `npm --prefix frontend run build` : OK. +- `npm --prefix frontend test -- --run src/features/skills/skills.test.tsx src/features/plugins/ProjectPluginSettings.test.tsx src/features/permissions/McpToolPermissionsPanel.test.tsx` : OK, 28 tests. +- `npm --prefix frontend test -- --run src/features/permissions/permissions.test.tsx src/features/projects/viewPlacement.test.ts src/features/web/webWorkspaceSession.test.ts src/features/web/webSurfaceNavigation.test.ts` : OK, 24 tests. +- `git diff --check` : OK. +- Suite frontend complete executee : 1223 tests OK, 32 echecs dans 6 fichiers hors perimetre (principalement attentes historiques des menus web et tests live/conversation instables); les suites directement touchees sont vertes isolees. + +## Validation QA - 2026-09-09T18:36:15Z + +Verdict QA: VERT pour le perimetre #297 sur `feature/297-skills-mcp-unified-menus`. + +Audit independant: +- Diff local relu: l'ancienne surface agent `AgentPluginCapabilities` est retiree de `AgentsPanel` et le composant supprime. +- `PermissionsPanel` ne contient plus l'onglet MCP et reste dedie aux permissions systeme/sandbox. +- `SkillsPanel` devient la surface d'assignation unifiee des skills IdeA + plugin, avec checkbox par agent et tombstones retirables, en conservant les stores separes (`SkillGateway` pour IdeA, `PluginGateway` pour plugins). +- `McpPanel` est expose comme panneau dedie desktop/web; il regroupe les tools MCP IdeA via `PermissionGateway` et les tools MCP plugins catalogues via `PluginGateway`. +- La saisie libre d'un nom exact de tool MCP plugin n'est plus presente dans le diff ni dans les tests cibles; les serveurs sans catalogue ne proposent que des tombstones retirables si des assignations anciennes existent. + +Commandes executees et sorties utiles: + +```bash +git diff --check +``` + +Sortie: aucune sortie, code 0. + +```bash +npm --prefix frontend test -- --run src/features/skills/skills.test.tsx src/features/plugins/ProjectPluginSettings.test.tsx src/features/permissions/McpToolPermissionsPanel.test.tsx +``` + +Sortie utile: + +```text +Test Files 3 passed (3) +Tests 28 passed (28) +``` + +```bash +npm --prefix frontend test -- --run src/features/permissions/permissions.test.tsx src/features/projects/viewPlacement.test.ts src/features/web/webWorkspaceSession.test.ts src/features/web/webSurfaceNavigation.test.ts +``` + +Sortie utile: + +```text +Test Files 4 passed (4) +Tests 24 passed (24) +``` + +```bash +npm --prefix frontend run build +``` + +Sortie utile: + +```text +tsc --noEmit && vite build +515 modules transformed. +dist/index.html 1.13 kB +dist/assets/index-BcSNdddW.css 49.69 kB +dist/assets/index-Cj2HhIoI.js 1,265.10 kB +built in 5.18s +``` + +```bash +npm --prefix frontend test -- --run +``` + +Sortie utile: + +```text +Test Files 6 failed | 120 passed (126) +Tests 32 failed | 1223 passed (1255) +``` + +Echecs de suite complete isoles hors perimetre #297: +- `frontend/src/features/projects/ProjectsView.ls7.test.tsx`: 3 echecs conversation viewer LS7. +- `frontend/src/features/web/WebAgentCell.test.tsx`: 1 echec mobile custom CLI. +- `frontend/src/features/web/WebApp.test.tsx`: 3 echecs routing/pairing web. +- `frontend/src/features/web/WebDesktopWorkspace.test.tsx`: 1 echec menu/dock web. +- `frontend/src/features/web/WebMenus.test.tsx`: 8 echecs menus web historiques. +- `frontend/src/features/web/tickets/WebTicketsSprints.test.tsx`: 16 echecs tickets/sprints web. + +Conclusion: les suites directement touchees par #297 sont vertes, le build TypeScript/Vite est vert, et la suite complete ne met pas en evidence de regression dans le perimetre #297. diff --git a/.ideai/tickets/297/issue.md b/.ideai/tickets/297/issue.md new file mode 100644 index 00000000..c3903b4e --- /dev/null +++ b/.ideai/tickets/297/issue.md @@ -0,0 +1,17 @@ +--- +id: "fc7ce9d2-870d-4de6-b27f-8a1e9e650451" +number: 297 +title: "Unifier les menus Skills et MCP tools avec sélection par liste cochable" +status: "closed" +priority: "high" +sprint: null +links: [] +agentRefs: [{"agentId":"1ff94b51-3e17-4a39-8543-7715d1ff0f80","role":"assigned"}] +attachments: [] +createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +createdAt: 1788977734368 +updatedAt: 1788979147782 +version: 6 +--- +Demande utilisateur : retirer les skills et tools MCP du menu agent, créer un menu MCP dédié listant les tools MCP IdeA et plugins, permettre la sélection des tools MCP de plugins par cases à cocher au lieu de saisir leur nom exact, et regrouper tous les skills dans un même menu. Cycle complet requis : UX, architecture, branche, implémentation, QA, commit local si vert. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 21ea1074..e2eb2c7c 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 297 + "nextNumber": 298 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 64de70f2..a3cc824b 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -4056,7 +4056,7 @@ "issueRef": "#296", "path": "296", "title": "Unifier l’attribution des skills et tools MCP natifs/plugins aux agents", - "status": "qa", + "status": "closed", "priority": "high", "sprint": null, "assignedAgentIds": [], @@ -4064,7 +4064,23 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1788976061895 + "updatedAt": 1788976624245 + }, + { + "issueRef": "#297", + "path": "297", + "title": "Unifier les menus Skills et MCP tools avec sélection par liste cochable", + "status": "closed", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "1ff94b51-3e17-4a39-8543-7715d1ff0f80" + ], + "createdBy": { + "kind": "agent", + "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + }, + "updatedAt": 1788979147782 } ] } \ No newline at end of file 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: {