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

@ -28,6 +28,7 @@ import type {
} from "@/ports";
import { TerminalView, useWritePortal } from "@/features/terminals";
import { useGateways } from "@/app/di";
import { useI18n } from "@/i18n";
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
import {
AgentRestartOverlay,
@ -94,6 +95,7 @@ export function LayoutGrid({
onOpenConversation,
onOpenPluginsSettings,
}: LayoutGridProps) {
const { tr } = useI18n();
const vm = useLayout(projectId, layoutId);
const work = useProjectWorkState(projectId);
const [refitEpoch, setRefitEpoch] = useState(0);
@ -110,7 +112,7 @@ export function LayoutGrid({
{vm.error}
</p>
) : (
<p>Loading layout…</p>
<p>{tr("Chargement de la disposition…", "Loading layout…")}</p>
)}
</div>
);
@ -346,26 +348,27 @@ function StoppedAgentCellPanel({
onRestart: () => void;
onRefresh: () => void;
}) {
const { tr } = useI18n();
return (
<section
className="stopped-agent-cell"
aria-label={`${agentName} arrêté`}
aria-label={`${agentName} ${tr("arrêté", "stopped")}`}
>
<div className="stopped-agent-cell__header">
<h3 className="stopped-agent-cell__name">{agentName}</h3>
<span className="stopped-agent-cell__badge">Arrêté</span>
<span className="stopped-agent-cell__badge">{tr("Arrêté", "Stopped")}</span>
</div>
<div className="stopped-agent-cell__content">
<p className="stopped-agent-cell__message">
{error ? "Erreur de relance" : restarting ? "Relance…" : "Cette session est terminée."}
{error ? tr("Erreur de relance", "Restart failed") : restarting ? tr("Relance…", "Restarting…") : tr("Cette session est terminée.", "This session has ended.")}
</p>
{error ? (
<p className="stopped-agent-cell__help">
Impossible de relancer l’agent. Réessayez.
{tr("Impossible de relancer l’agent. Réessayez.", "Unable to restart the agent. Try again.")}
</p>
) : (
<p className="stopped-agent-cell__help">
Ouvre une nouvelle session pour cet agent.
{tr("Ouvre une nouvelle session pour cet agent.", "Opens a new session for this agent.")}
</p>
)}
</div>
@ -376,7 +379,7 @@ function StoppedAgentCellPanel({
onClick={onRestart}
disabled={restarting}
>
{restarting ? "Relance…" : "Relancer"}
{restarting ? tr("Relance…", "Restarting…") : tr("Relancer", "Restart")}
</button>
<button
type="button"
@ -384,31 +387,31 @@ function StoppedAgentCellPanel({
onClick={onRefresh}
disabled={restarting}
>
Actualiser
{tr("Actualiser", "Refresh")}
</button>
</div>
</section>
);
}
function restartFailureLabel(code?: string): string {
function restartFailureLabel(code: string | undefined, tr: (fr: string, en: string) => string): string {
switch (code) {
case "STOP_TIMEOUT":
case "STOP_FAILED":
return "Impossible d’arrêter l’agent.";
return tr("Impossible d’arrêter l’agent.", "Unable to stop the agent.");
case "LAUNCH_FAILED":
case "RESUME_FAILED":
return "Impossible de relancer l’agent.";
return tr("Impossible de relancer l’agent.", "Unable to restart the agent.");
case "RUNTIME_NOT_FOUND":
return "Runtime de l’agent introuvable.";
return tr("Runtime de l’agent introuvable.", "Agent runtime not found.");
case "AGENT_NOT_BOUND_TO_CELL":
return "L’agent n’est plus associé à cette cellule.";
return tr("L’agent n’est plus associé à cette cellule.", "The agent is no longer assigned to this cell.");
case "CONFLICT_AGENT_VISIBLE_ELSEWHERE":
return "Cet agent est visible dans une autre cellule.";
return tr("Cet agent est visible dans une autre cellule.", "This agent is visible in another cell.");
case "CONFLICT":
return "Un redémarrage est déjà en cours pour cet agent.";
return tr("Un redémarrage est déjà en cours pour cet agent.", "This agent is already restarting.");
default:
return "Impossible de redémarrer l’agent.";
return tr("Impossible de redémarrer l’agent.", "Unable to restart the agent.");
}
}
@ -421,6 +424,7 @@ function CellAgentRestartOverlay({
onRetry: () => void;
onClose: () => void;
}) {
const { tr } = useI18n();
const failed = state.status === "failed";
return (
<div
@ -451,7 +455,7 @@ function CellAgentRestartOverlay({
}}
>
<strong>
{failed ? restartFailureLabel(state.code) : "Redémarrage de l’agent…"}
{failed ? restartFailureLabel(state.code, tr) : tr("Redémarrage de l’agent…", "Restarting agent…")}
</strong>
{failed && state.message && (
<p style={{ margin: "6px 0 0", color: "var(--color-muted, #9aa7b4)" }}>
@ -467,8 +471,8 @@ function CellAgentRestartOverlay({
marginTop: 8,
}}
>
<button type="button" onClick={onClose}>Fermer</button>
<button type="button" onClick={onRetry}>Réessayer</button>
<button type="button" onClick={onClose}>{tr("Fermer", "Close")}</button>
<button type="button" onClick={onRetry}>{tr("Réessayer", "Retry")}</button>
</div>
)}
</div>
@ -531,6 +535,7 @@ function LeafView({
refreshWorkState,
refitSignal,
}: LeafViewProps) {
const { tr } = useI18n();
// A cell can be closed only when it lives inside a (binary) split: closing it
// collapses the parent split, keeping the *sibling*. Splits are always binary
// in this model (a split wraps a leaf into a 2-child container), so the kept
@ -901,8 +906,8 @@ function LeafView({
else void restartCellAgent();
}
const targetAnnouncementsFallback = delegatedTicket?.requesterLabel
? `Travaille sur une demande de ${delegatedTicket.requesterLabel}.`
: "Travaille sur une demande en cours.";
? tr(`Travaille sur une demande de ${delegatedTicket.requesterLabel}.`, `Working on a request from ${delegatedTicket.requesterLabel}.`)
: tr("Travaille sur une demande en cours.", "Working on a request.");
const [restartPending, setRestartPending] = useState(false);
const [restartError, setRestartError] = useState(false);
const [freshRestartedSession, setFreshRestartedSession] =
@ -1041,7 +1046,7 @@ function LeafView({
await refreshWorkState();
return;
}
if (!agentGateway.launchAgent) throw new Error("Agent launch unavailable");
if (!agentGateway.launchAgent) throw new Error(tr("Lancement de l’agent indisponible", "Agent launch unavailable"));
const handle = await agentGateway.launchAgent(
projectId,
agentId,
@ -1094,7 +1099,7 @@ function LeafView({
: liveAgents;
const host = fresh.find((la) => la.agentId === candidate);
return {
message: "Cet agent est déjà actif dans une autre cellule.",
message: tr("Cet agent est déjà actif dans une autre cellule.", "This agent is already active in another cell."),
goToNodeId: host && host.nodeId !== id ? host.nodeId : undefined,
};
};
@ -1355,7 +1360,7 @@ function LeafView({
>
{/* Agent selector */}
<select
aria-label={`agent selector ${id}`}
aria-label={`${tr("Sélecteur d’agent", "Agent selector")} ${id}`}
value={agentId ?? ""}
disabled={cellRestartBusy}
onChange={(e) => {
@ -1381,7 +1386,7 @@ function LeafView({
const elsewhere = visibleElsewhere(val);
if (elsewhere) {
setBusyNotice({
message: "Cet agent est déjà actif dans une autre cellule.",
message: tr("Cet agent est déjà actif dans une autre cellule.", "This agent is already active in another cell."),
goToNodeId: elsewhere.nodeId,
});
return;
@ -1392,7 +1397,7 @@ function LeafView({
if (isBackground) {
if (!agentGateway?.attachLiveAgent || !live.sessionId) {
setBusyNotice({
message: "Session active introuvable pour cet agent.",
message: tr("Session active introuvable pour cet agent.", "No active session found for this agent."),
});
return;
}
@ -1421,14 +1426,14 @@ function LeafView({
minWidth: 56,
}}
>
<option value="">Plain</option>
<option value="">{tr("Terminal", "Plain")}</option>
{agents.map((a) => {
const elsewhere = visibleElsewhere(a.id);
const background = backgroundLive(a.id);
return (
<option key={a.id} value={a.id} disabled={Boolean(elsewhere)}>
{a.name}
{elsewhere ? " (visible ailleurs)" : background ? " (arrière-plan)" : ""}
{elsewhere ? tr(" (visible ailleurs)", " (visible elsewhere)") : background ? tr(" (arrière-plan)", " (background)") : ""}
</option>
);
})}
@ -1437,7 +1442,7 @@ function LeafView({
{(effectiveCustomCliAvailable || (agentId && cellMode === "custom")) && (
<div
role="group"
aria-label={`mode CLI agent ${id}`}
aria-label={`${tr("Mode CLI de l’agent", "Agent CLI mode")} ${id}`}
style={{
display: "inline-flex",
overflow: "hidden",
@ -1451,7 +1456,7 @@ function LeafView({
type="button"
disabled={cellRestartBusy}
aria-pressed={cellMode === "tui"}
title="TUI native"
title={tr("TUI native", "Native TUI")}
onClick={() => requestMode("tui")}
style={{
border: 0,
@ -1473,13 +1478,13 @@ function LeafView({
whiteSpace: "nowrap",
}}
>
TUI native
{tr("TUI native", "Native TUI")}
</button>
<button
type="button"
disabled={cellRestartBusy}
aria-pressed={cellMode === "custom"}
title="CLI custom"
title={tr("CLI custom", "Custom CLI")}
onClick={() => requestMode("custom")}
style={{
border: 0,
@ -1500,7 +1505,7 @@ function LeafView({
whiteSpace: "nowrap",
}}
>
CLI custom
{tr("CLI custom", "Custom CLI")}
</button>
</div>
)}
@ -1508,8 +1513,8 @@ function LeafView({
{agentId && (
<button
type="button"
title={cellRestartBusy ? "Redémarrage de l’agent…" : "Redémarrer l’agent"}
aria-label={cellRestartBusy ? "Redémarrage de l’agent…" : "Redémarrer l’agent"}
title={cellRestartBusy ? tr("Redémarrage de l’agent…", "Restarting agent…") : tr("Redémarrer l’agent", "Restart agent")}
aria-label={cellRestartBusy ? tr("Redémarrage de l’agent…", "Restarting agent…") : tr("Redémarrer l’agent", "Restart agent")}
disabled={cellRestartBusy}
onClick={requestCellAgentRestart}
>
@ -1519,16 +1524,16 @@ function LeafView({
<button
type="button"
title="Split into columns"
aria-label={`split ${id} columns`}
title={tr("Diviser en colonnes", "Split into columns")}
aria-label={tr(`Diviser ${id} en colonnes`, `Split ${id} into columns`)}
onClick={() => void vm.split(id, "row")}
>
⬌
</button>
<button
type="button"
title="Split into rows"
aria-label={`split ${id} rows`}
title={tr("Diviser en lignes", "Split into rows")}
aria-label={tr(`Diviser ${id} en lignes`, `Split ${id} into rows`)}
onClick={() => void vm.split(id, "column")}
>
⬍
@ -1536,8 +1541,8 @@ function LeafView({
{canClose && (
<button
type="button"
title="Close this terminal"
aria-label={`close ${id}`}
title={tr("Fermer ce terminal", "Close this terminal")}
aria-label={tr(`Fermer ${id}`, `Close ${id}`)}
onClick={() => void vm.merge(parentSplit.container, siblingIndex)}
>
✕
@ -1563,7 +1568,7 @@ function LeafView({
>
{!busyByWorkState && inboxDepth > 0 && (
<span
title={`${inboxDepth} pending message${inboxDepth === 1 ? "" : "s"}`}
title={tr(`${inboxDepth} message${inboxDepth === 1 ? "" : "s"} en attente`, `${inboxDepth} pending message${inboxDepth === 1 ? "" : "s"}`)}
style={{
maxWidth: 180,
overflow: "hidden",
@ -1578,12 +1583,12 @@ function LeafView({
fontWeight: 600,
}}
>
Queued · {inboxDepth}
{tr("En attente", "Queued")} · {inboxDepth}
</span>
)}
{busyByWorkState && inboxDepth > 0 && (
<span
title={`${inboxDepth} queued message${inboxDepth === 1 ? "" : "s"}`}
title={tr(`${inboxDepth} message${inboxDepth === 1 ? "" : "s"} en file`, `${inboxDepth} queued message${inboxDepth === 1 ? "" : "s"}`)}
style={{
flexShrink: 0,
border: "1px solid var(--color-primary, #5b9bd5)",
@ -1595,12 +1600,12 @@ function LeafView({
fontWeight: 600,
}}
>
Queued {inboxDepth}
{tr("En file", "Queued")} {inboxDepth}
</span>
)}
{completedBackgroundTasks.length > 0 && (
<span
title={`${completedBackgroundTasks.length} background task result${completedBackgroundTasks.length === 1 ? "" : "s"}`}
title={tr(`${completedBackgroundTasks.length} résultat${completedBackgroundTasks.length === 1 ? "" : "s"} de tâche de fond`, `${completedBackgroundTasks.length} background task result${completedBackgroundTasks.length === 1 ? "" : "s"}`)}
style={{
flexShrink: 0,
border: "1px solid var(--color-success, #57b36a)",
@ -1612,7 +1617,7 @@ function LeafView({
fontWeight: 600,
}}
>
Task done {completedBackgroundTasks.length}
{tr("Tâche terminée", "Task done")} {completedBackgroundTasks.length}
</span>
)}
</div>
@ -1654,11 +1659,11 @@ function LeafView({
fontSize: 12,
}}
>
Chargement…
{tr("Chargement…", "Loading…")}
</div>
) : agentId && stoppedAgentCell ? (
<StoppedAgentCellPanel
agentName={pinnedAgent?.name ?? "Agent"}
agentName={pinnedAgent?.name ?? tr("Agent", "Agent")}
restarting={restartPending}
error={restartError}
onRestart={() => void restartStoppedAgent()}
@ -1702,10 +1707,10 @@ function LeafView({
}}
>
<strong style={{ color: "var(--color-content, #e0e0e0)" }}>
CLI custom indisponible
{tr("CLI personnalisée indisponible", "Custom CLI unavailable")}
</strong>
<span>
Cette cellule reste configurée en CLI custom. Vérifiez le profil de l’agent ou utilisez temporairement la TUI native.
{tr("Cette cellule reste configurée en CLI personnalisée. Vérifiez le profil de l’agent ou utilisez temporairement la TUI native.", "This cell remains configured for the custom CLI. Check the agent profile or temporarily use the native TUI.")}
</span>
<button
type="button"
@ -1719,7 +1724,7 @@ function LeafView({
cursor: "pointer",
}}
>
Utiliser la TUI native
{tr("Utiliser la TUI native", "Use native TUI")}
</button>
</div>
) : (
@ -1797,7 +1802,7 @@ function LeafView({
data-testid="model-server-overlay"
role="status"
aria-live="polite"
aria-label="model server launch"
aria-label={tr("Lancement du serveur de modèles", "Model server launch")}
style={{
position: "absolute",
inset: 0,
@ -1833,7 +1838,7 @@ function LeafView({
<div
data-testid="model-server-progress"
role="progressbar"
aria-label="progression du téléchargement"
aria-label={tr("Progression du téléchargement", "Download progress")}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={
@ -1948,7 +1953,7 @@ function LeafView({
{busyNotice.goToNodeId && (
<button
type="button"
aria-label="aller à la cellule"
aria-label={tr("aller à la cellule", "go to cell")}
onClick={() => {
goToCell(busyNotice.goToNodeId!);
setBusyNotice(null);
@ -1964,7 +1969,7 @@ function LeafView({
padding: "1px 6px",
}}
>
Aller à la cellule
{tr("Aller à la cellule", "Go to cell")}
</button>
)}
</div>
@ -1973,7 +1978,7 @@ function LeafView({
<div
role="alertdialog"
aria-modal="true"
aria-label="Confirmer le changement de CLI"
aria-label={tr("Confirmer le changement de CLI", "Confirm CLI change")}
style={{
position: "absolute",
inset: 0,
@ -1998,18 +2003,18 @@ function LeafView({
}}
>
<h2 style={{ margin: 0, fontSize: 14 }}>
Changer de CLI agent
{tr("Changer de CLI agent", "Change agent CLI")}
</h2>
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}>
IdeA va basculer cette cellule vers{" "}
{tr("IdeA va basculer cette cellule vers", "IdeA will switch this cell to")}{" "}
{pendingModeSwitch.target === "custom"
? "la CLI custom"
: "la TUI native"}
{" "}et reprendre la session depuis cette surface.
? tr("la CLI custom", "the custom CLI")
: tr("la TUI native", "the native TUI")}
{" "}{tr("et reprendre la session depuis cette surface.", "and resume the session from that surface.")}
</p>
{conversationId && (
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-warning, #d49b3a)" }}>
Une reprise est possible si le profil et le backend conservent cette conversation.
{tr("Une reprise est possible si le profil et le backend conservent cette conversation.", "The session can be resumed if the profile and backend retain this conversation.")}
</p>
)}
<div
@ -2032,7 +2037,7 @@ function LeafView({
cursor: "pointer",
}}
>
Annuler
{tr("Annuler", "Cancel")}
</button>
<button
type="button"
@ -2046,7 +2051,7 @@ function LeafView({
cursor: "pointer",
}}
>
Basculer
{tr("Basculer", "Switch")}
</button>
</div>
</div>
@ -2056,7 +2061,7 @@ function LeafView({
<div
role="alertdialog"
aria-modal="true"
aria-label="Redémarrer l’agent ?"
aria-label={tr("Redémarrer l’agent ?", "Restart agent?")}
style={{
position: "absolute",
inset: 0,
@ -2079,9 +2084,9 @@ function LeafView({
padding: 12,
}}
>
<h2 style={{ margin: 0, fontSize: 14 }}>Redémarrer l’agent ?</h2>
<h2 style={{ margin: 0, fontSize: 14 }}>{tr("Redémarrer l’agent ?", "Restart agent?")}</h2>
<p style={{ margin: "8px 0 0", fontSize: 12 }}>
L’activité en cours sera interrompue avant la relance de l’agent.
{tr("L’activité en cours sera interrompue avant la relance de l’agent.", "Current activity will be interrupted before the agent restarts.")}
</p>
<div
style={{
@ -2095,10 +2100,10 @@ function LeafView({
type="button"
onClick={() => setRestartConfirmationOpen(false)}
>
Annuler
{tr("Annuler", "Cancel")}
</button>
<button type="button" onClick={() => void restartCellAgent()}>
Interrompre et redémarrer
{tr("Interrompre et redémarrer", "Interrupt and restart")}
</button>
</div>
</div>