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

@ -12,6 +12,7 @@ import {
hasProjectCliInteraction,
} from "@/features/projects/projectCliInteractionActivity";
import { publishProjectIconChanged } from "@/features/projects/projectIcons";
import { publishProjectThemeChanged } from "@/features/projects/projectTheme";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
@ -213,9 +214,17 @@ describe("CustomAgentChatView", () => {
expect(watermark.getAttribute("aria-hidden")).toBe("true");
expect((watermark.firstElementChild as HTMLElement | null)?.style.width).toBe("92%");
expect((watermark.firstElementChild as HTMLElement | null)?.style.height).toBe("92%");
expect(watermark.textContent).toBe("🐍");
expect(watermark.textContent).toBe("🐍\uFE0E");
expect(watermark.dataset.colorMode).toBe("monochrome");
publishProjectIconChanged({ projectId: "project-1", icon: { set: "emoji", name: "star" } });
await waitFor(() => expect(watermark.textContent).toBe("⭐"));
await waitFor(() => expect(watermark.textContent).toBe("⭐\uFE0E"));
publishProjectThemeChanged({ projectId: "project-1", themeColor: "#7c3aed" });
await waitFor(() => {
expect(watermark.dataset.colorMode).toBe("project");
expect(watermark.style.color).toBe("rgb(124, 58, 237)");
});
expect(screen.getByTestId("custom-agent-chat-scroll").className).toContain("z-10");
expect(screen.getByTestId("custom-agent-chat-composer").className).toContain("bg-raised/40");
});
it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => {

View File

@ -45,6 +45,7 @@ import {
} from "@/features/projects/projectCliInteractionActivity";
import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared";
import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons";
import { onProjectThemeChanged, validThemeColor } from "@/features/projects/projectTheme";
import type {
ChatAttachmentInput,
ChatDraftScope,
@ -1012,6 +1013,7 @@ export function CustomAgentChatView({
const [stopTerminalOverride, setStopTerminalOverride] = useState(false);
const [error, setError] = useState<ChatErrorState | null>(null);
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
const [projectThemeColor, setProjectThemeColor] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const chatRootRef = useRef<HTMLDivElement | null>(null);
@ -1062,12 +1064,24 @@ export function CustomAgentChatView({
useEffect(() => {
let cancelled = false;
void project?.readProjectSettings(projectId).then((settings) => {
if (!cancelled) setProjectIcon(settings.icon ?? null);
if (!cancelled) {
setProjectIcon(settings.icon ?? null);
setProjectThemeColor(validThemeColor(settings.themeColor));
}
}).catch(() => undefined);
const unsubscribe = onProjectIconChanged((change) => {
const unsubscribeIcon = onProjectIconChanged((change) => {
if (change.projectId === projectId) setProjectIcon(change.icon);
});
return () => { cancelled = true; unsubscribe(); };
const unsubscribeTheme = onProjectThemeChanged((change) => {
if (change.projectId === projectId) {
setProjectThemeColor(validThemeColor(change.themeColor));
}
});
return () => {
cancelled = true;
unsubscribeIcon();
unsubscribeTheme();
};
}, [project, projectId]);
const clampComposerHeight = useCallback(
@ -2494,9 +2508,14 @@ export function CustomAgentChatView({
>
<div
data-testid="project-icon-watermark"
data-color-mode={projectThemeColor ? "project" : "monochrome"}
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center overflow-hidden text-content opacity-[0.035]"
style={{ pointerEvents: "none", containerType: "size" }}
className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center overflow-hidden text-content opacity-[0.055]"
style={{
pointerEvents: "none",
containerType: "size",
color: projectThemeColor ?? undefined,
}}
>
<ProjectIcon
icon={projectIcon}
@ -2521,14 +2540,14 @@ export function CustomAgentChatView({
</div>
{!supported && (
<p role="alert" className="m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
<p role="alert" className="relative z-10 m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
{t("custom.unsupported")}
</p>
)}
{error && (
<div
role="alert"
className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger"
className="relative z-10 m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger"
>
{error.openFailure ? (
<div className="flex min-w-0 flex-col gap-2">
@ -2569,7 +2588,7 @@ export function CustomAgentChatView({
aria-modal="true"
aria-labelledby={`profile-command-title-${domId}`}
className={cn(
"border border-border bg-surface p-3 shadow-lg",
"relative z-30 border border-border bg-surface p-3 shadow-lg",
mobile
? "fixed inset-x-0 bottom-0 z-40 m-0 rounded-t-md pb-[max(0.75rem,env(safe-area-inset-bottom))]"
: "m-3 rounded-md",
@ -2667,7 +2686,7 @@ export function CustomAgentChatView({
<div
ref={scrollRef}
data-testid="custom-agent-chat-scroll"
className="flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
>
<div className="shrink-0">
<AnnouncementsPreview
@ -2738,7 +2757,7 @@ export function CustomAgentChatView({
<div
data-testid="custom-agent-chat-composer"
className={cn(
"flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2",
"relative z-10 flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2",
mobile && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
)}
>
@ -2947,7 +2966,7 @@ function ChatBubble({
if (turn.role === "tool") {
return (
<div className="flex min-w-0 justify-center">
<span className="flex max-w-[80%] items-baseline gap-2 truncate rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted">
<span className="flex max-w-[80%] items-baseline gap-2 truncate rounded-md border border-border bg-raised/80 px-2 py-1 text-xs text-muted">
<span className="truncate">{turn.label === "Activité" ? tr("Activité", "Activity") : turn.label}</span>
<EntryTimestamp atMs={turn.receivedAtMs} />
</span>
@ -3007,7 +3026,7 @@ function ChatBubble({
<div
className={cn(
"min-w-0 max-w-[82%] overflow-hidden rounded-lg border px-3 py-2 text-sm text-content",
user ? "border-border bg-raised" : "border-primary/25 bg-primary/10",
user ? "border-border bg-raised/85" : "border-primary/25 bg-primary/10",
)}
>
<ChatMessageMeta

View File

@ -171,6 +171,51 @@ describe("project plugin settings #282", () => {
expect(assigned?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" });
});
it("shows stable refs without blocking assignment when skill names are ambiguous", async () => {
const gateways = gatewaysWithPlugin();
const duplicatedContribution = manifest();
duplicatedContribution.contributes.skills = [
duplicatedContribution.contributes.skills![0],
duplicatedContribution.contributes.skills![0],
];
gateways.plugin._seedProjectPluginManifest(duplicatedContribution);
const secondPlugin = manifest();
secondPlugin.id = "dev.acme.android";
secondPlugin.displayName = "Android Plugin";
secondPlugin.contributes.skills = [{
...secondPlugin.contributes.skills![0],
id: "android.debug",
}];
gateways.plugin._seedProjectPluginManifest(secondPlugin);
await gateways.agent.createAgent("project-1", {
name: "Dev",
profileId: "profile-1",
});
gateways.plugin._seedProjectPluginSettings("project-1", {
version: 1,
plugins: [
{ pluginId: "dev.acme.unity", enabled: true },
{ pluginId: "dev.acme.android", enabled: true },
],
agents: [],
});
render(
<DIProvider gateways={gateways}>
<SkillsPanel projectId="project-1" />
</DIProvider>,
);
expect((await screen.findByRole("status")).textContent).toContain(
"Plusieurs skills portent le même nom",
);
expect(screen.getByText("plugin:dev.acme.unity:unity.debug")).toBeTruthy();
expect(screen.getByText("plugin:dev.acme.android:android.debug")).toBeTruthy();
// A malformed manifest repeating the exact same stable ref never creates a
// second assignment control; only genuinely distinct refs stay visible.
expect(screen.getAllByRole("checkbox", { name: /unity-debug/ })).toHaveLength(2);
});
it("lists catalogued plugin tools in MCP and removes free-form assignment", async () => {
const gateways = gatewaysWithPlugin();
const agent = await gateways.agent.createAgent("project-1", {

View File

@ -7,7 +7,7 @@
* read as faint text rather than a control.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import { createEvent, render, screen, fireEvent, within } from "@testing-library/react";
import type { TabItem } from "@/shared";
import { ProjectTabs } from "./ProjectTabs";
@ -123,6 +123,60 @@ describe("ProjectTabs reordering (#347)", () => {
expect(onClose).not.toHaveBeenCalled();
});
it("opens a stable animated gap on the side selected by the pointer midpoint", () => {
renderTabs({ onReorder: vi.fn() });
const alphaContainer = screen.getByRole("tab", { name: /alpha/ }).parentElement!;
const betaContainer = screen.getByRole("tab", { name: /beta/ }).parentElement!;
const rectSpy = vi.spyOn(betaContainer, "getBoundingClientRect").mockReturnValue({
left: 100,
width: 120,
right: 220,
top: 0,
bottom: 32,
height: 32,
x: 100,
y: 0,
toJSON: () => ({}),
});
const dataTransfer = {
effectAllowed: "none",
dropEffect: "none",
setData: vi.fn(),
getData: vi.fn(() => "a"),
};
fireEvent.dragStart(alphaContainer, { dataTransfer });
expect(alphaContainer.dataset.dragged).toBe("true");
expect(alphaContainer.className).toContain("opacity-90");
const beforeEvent = createEvent.dragOver(betaContainer, { dataTransfer });
Object.defineProperty(beforeEvent, "clientX", { value: 130 });
fireEvent(betaContainer, beforeEvent);
expect(betaContainer.dataset.dropPosition).toBe("before");
expect(betaContainer.style.marginInlineStart).toBe("0.75rem");
expect(betaContainer.className).toContain("duration-150");
expect(betaContainer.className).toContain("ease-out");
expect(betaContainer.className).toContain("motion-reduce:transition-none");
rectSpy.mockReturnValue({
left: -200,
width: 120,
right: -80,
top: 0,
bottom: 32,
height: 32,
x: -200,
y: 0,
toJSON: () => ({}),
});
const afterEvent = createEvent.dragOver(betaContainer, { dataTransfer });
Object.defineProperty(afterEvent, "clientX", { value: 190 });
fireEvent(betaContainer, afterEvent);
expect(betaContainer.dataset.dropPosition).toBe("after");
expect(betaContainer.style.marginInlineStart).toBe("");
expect(betaContainer.style.marginInlineEnd).toBe("0.75rem");
});
it("moves the focused tab with Alt+Arrow and announces the change", () => {
const onReorder = vi.fn();
renderTabs({ onReorder });

View File

@ -42,6 +42,24 @@ describe("project emoji catalogue", () => {
expect(getByRole("img", { name: "A" }).textContent).toBe("A");
expect(getByRole("img").dataset.projectIcon).toBe("none");
});
it("requests a monochrome text presentation for watermark emoji only", () => {
const { getByTestId } = render(
<>
<ProjectIcon icon={{ set: "emoji", name: "star" }} data-testid="product-icon" />
<ProjectIcon
icon={{ set: "emoji", name: "star" }}
surface="watermark"
decorative
data-testid="watermark-icon"
/>
</>,
);
expect(getByTestId("product-icon").textContent).toBe("⭐");
expect(getByTestId("watermark-icon").textContent).toBe("⭐\uFE0E");
expect((getByTestId("watermark-icon") as HTMLElement).style.fontFamily).toContain("system-ui");
});
});
describe("ProjectIconPicker", () => {

View File

@ -1,4 +1,4 @@
import type { CSSProperties } from "react";
import type { CSSProperties, HTMLAttributes } from "react";
import type { ProjectIconRef } from "@/ports";
export type ProjectIconGroup =
@ -197,7 +197,7 @@ function fallbackInitial(ref: ProjectIconRef | null, fallbackLabel?: string): st
return first.toUpperCase();
}
export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, style, decorative = false, surface = "product-ui" }: {
export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, style, decorative = false, surface = "product-ui", ...htmlProps }: {
icon: ProjectIconRef | null;
fallbackLabel?: string;
size?: number | string;
@ -205,12 +205,17 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
style?: CSSProperties;
decorative?: boolean;
surface?: ProjectIconSurface;
}) {
} & Omit<HTMLAttributes<HTMLSpanElement>, "children" | "style" | "className">) {
const entry = resolveProjectIcon(ref, surface);
const label = entry?.label ?? fallbackInitial(ref, fallbackLabel);
const numericSize = typeof size === "number" ? size : null;
const glyph = entry?.glyph ?? fallbackInitial(ref, fallbackLabel);
const renderedGlyph = surface === "watermark" && entry
? `${glyph.replace(/\uFE0F/g, "\uFE0E")}${glyph.includes("\uFE0F") ? "" : "\uFE0E"}`
: glyph;
return (
<span
{...htmlProps}
className={className}
role={decorative ? undefined : "img"}
aria-hidden={decorative || undefined}
@ -221,11 +226,13 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
style={{
display: "inline-flex", alignItems: "center", justifyContent: "center",
width: size, height: size, fontSize: numericSize ? Math.max(16, numericSize * 0.72) : "62%",
fontFamily: '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif',
fontFamily: surface === "watermark"
? "ui-sans-serif, system-ui, sans-serif"
: '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif',
fontWeight: entry ? 400 : 700, lineHeight: 1, flexShrink: 0, ...style,
}}
>
{entry?.glyph ?? fallbackInitial(ref, fallbackLabel)}
{renderedGlyph}
</span>
);
}

View File

@ -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>

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