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

@ -148,13 +148,13 @@ async function flush() {
}
function controlsFor(cellId: string) {
const selector = screen.getByLabelText(`agent selector ${cellId}`);
const selector = screen.getByLabelText(`Sélecteur d’agent ${cellId}`);
const controls = selector.parentElement as HTMLElement;
return {
selector,
controls,
splitColumns: screen.getByLabelText(`split ${cellId} columns`),
close: screen.getByLabelText(`close ${cellId}`),
splitColumns: screen.getByLabelText(`Diviser ${cellId} en colonnes`),
close: screen.getByLabelText(`Fermer ${cellId}`),
};
}
@ -231,7 +231,7 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
}) as HTMLButtonElement).disabled,
).toBe(true);
expect(
(screen.getByLabelText(`agent selector ${setup.bId}`) as HTMLSelectElement)
(screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`) as HTMLSelectElement)
.disabled,
).toBe(true);
expect(screen.getByText("Redémarrage de l’agent…")).toBeTruthy();
@ -381,7 +381,7 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
renderGrid(setup.gateways);
const group = await screen.findByRole("group", {
name: `mode CLI agent ${setup.bId}`,
name: `Mode CLI de l’agent ${setup.bId}`,
});
const { controls } = controlsFor(setup.bId);
@ -417,19 +417,19 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
const mutate = vi.spyOn(setup.layout, "mutateLayout");
expect(customToolbar.className).toContain("z-20");
expect(screen.getByLabelText(`agent selector ${setup.bId}`)).toBeTruthy();
expect(screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`)).toBeTruthy();
expect(screen.getByRole("group", {
name: `mode CLI agent ${setup.bId}`,
name: `Mode CLI de l’agent ${setup.bId}`,
})).toBeTruthy();
expect(screen.getByLabelText(`split ${setup.bId} columns`)).toBeTruthy();
expect(screen.getByLabelText(`split ${setup.bId} rows`)).toBeTruthy();
expect(screen.getByLabelText(`close ${setup.bId}`)).toBeTruthy();
expect(screen.getByLabelText(`Diviser ${setup.bId} en colonnes`)).toBeTruthy();
expect(screen.getByLabelText(`Diviser ${setup.bId} en lignes`)).toBeTruthy();
expect(screen.getByLabelText(`Fermer ${setup.bId}`)).toBeTruthy();
expect(screen.queryByRole("button", {
name: `cancel current turn ${setup.bId}`,
})).toBeNull();
expect(screen.queryByText("Busy")).toBeNull();
fireEvent.click(screen.getByLabelText(`split ${setup.bId} columns`));
fireEvent.click(screen.getByLabelText(`Diviser ${setup.bId} en colonnes`));
await waitFor(() =>
expect(mutate).toHaveBeenCalledWith(
"p1",
@ -442,7 +442,7 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
),
);
const selector = screen.getByLabelText(`agent selector ${setup.bId}`);
const selector = screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`);
fireEvent.change(selector, { target: { value: setup.otherAgentId } });
await waitFor(() =>
expect(mutate).toHaveBeenCalledWith(

View File

@ -172,7 +172,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
await waitFor(() =>
expect(screen.getByText(/mock-attachment.txt/)).toBeTruthy(),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "hello there" },
});
fireEvent.click(screen.getByText("Envoyer"));
@ -324,12 +324,12 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(screen.queryByTestId("target-announcements-overlay")).toBeNull();
expect(screen.queryByText("Busy · Main")).toBeNull();
expect(screen.getByLabelText(`agent selector ${leaf.id}`)).toBeTruthy();
expect(screen.getByLabelText(`Sélecteur d’agent ${leaf.id}`)).toBeTruthy();
expect(screen.queryByRole("button", {
name: `cancel current turn ${leaf.id}`,
})).toBeNull();
await waitFor(() =>
expect(screen.getByLabelText(/message CLI custom/)).toHaveProperty(
expect(screen.getByLabelText(/Message CLI personnalisée/)).toHaveProperty(
"disabled",
true,
),
@ -411,9 +411,9 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
try {
renderGrid(gateways);
const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
const composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
const handle = screen.getByRole("button", {
name: `redimensionner le composer ${leafId}`,
name: `Redimensionner l’éditeur ${leafId}`,
});
expect(composer.style.height).toBe("64px");
expect(window.localStorage.getItem(`idea.custom-chat-composer-height.p1.${leafId}`))

View File

@ -151,7 +151,7 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
);
// Click the close button on cell `b` — `b` must disappear, `a` must remain.
fireEvent.click(screen.getByLabelText("close b"));
fireEvent.click(screen.getByLabelText("Fermer b"));
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
@ -180,7 +180,7 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
);
// Closing `a` (index 0) must keep `b` (index 1).
fireEvent.click(screen.getByLabelText(`close ${a}`));
fireEvent.click(screen.getByLabelText(`Fermer ${a}`));
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
@ -196,7 +196,7 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
);
expect(screen.queryByLabelText(/^close /)).toBeNull();
expect(screen.queryByLabelText(/^Fermer /)).toBeNull();
});
it("renders the loading/empty container when the layout gateway is absent", () => {

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>

View File

@ -47,7 +47,7 @@ describe("LayoutTabs", () => {
const layout = new MockLayoutGateway();
renderTabs(layout);
await waitFor(() => {
expect(screen.getByLabelText("create layout")).toBeTruthy();
expect(screen.getByLabelText("créer un layout")).toBeTruthy();
});
});
@ -85,10 +85,10 @@ describe("LayoutTabs", () => {
fireEvent.dblClick(screen.getByText("Default"));
await waitFor(() => {
expect(screen.getByLabelText(/rename layout/i)).toBeTruthy();
expect(screen.getByLabelText(/renommer le layout/i)).toBeTruthy();
});
const input = screen.getByLabelText(/rename layout/i) as HTMLInputElement;
const input = screen.getByLabelText(/renommer le layout/i) as HTMLInputElement;
fireEvent.change(input, { target: { value: "My Layout" } });
fireEvent.blur(input);
@ -102,10 +102,10 @@ describe("LayoutTabs", () => {
renderTabs(layout);
await waitFor(() => {
expect(screen.getByLabelText(/delete layout/i)).toBeTruthy();
expect(screen.getByLabelText(/supprimer le layout/i)).toBeTruthy();
});
const closeBtn = screen.getByLabelText(/delete layout/i) as HTMLButtonElement;
const closeBtn = screen.getByLabelText(/supprimer le layout/i) as HTMLButtonElement;
expect(closeBtn.disabled).toBe(true);
});
@ -130,7 +130,7 @@ describe("LayoutTabs", () => {
expect(screen.getByText("Second")).toBeTruthy();
});
const deleteBtns = screen.getAllByLabelText(/delete layout/i);
const deleteBtns = screen.getAllByLabelText(/supprimer le layout/i);
// Both tabs exist, pick the second (Second).
await act(async () => {
fireEvent.click(deleteBtns[1]);

View File

@ -17,6 +17,7 @@ import type { LayoutInfo } from "@/domain";
import { cn } from "@/shared";
import { PluginLayoutSelectorSection, usePluginRuntime } from "@/features/plugins";
import { useLayouts } from "./useLayouts";
import { useI18n } from "@/i18n";
interface LayoutTabsProps {
projectId: string;
@ -31,6 +32,7 @@ interface LayoutTabsProps {
}
export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps) {
const { tr } = useI18n();
const vm = useLayouts(projectId);
// Propagate the active layout (id + kind) to the parent on every change.
@ -130,7 +132,7 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
setRenameValue("");
}
}}
aria-label={`rename layout ${tab.name}`}
aria-label={tr(`renommer le layout ${tab.name}`, `rename layout ${tab.name}`)}
className="w-24 rounded border border-border bg-base px-1 py-0.5 text-sm text-content focus:outline-none focus:ring-1 focus:ring-primary"
/>
) : (
@ -149,9 +151,9 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
>
{tab.kind === "gitGraph" && (
<span
aria-label="git graph layout"
aria-label={tr("layout du graphe Git", "git graph layout")}
className="text-xs text-primary"
title="Git Graph"
title={tr("Graphe Git", "Git graph")}
>
⎇
</span>
@ -161,7 +163,7 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
)}
<button
type="button"
aria-label={`delete layout ${tab.name}`}
aria-label={tr(`supprimer le layout ${tab.name}`, `delete layout ${tab.name}`)}
disabled={vm.layouts.length <= 1}
onClick={() => void handleClose(tab.id)}
className={cn(
@ -181,7 +183,7 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
<div className="relative">
<button
type="button"
aria-label="create layout"
aria-label={tr("créer un layout", "create layout")}
onClick={() => setShowCreateMenu((v) => !v)}
className="flex h-6 w-6 items-center justify-center rounded border border-transparent text-sm text-muted transition-colors hover:border-border hover:text-content"
>
@ -195,7 +197,7 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
<button
type="button"
role="menuitem"
aria-label="create terminal layout"
aria-label={tr("créer un layout terminal", "create terminal layout")}
onClick={() => void handleCreate("terminal")}
className="px-3 py-2 text-left text-sm text-content hover:bg-raised"
>
@ -204,11 +206,11 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
<button
type="button"
role="menuitem"
aria-label="create git graph layout"
aria-label={tr("créer un layout de graphe Git", "create git graph layout")}
onClick={() => void handleCreate("gitGraph")}
className="px-3 py-2 text-left text-sm text-content hover:bg-raised"
>
⎇ Git graph
⎇ {tr("Graphe Git", "Git graph")}
</button>
<PluginLayoutSelectorSection
registry={pluginRegistry}

View File

@ -84,14 +84,14 @@ describe("LayoutTabs — git graph kind", () => {
renderTabs(layout);
await waitFor(() => {
expect(screen.getByLabelText("create layout")).toBeTruthy();
expect(screen.getByLabelText("créer un layout")).toBeTruthy();
});
fireEvent.click(screen.getByLabelText("create layout"));
fireEvent.click(screen.getByLabelText("créer un layout"));
await waitFor(() => {
expect(screen.getByLabelText("create terminal layout")).toBeTruthy();
expect(screen.getByLabelText("create git graph layout")).toBeTruthy();
expect(screen.getByLabelText("créer un layout terminal")).toBeTruthy();
expect(screen.getByLabelText("créer un layout de graphe Git")).toBeTruthy();
});
});
@ -134,10 +134,10 @@ describe("LayoutTabs — git graph kind", () => {
renderTabs(layout, "p1", onActiveLayoutChange, registry);
await waitFor(() => {
expect(screen.getByLabelText("create layout")).toBeTruthy();
expect(screen.getByLabelText("créer un layout")).toBeTruthy();
});
fireEvent.click(screen.getByLabelText("create layout"));
fireEvent.click(screen.getByLabelText("créer un layout"));
fireEvent.click(await screen.findByRole("menuitem", { name: /Android Health/ }));
await waitFor(() => {
@ -172,7 +172,7 @@ describe("LayoutTabs — git graph kind", () => {
await waitFor(() => {
// The aria-label "git graph layout" should be present.
expect(screen.getByLabelText("git graph layout")).toBeTruthy();
expect(screen.getByLabelText("layout du graphe Git")).toBeTruthy();
});
});
@ -185,6 +185,6 @@ describe("LayoutTabs — git graph kind", () => {
expect(screen.getByText("Default")).toBeTruthy();
});
expect(screen.queryByLabelText("git graph layout")).toBeNull();
expect(screen.queryByLabelText("layout du graphe Git")).toBeNull();
});
});

View File

@ -244,9 +244,9 @@ describe("setCellAgent (agent dropdown per cell)", () => {
// Close the SECOND cell: keep the first, drop the second. Closing a cell is
// now a detach-only view operation; it must not kill the PTY.
await waitFor(() => {
expect(screen.getByLabelText(`close ${droppedId}`)).toBeTruthy();
expect(screen.getByLabelText(`Fermer ${droppedId}`)).toBeTruthy();
});
fireEvent.click(screen.getByLabelText(`close ${droppedId}`));
fireEvent.click(screen.getByLabelText(`Fermer ${droppedId}`));
await waitFor(() => {
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1);

View File

@ -292,7 +292,7 @@ describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => {
// In cell A's dropdown the option is DISABLED (cannot run in two cells).
const selectA = (await screen.findByLabelText(
`agent selector ${a}`,
`Sélecteur d’agent ${a}`,
)) as HTMLSelectElement;
await waitFor(() => {
const opt = Array.from(selectA.options).find((o) => o.value === ag.id)!;
@ -397,7 +397,7 @@ describe("ticket #56 — 'visible ailleurs' derives from the layout, not stale l
// NOT "visible ailleurs" (regression: it used to be disabled via the stale
// live nodeId still pointing at the now-visible cell A).
const selectB = (await screen.findByLabelText(
`agent selector ${b}`,
`Sélecteur d’agent ${b}`,
)) as HTMLSelectElement;
await waitFor(() => {
const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;
@ -439,7 +439,7 @@ describe("ticket #56 — 'visible ailleurs' derives from the layout, not stale l
renderGrid(layout, agent);
const selectB = (await screen.findByLabelText(
`agent selector ${b}`,
`Sélecteur d’agent ${b}`,
)) as HTMLSelectElement;
await waitFor(() => {
const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;