feat(frontend): groupes repliables par plugin pour skills et tools MCP, bouton Joindre harmonisé avec Envoyer — nouveau composant PluginContributionGroup (en-tête cliquable aria-expanded/aria-controls, badge Chargé/Non chargé, compteur de contributions, replié par défaut pour les plugins non loadés, dépliage forcé pendant la recherche) adopté par SkillsPanel et McpToolPermissionsPanel avec déduplication des plugins — #367 ; bouton Joindre du chat aligné sur le format d'Envoyer (size md, variante secondary, état loading « Ajout… », aria-label dédié, conteneur custom-agent-chat-composer-actions avec data-busy, Stop aligné md, container queries 419.99px en colonne et 279.99px wrap puis pleine largeur en busy) — #368 (QA verte: npm test ciblé ProjectPluginSettings + skills + McpToolPermissionsPanel + CustomAgentChatView + visibleSourceScan 5 fichiers 152 tests OK, Firefox headless layout 600/360/240 et busy 240 PASS, npm run build OK, git diff --check OK)

This commit is contained in:
2026-09-17 09:46:21 +02:00
parent 4c86f9bb6f
commit 5d48a39b18
7 changed files with 453 additions and 51 deletions

View File

@ -212,6 +212,7 @@ describe("CustomAgentChatView", () => {
{ width: 240, mode: "full-width" }, { width: 240, mode: "full-width" },
])("keeps its conversation access clear of cell controls at $width px ($mode)", ({ width, mode }) => { ])("keeps its conversation access clear of cell controls at $width px ($mode)", ({ width, mode }) => {
const agent = { const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
sendAgentChat: vi.fn(), sendAgentChat: vi.fn(),
cancelAgentChat: 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 () => { it("renders the project icon as a non-interactive custom-CLI watermark and updates it immediately", async () => {
const agent = { const agent = {
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),

View File

@ -1093,6 +1093,7 @@ export function CustomAgentChatView({
? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? []) ? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? [])
: [], : [],
); );
const [attachmentPicking, setAttachmentPicking] = useState(false);
const [queuedMessages, setQueuedMessages] = useState<QueuedChatMessage[]>( const [queuedMessages, setQueuedMessages] = useState<QueuedChatMessage[]>(
() => chatMessageQueueCache.get(initialMessageQueueKey) ?? [], () => chatMessageQueueCache.get(initialMessageQueueKey) ?? [],
); );
@ -2597,16 +2598,21 @@ export function CustomAgentChatView({
} }
async function pickAttachment() { async function pickAttachment() {
const path = await system.pickFile(); setAttachmentPicking(true);
if (path) { try {
setAttachments((prev) => [ const path = await system.pickFile();
...prev, if (path) {
{ setAttachments((prev) => [
id: `path-${Date.now()}-${Math.random().toString(36).slice(2)}`, ...prev,
label: pathBasename(path), {
input: { path, sourceKind: "localFile" }, 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} {composerHistoryWarning}
</p> </p>
)} )}
<div className="flex min-w-0 items-end gap-2"> <div className="custom-agent-chat-composer flex min-w-0 items-end gap-2">
<div className="relative flex min-w-0 flex-1"> <div className="custom-agent-chat-composer-editor relative flex min-w-0 flex-1">
<textarea <textarea
ref={composerRef} ref={composerRef}
aria-label={`${tr("Message CLI personnalisée", "Custom CLI message")} ${domId}`} aria-label={`${tr("Message CLI personnalisée", "Custom CLI message")} ${domId}`}
@ -3677,39 +3683,47 @@ export function CustomAgentChatView({
↕ ↕
</button> </button>
</div> </div>
<Button <div
size="sm" className="custom-agent-chat-composer-actions flex shrink-0 items-end gap-2"
variant="ghost" data-busy={effectiveBusy ? "true" : "false"}
className="shrink-0 whitespace-nowrap"
disabled={!supported || opening || Boolean(savedConversationLoadFailure)}
onClick={() => void pickAttachment()}
> >
{t("custom.attach")}
</Button>
{effectiveBusy && (
<Button <Button
size="sm" size="md"
variant="danger" variant="secondary"
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} aria-label={tr("Joindre un fichier", "Attach a file")}
loading={stopping} disabled={!supported || opening || attachmentPicking || Boolean(savedConversationLoadFailure)}
title={t("custom.stop")} loading={attachmentPicking}
aria-label={t("custom.stop")} onClick={() => void pickAttachment()}
onClick={() => void stopCurrentRequest()}
> >
{stopping ? t("custom.stopping") : t("custom.stop")} {attachmentPicking ? tr("Ajout…", "Adding…") : t("custom.attach")}
</Button> </Button>
)} {effectiveBusy && (
<Button <Button
size="sm" size="md"
className="min-h-11 shrink-0 whitespace-nowrap" variant="danger"
disabled={!canSubmit} className="custom-agent-chat-composer-action min-h-11 whitespace-nowrap"
onClick={() => void send()} disabled={!supported || !currentSession || !agent.cancelAgentChat || stopping}
> loading={stopping}
{effectiveBusy title={t("custom.stop")}
? tr("Mettre en attente", "Queue message") aria-label={t("custom.stop")}
: t("custom.send")} onClick={() => void stopCurrentRequest()}
</Button> >
{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> </div>
</div> </div>

View File

@ -17,6 +17,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import type { Agent, McpToolCatalogue, PluginToolRef } from "@/domain"; import type { Agent, McpToolCatalogue, PluginToolRef } from "@/domain";
import { useProjectPlugins } from "@/features/plugins/useProjectPlugins"; import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
import { PluginContributionGroup } from "@/features/plugins/PluginContributionGroup";
import { Button, Input, Panel, cn } from "@/shared"; import { Button, Input, Panel, cn } from "@/shared";
import { import {
mcpToolGroupLabel, mcpToolGroupLabel,
@ -229,6 +230,7 @@ function PluginMcpToolsEditor({
}) { }) {
const { tr } = useI18n(); const { tr } = useI18n();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [expandedPluginGroups, setExpandedPluginGroups] = useState<Record<string, boolean>>({});
const normalizedQuery = query.trim().toLocaleLowerCase(); const normalizedQuery = query.trim().toLocaleLowerCase();
const matches = (value: string) => const matches = (value: string) =>
!normalizedQuery || value.toLocaleLowerCase().includes(normalizedQuery); !normalizedQuery || value.toLocaleLowerCase().includes(normalizedQuery);
@ -260,6 +262,19 @@ function PluginMcpToolsEditor({
0, 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 ( return (
<Panel title={`MCP plugins — ${agentName}`} flush> <Panel title={`MCP plugins — ${agentName}`} flush>
<div className="flex flex-col gap-3 p-4"> <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")} 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.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) => const rows = (plugin.contributes.mcpServers ?? []).flatMap((server) =>
(server.tools ?? []) (server.tools ?? [])
.filter((tool) => .filter((tool) =>
@ -280,9 +299,26 @@ function PluginMcpToolsEditor({
); );
if (rows.length === 0) return null; if (rows.length === 0) return null;
const enabled = enabledPluginIds.has(plugin.id); 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 ( return (
<section key={plugin.id} className="flex flex-col gap-2" aria-label={`Plugin ${plugin.displayName}`}> <PluginContributionGroup
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">{plugin.displayName}</h4> key={plugin.id}
pluginName={plugin.displayName}
loaded={enabled}
countLabel={countLabel}
expanded={expanded}
controlsId={controlsId}
onToggle={() => togglePluginGroup(plugin.id, enabled)}
>
{rows.map(({ server, tool }) => { {rows.map(({ server, tool }) => {
const ref = { pluginId: plugin.id, serverId: server.id, toolName: tool.name }; const ref = { pluginId: plugin.id, serverId: server.id, toolName: tool.name };
const assigned = assignedKeys.has(pluginToolKey(ref)); const assigned = assignedKeys.has(pluginToolKey(ref));
@ -298,7 +334,7 @@ function PluginMcpToolsEditor({
</label> </label>
); );
})} })}
</section> </PluginContributionGroup>
); );
})} })}
{tombstones.filter((ref) => matches(`${ref.pluginId} ${ref.serverId} ${ref.toolName}`)).map((ref) => ( {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" }); 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 () => { it("shows stable refs without blocking assignment when skill names are ambiguous", async () => {
const gateways = gatewaysWithPlugin(); const gateways = gatewaysWithPlugin();
const duplicatedContribution = manifest(); 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: /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/ }); const disabledAssigned = await screen.findByRole("checkbox", { name: /unity_build/ });
expect((disabledAssigned as HTMLInputElement).checked).toBe(true); expect((disabledAssigned as HTMLInputElement).checked).toBe(true);
expect((disabledAssigned as HTMLInputElement).disabled).toBe(false); 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 { onAgentRosterChanged } from "@/features/agents/agentRosterEvents";
import type { Agent, PluginSkillRef, Skill, SkillRef, SkillScope } from "@/domain"; import type { Agent, PluginSkillRef, Skill, SkillRef, SkillScope } from "@/domain";
import { useProjectPlugins } from "@/features/plugins/useProjectPlugins"; import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
import { PluginContributionGroup } from "@/features/plugins/PluginContributionGroup";
import { Button, Input, Panel, Spinner, cn } from "@/shared"; import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { SkillEditor } from "./SkillEditor"; import { SkillEditor } from "./SkillEditor";
@ -51,6 +52,7 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
const [assignmentBusy, setAssignmentBusy] = useState(false); const [assignmentBusy, setAssignmentBusy] = useState(false);
const [assignmentError, setAssignmentError] = useState<string | null>(null); const [assignmentError, setAssignmentError] = useState<string | null>(null);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [expandedPluginGroups, setExpandedPluginGroups] = useState<Record<string, boolean>>({});
const loadAgents = useCallback(async () => { const loadAgents = useCallback(async () => {
try { try {
@ -135,6 +137,19 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
(ref) => !knownNativeKeys.has(nativeSkillKey(ref)), (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) { async function setNativeSkill(skill: Skill, assigned: boolean) {
if (!selectedAgent) return; if (!selectedAgent) return;
setAssignmentBusy(true); setAssignmentBusy(true);
@ -324,11 +339,38 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
<h5 className="mt-2 text-xs font-semibold uppercase tracking-wide text-faint"> <h5 className="mt-2 text-xs font-semibold uppercase tracking-wide text-faint">
{tr("Plugins", "Plugins")} {tr("Plugins", "Plugins")}
</h5> </h5>
{pluginSkillRows {plugins.installed
.filter(({ plugin, skill, reference }) => matches(`${skill.name} ${skill.description} ${plugin.displayName} ${reference}`)) .filter((plugin, index, all) =>
.map(({ plugin, skill, reference }) => { 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 assigned = assignedPluginKeys.has(`${plugin.id}:${skill.id}`);
const enabled = enabledPluginIds.has(plugin.id);
return ( return (
<label key={`${plugin.id}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content"> <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)} /> <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> </span>
<span className="text-faint">{plugin.displayName}</span>
</label> </label>
); );
})} })}
</PluginContributionGroup>
);
})}
{stalePluginSkills.filter((ref) => matches(`${ref.pluginId} ${ref.skillId}`)).map((ref) => ( {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"> <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> <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 { .project-cell-illustration-area {
container-type: size; container-type: size;
} }