feat(backend,frontend): lot utilisateur #346 #347 #348 — exécution workspace plugin contrôlée par l'hôte, réordonnancement des onglets projet par glisser-déposer, Final restauré après stop d'agent CLI custom (QA verte)

#346 — capacités plugin SCM: le manifeste permissions.workspaceExec
(contrat SDK 8ff0f47) est appliqué côté hôte — domain PluginWorkspaceExec
Rule/Effect + audit sink, port WorkspaceToolRunner, adapter
LocalWorkspaceToolRunner sandboxé (concurrence 4, timeout, bornes
stdout/stderr, résolution bare executable sans shell), service
application + DTO backend + commandes app-tauri, gateway frontend
execTool exposée au runtime plugin — fin des lectures ad hoc du store
interne .git par les plugins.
#347 — onglets projet réordonnables par glisser-déposer (Tabs DnD,
onReorder, persistance de l'ordre via useProjects).
#348 — stop d'agent CLI custom: le message Final vert réapparaît au tour
suivant (override terminal stop armé correctement, reset de
l'interaction, refresh de la délégation requester).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 12:23:44 +02:00
parent 2b0d82efa3
commit 561c50f6ed
28 changed files with 1761 additions and 51 deletions

View File

@ -7,7 +7,8 @@
import { cn } from "../lib/cn";
import { IconButton } from "./IconButton";
import type { CSSProperties, ReactNode } from "react";
import { useState } from "react";
import type { CSSProperties, DragEvent, KeyboardEvent, ReactNode } from "react";
import { useI18n } from "@/i18n";
interface ThemedTabStyle extends CSSProperties {
@ -37,17 +38,53 @@ export interface TabsProps {
onSelect: (id: string) => void;
/** When provided, each tab shows a close (×) control. */
onClose?: (id: string) => void;
/** When provided, tabs can be reordered with drag-and-drop or Alt+Arrow. */
onReorder?: (orderedIds: string[]) => void;
className?: string;
}
/** A themed tab strip with selection and optional per-tab close. */
export function Tabs({ items, value, onSelect, onClose, className }: TabsProps) {
export function Tabs({ items, value, onSelect, onClose, onReorder, className }: TabsProps) {
const { tr } = useI18n();
const [draggedId, setDraggedId] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState("");
const reorder = (id: string, targetId: string, after: boolean) => {
if (!onReorder || id === targetId) return;
const orderedIds = items.map((item) => item.id).filter((itemId) => itemId !== id);
const targetIndex = orderedIds.indexOf(targetId);
if (targetIndex < 0) return;
orderedIds.splice(targetIndex + (after ? 1 : 0), 0, id);
if (orderedIds.every((itemId, index) => itemId === items[index]?.id)) return;
onReorder(orderedIds);
const moved = items.find((item) => item.id === id)?.label ?? id;
setAnnouncement(tr(`${moved} déplacé.`, `${moved} moved.`));
};
const handleDrop = (event: DragEvent<HTMLDivElement>, targetId: string) => {
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 handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
if (!onReorder || !event.altKey || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return;
const index = items.findIndex((item) => item.id === id);
const targetIndex = index + (event.key === "ArrowLeft" ? -1 : 1);
const target = items[targetIndex];
if (!target) return;
event.preventDefault();
reorder(id, target.id, event.key === "ArrowRight");
};
return (
<div
role="tablist"
className={cn("flex flex-wrap items-stretch gap-1", className)}
>
<span aria-live="polite" className="sr-only">{announcement}</span>
{items.map((tab) => {
const active = tab.id === value;
const themedStyle: ThemedTabStyle | undefined = tab.themeColor
@ -62,9 +99,25 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps)
return (
<div
key={tab.id}
draggable={Boolean(onReorder)}
onDragStart={(event) => {
if (!onReorder) return;
setDraggedId(tab.id);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", tab.id);
}}
onDragOver={(event) => {
if (!onReorder) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}}
onDrop={(event) => handleDrop(event, tab.id)}
onDragEnd={() => setDraggedId(null)}
style={themedStyle}
className={cn(
"group flex items-center gap-1 rounded-md border px-1 transition-colors",
onReorder && "cursor-grab active:cursor-grabbing",
draggedId === tab.id && "opacity-60",
active
? cn(
"border-border-strong",
@ -83,6 +136,8 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps)
role="tab"
aria-selected={active}
onClick={() => onSelect(tab.id)}
onKeyDown={(event) => handleKeyDown(event, tab.id)}
aria-keyshortcuts={onReorder ? "Alt+ArrowLeft Alt+ArrowRight" : undefined}
className={cn(
"flex items-center gap-1.5 px-2 py-1 text-sm focus-visible:outline-none",
active ? "font-semibold text-content" : "text-muted hover:text-content",
@ -96,6 +151,8 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps)
size="sm"
aria-label={tr(`fermer ${tab.label}`, `close ${tab.label}`)}
onClick={() => onClose(tab.id)}
draggable={false}
onDragStart={(event) => event.stopPropagation()}
className="opacity-60 group-hover:opacity-100"
>
×