@@ -270,7 +285,11 @@ function PluginMcpToolsEditor({
placeholder={tr("Rechercher un tool ou un plugin", "Search for a tool or plugin")}
/>
{plugins.error &&
{plugins.error}
}
- {plugins.installed.map((plugin) => {
+ {plugins.installed
+ .filter((plugin, index, all) =>
+ all.findIndex((candidate) => candidate.id === plugin.id) === index,
+ )
+ .map((plugin) => {
const rows = (plugin.contributes.mcpServers ?? []).flatMap((server) =>
(server.tools ?? [])
.filter((tool) =>
@@ -280,9 +299,26 @@ function PluginMcpToolsEditor({
);
if (rows.length === 0) return null;
const enabled = enabledPluginIds.has(plugin.id);
+ const manuallyExpanded = pluginGroupExpanded(plugin.id, enabled);
+ const expanded = Boolean(normalizedQuery) || manuallyExpanded;
+ const controlsId = `plugin-mcp-tools-${agentId}-${plugin.id}`;
+ const contributionCount = (plugin.contributes.mcpServers ?? []).reduce(
+ (total, server) => total + (server.tools ?? []).length,
+ 0,
+ );
+ const countLabel = contributionCount === 1
+ ? tr("1 tool MCP", "1 MCP tool")
+ : tr(`${contributionCount} tools MCP`, `${contributionCount} MCP tools`);
return (
-
- {plugin.displayName}
+ togglePluginGroup(plugin.id, enabled)}
+ >
{rows.map(({ server, tool }) => {
const ref = { pluginId: plugin.id, serverId: server.id, toolName: tool.name };
const assigned = assignedKeys.has(pluginToolKey(ref));
@@ -298,7 +334,7 @@ function PluginMcpToolsEditor({
);
})}
-
+
);
})}
{tombstones.filter((ref) => matches(`${ref.pluginId} ${ref.serverId} ${ref.toolName}`)).map((ref) => (
diff --git a/frontend/src/features/plugins/PluginContributionGroup.tsx b/frontend/src/features/plugins/PluginContributionGroup.tsx
new file mode 100644
index 00000000..7e7d2fd6
--- /dev/null
+++ b/frontend/src/features/plugins/PluginContributionGroup.tsx
@@ -0,0 +1,68 @@
+import type { ReactNode } from "react";
+
+import { cn } from "@/shared";
+import { useI18n } from "@/i18n";
+
+interface PluginContributionGroupProps {
+ pluginName: string;
+ loaded: boolean;
+ countLabel: string;
+ expanded: boolean;
+ controlsId: string;
+ onToggle: () => void;
+ children: ReactNode;
+ className?: string;
+}
+
+export function PluginContributionGroup({
+ pluginName,
+ loaded,
+ countLabel,
+ expanded,
+ controlsId,
+ onToggle,
+ children,
+ className,
+}: PluginContributionGroupProps) {
+ const { tr } = useI18n();
+ const stateLabel = loaded ? tr("Chargé", "Loaded") : tr("Non chargé", "Not loaded");
+ const actionLabel = expanded ? tr("Replier", "Collapse") : tr("Déplier", "Expand");
+
+ return (
+
+
+
+ ▸
+
+
+ {pluginName}
+
+
+ {stateLabel}
+
+ {countLabel}
+
+
+ {children}
+
+
+ );
+}
diff --git a/frontend/src/features/plugins/ProjectPluginSettings.test.tsx b/frontend/src/features/plugins/ProjectPluginSettings.test.tsx
index 43c929c1..82b00a43 100644
--- a/frontend/src/features/plugins/ProjectPluginSettings.test.tsx
+++ b/frontend/src/features/plugins/ProjectPluginSettings.test.tsx
@@ -171,6 +171,70 @@ describe("project plugin settings #282", () => {
expect(assigned?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" });
});
+ it("collapses plugin skills independently and temporarily opens search matches", async () => {
+ const gateways = gatewaysWithPlugin();
+ const dormant = manifest();
+ dormant.id = "dev.acme.dormant";
+ dormant.displayName = "Dormant Plugin";
+ dormant.contributes.skills = [{
+ ...dormant.contributes.skills![0],
+ id: "dormant.audit",
+ name: "dormant-audit",
+ }];
+ gateways.plugin._seedProjectPluginManifest(dormant);
+ const agent = await gateways.agent.createAgent("project-1", {
+ name: "Dev",
+ profileId: "profile-1",
+ });
+ gateways.plugin._seedProjectPluginSettings("project-1", {
+ version: 1,
+ plugins: [
+ { pluginId: "dev.acme.unity", enabled: true },
+ { pluginId: "dev.acme.dormant", enabled: false },
+ ],
+ agents: [{
+ agentId: agent.id,
+ skills: [{ pluginId: "missing.plugin", skillId: "removed-skill" }],
+ tools: [],
+ }],
+ });
+
+ render(
+
+
+ ,
+ );
+
+ const loaded = await screen.findByRole("button", {
+ name: "Replier Unity Plugin · Chargé · 1 skill",
+ });
+ const dormantGroup = screen.getByRole("button", {
+ name: "Déplier Dormant Plugin · Non chargé · 1 skill",
+ });
+ expect(loaded.getAttribute("aria-expanded")).toBe("true");
+ expect(dormantGroup.getAttribute("aria-expanded")).toBe("false");
+ expect(screen.queryByRole("checkbox", { name: /dormant-audit/ })).toBeNull();
+ expect(screen.getByText(/removed-skill/)).toBeTruthy();
+
+ fireEvent.click(loaded);
+ expect(loaded.getAttribute("aria-expanded")).toBe("false");
+ fireEvent.change(screen.getByLabelText("Rechercher un skill"), {
+ target: { value: "unity-debug" },
+ });
+ expect(screen.getByRole("button", {
+ name: "Replier Unity Plugin · Chargé · 1 skill",
+ }).getAttribute("aria-expanded")).toBe("true");
+ expect(screen.getByRole("checkbox", { name: /unity-debug/ })).toBeTruthy();
+
+ fireEvent.change(screen.getByLabelText("Rechercher un skill"), {
+ target: { value: "" },
+ });
+ expect(screen.getByRole("button", {
+ name: "Déplier Unity Plugin · Chargé · 1 skill",
+ }).getAttribute("aria-expanded")).toBe("false");
+ expect(screen.getByText(/removed-skill/)).toBeTruthy();
+ });
+
it("shows stable refs without blocking assignment when skill names are ambiguous", async () => {
const gateways = gatewaysWithPlugin();
const duplicatedContribution = manifest();
@@ -272,6 +336,9 @@ describe("project plugin settings #282", () => {
);
fireEvent.click(await screen.findByRole("button", { name: /QA/ }));
+ fireEvent.click(await screen.findByRole("button", {
+ name: "Déplier Unity Plugin · Non chargé · 1 tool MCP",
+ }));
const disabledAssigned = await screen.findByRole("checkbox", { name: /unity_build/ });
expect((disabledAssigned as HTMLInputElement).checked).toBe(true);
expect((disabledAssigned as HTMLInputElement).disabled).toBe(false);
@@ -298,4 +365,42 @@ describe("project plugin settings #282", () => {
);
});
});
+
+ it("temporarily opens a closed plugin MCP group for search and restores it", async () => {
+ const gateways = gatewaysWithPlugin();
+ const agent = await gateways.agent.createAgent("project-1", {
+ name: "Search Agent",
+ profileId: "profile-1",
+ });
+ gateways.plugin._seedProjectPluginSettings("project-1", {
+ version: 1,
+ plugins: [{ pluginId: "dev.acme.unity", enabled: false }],
+ agents: [{ agentId: agent.id, skills: [], tools: [] }],
+ });
+
+ render(
+
+
+ ,
+ );
+ fireEvent.click(await screen.findByRole("button", { name: /Search Agent/ }));
+
+ expect(screen.getByRole("button", {
+ name: "Déplier Unity Plugin · Non chargé · 1 tool MCP",
+ }).getAttribute("aria-expanded")).toBe("false");
+ fireEvent.change(screen.getByLabelText("rechercher un tool MCP plugin"), {
+ target: { value: "unity_build" },
+ });
+ expect(screen.getByRole("button", {
+ name: "Replier Unity Plugin · Non chargé · 1 tool MCP",
+ }).getAttribute("aria-expanded")).toBe("true");
+ expect(screen.getByRole("checkbox", { name: /unity_build/ })).toBeTruthy();
+
+ fireEvent.change(screen.getByLabelText("rechercher un tool MCP plugin"), {
+ target: { value: "" },
+ });
+ expect(screen.getByRole("button", {
+ name: "Déplier Unity Plugin · Non chargé · 1 tool MCP",
+ }).getAttribute("aria-expanded")).toBe("false");
+ });
});
diff --git a/frontend/src/features/skills/SkillsPanel.tsx b/frontend/src/features/skills/SkillsPanel.tsx
index 860ad6f6..42fdb26d 100644
--- a/frontend/src/features/skills/SkillsPanel.tsx
+++ b/frontend/src/features/skills/SkillsPanel.tsx
@@ -19,6 +19,7 @@ import { useGateways } from "@/app/di";
import { onAgentRosterChanged } from "@/features/agents/agentRosterEvents";
import type { Agent, PluginSkillRef, Skill, SkillRef, SkillScope } from "@/domain";
import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
+import { PluginContributionGroup } from "@/features/plugins/PluginContributionGroup";
import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { useI18n } from "@/i18n";
import { SkillEditor } from "./SkillEditor";
@@ -51,6 +52,7 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
const [assignmentBusy, setAssignmentBusy] = useState(false);
const [assignmentError, setAssignmentError] = useState
(null);
const [query, setQuery] = useState("");
+ const [expandedPluginGroups, setExpandedPluginGroups] = useState>({});
const loadAgents = useCallback(async () => {
try {
@@ -135,6 +137,19 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
(ref) => !knownNativeKeys.has(nativeSkillKey(ref)),
);
+ function pluginGroupExpanded(pluginId: string, enabled: boolean): boolean {
+ const key = `${selectedAgentId ?? "none"}:${pluginId}`;
+ return expandedPluginGroups[key] ?? enabled;
+ }
+
+ function togglePluginGroup(pluginId: string, enabled: boolean) {
+ const key = `${selectedAgentId ?? "none"}:${pluginId}`;
+ setExpandedPluginGroups((current) => ({
+ ...current,
+ [key]: !(current[key] ?? enabled),
+ }));
+ }
+
async function setNativeSkill(skill: Skill, assigned: boolean) {
if (!selectedAgent) return;
setAssignmentBusy(true);
@@ -324,11 +339,38 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
{tr("Plugins", "Plugins")}
- {pluginSkillRows
- .filter(({ plugin, skill, reference }) => matches(`${skill.name} ${skill.description} ${plugin.displayName} ${reference}`))
- .map(({ plugin, skill, reference }) => {
+ {plugins.installed
+ .filter((plugin, index, all) =>
+ all.findIndex((candidate) => candidate.id === plugin.id) === index,
+ )
+ .map((plugin) => {
+ const allRows = pluginSkillRows.filter(
+ ({ plugin: rowPlugin }) => rowPlugin.id === plugin.id,
+ );
+ const rows = allRows.filter(({ skill, reference }) =>
+ matches(`${skill.name} ${skill.description} ${plugin.displayName} ${reference}`),
+ );
+ if (rows.length === 0) return null;
+ const enabled = enabledPluginIds.has(plugin.id);
+ const manuallyExpanded = pluginGroupExpanded(plugin.id, enabled);
+ const expanded = Boolean(normalizedQuery) || manuallyExpanded;
+ const controlsId = `plugin-skills-${selectedAgent.id}-${plugin.id}`;
+ const contributionCount = allRows.length;
+ const countLabel = contributionCount === 1
+ ? tr("1 skill", "1 skill")
+ : tr(`${contributionCount} skills`, `${contributionCount} skills`);
+ return (
+ togglePluginGroup(plugin.id, enabled)}
+ >
+ {rows.map(({ skill, reference }) => {
const assigned = assignedPluginKeys.has(`${plugin.id}:${skill.id}`);
- const enabled = enabledPluginIds.has(plugin.id);
return (
void plugins.setSkillAssigned(selectedAgent.id, plugin.id, skill.id, event.target.checked)} />
@@ -340,10 +382,12 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
)}
- {plugin.displayName}
);
- })}
+ })}
+
+ );
+ })}
{stalePluginSkills.filter((ref) => matches(`${ref.pluginId} ${ref.skillId}`)).map((ref) => (
{ref.skillId} — {tr("Indisponible", "Unavailable")} {tr("Plugin", "Plugin")}: {ref.pluginId}
diff --git a/frontend/src/shared/styles/theme.css b/frontend/src/shared/styles/theme.css
index 45886150..4813b604 100644
--- a/frontend/src/shared/styles/theme.css
+++ b/frontend/src/shared/styles/theme.css
@@ -156,6 +156,42 @@
}
}
+ .custom-agent-chat-composer-actions,
+ .custom-agent-chat-composer-action {
+ min-height: 2.75rem;
+ }
+
+ @container (max-width: 419.99px) {
+ .custom-agent-chat-composer {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .custom-agent-chat-composer-editor,
+ .custom-agent-chat-composer-actions {
+ width: 100%;
+ }
+
+ .custom-agent-chat-composer-action {
+ flex: 1 1 0;
+ }
+ }
+
+ @container (max-width: 279.99px) {
+ .custom-agent-chat-composer-actions {
+ flex-wrap: wrap;
+ }
+
+ .custom-agent-chat-composer-actions[data-busy="true"] {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .custom-agent-chat-composer-actions[data-busy="true"] .custom-agent-chat-composer-action {
+ width: 100%;
+ }
+ }
+
.project-cell-illustration-area {
container-type: size;
}