feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)

Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
This commit is contained in:
2026-09-11 21:53:56 +02:00
parent cd744b5f18
commit 1a1429f10e
157 changed files with 4531 additions and 3129 deletions

View File

@ -49,7 +49,7 @@ function renderWizard(
}
async function waitForLoaded() {
await screen.findByLabelText("first run setup");
await screen.findByLabelText("Configuration initiale");
}
describe("FirstRunWizard (with MockProfileGateway)", () => {
@ -64,11 +64,11 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
// Gemini/Aider are no longer proposed for selection.
expect(screen.queryByText("Gemini CLI")).toBeNull();
expect(screen.queryByText("Aider")).toBeNull();
expect(screen.queryByLabelText("Aider command")).toBeNull();
expect(screen.queryByLabelText("Commande de Aider")).toBeNull();
// Commands are editable inputs, pre-filled.
const claudeCmd = screen.getByLabelText(
"Claude Code command",
"Commande de Claude Code",
) as HTMLInputElement;
expect(claudeCmd.value).toBe("claude");
});
@ -77,7 +77,7 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
renderWizard();
await waitForLoaded();
const cmd = screen.getByLabelText(
"OpenAI Codex CLI command",
"Commande de OpenAI Codex CLI",
) as HTMLInputElement;
fireEvent.change(cmd, { target: { value: "codex-2" } });
expect(cmd.value).toBe("codex-2");
@ -88,18 +88,18 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
await waitForLoaded();
await waitFor(() =>
expect(
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
(screen.getByLabelText("Utiliser Claude Code") as HTMLInputElement).checked,
).toBe(true),
);
const args = screen.getByLabelText("Claude Code args") as HTMLInputElement;
const args = screen.getByLabelText("Arguments de Claude Code") as HTMLInputElement;
fireEvent.change(args, {
target: { value: "--model claude-sonnet-4-5 --verbose " },
});
expect(args.value).toBe("--model claude-sonnet-4-5 --verbose ");
fireEvent.blur(args);
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const [saved] = await profile.listProfiles();
@ -111,16 +111,16 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
renderWizard();
await waitForLoaded();
fireEvent.click(screen.getByRole("button", { name: "Detect installed CLIs" }));
fireEvent.click(screen.getByRole("button", { name: "Détecter les CLI installées" }));
await waitFor(() => {
expect(
screen.getByLabelText("Claude Code availability").textContent,
).toMatch(/installed/);
screen.getByLabelText("Disponibilité de Claude Code").textContent,
).toMatch(/installée/);
});
expect(
screen.getByLabelText("OpenAI Codex CLI availability").textContent,
).toMatch(/not found/);
screen.getByLabelText("Disponibilité de OpenAI Codex CLI").textContent,
).toMatch(/introuvable/);
});
// §17.3/D7 — anti-regression guard. The custom-profile block was removed
@ -135,8 +135,8 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
expect(
screen.queryByRole("button", { name: "Add custom profile" }),
).toBeNull();
expect(screen.queryByLabelText("custom name")).toBeNull();
expect(screen.queryByLabelText("custom command")).toBeNull();
expect(screen.queryByLabelText("Nom de custom")).toBeNull();
expect(screen.queryByLabelText("Commande de custom")).toBeNull();
});
it("auto-detects on open and pre-checks only installed CLIs", async () => {
@ -146,16 +146,16 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
// The mock reports only `claude` as installed → only it is pre-checked.
await waitFor(() =>
expect(
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
(screen.getByLabelText("Utiliser Claude Code") as HTMLInputElement).checked,
).toBe(true),
);
expect(
(screen.getByLabelText("use OpenAI Codex CLI") as HTMLInputElement).checked,
(screen.getByLabelText("Utiliser OpenAI Codex CLI") as HTMLInputElement).checked,
).toBe(false);
// Availability was filled automatically, without clicking the button.
expect(
screen.getByLabelText("Claude Code availability").textContent,
).toMatch(/installed/);
screen.getByLabelText("Disponibilité de Claude Code").textContent,
).toMatch(/installée/);
});
it("finishing persists the auto-selected (installed) profiles and closes the first run", async () => {
@ -163,15 +163,15 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
await waitForLoaded();
await waitFor(() =>
expect(
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
(screen.getByLabelText("Utiliser Claude Code") as HTMLInputElement).checked,
).toBe(true),
);
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(() => expect(onDone).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByLabelText("first run setup")).toBeNull(),
expect(screen.queryByLabelText("Configuration initiale")).toBeNull(),
);
// Only the installed CLI was pre-selected, so only it is persisted.
@ -185,13 +185,13 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
await waitForLoaded();
await waitFor(() =>
expect(
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
(screen.getByLabelText("Utiliser Claude Code") as HTMLInputElement).checked,
).toBe(true),
);
// Keep Codex too (not installed, unchecked by default).
fireEvent.click(screen.getByLabelText("use OpenAI Codex CLI"));
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByLabelText("Utiliser OpenAI Codex CLI"));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
@ -206,7 +206,7 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
// No wizard section ever appears.
await waitFor(() =>
expect(screen.queryByLabelText("first run setup")).toBeNull(),
expect(screen.queryByLabelText("Configuration initiale")).toBeNull(),
);
expect(container.querySelector("section")).toBeNull();
});
@ -218,24 +218,24 @@ describe("FirstRunWizard — Codex/Claude model configuration (ticket #99)", ()
await waitForLoaded();
await waitFor(() =>
expect(
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
(screen.getByLabelText("Utiliser Claude Code") as HTMLInputElement).checked,
).toBe(true),
);
fireEvent.click(screen.getByLabelText("use OpenAI Codex CLI"));
fireEvent.click(screen.getByLabelText("Utiliser OpenAI Codex CLI"));
const row = within(
screen.getByLabelText("use OpenAI Codex CLI").closest("li")!,
screen.getByLabelText("Utiliser OpenAI Codex CLI").closest("li")!,
);
expect(row.queryByLabelText("OpenAI Codex CLI provider")).toBeNull();
expect(row.queryByLabelText("Fournisseur de OpenAI Codex CLI")).toBeNull();
expect(row.queryByLabelText("OpenAI Codex CLI provider search")).toBeNull();
expect(row.queryByLabelText("OpenAI Codex CLI api key")).toBeNull();
expect(row.queryByLabelText("Clé API de OpenAI Codex CLI")).toBeNull();
fireEvent.change(row.getByLabelText("OpenAI Codex CLI model"), {
fireEvent.change(row.getByLabelText("Modèle de OpenAI Codex CLI"), {
target: { value: "gpt-5-codex" },
});
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
@ -251,20 +251,20 @@ describe("FirstRunWizard — Codex/Claude model configuration (ticket #99)", ()
await waitForLoaded();
const claudeToggle = screen.getByLabelText(
"use Claude Code",
"Utiliser Claude Code",
) as HTMLInputElement;
if (!claudeToggle.checked) fireEvent.click(claudeToggle);
const row = within(claudeToggle.closest("li")!);
expect(row.queryByLabelText("Claude Code provider")).toBeNull();
expect(row.queryByLabelText("Fournisseur de Claude Code")).toBeNull();
expect(row.queryByLabelText("Claude Code provider search")).toBeNull();
expect(row.queryByLabelText("Claude Code api key")).toBeNull();
expect(row.queryByLabelText("Clé API de Claude Code")).toBeNull();
fireEvent.change(row.getByLabelText("Claude Code model"), {
fireEvent.change(row.getByLabelText("Modèle de Claude Code"), {
target: { value: "claude-sonnet-4-5" },
});
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
@ -302,18 +302,18 @@ describe("FirstRunWizard — Codex/Claude model configuration (ticket #99)", ()
await waitForLoaded();
const row = within(
screen.getByLabelText("use Codex configured").closest("li")!,
screen.getByLabelText("Utiliser Codex configured").closest("li")!,
);
expect(row.queryByLabelText("Codex configured provider")).toBeNull();
expect(row.queryByLabelText("Codex configured api key")).toBeNull();
expect(row.queryByLabelText("Fournisseur de Codex configured")).toBeNull();
expect(row.queryByLabelText("Clé API de Codex configured")).toBeNull();
expect(
(row.getByLabelText("Codex configured model") as HTMLInputElement).value,
(row.getByLabelText("Modèle de Codex configured") as HTMLInputElement).value,
).toBe("gpt-5-mini");
fireEvent.change(row.getByLabelText("Codex configured model"), {
fireEvent.change(row.getByLabelText("Modèle de Codex configured"), {
target: { value: "gpt-5" },
});
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const [saved] = await profile.listProfiles();
@ -334,20 +334,20 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
expect(screen.getByText(OPENCODE)).toBeTruthy();
// The OpenCode fields are rendered, pre-filled from the reference.
expect(
(screen.getByLabelText(`${OPENCODE} base url`) as HTMLInputElement).value,
(screen.getByLabelText(`URL de base de ${OPENCODE}`) as HTMLInputElement).value,
).toBe("http://localhost:8080/v1");
expect(
(screen.getByLabelText(`${OPENCODE} model`) as HTMLInputElement).value,
(screen.getByLabelText(`Modèle de ${OPENCODE}`) as HTMLInputElement).value,
).toBe("qwen3-coder-30b");
// Claude/Codex rows must NOT grow endpoint fields (additive, no regression).
expect(screen.queryByLabelText("Claude Code base url")).toBeNull();
expect(screen.queryByLabelText("URL de base de Claude Code")).toBeNull();
});
it("exposes the API key as a free-form optional field (not an env var name)", async () => {
renderWizard();
await waitForLoaded();
const apiKey = screen.getByLabelText(`${OPENCODE} api key`) as HTMLInputElement;
const apiKey = screen.getByLabelText(`Clé API de ${OPENCODE}`) as HTMLInputElement;
expect(apiKey.value).toBe("sk-no-key");
// A raw secret is accepted verbatim (OpenCode carries the key itself) — no
@ -363,7 +363,7 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
renderWizard();
await waitForLoaded();
const baseUrl = screen.getByLabelText(`${OPENCODE} base url`);
const baseUrl = screen.getByLabelText(`URL de base de ${OPENCODE}`);
fireEvent.change(baseUrl, { target: { value: "ftp://oops" } });
expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy();
});
@ -372,7 +372,7 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
renderWizard();
await waitForLoaded();
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
fireEvent.change(screen.getByLabelText(`Modèle de ${OPENCODE}`), {
target: { value: "" },
});
expect(screen.getByText(/model is required/i)).toBeTruthy();
@ -383,18 +383,18 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
await waitForLoaded();
// Select the local model and edit its base URL, model and API key.
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
fireEvent.change(screen.getByLabelText(`${OPENCODE} base url`), {
fireEvent.click(screen.getByLabelText(`Utiliser ${OPENCODE}`));
fireEvent.change(screen.getByLabelText(`URL de base de ${OPENCODE}`), {
target: { value: "http://localhost:9090/v1" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
fireEvent.change(screen.getByLabelText(`Modèle de ${OPENCODE}`), {
target: { value: "qwen3-coder-14b" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
fireEvent.change(screen.getByLabelText(`Clé API de ${OPENCODE}`), {
target: { value: "sk-local" },
});
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
@ -410,12 +410,12 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
const { profile } = renderWizard();
await waitForLoaded();
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
fireEvent.click(screen.getByLabelText(`Utiliser ${OPENCODE}`));
fireEvent.change(screen.getByLabelText(`Clé API de ${OPENCODE}`), {
target: { value: "" },
});
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
@ -437,12 +437,12 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
"aria-checked",
),
).toBe("true");
expect(screen.getByLabelText(`${OPENCODE} base url`)).toBeTruthy();
expect(screen.getByLabelText(`URL de base de ${OPENCODE}`)).toBeTruthy();
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
fireEvent.click(screen.getByRole("radio", { name: "Fournisseur cloud" }));
expect(screen.queryByLabelText(`${OPENCODE} base url`)).toBeNull();
await screen.findByLabelText(`${OPENCODE} provider`);
expect(screen.queryByLabelText(`URL de base de ${OPENCODE}`)).toBeNull();
await screen.findByLabelText(`Fournisseur de ${OPENCODE}`);
});
it("editing an already-cloud profile preselects the Cloud segment with an empty key", async () => {
@ -480,10 +480,10 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
// reference also renders (edit mode dedups by id only), with its own
// Local/Cloud segmented control.
const row = within(
screen.getByLabelText("use Claude via OpenCode").closest("li")!,
screen.getByLabelText("Utiliser Claude via OpenCode").closest("li")!,
);
expect(
row.getByRole("radio", { name: "Provider cloud" }).getAttribute(
row.getByRole("radio", { name: "Fournisseur cloud" }).getAttribute(
"aria-checked",
),
).toBe("true");
@ -493,7 +493,7 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
),
).toBe("false");
expect(
(row.getByLabelText("Claude via OpenCode api key") as HTMLInputElement)
(row.getByLabelText("Clé API de Claude via OpenCode") as HTMLInputElement)
.value,
).toBe("");
});
@ -501,13 +501,13 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
it("cascades provider ➜ model and disables Save until the API key is filled", async () => {
renderWizard();
await waitForLoaded();
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
fireEvent.click(screen.getByRole("radio", { name: "Fournisseur cloud" }));
await screen.findByLabelText(`${OPENCODE} provider`);
const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLButtonElement;
await screen.findByLabelText(`Fournisseur de ${OPENCODE}`);
const modelSelect = screen.getByLabelText(`Modèle de ${OPENCODE}`) as HTMLButtonElement;
expect(modelSelect.disabled).toBe(true);
chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
chooseDropdownOption(`Fournisseur de ${OPENCODE}`, "Anthropic");
expect(modelSelect.disabled).toBe(false);
const saveButton = screen.getByRole("button", {
@ -515,10 +515,10 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
}) as HTMLButtonElement;
expect(saveButton.disabled).toBe(true);
chooseDropdownOption(`${OPENCODE} model`, "claude-sonnet-5");
chooseDropdownOption(`Modèle de ${OPENCODE}`, "claude-sonnet-5");
expect(saveButton.disabled).toBe(true);
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
fireEvent.change(screen.getByLabelText(`Clé API de ${OPENCODE}`), {
target: { value: "sk-ant-secret" },
});
expect(saveButton.disabled).toBe(false);
@ -527,13 +527,13 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
it("saves a cloud profile via saveOpenCodeProviderProfile and clears the key afterwards", async () => {
const { profile } = renderWizard();
await waitForLoaded();
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
fireEvent.click(screen.getByRole("radio", { name: "Fournisseur cloud" }));
await screen.findByLabelText(`${OPENCODE} provider`);
chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
chooseDropdownOption(`${OPENCODE} model`, "claude-sonnet-5");
await screen.findByLabelText(`Fournisseur de ${OPENCODE}`);
chooseDropdownOption(`Fournisseur de ${OPENCODE}`, "Anthropic");
chooseDropdownOption(`Modèle de ${OPENCODE}`, "claude-sonnet-5");
const apiKeyInput = screen.getByLabelText(
`${OPENCODE} api key`,
`Clé API de ${OPENCODE}`,
) as HTMLInputElement;
fireEvent.change(apiKeyInput, { target: { value: "sk-ant-secret" } });
@ -553,17 +553,17 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
it("shows submit-time validation messages when provider/model/key are missing", async () => {
renderWizard();
await waitForLoaded();
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
await screen.findByLabelText(`${OPENCODE} provider`);
fireEvent.click(screen.getByRole("radio", { name: "Fournisseur cloud" }));
await screen.findByLabelText(`Fournisseur de ${OPENCODE}`);
// Save stays disabled with no key, so drive validation via a filled key but
// no provider/model to see the field-level messages fire on submit.
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
fireEvent.change(screen.getByLabelText(`Clé API de ${OPENCODE}`), {
target: { value: "sk-ant-secret" },
});
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
expect(screen.getByText("Le provider est obligatoire.")).toBeTruthy();
expect(screen.getByText("Le fournisseur est obligatoire.")).toBeTruthy();
expect(screen.getByText("Le modèle est obligatoire.")).toBeTruthy();
});
});
@ -572,9 +572,9 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
const OPENCODE = "OpenCode + llama.cpp";
async function goToCloudCustomMode() {
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
await screen.findByLabelText(`${OPENCODE} provider`);
chooseDropdownOption(`${OPENCODE} provider`, "Autre / personnalisé…");
fireEvent.click(screen.getByRole("radio", { name: "Fournisseur cloud" }));
await screen.findByLabelText(`Fournisseur de ${OPENCODE}`);
chooseDropdownOption(`Fournisseur de ${OPENCODE}`, "Autre / personnalisé…");
}
it("selecting 'Autre / personnalisé' swaps the cascade for free-form fields", async () => {
@ -584,16 +584,16 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
expect(screen.queryByLabelText(`${OPENCODE} provider search`)).toBeNull();
expect(
screen.getByLabelText(`${OPENCODE} custom provider id`),
screen.getByLabelText(`Identifiant du fournisseur personnalisé de ${OPENCODE}`),
).toBeTruthy();
expect(
(screen.getByLabelText(`${OPENCODE} custom npm package`) as HTMLInputElement)
(screen.getByLabelText(`Paquet npm personnalisé de ${OPENCODE}`) as HTMLInputElement)
.value,
).toBe("@ai-sdk/openai-compatible");
expect(screen.getByLabelText(`${OPENCODE} custom base url`)).toBeTruthy();
expect(screen.getByLabelText(`${OPENCODE} model`)).toBeTruthy();
expect(screen.getByLabelText(`URL de base personnalisée de ${OPENCODE}`)).toBeTruthy();
expect(screen.getByLabelText(`Modèle de ${OPENCODE}`)).toBeTruthy();
expect(
screen.getByLabelText(`${OPENCODE} custom display name`),
screen.getByLabelText(`Libellé personnalisé de ${OPENCODE}`),
).toBeTruthy();
});
@ -602,14 +602,14 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
await waitForLoaded();
await goToCloudCustomMode();
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
fireEvent.change(screen.getByLabelText(`Clé API de ${OPENCODE}`), {
target: { value: "sk-custom-secret" },
});
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
expect(screen.getByText("Le provider est obligatoire.")).toBeTruthy();
expect(screen.getByText("Le fournisseur est obligatoire.")).toBeTruthy();
expect(screen.getByText("Le modèle est obligatoire.")).toBeTruthy();
expect(screen.getByText("L'URL de base est obligatoire.")).toBeTruthy();
expect(screen.getByText("L’URL de base est obligatoire.")).toBeTruthy();
// npm is pre-filled by default, so it doesn't fail validation here.
expect(screen.queryByText("Le paquet npm est obligatoire.")).toBeNull();
});
@ -619,23 +619,23 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
await waitForLoaded();
await goToCloudCustomMode();
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), {
fireEvent.change(screen.getByLabelText(`Identifiant du fournisseur personnalisé de ${OPENCODE}`), {
target: { value: "mon-provider" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom npm package`), {
fireEvent.change(screen.getByLabelText(`Paquet npm personnalisé de ${OPENCODE}`), {
target: { value: "@ai-sdk/openai-compatible" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), {
fireEvent.change(screen.getByLabelText(`URL de base personnalisée de ${OPENCODE}`), {
target: { value: "https://api.mon-provider.example/v1" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
fireEvent.change(screen.getByLabelText(`Modèle de ${OPENCODE}`), {
target: { value: "mon-modele-1" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom display name`), {
fireEvent.change(screen.getByLabelText(`Libellé personnalisé de ${OPENCODE}`), {
target: { value: "Mon Modèle" },
});
const apiKeyInput = screen.getByLabelText(
`${OPENCODE} api key`,
`Clé API de ${OPENCODE}`,
) as HTMLInputElement;
fireEvent.change(apiKeyInput, { target: { value: "sk-custom-secret" } });
@ -660,16 +660,16 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
await waitForLoaded();
await goToCloudCustomMode();
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), {
fireEvent.change(screen.getByLabelText(`Identifiant du fournisseur personnalisé de ${OPENCODE}`), {
target: { value: "mon-provider" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), {
fireEvent.change(screen.getByLabelText(`URL de base personnalisée de ${OPENCODE}`), {
target: { value: "https://api.mon-provider.example/v1" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
fireEvent.change(screen.getByLabelText(`Modèle de ${OPENCODE}`), {
target: { value: "mon-modele-1" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
fireEvent.change(screen.getByLabelText(`Clé API de ${OPENCODE}`), {
target: { value: "sk-custom-secret" },
});
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
@ -681,17 +681,17 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
});
});
it('"← Choisir un provider du catalogue" switches back to the cascade', async () => {
it('"← Choisir un fournisseur du catalogue" switches back to the cascade', async () => {
renderWizard();
await waitForLoaded();
await goToCloudCustomMode();
fireEvent.click(
screen.getByRole("button", { name: "← Choisir un provider du catalogue" }),
screen.getByRole("button", { name: "← Choisir un fournisseur du catalogue" }),
);
expect(screen.getByLabelText(`${OPENCODE} provider`)).toBeTruthy();
expect(screen.queryByLabelText(`${OPENCODE} custom provider id`)).toBeNull();
expect(screen.getByLabelText(`Fournisseur de ${OPENCODE}`)).toBeTruthy();
expect(screen.queryByLabelText(`Identifiant du fournisseur personnalisé de ${OPENCODE}`)).toBeNull();
});
it("editing an existing custom-provider profile preselects custom mode and prefills fields (except the key)", async () => {
@ -731,34 +731,34 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
await waitForLoaded();
const row = within(
screen.getByLabelText("use Mon provider via OpenCode").closest("li")!,
screen.getByLabelText("Utiliser Mon provider via OpenCode").closest("li")!,
);
expect(
row.getByRole("radio", { name: "Provider cloud" }).getAttribute(
row.getByRole("radio", { name: "Fournisseur cloud" }).getAttribute(
"aria-checked",
),
).toBe("true");
expect(
(row.getByLabelText("Mon provider via OpenCode custom provider id") as HTMLInputElement)
(row.getByLabelText("Identifiant du fournisseur personnalisé de Mon provider via OpenCode") as HTMLInputElement)
.value,
).toBe("mon-provider");
expect(
(row.getByLabelText("Mon provider via OpenCode custom npm package") as HTMLInputElement)
(row.getByLabelText("Paquet npm personnalisé de Mon provider via OpenCode") as HTMLInputElement)
.value,
).toBe("@ai-sdk/openai-compatible");
expect(
(row.getByLabelText("Mon provider via OpenCode custom base url") as HTMLInputElement)
(row.getByLabelText("URL de base personnalisée de Mon provider via OpenCode") as HTMLInputElement)
.value,
).toBe("https://api.mon-provider.example/v1");
expect(
(row.getByLabelText("Mon provider via OpenCode model") as HTMLInputElement).value,
(row.getByLabelText("Modèle de Mon provider via OpenCode") as HTMLInputElement).value,
).toBe("mon-modele-1");
expect(
(row.getByLabelText("Mon provider via OpenCode custom display name") as HTMLInputElement)
(row.getByLabelText("Libellé personnalisé de Mon provider via OpenCode") as HTMLInputElement)
.value,
).toBe("Mon Modèle");
expect(
(row.getByLabelText("Mon provider via OpenCode api key") as HTMLInputElement)
(row.getByLabelText("Clé API de Mon provider via OpenCode") as HTMLInputElement)
.value,
).toBe("");
});
@ -776,17 +776,17 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
expect(screen.getAllByText(OPENCODE).length).toBe(1);
fireEvent.click(
screen.getByRole("button", { name: "Add OpenCode profile" }),
screen.getByRole("button", { name: "Ajouter un profil OpenCode" }),
);
// A second OpenCode row appears, pre-filled from the seed and pre-selected.
const added = await screen.findByLabelText(`use ${CLONE1}`);
const added = await screen.findByLabelText(`Utiliser ${CLONE1}`);
expect((added as HTMLInputElement).checked).toBe(true);
expect(
(screen.getByLabelText(`${CLONE1} base url`) as HTMLInputElement).value,
(screen.getByLabelText(`URL de base de ${CLONE1}`) as HTMLInputElement).value,
).toBe("http://localhost:8080/v1");
// Its name is editable per profile (identity is the id, not the name).
expect(screen.getByLabelText(`${CLONE1} name`)).toBeTruthy();
expect(screen.getByLabelText(`Nom de ${CLONE1}`)).toBeTruthy();
});
it("persists two distinct OpenCode profiles when both are selected", async () => {
@ -794,21 +794,21 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
await waitForLoaded();
// Select the seed row and edit it.
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
fireEvent.click(screen.getByLabelText(`Utiliser ${OPENCODE}`));
fireEvent.change(screen.getByLabelText(`Modèle de ${OPENCODE}`), {
target: { value: "qwen3-coder-14b" },
});
// Add a second one and give it a different endpoint.
fireEvent.click(
screen.getByRole("button", { name: "Add OpenCode profile" }),
screen.getByRole("button", { name: "Ajouter un profil OpenCode" }),
);
await screen.findByLabelText(`use ${CLONE1}`);
fireEvent.change(screen.getByLabelText(`${CLONE1} base url`), {
await screen.findByLabelText(`Utiliser ${CLONE1}`);
fireEvent.change(screen.getByLabelText(`URL de base de ${CLONE1}`), {
target: { value: "http://localhost:9191/v1" },
});
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
@ -828,22 +828,22 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
await waitForLoaded();
// Edit the seed row, then duplicate it.
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
fireEvent.click(screen.getByLabelText(`Utiliser ${OPENCODE}`));
fireEvent.change(screen.getByLabelText(`Modèle de ${OPENCODE}`), {
target: { value: "qwen3-coder-7b" },
});
fireEvent.click(
screen.getByRole("button", { name: `duplicate ${OPENCODE}` }),
screen.getByRole("button", { name: `Dupliquer ${OPENCODE}` }),
);
const dupName = `${OPENCODE} (copy)`;
await screen.findByLabelText(`use ${dupName}`);
const dupName = `${OPENCODE} (copie)`;
await screen.findByLabelText(`Utiliser ${dupName}`);
// The duplicate inherits the edited model.
expect(
(screen.getByLabelText(`${dupName} model`) as HTMLInputElement).value,
(screen.getByLabelText(`Modèle de ${dupName}`) as HTMLInputElement).value,
).toBe("qwen3-coder-7b");
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const models = saved
@ -858,14 +858,14 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
await waitForLoaded();
fireEvent.click(
screen.getByRole("button", { name: "Add OpenCode profile" }),
screen.getByRole("button", { name: "Ajouter un profil OpenCode" }),
);
await screen.findByLabelText(`use ${CLONE1}`);
fireEvent.change(screen.getByLabelText(`${CLONE1} name`), {
await screen.findByLabelText(`Utiliser ${CLONE1}`);
fireEvent.change(screen.getByLabelText(`Nom de ${CLONE1}`), {
target: { value: "Fast local model" },
});
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const oc = saved.find((p) => p.structuredAdapter === "openCode");
@ -878,20 +878,20 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
await waitForLoaded();
fireEvent.click(
screen.getByRole("button", { name: "Add OpenCode profile" }),
screen.getByRole("button", { name: "Ajouter un profil OpenCode" }),
);
await screen.findByLabelText(`use ${CLONE1}`);
await screen.findByLabelText(`Utiliser ${CLONE1}`);
// Reasoning defaults on (backend effective default true); turn it off.
const reasoning = screen.getByLabelText(
`${CLONE1} reasoning`,
`Raisonnement de ${CLONE1}`,
) as HTMLInputElement;
expect(reasoning.checked).toBe(true);
fireEvent.click(reasoning);
// Attachments default off; turn them on.
fireEvent.click(screen.getByLabelText(`${CLONE1} attachment`));
fireEvent.click(screen.getByLabelText(`Pièces jointes de ${CLONE1}`));
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const oc = saved.find((p) => p.structuredAdapter === "openCode");
@ -922,18 +922,18 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
await waitForLoaded();
fireEvent.click(
screen.getByRole("button", { name: "Add OpenCode profile" }),
screen.getByRole("button", { name: "Ajouter un profil OpenCode" }),
);
await screen.findByLabelText(`use ${CLONE1}`);
await screen.findByLabelText(`Utiliser ${CLONE1}`);
// The old free-text field is gone; a dropdown replaces it.
expect(
screen.queryByLabelText(`${CLONE1} local model server id`),
).toBeNull();
await screen.findByLabelText(`${CLONE1} local model server`);
chooseDropdownOption(`${CLONE1} local model server`, /Local A/);
await screen.findByLabelText(`Serveur de modèle local de ${CLONE1}`);
chooseDropdownOption(`Serveur de modèle local de ${CLONE1}`, /Local A/);
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const oc = saved.find((p) => p.structuredAdapter === "openCode");
@ -960,13 +960,13 @@ describe("FirstRunWizard — detection that never answers (ticket #28)", () => {
await waitForLoaded();
// The rows are rendered, so the blocking load is over: both actions are live.
const save = screen.getByRole("button", { name: "Save and continue" });
const detect = screen.getByRole("button", { name: "Detect installed CLIs" });
const save = screen.getByRole("button", { name: "Enregistrer et continuer" });
const detect = screen.getByRole("button", { name: "Détecter les CLI installées" });
expect((save as HTMLButtonElement).disabled).toBe(false);
expect((detect as HTMLButtonElement).disabled).toBe(false);
// Detection is merely reported as in flight, never as a blocking state.
expect(screen.getByRole("status").textContent).toMatch(/detecting/i);
expect(screen.getByRole("status").textContent).toMatch(/détection/i);
// Re-probing by hand must not freeze them either.
fireEvent.click(detect);
@ -980,8 +980,8 @@ describe("FirstRunWizard — detection that never answers (ticket #28)", () => {
await waitForLoaded();
// Nothing got pre-checked (detection never answered): tick a profile by hand.
fireEvent.click(screen.getByLabelText("use Claude Code"));
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByLabelText("Utiliser Claude Code"));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(() => expect(onDone).toHaveBeenCalled());
const saved = await profile.listProfiles();
@ -993,7 +993,7 @@ describe("FirstRunWizard — detection that never answers (ticket #28)", () => {
try {
renderWizard(new HangingDetectGateway());
await waitForLoaded();
expect(screen.getByRole("status").textContent).toMatch(/detecting/i);
expect(screen.getByRole("status").textContent).toMatch(/détection/i);
await act(async () => {
vi.advanceTimersByTime(DETECT_TIMEOUT_MS + 1);
@ -1023,7 +1023,7 @@ describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
);
// Give the async first-run state time to resolve to `false`.
await waitFor(() =>
expect(screen.queryByLabelText("first run setup")).toBeNull(),
expect(screen.queryByLabelText("Configuration initiale")).toBeNull(),
);
});
@ -1035,7 +1035,7 @@ describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
<FirstRunWizard forceOpen />
</DIProvider>,
);
expect(await screen.findByLabelText("first run setup")).toBeTruthy();
expect(await screen.findByLabelText("Configuration initiale")).toBeTruthy();
});
});
@ -1090,13 +1090,13 @@ describe("FirstRunWizard edit mode reopening (#44)", () => {
// The configured OpenCode comes back checked, with its real endpoint/model.
expect(
(screen.getByLabelText("use Local A") as HTMLInputElement).checked,
(screen.getByLabelText("Utiliser Local A") as HTMLInputElement).checked,
).toBe(true);
expect(
(screen.getByLabelText("Local A base url") as HTMLInputElement).value,
(screen.getByLabelText("URL de base de Local A") as HTMLInputElement).value,
).toBe("http://localhost:9090/v1");
expect(
(screen.getByLabelText("Local A model") as HTMLInputElement).value,
(screen.getByLabelText("Modèle de Local A") as HTMLInputElement).value,
).toBe("qwen3-coder-14b");
});
@ -1115,7 +1115,7 @@ describe("FirstRunWizard edit mode reopening (#44)", () => {
expect(screen.getAllByText("OpenCode (edited)")).toHaveLength(1);
expect(screen.queryByText("OpenCode + llama.cpp")).toBeNull();
expect(
(screen.getByLabelText("use OpenCode (edited)") as HTMLInputElement).checked,
(screen.getByLabelText("Utiliser OpenCode (edited)") as HTMLInputElement).checked,
).toBe(true);
});
@ -1131,16 +1131,16 @@ describe("FirstRunWizard edit mode reopening (#44)", () => {
// Both distinct profiles are present and pre-selected, none collapsed.
expect(
(screen.getByLabelText("use Local A") as HTMLInputElement).checked,
(screen.getByLabelText("Utiliser Local A") as HTMLInputElement).checked,
).toBe(true);
expect(
(screen.getByLabelText("use Local B") as HTMLInputElement).checked,
(screen.getByLabelText("Utiliser Local B") as HTMLInputElement).checked,
).toBe(true);
expect(
(screen.getByLabelText("Local A base url") as HTMLInputElement).value,
(screen.getByLabelText("URL de base de Local A") as HTMLInputElement).value,
).toBe("http://localhost:8080/v1");
expect(
(screen.getByLabelText("Local B base url") as HTMLInputElement).value,
(screen.getByLabelText("URL de base de Local B") as HTMLInputElement).value,
).toBe("http://localhost:9191/v1");
});
@ -1154,7 +1154,7 @@ describe("FirstRunWizard edit mode reopening (#44)", () => {
renderEdit(profile);
await waitForLoaded();
// Save straight away without touching anything.
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
@ -1176,8 +1176,8 @@ describe("FirstRunWizard edit mode reopening (#44)", () => {
renderEdit(profile);
await waitForLoaded();
// Deselect Local B, then save — an explicit removal, not an omission.
fireEvent.click(screen.getByLabelText("use Local B"));
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
fireEvent.click(screen.getByLabelText("Utiliser Local B"));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer et continuer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
@ -1207,17 +1207,17 @@ describe("FirstRunWizard edit mode reopening (#44)", () => {
// Wait until detection has filled availability (Claude reads as installed).
await waitFor(() =>
expect(
screen.getByLabelText("Claude Code availability").textContent,
).toMatch(/installed/),
screen.getByLabelText("Disponibilité de Claude Code").textContent,
).toMatch(/installée/),
);
// Selection is untouched by detection: the configured (uninstalled) Codex
// stays checked; the installed Claude reference stays UNchecked.
expect(
(screen.getByLabelText("use Codex Configured") as HTMLInputElement).checked,
(screen.getByLabelText("Utiliser Codex Configured") as HTMLInputElement).checked,
).toBe(true);
expect(
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
(screen.getByLabelText("Utiliser Claude Code") as HTMLInputElement).checked,
).toBe(false);
});
});

View File

@ -22,6 +22,7 @@ import type {
} from "@/domain";
import { useEffect, useState } from "react";
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
import { useI18n } from "@/i18n";
import {
ModelServersPanel,
useModelServers,
@ -60,6 +61,7 @@ export function FirstRunWizard({
/** Render the wizard even when it is no longer the first run. */
forceOpen?: boolean;
}) {
const { tr } = useI18n();
// Mode is explicit (ticket #44), driven by the entry point — never inferred
// from `isFirstRun`: reopening from Settings (`forceOpen`) is the "edit" mode,
// which pre-loads and pre-selects the already-configured profiles.
@ -77,14 +79,13 @@ export function FirstRunWizard({
return (
<Panel
aria-label="first run setup"
aria-label={tr("Configuration initiale", "First run setup")}
className="border-primary/40"
title={
<div className="flex flex-col">
<h2 className="text-base font-semibold text-content">Welcome to IdeA</h2>
<h2 className="text-base font-semibold text-content">{tr("Bienvenue dans IdeA", "Welcome to IdeA")}</h2>
<p className="text-sm text-muted">
Choose which AI CLIs to configure. Commands are pre-filled and
editable.
{tr("Choisissez les CLI d’IA à configurer. Les commandes sont préremplies et modifiables.", "Choose which AI CLIs to configure. Commands are pre-filled and editable.")}
</p>
</div>
}
@ -104,11 +105,11 @@ export function FirstRunWizard({
disabled={vm.busy}
className="whitespace-nowrap"
>
Detect installed CLIs
{tr("Détecter les CLI installées", "Detect installed CLIs")}
</Button>
{vm.detecting && (
<span role="status" className="text-xs text-muted">
Detecting…
{tr("Détection…", "Detecting…")}
</span>
)}
{/* F36: declare several local OpenCode profiles. Each click clones the
@ -118,7 +119,7 @@ export function FirstRunWizard({
disabled={vm.busy}
className="whitespace-nowrap"
>
Add OpenCode profile
{tr("Ajouter un profil OpenCode", "Add OpenCode profile")}
</Button>
</Toolbar>
@ -141,7 +142,7 @@ export function FirstRunWizard({
entry.profile.structuredAdapter === "openCode"
? () =>
vm.addOpenCodeProfile({
name: `${entry.profile.name} (copy)`,
name: `${entry.profile.name} (${tr("copie", "copy")})`,
opencode: entry.profile.opencode,
})
: undefined
@ -152,7 +153,7 @@ export function FirstRunWizard({
<footer className="flex gap-2">
<Button variant="primary" onClick={() => void finish()} disabled={vm.busy}>
Save and continue
{tr("Enregistrer et continuer", "Save and continue")}
</Button>
</footer>
</div>
@ -181,6 +182,7 @@ function ProfileRow({
/** Present only for OpenCode rows: clone this row into a new profile (F36). */
onDuplicate?: () => void;
}) {
const { tr } = useI18n();
const { profile, selected, available } = entry;
const errors = validateProfile(profile);
const isOpenCode = profile.structuredAdapter === "openCode";
@ -203,13 +205,13 @@ function ProfileRow({
type="checkbox"
checked={selected}
onChange={onToggle}
aria-label={`use ${profile.name}`}
aria-label={tr(`Utiliser ${profile.name}`, `Use ${profile.name}`)}
className="accent-primary"
/>
<strong className="text-sm text-content">{profile.name}</strong>
</label>
<span
aria-label={`${profile.name} availability`}
aria-label={tr(`Disponibilité de ${profile.name}`, `${profile.name} availability`)}
className={cn(
"text-xs",
available === null
@ -219,21 +221,21 @@ function ProfileRow({
: "text-danger",
)}
>
{available === null ? "—" : available ? "✓ installed" : "✗ not found"}
{available === null ? "—" : available ? tr("✓ installée", "✓ installed") : tr("✗ introuvable", "✗ not found")}
</span>
<div className="ml-auto flex items-center gap-1">
{onDuplicate && (
<Button
size="sm"
aria-label={`duplicate ${profile.name}`}
aria-label={tr(`Dupliquer ${profile.name}`, `Duplicate ${profile.name}`)}
onClick={onDuplicate}
>
Duplicate
{tr("Dupliquer", "Duplicate")}
</Button>
)}
<IconButton
size="sm"
aria-label={`remove ${profile.name}`}
aria-label={tr(`Retirer ${profile.name}`, `Remove ${profile.name}`)}
onClick={onRemove}
>
×
@ -245,9 +247,9 @@ function ProfileRow({
it is editable per profile (several local models coexist). */}
{isOpenCode && (
<label className="flex flex-col gap-1">
<Caption>Name</Caption>
<Caption>{tr("Nom", "Name")}</Caption>
<Input
aria-label={`${profile.name} name`}
aria-label={tr(`Nom de ${profile.name}`, `Name for ${profile.name}`)}
value={profile.name}
invalid={Boolean(errors.name)}
onChange={(e) => onChange({ ...profile, name: e.target.value })}
@ -257,9 +259,9 @@ function ProfileRow({
)}
<label className="flex flex-col gap-1">
<Caption>Command</Caption>
<Caption>{tr("Commande", "Command")}</Caption>
<Input
aria-label={`${profile.name} command`}
aria-label={tr(`Commande de ${profile.name}`, `Command for ${profile.name}`)}
value={profile.command}
invalid={Boolean(errors.command)}
onChange={(e) => onChange({ ...profile, command: e.target.value })}
@ -268,9 +270,9 @@ function ProfileRow({
</label>
<label className="flex flex-col gap-1">
<Caption>Arguments</Caption>
<Caption>{tr("Arguments", "Arguments")}</Caption>
<Input
aria-label={`${profile.name} args`}
aria-label={tr(`Arguments de ${profile.name}`, `Arguments for ${profile.name}`)}
value={argsText}
onChange={(e) => setArgsText(e.target.value)}
onBlur={commitArgs}
@ -307,13 +309,14 @@ function CliModelField({
profile: AgentProfile;
onChange: (p: AgentProfile) => void;
}) {
const { tr } = useI18n();
return (
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<Caption>{tr("Modèle", "Model")}</Caption>
<Input
aria-label={`${profile.name} model`}
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
value={profile.model ?? ""}
placeholder="Laisser vide pour le modèle par défaut de la CLI"
placeholder={tr("Laisser vide pour le modèle par défaut de la CLI", "Leave empty for the CLI default model")}
onChange={(e) => {
const model = e.target.value.trim();
onChange({
@ -323,7 +326,7 @@ function CliModelField({
}}
/>
<small className="text-xs text-faint">
Optionnel. L'authentification Codex/Claude reste gérée par la CLI.
{tr("Facultatif. L’authentification Codex/Claude reste gérée par la CLI.", "Optional. Codex/Claude authentication remains managed by the CLI.")}
</small>
</label>
);
@ -352,6 +355,7 @@ function HttpChatFields({
errors: ProfileErrors;
onChange: (p: AgentProfile) => void;
}) {
const { tr } = useI18n();
const http = profile.chatHttp ?? defaultHttpChatConfig();
const patch = (next: Partial<HttpChatConfig>) =>
onChange({ ...profile, chatHttp: { ...http, ...next } });
@ -359,13 +363,13 @@ function HttpChatFields({
return (
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Local / LAN model (OpenAI-compatible)
{tr("Modèle local / réseau (compatible OpenAI)", "Local / LAN model (OpenAI-compatible)")}
</legend>
<label className="flex flex-col gap-1">
<Caption>Endpoint</Caption>
<Caption>{tr("Point d’accès", "Endpoint")}</Caption>
<Input
aria-label={`${profile.name} endpoint`}
aria-label={tr(`Point d’accès de ${profile.name}`, `Endpoint for ${profile.name}`)}
value={http.endpoint}
placeholder="http://localhost:11434/v1"
invalid={Boolean(errors.endpoint)}
@ -377,9 +381,9 @@ function HttpChatFields({
</label>
<label className="flex flex-col gap-1">
<Caption>Model</Caption>
<Caption>{tr("Modèle", "Model")}</Caption>
<Input
aria-label={`${profile.name} model`}
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
value={http.model}
placeholder="qwen2.5-coder"
invalid={Boolean(errors.model)}
@ -389,11 +393,11 @@ function HttpChatFields({
</label>
<label className="flex flex-col gap-1">
<Caption>API key — environment variable name (never the key itself)</Caption>
<Caption>{tr("Clé API — nom de la variable d’environnement (jamais la clé elle-même)", "API key — environment variable name (never the key itself)")}</Caption>
<Input
aria-label={`${profile.name} api key env`}
aria-label={tr(`Variable de clé API de ${profile.name}`, `API key environment variable for ${profile.name}`)}
value={http.apiKeyEnv ?? ""}
placeholder="e.g. OPENAI_API_KEY (variable name, not the secret)"
placeholder={tr("ex. OPENAI_API_KEY (nom de variable, pas le secret)", "e.g. OPENAI_API_KEY (variable name, not the secret)")}
invalid={Boolean(errors.apiKeyEnv)}
onChange={(e) => {
const v = e.target.value.trim();
@ -407,9 +411,9 @@ function HttpChatFields({
<div className="flex flex-wrap gap-2">
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
<Caption>Request timeout (ms)</Caption>
<Caption>{tr("Délai de requête (ms)", "Request timeout (ms)")}</Caption>
<Input
aria-label={`${profile.name} request timeout`}
aria-label={tr(`Délai de requête de ${profile.name}`, `Request timeout for ${profile.name}`)}
value={http.requestTimeoutMs?.toString() ?? ""}
inputMode="numeric"
invalid={Boolean(errors.requestTimeoutMs)}
@ -421,9 +425,9 @@ function HttpChatFields({
</label>
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
<Caption>Connect timeout (ms)</Caption>
<Caption>{tr("Délai de connexion (ms)", "Connect timeout (ms)")}</Caption>
<Input
aria-label={`${profile.name} connect timeout`}
aria-label={tr(`Délai de connexion de ${profile.name}`, `Connect timeout for ${profile.name}`)}
value={http.connectTimeoutMs?.toString() ?? ""}
inputMode="numeric"
invalid={Boolean(errors.connectTimeoutMs)}
@ -435,9 +439,9 @@ function HttpChatFields({
</label>
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
<Caption>Max tool iterations</Caption>
<Caption>{tr("Nombre maximal d’itérations d’outils", "Max tool iterations")}</Caption>
<Input
aria-label={`${profile.name} max tool iterations`}
aria-label={tr(`Itérations d’outils maximales de ${profile.name}`, `Max tool iterations for ${profile.name}`)}
value={http.maxToolIterations?.toString() ?? ""}
inputMode="numeric"
invalid={Boolean(errors.maxToolIterations)}

View File

@ -9,6 +9,7 @@ import type {
} from "@/domain";
import { useGateways } from "@/app/di";
import { Button, IconButton, Input, SmallDropdown, cn } from "@/shared";
import { useI18n } from "@/i18n";
import { ModelServerSelect } from "@/features/model-servers";
import {
defaultOpenCodeConfig,
@ -84,6 +85,7 @@ export function OpenCodeModeFields({
providerCatalog: OpenCodeProviderCatalog;
onChange: (p: AgentProfile) => void;
}) {
const { tr } = useI18n();
const [mode, setMode] = useState<"local" | "cloud">(
profile.opencodeProvider ? "cloud" : "local",
);
@ -103,13 +105,13 @@ export function OpenCodeModeFields({
<div className="mt-1 flex flex-col gap-2">
<div
role="radiogroup"
aria-label="mode du profil OpenCode"
aria-label={tr("Mode du profil OpenCode", "OpenCode profile mode")}
className="flex w-fit gap-1 rounded-md border border-border bg-raised p-0.5"
>
{(
[
{ id: "local", label: "Local (llama.cpp)" },
{ id: "cloud", label: "Provider cloud" },
{ id: "local", label: tr("Local (llama.cpp)", "Local (llama.cpp)") },
{ id: "cloud", label: tr("Fournisseur cloud", "Cloud provider") },
] as const
).map((seg) => {
const active = mode === seg.id;
@ -177,6 +179,7 @@ function OpenCodeProviderFields({
catalog: OpenCodeProviderCatalog;
onChange: (p: AgentProfile) => void;
}) {
const { tr } = useI18n();
const { profile: profileGateway } = useGateways();
const existing = profile.opencodeProvider;
const [mode, setMode] = useState<"catalog" | "custom">(
@ -217,16 +220,16 @@ function OpenCodeProviderFields({
async function save() {
const errors: CloudFieldErrors = {};
if (providerId.trim().length === 0) {
errors.providerId = "Le provider est obligatoire.";
errors.providerId = tr("Le fournisseur est obligatoire.", "Provider is required.");
}
if (model.trim().length === 0) errors.model = "Le modèle est obligatoire.";
if (model.trim().length === 0) errors.model = tr("Le modèle est obligatoire.", "Model is required.");
if (mode === "custom") {
if (customNpm.trim().length === 0) errors.npm = "Le paquet npm est obligatoire.";
if (customNpm.trim().length === 0) errors.npm = tr("Le paquet npm est obligatoire.", "npm package is required.");
if (customBaseUrl.trim().length === 0) {
errors.baseUrl = "L'URL de base est obligatoire.";
errors.baseUrl = tr("L’URL de base est obligatoire.", "Base URL is required.");
}
}
if (apiKey.length === 0) errors.apiKey = "La clé API est obligatoire.";
if (apiKey.length === 0) errors.apiKey = tr("La clé API est obligatoire.", "API key is required.");
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
@ -263,7 +266,7 @@ function OpenCodeProviderFields({
return (
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Provider cloud (OpenCode)
{tr("Fournisseur cloud (OpenCode)", "Cloud provider (OpenCode)")}
</legend>
{saveError && (
@ -273,15 +276,15 @@ function OpenCodeProviderFields({
)}
{catalog.loading && (
<p className="text-xs text-faint">Chargement des providers…</p>
<p className="text-xs text-faint">{tr("Chargement des fournisseurs…", "Loading providers…")}</p>
)}
{catalog.error && (
<div className="flex flex-col gap-1">
<p role="alert" className="text-sm text-danger">
Impossible de charger la liste des providers cloud.
{tr("Impossible de charger la liste des fournisseurs cloud.", "Unable to load the cloud provider list.")}
</p>
<Button size="sm" onClick={() => catalog.reload()} className="w-fit">
Réessayer
{tr("Réessayer", "Retry")}
</Button>
</div>
)}
@ -289,19 +292,19 @@ function OpenCodeProviderFields({
{mode === "catalog" && (
<>
<label className="flex flex-col gap-1">
<Caption>Provider</Caption>
<Caption>{tr("Fournisseur", "Provider")}</Caption>
{catalogReady && (
<input
type="text"
aria-label={`${profile.name} provider search`}
placeholder="Rechercher un provider…"
aria-label={tr(`Rechercher un fournisseur pour ${profile.name}`, `Search providers for ${profile.name}`)}
placeholder={tr("Rechercher un fournisseur…", "Search providers…")}
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
/>
)}
<SmallDropdown
aria-label={`${profile.name} provider`}
aria-label={tr(`Fournisseur de ${profile.name}`, `Provider for ${profile.name}`)}
value={providerId}
disabled={!catalogReady}
onChange={(v) => {
@ -322,8 +325,8 @@ function OpenCodeProviderFields({
{
value: "",
label: catalog.loading
? "Chargement des providers…"
: "Choisir un provider…",
? tr("Chargement des fournisseurs…", "Loading providers…")
: tr("Choisir un fournisseur…", "Choose a provider…"),
disabled: true,
},
...filteredProviders.map((p) => ({
@ -332,7 +335,7 @@ function OpenCodeProviderFields({
})),
{
value: CUSTOM_PROVIDER_VALUE,
label: "Autre / personnalisé…",
label: tr("Autre / personnalisé…", "Other / custom…"),
},
]}
/>
@ -342,9 +345,9 @@ function OpenCodeProviderFields({
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<Caption>{tr("Modèle", "Model")}</Caption>
<SmallDropdown
aria-label={`${profile.name} model`}
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
value={model}
disabled={providerId.length === 0}
onChange={(next) => {
@ -357,7 +360,7 @@ function OpenCodeProviderFields({
options={[
{
value: "",
label: providerId.length === 0 ? "—" : "Choisir un modèle…",
label: providerId.length === 0 ? "—" : tr("Choisir un modèle…", "Choose a model…"),
disabled: true,
},
...models.map((m) => ({ value: m, label: m })),
@ -373,7 +376,7 @@ function OpenCodeProviderFields({
{mode === "custom" && (
<fieldset className="flex flex-col gap-2 rounded-md border border-border/50 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Provider personnalisé
{tr("Fournisseur personnalisé", "Custom provider")}
</legend>
<Button
@ -387,15 +390,15 @@ function OpenCodeProviderFields({
setFieldErrors({});
}}
>
← Choisir un provider du catalogue
← {tr("Choisir un fournisseur du catalogue", "Choose a catalog provider")}
</Button>
<label className="flex flex-col gap-1">
<Caption>Identifiant du provider</Caption>
<Caption>{tr("Identifiant du fournisseur", "Provider ID")}</Caption>
<Input
aria-label={`${profile.name} custom provider id`}
aria-label={tr(`Identifiant du fournisseur personnalisé de ${profile.name}`, `Custom provider ID for ${profile.name}`)}
value={providerId}
placeholder="ex. mon-provider"
placeholder={tr("ex. mon-fournisseur", "e.g. my-provider")}
invalid={Boolean(fieldErrors.providerId)}
onChange={(e) => {
setProviderId(e.target.value);
@ -408,9 +411,9 @@ function OpenCodeProviderFields({
</label>
<label className="flex flex-col gap-1">
<Caption>Paquet npm</Caption>
<Caption>{tr("Paquet npm", "npm package")}</Caption>
<Input
aria-label={`${profile.name} custom npm package`}
aria-label={tr(`Paquet npm personnalisé de ${profile.name}`, `Custom npm package for ${profile.name}`)}
value={customNpm}
placeholder={DEFAULT_CUSTOM_NPM}
invalid={Boolean(fieldErrors.npm)}
@ -425,11 +428,11 @@ function OpenCodeProviderFields({
</label>
<label className="flex flex-col gap-1">
<Caption>URL de base</Caption>
<Caption>{tr("URL de base", "Base URL")}</Caption>
<Input
aria-label={`${profile.name} custom base url`}
aria-label={tr(`URL de base personnalisée de ${profile.name}`, `Custom base URL for ${profile.name}`)}
value={customBaseUrl}
placeholder="https://api.mon-provider.example/v1"
placeholder="https://api.my-provider.example/v1"
invalid={Boolean(fieldErrors.baseUrl)}
onChange={(e) => {
setCustomBaseUrl(e.target.value);
@ -442,11 +445,11 @@ function OpenCodeProviderFields({
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<Caption>{tr("Modèle", "Model")}</Caption>
<Input
aria-label={`${profile.name} model`}
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
value={model}
placeholder="ex. mon-modele-1"
placeholder={tr("ex. mon-modele-1", "e.g. my-model-1")}
invalid={Boolean(fieldErrors.model)}
onChange={(e) => {
setModel(e.target.value);
@ -459,11 +462,11 @@ function OpenCodeProviderFields({
</label>
<label className="flex flex-col gap-1">
<Caption>Libellé du modèle (optionnel)</Caption>
<Caption>{tr("Libellé du modèle (facultatif)", "Model label (optional)")}</Caption>
<Input
aria-label={`${profile.name} custom display name`}
aria-label={tr(`Libellé personnalisé de ${profile.name}`, `Custom display name for ${profile.name}`)}
value={customDisplayName}
placeholder="ex. Mon modèle"
placeholder={tr("ex. Mon modèle", "e.g. My model")}
onChange={(e) => setCustomDisplayName(e.target.value)}
/>
</label>
@ -471,16 +474,16 @@ function OpenCodeProviderFields({
)}
<label className="flex flex-col gap-1">
<Caption>Clé API</Caption>
<Caption>{tr("Clé API", "API key")}</Caption>
<div className="flex items-center gap-1">
<Input
aria-label={`${profile.name} api key`}
aria-label={tr(`Clé API de ${profile.name}`, `API key for ${profile.name}`)}
type={showKey ? "text" : "password"}
value={apiKey}
placeholder={
isEditing
? "Ressaisissez la clé API pour confirmer l'enregistrement"
: "ex. sk-ant-…"
? tr("Ressaisissez la clé API pour confirmer l’enregistrement", "Enter the API key again to confirm saving")
: tr("ex. sk-ant-…", "e.g. sk-ant-…")
}
invalid={Boolean(fieldErrors.apiKey)}
onChange={(e) => {
@ -490,7 +493,7 @@ function OpenCodeProviderFields({
/>
<IconButton
size="sm"
aria-label="afficher/masquer la clé API"
aria-label={tr("Afficher ou masquer la clé API", "Show or hide API key")}
onClick={() => setShowKey((v) => !v)}
>
{showKey ? "🙈" : "👁"}
@ -500,13 +503,11 @@ function OpenCodeProviderFields({
<small className="text-xs text-danger">{fieldErrors.apiKey}</small>
)}
<small className="text-xs text-faint">
Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée
chiffrée localement.
{tr("Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée chiffrée localement.", "Never displayed or returned by IdeA after saving; stored locally in encrypted form.")}
</small>
{isEditing && (
<small className="text-xs text-muted">
Pour des raisons de sécurité, la clé n&apos;est jamais réaffichée :
ressaisissez-la à chaque modification de ce profil.
{tr("Pour des raisons de sécurité, la clé n’est jamais réaffichée : ressaisissez-la à chaque modification de ce profil.", "For security, the key is never displayed again: enter it again whenever you edit this profile.")}
</small>
)}
</label>
@ -519,7 +520,7 @@ function OpenCodeProviderFields({
onClick={() => void save()}
className="w-fit"
>
Enregistrer
{tr("Enregistrer", "Save")}
</Button>
</fieldset>
);
@ -537,6 +538,7 @@ function OpenCodeFields({
servers: LocalModelServerConfig[];
onChange: (p: AgentProfile) => void;
}) {
const { tr } = useI18n();
const opencode = profile.opencode ?? defaultOpenCodeConfig();
const patch = (next: Partial<OpenCodeConfig>) =>
onChange({
@ -548,13 +550,13 @@ function OpenCodeFields({
return (
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Local model (OpenCode + llama.cpp)
{tr("Modèle local (OpenCode + llama.cpp)", "Local model (OpenCode + llama.cpp)")}
</legend>
<label className="flex flex-col gap-1">
<Caption>Base URL</Caption>
<Caption>{tr("URL de base", "Base URL")}</Caption>
<Input
aria-label={`${profile.name} base url`}
aria-label={tr(`URL de base de ${profile.name}`, `Base URL for ${profile.name}`)}
value={opencode.baseURL}
placeholder="http://localhost:8080/v1"
invalid={Boolean(errors.baseURL)}
@ -566,9 +568,9 @@ function OpenCodeFields({
</label>
<label className="flex flex-col gap-1">
<Caption>Model</Caption>
<Caption>{tr("Modèle", "Model")}</Caption>
<Input
aria-label={`${profile.name} model`}
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
value={opencode.model}
placeholder="qwen3-coder-30b"
invalid={Boolean(errors.model)}
@ -578,11 +580,11 @@ function OpenCodeFields({
</label>
<label className="flex flex-col gap-1">
<Caption>API key (optional — a local llama.cpp needs none)</Caption>
<Caption>{tr("Clé API (facultative — un llama.cpp local n’en a pas besoin)", "API key (optional — a local llama.cpp needs none)")}</Caption>
<Input
aria-label={`${profile.name} api key`}
aria-label={tr(`Clé API de ${profile.name}`, `API key for ${profile.name}`)}
value={opencode.apiKey ?? ""}
placeholder="e.g. sk-no-key"
placeholder={tr("ex. sk-no-key", "e.g. sk-no-key")}
onChange={(e) => {
const v = e.target.value;
patch({ apiKey: v.length === 0 ? undefined : v });
@ -594,32 +596,32 @@ function OpenCodeFields({
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`${profile.name} reasoning`}
aria-label={tr(`Raisonnement de ${profile.name}`, `Reasoning for ${profile.name}`)}
checked={opencode.reasoning ?? true}
onChange={(e) => patch({ reasoning: e.target.checked })}
className="accent-primary"
/>
<Caption>Reasoning</Caption>
<Caption>{tr("Raisonnement", "Reasoning")}</Caption>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`${profile.name} attachment`}
aria-label={tr(`Pièces jointes de ${profile.name}`, `Attachments for ${profile.name}`)}
checked={opencode.attachment ?? false}
onChange={(e) => patch({ attachment: e.target.checked })}
className="accent-primary"
/>
<Caption>Attachments</Caption>
<Caption>{tr("Pièces jointes", "Attachments")}</Caption>
</label>
</div>
<label className="flex flex-col gap-1">
<Caption>
Local model server (bind to a managed llama.cpp server, or none)
{tr("Serveur de modèle local (lier à un serveur llama.cpp géré, ou aucun)", "Local model server (bind to a managed llama.cpp server, or none)")}
</Caption>
<ModelServerSelect
ariaLabel={`${profile.name} local model server`}
ariaLabel={tr(`Serveur de modèle local de ${profile.name}`, `Local model server for ${profile.name}`)}
servers={servers}
value={opencode.localModelServerId}
onChange={(serverId) => patch({ localModelServerId: serverId })}

View File

@ -30,7 +30,7 @@ function chooseDropdownOption(label: string | RegExp, optionName: string | RegEx
async function waitReady() {
await waitFor(() =>
expect(
(screen.getByRole("button", { name: "Creer un profil" }) as HTMLButtonElement)
(screen.getByRole("button", { name: "Créer un profil" }) as HTMLButtonElement)
.disabled,
).toBe(false),
);
@ -38,7 +38,7 @@ async function waitReady() {
async function createProfile() {
const before = screen.queryAllByRole("listitem").length;
fireEvent.click(screen.getByRole("button", { name: "Creer un profil" }));
fireEvent.click(screen.getByRole("button", { name: "Créer un profil" }));
await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(before + 1));
}
@ -50,10 +50,10 @@ describe("ProfilesSettings", () => {
await createProfile();
await createProfile();
let rows = screen.getAllByRole("listitem");
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
fireEvent.change(within(rows[1]).getByLabelText(/Nom du profil/i), {
target: { value: "Codex mini" },
});
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
fireEvent.change(within(rows[1]).getByLabelText(/Modèle du profil/i), {
target: { value: "gpt-5-mini" },
});
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
@ -63,10 +63,10 @@ describe("ProfilesSettings", () => {
await createProfile();
await createProfile();
rows = screen.getAllByRole("listitem");
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
fireEvent.change(within(rows[1]).getByLabelText(/Nom du profil/i), {
target: { value: "Claude Opus" },
});
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
fireEvent.change(within(rows[1]).getByLabelText(/Modèle du profil/i), {
target: { value: "claude-opus-4-8" },
});
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
@ -96,7 +96,7 @@ describe("ProfilesSettings", () => {
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.some((p) => p.name === "OpenAI Codex CLI copy copy")).toBe(true);
expect(saved.some((p) => p.name === "OpenAI Codex CLI copie copie")).toBe(true);
expect(saved.filter((p) => p.model === "gpt-5-codex")).toHaveLength(2);
});
});
@ -114,7 +114,7 @@ describe("ProfilesSettings", () => {
await createProfile();
const model = within(screen.getAllByRole("listitem")[0]).getByLabelText(
/modele du profil/,
/Modèle du profil/i,
) as HTMLInputElement;
fireEvent.change(model, { target: { value: "future-codex-model" } });
expect(model.value).toBe("future-codex-model");
@ -127,13 +127,13 @@ describe("ProfilesSettings", () => {
await createProfile();
const row = screen.getAllByRole("listitem")[0];
const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement;
const model = within(row).getByLabelText(/Modèle du profil/i) as HTMLInputElement;
fireEvent.focus(model);
expect(await within(row).findByText("Compatible")).toBeTruthy();
expect(
within(row).getByText(
/Compatible avec Codex CLI 0\.145\.0 d'après le catalogue local IdeA\./,
/Compatible avec Codex CLI 0\.145\.0 d’après le catalogue local IdeA\./,
),
).toBeTruthy();
@ -145,7 +145,7 @@ describe("ProfilesSettings", () => {
expect(within(row).getByText("Inconnu")).toBeTruthy();
expect(
within(row).getByText(
/Compatibilité non connue pour Codex CLI 0\.145\.0 ; la saisie reste autorisée\./,
/Compatibilité inconnue pour Codex CLI 0\.145\.0 ; la saisie reste autorisée\./,
),
).toBeTruthy();
});
@ -156,7 +156,7 @@ describe("ProfilesSettings", () => {
await createProfile();
const row = screen.getAllByRole("listitem")[0];
const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement;
const model = within(row).getByLabelText(/Modèle du profil/i) as HTMLInputElement;
fireEvent.change(model, { target: { value: "gpt-5-mini" } });
fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" }));
@ -192,20 +192,20 @@ describe("ProfilesSettings", () => {
fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" }));
await waitReady();
expect(screen.getByRole("button", { name: "add model server" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Ajouter un serveur de modèles" })).toBeTruthy();
expect(screen.getByText("Local A")).toBeTruthy();
await createProfile();
await screen.findByLabelText(
"OpenCode + llama.cpp copy local model server",
"Serveur de modèle local de OpenCode + llama.cpp copie",
);
chooseDropdownOption(
"OpenCode + llama.cpp copy local model server",
"Serveur de modèle local de OpenCode + llama.cpp copie",
/Local A/,
);
const profileRow = screen
.getByLabelText("OpenCode + llama.cpp copy local model server")
.getByLabelText("Serveur de modèle local de OpenCode + llama.cpp copie")
.closest("li");
expect(profileRow).not.toBeNull();
fireEvent.click(
@ -222,12 +222,12 @@ describe("ProfilesSettings", () => {
fireEvent.click(
within(profileRow as HTMLElement).getByRole("radio", {
name: "Provider cloud",
name: "Fournisseur cloud",
}),
);
expect(
await within(profileRow as HTMLElement).findByLabelText(
"OpenCode + llama.cpp copy provider",
"Fournisseur de OpenCode + llama.cpp copie",
),
).toBeTruthy();
});
@ -242,17 +242,17 @@ describe("ProfilesSettings", () => {
fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" }));
await waitReady();
fireEvent.click(screen.getByRole("button", { name: "Creer un profil" }));
fireEvent.click(screen.getByRole("button", { name: "Créer un profil" }));
const name = await screen.findByDisplayValue("OpenCode + llama.cpp copy");
const name = await screen.findByDisplayValue("OpenCode + llama.cpp copie");
expect(name).toBeTruthy();
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.map((p) => p.name)).toContain("OpenCode + llama.cpp copy");
expect(saved.map((p) => p.name)).toContain("OpenCode + llama.cpp copie");
expect(saved.some((p) => p.structuredAdapter === "openCode")).toBe(true);
});
expect(cloneOpenCodeSpy).toHaveBeenCalledWith({
name: "OpenCode + llama.cpp copy",
name: "OpenCode + llama.cpp copie",
opencode: expect.objectContaining({
baseURL: "http://localhost:8080/v1",
model: "qwen3-coder-30b",
@ -271,10 +271,10 @@ describe("ProfilesSettings", () => {
await createProfile();
const row = screen.getAllByRole("listitem")[0];
fireEvent.change(within(row).getByLabelText("OpenCode + llama.cpp copy base url"), {
fireEvent.change(within(row).getByLabelText("URL de base de OpenCode + llama.cpp copie"), {
target: { value: "http://localhost:9090/v1" },
});
fireEvent.change(within(row).getByLabelText("OpenCode + llama.cpp copy model"), {
fireEvent.change(within(row).getByLabelText("Modèle de OpenCode + llama.cpp copie"), {
target: { value: "local-special" },
});
fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" }));
@ -292,7 +292,7 @@ describe("ProfilesSettings", () => {
const saved = await profile.listProfiles();
const copies = saved.filter((p) => p.structuredAdapter === "openCode");
expect(copies).toHaveLength(2);
expect(copies[1]?.name).toBe("OpenCode + llama.cpp copy copy");
expect(copies[1]?.name).toBe("OpenCode + llama.cpp copie copie");
expect(copies[1]?.opencode?.baseURL).toBe("http://localhost:9090/v1");
expect(copies[1]?.opencode?.model).toBe("local-special");
});

View File

@ -14,6 +14,7 @@ import type {
} from "@/domain";
import { useGateways } from "@/app/di";
import { Button, Input, Panel, cn } from "@/shared";
import { useI18n } from "@/i18n";
import {
ModelServersPanel,
useModelServers,
@ -27,6 +28,7 @@ import { validateProfile } from "./profile";
type ProfileTab = "codex" | "claude" | "openCode";
type ModelTab = "codex" | "claude";
type CatalogState = Record<ModelTab, ProfileModelCatalog & { unavailable: boolean }>;
type Tr = (french: string, english: string) => string;
const TABS: Array<{ id: ProfileTab; label: string }> = [
{ id: "codex", label: "Codex" },
@ -83,16 +85,16 @@ function withModel(profile: AgentProfile, model: string): AgentProfile {
return { ...profile, model: nextModel };
}
function optionLabel(entry: ProfileModelCatalogEntry): string {
function optionLabel(entry: ProfileModelCatalogEntry, tr: Tr): string {
return entry.recommended
? `${entry.displayName} (${entry.modelId}, recommande)`
? `${entry.displayName} (${entry.modelId}, ${tr("recommandé", "recommended")})`
: `${entry.displayName} (${entry.modelId})`;
}
function compatibilityLabel(compatibility: ModelCompatibility): string {
if (compatibility === "compatible") return "Compatible";
if (compatibility === "likelyTooRecent") return "Probablement trop récent";
return "Inconnu";
function compatibilityLabel(compatibility: ModelCompatibility, tr: Tr): string {
if (compatibility === "compatible") return tr("Compatible", "Compatible");
if (compatibility === "likelyTooRecent") return tr("Probablement trop récent", "Likely too recent");
return tr("Inconnu", "Unknown");
}
function engineLabel(tab: ModelTab): string {
@ -102,10 +104,11 @@ function engineLabel(tab: ModelTab): string {
function attentionBadge(
compatibility: ModelCompatibility,
cliVersion: string | null,
tr: Tr,
): string | null {
if (!cliVersion) return "CLI non détecté";
if (!cliVersion) return tr("CLI non détectée", "CLI not detected");
if (compatibility === "compatible") return null;
return compatibilityLabel(compatibility);
return compatibilityLabel(compatibility, tr);
}
function catalogEntryFor(
@ -128,28 +131,29 @@ function modelHelp(
tab: ModelTab,
model: string,
catalog: ProfileModelCatalog & { unavailable: boolean },
tr: Tr,
): string {
const cli = engineLabel(tab);
if (catalog.unavailable) {
return "Catalogue provider indisponible ; vous pouvez saisir le modèle manuellement.";
return tr("Catalogue du fournisseur indisponible ; vous pouvez saisir le modèle manuellement.", "Provider catalog unavailable; you can enter the model manually.");
}
if (!catalog.cliVersion) {
return `Version du ${cli} non détectée ; IdeA ne peut pas estimer la compatibilité.`;
return tr(`Version de ${cli} non détectée ; IdeA ne peut pas estimer la compatibilité.`, `${cli} version not detected; IdeA cannot estimate compatibility.`);
}
const compatibility = compatibilityFor(model, catalog.models);
if (compatibility === "compatible") {
return `Compatible avec ${cli} ${catalog.cliVersion} d'après le catalogue local IdeA.`;
return tr(`Compatible avec ${cli} ${catalog.cliVersion} d’après le catalogue local IdeA.`, `Compatible with ${cli} ${catalog.cliVersion} according to IdeA's local catalog.`);
}
if (compatibility === "likelyTooRecent") {
return `Probablement trop récent pour ${cli} ${catalog.cliVersion} ; mettez à jour le CLI si le lancement échoue.`;
return tr(`Probablement trop récent pour ${cli} ${catalog.cliVersion} ; mettez à jour la CLI si le lancement échoue.`, `Likely too recent for ${cli} ${catalog.cliVersion}; update the CLI if launch fails.`);
}
return `Compatibilité non connue pour ${cli} ${catalog.cliVersion} ; la saisie reste autorisée.`;
return tr(`Compatibilité inconnue pour ${cli} ${catalog.cliVersion} ; la saisie reste autorisée.`, `Compatibility unknown for ${cli} ${catalog.cliVersion}; manual entry remains allowed.`);
}
function saveWarningText(tab: ModelTab, cliVersion: string | null): string {
function saveWarningText(tab: ModelTab, cliVersion: string | null, tr: Tr): string {
const cli = engineLabel(tab);
const version = cliVersion ? ` ${cliVersion}` : "";
return `Ce modèle semble plus récent que votre ${cli}${version}. Le profil peut être enregistré, mais l'agent pourrait échouer au lancement tant que le CLI n'est pas mis à jour.`;
return tr(`Ce modèle semble plus récent que votre ${cli}${version}. Le profil peut être enregistré, mais l’agent pourrait échouer au lancement tant que la CLI n’est pas mise à jour.`, `This model appears newer than your ${cli}${version}. The profile can be saved, but the agent may fail to launch until the CLI is updated.`);
}
function matchingSuggestions(
@ -183,10 +187,11 @@ function ModelField({
catalog: ProfileModelCatalog & { unavailable: boolean };
onChange: (model: string) => void;
}) {
const { tr } = useI18n();
const [focused, setFocused] = useState(false);
const inputId = `profile-model-${tab}-${profileId}`;
const compatibility = compatibilityFor(model, catalog.models);
const badge = attentionBadge(compatibility, catalog.cliVersion);
const badge = attentionBadge(compatibility, catalog.cliVersion, tr);
const suggestions = matchingSuggestions(model, catalog.models);
return (
@ -195,12 +200,12 @@ function ModelField({
htmlFor={inputId}
className="flex items-center gap-2 text-xs font-medium text-muted"
>
Modèle
{tr("Modèle", "Model")}
<span
className="text-faint"
title="IdeA estime la compatibilité à partir du catalogue maintenu dans l'application et de la version du CLI détectée localement. Le provider peut accepter ou refuser le modèle différemment au moment du lancement."
title={tr("IdeA estime la compatibilité à partir du catalogue maintenu dans l’application et de la version de la CLI détectée localement. Le fournisseur peut accepter ou refuser le modèle différemment au lancement.", "IdeA estimates compatibility from its maintained catalog and the locally detected CLI version. The provider may accept or reject the model differently at launch.")}
>
compatibilité estimée
{tr("compatibilité estimée", "estimated compatibility")}
</span>
{badge && (
<span
@ -217,22 +222,22 @@ function ModelField({
</label>
<Input
id={inputId}
aria-label={`modele du profil ${profileName}`}
aria-label={tr(`Modèle du profil ${profileName}`, `Model for profile ${profileName}`)}
placeholder={
catalog.models.length > 0
? "Choisir ou saisir un modèle"
: "Saisir un modèle"
? tr("Choisir ou saisir un modèle", "Choose or enter a model")
: tr("Saisir un modèle", "Enter a model")
}
value={model}
onFocus={() => setFocused(true)}
onBlur={() => window.setTimeout(() => setFocused(false), 120)}
onChange={(e) => onChange(e.target.value)}
/>
<small className="text-xs text-faint">{modelHelp(tab, model, catalog)}</small>
<small className="text-xs text-faint">{modelHelp(tab, model, catalog, tr)}</small>
{focused && suggestions.length > 0 && (
<div
role="listbox"
aria-label={`suggestions de modèles ${profileName}`}
aria-label={tr(`Suggestions de modèles pour ${profileName}`, `Model suggestions for ${profileName}`)}
className="mt-1 max-h-36 overflow-auto rounded-md border border-border bg-raised p-1"
>
{suggestions.map((entry) => (
@ -247,7 +252,7 @@ function ModelField({
setFocused(false);
}}
>
<span className="min-w-0 truncate">{optionLabel(entry)}</span>
<span className="min-w-0 truncate">{optionLabel(entry, tr)}</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium",
@ -258,7 +263,7 @@ function ModelField({
: "bg-surface text-muted",
)}
>
{compatibilityLabel(entry.compatibility)}
{compatibilityLabel(entry.compatibility, tr)}
</span>
</button>
))}
@ -269,6 +274,7 @@ function ModelField({
}
export function ProfilesSettings() {
const { tr } = useI18n();
const { profile } = useGateways();
const modelServers = useModelServers();
const providerCatalog = useOpenCodeProviderCatalog();
@ -357,7 +363,7 @@ export function ProfilesSettings() {
try {
if (activeTab === "openCode") {
const created = await profile.cloneOpenCodeProfileFromSeed({
name: `${seed.name} copy`,
name: `${seed.name} ${tr("copie", "copy")}`,
opencode: seed.opencode,
});
await profile.saveProfile(created);
@ -372,7 +378,7 @@ export function ProfilesSettings() {
const recommended = models.find((m) => m.recommended)?.modelId;
await profile.cloneProfileFromSeed({
seedProfileId: seed.id,
name: `${seed.name} copy`,
name: `${seed.name} ${tr("copie", "copy")}`,
model: recommended,
});
await refresh();
@ -398,7 +404,7 @@ export function ProfilesSettings() {
tab === "codex" || tab === "claude"
? compatibilityFor(modelOf(draft), catalogue[tab].models) ===
"likelyTooRecent"
? saveWarningText(tab, catalogue[tab].cliVersion)
? saveWarningText(tab, catalogue[tab].cliVersion, tr)
: null
: null;
await profile.saveProfile(draft);
@ -419,7 +425,7 @@ export function ProfilesSettings() {
try {
if (source.structuredAdapter === "openCode") {
const created = await profile.cloneOpenCodeProfileFromSeed({
name: `${source.name} copy`,
name: `${source.name} ${tr("copie", "copy")}`,
opencode: source.opencode,
});
await profile.saveProfile(created);
@ -429,7 +435,7 @@ export function ProfilesSettings() {
await profile.cloneProfileFromSeed({
seedProfileId: source.id,
name: `${source.name} copy`,
name: `${source.name} ${tr("copie", "copy")}`,
model:
source.structuredAdapter === "codex" ||
source.structuredAdapter === "claude"
@ -459,18 +465,18 @@ export function ProfilesSettings() {
return (
<Panel
aria-label="ai profiles settings"
title="Profils IA"
aria-label={tr("Paramètres des profils IA", "AI profile settings")}
title={tr("Profils IA", "AI profiles")}
actions={
<Button size="sm" onClick={() => void createFromSeed()} disabled={!seed || busy}>
Creer un profil
{tr("Créer un profil", "Create profile")}
</Button>
}
>
<div className="flex flex-col gap-4">
<div
role="tablist"
aria-label="types de profils IA"
aria-label={tr("Types de profils IA", "AI profile types")}
className="inline-flex w-fit rounded-md border border-border bg-raised p-0.5"
>
{TABS.map((tab) => (
@ -512,7 +518,7 @@ export function ProfilesSettings() {
{visibleProfiles.length === 0 ? (
<p className="text-sm text-muted">
Aucun profil {TABS.find((tab) => tab.id === activeTab)?.label} configure.
{tr("Aucun profil", "No")} {TABS.find((tab) => tab.id === activeTab)?.label} {tr("configuré.", "profile configured.")}
</p>
) : (
<ul className="flex flex-col gap-3">
@ -528,9 +534,9 @@ export function ProfilesSettings() {
>
<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>
<span className="text-xs font-medium text-muted">{tr("Nom", "Name")}</span>
<Input
aria-label={`nom du profil ${saved.name}`}
aria-label={tr(`Nom du profil ${saved.name}`, `Name of profile ${saved.name}`)}
value={draft.name}
onChange={(e) =>
updateDraft(saved.id, (p) => ({
@ -585,7 +591,7 @@ export function ProfilesSettings() {
disabled={!dirty || busy || draft.name.trim() === ""}
onClick={() => void save(saved.id)}
>
Enregistrer
{tr("Enregistrer", "Save")}
</Button>
<Button
size="sm"
@ -593,17 +599,17 @@ export function ProfilesSettings() {
disabled={busy}
onClick={() => void duplicate(saved)}
>
Dupliquer
{tr("Dupliquer", "Duplicate")}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy}
className="text-danger hover:text-danger"
aria-label={`supprimer ${saved.name}`}
aria-label={tr(`Supprimer ${saved.name}`, `Delete ${saved.name}`)}
onClick={() => void del(saved)}
>
Supprimer
{tr("Supprimer", "Delete")}
</Button>
</span>
</div>