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

@ -7,7 +7,7 @@
import { cn } from "../lib/cn";
import { IconButton } from "./IconButton";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, DragEvent, KeyboardEvent, ReactNode } from "react";
import { useI18n } from "@/i18n";
@ -47,7 +47,19 @@ export interface TabsProps {
export function Tabs({ items, value, onSelect, onClose, onReorder, className }: TabsProps) {
const { tr } = useI18n();
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<{
id: string;
position: "before" | "after";
} | null>(null);
const [announcement, setAnnouncement] = useState("");
const dropCommittedRef = useRef(false);
const dropResetFrameRef = useRef<number | null>(null);
useEffect(() => () => {
if (dropResetFrameRef.current !== null) {
cancelAnimationFrame(dropResetFrameRef.current);
}
}, []);
const reorder = (id: string, targetId: string, after: boolean) => {
if (!onReorder || id === targetId) return;
@ -65,8 +77,20 @@ export function Tabs({ items, value, onSelect, onClose, onReorder, className }:
event.preventDefault();
const id = draggedId ?? event.dataTransfer.getData("text/plain");
const rect = event.currentTarget.getBoundingClientRect();
reorder(id, targetId, rect.width > 0 && event.clientX >= rect.left + rect.width / 2);
setDraggedId(null);
const after = dropTarget?.id === targetId
? dropTarget.position === "after"
: rect.width > 0 && event.clientX >= rect.left + rect.width / 2;
dropCommittedRef.current = true;
reorder(id, targetId, after);
// Keep the visual state until the controlled parent has rendered the new
// order. Clearing it synchronously briefly paints the old order at rest.
dropResetFrameRef.current = requestAnimationFrame(() => {
dropCommittedRef.current = false;
dropResetFrameRef.current = null;
setDraggedId(null);
setDropTarget(null);
});
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
@ -96,13 +120,23 @@ export function Tabs({ items, value, onSelect, onClose, onReorder, className }:
...(active ? { borderColor: tab.themeAccent ?? tab.themeColor } : {}),
}
: undefined;
const targetPosition = dropTarget?.id === tab.id ? dropTarget.position : null;
const tabStyle: CSSProperties | undefined = themedStyle || targetPosition
? {
...themedStyle,
...(targetPosition === "before" ? { marginInlineStart: "0.75rem" } : {}),
...(targetPosition === "after" ? { marginInlineEnd: "0.75rem" } : {}),
}
: undefined;
return (
<div
key={tab.id}
draggable={Boolean(onReorder)}
onDragStart={(event) => {
if (!onReorder) return;
dropCommittedRef.current = false;
setDraggedId(tab.id);
setDropTarget(null);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", tab.id);
}}
@ -110,14 +144,33 @@ export function Tabs({ items, value, onSelect, onClose, onReorder, className }:
if (!onReorder) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
if (!draggedId || draggedId === tab.id) {
setDropTarget(null);
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const position = rect.width > 0 && event.clientX >= rect.left + rect.width / 2
? "after"
: "before";
setDropTarget((current) =>
current?.id === tab.id && current.position === position
? current
: { id: tab.id, position },
);
}}
onDrop={(event) => handleDrop(event, tab.id)}
onDragEnd={() => setDraggedId(null)}
style={themedStyle}
onDragEnd={() => {
if (dropCommittedRef.current) return;
setDraggedId(null);
setDropTarget(null);
}}
data-dragged={draggedId === tab.id || undefined}
data-drop-position={targetPosition ?? undefined}
style={tabStyle}
className={cn(
"group flex items-center gap-1 rounded-md border px-1 transition-colors",
"group flex items-center gap-1 rounded-md border px-1 transition-[margin,transform,opacity,box-shadow,background-color,border-color] duration-150 ease-out motion-reduce:transition-none",
onReorder && "cursor-grab active:cursor-grabbing",
draggedId === tab.id && "opacity-60",
draggedId === tab.id && "-translate-y-0.5 opacity-90 shadow-md",
active
? cn(
"border-border-strong",