feat: finalise multi-profil Codex/Claude avec catalogue de modèles
- Backend : clone_profile_from_seed généralisé (non OpenCode) - Backend : catalogue static Claude/Codex (3 modèles chacun, 1 recommandé) - Backend : commandes Tauri list_claude_models/list_codex_models - Frontend : ProfilesSettings refonte en onglets Codex/Claude + create/duplicate/edit/delete - Frontend : ModelSelect searchable partagé + fallback saisie manuelle - Frontend : assignation agent nom · modèle - Tests QA : 4 profils modèles distincts (2 Claude, 2 Codex) assignés à agents
This commit is contained in:
@ -237,6 +237,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
// Determine if a template is chosen → profile selector is hidden (template imposes it).
|
||||
const hasTemplate = newTemplateId !== "";
|
||||
|
||||
const profileLabel = (profile: import("@/domain").AgentProfile): string => {
|
||||
const model =
|
||||
profile.model ??
|
||||
profile.opencode?.model ??
|
||||
profile.opencodeProvider?.model ??
|
||||
profile.chatHttp?.model;
|
||||
return model ? `${profile.name} · ${model}` : profile.name;
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel title="Agents" className="flex flex-col gap-0">
|
||||
{vm.error && (
|
||||
@ -326,7 +335,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
<option value="">— select profile —</option>
|
||||
{vm.profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{profileLabel(p)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@ -366,7 +375,10 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
const isRunning = a.id === activeAgentId;
|
||||
const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id);
|
||||
const profileName =
|
||||
vm.profiles.find((p) => p.id === a.profileId)?.name ??
|
||||
(() => {
|
||||
const p = vm.profiles.find((p) => p.id === a.profileId);
|
||||
return p ? profileLabel(p) : null;
|
||||
})() ??
|
||||
a.profileId;
|
||||
const agentDrift = drift.driftByAgentId.get(a.id);
|
||||
// Source of this agent's last orchestration delegation (mcp vs
|
||||
@ -478,7 +490,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
)}
|
||||
{vm.profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{profileLabel(p)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@ -141,6 +141,42 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows profile names with their model in the assignment selector", async () => {
|
||||
const profile = new MockProfileGateway();
|
||||
await profile.saveProfile({
|
||||
id: "codex-fast",
|
||||
name: "Codex fast",
|
||||
command: "codex",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||
detect: "codex --version",
|
||||
cwdTemplate: "{projectRoot}",
|
||||
structuredAdapter: "codex",
|
||||
model: "gpt-5-mini",
|
||||
});
|
||||
await profile.saveProfile({
|
||||
id: "claude-opus",
|
||||
name: "Claude deep",
|
||||
command: "claude",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
|
||||
detect: "claude --version",
|
||||
cwdTemplate: "{projectRoot}",
|
||||
structuredAdapter: "claude",
|
||||
model: "claude-opus-4-8",
|
||||
});
|
||||
|
||||
renderPanel(new MockAgentGateway(), profile);
|
||||
await waitForIdle();
|
||||
|
||||
const labels = Array.from(
|
||||
screen.getByLabelText("agent profile").querySelectorAll("option"),
|
||||
).map((option) => option.textContent);
|
||||
|
||||
expect(labels).toContain("Codex fast · gpt-5-mini");
|
||||
expect(labels).toContain("Claude deep · claude-opus-4-8");
|
||||
});
|
||||
|
||||
it("selecting an agent displays its context", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
// Pre-seed an agent with initial content.
|
||||
|
||||
113
frontend/src/features/first-run/ProfilesSettings.test.tsx
Normal file
113
frontend/src/features/first-run/ProfilesSettings.test.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { MockProfileGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import type { ProfileModelCatalogEntry } from "@/domain";
|
||||
import { ProfilesSettings } from "./ProfilesSettings";
|
||||
|
||||
function renderSettings(profile: MockProfileGateway = new MockProfileGateway()) {
|
||||
return {
|
||||
profile,
|
||||
...render(
|
||||
<DIProvider gateways={{ profile } as unknown as Gateways}>
|
||||
<ProfilesSettings />
|
||||
</DIProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitReady() {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Creer un profil" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false),
|
||||
);
|
||||
}
|
||||
|
||||
async function createProfile() {
|
||||
const before = screen.queryAllByRole("listitem").length;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Creer un profil" }));
|
||||
await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(before + 1));
|
||||
}
|
||||
|
||||
describe("ProfilesSettings", () => {
|
||||
it("creates multiple named Codex and Claude profiles with different models", async () => {
|
||||
const { profile } = renderSettings();
|
||||
await waitReady();
|
||||
|
||||
await createProfile();
|
||||
await createProfile();
|
||||
let rows = screen.getAllByRole("listitem");
|
||||
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
|
||||
target: { value: "Codex mini" },
|
||||
});
|
||||
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
|
||||
target: { value: "gpt-5-mini" },
|
||||
});
|
||||
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Claude" }));
|
||||
await waitReady();
|
||||
await createProfile();
|
||||
await createProfile();
|
||||
rows = screen.getAllByRole("listitem");
|
||||
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
|
||||
target: { value: "Claude Opus" },
|
||||
});
|
||||
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
|
||||
target: { value: "claude-opus-4-8" },
|
||||
});
|
||||
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
expect(saved.filter((p) => p.structuredAdapter === "codex")).toHaveLength(2);
|
||||
expect(saved.filter((p) => p.structuredAdapter === "claude")).toHaveLength(2);
|
||||
expect(saved.map((p) => p.model)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"gpt-5-codex",
|
||||
"gpt-5-mini",
|
||||
"claude-sonnet-5",
|
||||
"claude-opus-4-8",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("duplicates from an existing profile with '<name> copy' and preserves the model", async () => {
|
||||
const { profile } = renderSettings();
|
||||
await waitReady();
|
||||
await createProfile();
|
||||
|
||||
const row = screen.getAllByRole("listitem")[0];
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Dupliquer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
expect(saved.some((p) => p.name === "OpenAI Codex CLI copy copy")).toBe(true);
|
||||
expect(saved.filter((p) => p.model === "gpt-5-codex")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps manual model entry available when the catalogue fails", async () => {
|
||||
class CatalogueDownProfileGateway extends MockProfileGateway {
|
||||
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
|
||||
return Promise.reject(new Error("catalogue down"));
|
||||
}
|
||||
}
|
||||
|
||||
renderSettings(new CatalogueDownProfileGateway());
|
||||
await waitReady();
|
||||
expect(await screen.findByText(/saisie manuelle active/)).toBeTruthy();
|
||||
|
||||
await createProfile();
|
||||
const model = within(screen.getAllByRole("listitem")[0]).getByLabelText(
|
||||
/modele du profil/,
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(model, { target: { value: "future-codex-model" } });
|
||||
expect(model.value).toBe("future-codex-model");
|
||||
});
|
||||
});
|
||||
@ -1,34 +1,94 @@
|
||||
/**
|
||||
* Minimal "Settings → AI Profiles" panel (L5). An always-available entry point
|
||||
* to review the configured profiles and re-run the setup wizard after the first
|
||||
* run. Kept intentionally small; richer per-profile editing reuses the wizard.
|
||||
*
|
||||
* Pure presentation over the {@link ProfileGateway} port (no `invoke()`).
|
||||
* Settings -> AI Profiles. This is the durable CRUD surface for named runtime
|
||||
* profiles; first-run stays a small default-profile bootstrap.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { AgentProfile, GatewayError } from "@/domain";
|
||||
import type {
|
||||
AgentProfile,
|
||||
GatewayError,
|
||||
ProfileModelCatalogEntry,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { Button, Panel } from "@/shared";
|
||||
import { FirstRunWizard } from "./FirstRunWizard";
|
||||
import { Button, Input, Panel, cn } from "@/shared";
|
||||
|
||||
type ProfileTab = "codex" | "claude" | "openCode";
|
||||
|
||||
const TABS: Array<{ id: ProfileTab; label: string }> = [
|
||||
{ id: "codex", label: "Codex" },
|
||||
{ id: "claude", label: "Claude" },
|
||||
{ id: "openCode", label: "OpenCode-local" },
|
||||
];
|
||||
|
||||
const EMPTY_CATALOGUE: Record<"codex" | "claude", ProfileModelCatalogEntry[]> = {
|
||||
codex: [],
|
||||
claude: [],
|
||||
};
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
function tabFor(profile: AgentProfile): ProfileTab | null {
|
||||
if (profile.structuredAdapter === "codex") return "codex";
|
||||
if (profile.structuredAdapter === "claude") return "claude";
|
||||
if (profile.structuredAdapter === "openCode" && profile.opencode) {
|
||||
return "openCode";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function modelOf(profile: AgentProfile): string {
|
||||
if (profile.structuredAdapter === "openCode") {
|
||||
return profile.opencode?.model ?? profile.opencodeProvider?.model ?? "";
|
||||
}
|
||||
return profile.model ?? "";
|
||||
}
|
||||
|
||||
function withModel(profile: AgentProfile, model: string): AgentProfile {
|
||||
const nextModel = model.trim() || undefined;
|
||||
if (profile.structuredAdapter === "openCode" && profile.opencode) {
|
||||
return {
|
||||
...profile,
|
||||
opencode: { ...profile.opencode, model: model.trim() },
|
||||
};
|
||||
}
|
||||
return { ...profile, model: nextModel };
|
||||
}
|
||||
|
||||
function optionLabel(entry: ProfileModelCatalogEntry): string {
|
||||
return entry.recommended
|
||||
? `${entry.displayName} (${entry.modelId}, recommande)`
|
||||
: `${entry.displayName} (${entry.modelId})`;
|
||||
}
|
||||
|
||||
export function ProfilesSettings() {
|
||||
const { profile } = useGateways();
|
||||
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
||||
const [references, setReferences] = useState<AgentProfile[]>([]);
|
||||
const [catalogue, setCatalogue] = useState(EMPTY_CATALOGUE);
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>("codex");
|
||||
const [drafts, setDrafts] = useState<Record<string, AgentProfile>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [catalogueWarning, setCatalogueWarning] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
setProfiles(await profile.listProfiles());
|
||||
const [saved, refs] = await Promise.all([
|
||||
profile.listProfiles(),
|
||||
profile.referenceProfiles(),
|
||||
]);
|
||||
setProfiles(saved);
|
||||
setReferences(refs);
|
||||
setDrafts(Object.fromEntries(saved.map((p) => [p.id, p])));
|
||||
} catch (e) {
|
||||
setError(
|
||||
e && typeof e === "object" && "message" in e
|
||||
? String((e as GatewayError).message)
|
||||
: String(e),
|
||||
);
|
||||
setError(describe(e));
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
@ -36,64 +96,263 @@ export function ProfilesSettings() {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function del(id: string) {
|
||||
await profile.deleteProfile(id);
|
||||
await refresh();
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadCatalogue() {
|
||||
setCatalogueWarning(null);
|
||||
const [codex, claude] = await Promise.allSettled([
|
||||
profile.listCodexModels(),
|
||||
profile.listClaudeModels(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setCatalogue({
|
||||
codex: codex.status === "fulfilled" ? codex.value : [],
|
||||
claude: claude.status === "fulfilled" ? claude.value : [],
|
||||
});
|
||||
if (codex.status === "rejected" || claude.status === "rejected") {
|
||||
setCatalogueWarning(
|
||||
"Catalogue de modeles indisponible: saisie manuelle active.",
|
||||
);
|
||||
}
|
||||
}
|
||||
void loadCatalogue();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [profile]);
|
||||
|
||||
const visibleProfiles = useMemo(
|
||||
() => profiles.filter((p) => tabFor(p) === activeTab),
|
||||
[profiles, activeTab],
|
||||
);
|
||||
|
||||
const seed = useMemo(
|
||||
() => references.find((p) => tabFor(p) === activeTab) ?? null,
|
||||
[references, activeTab],
|
||||
);
|
||||
|
||||
function updateDraft(id: string, updater: (profile: AgentProfile) => AgentProfile) {
|
||||
setDrafts((prev) => {
|
||||
const current = prev[id] ?? profiles.find((p) => p.id === id);
|
||||
if (!current) return prev;
|
||||
return { ...prev, [id]: updater(current) };
|
||||
});
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
// Reopened after the first run, so force the wizard to render.
|
||||
return (
|
||||
<FirstRunWizard
|
||||
forceOpen
|
||||
onDone={() => {
|
||||
setEditing(false);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
async function createFromSeed() {
|
||||
if (!seed) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const models =
|
||||
activeTab === "codex" || activeTab === "claude"
|
||||
? catalogue[activeTab]
|
||||
: [];
|
||||
const recommended = models.find((m) => m.recommended)?.modelId;
|
||||
await profile.cloneProfileFromSeed({
|
||||
seedProfileId: seed.id,
|
||||
name: `${seed.name} copy`,
|
||||
model: recommended,
|
||||
});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(id: string) {
|
||||
const draft = drafts[id];
|
||||
if (!draft) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await profile.saveProfile(draft);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicate(source: AgentProfile) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await profile.cloneProfileFromSeed({
|
||||
seedProfileId: source.id,
|
||||
name: `${source.name} copy`,
|
||||
model:
|
||||
source.structuredAdapter === "codex" ||
|
||||
source.structuredAdapter === "claude"
|
||||
? modelOf(source) || undefined
|
||||
: undefined,
|
||||
});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function del(source: AgentProfile) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await profile.deleteProfile(source.id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const modelOptions =
|
||||
activeTab === "codex" || activeTab === "claude" ? catalogue[activeTab] : [];
|
||||
|
||||
return (
|
||||
<Panel
|
||||
aria-label="ai profiles settings"
|
||||
title="Profils IA"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setEditing(true)}>
|
||||
Configurer les profils
|
||||
<Button size="sm" onClick={() => void createFromSeed()} disabled={!seed || busy}>
|
||||
Creer un profil
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="types de profils IA"
|
||||
className="inline-flex w-fit rounded-md border border-border bg-raised p-0.5"
|
||||
>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"h-7 rounded px-3 text-xs font-medium transition-colors",
|
||||
activeTab === tab.id
|
||||
? "bg-surface text-content shadow-sm"
|
||||
: "text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{catalogueWarning && (
|
||||
<p className="text-xs text-muted">{catalogueWarning}</p>
|
||||
)}
|
||||
|
||||
{profiles.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun profil configuré.</p>
|
||||
<datalist id={`profile-models-${activeTab}`}>
|
||||
{modelOptions.map((entry) => (
|
||||
<option key={entry.modelId} value={entry.modelId}>
|
||||
{optionLabel(entry)}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{visibleProfiles.length === 0 ? (
|
||||
<p className="text-sm text-muted">
|
||||
Aucun profil {TABS.find((tab) => tab.id === activeTab)?.label} configure.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{profiles.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span className="flex items-baseline gap-2">
|
||||
<strong className="text-sm text-content">{p.name}</strong>
|
||||
<code className="text-xs text-muted">{p.command}</code>
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`supprimer ${p.name}`}
|
||||
onClick={() => void del(p.id)}
|
||||
<ul className="flex flex-col gap-3">
|
||||
{visibleProfiles.map((saved) => {
|
||||
const draft = drafts[saved.id] ?? saved;
|
||||
const model = modelOf(draft);
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
|
||||
return (
|
||||
<li
|
||||
key={saved.id}
|
||||
className="rounded-md border border-border bg-surface p-3"
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<label className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">Nom</span>
|
||||
<Input
|
||||
aria-label={`nom du profil ${saved.name}`}
|
||||
value={draft.name}
|
||||
onChange={(e) =>
|
||||
updateDraft(saved.id, (p) => ({
|
||||
...p,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">Modele</span>
|
||||
<Input
|
||||
aria-label={`modele du profil ${saved.name}`}
|
||||
list={`profile-models-${activeTab}`}
|
||||
placeholder={
|
||||
modelOptions.length > 0
|
||||
? "Choisir ou saisir un modele"
|
||||
: "Saisir un modele"
|
||||
}
|
||||
value={model}
|
||||
onChange={(e) =>
|
||||
updateDraft(saved.id, (p) =>
|
||||
withModel(p, e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<code className="min-w-0 truncate text-xs text-muted">
|
||||
{draft.command}
|
||||
{model ? ` · ${model}` : ""}
|
||||
</code>
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={!dirty || busy || draft.name.trim() === ""}
|
||||
onClick={() => void save(saved.id)}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => void duplicate(saved)}
|
||||
>
|
||||
Dupliquer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
className="text-danger hover:text-danger"
|
||||
aria-label={`supprimer ${saved.name}`}
|
||||
onClick={() => void del(saved)}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user