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