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

@ -17,6 +17,7 @@
import { useEffect, useState } from "react";
import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared";
import { useI18n } from "@/i18n";
import { TerminalView } from "@/features/terminals/TerminalView";
import { AnnouncementsPreview } from "@/features/announcements";
import { useDrift } from "@/features/templates/useDrift";
@ -45,6 +46,7 @@ export interface AgentsPanelProps {
}
export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
const { tr } = useI18n();
const vm = useAgents(projectId);
const drift = useDrift(projectId);
const gateways = useGateways();
@ -260,7 +262,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
};
return (
<Panel title="Agents" className="flex flex-col gap-0">
<Panel title={tr("Agents", "Agents")} className="flex flex-col gap-0">
{vm.error && (
<p
role="alert"
@ -280,12 +282,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
htmlFor="agent-name-input"
className="text-xs font-medium text-muted"
>
Name
{tr("Nom", "Name")}
</label>
<Input
id="agent-name-input"
aria-label="agent name"
placeholder="My agent"
aria-label={tr("Nom de l’agent", "Agent name")}
placeholder={tr("Mon agent", "My agent")}
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full"
@ -298,39 +300,38 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
htmlFor="agent-template-select"
className="text-xs font-medium text-muted"
>
Template
{tr("Modèle", "Template")}
</label>
<SmallDropdown
id="agent-template-select"
aria-label="agent template"
aria-label={tr("Modèle d’agent", "Agent template")}
value={newTemplateId}
onChange={handleTemplateChange}
className="w-full"
disabled={vm.busy}
size="md"
options={[
{ value: "", label: "(none / from scratch)" },
{ value: "", label: tr("(aucun / partir de zéro)", "(none / from scratch)") },
...templates.map((t) => ({ value: t.id, label: t.name })),
]}
/>
{selectedTemplate && (
<div className="rounded-md border border-border bg-raised px-3 py-2 text-xs text-muted">
<div>
Nom initial:{" "}
{tr("Nom initial", "Initial name")}: {" "}
<span className="text-content">
{selectedTemplate.defaultAgentName || selectedTemplate.name}
</span>
</div>
<div>
Modèle appliqué:{" "}
{tr("Modèle appliqué", "Applied model")}: {" "}
<span className="text-content">
{templateProfileLabel(selectedTemplate, vm.profiles)}
{templateProfileLabel(selectedTemplate, vm.profiles, tr)}
</span>
</div>
{selectedTemplateMissingProfile && (
<p role="alert" className="mt-1 text-danger">
Ce template n'a pas de modèle AI par défaut. Choisis un autre
template ou édite-le avant de créer l'agent.
{tr("Ce modèle n’a pas de profil IA par défaut. Choisissez un autre modèle ou modifiez-le avant de créer l’agent.", "This template has no default AI profile. Choose another template or edit it before creating the agent.")}
</p>
)}
</div>
@ -344,21 +345,21 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
htmlFor="agent-profile-select"
className="text-xs font-medium text-muted"
>
Profile
{tr("Profil", "Profile")}
{vm.profiles.length === 0 && (
<span className="ml-1 text-faint">(no profiles configured — you can still enter an id)</span>
<span className="ml-1 text-faint">{tr("(aucun profil configuré — vous pouvez saisir un identifiant)", "(no profiles configured — you can still enter an id)")}</span>
)}
</label>
{vm.profiles.length > 0 ? (
<SmallDropdown
id="agent-profile-select"
aria-label="agent profile"
aria-label={tr("Profil de l’agent", "Agent profile")}
value={newProfileId}
onChange={setNewProfileId}
className="w-full"
size="md"
options={[
{ value: "", label: "— select profile —" },
{ value: "", label: tr("— sélectionner un profil —", "— select profile —") },
...vm.profiles.map((p) => ({
value: p.id,
label: profileLabel(p),
@ -368,8 +369,8 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
) : (
<Input
id="agent-profile-select"
aria-label="agent profile"
placeholder="profile-id (optional)"
aria-label={tr("Profil de l’agent", "Agent profile")}
placeholder={tr("identifiant-profil (facultatif)", "profile-id (optional)")}
value={newProfileId}
onChange={(e) => setNewProfileId(e.target.value)}
className="min-w-32"
@ -381,19 +382,19 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
<Button
type="submit"
variant="primary"
aria-label="create agent"
aria-label={tr("Créer l’agent", "Create agent")}
disabled={!canCreate}
loading={vm.busy}
className="self-end"
>
Create
{tr("Créer", "Create")}
</Button>
</form>
{/* ── Agent list ── */}
<div className="p-4">
{vm.agents.length === 0 ? (
<p className="text-sm text-muted">No agents yet.</p>
<p className="text-sm text-muted">{tr("Aucun agent pour le moment.", "No agents yet.")}</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{vm.agents.map((a) => {
@ -438,27 +439,27 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
<span className="font-medium text-content">{a.name}</span>
{a.isOrchestrator && (
<span
aria-label="Writer contexte"
aria-label={tr("Responsable du contexte", "Context writer")}
className="rounded-full border border-border-strong bg-raised px-2 py-0.5 text-xs font-medium text-content"
>
Writer contexte
{tr("Responsable du contexte", "Context writer")}
</span>
)}
{agentDrift && (
<span
aria-label="update available"
aria-label={tr("Mise à jour disponible", "Update available")}
className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary"
>
update available
{tr("mise à jour disponible", "update available")}
</span>
)}
{delegationSource && (
<span
aria-label={`delegation source ${delegationSource}`}
aria-label={tr(`source de délégation ${delegationSource}`, `delegation source ${delegationSource}`)}
title={
delegationSource === "mcp"
? "Last delegation arrived via the MCP server"
: "Last delegation arrived via .ideai/requests"
? tr("La dernière délégation est arrivée via le serveur MCP", "Last delegation arrived via the MCP server")
: tr("La dernière délégation est arrivée via .ideai/requests", "Last delegation arrived via .ideai/requests")
}
className="rounded-full bg-raised px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted"
>
@ -470,36 +471,35 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
<span className="text-xs text-muted">{profileName}</span>
{live && (
<span className="block w-full min-w-0 truncate text-xs text-primary">
running in IdeA · {live.sessionId ?? live.nodeId}
{tr("actif dans IdeA", "running in IdeA")} · {live.sessionId ?? live.nodeId}
</span>
)}
</button>
{isSelected && (
<section
aria-label={`Rôle dans le projet — ${a.name}`}
aria-label={`${tr("Rôle dans le projet", "Role in project")} — ${a.name}`}
className="rounded-md border border-border bg-surface p-3"
>
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
Rôle dans le projet
{tr("Rôle dans le projet", "Role in project")}
</h4>
<label className="mt-2 flex cursor-pointer items-start gap-2">
<input
type="radio"
name="project-context-writer"
aria-label={`Writer du contexte projet global pour ${a.name}`}
aria-label={tr(`Responsable du contexte global du projet pour ${a.name}`, `Global project context writer for ${a.name}`)}
checked={a.isOrchestrator === true}
disabled={vm.busy}
onChange={() => requestWriterChange(a)}
className="mt-0.5"
/>
<span className="text-sm font-medium text-content">
Writer du contexte projet global
{tr("Responsable du contexte global du projet", "Global project context writer")}
</span>
</label>
<p className="mt-1 pl-5 text-xs text-muted">
Peut modifier directement le contexte partagé du projet.
Un seul agent peut avoir ce rôle.
{tr("Peut modifier directement le contexte partagé du projet. Un seul agent peut avoir ce rôle.", "Can directly edit the project’s shared context. Only one agent can have this role.")}
</p>
</section>
)}
@ -531,7 +531,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
engine abandons the conversation history → confirmation. */}
{vm.profiles.length > 0 && (
<SmallDropdown
aria-label={`profile for ${a.name}`}
aria-label={tr(`Profil de ${a.name}`, `Profile for ${a.name}`)}
value={a.profileId}
disabled={vm.busy}
onChange={(profileId) => {
@ -562,13 +562,13 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
<Button
size="sm"
variant="primary"
aria-label={`sync ${a.name}`}
aria-label={tr(`Synchroniser ${a.name}`, `Sync ${a.name}`)}
disabled={vm.busy || drift.driftBusy}
onClick={() =>
void drift.syncAgent(a.id, vm.refresh)
}
>
Sync
{tr("Synchroniser", "Sync")}
</Button>
)}
{isRunning || live ? (
@ -577,41 +577,41 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
variant="ghost"
aria-label={
stopState === "stopping"
? `Arrêt en cours… — ${a.name}`
? tr(`Arrêt en cours… — ${a.name}`, `Stopping… — ${a.name}`)
: stopState === "incomplete"
? `Réessayer l’arrêt — ${a.name}`
: `stop ${a.name}`
? tr(`Réessayer l’arrêt — ${a.name}`, `Retry stopping — ${a.name}`)
: tr(`Arrêter ${a.name}`, `Stop ${a.name}`)
}
disabled={stopState === "stopping"}
onClick={() => void handleStop(a.id)}
className="text-danger hover:text-danger"
>
{stopState === "stopping"
? "Arrêt en cours…"
? tr("Arrêt en cours…", "Stopping…")
: stopState === "incomplete"
? "Réessayer l’arrêt"
: "Stop"}
? tr("Réessayer l’arrêt", "Retry stopping")
: tr("Arrêter", "Stop")}
</Button>
) : (
<Button
size="sm"
variant="primary"
aria-label={`launch ${a.name}`}
aria-label={tr(`Lancer ${a.name}`, `Launch ${a.name}`)}
disabled={vm.busy || activeAgentId !== null}
onClick={() => handleLaunch(a.id)}
>
Launch
{tr("Lancer", "Launch")}
</Button>
)}
<Button
size="sm"
variant="ghost"
aria-label={`delete ${a.name}`}
aria-label={tr(`Supprimer ${a.name}`, `Delete ${a.name}`)}
disabled={vm.busy}
onClick={() => void vm.deleteAgent(a.id)}
className="text-danger hover:text-danger"
>
Delete
{tr("Supprimer", "Delete")}
</Button>
</div>
</li>
@ -653,12 +653,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
<div className="flex flex-col gap-2 border-t border-border p-4">
<div className="flex items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-content">
Context — {selectedAgent.name}
{tr("Contexte", "Context")} — {selectedAgent.name}
</h4>
<code className="text-xs text-muted">{selectedAgent.contextPath}</code>
</div>
<textarea
aria-label="agent context"
aria-label={tr("Contexte de l’agent", "Agent context")}
value={editedContext}
onChange={(e) => setEditedContext(e.target.value)}
rows={10}
@ -677,7 +677,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
disabled={vm.busy}
onClick={() => void vm.saveContext(editedContext)}
>
Save
{tr("Enregistrer", "Save")}
</Button>
</div>
</div>
@ -688,34 +688,33 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
<div
role="dialog"
aria-modal="true"
aria-label="Confirm profile change"
aria-label={tr("Confirmer le changement de profil", "Confirm profile change")}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
<h4 className="text-sm font-semibold text-content">
Changer le moteur IA
{tr("Changer le moteur IA", "Change AI engine")}
</h4>
<p className="mt-2 text-sm text-muted">
Changer le moteur abandonne l'historique de conversation (le
contexte et la mémoire sont conservés). Continuer&nbsp;?
{tr("Changer le moteur abandonne l’historique de conversation (le contexte et la mémoire sont conservés). Continuer ?", "Changing the engine discards conversation history (context and memory are preserved). Continue?")}
</p>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
aria-label="cancel profile change"
aria-label={tr("Annuler le changement de profil", "Cancel profile change")}
disabled={vm.busy}
onClick={() => setPendingProfileChange(null)}
>
Annuler
{tr("Annuler", "Cancel")}
</Button>
<Button
variant="primary"
aria-label="confirm profile change"
aria-label={tr("Confirmer le changement de profil", "Confirm profile change")}
loading={vm.busy}
disabled={vm.busy}
onClick={() => void confirmProfileChange()}
>
Continuer
{tr("Continuer", "Continue")}
</Button>
</div>
</div>
@ -726,34 +725,33 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
<div
role="dialog"
aria-modal="true"
aria-label="Confirmer le changement de writer contexte"
aria-label={tr("Confirmer le changement de responsable du contexte", "Confirm context writer change")}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
<h4 className="text-sm font-semibold text-content">
Changer le writer du contexte
{tr("Changer le responsable du contexte", "Change context writer")}
</h4>
<p className="mt-2 text-sm text-muted">
Le rôle sera retiré de {pendingWriterChange.currentWriterName} et
attribué à {pendingWriterChange.agentName}. Continuer&nbsp;?
{tr(`Le rôle sera retiré de ${pendingWriterChange.currentWriterName} et attribué à ${pendingWriterChange.agentName}. Continuer ?`, `The role will be removed from ${pendingWriterChange.currentWriterName} and assigned to ${pendingWriterChange.agentName}. Continue?`)}
</p>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
aria-label="Annuler le changement de writer contexte"
aria-label={tr("Annuler le changement de responsable du contexte", "Cancel context writer change")}
disabled={vm.busy}
onClick={() => setPendingWriterChange(null)}
>
Annuler
{tr("Annuler", "Cancel")}
</Button>
<Button
variant="primary"
aria-label="Confirmer le changement de writer contexte"
aria-label={tr("Confirmer le changement de responsable du contexte", "Confirm context writer change")}
loading={vm.busy}
disabled={vm.busy}
onClick={() => void confirmWriterChange()}
>
Continuer
{tr("Continuer", "Continue")}
</Button>
</div>
</div>
@ -766,23 +764,13 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
const EFFORT_DEFAULT_VALUE = "__profile_default__";
const EFFORT_CUSTOM_VALUE = "__custom_effort__";
const GENERIC_EFFORT_OPTIONS: EffortOption[] = [
{
value: "low",
label: "Rapide (par défaut)",
hint: "Fallback générique léger.",
},
{
value: "medium",
label: "Standard (par défaut)",
hint: "Fallback générique équilibré.",
},
{
value: "high",
label: "Approfondi (par défaut)",
hint: "Fallback générique profond.",
},
];
function genericEffortOptions(tr: (fr: string, en: string) => string): EffortOption[] {
return [
{ value: "low", label: tr("Rapide (par défaut)", "Fast (default)"), hint: tr("Réglage générique léger.", "Light generic fallback.") },
{ value: "medium", label: tr("Standard (par défaut)", "Standard (default)"), hint: tr("Réglage générique équilibré.", "Balanced generic fallback.") },
{ value: "high", label: tr("Approfondi (par défaut)", "Thorough (default)"), hint: tr("Réglage générique approfondi.", "Deep generic fallback.") },
];
}
function rawEffortValue(selection: EffortSelection | undefined): string {
return selection?.value ?? "";
@ -799,11 +787,12 @@ function EffortSelector({
busy: boolean;
onChange: (effort: EffortSelection | null) => void;
}) {
const { tr } = useI18n();
const nativeOptions = profile?.effortOptions ?? [];
const hasNativeOptions = nativeOptions.length > 0;
const displayedOptions = hasNativeOptions
? nativeOptions
: GENERIC_EFFORT_OPTIONS;
: genericEffortOptions(tr);
const [customText, setCustomText] = useState(rawEffortValue(agent.effort));
const [forceCustom, setForceCustom] = useState(agent.effort?.kind === "custom");
@ -843,7 +832,7 @@ function EffortSelector({
return (
<span className="flex min-w-[11rem] max-w-full flex-wrap items-center gap-1.5">
<SmallDropdown
aria-label={`effort for ${agent.name}`}
aria-label={tr(`Effort pour ${agent.name}`, `Effort for ${agent.name}`)}
value={selectedOption}
disabled={busy}
onChange={(value) => {
@ -868,20 +857,20 @@ function EffortSelector({
}
}}
options={[
{ value: EFFORT_DEFAULT_VALUE, label: "Effort: profil" },
{ value: EFFORT_DEFAULT_VALUE, label: tr("Effort : profil", "Effort: profile") },
...displayedOptions.map((option) => ({
value: `${hasNativeOptions ? "preset" : "fallback"}:${option.value}`,
label: option.label,
})),
{ value: EFFORT_CUSTOM_VALUE, label: "Personnalisé" },
{ value: EFFORT_CUSTOM_VALUE, label: tr("Personnalisé", "Custom") },
]}
/>
{customVisible && (
<Input
aria-label={`custom effort for ${agent.name}`}
aria-label={tr(`Effort personnalisé pour ${agent.name}`, `Custom effort for ${agent.name}`)}
value={customText}
disabled={busy}
placeholder="valeur brute"
placeholder={tr("valeur brute", "raw value")}
onChange={(event) => setCustomText(event.target.value)}
onBlur={commitCustom}
onKeyDown={(event) => {
@ -903,21 +892,22 @@ function NetworkPermissionBadge({
}: {
state: ResolvedAgentSystemPermissions;
}) {
const { tr } = useI18n();
const locked = state.runtimeLock.state === "locked";
const label = locked
? "Verrouillé"
? tr("Verrouillé", "Locked")
: state.effective === "allow"
? "Autorisé"
? tr("Autorisé", "Allowed")
: state.effective === "deny"
? "Interdit"
: "Demande";
? tr("Interdit", "Denied")
: tr("Demande", "Ask");
return (
<span
aria-label={`network ${label}`}
aria-label={`${tr("Réseau", "Network")} ${label}`}
title={
locked
? (state.runtimeLock.reason ?? "Réseau verrouillé par le runtime")
: `État effectif : ${label}`
? (state.runtimeLock.reason ?? tr("Réseau verrouillé par l’exécution", "Network locked by runtime"))
: `${tr("État effectif", "Effective state")} : ${label}`
}
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
@ -930,7 +920,7 @@ function NetworkPermissionBadge({
: "bg-primary/15 text-primary",
)}
>
Réseau: {label}
{tr("Réseau", "Network")}: {label}
</span>
);
}