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:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user