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

@ -59,6 +59,8 @@ import type {
PluginToolchainDiagnosticRequest,
PluginToolchainGateway,
PluginWorkspaceGateway,
PluginWorkspaceExecInput,
PluginWorkspaceExecResult,
PluginWorkspacePathInput,
PluginWorkspaceWriteBinaryInput,
PluginWorkspaceWriteTextInput,
@ -221,6 +223,9 @@ export class WebPluginGateway implements PluginGateway {
/** Web stub: public plugin workspace services are only meaningful where plugins run. */
export class WebPluginWorkspaceGateway implements PluginWorkspaceGateway {
async execTool(_input: PluginWorkspaceExecInput): Promise<PluginWorkspaceExecResult> {
return unsupportedOnWeb("Plugin workspace tool execution");
}
async readText(_input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile> {
return unsupportedOnWeb("Plugin workspace access");
}

View File

@ -160,6 +160,8 @@ import type {
PluginToolchainDiagnosticRequest,
PluginToolchainGateway,
PluginWorkspaceGateway,
PluginWorkspaceExecInput,
PluginWorkspaceExecResult,
PluginWorkspacePathInput,
PluginWorkspaceWriteBinaryInput,
PluginWorkspaceWriteTextInput,
@ -4553,6 +4555,10 @@ function basename(path: string): string {
export class MockPluginWorkspaceGateway implements PluginWorkspaceGateway {
private readonly files = new Map<string, Map<string, Uint8Array>>();
async execTool(_input: PluginWorkspaceExecInput): Promise<PluginWorkspaceExecResult> {
throw new Error("Plugin workspace tool execution is unavailable in the mock adapter");
}
private bucket(projectId: string): Map<string, Uint8Array> {
let files = this.files.get(projectId);
if (!files) {

View File

@ -16,6 +16,8 @@ import type {
import type {
PluginProjectStructureQuery,
PluginWorkspaceGateway,
PluginWorkspaceExecInput,
PluginWorkspaceExecResult,
PluginWorkspacePathInput,
PluginWorkspaceWriteBinaryInput,
PluginWorkspaceWriteTextInput,
@ -74,4 +76,8 @@ export class TauriPluginWorkspaceGateway implements PluginWorkspaceGateway {
queryProjectStructure(input: PluginProjectStructureQuery): Promise<PluginProjectStructure> {
return invoke<PluginProjectStructure>("plugin_query_project_structure", { input });
}
execTool(input: PluginWorkspaceExecInput): Promise<PluginWorkspaceExecResult> {
return invoke<PluginWorkspaceExecResult>("plugin_workspace_exec_tool", { input });
}
}

View File

@ -1564,6 +1564,7 @@ describe("CustomAgentChatView", () => {
it("cancels only the current request and keeps the structured session usable", async () => {
let resolveCancel: (result: CancelAgentChatResult) => void = () => {};
let firstTurnChunk: (chunk: ReplyChunk) => void = () => {};
let reattachedChunk: (chunk: ReplyChunk) => void = () => {};
let rejectFirstTurn: (reason: unknown) => void = () => {};
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
@ -1581,10 +1582,12 @@ describe("CustomAgentChatView", () => {
});
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
reattachAgentChat: vi.fn(
async (sessionId: string, onChunk: (chunk: ReplyChunk) => void) => {
reattachedChunk = onChunk;
return { sessionId, scrollback: [] };
},
),
sendAgentChat: vi
.fn()
.mockImplementationOnce((_sessionId: string, _prompt: string, onChunk) => {
@ -1594,6 +1597,41 @@ describe("CustomAgentChatView", () => {
});
})
.mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => {
reattachedChunk({ kind: "final", content: "Stale cancelled answer" });
onChunk({
kind: "progress",
progress: {
source: "providerNative",
kind: "tool",
stage: "completed",
label: "web_search",
provider: "codex",
nativeEvent: "item.completed",
},
});
onChunk({
kind: "progress",
progress: {
source: "providerNative",
kind: "message",
stage: "completed",
label: "message intermédiaire",
text: "Complete intermediate answer",
provider: "codex",
nativeEvent: "item.completed",
},
});
onChunk({
kind: "progress",
progress: {
source: "providerNative",
kind: "turn",
stage: "completed",
label: "tour terminé côté provider",
provider: "codex",
nativeEvent: "turn.completed",
},
});
onChunk({ kind: "final", content: "Next answer" });
}),
stopLiveAgent: vi.fn(),
@ -1729,7 +1767,11 @@ describe("CustomAgentChatView", () => {
"next turn",
expect.any(Function),
);
expect(await screen.findByText("web_search")).toBeTruthy();
expect(await screen.findByText("Complete intermediate answer")).toBeTruthy();
expect(await screen.findByText("Next answer")).toBeTruthy();
expect(screen.queryByText("Stale cancelled answer")).toBeNull();
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
});
it.each(["alreadyCancelled", "alreadyCompleted"] as const)(

View File

@ -1503,10 +1503,17 @@ export function CustomAgentChatView({
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
stopTerminalOverrideRef.current = true;
setStopTerminalOverride(true);
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
setBusy(false);
void refreshRequesterDelegation();
}
},
[projectId, updateChatViewState],
[projectId, refreshRequesterDelegation, updateChatViewState],
);
useEffect(() => {
@ -2315,7 +2322,6 @@ export function CustomAgentChatView({
const attachmentLabels = outgoingAttachments.map((item) => item.label);
const displayText = text || "Pièce jointe";
const interactionId = `${agentId}:${++interactionSeqRef.current}`;
ignoreUnscopedChunksAfterStopRef.current = false;
stopTerminalOverrideRef.current = false;
setStopTerminalOverride(false);
activeInteractionIdRef.current = interactionId;

View File

@ -25,6 +25,7 @@ function renderTabs(props: Partial<React.ComponentProps<typeof ProjectTabs>> = {
activeTabId={props.activeTabId ?? "a"}
onSelect={props.onSelect ?? vi.fn()}
onClose={props.onClose ?? vi.fn()}
onReorder={props.onReorder}
projectsPanelOpen={props.projectsPanelOpen ?? false}
onOpenProjectsPanel={onOpenProjectsPanel}
activityByProjectId={props.activityByProjectId}
@ -94,3 +95,44 @@ describe("ProjectTabs project activity dots (#159)", () => {
expect(screen.getByTitle(/beta.*échange en cours/i)).toBeTruthy();
});
});
describe("ProjectTabs reordering (#347)", () => {
it("reorders tabs horizontally by drag-and-drop without selecting or closing them", () => {
const onReorder = vi.fn();
const onSelect = vi.fn();
const onClose = vi.fn();
renderTabs({ onReorder, onSelect, onClose });
const alpha = screen.getByRole("tab", { name: /alpha/ });
const beta = screen.getByRole("tab", { name: /beta/ });
const alphaContainer = alpha.parentElement!;
const betaContainer = beta.parentElement!;
const data = new Map<string, string>();
const dataTransfer = {
effectAllowed: "none",
dropEffect: "none",
setData: (type: string, value: string) => data.set(type, value),
getData: (type: string) => data.get(type) ?? "",
};
fireEvent.dragStart(betaContainer, { dataTransfer });
fireEvent.dragOver(alphaContainer, { dataTransfer });
fireEvent.drop(alphaContainer, { dataTransfer, clientX: 0 });
expect(onReorder).toHaveBeenCalledWith(["b", "a"]);
expect(onSelect).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it("moves the focused tab with Alt+Arrow and announces the change", () => {
const onReorder = vi.fn();
renderTabs({ onReorder });
fireEvent.keyDown(screen.getByRole("tab", { name: /alpha/ }), {
key: "ArrowRight",
altKey: true,
});
expect(onReorder).toHaveBeenCalledWith(["b", "a"]);
expect(screen.getByText("alpha déplacé.")).toBeTruthy();
});
});

View File

@ -21,6 +21,7 @@ export interface ProjectTabsProps {
activeTabId: string | null;
onSelect: (id: string) => void;
onClose: (id: string) => void;
onReorder?: (orderedIds: string[]) => void;
/** Whether the Projects panel is currently open (in any placement). */
projectsPanelOpen: boolean;
/** Open the Projects panel (floating) — create/switch project. */
@ -36,6 +37,7 @@ export function ProjectTabs({
activeTabId,
onSelect,
onClose,
onReorder,
projectsPanelOpen,
onOpenProjectsPanel,
activityByProjectId = {},
@ -72,6 +74,7 @@ export function ProjectTabs({
value={activeTabId}
onSelect={onSelect}
onClose={onClose}
onReorder={onReorder}
/>
)}
{/* Add-project affordance (#46): always a bordered `secondary` button so

View File

@ -1071,6 +1071,7 @@ export function ProjectsView() {
activeTabId={vm.activeTabId}
onSelect={(id) => vm.activateTab(id)}
onClose={(id) => void vm.closeTab(id)}
onReorder={(orderedIds) => void vm.reorderTabs(orderedIds)}
activityByProjectId={projectActivity}
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
onOpenProjectsPanel={() => {

View File

@ -3,7 +3,7 @@
* real `DIProvider`. Covers create → list/tab, open → tab, close → remove,
* duplicate root (INVALID) and unknown id (NOT_FOUND) error handling.
*/
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import {
render,
screen,
@ -76,6 +76,10 @@ function tablist() {
return screen.getByRole("tablist");
}
function tabLabels() {
return within(tablist()).getAllByRole("tab").map((tab) => tab.textContent?.trim());
}
/**
* Opens a panel floating via the single « Panneaux » menu (#26): click the menu,
* click the panel entry (opens its placement submenu), then pick « Flottant ».
@ -165,6 +169,56 @@ describe("ProjectsView (with MockProjectGateway)", () => {
expect(screen.queryByRole("status")).toBeNull();
});
it("reorders project tabs, persists the order, and preserves the active tab", async () => {
const project = new MockProjectGateway();
const alpha = await project.createProject("alpha", "/p/a");
const beta = await project.createProject("beta", "/p/b");
project.setOpenProjectRestoreOrder([alpha.id, beta.id]);
const saveOrder = vi.spyOn(project, "saveOpenProjectRestoreOrder");
renderView(project);
await waitFor(() => expect(tabLabels()).toEqual(["alpha", "beta"]));
expect(screen.getByRole("tab", { name: "beta" }).getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(screen.getByRole("tab", { name: "beta" }), {
key: "ArrowLeft",
altKey: true,
});
await waitFor(() => expect(tabLabels()).toEqual(["beta", "alpha"]));
expect(saveOrder).toHaveBeenCalledWith([beta.id, alpha.id]);
expect(screen.getByRole("tab", { name: "beta" }).getAttribute("aria-selected")).toBe("true");
await expect(project.readOpenProjectRestoreState()).resolves.toMatchObject({
entries: [
expect.objectContaining({ projectId: beta.id, position: 0 }),
expect.objectContaining({ projectId: alpha.id, position: 1 }),
],
});
});
it("rolls a tab reorder back when persistence fails", async () => {
const project = new MockProjectGateway();
const alpha = await project.createProject("alpha", "/p/a");
const beta = await project.createProject("beta", "/p/b");
project.setOpenProjectRestoreOrder([alpha.id, beta.id]);
let rejectSave: (reason: unknown) => void = () => {};
vi.spyOn(project, "saveOpenProjectRestoreOrder").mockImplementationOnce(
() => new Promise((_resolve, reject) => { rejectSave = reject; }),
);
renderView(project);
await waitFor(() => expect(tabLabels()).toEqual(["alpha", "beta"]));
fireEvent.keyDown(screen.getByRole("tab", { name: "beta" }), {
key: "ArrowLeft",
altKey: true,
});
await waitFor(() => expect(tabLabels()).toEqual(["beta", "alpha"]));
rejectSave({ code: "STORE", message: "could not persist tab order" });
await waitFor(() => expect(tabLabels()).toEqual(["alpha", "beta"]));
expect(screen.getByRole("alert").textContent).toContain("could not persist tab order");
expect(screen.getByRole("tab", { name: "beta" }).getAttribute("aria-selected")).toBe("true");
});
it("shows one restore warning, details its cause, and retries at the retained position", async () => {
const project = new MockProjectGateway();
const alpha = await project.createProject("alpha", "/p/a");

View File

@ -40,6 +40,8 @@ export interface ProjectsViewModel {
openProject: (projectId: string) => Promise<void>;
/** Closes a tab (does not delete the project from the registry). */
closeTab: (projectId: string) => Promise<void>;
/** Reorders open tabs optimistically and persists their restore order. */
reorderTabs: (orderedProjectIds: string[]) => Promise<void>;
/** Removes a project from IdeA's registry without deleting its files. */
removeProject: (projectId: string) => Promise<void>;
/** Retries one failed restore, or every failure when omitted. */
@ -68,6 +70,9 @@ export function useProjects(): ProjectsViewModel {
const [restoreFailures, setRestoreFailures] = useState<OpenProjectRestoreFailure[]>([]);
const [restoreNotificationVisible, setRestoreNotificationVisible] = useState(false);
const initializationStarted = useRef(false);
const openTabsRef = useRef<Project[]>([]);
const reorderInFlight = useRef(false);
openTabsRef.current = openTabs;
const refresh = useCallback(async () => {
setBusy(true);
@ -174,6 +179,43 @@ export function useProjects(): ProjectsViewModel {
[project],
);
const reorderTabs = useCallback(
async (orderedProjectIds: string[]) => {
if (reorderInFlight.current) return;
const previous = openTabsRef.current;
if (
orderedProjectIds.length !== previous.length ||
new Set(orderedProjectIds).size !== previous.length ||
orderedProjectIds.some((id) => !previous.some((tab) => tab.id === id))
) return;
if (orderedProjectIds.every((id, index) => id === previous[index]?.id)) return;
const byId = new Map(previous.map((tab) => [tab.id, tab]));
const next = orderedProjectIds.map((id) => byId.get(id)!);
reorderInFlight.current = true;
openTabsRef.current = next;
setOpenTabs(next);
setError(null);
try {
await project.saveOpenProjectRestoreOrder(orderedProjectIds);
} catch (e) {
setOpenTabs((current) => {
const currentById = new Map(current.map((tab) => [tab.id, tab]));
const restored = previous
.filter((tab) => currentById.has(tab.id))
.map((tab) => currentById.get(tab.id)!);
const previousIds = new Set(previous.map((tab) => tab.id));
const merged = [...restored, ...current.filter((tab) => !previousIds.has(tab.id))];
openTabsRef.current = merged;
return merged;
});
setError(describe(e));
} finally {
reorderInFlight.current = false;
}
},
[project],
);
const reconcileRemovedProject = useCallback(
(projectId: string, nextProjects: Project[]) => {
setProjects(nextProjects);
@ -290,6 +332,7 @@ export function useProjects(): ProjectsViewModel {
createProject,
openProject,
closeTab,
reorderTabs,
removeProject,
retryRestore,
removeFromRestore,

View File

@ -64,6 +64,8 @@ export {
type WorkspaceBinaryFile,
type WorkspaceDirEntry,
type WorkspaceDirectoryListing,
type WorkspaceExecToolRequest,
type WorkspaceExecToolResult,
type WorkspaceResolvedPath,
type WorkspaceStat,
type WorkspaceStructureQuery,

View File

@ -268,6 +268,7 @@ describe("loadPlugins", () => {
"workspace",
]);
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
"execTool",
"getCurrentProject",
"getProjectRoot",
"listDirectory",

View File

@ -125,6 +125,16 @@ function gateways(overrides: {
...overrides.window,
};
const pluginWorkspace: PluginWorkspaceGateway = {
execTool: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "ok\n",
stderr: "",
stdoutTruncated: false,
stderrTruncated: false,
durationMs: 4,
})),
readText: vi.fn(async ({ path }) => ({ path, content: "file text" })),
readBinary: vi.fn(async ({ path }) => ({ path, bytes: new Uint8Array([67]) })),
writeText: vi.fn(),
@ -332,6 +342,21 @@ describe("createPluginServices", () => {
expect(g.project.updateProjectContext).toHaveBeenCalledWith("project-1", "next context");
});
it("injects plugin and project identity into workspace execTool", async () => {
const g = gateways();
const services = createPluginServices(g, { pluginId: "dev.acme.plugin" });
await expect(
services.workspace.execTool({ command: "tool", args: ["status"] }),
).resolves.toMatchObject({ exitCode: 0, stdout: "ok\n", timedOut: false });
expect(g.pluginWorkspace.execTool).toHaveBeenCalledWith(expect.objectContaining({
pluginId: "dev.acme.plugin",
projectId: "project-1",
command: "tool",
args: ["status"],
}));
});
it("maps background tasks through the work-state gateway and wraps output attachment", async () => {
const detach = vi.fn();
const onData = vi.fn();

View File

@ -13,6 +13,7 @@ import type {
PluginConfigGateway,
PluginEventGateway,
PluginProjectStructureQuery,
PluginWorkspaceExecInput as HostPluginWorkspaceExecInput,
PluginTaskGateway,
PluginToolchainGateway,
PluginWorkspaceGateway,
@ -52,6 +53,31 @@ export interface WorkspaceService {
stat(path: string, projectId?: string): Promise<WorkspaceStat>;
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
queryStructure(query?: WorkspaceStructureQuery): Promise<ProjectStructure>;
/** Runs one manifest-allowlisted binary directly, without a visible task. */
execTool(request: WorkspaceExecToolRequest): Promise<WorkspaceExecToolResult>;
}
export interface WorkspaceExecToolRequest {
projectId?: string;
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
maxStdoutBytes?: number;
maxStderrBytes?: number;
stdin?: string;
}
export interface WorkspaceExecToolResult {
exitCode: number | null;
signal?: string;
timedOut: boolean;
stdout: string;
stderr: string;
stdoutTruncated: boolean;
stderrTruncated: boolean;
durationMs: number;
}
export interface WorkspaceResolvedPath {
@ -651,6 +677,33 @@ export function createPluginServices(
};
return gateways.pluginWorkspace.queryProjectStructure(input);
},
async execTool(request) {
if (!scope.pluginId) throw new Error("pluginId is required for workspace.execTool");
const project = await requireProject(request.projectId);
const input: HostPluginWorkspaceExecInput = {
pluginId: scope.pluginId,
projectId: project.id,
command: request.command,
args: request.args,
cwd: request.cwd,
env: request.env,
timeoutMs: request.timeoutMs,
maxStdoutBytes: request.maxStdoutBytes,
maxStderrBytes: request.maxStderrBytes,
stdin: request.stdin,
};
const result = await gateways.pluginWorkspace.execTool(input);
return {
exitCode: result.exitCode,
...(result.signal ? { signal: result.signal } : {}),
timedOut: result.timedOut,
stdout: result.stdout,
stderr: result.stderr,
stdoutTruncated: result.stdoutTruncated,
stderrTruncated: result.stderrTruncated,
durationMs: result.durationMs,
};
},
};
async function listTasks(projectId?: string): Promise<BackgroundTaskStatus[]> {

View File

@ -1746,6 +1746,30 @@ export interface PluginProjectStructureQuery {
maxEntries?: number;
}
export interface PluginWorkspaceExecInput {
pluginId: string;
projectId: string;
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
maxStdoutBytes?: number;
maxStderrBytes?: number;
}
export interface PluginWorkspaceExecResult {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
stdout: string;
stderr: string;
stdoutTruncated: boolean;
stderrTruncated: boolean;
durationMs: number;
}
export interface PluginWorkspaceGateway {
readText(input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile>;
readBinary(input: PluginWorkspacePathInput): Promise<PluginWorkspaceBinaryFile>;
@ -1754,6 +1778,7 @@ export interface PluginWorkspaceGateway {
listDir(input: PluginWorkspacePathInput): Promise<PluginWorkspaceDirectoryListing>;
stat(input: PluginWorkspacePathInput): Promise<PluginWorkspaceStat>;
queryProjectStructure(input: PluginProjectStructureQuery): Promise<PluginProjectStructure>;
execTool(input: PluginWorkspaceExecInput): Promise<PluginWorkspaceExecResult>;
}
export interface PluginRunCommandInput {

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"
>
×