feat(domain,application,infrastructure,backend,app-tauri,frontend): lot utilisateur #350 — animation d'écartement des onglets au drag/reorder, icônes projet N&B colorisées derrière les messages, tools/skills plugin utilisables par les agents autorisés en headless, refs stables des skills assignés (QA verte)

- Tabs (shared/ui): cible de drop matérialisée par écartement before/after pendant le drag/reorder, reset via rAF sans reliquat, clavier et annonces préservés.
- projectIcons: icônes projet en noir et blanc par défaut, colorisées avec la couleur du projet, occupant la cellule et visibles derrière les messages rendus légèrement transparents.
- Permissions plugin headless: PluginMcpServerSpec transporte l'allowlist exacte authorized_tools regroupée par (pluginId, serverId) depuis les PluginToolRef; projection Codex enabled_tools + default_tools_approval_mode="approve" — une permission déjà accordée au niveau projet/template ne déclenche pas de seconde approbation, un tool non assigné reste hors surface appelable; réserves orchestrator/mcp server+tools alignées, dto backend + dto_agents app-tauri.
- Skills: ref stable plugin:<pluginId>:<skillId> exposée dans le contexte agent, idea_skill_read(ref=...) canonique via read_assigned_by_ref, erreur structurée ambiguousSkillName avec candidates; SkillsPanel dédoublonné (android-build-install); agents-dev/L12-skills.md à jour.
- Docs: ARCHITECTURE.md section 22.3 (projection des autorisations agent vers les runtimes plugin).
- QA verte #350 reconfirmée avant commit.
This commit is contained in:
2026-09-13 14:11:31 +02:00
parent 1e15bf9f96
commit 92d0dce2b7
23 changed files with 660 additions and 95 deletions

View File

@ -33,6 +33,9 @@ type EditorState = { mode: "create" } | { mode: "edit"; skill: Skill };
const nativeSkillKey = (ref: SkillRef) => `${ref.scope}:${ref.skillId}`;
const pluginSkillKey = (ref: PluginSkillRef) => `${ref.pluginId}:${ref.skillId}`;
const nativeSkillStableRef = (skill: Skill) => `skill:${skill.scope}:${skill.id}`;
const pluginSkillStableRef = (pluginId: string, skillId: string) =>
`plugin:${pluginId}:${skillId}`;
export function SkillsPanel({ projectId }: SkillsPanelProps) {
const { tr } = useI18n();
@ -71,6 +74,32 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
() => [...vm.projectSkills, ...vm.globalSkills],
[vm.globalSkills, vm.projectSkills],
);
const pluginSkillRows = useMemo(() => {
const seen = new Set<string>();
return plugins.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? []).flatMap((skill) => {
const reference = pluginSkillStableRef(plugin.id, skill.id);
if (seen.has(reference)) return [];
seen.add(reference);
return [{ plugin, skill, reference }];
}),
);
}, [plugins.installed]);
const ambiguousSkillNames = useMemo(() => {
const counts = new Map<string, number>();
for (const name of [
...nativeSkills.map((skill) => skill.name),
...pluginSkillRows.map(({ skill }) => skill.name),
]) {
const normalized = name.trim().toLocaleLowerCase();
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
}
return new Set(
[...counts.entries()].filter(([, count]) => count > 1).map(([name]) => name),
);
}, [nativeSkills, pluginSkillRows]);
const isAmbiguousName = (name: string) =>
ambiguousSkillNames.has(name.trim().toLocaleLowerCase());
const normalizedQuery = query.trim().toLocaleLowerCase();
const matches = (value: string) =>
!normalizedQuery || value.toLocaleLowerCase().includes(normalizedQuery);
@ -84,9 +113,7 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
)?.skills ?? [];
const assignedPluginKeys = new Set(pluginAssignments.map(pluginSkillKey));
const knownPluginKeys = new Set(
plugins.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`),
),
pluginSkillRows.map(({ plugin, skill }) => `${plugin.id}:${skill.id}`),
);
const stalePluginSkills = pluginAssignments.filter(
(ref) => !knownPluginKeys.has(pluginSkillKey(ref)),
@ -249,13 +276,31 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
/>
{selectedAgent && (
<div className="mt-3 flex flex-col gap-2">
{ambiguousSkillNames.size > 0 && (
<p
role="status"
className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"
>
{tr(
"Plusieurs skills portent le même nom. Leur référence stable permet de les distinguer.",
"Several skills share the same name. Their stable reference distinguishes them.",
)}
</p>
)}
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">IdeA</h5>
{nativeSkills.filter((skill) => matches(`${skill.name} ${skill.description ?? ""}`)).map((skill) => {
const assigned = assignedNativeKeys.has(`${skill.scope}:${skill.id}`);
return (
<label key={`${skill.scope}:${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={assignmentBusy || vm.busy} onChange={(event) => void setNativeSkill(skill, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span>
<span className="min-w-0 flex-1">
<span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}
{isAmbiguousName(skill.name) && (
<span className="mt-0.5 block break-all font-mono text-[10px] text-faint">
{nativeSkillStableRef(skill)}
</span>
)}
</span>
<span className="text-faint">{skill.scope === "project" ? tr("Projet", "Project") : tr("Global", "Global")}</span>
</label>
);
@ -270,28 +315,33 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
<h5 className="mt-2 text-xs font-semibold uppercase tracking-wide text-faint">
{tr("Plugins", "Plugins")}
</h5>
{plugins.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? [])
.filter((skill) => matches(`${skill.name} ${skill.description} ${plugin.displayName}`))
.map((skill) => {
{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}`);
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)} />
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}{!enabled && <span className="text-warning"> — {tr("Plugin désactivé", "Plugin disabled")}</span>}</span>
<span className="min-w-0 flex-1">
<span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}{!enabled && <span className="text-warning"> — {tr("Plugin désactivé", "Plugin disabled")}</span>}
{isAmbiguousName(skill.name) && (
<span className="mt-0.5 block break-all font-mono text-[10px] text-faint">
{reference}
</span>
)}
</span>
<span className="text-faint">{plugin.displayName}</span>
</label>
);
}),
)}
})}
{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>
<Button size="sm" variant="ghost" disabled={plugins.busy} onClick={() => void plugins.setSkillAssigned(selectedAgent.id, ref.pluginId, ref.skillId, false)}>{tr("Retirer", "Remove")}</Button>
</div>
))}
{nativeSkills.length === 0 && plugins.installed.every((plugin) => (plugin.contributes.skills ?? []).length === 0) && staleNativeSkills.length === 0 && stalePluginSkills.length === 0 && (
{nativeSkills.length === 0 && pluginSkillRows.length === 0 && staleNativeSkills.length === 0 && stalePluginSkills.length === 0 && (
<p className="text-sm text-muted">{tr("Aucun skill disponible.", "No skills available.")}</p>
)}
</div>