feat(plugins): paramètres projet plugins + attribution skills/tools aux agents — #282 (manifeste accepte contributes.skills IdeA-native multi-harness; store .ideai/project-plugins.json v1: enable plugin par projet + assignments skills/tools par agent; refs + use cases settings/assign/unassign; resolver skills pour idea_skill_read et injection catalogue lancement agent; permissions MCP dynamiques projetées par harness Claude Code/Codex/OpenCode; tombstones uninstall, hot reload recalcule catalogue et marque assignments cassés; frontend: Settings projet Plugins, détail capabilities, attribution agent, catalogue permissions; bump pointeur IdeaSDK 6eb8db3; QA verte: cargo test -p application, project_plugin_settings 3/3, dto_plugins 2/2, structured_launch_d3 26/26, catalogue mcp 7/7, frontend 5 tests ProjectPluginSettings/plugin, npm build frontend)
This commit is contained in:
@ -19,6 +19,7 @@ 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";
|
||||
@ -737,6 +738,16 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedAgent && (
|
||||
<div className="border-t border-border p-4">
|
||||
<AgentPluginCapabilities
|
||||
projectId={projectId}
|
||||
agentId={selectedAgent.id}
|
||||
agentName={selectedAgent.name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Context editor ── */}
|
||||
{selectedAgent && (
|
||||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||||
|
||||
148
frontend/src/features/plugins/AgentPluginCapabilities.tsx
Normal file
148
frontend/src/features/plugins/AgentPluginCapabilities.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button, Input, Panel, SmallDropdown, Spinner } from "@/shared";
|
||||
import type { PluginSkillRef, PluginToolRef } from "@/domain";
|
||||
import { useProjectPlugins } from "./useProjectPlugins";
|
||||
|
||||
interface AgentPluginCapabilitiesProps {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
}
|
||||
|
||||
function skillKey(ref: PluginSkillRef): string {
|
||||
return `${ref.pluginId}:${ref.skillId}`;
|
||||
}
|
||||
|
||||
function toolKey(ref: PluginToolRef): string {
|
||||
return `${ref.pluginId}:${ref.serverId}:${ref.toolName}`;
|
||||
}
|
||||
|
||||
export function AgentPluginCapabilities({
|
||||
projectId,
|
||||
agentId,
|
||||
agentName,
|
||||
}: AgentPluginCapabilitiesProps) {
|
||||
const vm = useProjectPlugins(projectId);
|
||||
const [serverChoice, setServerChoice] = useState("");
|
||||
const [toolName, setToolName] = useState("");
|
||||
const assignments = vm.settings?.agents.find((entry) => entry.agentId === agentId);
|
||||
const assignedSkills = new Set((assignments?.skills ?? []).map(skillKey));
|
||||
const assignedTools = assignments?.tools ?? [];
|
||||
const enabledIds = new Set(
|
||||
(vm.settings?.plugins ?? []).filter((entry) => entry.enabled).map((entry) => entry.pluginId),
|
||||
);
|
||||
const enabledPlugins = vm.installed.filter((plugin) => enabledIds.has(plugin.id));
|
||||
const serverOptions = useMemo(
|
||||
() => enabledPlugins.flatMap((plugin) =>
|
||||
(plugin.contributes.mcpServers ?? []).map((server) => ({
|
||||
value: `${plugin.id}\u0000${server.id}`,
|
||||
label: `${plugin.displayName} — ${server.displayName}`,
|
||||
})),
|
||||
),
|
||||
[enabledPlugins],
|
||||
);
|
||||
|
||||
const knownSkillKeys = new Set(vm.installed.flatMap((plugin) =>
|
||||
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`),
|
||||
));
|
||||
const knownServerKeys = new Set(vm.installed.flatMap((plugin) =>
|
||||
(plugin.contributes.mcpServers ?? []).map((server) => `${plugin.id}:${server.id}`),
|
||||
));
|
||||
const staleSkills = (assignments?.skills ?? []).filter((ref) => !knownSkillKeys.has(skillKey(ref)));
|
||||
const staleTools = assignedTools.filter(
|
||||
(ref) => !knownServerKeys.has(`${ref.pluginId}:${ref.serverId}`),
|
||||
);
|
||||
|
||||
async function assignTool() {
|
||||
const [pluginId, serverId] = serverChoice.split("\u0000");
|
||||
const name = toolName.trim();
|
||||
if (!pluginId || !serverId || !name) return;
|
||||
await vm.setToolAssigned({ agentId, pluginId, serverId, toolName: name }, true);
|
||||
setToolName("");
|
||||
}
|
||||
|
||||
if (vm.loading && !vm.settings) {
|
||||
return <div className="flex items-center gap-2 text-xs text-muted"><Spinner size={12} /> Chargement des capabilities de plugins…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{vm.error && <p role="alert" className="text-xs text-danger">{vm.error}</p>}
|
||||
|
||||
<section aria-label={`Skills de plugins — ${agentName}`} className="flex flex-col gap-2">
|
||||
<h4 className="text-sm font-semibold text-content">Skills de plugins</h4>
|
||||
{enabledPlugins.every((plugin) => (plugin.contributes.skills ?? []).length === 0) ? (
|
||||
<p className="text-xs text-muted">Aucun skill fourni par les plugins activés pour ce projet.</p>
|
||||
) : (
|
||||
enabledPlugins.map((plugin) => {
|
||||
const skills = plugin.contributes.skills ?? [];
|
||||
if (skills.length === 0) return null;
|
||||
return (
|
||||
<Panel key={plugin.id} className="flex flex-col gap-2">
|
||||
<h5 className="text-xs font-semibold text-content">{plugin.displayName}</h5>
|
||||
{skills.map((skill) => {
|
||||
const assigned = assignedSkills.has(`${plugin.id}:${skill.id}`);
|
||||
return (
|
||||
<label key={skill.id} className="flex items-start gap-2 text-xs text-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={assigned}
|
||||
disabled={vm.busy}
|
||||
onChange={(event) => void vm.setSkillAssigned(
|
||||
agentId, plugin.id, skill.id, event.target.checked,
|
||||
)}
|
||||
/>
|
||||
<span><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</Panel>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{staleSkills.map((ref) => (
|
||||
<div key={skillKey(ref)} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
|
||||
<span>{ref.pluginId} · {ref.skillId} <span className="text-warning">— Obsolète ou manquant</span></span>
|
||||
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => void vm.setSkillAssigned(agentId, ref.pluginId, ref.skillId, false)}>Retirer</Button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section aria-label={`Tools MCP de plugins — ${agentName}`} className="flex flex-col gap-2">
|
||||
<h4 className="text-sm font-semibold text-content">Tools MCP de plugins</h4>
|
||||
{assignedTools.length === 0 ? (
|
||||
<p className="text-xs text-muted">Aucun tool MCP de plugin attribué à l’agent.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{assignedTools.map((ref) => {
|
||||
const stale = staleTools.some((candidate) => toolKey(candidate) === toolKey(ref));
|
||||
return (
|
||||
<li key={toolKey(ref)} className="flex items-center justify-between gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
|
||||
<span>{ref.pluginId} · {ref.serverId} · <span className="font-medium">{ref.toolName}</span>{stale && <span className="text-warning"> — Obsolète ou manquant</span>}</span>
|
||||
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => void vm.setToolAssigned({ agentId, ...ref }, false)}>Retirer</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{serverOptions.length > 0 && (
|
||||
<div className="flex items-end gap-2">
|
||||
<SmallDropdown
|
||||
aria-label="serveur MCP de plugin"
|
||||
value={serverChoice}
|
||||
onChange={setServerChoice}
|
||||
disabled={vm.busy}
|
||||
className="min-w-0 flex-1"
|
||||
size="md"
|
||||
options={[{ value: "", label: "— choisir un serveur MCP —" }, ...serverOptions]}
|
||||
/>
|
||||
<Input aria-label="nom du tool MCP" value={toolName} onChange={(event) => setToolName(event.target.value)} placeholder="Nom du tool" disabled={vm.busy} />
|
||||
<Button variant="primary" disabled={vm.busy || !serverChoice || !toolName.trim()} onClick={() => void assignTool()}>Attribuer</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -15,6 +15,7 @@ import { Button, Panel } from "@/shared";
|
||||
import { PluginConfirmDialog as ConfirmDialog } from "./PluginConfirmDialog";
|
||||
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
||||
import { usePlugins } from "./usePlugins";
|
||||
import { ProjectPluginsPanel } from "./ProjectPluginsPanel";
|
||||
|
||||
const STATE_LABEL: Record<PluginLifecycleState, string> = {
|
||||
enabled: "Activé",
|
||||
@ -35,7 +36,11 @@ interface InstallFlow {
|
||||
review: PluginReview;
|
||||
}
|
||||
|
||||
export function PluginsPanel() {
|
||||
export interface PluginsPanelProps {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function PluginsPanel({ projectId }: PluginsPanelProps = {}) {
|
||||
const vm = usePlugins();
|
||||
const pluginRuntime = usePluginRuntime();
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
@ -87,8 +92,19 @@ export function PluginsPanel() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{projectId ? (
|
||||
<ProjectPluginsPanel projectId={projectId} />
|
||||
) : (
|
||||
<Panel>
|
||||
<p className="text-sm text-muted">
|
||||
Ouvrez un projet pour configurer les plugins utilisés par ce projet.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-content">Plugins</h2>
|
||||
<h2 className="text-sm font-semibold text-content">Installation globale</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => void startInstallFromArchive()} disabled={vm.busy}>
|
||||
Installer depuis une archive…
|
||||
@ -103,6 +119,7 @@ export function PluginsPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(vm.error || installFlowError) && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
|
||||
105
frontend/src/features/plugins/ProjectPluginSettings.test.tsx
Normal file
105
frontend/src/features/plugins/ProjectPluginSettings.test.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { createMockGateways, MockPluginGateway } from "@/adapters/mock";
|
||||
import type { ProjectPluginManifest } from "@/domain";
|
||||
import { AgentPluginCapabilities } from "./AgentPluginCapabilities";
|
||||
import { ProjectPluginsPanel } from "./ProjectPluginsPanel";
|
||||
|
||||
function manifest(): ProjectPluginManifest {
|
||||
return {
|
||||
ideaPluginManifestVersion: 1,
|
||||
id: "dev.acme.unity",
|
||||
displayName: "Unity Plugin",
|
||||
publisher: "Acme",
|
||||
version: "1.2.3",
|
||||
main: "dist/index.js",
|
||||
trustLevel: "full",
|
||||
capabilities: ["ui", "mcp"],
|
||||
activationScope: "app",
|
||||
contributes: {
|
||||
skills: [{
|
||||
id: "unity.debug",
|
||||
name: "unity-debug",
|
||||
description: "Diagnostiquer les builds Unity.",
|
||||
kind: "workflow",
|
||||
path: "skills/unity-debug/SKILL.md",
|
||||
}],
|
||||
menus: [],
|
||||
menuItems: [],
|
||||
layouts: [],
|
||||
mcpServers: [{ id: "unity-tools", displayName: "Unity Tools", transport: "stdio" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gatewaysWithPlugin() {
|
||||
const plugin = new MockPluginGateway();
|
||||
plugin._seedProjectPluginManifest(manifest());
|
||||
return { ...createMockGateways(), plugin };
|
||||
}
|
||||
|
||||
describe("project plugin settings #282", () => {
|
||||
it("lists globally installed plugins, details their capabilities and toggles project activation", async () => {
|
||||
const gateways = gatewaysWithPlugin();
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectPluginsPanel projectId="project-1" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Unity Plugin")).toBeTruthy();
|
||||
expect(screen.getByText("Installé globalement")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Voir les capabilities"));
|
||||
expect(screen.getByText("unity-debug")).toBeTruthy();
|
||||
expect(screen.getByText("Unity Tools")).toBeTruthy();
|
||||
|
||||
const toggle = screen.getByRole("checkbox", { name: "Activé pour ce projet" });
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect((toggle as HTMLInputElement).checked).toBe(true));
|
||||
expect((await gateways.plugin.listProjectSettings("project-1")).settings.plugins).toEqual([
|
||||
{ pluginId: "dev.acme.unity", enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("assigns plugin skills and MCP tools to an agent and keeps stale references removable", async () => {
|
||||
const gateways = gatewaysWithPlugin();
|
||||
gateways.plugin._seedProjectPluginSettings("project-1", {
|
||||
version: 1,
|
||||
plugins: [{ pluginId: "dev.acme.unity", enabled: true }],
|
||||
agents: [{
|
||||
agentId: "agent-1",
|
||||
skills: [{ pluginId: "missing.plugin", skillId: "removed-skill" }],
|
||||
tools: [],
|
||||
}],
|
||||
});
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AgentPluginCapabilities projectId="project-1" agentId="agent-1" agentName="Dev" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
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/).textContent).toContain("Obsolète ou manquant");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("serveur MCP de plugin"));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Unity Plugin — Unity Tools" }));
|
||||
fireEvent.change(screen.getByLabelText("nom du tool MCP"), {
|
||||
target: { value: "unity_build" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Attribuer" }));
|
||||
expect(await screen.findByText("unity_build")).toBeTruthy();
|
||||
|
||||
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
|
||||
const agent = settings.agents.find((entry) => entry.agentId === "agent-1");
|
||||
expect(agent?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" });
|
||||
expect(agent?.tools).toContainEqual({
|
||||
pluginId: "dev.acme.unity",
|
||||
serverId: "unity-tools",
|
||||
toolName: "unity_build",
|
||||
});
|
||||
});
|
||||
});
|
||||
143
frontend/src/features/plugins/ProjectPluginsPanel.tsx
Normal file
143
frontend/src/features/plugins/ProjectPluginsPanel.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { Panel, Spinner } from "@/shared";
|
||||
import { useProjectPlugins } from "./useProjectPlugins";
|
||||
|
||||
export interface ProjectPluginsPanelProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function countLabel(count: number, singular: string, plural: string): string {
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
export function ProjectPluginsPanel({ projectId }: ProjectPluginsPanelProps) {
|
||||
const vm = useProjectPlugins(projectId);
|
||||
|
||||
if (vm.loading && !vm.settings) {
|
||||
return (
|
||||
<Panel aria-label="plugins du projet" className="flex items-center gap-2">
|
||||
<Spinner size={14} />
|
||||
<span className="text-sm text-muted">Chargement des plugins du projet…</span>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
const installedIds = new Set(vm.installed.map((plugin) => plugin.id));
|
||||
const missing = (vm.settings?.plugins ?? []).filter(
|
||||
(activation) => !installedIds.has(activation.pluginId),
|
||||
);
|
||||
|
||||
return (
|
||||
<section aria-label="plugins du projet" className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-content">Plugins du projet</h2>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Un plugin reste installé globalement. Activez-le ici avant d’attribuer ses
|
||||
capabilities aux agents de ce projet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{vm.error && <p role="alert" className="text-sm text-danger">{vm.error}</p>}
|
||||
|
||||
{vm.installed.length === 0 && missing.length === 0 ? (
|
||||
<Panel><p className="text-sm text-muted">Aucun plugin installé globalement.</p></Panel>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{vm.installed.map((plugin) => {
|
||||
const enabled = vm.settings?.plugins.some(
|
||||
(entry) => entry.pluginId === plugin.id && entry.enabled,
|
||||
) ?? false;
|
||||
const skills = plugin.contributes.skills ?? [];
|
||||
const servers = plugin.contributes.mcpServers ?? [];
|
||||
return (
|
||||
<li key={plugin.id}>
|
||||
<Panel className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">{plugin.displayName}</span>
|
||||
<span className="text-xs text-faint">v{plugin.version}</span>
|
||||
<span className="rounded bg-raised px-1.5 py-0.5 text-[0.65rem] text-muted">
|
||||
Installé globalement
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{countLabel(skills.length, "skill", "skills")} · {countLabel(servers.length, "serveur MCP", "serveurs MCP")}
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex shrink-0 items-center gap-2 text-xs text-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={vm.busy}
|
||||
onChange={(event) => void vm.setEnabled(plugin.id, event.target.checked)}
|
||||
/>
|
||||
Activé pour ce projet
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs font-medium text-primary">
|
||||
Voir les capabilities
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-content">Skills fournis</h3>
|
||||
{skills.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-muted">Aucun skill fourni.</p>
|
||||
) : (
|
||||
<ul className="mt-1 flex flex-col gap-1">
|
||||
{skills.map((skill) => (
|
||||
<li key={skill.id} className="text-xs text-muted">
|
||||
<span className="font-medium text-content">{skill.name}</span>
|
||||
{skill.description ? ` — ${skill.description}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-content">Tools MCP fournis</h3>
|
||||
{servers.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-muted">Aucun serveur MCP fourni.</p>
|
||||
) : (
|
||||
<ul className="mt-1 flex flex-col gap-1">
|
||||
{servers.map((server) => (
|
||||
<li key={server.id} className="text-xs text-muted">
|
||||
<span className="font-medium text-content">{server.displayName}</span>
|
||||
<span className="text-faint"> · {server.id}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</Panel>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{missing.map((activation) => (
|
||||
<li key={activation.pluginId}>
|
||||
<Panel className="flex items-center justify-between gap-4 border-warning/40">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-content">{activation.pluginId}</span>
|
||||
<p className="text-xs text-warning">Plugin manquant · configuration conservée</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activation.enabled}
|
||||
disabled={vm.busy || !activation.enabled}
|
||||
onChange={(event) => void vm.setEnabled(activation.pluginId, event.target.checked)}
|
||||
/>
|
||||
Activé pour ce projet
|
||||
</label>
|
||||
</Panel>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
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";
|
||||
|
||||
105
frontend/src/features/plugins/useProjectPlugins.ts
Normal file
105
frontend/src/features/plugins/useProjectPlugins.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useGateways } from "@/app/di";
|
||||
import type {
|
||||
GatewayError,
|
||||
ProjectPluginManifest,
|
||||
ProjectPluginSettings,
|
||||
} from "@/domain";
|
||||
import type { MutateAgentPluginToolInput } from "@/ports";
|
||||
|
||||
function describe(error: unknown): string {
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
return String((error as GatewayError).message);
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export interface ProjectPluginsViewModel {
|
||||
installed: ProjectPluginManifest[];
|
||||
settings: ProjectPluginSettings | null;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
refresh(): Promise<void>;
|
||||
setEnabled(pluginId: string, enabled: boolean): Promise<void>;
|
||||
setSkillAssigned(agentId: string, pluginId: string, skillId: string, assigned: boolean): Promise<void>;
|
||||
setToolAssigned(input: Omit<MutateAgentPluginToolInput, "projectId">, assigned: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
export function useProjectPlugins(projectId: string): ProjectPluginsViewModel {
|
||||
const { plugin } = useGateways();
|
||||
const [installed, setInstalled] = useState<ProjectPluginManifest[]>([]);
|
||||
const [settings, setSettings] = useState<ProjectPluginSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const view = await plugin.listProjectSettings(projectId);
|
||||
setInstalled(view.installed);
|
||||
setSettings(view.settings);
|
||||
} catch (cause) {
|
||||
setError(describe(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [plugin, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const mutate = useCallback(async (operation: () => Promise<ProjectPluginSettings>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setSettings(await operation());
|
||||
} catch (cause) {
|
||||
setError(describe(cause));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setEnabled = useCallback(
|
||||
(pluginId: string, enabled: boolean) =>
|
||||
mutate(() => plugin.setProjectEnabled({ projectId, pluginId, enabled })),
|
||||
[mutate, plugin, projectId],
|
||||
);
|
||||
|
||||
const setSkillAssigned = useCallback(
|
||||
(agentId: string, pluginId: string, skillId: string, assigned: boolean) => {
|
||||
const input = { projectId, agentId, pluginId, skillId };
|
||||
return mutate(() =>
|
||||
assigned ? plugin.assignAgentSkill(input) : plugin.unassignAgentSkill(input),
|
||||
);
|
||||
},
|
||||
[mutate, plugin, projectId],
|
||||
);
|
||||
|
||||
const setToolAssigned = useCallback(
|
||||
(input: Omit<MutateAgentPluginToolInput, "projectId">, assigned: boolean) => {
|
||||
const scoped = { projectId, ...input };
|
||||
return mutate(() =>
|
||||
assigned ? plugin.assignAgentTool(scoped) : plugin.unassignAgentTool(scoped),
|
||||
);
|
||||
},
|
||||
[mutate, plugin, projectId],
|
||||
);
|
||||
|
||||
return {
|
||||
installed,
|
||||
settings,
|
||||
loading,
|
||||
busy,
|
||||
error,
|
||||
refresh,
|
||||
setEnabled,
|
||||
setSkillAssigned,
|
||||
setToolAssigned,
|
||||
};
|
||||
}
|
||||
@ -908,6 +908,7 @@ export function ProjectsView() {
|
||||
// while the menu bar above stays visible.
|
||||
<SettingsView
|
||||
section={settingsSection}
|
||||
projectId={active?.id}
|
||||
onSectionChange={setSettingsSection}
|
||||
onClose={() => setSettingsSection(null)}
|
||||
/>
|
||||
|
||||
@ -48,12 +48,14 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
|
||||
interface SettingsViewProps {
|
||||
section: SettingsSection;
|
||||
projectId?: string;
|
||||
onSectionChange: (section: SettingsSection) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SettingsView({
|
||||
section,
|
||||
projectId,
|
||||
onSectionChange,
|
||||
onClose,
|
||||
}: SettingsViewProps) {
|
||||
@ -97,7 +99,7 @@ export function SettingsView({
|
||||
) : section === "deployment" ? (
|
||||
<DeploymentSettings />
|
||||
) : section === "plugins" ? (
|
||||
<PluginsPanel />
|
||||
<PluginsPanel projectId={projectId} />
|
||||
) : (
|
||||
// No `onSessionEnded`: the desktop app hosts the server and is never
|
||||
// itself a paired device, so it cannot revoke its own session.
|
||||
|
||||
Reference in New Issue
Block a user