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:
@ -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 () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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