feat(skills,mcp): menus Skills et MCP unifies avec selection cochable des tools #297 (SkillsPanel devient la surface unique d'assignation cochable des skills IdeA + plugins — cible agent, recherche, groupes IdeA/Plugins, tombstones retirables, CRUD catalogue natif conserve, stores separes SkillGateway/PluginGateway; nouveau panneau MCP dedie McpPanel desktop+web — tools MCP IdeA via PermissionGateway et tools MCP plugins catalogues via PluginGateway groupes par plugin/serveur, selection cochable, fin de la saisie manuelle du nom exact, serveurs sans catalogue en tombstones retirables uniquement; retrait complet de la surface Capacites de la fiche agent et suppression d'AgentPluginCapabilities.tsx; PermissionsPanel reserve aux permissions systeme/sandbox, onglet MCP retire; raccourci Parametres > Plugins ouvre Skills avec renvois vers Skills/MCP; integration ProjectsView/ViewPanelBody/viewPlacement/WebWorkspace/webSurfaceCatalog; QA verte: tsc+vite build OK, 28+24 tests cibles OK, suite complete 1223 OK / 32 echecs sur 6 fichiers web hors perimetre classes non bloquants; scripts/ et sync tickets #296 hors perimetre; aucun push)
This commit is contained in:
@ -19,7 +19,6 @@ import { useEffect, useState } from "react";
|
||||
import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared";
|
||||
import { TerminalView } from "@/features/terminals/TerminalView";
|
||||
import { AnnouncementsPreview } from "@/features/announcements";
|
||||
import { AgentPluginCapabilities } from "@/features/plugins/AgentPluginCapabilities";
|
||||
import { useDrift } from "@/features/templates/useDrift";
|
||||
import { templateProfileLabel } from "@/features/templates/templatePresentation";
|
||||
import { useGateways } from "@/app/di";
|
||||
@ -624,20 +623,6 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedAgent && gateways.plugin && (
|
||||
<div className="border-t border-border p-4">
|
||||
<AgentPluginCapabilities
|
||||
projectId={projectId}
|
||||
agent={selectedAgent}
|
||||
active={
|
||||
activeAgentId === selectedAgent.id ||
|
||||
vm.liveAgents.some((live) => live.agentId === selectedAgent.id)
|
||||
}
|
||||
onAgentChanged={vm.refresh}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Context editor ── */}
|
||||
{selectedAgent && (
|
||||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||||
|
||||
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
|
||||
* override shows it inherits the project default with non-editable controls
|
||||
@ -14,11 +14,12 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockPermissionGateway,
|
||||
MockPluginGateway,
|
||||
MockProfileGateway,
|
||||
} from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { PermissionsPanel } from "./PermissionsPanel";
|
||||
import { McpPanel } from "./McpPanel";
|
||||
|
||||
const PROJECT_ID = "proj-mcp-permissions-test";
|
||||
|
||||
@ -32,12 +33,13 @@ async function renderPanel() {
|
||||
const gateways = {
|
||||
agent,
|
||||
permission,
|
||||
plugin: new MockPluginGateway(),
|
||||
profile: new MockProfileGateway(),
|
||||
} as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PermissionsPanel projectId={PROJECT_ID} />
|
||||
<McpPanel projectId={PROJECT_ID} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
@ -48,9 +50,7 @@ async function renderPanel() {
|
||||
return { agent, permission, agentId: created.id };
|
||||
}
|
||||
|
||||
/** Switches to the "Tools MCP IdeA" tab and waits past the doc load. */
|
||||
async function openMcpToolsTab() {
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Tools MCP IdeA" }));
|
||||
await screen.findByRole("navigation", { name: "cibles des permissions MCP" });
|
||||
}
|
||||
|
||||
@ -58,20 +58,16 @@ async function selectAgentTarget(name: string) {
|
||||
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
|
||||
fireEvent.click(within(nav).getByRole("button", { name: new RegExp(name) }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: `Tools MCP IdeA — ${name}` })).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: `MCP IdeA — ${name}` })).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
describe("PermissionsPanel — Tools MCP IdeA tab", () => {
|
||||
it("has an accessible tablist and defaults to Système", async () => {
|
||||
describe("McpPanel", () => {
|
||||
it("is exposed as a dedicated surface", async () => {
|
||||
await renderPanel();
|
||||
|
||||
const tablist = screen.getByRole("tablist", { name: "Permissions" });
|
||||
const tabs = within(tablist).getAllByRole("tab");
|
||||
expect(tabs.map((t) => t.textContent)).toEqual(["Système", "Tools MCP IdeA"]);
|
||||
expect(screen.getByRole("tab", { name: "Système" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
await openMcpToolsTab();
|
||||
expect(screen.getByRole("heading", { name: "MCP" })).toBeTruthy();
|
||||
expect(screen.queryByRole("tablist", { name: "Permissions" })).toBeNull();
|
||||
});
|
||||
|
||||
it("an agent without an override shows it inherits the project default, read-only", async () => {
|
||||
@ -131,11 +127,12 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => {
|
||||
const gateways = {
|
||||
agent,
|
||||
permission,
|
||||
plugin: new MockPluginGateway(),
|
||||
profile: new MockProfileGateway(),
|
||||
} as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PermissionsPanel projectId={PROJECT_ID} />
|
||||
<McpPanel projectId={PROJECT_ID} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
@ -262,7 +259,7 @@ describe("PermissionsPanel — Tools MCP IdeA tab", () => {
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
// Still on the agent editor, with the unsaved edit intact.
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Tools MCP IdeA — DevFrontend" }),
|
||||
screen.getByRole("heading", { name: "MCP IdeA — DevFrontend" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }) as HTMLInputElement)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* `McpToolPermissionsPanel` — the "Tools MCP IdeA" tab of the project
|
||||
* `Permissions` panel (ticket #82). Lets the user grant/revoke MCP tool
|
||||
* `McpToolPermissionsPanel` — the editor hosted by the dedicated `MCP` panel
|
||||
* (tickets #82/#297). Lets the user grant/revoke MCP tool
|
||||
* capabilities per agent (or project-wide default), grouped by domain rather
|
||||
* than as a flat 25-checkbox list (carnet #82 — Conception UX/F).
|
||||
*
|
||||
@ -15,8 +15,9 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { Agent, McpToolCatalogue } from "@/domain";
|
||||
import { Button, Panel, cn } from "@/shared";
|
||||
import type { Agent, McpToolCatalogue, PluginToolRef } from "@/domain";
|
||||
import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
|
||||
import { Button, Input, Panel, cn } from "@/shared";
|
||||
import {
|
||||
mcpToolGroupsForCatalogue,
|
||||
mcpToolLabel,
|
||||
@ -49,6 +50,7 @@ export function McpToolPermissionsPanel({
|
||||
agents,
|
||||
}: McpToolPermissionsPanelProps) {
|
||||
const vm = useMcpToolPermissions(projectId);
|
||||
const plugins = useProjectPlugins(projectId);
|
||||
const [target, setTarget] = useState<McpTarget>({ type: "project" });
|
||||
// Read imperatively (not via re-render) by `selectTarget` so an unsaved
|
||||
// draft in the currently-mounted editor blocks a target switch without
|
||||
@ -176,32 +178,138 @@ export function McpToolPermissionsPanel({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<McpToolEditor
|
||||
key={target.agentId}
|
||||
targetKind="agent"
|
||||
title={`Tools MCP IdeA — ${target.agentName}`}
|
||||
catalogue={doc.catalogue}
|
||||
storedAllowed={selectedAgentOverride?.policy.allowedTools ?? null}
|
||||
comparisonBaseline={
|
||||
doc.projectDefault?.allowedTools ?? doc.catalogue.readOnlyTools
|
||||
}
|
||||
busy={vm.busy}
|
||||
onDirtyChange={(dirty) => {
|
||||
dirtyRef.current = dirty;
|
||||
}}
|
||||
onSaveAgentOverride={async (allowed) => {
|
||||
await vm.saveAgentOverride(target.agentId, { allowedTools: allowed });
|
||||
}}
|
||||
onResetOverride={async () => {
|
||||
await vm.resetAgentOverride(target.agentId);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<McpToolEditor
|
||||
key={target.agentId}
|
||||
targetKind="agent"
|
||||
title={`MCP IdeA — ${target.agentName}`}
|
||||
catalogue={doc.catalogue}
|
||||
storedAllowed={selectedAgentOverride?.policy.allowedTools ?? null}
|
||||
comparisonBaseline={
|
||||
doc.projectDefault?.allowedTools ?? doc.catalogue.readOnlyTools
|
||||
}
|
||||
busy={vm.busy}
|
||||
onDirtyChange={(dirty) => {
|
||||
dirtyRef.current = dirty;
|
||||
}}
|
||||
onSaveAgentOverride={async (allowed) => {
|
||||
await vm.saveAgentOverride(target.agentId, { allowedTools: allowed });
|
||||
}}
|
||||
onResetOverride={async () => {
|
||||
await vm.resetAgentOverride(target.agentId);
|
||||
}}
|
||||
/>
|
||||
<PluginMcpToolsEditor
|
||||
agentId={target.agentId}
|
||||
agentName={target.agentName}
|
||||
plugins={plugins}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pluginToolKey = (ref: PluginToolRef) =>
|
||||
`${ref.pluginId}:${ref.serverId}:${ref.toolName}`;
|
||||
|
||||
function PluginMcpToolsEditor({
|
||||
agentId,
|
||||
agentName,
|
||||
plugins,
|
||||
}: {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
plugins: ReturnType<typeof useProjectPlugins>;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
const matches = (value: string) =>
|
||||
!normalizedQuery || value.toLocaleLowerCase().includes(normalizedQuery);
|
||||
const assignments = plugins.settings?.agents.find(
|
||||
(entry) => entry.agentId === agentId,
|
||||
)?.tools ?? [];
|
||||
const assignedKeys = new Set(assignments.map(pluginToolKey));
|
||||
const enabledPluginIds = new Set(
|
||||
(plugins.settings?.plugins ?? [])
|
||||
.filter((entry) => entry.enabled)
|
||||
.map((entry) => entry.pluginId),
|
||||
);
|
||||
const knownKeys = new Set(
|
||||
plugins.installed.flatMap((plugin) =>
|
||||
(plugin.contributes.mcpServers ?? []).flatMap((server) =>
|
||||
(server.tools ?? []).map(
|
||||
(tool) => `${plugin.id}:${server.id}:${tool.name}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const tombstones = assignments.filter((ref) => !knownKeys.has(pluginToolKey(ref)));
|
||||
const cataloguedCount = plugins.installed.reduce(
|
||||
(total, plugin) =>
|
||||
total + (plugin.contributes.mcpServers ?? []).reduce(
|
||||
(serverTotal, server) => serverTotal + (server.tools ?? []).length,
|
||||
0,
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel title={`MCP plugins — ${agentName}`} flush>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<Input
|
||||
aria-label="rechercher un tool MCP plugin"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Rechercher un tool ou un plugin"
|
||||
/>
|
||||
{plugins.error && <p role="alert" className="text-sm text-danger">{plugins.error}</p>}
|
||||
{plugins.installed.map((plugin) => {
|
||||
const rows = (plugin.contributes.mcpServers ?? []).flatMap((server) =>
|
||||
(server.tools ?? [])
|
||||
.filter((tool) =>
|
||||
matches(`${tool.name} ${tool.description ?? ""} ${server.displayName} ${plugin.displayName}`),
|
||||
)
|
||||
.map((tool) => ({ server, tool })),
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
const enabled = enabledPluginIds.has(plugin.id);
|
||||
return (
|
||||
<section key={plugin.id} className="flex flex-col gap-2" aria-label={`Plugin ${plugin.displayName}`}>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">{plugin.displayName}</h4>
|
||||
{rows.map(({ server, tool }) => {
|
||||
const ref = { pluginId: plugin.id, serverId: server.id, toolName: tool.name };
|
||||
const assigned = assignedKeys.has(pluginToolKey(ref));
|
||||
return (
|
||||
<label key={pluginToolKey(ref)} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={assigned}
|
||||
disabled={plugins.busy || (!enabled && !assigned)}
|
||||
onChange={(event) => void plugins.setToolAssigned({ agentId, ...ref }, event.target.checked)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1"><span className="font-medium">{tool.name}</span>{tool.description ? ` — ${tool.description}` : ""}<span className="text-faint"> · {server.displayName}</span>{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{tombstones.filter((ref) => matches(`${ref.pluginId} ${ref.serverId} ${ref.toolName}`)).map((ref) => (
|
||||
<div key={`missing:${pluginToolKey(ref)}`} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
|
||||
<span><span className="font-medium">{ref.toolName}</span> · {ref.serverId} <span className="text-warning">— Indisponible</span> <span className="text-faint">Plugin: {ref.pluginId}</span></span>
|
||||
<Button size="sm" variant="ghost" disabled={plugins.busy} onClick={() => void plugins.setToolAssigned({ agentId, ...ref }, false)}>Retirer</Button>
|
||||
</div>
|
||||
))}
|
||||
{!plugins.loading && cataloguedCount === 0 && tombstones.length === 0 && (
|
||||
<p className="text-sm text-muted">Aucun tool MCP plugin catalogué.</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
interface McpToolEditorProps {
|
||||
targetKind: "project" | "agent";
|
||||
title: string;
|
||||
|
||||
@ -8,7 +8,6 @@ import type {
|
||||
ResolvedAgentSystemPermissions,
|
||||
SystemPermissionSet,
|
||||
} from "@/domain";
|
||||
import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel";
|
||||
import {
|
||||
type CapabilityChoice,
|
||||
type PolicyDraft,
|
||||
@ -21,13 +20,6 @@ export interface PermissionsPanelProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
type PermissionsTab = "system" | "mcpTools";
|
||||
|
||||
const TABS: { id: PermissionsTab; label: string }[] = [
|
||||
{ id: "system", label: "Système" },
|
||||
{ id: "mcpTools", label: "Tools MCP IdeA" },
|
||||
];
|
||||
|
||||
type EditorTarget =
|
||||
| { type: "project" }
|
||||
| { type: "agent"; agentId: string; agentName: string };
|
||||
@ -66,7 +58,6 @@ function systemPermissionsFromDraft(network: NetworkPolicy | ""): SystemPermissi
|
||||
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
const vm = usePermissions(projectId);
|
||||
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
|
||||
const [tab, setTab] = useState<PermissionsTab>("system");
|
||||
|
||||
const selectedAgent = target.type === "agent"
|
||||
? vm.rows.find((row) => row.agent.id === target.agentId) ?? null
|
||||
@ -143,36 +134,6 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
flush
|
||||
>
|
||||
<div role="tablist" aria-label="Permissions" className="flex gap-1 border-b border-border px-4 pt-2">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`permissions-tab-${t.id}`}
|
||||
aria-selected={tab === t.id}
|
||||
aria-controls={`permissions-tabpanel-${t.id}`}
|
||||
tabIndex={tab === t.id ? 0 : -1}
|
||||
onClick={() => setTab(t.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
||||
e.preventDefault();
|
||||
const i = TABS.findIndex((x) => x.id === tab);
|
||||
const next = e.key === "ArrowRight" ? (i + 1) % TABS.length : (i - 1 + TABS.length) % TABS.length;
|
||||
setTab(TABS[next].id);
|
||||
}}
|
||||
className={cn(
|
||||
"min-h-[32px] rounded-t-md border-b-2 px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
tab === t.id
|
||||
? "border-primary text-content"
|
||||
: "border-transparent text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{vm.error && (
|
||||
<p
|
||||
role="alert"
|
||||
@ -183,10 +144,6 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
)}
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="permissions-tabpanel-system"
|
||||
aria-labelledby="permissions-tab-system"
|
||||
hidden={tab !== "system"}
|
||||
className="flex flex-col gap-4 p-4"
|
||||
>
|
||||
<PolicyCard
|
||||
@ -278,17 +235,6 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="permissions-tabpanel-mcpTools"
|
||||
aria-labelledby="permissions-tab-mcpTools"
|
||||
hidden={tab !== "mcpTools"}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{tab === "mcpTools" && (
|
||||
<McpToolPermissionsPanel projectId={projectId} agents={vm.agents} />
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1 +1,2 @@
|
||||
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 {
|
||||
createMockGateways,
|
||||
MockPermissionGateway,
|
||||
MockPluginGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { ProjectPluginManifest } from "@/domain";
|
||||
import { AgentPluginCapabilities } from "./AgentPluginCapabilities";
|
||||
import { SkillsPanel } from "@/features/skills";
|
||||
import { McpPanel } from "@/features/permissions";
|
||||
import { ProjectPluginsPanel } from "./ProjectPluginsPanel";
|
||||
|
||||
function manifest(): ProjectPluginManifest {
|
||||
@ -87,11 +87,11 @@ describe("project plugin settings #282", () => {
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Capacités des agents" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Gérer les skills" }));
|
||||
expect(onAssignAgents).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("assigns plugin skills and MCP tools to an agent and keeps stale references removable", async () => {
|
||||
it("assigns plugin skills from the unified Skills panel and keeps tombstones removable", async () => {
|
||||
const gateways = gatewaysWithPlugin();
|
||||
const agent = await gateways.agent.createAgent("project-1", {
|
||||
name: "Dev",
|
||||
@ -108,91 +108,48 @@ describe("project plugin settings #282", () => {
|
||||
});
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AgentPluginCapabilities projectId="project-1" agent={agent} active />
|
||||
<SkillsPanel projectId="project-1" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Capacités de l'agent — Dev")).toBeTruthy();
|
||||
const skill = await screen.findByRole("checkbox", { name: /unity-debug/ });
|
||||
fireEvent.click(skill);
|
||||
await waitFor(() => expect((skill as HTMLInputElement).checked).toBe(true));
|
||||
expect(screen.getByText(/removed-skill/).closest("div")?.textContent).toContain(
|
||||
"Indisponible",
|
||||
);
|
||||
|
||||
const staticTool = screen.getByRole("checkbox", { name: /unity_build/ });
|
||||
fireEvent.click(staticTool);
|
||||
await waitFor(() => expect((staticTool as HTMLInputElement).checked).toBe(true));
|
||||
|
||||
fireEvent.click(screen.getByText("Ajout avancé d’un tool MCP"));
|
||||
fireEvent.click(screen.getByLabelText("serveur MCP sans catalogue statique"));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Unity Plugin — Legacy Tools" }));
|
||||
fireEvent.change(screen.getByLabelText("nom avancé du tool MCP"), {
|
||||
target: { value: "legacy_ping" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Attribuer" }));
|
||||
expect(await screen.findByText(/legacy_ping/)).toBeTruthy();
|
||||
expect(screen.getByRole("status").textContent).toContain("Relance requise");
|
||||
|
||||
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
|
||||
const assigned = settings.agents.find((entry) => entry.agentId === agent.id);
|
||||
expect(assigned?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" });
|
||||
expect(assigned?.tools).toContainEqual({
|
||||
pluginId: "dev.acme.unity",
|
||||
serverId: "unity-tools",
|
||||
toolName: "unity_build",
|
||||
});
|
||||
expect(assigned?.tools).toContainEqual({
|
||||
pluginId: "dev.acme.unity",
|
||||
serverId: "legacy-tools",
|
||||
toolName: "legacy_ping",
|
||||
});
|
||||
});
|
||||
|
||||
it("aggregates native capabilities and uses the granular MCP tool toggle", async () => {
|
||||
it("lists catalogued plugin tools in MCP and removes free-form assignment", async () => {
|
||||
const gateways = gatewaysWithPlugin();
|
||||
const agent = await gateways.agent.createAgent("project-1", {
|
||||
name: "Main",
|
||||
profileId: "profile-1",
|
||||
});
|
||||
await gateways.skill.createSkill({
|
||||
projectId: "project-1",
|
||||
scope: "project",
|
||||
name: "release-check",
|
||||
description: "Vérifier une release.",
|
||||
content: "# Release",
|
||||
gateways.plugin._seedProjectPluginSettings("project-1", {
|
||||
version: 1,
|
||||
plugins: [{ pluginId: "dev.acme.unity", enabled: true }],
|
||||
agents: [{ agentId: agent.id, skills: [], tools: [] }],
|
||||
});
|
||||
(gateways.permission as MockPermissionGateway).setMcpToolCatalogue({
|
||||
readOnlyTools: ["idea_ticket_read"],
|
||||
writeActionTools: ["idea_ticket_update"],
|
||||
tools: [
|
||||
{ name: "idea_ticket_read", description: "Lire un ticket.", access: "read" },
|
||||
{ name: "idea_ticket_update", description: "Modifier un ticket.", access: "writeAction" },
|
||||
],
|
||||
});
|
||||
const toggleSpy = vi.spyOn(gateways.permission, "setAgentMcpToolAllowed");
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AgentPluginCapabilities projectId="project-1" agent={agent} active />
|
||||
<McpPanel projectId="project-1" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const nativeSkill = await screen.findByRole("checkbox", { name: /release-check/ });
|
||||
const nativeTool = screen.getByRole("checkbox", { name: /idea_ticket_update/ });
|
||||
expect(screen.getAllByText("IdeA").length).toBeGreaterThanOrEqual(2);
|
||||
fireEvent.click(nativeSkill);
|
||||
fireEvent.click(nativeTool);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toggleSpy).toHaveBeenCalledWith(
|
||||
"project-1",
|
||||
agent.id,
|
||||
"idea_ticket_update",
|
||||
true,
|
||||
),
|
||||
);
|
||||
expect(screen.getByRole("status").textContent).toContain("Relance requise");
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Main/ }));
|
||||
const staticTool = await screen.findByRole("checkbox", { name: /unity_build/ });
|
||||
fireEvent.click(staticTool);
|
||||
await waitFor(() => expect((staticTool as HTMLInputElement).checked).toBe(true));
|
||||
expect(screen.queryByPlaceholderText("Nom exact du tool")).toBeNull();
|
||||
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
|
||||
expect(settings.agents.find((entry) => entry.agentId === agent.id)?.tools).toContainEqual({
|
||||
pluginId: "dev.acme.unity", serverId: "unity-tools", toolName: "unity_build",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps disabled and missing plugin assignments removable", async () => {
|
||||
@ -209,6 +166,7 @@ describe("project plugin settings #282", () => {
|
||||
skills: [{ pluginId: "missing.plugin", skillId: "gone-skill" }],
|
||||
tools: [
|
||||
{ pluginId: "dev.acme.unity", serverId: "unity-tools", toolName: "unity_build" },
|
||||
{ pluginId: "dev.acme.unity", serverId: "legacy-tools", toolName: "legacy_ping" },
|
||||
{ pluginId: "missing.plugin", serverId: "gone-server", toolName: "gone_tool" },
|
||||
],
|
||||
}],
|
||||
@ -216,16 +174,28 @@ describe("project plugin settings #282", () => {
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AgentPluginCapabilities projectId="project-1" agent={agent} active={false} />
|
||||
<McpPanel projectId="project-1" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /QA/ }));
|
||||
const disabledAssigned = await screen.findByRole("checkbox", { name: /unity_build/ });
|
||||
expect((disabledAssigned as HTMLInputElement).checked).toBe(true);
|
||||
expect((disabledAssigned as HTMLInputElement).disabled).toBe(false);
|
||||
fireEvent.click(disabledAssigned);
|
||||
await waitFor(() => expect((disabledAssigned as HTMLInputElement).checked).toBe(false));
|
||||
|
||||
const runtimeOnly = screen.getByText(/legacy_ping/).closest("div")!;
|
||||
expect(runtimeOnly.textContent).toContain("Indisponible");
|
||||
expect(screen.queryByRole("checkbox", { name: /legacy_ping/ })).toBeNull();
|
||||
fireEvent.click(runtimeOnly.querySelector("button")!);
|
||||
await waitFor(async () => {
|
||||
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
|
||||
expect(settings.agents[0]?.tools).not.toContainEqual(
|
||||
expect.objectContaining({ toolName: "legacy_ping" }),
|
||||
);
|
||||
});
|
||||
|
||||
const stale = screen.getByText(/gone_tool/).closest("div")!;
|
||||
fireEvent.click(stale.querySelector("button")!);
|
||||
await waitFor(async () => {
|
||||
|
||||
@ -37,12 +37,12 @@ export function ProjectPluginsPanel({
|
||||
<h2 className="text-sm font-semibold text-content">Plugins du projet</h2>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Activez les plugins utilisés par ce projet. Les attributions se gèrent
|
||||
depuis la fiche de chaque agent.
|
||||
dans les panneaux Skills et MCP.
|
||||
</p>
|
||||
</div>
|
||||
{onAssignAgents && (
|
||||
<Button size="sm" variant="ghost" onClick={onAssignAgents}>
|
||||
Capacités des agents
|
||||
Gérer les skills
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
export { PluginRuntimeProvider, usePluginRuntime, type PluginRuntimeContextValue } from "./PluginRuntimeProvider";
|
||||
export { PluginsPanel } from "./PluginsPanel";
|
||||
export { ProjectPluginsPanel } from "./ProjectPluginsPanel";
|
||||
export { AgentPluginCapabilities } from "./AgentPluginCapabilities";
|
||||
export { usePlugins, type PluginsViewModel } from "./usePlugins";
|
||||
export { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus";
|
||||
export { usePluginMenus, type UsePluginMenusResult } from "./usePluginMenus";
|
||||
|
||||
@ -96,6 +96,7 @@ const PANEL_SIZE: Record<PanelId, FloatingWindowSize> = {
|
||||
agents: "lg",
|
||||
templates: "lg",
|
||||
skills: "lg",
|
||||
mcp: "lg",
|
||||
permissions: "md",
|
||||
memory: "lg",
|
||||
git: "lg",
|
||||
@ -549,6 +550,7 @@ export function ProjectsView() {
|
||||
"agents",
|
||||
"templates",
|
||||
"skills",
|
||||
"mcp",
|
||||
"permissions",
|
||||
"memory",
|
||||
"git",
|
||||
@ -914,8 +916,8 @@ export function ProjectsView() {
|
||||
onSectionChange={setSettingsSection}
|
||||
onAssignPluginCapabilities={() => {
|
||||
setSettingsSection(null);
|
||||
if (placementOf(placements, "agents") === "closed") {
|
||||
setPlacement("agents", "floating");
|
||||
if (placementOf(placements, "skills") === "closed") {
|
||||
setPlacement("skills", "floating");
|
||||
}
|
||||
}}
|
||||
onClose={() => setSettingsSection(null)}
|
||||
|
||||
@ -16,7 +16,7 @@ import { TemplatesPanel } from "@/features/templates";
|
||||
import { SkillsPanel } from "@/features/skills";
|
||||
import { MemoryPanel } from "@/features/memory";
|
||||
import { EmbedderSettings } from "@/features/embedder";
|
||||
import { PermissionsPanel } from "@/features/permissions";
|
||||
import { McpPanel, PermissionsPanel } from "@/features/permissions";
|
||||
import { ProjectWorkStatePanel } from "@/features/workstate";
|
||||
import { TicketsView } from "@/features/tickets";
|
||||
import { GitPanel } from "@/features/git";
|
||||
@ -60,6 +60,8 @@ export function ViewPanelBody({
|
||||
return <TemplatesPanel projectId={projectId} />;
|
||||
case "skills":
|
||||
return <SkillsPanel projectId={projectId} />;
|
||||
case "mcp":
|
||||
return <McpPanel projectId={projectId} />;
|
||||
case "permissions":
|
||||
return <PermissionsPanel projectId={projectId} />;
|
||||
case "memory":
|
||||
|
||||
@ -28,6 +28,7 @@ export type PanelId =
|
||||
| "agents"
|
||||
| "templates"
|
||||
| "skills"
|
||||
| "mcp"
|
||||
| "permissions"
|
||||
| "memory"
|
||||
| "git";
|
||||
@ -54,6 +55,7 @@ export const PANEL_TITLE: Record<PanelId, string> = {
|
||||
agents: "Agents",
|
||||
templates: "Templates",
|
||||
skills: "Skills",
|
||||
mcp: "MCP",
|
||||
permissions: "Permissions",
|
||||
memory: "Memory",
|
||||
git: "Git",
|
||||
|
||||
@ -9,15 +9,16 @@
|
||||
* - "New skill" button that opens {@link SkillEditor} in create-mode
|
||||
* - Per-skill "Edit" (opens the editor in edit-mode) and "Delete"
|
||||
*
|
||||
* Skills are assigned to agents from the `AgentsPanel`, not here — this panel is
|
||||
* the authoring surface only ("no direct invoke": a skill is never run from the
|
||||
* UI, only injected into an agent's convention file at activation).
|
||||
* This is also the unified assignment surface for IdeA and plugin skills. A
|
||||
* skill is never run from the UI; it is injected into an agent at activation.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { Skill, SkillScope } from "@/domain";
|
||||
import { Button, Panel, Spinner } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
import type { Agent, PluginSkillRef, Skill, SkillRef, SkillScope } from "@/domain";
|
||||
import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
|
||||
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
||||
import { SkillEditor } from "./SkillEditor";
|
||||
import { useSkills } from "./useSkills";
|
||||
|
||||
@ -29,11 +30,101 @@ export interface SkillsPanelProps {
|
||||
/** Editor open-state: "create" or edit-mode for a specific skill. */
|
||||
type EditorState = { mode: "create" } | { mode: "edit"; skill: Skill };
|
||||
|
||||
const nativeSkillKey = (ref: SkillRef) => `${ref.scope}:${ref.skillId}`;
|
||||
const pluginSkillKey = (ref: PluginSkillRef) => `${ref.pluginId}:${ref.skillId}`;
|
||||
|
||||
export function SkillsPanel({ projectId }: SkillsPanelProps) {
|
||||
const vm = useSkills(projectId);
|
||||
const { agent: agentGateway, skill: skillGateway } = useGateways();
|
||||
const plugins = useProjectPlugins(projectId);
|
||||
|
||||
const [editorState, setEditorState] = useState<EditorState | null>(null);
|
||||
const [editorBusy, setEditorBusy] = useState(false);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [assignmentBusy, setAssignmentBusy] = useState(false);
|
||||
const [assignmentError, setAssignmentError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const loadAgents = useCallback(async () => {
|
||||
try {
|
||||
const rows = await agentGateway.listAgents(projectId);
|
||||
setAgents(rows);
|
||||
setSelectedAgentId((current) =>
|
||||
current && rows.some((agent) => agent.id === current)
|
||||
? current
|
||||
: (rows[0]?.id ?? null),
|
||||
);
|
||||
} catch (cause) {
|
||||
setAssignmentError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
}, [agentGateway, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAgents();
|
||||
}, [loadAgents]);
|
||||
|
||||
const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
|
||||
const nativeSkills = useMemo(
|
||||
() => [...vm.projectSkills, ...vm.globalSkills],
|
||||
[vm.globalSkills, vm.projectSkills],
|
||||
);
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
const matches = (value: string) =>
|
||||
!normalizedQuery || value.toLocaleLowerCase().includes(normalizedQuery);
|
||||
const enabledPluginIds = new Set(
|
||||
(plugins.settings?.plugins ?? [])
|
||||
.filter((entry) => entry.enabled)
|
||||
.map((entry) => entry.pluginId),
|
||||
);
|
||||
const pluginAssignments = plugins.settings?.agents.find(
|
||||
(entry) => entry.agentId === selectedAgentId,
|
||||
)?.skills ?? [];
|
||||
const assignedPluginKeys = new Set(pluginAssignments.map(pluginSkillKey));
|
||||
const knownPluginKeys = new Set(
|
||||
plugins.installed.flatMap((plugin) =>
|
||||
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`),
|
||||
),
|
||||
);
|
||||
const stalePluginSkills = pluginAssignments.filter(
|
||||
(ref) => !knownPluginKeys.has(pluginSkillKey(ref)),
|
||||
);
|
||||
const assignedNativeKeys = new Set((selectedAgent?.skills ?? []).map(nativeSkillKey));
|
||||
const knownNativeKeys = new Set(
|
||||
nativeSkills.map((skill) => `${skill.scope}:${skill.id}`),
|
||||
);
|
||||
const staleNativeSkills = (selectedAgent?.skills ?? []).filter(
|
||||
(ref) => !knownNativeKeys.has(nativeSkillKey(ref)),
|
||||
);
|
||||
|
||||
async function setNativeSkill(skill: Skill, assigned: boolean) {
|
||||
if (!selectedAgent) return;
|
||||
setAssignmentBusy(true);
|
||||
setAssignmentError(null);
|
||||
try {
|
||||
if (assigned) {
|
||||
await skillGateway.assignSkill(projectId, selectedAgent.id, skill.id, skill.scope);
|
||||
} else {
|
||||
await skillGateway.unassignSkill(projectId, selectedAgent.id, skill.id);
|
||||
}
|
||||
await loadAgents();
|
||||
} catch (cause) {
|
||||
setAssignmentError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setAssignmentBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNativeSkill(ref: SkillRef) {
|
||||
if (!selectedAgent) return;
|
||||
setAssignmentBusy(true);
|
||||
try {
|
||||
await skillGateway.unassignSkill(projectId, selectedAgent.id, ref.skillId);
|
||||
await loadAgents();
|
||||
} finally {
|
||||
setAssignmentBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(name: string, content: string, scope: SkillScope) {
|
||||
if (!editorState) return;
|
||||
@ -105,15 +196,105 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
|
||||
)}
|
||||
|
||||
<Panel title="Skills" className="flex flex-col gap-0">
|
||||
{vm.error && (
|
||||
{(vm.error || plugins.error || assignmentError) && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
{vm.error ?? plugins.error ?? assignmentError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-3 border-b border-border p-4 md:flex-row">
|
||||
<nav aria-label="cibles des skills" className="flex w-full shrink-0 flex-col gap-1.5 md:w-56">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">Agents</h4>
|
||||
{agents.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun agent dans ce projet.</p>
|
||||
) : agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
aria-pressed={selectedAgentId === agent.id}
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-2 text-left text-sm font-medium transition-colors",
|
||||
selectedAgentId === agent.id
|
||||
? "border-primary bg-raised text-content"
|
||||
: "border-border bg-surface text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{agent.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className="min-w-0 flex-1" aria-label="attribution des skills">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-content">
|
||||
{selectedAgent ? `Skills — ${selectedAgent.name}` : "Skills"}
|
||||
</h4>
|
||||
<p className="text-xs text-muted">Skills IdeA et plugins disponibles.</p>
|
||||
</div>
|
||||
{(assignmentBusy || plugins.busy || plugins.loading) && <Spinner size={14} />}
|
||||
</div>
|
||||
<Input
|
||||
aria-label="rechercher un skill"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Rechercher un skill"
|
||||
disabled={!selectedAgent}
|
||||
/>
|
||||
{selectedAgent && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">IdeA</h5>
|
||||
{nativeSkills.filter((skill) => matches(`${skill.name} ${skill.description ?? ""}`)).map((skill) => {
|
||||
const assigned = assignedNativeKeys.has(`${skill.scope}:${skill.id}`);
|
||||
return (
|
||||
<label key={`${skill.scope}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
|
||||
<input type="checkbox" checked={assigned} disabled={assignmentBusy || vm.busy} onChange={(event) => void setNativeSkill(skill, event.target.checked)} />
|
||||
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span>
|
||||
<span className="text-faint">{skill.scope === "project" ? "Projet" : "Global"}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{staleNativeSkills.filter((ref) => matches(ref.skillId)).map((ref) => (
|
||||
<div key={`missing:${nativeSkillKey(ref)}`} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
|
||||
<span><span className="font-medium">{ref.skillId}</span> <span className="text-warning">— Indisponible</span> <span className="text-faint">IdeA</span></span>
|
||||
<Button size="sm" variant="ghost" disabled={assignmentBusy} onClick={() => void removeNativeSkill(ref)}>Retirer</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h5 className="mt-2 text-xs font-semibold uppercase tracking-wide text-faint">Plugins</h5>
|
||||
{plugins.installed.flatMap((plugin) =>
|
||||
(plugin.contributes.skills ?? [])
|
||||
.filter((skill) => matches(`${skill.name} ${skill.description} ${plugin.displayName}`))
|
||||
.map((skill) => {
|
||||
const assigned = assignedPluginKeys.has(`${plugin.id}:${skill.id}`);
|
||||
const enabled = enabledPluginIds.has(plugin.id);
|
||||
return (
|
||||
<label key={`${plugin.id}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
|
||||
<input type="checkbox" checked={assigned} disabled={plugins.busy || (!enabled && !assigned)} onChange={(event) => void plugins.setSkillAssigned(selectedAgent.id, plugin.id, skill.id, event.target.checked)} />
|
||||
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
|
||||
<span className="text-faint">{plugin.displayName}</span>
|
||||
</label>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
{stalePluginSkills.filter((ref) => matches(`${ref.pluginId} ${ref.skillId}`)).map((ref) => (
|
||||
<div key={`missing-plugin:${pluginSkillKey(ref)}`} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
|
||||
<span><span className="font-medium">{ref.skillId}</span> <span className="text-warning">— Indisponible</span> <span className="text-faint">Plugin: {ref.pluginId}</span></span>
|
||||
<Button size="sm" variant="ghost" disabled={plugins.busy} onClick={() => void plugins.setSkillAssigned(selectedAgent.id, ref.pluginId, ref.skillId, false)}>Retirer</Button>
|
||||
</div>
|
||||
))}
|
||||
{nativeSkills.length === 0 && plugins.installed.every((plugin) => (plugin.contributes.skills ?? []).length === 0) && staleNativeSkills.length === 0 && stalePluginSkills.length === 0 && (
|
||||
<p className="text-sm text-muted">Aucun skill disponible.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ── Create button ── */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
* - createSkill (project + global) → skill appears under the right scope
|
||||
* - updateSkill → content persisted in the gateway
|
||||
* - deleteSkill → skill removed from the list
|
||||
* - assignSkill / unassignSkill in AgentsPanel → reflected on the agent record
|
||||
* - assignSkill / unassignSkill in SkillsPanel → reflected on the agent record
|
||||
* - guardrail "no direct invoke": SkillsPanel never offers a run/launch action
|
||||
*
|
||||
* Also includes MockSkillGateway unit tests.
|
||||
@ -17,22 +17,19 @@ import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockPermissionGateway,
|
||||
MockPluginGateway,
|
||||
MockProfileGateway,
|
||||
MockSkillGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { SkillsPanel } from "./SkillsPanel";
|
||||
import { AgentsPanel } from "@/features/agents/AgentsPanel";
|
||||
|
||||
const PROJECT_ID = "proj-skill-test";
|
||||
|
||||
function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) {
|
||||
const a = agent ?? new MockAgentGateway();
|
||||
const sk = skill ?? new MockSkillGateway(a);
|
||||
const gateways = { agent: a, skill: sk } as unknown as Gateways;
|
||||
const gateways = { agent: a, skill: sk, plugin: new MockPluginGateway() } as unknown as Gateways;
|
||||
return {
|
||||
agent: a,
|
||||
skill: sk,
|
||||
@ -44,22 +41,6 @@ function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) {
|
||||
};
|
||||
}
|
||||
|
||||
function renderAgentsPanel(agent: MockAgentGateway, skill: MockSkillGateway) {
|
||||
const profile = new MockProfileGateway();
|
||||
const gateways = {
|
||||
agent,
|
||||
profile,
|
||||
skill,
|
||||
permission: new MockPermissionGateway(),
|
||||
plugin: new MockPluginGateway(),
|
||||
} as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AgentsPanel projectId={PROJECT_ID} projectRoot="/tmp/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForSkillsIdle() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "create skill" })).toBeTruthy();
|
||||
@ -171,7 +152,7 @@ describe("SkillsPanel (with MockSkillGateway)", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assignment integration (AgentsPanel)
|
||||
// Assignment integration (SkillsPanel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
|
||||
@ -189,11 +170,7 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
|
||||
scope: "project",
|
||||
});
|
||||
|
||||
renderAgentsPanel(agent, skill);
|
||||
await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
|
||||
|
||||
// Select the agent
|
||||
fireEvent.click(screen.getByText("Worker"));
|
||||
renderSkillsPanel(agent, skill);
|
||||
const toggle = await screen.findByRole("checkbox", { name: /Deploy/ });
|
||||
const sk = (await skill.listSkills(PROJECT_ID, "project"))[0];
|
||||
fireEvent.click(toggle);
|
||||
@ -222,9 +199,7 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
|
||||
});
|
||||
await skill.assignSkill(PROJECT_ID, a.id, sk.id, sk.scope);
|
||||
|
||||
renderAgentsPanel(agent, skill);
|
||||
await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
|
||||
fireEvent.click(screen.getByText("Worker2"));
|
||||
renderSkillsPanel(agent, skill);
|
||||
|
||||
const toggle = await screen.findByRole("checkbox", { name: /Cleanup/ });
|
||||
expect((toggle as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
@ -35,7 +35,7 @@ import { isDockedTo, type ViewPlacement } from "@/features/projects/viewPlacemen
|
||||
import { AgentsPanel } from "@/features/agents";
|
||||
import { TemplatesPanel } from "@/features/templates";
|
||||
import { SkillsPanel } from "@/features/skills";
|
||||
import { PermissionsPanel } from "@/features/permissions";
|
||||
import { McpPanel, PermissionsPanel } from "@/features/permissions";
|
||||
import { MemoryPanel } from "@/features/memory";
|
||||
import { EmbedderSettings } from "@/features/embedder";
|
||||
import { GitGraphView, GitPanel } from "@/features/git";
|
||||
@ -209,6 +209,7 @@ const WEB_PANEL_SIZE: Record<WebDesktopPanelId, FloatingWindowSize> = {
|
||||
agents: "lg",
|
||||
templates: "lg",
|
||||
skills: "lg",
|
||||
mcp: "lg",
|
||||
permissions: "md",
|
||||
memory: "lg",
|
||||
git: "lg",
|
||||
@ -336,6 +337,7 @@ function WebDesktopWorkspace({
|
||||
"agents",
|
||||
"templates",
|
||||
"skills",
|
||||
"mcp",
|
||||
"permissions",
|
||||
"memory",
|
||||
"git",
|
||||
@ -877,6 +879,8 @@ export function WebProjectPanelBody({
|
||||
return <TemplatesPanel projectId={projectId} />;
|
||||
case "skills":
|
||||
return <SkillsPanel projectId={projectId} />;
|
||||
case "mcp":
|
||||
return <McpPanel projectId={projectId} />;
|
||||
case "permissions":
|
||||
return <PermissionsPanel projectId={projectId} />;
|
||||
case "memory":
|
||||
|
||||
@ -8,6 +8,7 @@ export type WebProjectPanelId =
|
||||
| "agents"
|
||||
| "templates"
|
||||
| "skills"
|
||||
| "mcp"
|
||||
| "permissions"
|
||||
| "memory"
|
||||
| "git"
|
||||
@ -35,6 +36,7 @@ export const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
||||
"agents",
|
||||
"templates",
|
||||
"skills",
|
||||
"mcp",
|
||||
"permissions",
|
||||
"memory",
|
||||
"git",
|
||||
@ -92,11 +94,18 @@ export const WEB_PANEL_META: Record<WebProjectPanelId, WebPanelMeta> = {
|
||||
description: "Skills assignés et éditables",
|
||||
placement: "secondary",
|
||||
},
|
||||
mcp: {
|
||||
id: "mcp",
|
||||
label: "MCP",
|
||||
shortLabel: "MCP",
|
||||
description: "Tools MCP IdeA et plugins",
|
||||
placement: "secondary",
|
||||
},
|
||||
permissions: {
|
||||
id: "permissions",
|
||||
label: "Permissions",
|
||||
shortLabel: "Perms",
|
||||
description: "Permissions MCP et sandbox",
|
||||
description: "Permissions système et sandbox",
|
||||
placement: "secondary",
|
||||
},
|
||||
memory: {
|
||||
|
||||
Reference in New Issue
Block a user