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:
2026-07-26 14:56:26 +02:00
parent c807a70fea
commit ea7ea71230
21 changed files with 1298 additions and 109 deletions

View File

@ -42,6 +42,7 @@ import type {
ProjectWorkState,
ProjectSystemPermissions,
ProfileAvailability,
ProfileModelCatalogEntry,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
Skill,
@ -50,6 +51,7 @@ import type {
TurnPage,
} from "@/domain";
import type {
CloneProfileFromSeedInput,
CloneOpenCodeProfileFromSeedInput,
ConversationGateway,
ConversationPageRequest,
@ -176,6 +178,17 @@ export class HttpProfileGateway implements ProfileGateway {
async deleteProfile(profileId: string): Promise<void> {
await this.http.invoke("delete_profile", { profileId });
}
cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise<AgentProfile> {
return this.http.invoke<AgentProfile>("clone_profile_from_seed", {
request: { seedProfileId: input.seedProfileId, name: input.name, model: input.model },
});
}
listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
return this.http.invoke<ProfileModelCatalogEntry[]>("list_claude_models");
}
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
return this.http.invoke<ProfileModelCatalogEntry[]>("list_codex_models");
}
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
return this.http.invoke<AgentProfile[]>("configure_profiles", { request: { profiles } });
}

View File

@ -35,6 +35,7 @@ import type {
McpToolCatalogue,
McpToolPolicy,
OpenCodeProviderCatalogEntry,
ProfileModelCatalogEntry,
EffectivePermissions,
PairedDevice,
PairingCode,
@ -80,6 +81,7 @@ import type {
ConversationGateway,
ConversationPageRequest,
ConversationDetails,
CloneProfileFromSeedInput,
CloneOpenCodeProfileFromSeedInput,
CreateAgentInput,
CreateMemoryInput,
@ -1293,6 +1295,54 @@ const MOCK_OPENCODE_PROVIDERS: OpenCodeProviderCatalogEntry[] = [
},
];
const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [
{
adapter: "claude",
modelId: "claude-sonnet-5",
displayName: "Claude Sonnet 5",
aliases: ["sonnet"],
recommended: true,
},
{
adapter: "claude",
modelId: "claude-opus-4-8",
displayName: "Claude Opus 4.8",
aliases: ["opus"],
recommended: false,
},
{
adapter: "claude",
modelId: "claude-haiku-4-5-20251001",
displayName: "Claude Haiku 4.5",
aliases: ["haiku"],
recommended: false,
},
];
const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [
{
adapter: "codex",
modelId: "gpt-5-codex",
displayName: "GPT-5 Codex",
aliases: ["codex"],
recommended: true,
},
{
adapter: "codex",
modelId: "gpt-5",
displayName: "GPT-5",
aliases: ["general"],
recommended: false,
},
{
adapter: "codex",
modelId: "gpt-5-mini",
displayName: "GPT-5 mini",
aliases: ["mini", "fast"],
recommended: false,
},
];
/**
* In-memory profiles gateway. Tracks configured profiles and a first-run flag so
* the wizard can be driven and tested fully offline. By default it reports the
@ -1348,6 +1398,36 @@ export class MockProfileGateway implements ProfileGateway {
return structuredClone(profiles);
}
async cloneProfileFromSeed(
input: CloneProfileFromSeedInput,
): Promise<AgentProfile> {
const seed = [...this.profiles, ...MOCK_REFERENCE_PROFILES].find(
(p) => p.id === input.seedProfileId,
);
if (!seed) throw new Error(`unknown profile seed: ${input.seedProfileId}`);
this.cloneCounter += 1;
const cloned: AgentProfile = {
...structuredClone(seed),
id: `mock-profile-clone-${this.cloneCounter}`,
name: input.name ?? `${seed.name} copy`,
model:
input.model !== undefined && input.model.trim() !== ""
? input.model
: seed.model,
};
this.profiles.push(cloned);
this.configured = true;
return structuredClone(cloned);
}
async listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
return structuredClone(MOCK_CLAUDE_MODELS);
}
async listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
return structuredClone(MOCK_CODEX_MODELS);
}
async cloneOpenCodeProfileFromSeed(
input: CloneOpenCodeProfileFromSeedInput = {},
): Promise<AgentProfile> {

View File

@ -12,9 +12,11 @@ import type {
AgentProfile,
FirstRunState,
OpenCodeProviderCatalogEntry,
ProfileModelCatalogEntry,
ProfileAvailability,
} from "@/domain";
import type {
CloneProfileFromSeedInput,
CloneOpenCodeProfileFromSeedInput,
ProfileGateway,
SaveOpenCodeProviderProfileInput,
@ -47,6 +49,24 @@ export class TauriProfileGateway implements ProfileGateway {
await invoke("delete_profile", { profileId });
}
cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise<AgentProfile> {
return invoke<AgentProfile>("clone_profile_from_seed", {
request: {
seedProfileId: input.seedProfileId,
name: input.name,
model: input.model,
},
});
}
listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
return invoke<ProfileModelCatalogEntry[]>("list_claude_models");
}
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
return invoke<ProfileModelCatalogEntry[]>("list_codex_models");
}
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
return invoke<AgentProfile[]>("configure_profiles", {
request: { profiles },

View File

@ -1096,6 +1096,20 @@ export interface OpenCodeProviderCatalogEntry {
models: string[];
}
/** One searchable model from the Codex/Claude structured-profile catalogues. */
export interface ProfileModelCatalogEntry {
/** Structured adapter this model belongs to. */
adapter: "claude" | "codex";
/** Exact model identifier to persist on `AgentProfile.model`. */
modelId: string;
/** Human-readable label for picker display. */
displayName: string;
/** Extra search tokens useful to the frontend. */
aliases: string[];
/** Whether this entry is the conservative default suggestion. */
recommended: boolean;
}
/**
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
* UUID string; `detect` is the optional detection command line.

View File

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

View File

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

View 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");
});
});

View File

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

View File

@ -37,6 +37,7 @@ import type {
McpToolPolicy,
OpenCodeConfig,
OpenCodeProviderCatalogEntry,
ProfileModelCatalogEntry,
EffectivePermissions,
PairedDevice,
PairingCode,
@ -664,6 +665,15 @@ export interface ProfileGateway {
saveProfile(profile: AgentProfile): Promise<AgentProfile>;
/** Deletes a profile by id. */
deleteProfile(profileId: string): Promise<void>;
/**
* Clones a persisted or reference profile seed and saves the fresh profile.
* Used by Settings duplication for Codex/Claude/OpenCode identity copies.
*/
cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise<AgentProfile>;
/** Curated Claude Code model catalogue. Manual model entry remains supported. */
listClaudeModels(): Promise<ProfileModelCatalogEntry[]>;
/** Curated Codex CLI model catalogue. Manual model entry remains supported. */
listCodexModels(): Promise<ProfileModelCatalogEntry[]>;
/** Persists the batch of chosen profiles, closing the first run. */
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
/**
@ -701,6 +711,16 @@ export interface CloneOpenCodeProfileFromSeedInput {
opencode?: OpenCodeConfig;
}
/** Input for {@link ProfileGateway.cloneProfileFromSeed}. */
export interface CloneProfileFromSeedInput {
/** Id of the persisted or reference profile to clone. */
seedProfileId: string;
/** Optional display name for the new profile. */
name?: string;
/** Optional model override. When omitted, the seed model is copied. */
model?: string;
}
/** Input for {@link ProfileGateway.saveOpenCodeProviderProfile}. */
export interface SaveOpenCodeProviderProfileInput {
/** The profile to create or replace (by id). */