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:
@ -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(
|
||||
|
||||
@ -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}`))
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)!;
|
||||
|
||||
Reference in New Issue
Block a user