merge(frontend): feature #367 #368 — groupes repliables par plugin et bouton Joindre harmonisé (QA verte)
This commit is contained in:
@ -212,6 +212,7 @@ describe("CustomAgentChatView", () => {
|
||||
{ width: 240, mode: "full-width" },
|
||||
])("keeps its conversation access clear of cell controls at $width px ($mode)", ({ width, mode }) => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
|
||||
sendAgentChat: vi.fn(),
|
||||
cancelAgentChat: vi.fn(),
|
||||
@ -263,6 +264,104 @@ describe("CustomAgentChatView", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ width: 600, layout: "inline" },
|
||||
{ width: 360, layout: "equal-width-row" },
|
||||
{ width: 240, layout: "narrow-wrap" },
|
||||
])("keeps composer actions usable and visually aligned at $width px ($layout)", async ({ width, layout }) => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
|
||||
sendAgentChat: vi.fn(),
|
||||
cancelAgentChat: vi.fn(),
|
||||
closeAgentChat: vi.fn(),
|
||||
};
|
||||
|
||||
render(
|
||||
<div data-testid="composer-cell" style={{ width, height: 640 }}>
|
||||
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId="chat-1"
|
||||
conversationId="conversation-1"
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const attach = screen.getByRole("button", { name: "Joindre un fichier" });
|
||||
const send = screen.getByRole("button", { name: "Envoyer" });
|
||||
const actions = attach.parentElement!;
|
||||
const composer = actions.parentElement!;
|
||||
expect(screen.getByTestId("composer-cell").style.width).toBe(`${width}px`);
|
||||
expect(attach.textContent).toBe("Joindre");
|
||||
expect(attach.className).toContain("min-h-11");
|
||||
expect(attach.className).toContain("border-border");
|
||||
expect(send.className).toContain("min-h-11");
|
||||
expect(send.className).toContain("bg-primary");
|
||||
await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(false));
|
||||
expect((send as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(actions.className).toContain("custom-agent-chat-composer-actions");
|
||||
expect(composer.className).toContain("custom-agent-chat-composer");
|
||||
|
||||
if (layout === "equal-width-row") {
|
||||
expect(themeCss).toContain(".custom-agent-chat-composer-action {\n flex: 1 1 0;");
|
||||
} else if (layout === "narrow-wrap") {
|
||||
expect(themeCss).toContain(".custom-agent-chat-composer-actions {\n flex-wrap: wrap;");
|
||||
expect(themeCss).toContain('.custom-agent-chat-composer-actions[data-busy="true"]');
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps file selection behavior while exposing its in-progress state", async () => {
|
||||
const picked = deferred<string | null>();
|
||||
const pickFile = vi.fn(() => picked.promise);
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
|
||||
sendAgentChat: vi.fn(),
|
||||
cancelAgentChat: vi.fn(),
|
||||
closeAgentChat: vi.fn(),
|
||||
};
|
||||
render(
|
||||
<DIProvider gateways={{ agent, system: { pickFile } } as unknown as Gateways}>
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId="chat-1"
|
||||
conversationId="conversation-1"
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(agent.reattachAgentChat).toHaveBeenCalledWith("chat-1", expect.any(Function)),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect((screen.getByRole("button", { name: "Joindre un fichier" }) as HTMLButtonElement).disabled).toBe(false),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Joindre un fichier" }));
|
||||
expect(pickFile).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole("button", { name: "Joindre un fichier" }).textContent).toContain("Ajout…");
|
||||
expect((screen.getByRole("button", { name: "Joindre un fichier" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
await act(async () => picked.resolve("/tmp/report.pdf"));
|
||||
expect(await screen.findByText("Fichier joint: report.pdf")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Joindre un fichier" }).textContent).toBe("Joindre");
|
||||
});
|
||||
|
||||
it("renders the project icon as a non-interactive custom-CLI watermark and updates it immediately", async () => {
|
||||
const agent = {
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
|
||||
|
||||
@ -1093,6 +1093,7 @@ export function CustomAgentChatView({
|
||||
? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? [])
|
||||
: [],
|
||||
);
|
||||
const [attachmentPicking, setAttachmentPicking] = useState(false);
|
||||
const [queuedMessages, setQueuedMessages] = useState<QueuedChatMessage[]>(
|
||||
() => chatMessageQueueCache.get(initialMessageQueueKey) ?? [],
|
||||
);
|
||||
@ -2597,16 +2598,21 @@ export function CustomAgentChatView({
|
||||
}
|
||||
|
||||
async function pickAttachment() {
|
||||
const path = await system.pickFile();
|
||||
if (path) {
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
label: pathBasename(path),
|
||||
input: { path, sourceKind: "localFile" },
|
||||
},
|
||||
]);
|
||||
setAttachmentPicking(true);
|
||||
try {
|
||||
const path = await system.pickFile();
|
||||
if (path) {
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
label: pathBasename(path),
|
||||
input: { path, sourceKind: "localFile" },
|
||||
},
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
setAttachmentPicking(false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3608,8 +3614,8 @@ export function CustomAgentChatView({
|
||||
{composerHistoryWarning}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex min-w-0 items-end gap-2">
|
||||
<div className="relative flex min-w-0 flex-1">
|
||||
<div className="custom-agent-chat-composer flex min-w-0 items-end gap-2">
|
||||
<div className="custom-agent-chat-composer-editor relative flex min-w-0 flex-1">
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
aria-label={`${tr("Message CLI personnalisée", "Custom CLI message")} ${domId}`}
|
||||
@ -3677,39 +3683,47 @@ export function CustomAgentChatView({
|
||||
↕
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
disabled={!supported || opening || Boolean(savedConversationLoadFailure)}
|
||||
onClick={() => void pickAttachment()}
|
||||
<div
|
||||
className="custom-agent-chat-composer-actions flex shrink-0 items-end gap-2"
|
||||
data-busy={effectiveBusy ? "true" : "false"}
|
||||
>
|
||||
{t("custom.attach")}
|
||||
</Button>
|
||||
{effectiveBusy && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
className="min-h-11 shrink-0 whitespace-nowrap"
|
||||
disabled={!supported || !currentSession || !agent.cancelAgentChat || stopping}
|
||||
loading={stopping}
|
||||
title={t("custom.stop")}
|
||||
aria-label={t("custom.stop")}
|
||||
onClick={() => void stopCurrentRequest()}
|
||||
size="md"
|
||||
variant="secondary"
|
||||
className="custom-agent-chat-composer-action min-h-11 whitespace-nowrap"
|
||||
aria-label={tr("Joindre un fichier", "Attach a file")}
|
||||
disabled={!supported || opening || attachmentPicking || Boolean(savedConversationLoadFailure)}
|
||||
loading={attachmentPicking}
|
||||
onClick={() => void pickAttachment()}
|
||||
>
|
||||
{stopping ? t("custom.stopping") : t("custom.stop")}
|
||||
{attachmentPicking ? tr("Ajout…", "Adding…") : t("custom.attach")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 whitespace-nowrap"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
{effectiveBusy
|
||||
? tr("Mettre en attente", "Queue message")
|
||||
: t("custom.send")}
|
||||
</Button>
|
||||
{effectiveBusy && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="danger"
|
||||
className="custom-agent-chat-composer-action min-h-11 whitespace-nowrap"
|
||||
disabled={!supported || !currentSession || !agent.cancelAgentChat || stopping}
|
||||
loading={stopping}
|
||||
title={t("custom.stop")}
|
||||
aria-label={t("custom.stop")}
|
||||
onClick={() => void stopCurrentRequest()}
|
||||
>
|
||||
{stopping ? t("custom.stopping") : t("custom.stop")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
className="custom-agent-chat-composer-action min-h-11 whitespace-nowrap"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
{effectiveBusy
|
||||
? tr("Mettre en attente", "Queue message")
|
||||
: t("custom.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -17,6 +17,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { Agent, McpToolCatalogue, PluginToolRef } from "@/domain";
|
||||
import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
|
||||
import { PluginContributionGroup } from "@/features/plugins/PluginContributionGroup";
|
||||
import { Button, Input, Panel, cn } from "@/shared";
|
||||
import {
|
||||
mcpToolGroupLabel,
|
||||
@ -229,6 +230,7 @@ function PluginMcpToolsEditor({
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [expandedPluginGroups, setExpandedPluginGroups] = useState<Record<string, boolean>>({});
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
const matches = (value: string) =>
|
||||
!normalizedQuery || value.toLocaleLowerCase().includes(normalizedQuery);
|
||||
@ -260,6 +262,19 @@ function PluginMcpToolsEditor({
|
||||
0,
|
||||
);
|
||||
|
||||
function pluginGroupExpanded(pluginId: string, enabled: boolean): boolean {
|
||||
const key = `${agentId}:${pluginId}`;
|
||||
return expandedPluginGroups[key] ?? enabled;
|
||||
}
|
||||
|
||||
function togglePluginGroup(pluginId: string, enabled: boolean) {
|
||||
const key = `${agentId}:${pluginId}`;
|
||||
setExpandedPluginGroups((current) => ({
|
||||
...current,
|
||||
[key]: !(current[key] ?? enabled),
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title={`MCP plugins — ${agentName}`} flush>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
@ -270,7 +285,11 @@ function PluginMcpToolsEditor({
|
||||
placeholder={tr("Rechercher un tool ou un plugin", "Search for a tool or plugin")}
|
||||
/>
|
||||
{plugins.error && <p role="alert" className="text-sm text-danger">{plugins.error}</p>}
|
||||
{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 (
|
||||
<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>
|
||||
<PluginContributionGroup
|
||||
key={plugin.id}
|
||||
pluginName={plugin.displayName}
|
||||
loaded={enabled}
|
||||
countLabel={countLabel}
|
||||
expanded={expanded}
|
||||
controlsId={controlsId}
|
||||
onToggle={() => 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({
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</PluginContributionGroup>
|
||||
);
|
||||
})}
|
||||
{tombstones.filter((ref) => matches(`${ref.pluginId} ${ref.serverId} ${ref.toolName}`)).map((ref) => (
|
||||
|
||||
68
frontend/src/features/plugins/PluginContributionGroup.tsx
Normal file
68
frontend/src/features/plugins/PluginContributionGroup.tsx
Normal file
@ -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 (
|
||||
<section className={cn("overflow-hidden rounded-md border border-border", className)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={controlsId}
|
||||
aria-label={`${actionLabel} ${pluginName} · ${stateLabel} · ${countLabel}`}
|
||||
onClick={onToggle}
|
||||
className="flex min-h-11 w-full items-center gap-2 bg-surface px-3 py-2 text-left transition-colors hover:bg-raised focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"shrink-0 text-xs text-muted transition-transform",
|
||||
expanded && "rotate-90",
|
||||
)}
|
||||
>
|
||||
▸
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-content">
|
||||
{pluginName}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[0.65rem] font-medium",
|
||||
loaded ? "bg-success/15 text-success" : "bg-raised text-warning",
|
||||
)}
|
||||
>
|
||||
{stateLabel}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted">{countLabel}</span>
|
||||
</button>
|
||||
<div id={controlsId} hidden={!expanded} className="flex flex-col gap-2 border-t border-border p-2">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -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(
|
||||
<DIProvider gateways={gateways}>
|
||||
<SkillsPanel projectId="project-1" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<DIProvider gateways={gateways}>
|
||||
<McpPanel projectId="project-1" />
|
||||
</DIProvider>,
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [expandedPluginGroups, setExpandedPluginGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
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) {
|
||||
<h5 className="mt-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
{tr("Plugins", "Plugins")}
|
||||
</h5>
|
||||
{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 (
|
||||
<PluginContributionGroup
|
||||
key={plugin.id}
|
||||
pluginName={plugin.displayName}
|
||||
loaded={enabled}
|
||||
countLabel={countLabel}
|
||||
expanded={expanded}
|
||||
controlsId={controlsId}
|
||||
onToggle={() => togglePluginGroup(plugin.id, enabled)}
|
||||
>
|
||||
{rows.map(({ skill, reference }) => {
|
||||
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)} />
|
||||
@ -340,10 +382,12 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-faint">{plugin.displayName}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
})}
|
||||
</PluginContributionGroup>
|
||||
);
|
||||
})}
|
||||
{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">— {tr("Indisponible", "Unavailable")}</span> <span className="text-faint">{tr("Plugin", "Plugin")}: {ref.pluginId}</span></span>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user