merge(skills,mcp): menus Skills et MCP unifies avec selection cochable #297 (QA verte reconfirmée par Git: 52 tests cibles sur 7 fichiers + npm run build tsc/vite OK; branche feature/297-skills-mcp-unified-menus commits e6ce7d6 + 2070de9 + 48420ba; restants: rebuild AppImage + validation live des panneaux Skills/MCP depuis l'app installee; saisie manuelle du nom exact retiree — serveurs plugin sans catalogue en tombstones retirables uniquement; reserves non bloquantes: 32 echecs frontend sur 6 fichiers web hors perimetre classes par QA; bookkeeping tickets/296/* et scripts/ non suivis hors perimetre; aucun push)
This commit is contained in:
@ -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
|
- [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
|
- [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
|
- [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
|
||||||
|
|||||||
19
.ideai/memory/ticket297-skills-mcp-unified-surfaces.md
Normal file
19
.ideai/memory/ticket297-skills-mcp-unified-surfaces.md
Normal file
@ -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.
|
||||||
145
.ideai/tickets/297/carnet.md
Normal file
145
.ideai/tickets/297/carnet.md
Normal file
@ -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.
|
||||||
17
.ideai/tickets/297/issue.md
Normal file
17
.ideai/tickets/297/issue.md
Normal file
@ -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.
|
||||||
@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nextNumber": 297
|
"nextNumber": 298
|
||||||
}
|
}
|
||||||
@ -4056,7 +4056,7 @@
|
|||||||
"issueRef": "#296",
|
"issueRef": "#296",
|
||||||
"path": "296",
|
"path": "296",
|
||||||
"title": "Unifier l’attribution des skills et tools MCP natifs/plugins aux agents",
|
"title": "Unifier l’attribution des skills et tools MCP natifs/plugins aux agents",
|
||||||
"status": "qa",
|
"status": "closed",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
@ -4064,7 +4064,23 @@
|
|||||||
"kind": "agent",
|
"kind": "agent",
|
||||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -19,7 +19,6 @@ import { useEffect, useState } from "react";
|
|||||||
import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared";
|
import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared";
|
||||||
import { TerminalView } from "@/features/terminals/TerminalView";
|
import { TerminalView } from "@/features/terminals/TerminalView";
|
||||||
import { AnnouncementsPreview } from "@/features/announcements";
|
import { AnnouncementsPreview } from "@/features/announcements";
|
||||||
import { AgentPluginCapabilities } from "@/features/plugins/AgentPluginCapabilities";
|
|
||||||
import { useDrift } from "@/features/templates/useDrift";
|
import { useDrift } from "@/features/templates/useDrift";
|
||||||
import { templateProfileLabel } from "@/features/templates/templatePresentation";
|
import { templateProfileLabel } from "@/features/templates/templatePresentation";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
@ -624,20 +623,6 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedAgent && gateways.plugin && (
|
|
||||||
<div className="border-t border-border p-4">
|
|
||||||
<AgentPluginCapabilities
|
|
||||||
projectId={projectId}
|
|
||||||
agent={selectedAgent}
|
|
||||||
active={
|
|
||||||
activeAgentId === selectedAgent.id ||
|
|
||||||
vm.liveAgents.some((live) => live.agentId === selectedAgent.id)
|
|
||||||
}
|
|
||||||
onAgentChanged={vm.refresh}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Context editor ── */}
|
{/* ── Context editor ── */}
|
||||||
{selectedAgent && (
|
{selectedAgent && (
|
||||||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||||||
|
|||||||
51
frontend/src/features/permissions/McpPanel.tsx
Normal file
51
frontend/src/features/permissions/McpPanel.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
import type { Agent } from "@/domain";
|
||||||
|
import { Button, Panel, Spinner } from "@/shared";
|
||||||
|
import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel";
|
||||||
|
|
||||||
|
export interface McpPanelProps {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function McpPanel({ projectId }: McpPanelProps) {
|
||||||
|
const { agent } = useGateways();
|
||||||
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setAgents(await agent.listAgents(projectId));
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : String(cause));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [agent, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="MCP"
|
||||||
|
actions={loading ? <Spinner size={14} /> : <Button size="sm" variant="ghost" onClick={() => void refresh()}>Refresh</Button>}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
flush
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<div className="flex flex-col items-start gap-2 p-4">
|
||||||
|
<p role="alert" className="text-sm text-danger">{error}</p>
|
||||||
|
<Button size="sm" onClick={() => void refresh()}>Réessayer</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<McpToolPermissionsPanel projectId={projectId} agents={agents} />
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
|
* 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
|
* 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 {
|
import {
|
||||||
MockAgentGateway,
|
MockAgentGateway,
|
||||||
MockPermissionGateway,
|
MockPermissionGateway,
|
||||||
|
MockPluginGateway,
|
||||||
MockProfileGateway,
|
MockProfileGateway,
|
||||||
} from "@/adapters/mock";
|
} from "@/adapters/mock";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import { PermissionsPanel } from "./PermissionsPanel";
|
import { McpPanel } from "./McpPanel";
|
||||||
|
|
||||||
const PROJECT_ID = "proj-mcp-permissions-test";
|
const PROJECT_ID = "proj-mcp-permissions-test";
|
||||||
|
|
||||||
@ -32,12 +33,13 @@ async function renderPanel() {
|
|||||||
const gateways = {
|
const gateways = {
|
||||||
agent,
|
agent,
|
||||||
permission,
|
permission,
|
||||||
|
plugin: new MockPluginGateway(),
|
||||||
profile: new MockProfileGateway(),
|
profile: new MockProfileGateway(),
|
||||||
} as unknown as Gateways;
|
} as unknown as Gateways;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<DIProvider gateways={gateways}>
|
<DIProvider gateways={gateways}>
|
||||||
<PermissionsPanel projectId={PROJECT_ID} />
|
<McpPanel projectId={PROJECT_ID} />
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -48,9 +50,7 @@ async function renderPanel() {
|
|||||||
return { agent, permission, agentId: created.id };
|
return { agent, permission, agentId: created.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switches to the "Tools MCP IdeA" tab and waits past the doc load. */
|
|
||||||
async function openMcpToolsTab() {
|
async function openMcpToolsTab() {
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Tools MCP IdeA" }));
|
|
||||||
await screen.findByRole("navigation", { name: "cibles des permissions MCP" });
|
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" });
|
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
|
||||||
fireEvent.click(within(nav).getByRole("button", { name: new RegExp(name) }));
|
fireEvent.click(within(nav).getByRole("button", { name: new RegExp(name) }));
|
||||||
await waitFor(() => {
|
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", () => {
|
describe("McpPanel", () => {
|
||||||
it("has an accessible tablist and defaults to Système", async () => {
|
it("is exposed as a dedicated surface", async () => {
|
||||||
await renderPanel();
|
await renderPanel();
|
||||||
|
await openMcpToolsTab();
|
||||||
const tablist = screen.getByRole("tablist", { name: "Permissions" });
|
expect(screen.getByRole("heading", { name: "MCP" })).toBeTruthy();
|
||||||
const tabs = within(tablist).getAllByRole("tab");
|
expect(screen.queryByRole("tablist", { name: "Permissions" })).toBeNull();
|
||||||
expect(tabs.map((t) => t.textContent)).toEqual(["Système", "Tools MCP IdeA"]);
|
|
||||||
expect(screen.getByRole("tab", { name: "Système" }).getAttribute("aria-selected")).toBe(
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an agent without an override shows it inherits the project default, read-only", async () => {
|
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 = {
|
const gateways = {
|
||||||
agent,
|
agent,
|
||||||
permission,
|
permission,
|
||||||
|
plugin: new MockPluginGateway(),
|
||||||
profile: new MockProfileGateway(),
|
profile: new MockProfileGateway(),
|
||||||
} as unknown as Gateways;
|
} as unknown as Gateways;
|
||||||
render(
|
render(
|
||||||
<DIProvider gateways={gateways}>
|
<DIProvider gateways={gateways}>
|
||||||
<PermissionsPanel projectId={PROJECT_ID} />
|
<McpPanel projectId={PROJECT_ID} />
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -262,7 +259,7 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => {
|
|||||||
expect(window.confirm).toHaveBeenCalled();
|
expect(window.confirm).toHaveBeenCalled();
|
||||||
// Still on the agent editor, with the unsaved edit intact.
|
// Still on the agent editor, with the unsaved edit intact.
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { name: "Tools MCP IdeA — DevFrontend" }),
|
screen.getByRole("heading", { name: "MCP IdeA — DevFrontend" }),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }) as HTMLInputElement)
|
(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }) as HTMLInputElement)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* `McpToolPermissionsPanel` — the "Tools MCP IdeA" tab of the project
|
* `McpToolPermissionsPanel` — the editor hosted by the dedicated `MCP` panel
|
||||||
* `Permissions` panel (ticket #82). Lets the user grant/revoke MCP tool
|
* (tickets #82/#297). Lets the user grant/revoke MCP tool
|
||||||
* capabilities per agent (or project-wide default), grouped by domain rather
|
* capabilities per agent (or project-wide default), grouped by domain rather
|
||||||
* than as a flat 25-checkbox list (carnet #82 — Conception UX/F).
|
* than as a flat 25-checkbox list (carnet #82 — Conception UX/F).
|
||||||
*
|
*
|
||||||
@ -15,8 +15,9 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { Agent, McpToolCatalogue } from "@/domain";
|
import type { Agent, McpToolCatalogue, PluginToolRef } from "@/domain";
|
||||||
import { Button, Panel, cn } from "@/shared";
|
import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
|
||||||
|
import { Button, Input, Panel, cn } from "@/shared";
|
||||||
import {
|
import {
|
||||||
mcpToolGroupsForCatalogue,
|
mcpToolGroupsForCatalogue,
|
||||||
mcpToolLabel,
|
mcpToolLabel,
|
||||||
@ -49,6 +50,7 @@ export function McpToolPermissionsPanel({
|
|||||||
agents,
|
agents,
|
||||||
}: McpToolPermissionsPanelProps) {
|
}: McpToolPermissionsPanelProps) {
|
||||||
const vm = useMcpToolPermissions(projectId);
|
const vm = useMcpToolPermissions(projectId);
|
||||||
|
const plugins = useProjectPlugins(projectId);
|
||||||
const [target, setTarget] = useState<McpTarget>({ type: "project" });
|
const [target, setTarget] = useState<McpTarget>({ type: "project" });
|
||||||
// Read imperatively (not via re-render) by `selectTarget` so an unsaved
|
// Read imperatively (not via re-render) by `selectTarget` so an unsaved
|
||||||
// draft in the currently-mounted editor blocks a target switch without
|
// draft in the currently-mounted editor blocks a target switch without
|
||||||
@ -176,32 +178,138 @@ export function McpToolPermissionsPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<McpToolEditor
|
<div className="flex flex-col gap-4">
|
||||||
key={target.agentId}
|
<McpToolEditor
|
||||||
targetKind="agent"
|
key={target.agentId}
|
||||||
title={`Tools MCP IdeA — ${target.agentName}`}
|
targetKind="agent"
|
||||||
catalogue={doc.catalogue}
|
title={`MCP IdeA — ${target.agentName}`}
|
||||||
storedAllowed={selectedAgentOverride?.policy.allowedTools ?? null}
|
catalogue={doc.catalogue}
|
||||||
comparisonBaseline={
|
storedAllowed={selectedAgentOverride?.policy.allowedTools ?? null}
|
||||||
doc.projectDefault?.allowedTools ?? doc.catalogue.readOnlyTools
|
comparisonBaseline={
|
||||||
}
|
doc.projectDefault?.allowedTools ?? doc.catalogue.readOnlyTools
|
||||||
busy={vm.busy}
|
}
|
||||||
onDirtyChange={(dirty) => {
|
busy={vm.busy}
|
||||||
dirtyRef.current = dirty;
|
onDirtyChange={(dirty) => {
|
||||||
}}
|
dirtyRef.current = dirty;
|
||||||
onSaveAgentOverride={async (allowed) => {
|
}}
|
||||||
await vm.saveAgentOverride(target.agentId, { allowedTools: allowed });
|
onSaveAgentOverride={async (allowed) => {
|
||||||
}}
|
await vm.saveAgentOverride(target.agentId, { allowedTools: allowed });
|
||||||
onResetOverride={async () => {
|
}}
|
||||||
await vm.resetAgentOverride(target.agentId);
|
onResetOverride={async () => {
|
||||||
}}
|
await vm.resetAgentOverride(target.agentId);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
<PluginMcpToolsEditor
|
||||||
|
agentId={target.agentId}
|
||||||
|
agentName={target.agentName}
|
||||||
|
plugins={plugins}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pluginToolKey = (ref: PluginToolRef) =>
|
||||||
|
`${ref.pluginId}:${ref.serverId}:${ref.toolName}`;
|
||||||
|
|
||||||
|
function PluginMcpToolsEditor({
|
||||||
|
agentId,
|
||||||
|
agentName,
|
||||||
|
plugins,
|
||||||
|
}: {
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
plugins: ReturnType<typeof useProjectPlugins>;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||||
|
const matches = (value: string) =>
|
||||||
|
!normalizedQuery || value.toLocaleLowerCase().includes(normalizedQuery);
|
||||||
|
const assignments = plugins.settings?.agents.find(
|
||||||
|
(entry) => entry.agentId === agentId,
|
||||||
|
)?.tools ?? [];
|
||||||
|
const assignedKeys = new Set(assignments.map(pluginToolKey));
|
||||||
|
const enabledPluginIds = new Set(
|
||||||
|
(plugins.settings?.plugins ?? [])
|
||||||
|
.filter((entry) => entry.enabled)
|
||||||
|
.map((entry) => entry.pluginId),
|
||||||
|
);
|
||||||
|
const knownKeys = new Set(
|
||||||
|
plugins.installed.flatMap((plugin) =>
|
||||||
|
(plugin.contributes.mcpServers ?? []).flatMap((server) =>
|
||||||
|
(server.tools ?? []).map(
|
||||||
|
(tool) => `${plugin.id}:${server.id}:${tool.name}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const tombstones = assignments.filter((ref) => !knownKeys.has(pluginToolKey(ref)));
|
||||||
|
const cataloguedCount = plugins.installed.reduce(
|
||||||
|
(total, plugin) =>
|
||||||
|
total + (plugin.contributes.mcpServers ?? []).reduce(
|
||||||
|
(serverTotal, server) => serverTotal + (server.tools ?? []).length,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title={`MCP plugins — ${agentName}`} flush>
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
<Input
|
||||||
|
aria-label="rechercher un tool MCP plugin"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Rechercher un tool ou un plugin"
|
||||||
|
/>
|
||||||
|
{plugins.error && <p role="alert" className="text-sm text-danger">{plugins.error}</p>}
|
||||||
|
{plugins.installed.map((plugin) => {
|
||||||
|
const rows = (plugin.contributes.mcpServers ?? []).flatMap((server) =>
|
||||||
|
(server.tools ?? [])
|
||||||
|
.filter((tool) =>
|
||||||
|
matches(`${tool.name} ${tool.description ?? ""} ${server.displayName} ${plugin.displayName}`),
|
||||||
|
)
|
||||||
|
.map((tool) => ({ server, tool })),
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const enabled = enabledPluginIds.has(plugin.id);
|
||||||
|
return (
|
||||||
|
<section key={plugin.id} className="flex flex-col gap-2" aria-label={`Plugin ${plugin.displayName}`}>
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">{plugin.displayName}</h4>
|
||||||
|
{rows.map(({ server, tool }) => {
|
||||||
|
const ref = { pluginId: plugin.id, serverId: server.id, toolName: tool.name };
|
||||||
|
const assigned = assignedKeys.has(pluginToolKey(ref));
|
||||||
|
return (
|
||||||
|
<label key={pluginToolKey(ref)} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={assigned}
|
||||||
|
disabled={plugins.busy || (!enabled && !assigned)}
|
||||||
|
onChange={(event) => void plugins.setToolAssigned({ agentId, ...ref }, event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1"><span className="font-medium">{tool.name}</span>{tool.description ? ` — ${tool.description}` : ""}<span className="text-faint"> · {server.displayName}</span>{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{tombstones.filter((ref) => matches(`${ref.pluginId} ${ref.serverId} ${ref.toolName}`)).map((ref) => (
|
||||||
|
<div key={`missing:${pluginToolKey(ref)}`} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
|
||||||
|
<span><span className="font-medium">{ref.toolName}</span> · {ref.serverId} <span className="text-warning">— Indisponible</span> <span className="text-faint">Plugin: {ref.pluginId}</span></span>
|
||||||
|
<Button size="sm" variant="ghost" disabled={plugins.busy} onClick={() => void plugins.setToolAssigned({ agentId, ...ref }, false)}>Retirer</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!plugins.loading && cataloguedCount === 0 && tombstones.length === 0 && (
|
||||||
|
<p className="text-sm text-muted">Aucun tool MCP plugin catalogué.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface McpToolEditorProps {
|
interface McpToolEditorProps {
|
||||||
targetKind: "project" | "agent";
|
targetKind: "project" | "agent";
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import type {
|
|||||||
ResolvedAgentSystemPermissions,
|
ResolvedAgentSystemPermissions,
|
||||||
SystemPermissionSet,
|
SystemPermissionSet,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel";
|
|
||||||
import {
|
import {
|
||||||
type CapabilityChoice,
|
type CapabilityChoice,
|
||||||
type PolicyDraft,
|
type PolicyDraft,
|
||||||
@ -21,13 +20,6 @@ export interface PermissionsPanelProps {
|
|||||||
projectId: string;
|
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 EditorTarget =
|
||||||
| { type: "project" }
|
| { type: "project" }
|
||||||
| { type: "agent"; agentId: string; agentName: string };
|
| { type: "agent"; agentId: string; agentName: string };
|
||||||
@ -66,7 +58,6 @@ function systemPermissionsFromDraft(network: NetworkPolicy | ""): SystemPermissi
|
|||||||
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||||
const vm = usePermissions(projectId);
|
const vm = usePermissions(projectId);
|
||||||
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
|
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
|
||||||
const [tab, setTab] = useState<PermissionsTab>("system");
|
|
||||||
|
|
||||||
const selectedAgent = target.type === "agent"
|
const selectedAgent = target.type === "agent"
|
||||||
? vm.rows.find((row) => row.agent.id === target.agentId) ?? null
|
? 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"
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
flush
|
flush
|
||||||
>
|
>
|
||||||
<div role="tablist" aria-label="Permissions" className="flex gap-1 border-b border-border px-4 pt-2">
|
|
||||||
{TABS.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
id={`permissions-tab-${t.id}`}
|
|
||||||
aria-selected={tab === t.id}
|
|
||||||
aria-controls={`permissions-tabpanel-${t.id}`}
|
|
||||||
tabIndex={tab === t.id ? 0 : -1}
|
|
||||||
onClick={() => setTab(t.id)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
|
||||||
e.preventDefault();
|
|
||||||
const i = TABS.findIndex((x) => x.id === tab);
|
|
||||||
const next = e.key === "ArrowRight" ? (i + 1) % TABS.length : (i - 1 + TABS.length) % TABS.length;
|
|
||||||
setTab(TABS[next].id);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"min-h-[32px] rounded-t-md border-b-2 px-3 py-1.5 text-sm font-medium transition-colors",
|
|
||||||
tab === t.id
|
|
||||||
? "border-primary text-content"
|
|
||||||
: "border-transparent text-muted hover:text-content",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{vm.error && (
|
{vm.error && (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
@ -183,10 +144,6 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
role="tabpanel"
|
|
||||||
id="permissions-tabpanel-system"
|
|
||||||
aria-labelledby="permissions-tab-system"
|
|
||||||
hidden={tab !== "system"}
|
|
||||||
className="flex flex-col gap-4 p-4"
|
className="flex flex-col gap-4 p-4"
|
||||||
>
|
>
|
||||||
<PolicyCard
|
<PolicyCard
|
||||||
@ -278,17 +235,6 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
role="tabpanel"
|
|
||||||
id="permissions-tabpanel-mcpTools"
|
|
||||||
aria-labelledby="permissions-tab-mcpTools"
|
|
||||||
hidden={tab !== "mcpTools"}
|
|
||||||
className="flex min-h-0 flex-1 flex-col"
|
|
||||||
>
|
|
||||||
{tab === "mcpTools" && (
|
|
||||||
<McpToolPermissionsPanel projectId={projectId} agents={vm.agents} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from "./PermissionsPanel";
|
export * from "./PermissionsPanel";
|
||||||
|
export * from "./McpPanel";
|
||||||
|
|||||||
@ -1,411 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import { useGateways } from "@/app/di";
|
|
||||||
import type {
|
|
||||||
Agent,
|
|
||||||
McpToolCatalogueEntry,
|
|
||||||
PluginSkillRef,
|
|
||||||
PluginToolRef,
|
|
||||||
ProjectMcpToolPermissions,
|
|
||||||
Skill,
|
|
||||||
SkillRef,
|
|
||||||
} from "@/domain";
|
|
||||||
import { Button, Input, SmallDropdown, Spinner } from "@/shared";
|
|
||||||
import { useProjectPlugins } from "./useProjectPlugins";
|
|
||||||
|
|
||||||
interface AgentPluginCapabilitiesProps {
|
|
||||||
projectId: string;
|
|
||||||
agent: Agent;
|
|
||||||
active: boolean;
|
|
||||||
onAgentChanged?: () => Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginSkillKey = (ref: PluginSkillRef) => `${ref.pluginId}:${ref.skillId}`;
|
|
||||||
const pluginToolKey = (ref: PluginToolRef) =>
|
|
||||||
`${ref.pluginId}:${ref.serverId}:${ref.toolName}`;
|
|
||||||
const nativeSkillKey = (ref: SkillRef) => `${ref.scope}:${ref.skillId}`;
|
|
||||||
|
|
||||||
function nativeCatalogue(doc: ProjectMcpToolPermissions | null): McpToolCatalogueEntry[] {
|
|
||||||
if (!doc) return [];
|
|
||||||
if (doc.catalogue.tools?.length) return doc.catalogue.tools;
|
|
||||||
return [
|
|
||||||
...doc.catalogue.readOnlyTools.map((name) => ({
|
|
||||||
name,
|
|
||||||
description: "",
|
|
||||||
access: "read" as const,
|
|
||||||
})),
|
|
||||||
...doc.catalogue.writeActionTools.map((name) => ({
|
|
||||||
name,
|
|
||||||
description: "",
|
|
||||||
access: "writeAction" as const,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function Origin({ children }: { children: ReactNode }) {
|
|
||||||
return <span className="shrink-0 text-[0.65rem] text-faint">{children}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Tombstone({
|
|
||||||
label,
|
|
||||||
origin,
|
|
||||||
onRemove,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
origin: string;
|
|
||||||
onRemove: () => void;
|
|
||||||
disabled: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="font-medium">{label}</span>{" "}
|
|
||||||
<span className="text-warning">— Indisponible</span>{" "}
|
|
||||||
<Origin>{origin}</Origin>
|
|
||||||
</span>
|
|
||||||
<Button size="sm" variant="ghost" disabled={disabled} onClick={onRemove}>
|
|
||||||
Retirer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentPluginCapabilities({
|
|
||||||
projectId,
|
|
||||||
agent,
|
|
||||||
active,
|
|
||||||
onAgentChanged,
|
|
||||||
}: AgentPluginCapabilitiesProps) {
|
|
||||||
const { skill: skillGateway, permission: permissionGateway } = useGateways();
|
|
||||||
const plugins = useProjectPlugins(projectId);
|
|
||||||
const [nativeSkills, setNativeSkills] = useState<Skill[]>([]);
|
|
||||||
const [nativeAssignments, setNativeAssignments] = useState<SkillRef[]>(agent.skills);
|
|
||||||
const [permissions, setPermissions] = useState<ProjectMcpToolPermissions | null>(null);
|
|
||||||
const [loadingNative, setLoadingNative] = useState(true);
|
|
||||||
const [nativeBusy, setNativeBusy] = useState(false);
|
|
||||||
const [nativeError, setNativeError] = useState<string | null>(null);
|
|
||||||
const [serverChoice, setServerChoice] = useState("");
|
|
||||||
const [toolName, setToolName] = useState("");
|
|
||||||
const [restartRequired, setRestartRequired] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => setNativeAssignments(agent.skills), [agent.skills]);
|
|
||||||
|
|
||||||
const loadNative = useCallback(async () => {
|
|
||||||
setLoadingNative(true);
|
|
||||||
setNativeError(null);
|
|
||||||
try {
|
|
||||||
const [globals, projects, permissionDoc] = await Promise.all([
|
|
||||||
skillGateway?.listSkills(projectId, "global") ?? Promise.resolve([]),
|
|
||||||
skillGateway?.listSkills(projectId, "project") ?? Promise.resolve([]),
|
|
||||||
permissionGateway?.getMcpToolPermissions(projectId) ?? Promise.resolve(null),
|
|
||||||
]);
|
|
||||||
setNativeSkills([...projects, ...globals]);
|
|
||||||
setPermissions(permissionDoc);
|
|
||||||
} catch (cause) {
|
|
||||||
setNativeError(cause instanceof Error ? cause.message : String(cause));
|
|
||||||
} finally {
|
|
||||||
setLoadingNative(false);
|
|
||||||
}
|
|
||||||
}, [permissionGateway, projectId, skillGateway]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadNative();
|
|
||||||
}, [loadNative]);
|
|
||||||
|
|
||||||
function markRestartRequired() {
|
|
||||||
if (active) setRestartRequired(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setNativeSkill(skill: Skill, assigned: boolean) {
|
|
||||||
if (!skillGateway) return;
|
|
||||||
setNativeBusy(true);
|
|
||||||
setNativeError(null);
|
|
||||||
try {
|
|
||||||
if (assigned) {
|
|
||||||
await skillGateway.assignSkill(projectId, agent.id, skill.id, skill.scope);
|
|
||||||
setNativeAssignments((current) => [
|
|
||||||
...current.filter((ref) => ref.skillId !== skill.id),
|
|
||||||
{ skillId: skill.id, scope: skill.scope },
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
await skillGateway.unassignSkill(projectId, agent.id, skill.id);
|
|
||||||
setNativeAssignments((current) =>
|
|
||||||
current.filter((ref) => ref.skillId !== skill.id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await onAgentChanged?.();
|
|
||||||
markRestartRequired();
|
|
||||||
} catch (cause) {
|
|
||||||
setNativeError(cause instanceof Error ? cause.message : String(cause));
|
|
||||||
} finally {
|
|
||||||
setNativeBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeMissingNativeSkill(ref: SkillRef) {
|
|
||||||
if (!skillGateway) return;
|
|
||||||
setNativeBusy(true);
|
|
||||||
try {
|
|
||||||
await skillGateway.unassignSkill(projectId, agent.id, ref.skillId);
|
|
||||||
setNativeAssignments((current) =>
|
|
||||||
current.filter((candidate) => nativeSkillKey(candidate) !== nativeSkillKey(ref)),
|
|
||||||
);
|
|
||||||
await onAgentChanged?.();
|
|
||||||
markRestartRequired();
|
|
||||||
} finally {
|
|
||||||
setNativeBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setNativeTool(name: string, allowed: boolean) {
|
|
||||||
if (!permissionGateway) return;
|
|
||||||
setNativeBusy(true);
|
|
||||||
setNativeError(null);
|
|
||||||
try {
|
|
||||||
setPermissions(
|
|
||||||
await permissionGateway.setAgentMcpToolAllowed(
|
|
||||||
projectId,
|
|
||||||
agent.id,
|
|
||||||
name,
|
|
||||||
allowed,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
markRestartRequired();
|
|
||||||
} catch (cause) {
|
|
||||||
setNativeError(cause instanceof Error ? cause.message : String(cause));
|
|
||||||
} finally {
|
|
||||||
setNativeBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const assignments = plugins.settings?.agents.find((entry) => entry.agentId === agent.id);
|
|
||||||
const assignedPluginSkills = new Set((assignments?.skills ?? []).map(pluginSkillKey));
|
|
||||||
const assignedPluginTools = assignments?.tools ?? [];
|
|
||||||
const enabledPluginIds = new Set(
|
|
||||||
(plugins.settings?.plugins ?? [])
|
|
||||||
.filter((entry) => entry.enabled)
|
|
||||||
.map((entry) => entry.pluginId),
|
|
||||||
);
|
|
||||||
const knownPluginSkillKeys = new Set(
|
|
||||||
plugins.installed.flatMap((plugin) =>
|
|
||||||
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const knownServerKeys = new Set(
|
|
||||||
plugins.installed.flatMap((plugin) =>
|
|
||||||
(plugin.contributes.mcpServers ?? []).map((server) => `${plugin.id}:${server.id}`),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const staticPluginToolKeys = new Set(
|
|
||||||
plugins.installed.flatMap((plugin) =>
|
|
||||||
(plugin.contributes.mcpServers ?? []).flatMap((server) =>
|
|
||||||
(server.tools ?? []).map((tool) => `${plugin.id}:${server.id}:${tool.name}`),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const noCatalogueServerKeys = new Set(
|
|
||||||
plugins.installed.flatMap((plugin) =>
|
|
||||||
(plugin.contributes.mcpServers ?? [])
|
|
||||||
.filter((server) => (server.tools ?? []).length === 0)
|
|
||||||
.map((server) => `${plugin.id}:${server.id}`),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const stalePluginSkills = (assignments?.skills ?? []).filter(
|
|
||||||
(ref) => !knownPluginSkillKeys.has(pluginSkillKey(ref)),
|
|
||||||
);
|
|
||||||
const stalePluginTools = assignedPluginTools.filter(
|
|
||||||
(ref) => {
|
|
||||||
const serverKey = `${ref.pluginId}:${ref.serverId}`;
|
|
||||||
return (
|
|
||||||
!knownServerKeys.has(serverKey) ||
|
|
||||||
(!noCatalogueServerKeys.has(serverKey) &&
|
|
||||||
!staticPluginToolKeys.has(pluginToolKey(ref)))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const advancedAssignedTools = assignedPluginTools.filter(
|
|
||||||
(ref) =>
|
|
||||||
noCatalogueServerKeys.has(`${ref.pluginId}:${ref.serverId}`),
|
|
||||||
);
|
|
||||||
const advancedServers = useMemo(
|
|
||||||
() =>
|
|
||||||
plugins.installed.flatMap((plugin) =>
|
|
||||||
(plugin.contributes.mcpServers ?? [])
|
|
||||||
.filter((server) => (server.tools ?? []).length === 0)
|
|
||||||
.map((server) => ({
|
|
||||||
value: `${plugin.id}\u0000${server.id}`,
|
|
||||||
label: `${plugin.displayName} — ${server.displayName}`,
|
|
||||||
enabled: enabledPluginIds.has(plugin.id),
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
[plugins.installed, plugins.settings],
|
|
||||||
);
|
|
||||||
|
|
||||||
async function setPluginSkill(pluginId: string, skillId: string, assigned: boolean) {
|
|
||||||
await plugins.setSkillAssigned(agent.id, pluginId, skillId, assigned);
|
|
||||||
markRestartRequired();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setPluginTool(ref: PluginToolRef, assigned: boolean) {
|
|
||||||
await plugins.setToolAssigned({ agentId: agent.id, ...ref }, assigned);
|
|
||||||
markRestartRequired();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assignAdvancedTool() {
|
|
||||||
const [pluginId, serverId] = serverChoice.split("\u0000");
|
|
||||||
const name = toolName.trim();
|
|
||||||
if (!pluginId || !serverId || !name) return;
|
|
||||||
await setPluginTool({ pluginId, serverId, toolName: name }, true);
|
|
||||||
setToolName("");
|
|
||||||
}
|
|
||||||
|
|
||||||
const nativeAssignedKeys = new Set(nativeAssignments.map(nativeSkillKey));
|
|
||||||
const knownNativeSkillKeys = new Set(
|
|
||||||
nativeSkills.map((skill) => `${skill.scope}:${skill.id}`),
|
|
||||||
);
|
|
||||||
const staleNativeSkills = nativeAssignments.filter(
|
|
||||||
(ref) => !knownNativeSkillKeys.has(nativeSkillKey(ref)),
|
|
||||||
);
|
|
||||||
const nativeTools = nativeCatalogue(permissions);
|
|
||||||
const explicitNativeTools = new Set(
|
|
||||||
permissions?.agents.find((entry) => entry.agentId === agent.id)?.policy.allowedTools ?? [],
|
|
||||||
);
|
|
||||||
const knownNativeTools = new Set(nativeTools.map((tool) => tool.name));
|
|
||||||
const staleNativeTools = [...explicitNativeTools].filter(
|
|
||||||
(name) => !knownNativeTools.has(name),
|
|
||||||
);
|
|
||||||
const busy = nativeBusy || plugins.busy;
|
|
||||||
|
|
||||||
if ((loadingNative || plugins.loading) && !permissions && !plugins.settings) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted">
|
|
||||||
<Spinner size={12} /> Chargement des capacités de l’agent…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-label={`Capacités de l'agent — ${agent.name}`} className="flex flex-col gap-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-content">
|
|
||||||
Capacités de l'agent — {agent.name}
|
|
||||||
</h4>
|
|
||||||
<p className="mt-1 text-xs text-muted">
|
|
||||||
Attribuez les skills et tools MCP disponibles pour cet agent.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{restartRequired && (
|
|
||||||
<p role="status" className="rounded border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
|
||||||
Relance requise pour appliquer ces changements à l’agent actif.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{(nativeError || plugins.error) && (
|
|
||||||
<p role="alert" className="text-xs text-danger">{nativeError ?? plugins.error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section aria-label={`Skills — ${agent.name}`} className="flex flex-col gap-2">
|
|
||||||
<h5 className="text-sm font-semibold text-content">Skills</h5>
|
|
||||||
{nativeSkills.length === 0 &&
|
|
||||||
plugins.installed.every(
|
|
||||||
(plugin) => (plugin.contributes.skills ?? []).length === 0,
|
|
||||||
) &&
|
|
||||||
staleNativeSkills.length === 0 &&
|
|
||||||
stalePluginSkills.length === 0 && (
|
|
||||||
<p className="text-xs text-muted">Aucun skill disponible.</p>
|
|
||||||
)}
|
|
||||||
{nativeSkills.map((skill) => {
|
|
||||||
const assigned = nativeAssignedKeys.has(`${skill.scope}:${skill.id}`);
|
|
||||||
return (
|
|
||||||
<label key={`native:${skill.scope}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
|
|
||||||
<input type="checkbox" checked={assigned} disabled={busy || !skillGateway} onChange={(event) => void setNativeSkill(skill, event.target.checked)} />
|
|
||||||
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span>
|
|
||||||
<Origin>IdeA</Origin>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{staleNativeSkills.map((ref) => (
|
|
||||||
<Tombstone key={`stale-native:${nativeSkillKey(ref)}`} label={ref.skillId} origin="IdeA" disabled={busy} onRemove={() => void removeMissingNativeSkill(ref)} />
|
|
||||||
))}
|
|
||||||
{plugins.installed.flatMap((plugin) =>
|
|
||||||
(plugin.contributes.skills ?? []).map((skill) => {
|
|
||||||
const assigned = assignedPluginSkills.has(`${plugin.id}:${skill.id}`);
|
|
||||||
const enabled = enabledPluginIds.has(plugin.id);
|
|
||||||
return (
|
|
||||||
<label key={`plugin:${plugin.id}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
|
|
||||||
<input type="checkbox" checked={assigned} disabled={busy || (!enabled && !assigned)} onChange={(event) => void setPluginSkill(plugin.id, skill.id, event.target.checked)} />
|
|
||||||
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
|
|
||||||
<Origin>Plugin: {plugin.displayName}</Origin>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
{stalePluginSkills.map((ref) => (
|
|
||||||
<Tombstone key={`stale-plugin:${pluginSkillKey(ref)}`} label={ref.skillId} origin={`Plugin: ${ref.pluginId}`} disabled={busy} onRemove={() => void setPluginSkill(ref.pluginId, ref.skillId, false)} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section aria-label={`Tools MCP — ${agent.name}`} className="flex flex-col gap-2">
|
|
||||||
<h5 className="text-sm font-semibold text-content">Tools MCP</h5>
|
|
||||||
{nativeTools.length === 0 &&
|
|
||||||
staticPluginToolKeys.size === 0 &&
|
|
||||||
advancedAssignedTools.length === 0 &&
|
|
||||||
stalePluginTools.length === 0 &&
|
|
||||||
advancedServers.length === 0 && (
|
|
||||||
<p className="text-xs text-muted">Aucun tool MCP disponible.</p>
|
|
||||||
)}
|
|
||||||
{nativeTools.map((tool) => (
|
|
||||||
<label key={`native-tool:${tool.name}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
|
|
||||||
<input type="checkbox" checked={explicitNativeTools.has(tool.name)} disabled={busy || !permissionGateway} onChange={(event) => void setNativeTool(tool.name, event.target.checked)} />
|
|
||||||
<span className="min-w-0 flex-1"><span className="font-medium">{tool.name}</span>{tool.description ? ` — ${tool.description}` : ""}<span className="ml-1 text-faint">({tool.access === "read" ? "lecture" : "action"})</span></span>
|
|
||||||
<Origin>IdeA</Origin>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
{staleNativeTools.map((name) => (
|
|
||||||
<Tombstone key={`stale-native-tool:${name}`} label={name} origin="IdeA" disabled={busy} onRemove={() => void setNativeTool(name, false)} />
|
|
||||||
))}
|
|
||||||
{plugins.installed.flatMap((plugin) =>
|
|
||||||
(plugin.contributes.mcpServers ?? []).flatMap((server) =>
|
|
||||||
(server.tools ?? []).map((tool) => {
|
|
||||||
const ref = { pluginId: plugin.id, serverId: server.id, toolName: tool.name };
|
|
||||||
const assigned = assignedPluginTools.some((candidate) => pluginToolKey(candidate) === pluginToolKey(ref));
|
|
||||||
const enabled = enabledPluginIds.has(plugin.id);
|
|
||||||
return (
|
|
||||||
<label key={`plugin-tool:${pluginToolKey(ref)}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
|
|
||||||
<input type="checkbox" checked={assigned} disabled={busy || (!enabled && !assigned)} onChange={(event) => void setPluginTool(ref, event.target.checked)} />
|
|
||||||
<span className="min-w-0 flex-1"><span className="font-medium">{tool.name}</span>{tool.description ? ` — ${tool.description}` : ""}<span className="text-faint"> · {server.displayName}</span>{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
|
|
||||||
<Origin>Plugin: {plugin.displayName}</Origin>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
{advancedAssignedTools.map((ref) => {
|
|
||||||
const plugin = plugins.installed.find((candidate) => candidate.id === ref.pluginId);
|
|
||||||
const enabled = enabledPluginIds.has(ref.pluginId);
|
|
||||||
return (
|
|
||||||
<div key={`advanced:${pluginToolKey(ref)}`} className="flex items-center justify-between gap-2 rounded bg-raised px-3 py-2 text-xs">
|
|
||||||
<span><span className="font-medium">{ref.toolName}</span> · {ref.serverId}{!enabled && <span className="text-warning"> — Plugin désactivé</span>} <Origin>Plugin: {plugin?.displayName ?? ref.pluginId}</Origin></span>
|
|
||||||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void setPluginTool(ref, false)}>Retirer</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{stalePluginTools.map((ref) => (
|
|
||||||
<Tombstone key={`stale-tool:${pluginToolKey(ref)}`} label={`${ref.serverId} · ${ref.toolName}`} origin={`Plugin: ${ref.pluginId}`} disabled={busy} onRemove={() => void setPluginTool(ref, false)} />
|
|
||||||
))}
|
|
||||||
{advancedServers.length > 0 && (
|
|
||||||
<details>
|
|
||||||
<summary className="cursor-pointer text-xs font-medium text-primary">Ajout avancé d’un tool MCP</summary>
|
|
||||||
<div className="mt-2 flex items-end gap-2">
|
|
||||||
<SmallDropdown aria-label="serveur MCP sans catalogue statique" value={serverChoice} onChange={setServerChoice} disabled={busy} className="min-w-0 flex-1" size="md" options={[{ value: "", label: "— choisir un serveur MCP —" }, ...advancedServers.map((server) => ({ value: server.value, label: `${server.label}${server.enabled ? "" : " — désactivé"}`, disabled: !server.enabled }))]} />
|
|
||||||
<Input aria-label="nom avancé du tool MCP" value={toolName} onChange={(event) => setToolName(event.target.value)} placeholder="Nom exact du tool" disabled={busy} />
|
|
||||||
<Button variant="primary" disabled={busy || !serverChoice || !toolName.trim()} onClick={() => void assignAdvancedTool()}>Attribuer</Button>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,11 +4,11 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import {
|
import {
|
||||||
createMockGateways,
|
createMockGateways,
|
||||||
MockPermissionGateway,
|
|
||||||
MockPluginGateway,
|
MockPluginGateway,
|
||||||
} from "@/adapters/mock";
|
} from "@/adapters/mock";
|
||||||
import type { ProjectPluginManifest } from "@/domain";
|
import type { ProjectPluginManifest } from "@/domain";
|
||||||
import { AgentPluginCapabilities } from "./AgentPluginCapabilities";
|
import { SkillsPanel } from "@/features/skills";
|
||||||
|
import { McpPanel } from "@/features/permissions";
|
||||||
import { ProjectPluginsPanel } from "./ProjectPluginsPanel";
|
import { ProjectPluginsPanel } from "./ProjectPluginsPanel";
|
||||||
|
|
||||||
function manifest(): ProjectPluginManifest {
|
function manifest(): ProjectPluginManifest {
|
||||||
@ -87,11 +87,11 @@ describe("project plugin settings #282", () => {
|
|||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Capacités des agents" }));
|
fireEvent.click(await screen.findByRole("button", { name: "Gérer les skills" }));
|
||||||
expect(onAssignAgents).toHaveBeenCalledOnce();
|
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 gateways = gatewaysWithPlugin();
|
||||||
const agent = await gateways.agent.createAgent("project-1", {
|
const agent = await gateways.agent.createAgent("project-1", {
|
||||||
name: "Dev",
|
name: "Dev",
|
||||||
@ -108,91 +108,48 @@ describe("project plugin settings #282", () => {
|
|||||||
});
|
});
|
||||||
render(
|
render(
|
||||||
<DIProvider gateways={gateways}>
|
<DIProvider gateways={gateways}>
|
||||||
<AgentPluginCapabilities projectId="project-1" agent={agent} active />
|
<SkillsPanel projectId="project-1" />
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await screen.findByText("Capacités de l'agent — Dev")).toBeTruthy();
|
|
||||||
const skill = await screen.findByRole("checkbox", { name: /unity-debug/ });
|
const skill = await screen.findByRole("checkbox", { name: /unity-debug/ });
|
||||||
fireEvent.click(skill);
|
fireEvent.click(skill);
|
||||||
await waitFor(() => expect((skill as HTMLInputElement).checked).toBe(true));
|
await waitFor(() => expect((skill as HTMLInputElement).checked).toBe(true));
|
||||||
expect(screen.getByText(/removed-skill/).closest("div")?.textContent).toContain(
|
expect(screen.getByText(/removed-skill/).closest("div")?.textContent).toContain(
|
||||||
"Indisponible",
|
"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 settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
|
||||||
const assigned = settings.agents.find((entry) => entry.agentId === agent.id);
|
const assigned = settings.agents.find((entry) => entry.agentId === agent.id);
|
||||||
expect(assigned?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" });
|
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 gateways = gatewaysWithPlugin();
|
||||||
const agent = await gateways.agent.createAgent("project-1", {
|
const agent = await gateways.agent.createAgent("project-1", {
|
||||||
name: "Main",
|
name: "Main",
|
||||||
profileId: "profile-1",
|
profileId: "profile-1",
|
||||||
});
|
});
|
||||||
await gateways.skill.createSkill({
|
gateways.plugin._seedProjectPluginSettings("project-1", {
|
||||||
projectId: "project-1",
|
version: 1,
|
||||||
scope: "project",
|
plugins: [{ pluginId: "dev.acme.unity", enabled: true }],
|
||||||
name: "release-check",
|
agents: [{ agentId: agent.id, skills: [], tools: [] }],
|
||||||
description: "Vérifier une release.",
|
|
||||||
content: "# Release",
|
|
||||||
});
|
});
|
||||||
(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(
|
render(
|
||||||
<DIProvider gateways={gateways}>
|
<DIProvider gateways={gateways}>
|
||||||
<AgentPluginCapabilities projectId="project-1" agent={agent} active />
|
<McpPanel projectId="project-1" />
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const nativeSkill = await screen.findByRole("checkbox", { name: /release-check/ });
|
fireEvent.click(await screen.findByRole("button", { name: /Main/ }));
|
||||||
const nativeTool = screen.getByRole("checkbox", { name: /idea_ticket_update/ });
|
const staticTool = await screen.findByRole("checkbox", { name: /unity_build/ });
|
||||||
expect(screen.getAllByText("IdeA").length).toBeGreaterThanOrEqual(2);
|
fireEvent.click(staticTool);
|
||||||
fireEvent.click(nativeSkill);
|
await waitFor(() => expect((staticTool as HTMLInputElement).checked).toBe(true));
|
||||||
fireEvent.click(nativeTool);
|
expect(screen.queryByPlaceholderText("Nom exact du tool")).toBeNull();
|
||||||
|
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
|
||||||
await waitFor(() =>
|
expect(settings.agents.find((entry) => entry.agentId === agent.id)?.tools).toContainEqual({
|
||||||
expect(toggleSpy).toHaveBeenCalledWith(
|
pluginId: "dev.acme.unity", serverId: "unity-tools", toolName: "unity_build",
|
||||||
"project-1",
|
});
|
||||||
agent.id,
|
|
||||||
"idea_ticket_update",
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("status").textContent).toContain("Relance requise");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps disabled and missing plugin assignments removable", async () => {
|
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" }],
|
skills: [{ pluginId: "missing.plugin", skillId: "gone-skill" }],
|
||||||
tools: [
|
tools: [
|
||||||
{ pluginId: "dev.acme.unity", serverId: "unity-tools", toolName: "unity_build" },
|
{ 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" },
|
{ pluginId: "missing.plugin", serverId: "gone-server", toolName: "gone_tool" },
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
@ -216,16 +174,28 @@ describe("project plugin settings #282", () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<DIProvider gateways={gateways}>
|
<DIProvider gateways={gateways}>
|
||||||
<AgentPluginCapabilities projectId="project-1" agent={agent} active={false} />
|
<McpPanel projectId="project-1" />
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: /QA/ }));
|
||||||
const disabledAssigned = await screen.findByRole("checkbox", { name: /unity_build/ });
|
const disabledAssigned = await screen.findByRole("checkbox", { name: /unity_build/ });
|
||||||
expect((disabledAssigned as HTMLInputElement).checked).toBe(true);
|
expect((disabledAssigned as HTMLInputElement).checked).toBe(true);
|
||||||
expect((disabledAssigned as HTMLInputElement).disabled).toBe(false);
|
expect((disabledAssigned as HTMLInputElement).disabled).toBe(false);
|
||||||
fireEvent.click(disabledAssigned);
|
fireEvent.click(disabledAssigned);
|
||||||
await waitFor(() => expect((disabledAssigned as HTMLInputElement).checked).toBe(false));
|
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")!;
|
const stale = screen.getByText(/gone_tool/).closest("div")!;
|
||||||
fireEvent.click(stale.querySelector("button")!);
|
fireEvent.click(stale.querySelector("button")!);
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
|
|||||||
@ -37,12 +37,12 @@ export function ProjectPluginsPanel({
|
|||||||
<h2 className="text-sm font-semibold text-content">Plugins du projet</h2>
|
<h2 className="text-sm font-semibold text-content">Plugins du projet</h2>
|
||||||
<p className="mt-1 text-xs text-muted">
|
<p className="mt-1 text-xs text-muted">
|
||||||
Activez les plugins utilisés par ce projet. Les attributions se gèrent
|
Activez les plugins utilisés par ce projet. Les attributions se gèrent
|
||||||
depuis la fiche de chaque agent.
|
dans les panneaux Skills et MCP.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{onAssignAgents && (
|
{onAssignAgents && (
|
||||||
<Button size="sm" variant="ghost" onClick={onAssignAgents}>
|
<Button size="sm" variant="ghost" onClick={onAssignAgents}>
|
||||||
Capacités des agents
|
Gérer les skills
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
export { PluginRuntimeProvider, usePluginRuntime, type PluginRuntimeContextValue } from "./PluginRuntimeProvider";
|
export { PluginRuntimeProvider, usePluginRuntime, type PluginRuntimeContextValue } from "./PluginRuntimeProvider";
|
||||||
export { PluginsPanel } from "./PluginsPanel";
|
export { PluginsPanel } from "./PluginsPanel";
|
||||||
export { ProjectPluginsPanel } from "./ProjectPluginsPanel";
|
export { ProjectPluginsPanel } from "./ProjectPluginsPanel";
|
||||||
export { AgentPluginCapabilities } from "./AgentPluginCapabilities";
|
|
||||||
export { usePlugins, type PluginsViewModel } from "./usePlugins";
|
export { usePlugins, type PluginsViewModel } from "./usePlugins";
|
||||||
export { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus";
|
export { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus";
|
||||||
export { usePluginMenus, type UsePluginMenusResult } from "./usePluginMenus";
|
export { usePluginMenus, type UsePluginMenusResult } from "./usePluginMenus";
|
||||||
|
|||||||
@ -96,6 +96,7 @@ const PANEL_SIZE: Record<PanelId, FloatingWindowSize> = {
|
|||||||
agents: "lg",
|
agents: "lg",
|
||||||
templates: "lg",
|
templates: "lg",
|
||||||
skills: "lg",
|
skills: "lg",
|
||||||
|
mcp: "lg",
|
||||||
permissions: "md",
|
permissions: "md",
|
||||||
memory: "lg",
|
memory: "lg",
|
||||||
git: "lg",
|
git: "lg",
|
||||||
@ -549,6 +550,7 @@ export function ProjectsView() {
|
|||||||
"agents",
|
"agents",
|
||||||
"templates",
|
"templates",
|
||||||
"skills",
|
"skills",
|
||||||
|
"mcp",
|
||||||
"permissions",
|
"permissions",
|
||||||
"memory",
|
"memory",
|
||||||
"git",
|
"git",
|
||||||
@ -914,8 +916,8 @@ export function ProjectsView() {
|
|||||||
onSectionChange={setSettingsSection}
|
onSectionChange={setSettingsSection}
|
||||||
onAssignPluginCapabilities={() => {
|
onAssignPluginCapabilities={() => {
|
||||||
setSettingsSection(null);
|
setSettingsSection(null);
|
||||||
if (placementOf(placements, "agents") === "closed") {
|
if (placementOf(placements, "skills") === "closed") {
|
||||||
setPlacement("agents", "floating");
|
setPlacement("skills", "floating");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClose={() => setSettingsSection(null)}
|
onClose={() => setSettingsSection(null)}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import { TemplatesPanel } from "@/features/templates";
|
|||||||
import { SkillsPanel } from "@/features/skills";
|
import { SkillsPanel } from "@/features/skills";
|
||||||
import { MemoryPanel } from "@/features/memory";
|
import { MemoryPanel } from "@/features/memory";
|
||||||
import { EmbedderSettings } from "@/features/embedder";
|
import { EmbedderSettings } from "@/features/embedder";
|
||||||
import { PermissionsPanel } from "@/features/permissions";
|
import { McpPanel, PermissionsPanel } from "@/features/permissions";
|
||||||
import { ProjectWorkStatePanel } from "@/features/workstate";
|
import { ProjectWorkStatePanel } from "@/features/workstate";
|
||||||
import { TicketsView } from "@/features/tickets";
|
import { TicketsView } from "@/features/tickets";
|
||||||
import { GitPanel } from "@/features/git";
|
import { GitPanel } from "@/features/git";
|
||||||
@ -60,6 +60,8 @@ export function ViewPanelBody({
|
|||||||
return <TemplatesPanel projectId={projectId} />;
|
return <TemplatesPanel projectId={projectId} />;
|
||||||
case "skills":
|
case "skills":
|
||||||
return <SkillsPanel projectId={projectId} />;
|
return <SkillsPanel projectId={projectId} />;
|
||||||
|
case "mcp":
|
||||||
|
return <McpPanel projectId={projectId} />;
|
||||||
case "permissions":
|
case "permissions":
|
||||||
return <PermissionsPanel projectId={projectId} />;
|
return <PermissionsPanel projectId={projectId} />;
|
||||||
case "memory":
|
case "memory":
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export type PanelId =
|
|||||||
| "agents"
|
| "agents"
|
||||||
| "templates"
|
| "templates"
|
||||||
| "skills"
|
| "skills"
|
||||||
|
| "mcp"
|
||||||
| "permissions"
|
| "permissions"
|
||||||
| "memory"
|
| "memory"
|
||||||
| "git";
|
| "git";
|
||||||
@ -54,6 +55,7 @@ export const PANEL_TITLE: Record<PanelId, string> = {
|
|||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
templates: "Templates",
|
templates: "Templates",
|
||||||
skills: "Skills",
|
skills: "Skills",
|
||||||
|
mcp: "MCP",
|
||||||
permissions: "Permissions",
|
permissions: "Permissions",
|
||||||
memory: "Memory",
|
memory: "Memory",
|
||||||
git: "Git",
|
git: "Git",
|
||||||
|
|||||||
@ -9,15 +9,16 @@
|
|||||||
* - "New skill" button that opens {@link SkillEditor} in create-mode
|
* - "New skill" button that opens {@link SkillEditor} in create-mode
|
||||||
* - Per-skill "Edit" (opens the editor in edit-mode) and "Delete"
|
* - Per-skill "Edit" (opens the editor in edit-mode) and "Delete"
|
||||||
*
|
*
|
||||||
* Skills are assigned to agents from the `AgentsPanel`, not here — this panel is
|
* This is also the unified assignment surface for IdeA and plugin skills. A
|
||||||
* the authoring surface only ("no direct invoke": a skill is never run from the
|
* skill is never run from the UI; it is injected into an agent at activation.
|
||||||
* UI, only injected into an agent's convention file at activation).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { Skill, SkillScope } from "@/domain";
|
import { useGateways } from "@/app/di";
|
||||||
import { Button, Panel, Spinner } from "@/shared";
|
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 { SkillEditor } from "./SkillEditor";
|
||||||
import { useSkills } from "./useSkills";
|
import { useSkills } from "./useSkills";
|
||||||
|
|
||||||
@ -29,11 +30,101 @@ export interface SkillsPanelProps {
|
|||||||
/** Editor open-state: "create" or edit-mode for a specific skill. */
|
/** Editor open-state: "create" or edit-mode for a specific skill. */
|
||||||
type EditorState = { mode: "create" } | { mode: "edit"; skill: 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) {
|
export function SkillsPanel({ projectId }: SkillsPanelProps) {
|
||||||
const vm = useSkills(projectId);
|
const vm = useSkills(projectId);
|
||||||
|
const { agent: agentGateway, skill: skillGateway } = useGateways();
|
||||||
|
const plugins = useProjectPlugins(projectId);
|
||||||
|
|
||||||
const [editorState, setEditorState] = useState<EditorState | null>(null);
|
const [editorState, setEditorState] = useState<EditorState | null>(null);
|
||||||
const [editorBusy, setEditorBusy] = useState(false);
|
const [editorBusy, setEditorBusy] = useState(false);
|
||||||
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||||
|
const [assignmentBusy, setAssignmentBusy] = useState(false);
|
||||||
|
const [assignmentError, setAssignmentError] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const loadAgents = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const rows = await agentGateway.listAgents(projectId);
|
||||||
|
setAgents(rows);
|
||||||
|
setSelectedAgentId((current) =>
|
||||||
|
current && rows.some((agent) => agent.id === current)
|
||||||
|
? current
|
||||||
|
: (rows[0]?.id ?? null),
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
setAssignmentError(cause instanceof Error ? cause.message : String(cause));
|
||||||
|
}
|
||||||
|
}, [agentGateway, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAgents();
|
||||||
|
}, [loadAgents]);
|
||||||
|
|
||||||
|
const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
|
||||||
|
const nativeSkills = useMemo(
|
||||||
|
() => [...vm.projectSkills, ...vm.globalSkills],
|
||||||
|
[vm.globalSkills, vm.projectSkills],
|
||||||
|
);
|
||||||
|
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||||
|
const matches = (value: string) =>
|
||||||
|
!normalizedQuery || value.toLocaleLowerCase().includes(normalizedQuery);
|
||||||
|
const enabledPluginIds = new Set(
|
||||||
|
(plugins.settings?.plugins ?? [])
|
||||||
|
.filter((entry) => entry.enabled)
|
||||||
|
.map((entry) => entry.pluginId),
|
||||||
|
);
|
||||||
|
const pluginAssignments = plugins.settings?.agents.find(
|
||||||
|
(entry) => entry.agentId === selectedAgentId,
|
||||||
|
)?.skills ?? [];
|
||||||
|
const assignedPluginKeys = new Set(pluginAssignments.map(pluginSkillKey));
|
||||||
|
const knownPluginKeys = new Set(
|
||||||
|
plugins.installed.flatMap((plugin) =>
|
||||||
|
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const stalePluginSkills = pluginAssignments.filter(
|
||||||
|
(ref) => !knownPluginKeys.has(pluginSkillKey(ref)),
|
||||||
|
);
|
||||||
|
const assignedNativeKeys = new Set((selectedAgent?.skills ?? []).map(nativeSkillKey));
|
||||||
|
const knownNativeKeys = new Set(
|
||||||
|
nativeSkills.map((skill) => `${skill.scope}:${skill.id}`),
|
||||||
|
);
|
||||||
|
const staleNativeSkills = (selectedAgent?.skills ?? []).filter(
|
||||||
|
(ref) => !knownNativeKeys.has(nativeSkillKey(ref)),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function setNativeSkill(skill: Skill, assigned: boolean) {
|
||||||
|
if (!selectedAgent) return;
|
||||||
|
setAssignmentBusy(true);
|
||||||
|
setAssignmentError(null);
|
||||||
|
try {
|
||||||
|
if (assigned) {
|
||||||
|
await skillGateway.assignSkill(projectId, selectedAgent.id, skill.id, skill.scope);
|
||||||
|
} else {
|
||||||
|
await skillGateway.unassignSkill(projectId, selectedAgent.id, skill.id);
|
||||||
|
}
|
||||||
|
await loadAgents();
|
||||||
|
} catch (cause) {
|
||||||
|
setAssignmentError(cause instanceof Error ? cause.message : String(cause));
|
||||||
|
} finally {
|
||||||
|
setAssignmentBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeNativeSkill(ref: SkillRef) {
|
||||||
|
if (!selectedAgent) return;
|
||||||
|
setAssignmentBusy(true);
|
||||||
|
try {
|
||||||
|
await skillGateway.unassignSkill(projectId, selectedAgent.id, ref.skillId);
|
||||||
|
await loadAgents();
|
||||||
|
} finally {
|
||||||
|
setAssignmentBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave(name: string, content: string, scope: SkillScope) {
|
async function handleSave(name: string, content: string, scope: SkillScope) {
|
||||||
if (!editorState) return;
|
if (!editorState) return;
|
||||||
@ -105,15 +196,105 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Panel title="Skills" className="flex flex-col gap-0">
|
<Panel title="Skills" className="flex flex-col gap-0">
|
||||||
{vm.error && (
|
{(vm.error || plugins.error || assignmentError) && (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||||
>
|
>
|
||||||
{vm.error}
|
{vm.error ?? plugins.error ?? assignmentError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-col gap-3 border-b border-border p-4 md:flex-row">
|
||||||
|
<nav aria-label="cibles des skills" className="flex w-full shrink-0 flex-col gap-1.5 md:w-56">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">Agents</h4>
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">Aucun agent dans ce projet.</p>
|
||||||
|
) : agents.map((agent) => (
|
||||||
|
<button
|
||||||
|
key={agent.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selectedAgentId === agent.id}
|
||||||
|
onClick={() => setSelectedAgentId(agent.id)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border px-3 py-2 text-left text-sm font-medium transition-colors",
|
||||||
|
selectedAgentId === agent.id
|
||||||
|
? "border-primary bg-raised text-content"
|
||||||
|
: "border-border bg-surface text-muted hover:text-content",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{agent.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section className="min-w-0 flex-1" aria-label="attribution des skills">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-content">
|
||||||
|
{selectedAgent ? `Skills — ${selectedAgent.name}` : "Skills"}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted">Skills IdeA et plugins disponibles.</p>
|
||||||
|
</div>
|
||||||
|
{(assignmentBusy || plugins.busy || plugins.loading) && <Spinner size={14} />}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
aria-label="rechercher un skill"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Rechercher un skill"
|
||||||
|
disabled={!selectedAgent}
|
||||||
|
/>
|
||||||
|
{selectedAgent && (
|
||||||
|
<div className="mt-3 flex flex-col gap-2">
|
||||||
|
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">IdeA</h5>
|
||||||
|
{nativeSkills.filter((skill) => matches(`${skill.name} ${skill.description ?? ""}`)).map((skill) => {
|
||||||
|
const assigned = assignedNativeKeys.has(`${skill.scope}:${skill.id}`);
|
||||||
|
return (
|
||||||
|
<label key={`${skill.scope}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
|
||||||
|
<input type="checkbox" checked={assigned} disabled={assignmentBusy || vm.busy} onChange={(event) => void setNativeSkill(skill, event.target.checked)} />
|
||||||
|
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span>
|
||||||
|
<span className="text-faint">{skill.scope === "project" ? "Projet" : "Global"}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{staleNativeSkills.filter((ref) => matches(ref.skillId)).map((ref) => (
|
||||||
|
<div key={`missing:${nativeSkillKey(ref)}`} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
|
||||||
|
<span><span className="font-medium">{ref.skillId}</span> <span className="text-warning">— Indisponible</span> <span className="text-faint">IdeA</span></span>
|
||||||
|
<Button size="sm" variant="ghost" disabled={assignmentBusy} onClick={() => void removeNativeSkill(ref)}>Retirer</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<h5 className="mt-2 text-xs font-semibold uppercase tracking-wide text-faint">Plugins</h5>
|
||||||
|
{plugins.installed.flatMap((plugin) =>
|
||||||
|
(plugin.contributes.skills ?? [])
|
||||||
|
.filter((skill) => matches(`${skill.name} ${skill.description} ${plugin.displayName}`))
|
||||||
|
.map((skill) => {
|
||||||
|
const assigned = assignedPluginKeys.has(`${plugin.id}:${skill.id}`);
|
||||||
|
const enabled = enabledPluginIds.has(plugin.id);
|
||||||
|
return (
|
||||||
|
<label key={`${plugin.id}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
|
||||||
|
<input type="checkbox" checked={assigned} disabled={plugins.busy || (!enabled && !assigned)} onChange={(event) => void plugins.setSkillAssigned(selectedAgent.id, plugin.id, skill.id, event.target.checked)} />
|
||||||
|
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
|
||||||
|
<span className="text-faint">{plugin.displayName}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
{stalePluginSkills.filter((ref) => matches(`${ref.pluginId} ${ref.skillId}`)).map((ref) => (
|
||||||
|
<div key={`missing-plugin:${pluginSkillKey(ref)}`} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
|
||||||
|
<span><span className="font-medium">{ref.skillId}</span> <span className="text-warning">— Indisponible</span> <span className="text-faint">Plugin: {ref.pluginId}</span></span>
|
||||||
|
<Button size="sm" variant="ghost" disabled={plugins.busy} onClick={() => void plugins.setSkillAssigned(selectedAgent.id, ref.pluginId, ref.skillId, false)}>Retirer</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{nativeSkills.length === 0 && plugins.installed.every((plugin) => (plugin.contributes.skills ?? []).length === 0) && staleNativeSkills.length === 0 && stalePluginSkills.length === 0 && (
|
||||||
|
<p className="text-sm text-muted">Aucun skill disponible.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Create button ── */}
|
{/* ── Create button ── */}
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
* - createSkill (project + global) → skill appears under the right scope
|
* - createSkill (project + global) → skill appears under the right scope
|
||||||
* - updateSkill → content persisted in the gateway
|
* - updateSkill → content persisted in the gateway
|
||||||
* - deleteSkill → skill removed from the list
|
* - 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
|
* - guardrail "no direct invoke": SkillsPanel never offers a run/launch action
|
||||||
*
|
*
|
||||||
* Also includes MockSkillGateway unit tests.
|
* Also includes MockSkillGateway unit tests.
|
||||||
@ -17,22 +17,19 @@ import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
MockAgentGateway,
|
MockAgentGateway,
|
||||||
MockPermissionGateway,
|
|
||||||
MockPluginGateway,
|
MockPluginGateway,
|
||||||
MockProfileGateway,
|
|
||||||
MockSkillGateway,
|
MockSkillGateway,
|
||||||
} from "@/adapters/mock";
|
} from "@/adapters/mock";
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { SkillsPanel } from "./SkillsPanel";
|
import { SkillsPanel } from "./SkillsPanel";
|
||||||
import { AgentsPanel } from "@/features/agents/AgentsPanel";
|
|
||||||
|
|
||||||
const PROJECT_ID = "proj-skill-test";
|
const PROJECT_ID = "proj-skill-test";
|
||||||
|
|
||||||
function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) {
|
function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) {
|
||||||
const a = agent ?? new MockAgentGateway();
|
const a = agent ?? new MockAgentGateway();
|
||||||
const sk = skill ?? new MockSkillGateway(a);
|
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 {
|
return {
|
||||||
agent: a,
|
agent: a,
|
||||||
skill: sk,
|
skill: sk,
|
||||||
@ -44,22 +41,6 @@ function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAgentsPanel(agent: MockAgentGateway, skill: MockSkillGateway) {
|
|
||||||
const profile = new MockProfileGateway();
|
|
||||||
const gateways = {
|
|
||||||
agent,
|
|
||||||
profile,
|
|
||||||
skill,
|
|
||||||
permission: new MockPermissionGateway(),
|
|
||||||
plugin: new MockPluginGateway(),
|
|
||||||
} as unknown as Gateways;
|
|
||||||
return render(
|
|
||||||
<DIProvider gateways={gateways}>
|
|
||||||
<AgentsPanel projectId={PROJECT_ID} projectRoot="/tmp/proj" />
|
|
||||||
</DIProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForSkillsIdle() {
|
async function waitForSkillsIdle() {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("button", { name: "create skill" })).toBeTruthy();
|
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)", () => {
|
describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
|
||||||
@ -189,11 +170,7 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
|
|||||||
scope: "project",
|
scope: "project",
|
||||||
});
|
});
|
||||||
|
|
||||||
renderAgentsPanel(agent, skill);
|
renderSkillsPanel(agent, skill);
|
||||||
await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
|
|
||||||
|
|
||||||
// Select the agent
|
|
||||||
fireEvent.click(screen.getByText("Worker"));
|
|
||||||
const toggle = await screen.findByRole("checkbox", { name: /Deploy/ });
|
const toggle = await screen.findByRole("checkbox", { name: /Deploy/ });
|
||||||
const sk = (await skill.listSkills(PROJECT_ID, "project"))[0];
|
const sk = (await skill.listSkills(PROJECT_ID, "project"))[0];
|
||||||
fireEvent.click(toggle);
|
fireEvent.click(toggle);
|
||||||
@ -222,9 +199,7 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
|
|||||||
});
|
});
|
||||||
await skill.assignSkill(PROJECT_ID, a.id, sk.id, sk.scope);
|
await skill.assignSkill(PROJECT_ID, a.id, sk.id, sk.scope);
|
||||||
|
|
||||||
renderAgentsPanel(agent, skill);
|
renderSkillsPanel(agent, skill);
|
||||||
await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
|
|
||||||
fireEvent.click(screen.getByText("Worker2"));
|
|
||||||
|
|
||||||
const toggle = await screen.findByRole("checkbox", { name: /Cleanup/ });
|
const toggle = await screen.findByRole("checkbox", { name: /Cleanup/ });
|
||||||
expect((toggle as HTMLInputElement).checked).toBe(true);
|
expect((toggle as HTMLInputElement).checked).toBe(true);
|
||||||
|
|||||||
@ -35,7 +35,7 @@ import { isDockedTo, type ViewPlacement } from "@/features/projects/viewPlacemen
|
|||||||
import { AgentsPanel } from "@/features/agents";
|
import { AgentsPanel } from "@/features/agents";
|
||||||
import { TemplatesPanel } from "@/features/templates";
|
import { TemplatesPanel } from "@/features/templates";
|
||||||
import { SkillsPanel } from "@/features/skills";
|
import { SkillsPanel } from "@/features/skills";
|
||||||
import { PermissionsPanel } from "@/features/permissions";
|
import { McpPanel, PermissionsPanel } from "@/features/permissions";
|
||||||
import { MemoryPanel } from "@/features/memory";
|
import { MemoryPanel } from "@/features/memory";
|
||||||
import { EmbedderSettings } from "@/features/embedder";
|
import { EmbedderSettings } from "@/features/embedder";
|
||||||
import { GitGraphView, GitPanel } from "@/features/git";
|
import { GitGraphView, GitPanel } from "@/features/git";
|
||||||
@ -209,6 +209,7 @@ const WEB_PANEL_SIZE: Record<WebDesktopPanelId, FloatingWindowSize> = {
|
|||||||
agents: "lg",
|
agents: "lg",
|
||||||
templates: "lg",
|
templates: "lg",
|
||||||
skills: "lg",
|
skills: "lg",
|
||||||
|
mcp: "lg",
|
||||||
permissions: "md",
|
permissions: "md",
|
||||||
memory: "lg",
|
memory: "lg",
|
||||||
git: "lg",
|
git: "lg",
|
||||||
@ -336,6 +337,7 @@ function WebDesktopWorkspace({
|
|||||||
"agents",
|
"agents",
|
||||||
"templates",
|
"templates",
|
||||||
"skills",
|
"skills",
|
||||||
|
"mcp",
|
||||||
"permissions",
|
"permissions",
|
||||||
"memory",
|
"memory",
|
||||||
"git",
|
"git",
|
||||||
@ -877,6 +879,8 @@ export function WebProjectPanelBody({
|
|||||||
return <TemplatesPanel projectId={projectId} />;
|
return <TemplatesPanel projectId={projectId} />;
|
||||||
case "skills":
|
case "skills":
|
||||||
return <SkillsPanel projectId={projectId} />;
|
return <SkillsPanel projectId={projectId} />;
|
||||||
|
case "mcp":
|
||||||
|
return <McpPanel projectId={projectId} />;
|
||||||
case "permissions":
|
case "permissions":
|
||||||
return <PermissionsPanel projectId={projectId} />;
|
return <PermissionsPanel projectId={projectId} />;
|
||||||
case "memory":
|
case "memory":
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export type WebProjectPanelId =
|
|||||||
| "agents"
|
| "agents"
|
||||||
| "templates"
|
| "templates"
|
||||||
| "skills"
|
| "skills"
|
||||||
|
| "mcp"
|
||||||
| "permissions"
|
| "permissions"
|
||||||
| "memory"
|
| "memory"
|
||||||
| "git"
|
| "git"
|
||||||
@ -35,6 +36,7 @@ export const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
|||||||
"agents",
|
"agents",
|
||||||
"templates",
|
"templates",
|
||||||
"skills",
|
"skills",
|
||||||
|
"mcp",
|
||||||
"permissions",
|
"permissions",
|
||||||
"memory",
|
"memory",
|
||||||
"git",
|
"git",
|
||||||
@ -92,11 +94,18 @@ export const WEB_PANEL_META: Record<WebProjectPanelId, WebPanelMeta> = {
|
|||||||
description: "Skills assignés et éditables",
|
description: "Skills assignés et éditables",
|
||||||
placement: "secondary",
|
placement: "secondary",
|
||||||
},
|
},
|
||||||
|
mcp: {
|
||||||
|
id: "mcp",
|
||||||
|
label: "MCP",
|
||||||
|
shortLabel: "MCP",
|
||||||
|
description: "Tools MCP IdeA et plugins",
|
||||||
|
placement: "secondary",
|
||||||
|
},
|
||||||
permissions: {
|
permissions: {
|
||||||
id: "permissions",
|
id: "permissions",
|
||||||
label: "Permissions",
|
label: "Permissions",
|
||||||
shortLabel: "Perms",
|
shortLabel: "Perms",
|
||||||
description: "Permissions MCP et sandbox",
|
description: "Permissions système et sandbox",
|
||||||
placement: "secondary",
|
placement: "secondary",
|
||||||
},
|
},
|
||||||
memory: {
|
memory: {
|
||||||
|
|||||||
Reference in New Issue
Block a user