merge(frontend): feature #367 #368 — groupes repliables par plugin et bouton Joindre harmonisé (QA verte)

This commit is contained in:
2026-09-17 09:46:31 +02:00
7 changed files with 453 additions and 51 deletions

View File

@ -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: [] })),

View File

@ -1093,6 +1093,7 @@ export function CustomAgentChatView({
? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? [])
: [],
);
const [attachmentPicking, setAttachmentPicking] = useState(false);
const [queuedMessages, setQueuedMessages] = useState<QueuedChatMessage[]>(
() => chatMessageQueueCache.get(initialMessageQueueKey) ?? [],
);
@ -2597,6 +2598,8 @@ export function CustomAgentChatView({
}
async function pickAttachment() {
setAttachmentPicking(true);
try {
const path = await system.pickFile();
if (path) {
setAttachments((prev) => [
@ -2608,6 +2611,9 @@ export function CustomAgentChatView({
},
]);
}
} finally {
setAttachmentPicking(false);
}
}
function removeAttachment(item: AttachmentDraft) {
@ -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,20 +3683,26 @@ export function CustomAgentChatView({
↕
</button>
</div>
<div
className="custom-agent-chat-composer-actions flex shrink-0 items-end gap-2"
data-busy={effectiveBusy ? "true" : "false"}
>
<Button
size="sm"
variant="ghost"
className="shrink-0 whitespace-nowrap"
disabled={!supported || opening || Boolean(savedConversationLoadFailure)}
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()}
>
{t("custom.attach")}
{attachmentPicking ? tr("Ajout…", "Adding…") : t("custom.attach")}
</Button>
{effectiveBusy && (
<Button
size="sm"
size="md"
variant="danger"
className="min-h-11 shrink-0 whitespace-nowrap"
className="custom-agent-chat-composer-action min-h-11 whitespace-nowrap"
disabled={!supported || !currentSession || !agent.cancelAgentChat || stopping}
loading={stopping}
title={t("custom.stop")}
@ -3701,8 +3713,9 @@ export function CustomAgentChatView({
</Button>
)}
<Button
size="sm"
className="min-h-11 shrink-0 whitespace-nowrap"
size="md"
variant="primary"
className="custom-agent-chat-composer-action min-h-11 whitespace-nowrap"
disabled={!canSubmit}
onClick={() => void send()}
>
@ -3713,6 +3726,7 @@ export function CustomAgentChatView({
</div>
</div>
</div>
</div>
);
}

View File

@ -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) => (

View 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>
);
}

View File

@ -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");
});
});

View File

@ -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 }) => {
const assigned = assignedPluginKeys.has(`${plugin.id}:${skill.id}`);
{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}`);
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>

View File

@ -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;
}