{vm.error && (
- Name
+ {tr("Nom", "Name")}
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")}
({ value: t.id, label: t.name })),
]}
/>
{selectedTemplate && (
- Nom initial:{" "}
+ {tr("Nom initial", "Initial name")}: {" "}
{selectedTemplate.defaultAgentName || selectedTemplate.name}
- Modèle appliqué:{" "}
+ {tr("Modèle appliqué", "Applied model")}: {" "}
- {templateProfileLabel(selectedTemplate, vm.profiles)}
+ {templateProfileLabel(selectedTemplate, vm.profiles, tr)}
{selectedTemplateMissingProfile && (
- 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.")}
)}
@@ -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 && (
- (no profiles configured — you can still enter an id)
+ {tr("(aucun profil configuré — vous pouvez saisir un identifiant)", "(no profiles configured — you can still enter an id)")}
)}
{vm.profiles.length > 0 ? (
({
value: p.id,
label: profileLabel(p),
@@ -368,8 +369,8 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
) : (
setNewProfileId(e.target.value)}
className="min-w-32"
@@ -381,19 +382,19 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
{/* ── Agent list ── */}
{vm.agents.length === 0 ? (
-
No agents yet.
+
{tr("Aucun agent pour le moment.", "No agents yet.")}
) : (
{vm.agents.map((a) => {
@@ -438,27 +439,27 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
{a.name}
{a.isOrchestrator && (
- Writer contexte
+ {tr("Responsable du contexte", "Context writer")}
)}
{agentDrift && (
- update available
+ {tr("mise à jour disponible", "update available")}
)}
{delegationSource && (
@@ -470,36 +471,35 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
{profileName}
{live && (
- running in IdeA · {live.sessionId ?? live.nodeId}
+ {tr("actif dans IdeA", "running in IdeA")} · {live.sessionId ?? live.nodeId}
)}
{isSelected && (
)}
@@ -531,7 +531,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
engine abandons the conversation history → confirmation. */}
{vm.profiles.length > 0 && (
{
@@ -562,13 +562,13 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
)}
{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")}
) : (
)}
@@ -653,12 +653,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
- Context — {selectedAgent.name}
+ {tr("Contexte", "Context")} — {selectedAgent.name}
{selectedAgent.contextPath}
@@ -688,34 +688,33 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
- Changer le moteur IA
+ {tr("Changer le moteur IA", "Change AI engine")}
- Changer le moteur abandonne l'historique de conversation (le
- contexte et la mémoire sont conservés). Continuer ?
+ {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?")}
@@ -726,34 +725,33 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
- Changer le writer du contexte
+ {tr("Changer le responsable du contexte", "Change context writer")}
- Le rôle sera retiré de {pendingWriterChange.currentWriterName} et
- attribué à {pendingWriterChange.agentName}. Continuer ?
+ {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?`)}
@@ -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 (
{
@@ -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 && (
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 (
- Réseau: {label}
+ {tr("Réseau", "Network")}: {label}
);
}
diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx
index 3048df8e..13989e42 100644
--- a/frontend/src/features/agents/CustomAgentChatView.test.tsx
+++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx
@@ -11,6 +11,7 @@ import {
__resetProjectCliInteractionActivityForTests,
hasProjectCliInteraction,
} from "@/features/projects/projectCliInteractionActivity";
+import { publishProjectIconChanged } from "@/features/projects/projectIcons";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
@@ -197,6 +198,23 @@ describe("CustomAgentChatView", () => {
__resetProjectCliInteractionActivityForTests();
});
+ it("renders the project icon as a non-interactive custom-CLI watermark and updates it immediately", async () => {
+ const agent = {
+ reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
+ sendAgentChat: vi.fn(), cancelAgentChat: vi.fn(), closeAgentChat: vi.fn(),
+ };
+ const project = { readProjectSettings: vi.fn(async () => ({ themeColor: null, icon: { set: "devicon" as const, name: "python" } })) };
+ render( null) } } as unknown as Gateways}>
+
+ );
+
+ const watermark = await screen.findByTestId("project-icon-watermark");
+ expect(watermark.style.pointerEvents).toBe("none");
+ expect(watermark.textContent).toBe("Py");
+ publishProjectIconChanged({ projectId: "project-1", icon: { set: "lucide", name: "star" } });
+ await waitFor(() => expect(watermark.textContent).toBe("★"));
+ });
+
it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
@@ -246,7 +264,7 @@ describe("CustomAgentChatView", () => {
expect(
await screen.findByText("La conversation liée n’a pas pu être restaurée."),
).toBeTruthy();
- expect(screen.getByText("Son transcript n’est plus disponible après la reprise.")).toBeTruthy();
+ expect(screen.getByText("Sa transcription n’est plus disponible après la reprise.")).toBeTruthy();
expect(
screen.queryByText("Conversation liée — récupération du transcript en cours."),
).toBeNull();
@@ -370,10 +388,10 @@ describe("CustomAgentChatView", () => {
});
expect(
- await screen.findByText("------- redemarrage de l'agent -------"),
+ await screen.findByText("------- redémarrage de l’agent -------"),
).not.toBeNull();
expect(
- screen.getByText("------- redemarrage de l'agent termine -------"),
+ screen.getByText("------- redémarrage de l’agent terminé -------"),
).not.toBeNull();
expect(screen.getAllByTestId("agent-restart-separator")).toHaveLength(2);
@@ -403,7 +421,7 @@ describe("CustomAgentChatView", () => {
});
});
expect(
- screen.getByText("------- redemarrage de l'agent echoue -------"),
+ screen.getByText("------- redémarrage de l’agent échoué -------"),
).not.toBeNull();
expect(screen.getByText("runtime unavailable")).not.toBeNull();
});
@@ -676,7 +694,7 @@ describe("CustomAgentChatView", () => {
,
);
- const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
+ const composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled());
fireEvent.change(composer, { target: { value: "first prompt" } });
@@ -712,7 +730,7 @@ describe("CustomAgentChatView", () => {
it("recalls history from the exact end of a multiline draft and restores it verbatim", async () => {
const agent = renderHistoryChat();
const composer = (await screen.findByLabelText(
- /message CLI custom/,
+ /Message CLI personnalisée/,
)) as HTMLTextAreaElement;
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled());
await sendHistoryPrompt(composer, agent, "historic prompt");
@@ -742,7 +760,7 @@ describe("CustomAgentChatView", () => {
it("keeps ArrowUp native away from a simple end caret or during modified/IME input", async () => {
const agent = renderHistoryChat();
const composer = (await screen.findByLabelText(
- /message CLI custom/,
+ /Message CLI personnalisée/,
)) as HTMLTextAreaElement;
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled());
await sendHistoryPrompt(composer, agent, "historic prompt");
@@ -778,7 +796,7 @@ describe("CustomAgentChatView", () => {
it("does not consume ArrowUp with empty history and never mutates recalled entries", async () => {
const agent = renderHistoryChat();
const composer = (await screen.findByLabelText(
- /message CLI custom/,
+ /Message CLI personnalisée/,
)) as HTMLTextAreaElement;
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled());
@@ -838,7 +856,7 @@ describe("CustomAgentChatView", () => {
,
);
- let composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
+ let composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "remember me" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith(
@@ -859,7 +877,7 @@ describe("CustomAgentChatView", () => {
,
);
- composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
+ composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
fireEvent.keyDown(composer, { key: "ArrowUp" });
await waitFor(() => expect(composer.value).toBe("remember me"));
});
@@ -897,7 +915,7 @@ describe("CustomAgentChatView", () => {
,
);
- const composer = await screen.findByLabelText(/message CLI custom/);
+ const composer = await screen.findByLabelText(/Message CLI personnalisée/);
fireEvent.change(composer, { target: { value: "persist before send" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -911,7 +929,7 @@ describe("CustomAgentChatView", () => {
});
try {
const agent = renderHistoryChat();
- const composer = await screen.findByLabelText(/message CLI custom/);
+ const composer = await screen.findByLabelText(/Message CLI personnalisée/);
fireEvent.change(composer, { target: { value: "still send" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -1020,7 +1038,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "Initial prompt" },
});
nowMs = userReceivedAtMs;
@@ -1100,7 +1118,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "Initial prompt" },
});
nowMs = userReceivedAtMs;
@@ -1235,9 +1253,9 @@ describe("CustomAgentChatView", () => {
try {
render();
- 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 node-1",
+ name: "Redimensionner l’éditeur node-1",
});
expect(composer.style.height).toBe("64px");
expect(handle.style.cursor).toBe("ns-resize");
@@ -1401,7 +1419,7 @@ describe("CustomAgentChatView", () => {
expect(screen.getByText("Initial reply")).toBeTruthy();
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true);
- expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true);
+ expect(screen.getByLabelText(/Message CLI personnalisée/).hasAttribute("disabled")).toBe(true);
act(() => {
secondReattach.resolve({ sessionId: "chat-session-1", scrollback });
@@ -1411,7 +1429,7 @@ describe("CustomAgentChatView", () => {
expect(screen.getByText("Partial answer while away")).toBeTruthy();
expect(screen.queryByText(/Reprise effectuée:/)).toBeNull();
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
- expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true);
+ expect(screen.getByLabelText(/Message CLI personnalisée/).hasAttribute("disabled")).toBe(true);
});
it("restores visible conversation from conversationId cache when layout returns before sessionId", async () => {
@@ -1594,7 +1612,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "first turn" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -1619,12 +1637,12 @@ describe("CustomAgentChatView", () => {
);
expect(agent.cancelAgentChat).toHaveBeenCalledTimes(1);
resolveCancel();
- await waitFor(() => expect(screen.getByText("Request stopped.")).toBeTruthy());
+ await waitFor(() => expect(screen.getByText("Demande arrêtée.")).toBeTruthy());
expect(hasProjectCliInteraction("project-1")).toBe(false);
expect(onSessionId).not.toHaveBeenCalledWith(null);
expect(agent.stopLiveAgent).not.toHaveBeenCalled();
expect(agent.closeAgentChat).not.toHaveBeenCalled();
- const composer = screen.getByLabelText(/message CLI custom/);
+ const composer = screen.getByLabelText(/Message CLI personnalisée/);
expect(composer).toHaveProperty("disabled", false);
fireEvent.change(composer, {
@@ -1696,7 +1714,7 @@ describe("CustomAgentChatView", () => {
,
);
- const composer = await screen.findByLabelText(/message CLI custom/);
+ const composer = await screen.findByLabelText(/Message CLI personnalisée/);
await waitFor(() => expect(composer).toHaveProperty("disabled", true));
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
@@ -1791,7 +1809,7 @@ describe("CustomAgentChatView", () => {
);
expect(await screen.findByText("Main attend Architect")).toBeTruthy();
- expect(screen.getByLabelText(/message CLI custom/)).toHaveProperty("disabled", true);
+ expect(screen.getByLabelText(/Message CLI personnalisée/)).toHaveProperty("disabled", true);
});
it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => {
@@ -1895,7 +1913,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "hello agent" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -1974,13 +1992,13 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "first request" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("final first request");
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "second request" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -2117,16 +2135,16 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "ship progress UI" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
- await screen.findByText("Progress");
+ await screen.findByText("Progression");
expect(screen.getAllByTestId("chat-turn-marker")).toHaveLength(2);
expect(screen.getByText("turn.started")).toBeTruthy();
expect(screen.getByText("turn.completed")).toBeTruthy();
- expect(screen.getByText("Progress").closest("[data-testid='chat-progress-status']")).toBeTruthy();
+ expect(screen.getByText("Progression").closest("[data-testid='chat-progress-status']")).toBeTruthy();
expect(screen.getByText("Analyse")).toBeTruthy();
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
expect(screen.getByText("MCP")).toBeTruthy();
@@ -2203,13 +2221,13 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "first" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("done first");
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "second" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -2251,7 +2269,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "first request" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -2271,7 +2289,7 @@ describe("CustomAgentChatView", () => {
});
});
expect(screen.getByText("Running first request")).toBeTruthy();
- expect(screen.getByText("Tool").parentElement?.textContent).toContain("En cours");
+ expect(screen.getByText("Outil").parentElement?.textContent).toContain("En cours");
act(() => {
harness.emit(0, {
@@ -2289,7 +2307,7 @@ describe("CustomAgentChatView", () => {
});
await screen.findByText("final first request");
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "second request" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -2308,7 +2326,7 @@ describe("CustomAgentChatView", () => {
});
});
expect(screen.getByText("Second request context loaded.")).toBeTruthy();
- expect(screen.getByText("Progress").parentElement?.textContent).toContain("En cours");
+ expect(screen.getByText("Progression").parentElement?.textContent).toContain("En cours");
act(() => {
harness.emit(1, {
@@ -2379,7 +2397,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "first request" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -2412,7 +2430,7 @@ describe("CustomAgentChatView", () => {
});
await screen.findByText("done first request");
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "second request" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -2487,7 +2505,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "first request" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -2522,7 +2540,7 @@ describe("CustomAgentChatView", () => {
});
await screen.findByText("done first request");
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "second request" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -2599,7 +2617,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "first request" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -2636,7 +2654,7 @@ describe("CustomAgentChatView", () => {
});
expect(screen.queryByText("Late first request completion")).toBeNull();
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "second request" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -2727,7 +2745,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "delegate after intent" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -2831,7 +2849,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "delegate once" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -2882,7 +2900,7 @@ describe("CustomAgentChatView", () => {
expect(screen.getAllByText("validate")).toHaveLength(2);
expect(screen.queryByText("Appel inter-agent")).toBeNull();
expect(screen.queryByText("reply delivered")).toBeNull();
- expect(screen.queryByText("Tool")).toBeNull();
+ expect(screen.queryByText("Outil")).toBeNull();
expect(screen.queryByText("started")).toBeNull();
await waitFor(() =>
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(),
@@ -2939,7 +2957,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "read many tickets" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -3032,7 +3050,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "read ticket" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -3108,7 +3126,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "download model" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -3134,7 +3152,7 @@ describe("CustomAgentChatView", () => {
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
expect(screen.queryByText("Downloading 10%")).toBeNull();
expect(screen.getByText("Downloading 75%")).toBeTruthy();
- expect(screen.getByText("Tool").parentElement?.textContent).toContain("En cours");
+ expect(screen.getByText("Outil").parentElement?.textContent).toContain("En cours");
});
it("expands only truncated MCP progress details and preserves expansion across updates", async () => {
@@ -3187,7 +3205,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "inspect mcp payloads" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -3308,7 +3326,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "stream progress" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -3382,7 +3400,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "run tools" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -3473,7 +3491,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "call tool" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -3499,7 +3517,7 @@ describe("CustomAgentChatView", () => {
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
expect(screen.queryByText("Searching...")).toBeNull();
expect(screen.getByText("Found results")).toBeTruthy();
- expect(screen.getByText("Tool").parentElement?.textContent).toContain("Terminé");
+ expect(screen.getByText("Outil").parentElement?.textContent).toContain("Terminé");
act(() => {
emitChunk?.({ kind: "final", content: "done" });
@@ -3560,7 +3578,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "ship progress UI" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -3580,7 +3598,7 @@ describe("CustomAgentChatView", () => {
});
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
- expect(screen.getByText("Progress").parentElement?.textContent).toContain("En cours");
+ expect(screen.getByText("Progression").parentElement?.textContent).toContain("En cours");
act(() => {
emitChunk?.({ kind: "final", content: "done" });
@@ -3591,7 +3609,7 @@ describe("CustomAgentChatView", () => {
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(),
);
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
- expect(screen.getByText("Progress").parentElement?.textContent).toContain("Terminé");
+ expect(screen.getByText("Progression").parentElement?.textContent).toContain("Terminé");
});
it("ignores late progress chunks after the final answer without reopening the turn", async () => {
@@ -3644,7 +3662,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "ship ordered stream" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -3734,7 +3752,7 @@ describe("CustomAgentChatView", () => {
),
);
- const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
+ const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/c" } });
const menu = await screen.findByRole("listbox", {
@@ -3804,7 +3822,7 @@ describe("CustomAgentChatView", () => {
),
);
- const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
+ const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "hello /c" } });
expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull();
expect(agent.listSlashCommands).not.toHaveBeenCalled();
@@ -3889,7 +3907,7 @@ describe("CustomAgentChatView", () => {
),
);
- const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
+ const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/explain focus this" } });
fireEvent.keyDown(composer, { key: "Enter" });
@@ -3958,7 +3976,7 @@ describe("CustomAgentChatView", () => {
expect(await screen.findByText("old conversation")).toBeTruthy();
- const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
+ const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/clean" } });
fireEvent.keyDown(composer, { key: "Enter" });
@@ -4053,7 +4071,7 @@ describe("CustomAgentChatView", () => {
),
);
- const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
+ const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/help" } });
fireEvent.keyDown(composer, { key: "Enter" });
@@ -4135,7 +4153,7 @@ describe("CustomAgentChatView", () => {
),
);
- const composer = screen.getByLabelText(/message CLI custom/);
+ const composer = screen.getByLabelText(/Message CLI personnalisée/);
fireEvent.change(composer, { target: { value: "/explain" } });
fireEvent.keyDown(composer, { key: "Enter" });
@@ -4219,7 +4237,7 @@ describe("CustomAgentChatView", () => {
),
);
- const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
+ const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/profile" } });
fireEvent.keyDown(composer, { key: "Enter" });
@@ -4232,14 +4250,14 @@ describe("CustomAgentChatView", () => {
expect(agent.sendAgentChat).not.toHaveBeenCalled();
const confirmButton = screen.getByRole("button", {
- name: "Reset et changer le profil",
+ name: "Réinitialiser et changer le profil",
}) as HTMLButtonElement;
expect(confirmButton.disabled).toBe(true);
- fireEvent.change(screen.getByLabelText("profil cible"), {
+ fireEvent.change(screen.getByLabelText("Profil cible"), {
target: { value: "codex-high" },
});
- fireEvent.click(screen.getByLabelText("Je confirme que la session courante sera reset."));
+ fireEvent.click(screen.getByLabelText("Je confirme que la session courante sera réinitialisée."));
expect(confirmButton.disabled).toBe(false);
fireEvent.click(confirmButton);
@@ -4314,7 +4332,7 @@ describe("CustomAgentChatView", () => {
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
- const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
+ const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
fireEvent.paste(composer, {
clipboardData: {
items: [
@@ -4332,7 +4350,7 @@ describe("CustomAgentChatView", () => {
expect(screen.getByTestId("attachment-preview-clipboard.png")).toBeTruthy();
await waitFor(() => expect(composer.value).toBe("[Image 1]"));
- fireEvent.click(screen.getByRole("button", { name: "retirer clipboard.png" }));
+ fireEvent.click(screen.getByRole("button", { name: "Retirer clipboard.png" }));
await waitFor(() =>
expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(),
@@ -4402,7 +4420,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.paste(screen.getByLabelText(/Message CLI personnalisée/), {
clipboardData: {
items: [],
files: [],
@@ -4492,7 +4510,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.paste(screen.getByLabelText(/Message CLI personnalisée/), {
clipboardData: {
items: [],
files: [],
@@ -4564,7 +4582,7 @@ describe("CustomAgentChatView", () => {
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
- fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.paste(screen.getByLabelText(/Message CLI personnalisée/), {
clipboardData: {
items: [
{
@@ -4645,7 +4663,7 @@ describe("CustomAgentChatView", () => {
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([4, 5, 6]).buffer),
});
- fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.paste(screen.getByLabelText(/Message CLI personnalisée/), {
clipboardData: {
items: [],
files: [file],
@@ -4716,7 +4734,7 @@ describe("CustomAgentChatView", () => {
),
);
- const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
+ const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "prefix suffix" } });
composer.setSelectionRange(7, 13);
@@ -4802,7 +4820,7 @@ describe("CustomAgentChatView", () => {
),
);
- const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
+ const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "voir maintenant" } });
composer.setSelectionRange(5, 5);
const file = new File(["ignored"], "cursor.png", { type: "image/png" });
@@ -4904,7 +4922,7 @@ describe("CustomAgentChatView", () => {
files: [file],
getData: () => "",
};
- const composer = screen.getByLabelText(/message CLI custom/);
+ const composer = screen.getByLabelText(/Message CLI personnalisée/);
fireEvent.paste(composer, { clipboardData });
await screen.findByText("Fichier joint: duplicate.pdf");
@@ -4994,7 +5012,7 @@ describe("CustomAgentChatView", () => {
,
);
await waitFor(() =>
- expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""),
+ expect((screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement).value).toBe(""),
);
rerender(
@@ -5028,7 +5046,7 @@ describe("CustomAgentChatView", () => {
,
);
await waitFor(() =>
- expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""),
+ expect((screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement).value).toBe(""),
);
});
@@ -5083,7 +5101,7 @@ describe("CustomAgentChatView", () => {
await waitFor(() =>
expect(agent.readChatDraft).toHaveBeenCalledWith(scope),
);
- const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
+ const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "draft with file" } });
const file = new File(["ignored"], "not-persisted.png", { type: "image/png" });
@@ -5159,7 +5177,7 @@ describe("CustomAgentChatView", () => {
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.clearChatDraft).toHaveBeenCalledWith(scope);
- expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe("");
+ expect((screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement).value).toBe("");
});
it("clears the persisted draft when a slash command is consumed", async () => {
@@ -5224,7 +5242,7 @@ describe("CustomAgentChatView", () => {
}),
);
expect(agent.clearChatDraft).toHaveBeenCalledWith(scope);
- expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe("");
+ expect((screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement).value).toBe("");
});
it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => {
@@ -5457,7 +5475,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "please recover" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -5531,7 +5549,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "please recover" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -5595,7 +5613,7 @@ describe("CustomAgentChatView", () => {
),
);
- fireEvent.change(screen.getByLabelText(/message CLI custom/), {
+ fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "long turn" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
@@ -5603,7 +5621,7 @@ describe("CustomAgentChatView", () => {
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
- expect(await screen.findByText("Request already stopped.")).toBeTruthy();
+ expect(await screen.findByText("Demande déjà arrêtée.")).toBeTruthy();
expect(onSessionId).not.toHaveBeenCalledWith(null);
expect(agent.cancelAgentChat).toHaveBeenCalledWith("gone-session");
expect(agent.stopLiveAgent).not.toHaveBeenCalled();
diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx
index 6620805d..a4ec4bd3 100644
--- a/frontend/src/features/agents/CustomAgentChatView.tsx
+++ b/frontend/src/features/agents/CustomAgentChatView.tsx
@@ -31,6 +31,7 @@ import type {
SlashCommand,
} from "@/domain";
import { useGateways } from "@/app/di";
+import { useI18n } from "@/i18n";
import {
AnnouncementsPreview,
shortRestartFailure,
@@ -43,10 +44,12 @@ import {
markProjectCliInteractionStopped,
} from "@/features/projects/projectCliInteractionActivity";
import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared";
+import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons";
import type {
ChatAttachmentInput,
ChatDraftScope,
ConversationTranscriptUnavailableReason,
+ ProjectIconRef,
} from "@/ports";
export interface CustomAgentChatViewProps {
@@ -947,7 +950,8 @@ export function CustomAgentChatView({
onSessionId,
onConversationId,
}: CustomAgentChatViewProps) {
- const { agent, profile: profileGateway, system, workState } = useGateways();
+ const { agent, profile: profileGateway, project, system, workState } = useGateways();
+ const { t, tr } = useI18n();
const pluginRuntime = usePluginRuntime();
const initialConversationCacheKey = conversationCacheKey(
projectId,
@@ -1006,6 +1010,7 @@ export function CustomAgentChatView({
const [busy, setBusy] = useState(false);
const [stopping, setStopping] = useState(false);
const [error, setError] = useState(null);
+ const [projectIcon, setProjectIcon] = useState(null);
const scrollRef = useRef(null);
const composerRef = useRef(null);
const chatRootRef = useRef(null);
@@ -1048,6 +1053,17 @@ export function CustomAgentChatView({
onConversationIdRef.current = onConversationId;
const domId = nodeId ?? `agent-${agentId}`;
+ useEffect(() => {
+ let cancelled = false;
+ void project?.readProjectSettings(projectId).then((settings) => {
+ if (!cancelled) setProjectIcon(settings.icon ?? null);
+ }).catch(() => undefined);
+ const unsubscribe = onProjectIconChanged((change) => {
+ if (change.projectId === projectId) setProjectIcon(change.icon);
+ });
+ return () => { cancelled = true; unsubscribe(); };
+ }, [project, projectId]);
+
const clampComposerHeight = useCallback(
(heightPx: number): number => {
const visibleHeight =
@@ -1173,7 +1189,7 @@ export function CustomAgentChatView({
delegationActive;
const delegationFallbackText = requesterDelegation.delegationTargetName
? `${agentName} attend ${requesterDelegation.delegationTargetName}`
- : "Délégation en cours.";
+ : tr("Délégation en cours.", "Delegation in progress.");
const refreshRequesterDelegation = useCallback(async () => {
if (!workState) {
@@ -1391,21 +1407,21 @@ export function CustomAgentChatView({
nextTurns.push({
role: "restart",
key,
- text: "------- redemarrage de l'agent -------",
+ text: tr("------- redémarrage de l’agent -------", "------- restarting agent -------"),
});
} else if (event.type === "agentRestartCompleted") {
changed = true;
nextTurns.push({
role: "restart",
key,
- text: "------- redemarrage de l'agent termine -------",
+ text: tr("------- redémarrage de l’agent terminé -------", "------- agent restart completed -------"),
});
} else {
changed = true;
nextTurns.push({
role: "restart",
key,
- text: "------- redemarrage de l'agent echoue -------",
+ text: tr("------- redémarrage de l’agent échoué -------", "------- agent restart failed -------"),
message: shortRestartFailure(event.message),
failed: true,
});
@@ -2028,7 +2044,7 @@ export function CustomAgentChatView({
if (!profileDialog) return;
if (!agent.changeAgentProfile) {
setProfileDialog((prev) =>
- prev ? { ...prev, error: "Changement de profil indisponible." } : prev,
+ prev ? { ...prev, error: tr("Changement de profil indisponible.", "Profile change unavailable.") } : prev,
);
return;
}
@@ -2077,7 +2093,7 @@ export function CustomAgentChatView({
turns: [
{
role: "tool",
- label: `Profil changé vers ${selected.name}.`,
+ label: tr(`Profil changé vers ${selected.name}.`, `Profile changed to ${selected.name}.`),
receivedAtMs: null,
},
],
@@ -2095,7 +2111,7 @@ export function CustomAgentChatView({
async function executeSlashCommand(invocation: SlashCommandInvocation) {
if (!agent.executeSlashCommand) {
setError({
- message: `Commande ${invocation.name} indisponible dans ce runtime.`,
+ message: tr(`Commande ${invocation.name} indisponible dans ce runtime.`, `Command ${invocation.name} is unavailable in this runtime.`),
openFailure: false,
});
return;
@@ -2144,7 +2160,7 @@ export function CustomAgentChatView({
...prev.turns,
{
role: "tool",
- label: `Commande plugin ${result.command.name} exécutée.`,
+ label: tr(`Commande plugin ${result.command.name} exécutée.`, `Plugin command ${result.command.name} executed.`),
receivedAtMs: null,
},
],
@@ -2152,7 +2168,7 @@ export function CustomAgentChatView({
return;
}
} catch (e) {
- const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
+ const message = `${tr(`Commande ${invocation.name} échouée :`, `Command ${invocation.name} failed:`)} ${describe(e)}`;
setError({ message, openFailure: false });
updateChatViewState((prev) => ({
...prev,
@@ -2253,7 +2269,7 @@ export function CustomAgentChatView({
const persisted = writeComposerHistory(composerHistoryKey, nextHistory);
setComposerHistory(nextHistory);
setComposerHistoryWarning(
- persisted ? null : "Historique des prompts non sauvegardé.",
+ persisted ? null : tr("Historique des prompts non sauvegardé.", "Prompt history was not saved."),
);
setComposerHistoryIndex(null);
draftBeforeHistoryNavigationRef.current = "";
@@ -2364,7 +2380,7 @@ export function CustomAgentChatView({
...completed.turns,
{
role: "tool",
- label: "Request stopped.",
+ label: tr("Demande arrêtée.", "Request stopped."),
receivedAtMs: null,
},
],
@@ -2384,7 +2400,7 @@ export function CustomAgentChatView({
...completed,
turns: [
...completed.turns,
- { role: "tool", label: "Request already stopped.", receivedAtMs: null },
+ { role: "tool", label: tr("Demande déjà arrêtée.", "Request already stopped."), receivedAtMs: null },
],
};
});
@@ -2397,7 +2413,7 @@ export function CustomAgentChatView({
...prev.turns,
{
role: "error",
- text: `Arrêt de la demande échoué: ${describe(e)}`,
+ text: `${tr("Échec de l’arrêt de la demande", "Failed to stop request")}: ${describe(e)}`,
receivedAtMs: null,
},
],
@@ -2413,22 +2429,27 @@ export function CustomAgentChatView({
data-testid="custom-agent-chat-view"
className="relative z-0 flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
>
+ {projectIcon && (
+
+ )}
{agentName}
- CLI custom · {profile.name}
+ {tr("CLI personnalisée", "Custom CLI")} · {profile.name}
{!supported && (
- CLI custom indisponible pour ce profil ou ce transport. Utilisez la TUI native.
+ {t("custom.unsupported")}
)}
{error && (
@@ -2438,10 +2459,12 @@ export function CustomAgentChatView({
>
{error.openFailure ? (
-
Le CLI custom n'a pas pu démarrer.
-
- Cause: {error.message}
-
+
{t("custom.startFailure")}
+ {(!mobile || !/invalid node id/i.test(error.message)) && (
+
+ {tr("Cause", "Cause")}: {error.message}
+
+ )}
{onOpenNativeTui && (
)}
@@ -2485,10 +2508,10 @@ export function CustomAgentChatView({
id={`profile-command-title-${domId}`}
className="text-sm font-semibold text-content"
>
- Changer de profil
+ {t("custom.profile.change")}
- Valider ce changement va réinitialiser la session courante. L'historique de cette CLI custom sera perdu.
+ {t("custom.profile.warning")}
{profileDialog.error && (
@@ -2552,7 +2575,7 @@ export function CustomAgentChatView({
disabled={profileDialog.busy}
onClick={() => setProfileDialog(null)}
>
- Annuler
+ {tr("Annuler", "Cancel")}
@@ -2584,18 +2607,18 @@ export function CustomAgentChatView({
- {externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
+ {externalSessionId ? t("custom.resume") : t("custom.opening")}
) : unavailableLinkedTranscript ? (
- La conversation liée n’a pas pu être restaurée.
+ {tr("La conversation liée n’a pas pu être restaurée.", "The linked conversation could not be restored.")}
{unavailableLinkedTranscript === "conversationNotFound"
- ? "Cette conversation est introuvable dans le stockage disponible."
- : "Son transcript n’est plus disponible après la reprise."}
+ ? tr("Cette conversation est introuvable dans le stockage disponible.", "This conversation cannot be found in available storage.")
+ : tr("Sa transcription n’est plus disponible après la reprise.", "Its transcript is no longer available after restoration.")}
) : displayTurns.length === 0 ? (
- Envoyez un message pour démarrer la conversation structurée.
+ {t("custom.empty")}
) : (
displayTurns.map((turn, index) => (
@@ -2661,14 +2684,14 @@ export function CustomAgentChatView({
className="h-8 w-8 shrink-0 rounded border border-border object-cover"
/>
)}
- Fichier joint: {item.label}
+ {t("custom.attachment")}: {item.label}
))}
@@ -2678,7 +2701,7 @@ export function CustomAgentChatView({
{slashCommands.map((command, index) => {
@@ -2705,7 +2728,7 @@ export function CustomAgentChatView({
{command.shortDescription}
{command.requiresConfirmation && (
-
confirm
+
{tr("confirmation", "confirm")}
)}
);
@@ -2721,7 +2744,7 @@ export function CustomAgentChatView({
@@ -2830,6 +2853,7 @@ function ChatBubble({
expandedProgressKeys: Set;
onToggleProgressExpanded: (key: string) => void;
}) {
+ const { tr } = useI18n();
if (turn.role === "restart") {
return (
- {turn.label}
+ {turn.label === "Activité" ? tr("Activité", "Activity") : turn.label}
@@ -2874,7 +2898,7 @@ function ChatBubble({
@@ -2889,7 +2913,7 @@ function ChatBubble({
{turn.role === "error" && (
@@ -2897,8 +2921,8 @@ function ChatBubble({
{turn.role === "error" && sessionLabel && (
{sessionLabel}
)}
- {turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
-
{turn.text}
+ {turn.role === "unknown" ? tr("Fragment inconnu reçu : ", "Unknown chunk received: ") : ""}
+
{turn.text === "Erreur agent" ? tr("Erreur agent", "Agent error") : turn.text}
);
@@ -2914,7 +2938,7 @@ function ChatBubble({
)}
>
{turn.text}
@@ -2922,7 +2946,7 @@ function ChatBubble({
{turn.attachments.map((attachment) => (
- Fichier: {attachment}
+ {tr("Fichier", "File")}: {attachment}
))}
@@ -2930,7 +2954,7 @@ function ChatBubble({
{!user && turn.pending && (
- Réponse en cours
+ {tr("Réponse en cours", "Reply in progress")}
)}
@@ -2957,28 +2981,30 @@ function ChatMessageMeta({
);
}
-function progressTitle(progress: ReplyProgress): string {
- if (isDelegationProgress(progress)) return "Délégation";
+type Tr = (french: string, english: string) => string;
+
+function progressTitle(progress: ReplyProgress, tr: Tr): string {
+ if (isDelegationProgress(progress)) return tr("Délégation", "Delegation");
if (progress.kind === "mcp") return "MCP";
- if (progress.kind === "tool") return "Tool";
- if (progress.kind === "message") return "Progress";
- if (progress.kind === "turn") return "Tour";
- return "Activité";
+ if (progress.kind === "tool") return tr("Outil", "Tool");
+ if (progress.kind === "message") return tr("Progression", "Progress");
+ if (progress.kind === "turn") return tr("Tour", "Turn");
+ return tr("Activité", "Activity");
}
-function progressSourceLabel(progress: ReplyProgress): string {
+function progressSourceLabel(progress: ReplyProgress, tr: Tr): string {
if (progress.source === "ideaLocal") return "IdeA";
- return progress.provider ?? "Provider";
+ return progress.provider ?? tr("Fournisseur", "Provider");
}
-function progressStageLabel(stage: ReplyProgressStage): string {
+function progressStageLabel(stage: ReplyProgressStage, tr: Tr): string {
switch (stage) {
case "started":
- return "En cours";
+ return tr("En cours", "In progress");
case "delta":
- return "En cours";
+ return tr("En cours", "In progress");
case "completed":
- return "Terminé";
+ return tr("Terminé", "Completed");
case "info":
return "info";
}
@@ -3125,6 +3151,7 @@ function ExpandableProgressDetail({
expanded: boolean;
onToggle: () => void;
}) {
+ const { tr } = useI18n();
const contentId = useId();
const detailRef = useRef(null);
const truncated = useIsTextTruncated(detailRef, content, expanded);
@@ -3155,7 +3182,7 @@ function ExpandableProgressDetail({
aria-controls={contentId}
onClick={onToggle}
>
- {expanded ? "Afficher moins" : "Afficher plus"}
+ {expanded ? tr("Afficher moins", "Show less") : tr("Afficher plus", "Show more")}
)}
@@ -3163,8 +3190,9 @@ function ExpandableProgressDetail({
}
function TurnMarker({ progress }: { progress: ReplyProgress }) {
- const stage = progressStageLabel(progress.stage);
- const label = progress.label.trim() || "Tour";
+ const { tr } = useI18n();
+ const stage = progressStageLabel(progress.stage, tr);
+ const label = progress.label.trim() || tr("Tour", "Turn");
return (
@@ -3193,7 +3221,8 @@ function ToolProgressCard({
expanded: boolean;
onToggleExpanded: () => void;
}) {
- const stage = progressStageLabel(progress.stage);
+ const { tr } = useI18n();
+ const stage = progressStageLabel(progress.stage, tr);
const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null;
const detail = delegation
? delegation.detail
@@ -3203,8 +3232,8 @@ function ToolProgressCard({
? `${delegation.requester ?? requesterName} ${
progress.stage === "completed" ? "←" : "→"
} ${delegation.target}`
- : "Appel inter-agent"
- : progressTitle(progress);
+ : tr("Appel inter-agent", "Inter-agent call")
+ : progressTitle(progress, tr);
return (
- {progressSourceLabel(progress)}
+ {progressSourceLabel(progress, tr)}
{progress.nativeEvent && (
- event: {progress.nativeEvent}
+ {tr("événement", "event")}: {progress.nativeEvent}
)}
)}
@@ -3266,6 +3295,7 @@ function ProgressBubble({
expanded: boolean;
onToggleExpanded: () => void;
}) {
+ const { tr } = useI18n();
if (isTimelineProgress(progress)) return
;
if (isCenteredToolProgress(progress)) {
return (
@@ -3278,9 +3308,9 @@ function ProgressBubble({
/>
);
}
- const title = progressTitle(progress);
- const stage = progressStageLabel(progress.stage);
- const source = progressSourceLabel(progress);
+ const title = progressTitle(progress, tr);
+ const stage = progressStageLabel(progress.stage, tr);
+ const source = progressSourceLabel(progress, tr);
const detail = progress.text?.trim() || null;
return (
@@ -3299,7 +3329,7 @@ function ProgressBubble({
{source}
{progress.nativeEvent && (
- event: {progress.nativeEvent}
+ {tr("événement", "event")}: {progress.nativeEvent}
)}
{detail && (
diff --git a/frontend/src/features/agents/ModelServerLaunchBadge.test.tsx b/frontend/src/features/agents/ModelServerLaunchBadge.test.tsx
index 9e92b579..b64f1c4a 100644
--- a/frontend/src/features/agents/ModelServerLaunchBadge.test.tsx
+++ b/frontend/src/features/agents/ModelServerLaunchBadge.test.tsx
@@ -27,11 +27,11 @@ describe("ModelServerLaunchBadge (F35.1)", () => {
const { rerender } = render(
,
);
- expect(screen.getByLabelText("model server status").textContent).toMatch(
+ expect(screen.getByLabelText("état du serveur de modèle").textContent).toMatch(
/vérification/i,
);
rerender(
);
- expect(screen.getByLabelText("model server status").textContent).toMatch(
+ expect(screen.getByLabelText("état du serveur de modèle").textContent).toMatch(
/démarrage/i,
);
});
@@ -40,13 +40,13 @@ describe("ModelServerLaunchBadge (F35.1)", () => {
const { rerender } = render(
,
);
- expect(screen.getByLabelText("model server status").textContent).toMatch(
+ expect(screen.getByLabelText("état du serveur de modèle").textContent).toMatch(
/réutilisé/i,
);
rerender(
,
);
- const label = screen.getByLabelText("model server status").textContent ?? "";
+ const label = screen.getByLabelText("état du serveur de modèle").textContent ?? "";
expect(label).toMatch(/prêt/i);
expect(label).not.toMatch(/réutilisé/i);
});
@@ -64,7 +64,7 @@ describe("ModelServerLaunchBadge (F35.1)", () => {
const alert = screen.getByRole("alert");
expect(alert.textContent).toMatch(/did not become ready on :8080/);
// The code is not exposed by default (logs/detail stay hidden).
- expect(screen.queryByLabelText("launch failure detail")).toBeNull();
+ expect(screen.queryByLabelText("détail de l'échec du lancement")).toBeNull();
});
it("reveals the stable code/cause only when Détails is toggled", () => {
@@ -73,8 +73,8 @@ describe("ModelServerLaunchBadge (F35.1)", () => {
failure={{ cause: "model_server", code: "SPAWN", message: "boom" }}
/>,
);
- fireEvent.click(screen.getByLabelText("toggle launch failure detail"));
- const detail = screen.getByLabelText("launch failure detail");
+ fireEvent.click(screen.getByLabelText("afficher ou masquer le détail de l'échec"));
+ const detail = screen.getByLabelText("détail de l'échec du lancement");
expect(detail.textContent).toMatch(/SPAWN/);
expect(detail.textContent).toMatch(/model_server/);
});
diff --git a/frontend/src/features/agents/ModelServerLaunchBadge.tsx b/frontend/src/features/agents/ModelServerLaunchBadge.tsx
index 9bc1bf2c..5ef8c810 100644
--- a/frontend/src/features/agents/ModelServerLaunchBadge.tsx
+++ b/frontend/src/features/agents/ModelServerLaunchBadge.tsx
@@ -18,6 +18,7 @@
import { useState } from "react";
import { cn } from "@/shared";
+import { useI18n } from "@/i18n";
import type { ModelServerStatus } from "@/domain";
import type { AgentLaunchFailure } from "./useAgents";
@@ -31,20 +32,21 @@ export interface ModelServerLaunchBadgeProps {
/** Human label + tone for a non-failed lifecycle state. */
function describeStatus(
status: ModelServerStatus,
+ tr: (french: string, english: string) => string,
): { label: string; tone: string; role?: "status" } | null {
switch (status.state) {
case "notConfigured":
return null; // Nothing to surface for an unmanaged endpoint.
case "probing":
- return { label: "vérification du serveur…", tone: "bg-muted/20 text-muted", role: "status" };
+ return { label: tr("vérification du serveur…", "checking server…"), tone: "bg-muted/20 text-muted", role: "status" };
case "starting":
- return { label: "démarrage du serveur…", tone: "bg-warning/20 text-warning", role: "status" };
+ return { label: tr("démarrage du serveur…", "starting server…"), tone: "bg-warning/20 text-warning", role: "status" };
case "downloading":
- return { label: "téléchargement du modèle…", tone: "bg-warning/20 text-warning", role: "status" };
+ return { label: tr("téléchargement du modèle…", "downloading model…"), tone: "bg-warning/20 text-warning", role: "status" };
case "ready":
return status.reused
- ? { label: "serveur prêt (réutilisé)", tone: "bg-success/20 text-success" }
- : { label: "serveur prêt", tone: "bg-success/20 text-success" };
+ ? { label: tr("serveur prêt (réutilisé)", "server ready (reused)"), tone: "bg-success/20 text-success" }
+ : { label: tr("serveur prêt", "server ready"), tone: "bg-success/20 text-success" };
case "failed":
// Handled by the failure branch below (richer, actionable).
return null;
@@ -55,6 +57,7 @@ export function ModelServerLaunchBadge({
status,
failure,
}: ModelServerLaunchBadgeProps) {
+ const { tr } = useI18n();
const [showDetail, setShowDetail] = useState(false);
// A launch failure (from `agentLaunchFailed`) or a `failed` status both mean
@@ -72,26 +75,26 @@ export function ModelServerLaunchBadge({
- Échec du lancement : {failed.message}
+ {tr("Échec du lancement :", "Launch failed:")} {failed.message}
{showDetail && (
-
- code {failed.code}
- {failed.cause ? ` · cause ${failed.cause}` : ""}
+
+ {tr("code", "code")} {failed.code}
+ {failed.cause ? ` · ${tr("cause", "cause")} ${failed.cause}` : ""}
)}
@@ -99,13 +102,13 @@ export function ModelServerLaunchBadge({
}
if (!status) return null;
- const described = describeStatus(status);
+ const described = describeStatus(status, tr);
if (!described) return null;
return (
- Reprendre les agents ?
+ {tr("Reprendre les agents ?", "Resume agents?")}
- Ces agents tournaient à la dernière fermeture du projet. Choisissez
- ceux à reprendre.
+ {tr("Ces agents tournaient à la dernière fermeture du projet. Choisissez ceux à reprendre.", "These agents were running when the project was last closed. Choose which ones to resume.")}
{vm.error && (
@@ -61,7 +62,7 @@ export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps)
{/* ── Per-agent rows ── */}
{vm.pending.map((a) => {
- const statusLabel = a.wasRunning ? "en cours" : "clôt";
+ const statusLabel = a.wasRunning ? tr("en cours", "running") : tr("clôt", "closed");
return (
-
- Historique non disponible pour ce moteur — relance à neuf.
+ {tr("Historique non disponible pour ce moteur — relance à neuf.", "History is unavailable for this engine — starting fresh.")}
)}
@@ -98,28 +99,28 @@ export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps)
@@ -131,20 +132,20 @@ export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps)
diff --git a/frontend/src/features/agents/agents.test.tsx b/frontend/src/features/agents/agents.test.tsx
index b93a3a27..1b9702ac 100644
--- a/frontend/src/features/agents/agents.test.tsx
+++ b/frontend/src/features/agents/agents.test.tsx
@@ -77,7 +77,7 @@ async function waitForIdle() {
// for the agent list to be rendered.
await waitFor(() => {
// Panel has finished its initial load when the form is accessible.
- expect(screen.getByLabelText("agent name")).toBeTruthy();
+ expect(screen.getByLabelText("Nom de l’agent")).toBeTruthy();
});
}
@@ -96,18 +96,18 @@ function chooseDropdownOption(label: string | RegExp, optionName: string | RegEx
async function createAgent(name: string, profileId?: string) {
await waitForIdle();
- fireEvent.change(screen.getByLabelText("agent name"), {
+ fireEvent.change(screen.getByLabelText("Nom de l’agent"), {
target: { value: name },
});
if (profileId) {
- const profileInput = screen.queryByLabelText("agent profile");
+ const profileInput = screen.queryByLabelText("Profil de l’agent");
if (profileInput) {
- chooseDropdownOption("agent profile", profileId);
+ chooseDropdownOption("Profil de l’agent", profileId);
}
}
- const createBtn = screen.getByRole("button", { name: "create agent" });
+ const createBtn = screen.getByRole("button", { name: "Créer l’agent" });
expect((createBtn as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(createBtn);
}
@@ -120,7 +120,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
it("shows 'No agents yet.' when the project has no agents", async () => {
renderPanel();
await waitForIdle();
- expect(screen.getByText("No agents yet.")).toBeTruthy();
+ expect(screen.getByText("Aucun agent pour le moment.")).toBeTruthy();
});
it("creating an agent adds it to the list", async () => {
@@ -162,7 +162,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
renderPanel(agent);
- expect(await screen.findByText("Writer contexte")).toBeTruthy();
+ expect(await screen.findByText("Responsable du contexte")).toBeTruthy();
const rowButton = screen
.getAllByRole("button", { name: /main/i })
.find((button) => button.hasAttribute("aria-pressed"))!;
@@ -176,7 +176,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
).toBeTruthy();
expect(
(screen.getByRole("radio", {
- name: "Writer du contexte projet global pour Main",
+ name: "Responsable du contexte global du projet pour Main",
}) as HTMLInputElement).checked,
).toBe(true);
});
@@ -201,7 +201,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
fireEvent.click(rowButton);
fireEvent.click(
await screen.findByRole("radio", {
- name: "Writer du contexte projet global pour Architect",
+ name: "Responsable du contexte global du projet pour Architect",
}),
);
@@ -212,7 +212,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
fireEvent.click(
screen.getByRole("button", {
- name: "Confirmer le changement de writer contexte",
+ name: "Confirmer le changement de responsable du contexte",
}),
);
@@ -221,7 +221,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
expect(screen.queryByRole("dialog")).toBeNull();
expect(
(screen.getByRole("radio", {
- name: "Writer du contexte projet global pour Architect",
+ name: "Responsable du contexte global du projet pour Architect",
}) as HTMLInputElement).checked,
).toBe(true);
});
@@ -254,12 +254,12 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
fireEvent.click(rowButton);
fireEvent.click(
await screen.findByRole("radio", {
- name: "Writer du contexte projet global pour QA",
+ name: "Responsable du contexte global du projet pour QA",
}),
);
fireEvent.click(
screen.getByRole("button", {
- name: "Confirmer le changement de writer contexte",
+ name: "Confirmer le changement de responsable du contexte",
}),
);
@@ -272,7 +272,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
renderPanel();
await waitForIdle();
// Name field is empty by default → disabled
- const btn = screen.getByRole("button", { name: "create agent" });
+ const btn = screen.getByRole("button", { name: "Créer l’agent" });
expect((btn as HTMLButtonElement).disabled).toBe(true);
});
@@ -304,7 +304,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
renderPanel(new MockAgentGateway(), profile);
await waitForIdle();
- openDropdown("agent profile");
+ openDropdown("Profil de l’agent");
const labels = screen
.getAllByRole("option")
.map((option) => option.textContent);
@@ -326,14 +326,14 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
await waitForIdle();
// Click on the agent row button (aria-pressed) to select it — not the
- // "launch Alpha" action button.
+ // "Lancer Alpha" action button.
const buttons = screen.getAllByRole("button", { name: /alpha/i });
const rowBtn = buttons.find(
(b) => b.hasAttribute("aria-pressed"),
)!;
fireEvent.click(rowBtn);
- const textarea = await screen.findByLabelText("agent context");
+ const textarea = await screen.findByLabelText("Contexte de l’agent");
expect((textarea as HTMLTextAreaElement).value).toBe("## Alpha context");
});
@@ -352,10 +352,10 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
const rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
fireEvent.click(rowBtn);
- const textarea = await screen.findByLabelText("agent context");
+ const textarea = await screen.findByLabelText("Contexte de l’agent");
fireEvent.change(textarea, { target: { value: "updated content" } });
- fireEvent.click(screen.getByRole("button", { name: "Save" }));
+ fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
// After save the gateway should hold the new content.
await waitFor(async () => {
@@ -379,9 +379,9 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
let rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
fireEvent.click(rowBtn);
- let textarea = await screen.findByLabelText("agent context");
+ let textarea = await screen.findByLabelText("Contexte de l’agent");
fireEvent.change(textarea, { target: { value: "persisted after reopen" } });
- fireEvent.click(screen.getByRole("button", { name: "Save" }));
+ fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
await waitFor(async () => {
const agents = await agent.listAgents(PROJECT_ID);
@@ -398,7 +398,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
fireEvent.click(rowBtn);
- textarea = await screen.findByLabelText("agent context");
+ textarea = await screen.findByLabelText("Contexte de l’agent");
expect((textarea as HTMLTextAreaElement).value).toBe("persisted after reopen");
});
@@ -413,7 +413,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
await screen.findByText("Gamma");
- fireEvent.click(screen.getByRole("button", { name: "delete Gamma" }));
+ fireEvent.click(screen.getByRole("button", { name: "Supprimer Gamma" }));
await waitFor(() => {
expect(screen.queryByText("Gamma")).toBeNull();
@@ -437,7 +437,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
renderPanel(agent);
await waitForIdle();
- fireEvent.click(screen.getByRole("button", { name: "launch Delta" }));
+ fireEvent.click(screen.getByRole("button", { name: "Lancer Delta" }));
// The terminal container div (data-testid="terminal-view") should be mounted.
await waitFor(() => {
@@ -466,13 +466,13 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
await waitForIdle();
// Launch
- fireEvent.click(screen.getByRole("button", { name: "launch Echo" }));
+ fireEvent.click(screen.getByRole("button", { name: "Lancer Echo" }));
await waitFor(() => {
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
// Stop
- fireEvent.click(screen.getByRole("button", { name: "stop Echo" }));
+ fireEvent.click(screen.getByRole("button", { name: "Arrêter Echo" }));
const stopping = screen.getByRole("button", { name: "Arrêt en cours… — Echo" });
expect((stopping as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByTestId("terminal-view")).toBeTruthy();
@@ -498,9 +498,9 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
renderPanel(agent);
await waitForIdle();
- fireEvent.click(screen.getByRole("button", { name: "launch Main" }));
+ fireEvent.click(screen.getByRole("button", { name: "Lancer Main" }));
await screen.findByTestId("terminal-view");
- fireEvent.click(screen.getByRole("button", { name: "stop Main" }));
+ fireEvent.click(screen.getByRole("button", { name: "Arrêter Main" }));
expect(
await screen.findByText("Arrêt incomplet — Certains travaux sont encore actifs."),
@@ -524,9 +524,9 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
const stop = vi.spyOn(agent, "stopLiveAgent");
renderPanel(agent);
- await screen.findByRole("button", { name: "stop Headless" });
+ await screen.findByRole("button", { name: "Arrêter Headless" });
expect(screen.queryByTestId("terminal-view")).toBeNull();
- fireEvent.click(screen.getByRole("button", { name: "stop Headless" }));
+ fireEvent.click(screen.getByRole("button", { name: "Arrêter Headless" }));
await waitFor(() => expect(stop).toHaveBeenCalled());
await expect(agent.listLiveAgents(PROJECT_ID)).resolves.toEqual([]);
@@ -548,15 +548,15 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
await waitForIdle();
// Select the template in the dropdown
- chooseDropdownOption("agent template", "My Template");
+ chooseDropdownOption("Modèle d’agent", "My Template");
// Fill agent name
- fireEvent.change(screen.getByLabelText("agent name"), {
+ fireEvent.change(screen.getByLabelText("Nom de l’agent"), {
target: { value: "From Template" },
});
// Click Create
- fireEvent.click(screen.getByRole("button", { name: "create agent" }));
+ fireEvent.click(screen.getByRole("button", { name: "Créer l’agent" }));
// createAgentFromTemplate should have been called with the correct args
await waitFor(() => {
@@ -585,12 +585,12 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
renderPanel(agent, new MockProfileGateway(), "/home/me/proj", tmpl);
await waitForIdle();
- chooseDropdownOption("agent template", "Main template");
- expect((screen.getByLabelText("agent name") as HTMLInputElement).value).toBe("Main");
+ chooseDropdownOption("Modèle d’agent", "Main template");
+ expect((screen.getByLabelText("Nom de l’agent") as HTMLInputElement).value).toBe("Main");
expect(screen.getByText(/Modèle appliqué:/)).toBeTruthy();
expect(screen.getByText("Profil profile-main")).toBeTruthy();
- fireEvent.click(screen.getByRole("button", { name: "create agent" }));
+ fireEvent.click(screen.getByRole("button", { name: "Créer l’agent" }));
await waitFor(() => {
expect(createFromTemplateSpy).toHaveBeenCalledWith(PROJECT_ID, t.id, {
@@ -619,7 +619,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
renderPanel(new MockAgentGateway(), profile);
await waitForIdle();
- openDropdown("agent profile");
+ openDropdown("Profil de l’agent");
const options = screen.getAllByRole("option").map((o) => o.textContent);
expect(options).toContain("Claude Code");
});
@@ -634,7 +634,7 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
renderPanel(new MockAgentGateway(), profile);
await waitForIdle();
- openDropdown("agent profile");
+ openDropdown("Profil de l’agent");
const options = screen.getAllByRole("option").map((o) => o.textContent ?? "");
expect(options.some((text) => text.includes("OpenCode Local Durable"))).toBe(true);
});
@@ -770,7 +770,7 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
await waitForIdle();
await screen.findByText("Swap");
- chooseDropdownOption("profile for Swap", /Codex CLI/);
+ chooseDropdownOption("Profil de Swap", /Codex CLI/);
// No gateway call yet — only the dialog appears.
expect(swapSpy).not.toHaveBeenCalled();
@@ -786,9 +786,9 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
await waitForIdle();
await screen.findByText("Swap");
- chooseDropdownOption("profile for Swap", /Codex CLI/);
+ chooseDropdownOption("Profil de Swap", /Codex CLI/);
fireEvent.click(
- screen.getByRole("button", { name: "cancel profile change" }),
+ screen.getByRole("button", { name: "Annuler le changement de profil" }),
);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
@@ -807,9 +807,9 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
await waitForIdle();
await screen.findByText("Swap");
- chooseDropdownOption("profile for Swap", /Codex CLI/);
+ chooseDropdownOption("Profil de Swap", /Codex CLI/);
fireEvent.click(
- screen.getByRole("button", { name: "confirm profile change" }),
+ screen.getByRole("button", { name: "Confirmer le changement de profil" }),
);
await waitFor(() => {
@@ -830,13 +830,13 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
await waitForIdle();
await screen.findByText("Swap");
- chooseDropdownOption("profile for Swap", /Codex CLI/);
+ chooseDropdownOption("Profil de Swap", /Codex CLI/);
const dialog = screen.getByRole("dialog");
const text = dialog.textContent ?? "";
// Spec §15.1: « Changer le moteur abandonne l'historique de conversation
// (le contexte et la mémoire sont conservés). Continuer ? » — must be FRENCH.
- expect(text).toMatch(/abandonne l'historique de conversation/i);
+ expect(text).toMatch(/abandonne l.historique de conversation/i);
expect(text).toMatch(/contexte et la mémoire sont conservés/i);
expect(text).toMatch(/Continuer\s*\?/);
});
@@ -852,13 +852,13 @@ describe("AgentsPanel effort selection (#131)", () => {
await waitForIdle();
await screen.findByText("Thinker");
- openDropdown("effort for Thinker");
+ openDropdown("Effort pour Thinker");
const labels = screen
.getAllByRole("option")
.map((option) => option.textContent);
expect(labels).toEqual([
- "Effort: profil",
+ "Effort : profil",
"Léger",
"Standard",
"Profond",
@@ -878,7 +878,7 @@ describe("AgentsPanel effort selection (#131)", () => {
await waitForIdle();
await screen.findByText("Thinker");
- chooseDropdownOption("effort for Thinker", "Profond");
+ chooseDropdownOption("Effort pour Thinker", "Profond");
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, {
@@ -911,14 +911,14 @@ describe("AgentsPanel effort selection (#131)", () => {
renderPanel(agent, profile);
await waitForIdle();
- await screen.findByText("Fallback");
+ await screen.findByText("Effort : profil");
- openDropdown("effort for Fallback");
+ openDropdown("Effort pour Fallback");
const labels = screen
.getAllByRole("option")
.map((option) => option.textContent);
expect(labels).toEqual([
- "Effort: profil",
+ "Effort : profil",
"Rapide (par défaut)",
"Standard (par défaut)",
"Approfondi (par défaut)",
@@ -943,10 +943,10 @@ describe("AgentsPanel effort selection (#131)", () => {
renderPanel(agent, await seededProfiles());
await waitForIdle();
- await screen.findByText("Custom");
+ await screen.findByText("Effort : profil");
- chooseDropdownOption("effort for Custom", "Personnalisé");
- const input = screen.getByLabelText("custom effort for Custom");
+ chooseDropdownOption("Effort pour Custom", "Personnalisé");
+ const input = screen.getByLabelText("Effort personnalisé pour Custom");
fireEvent.change(input, { target: { value: "x-provider-deep" } });
fireEvent.blur(input);
@@ -974,7 +974,7 @@ describe("AgentsPanel live refresh on domain events", () => {
);
// Initially empty.
- await waitFor(() => expect(screen.getByText("No agents yet.")).toBeTruthy());
+ await waitFor(() => expect(screen.getByText("Aucun agent pour le moment.")).toBeTruthy());
// Simulate the orchestrator creating an agent directly in the store
// (bypassing the UI's createAgent path), then emitting `agentLaunched`.
@@ -1029,7 +1029,7 @@ describe("AgentsPanel live refresh on domain events", () => {
// subscription effect must no-op rather than throw.
renderPanel();
await waitForIdle();
- expect(screen.getByText("No agents yet.")).toBeTruthy();
+ expect(screen.getByText("Aucun agent pour le moment.")).toBeTruthy();
});
});
@@ -1071,7 +1071,7 @@ describe("AgentsPanel orchestration source badge (M4)", () => {
source: "mcp",
});
- const badge = await screen.findByLabelText("delegation source mcp");
+ const badge = await screen.findByLabelText("source de délégation mcp");
expect(badge.textContent?.toLowerCase()).toContain("mcp");
});
@@ -1086,7 +1086,7 @@ describe("AgentsPanel orchestration source badge (M4)", () => {
source: "file",
});
- const badge = await screen.findByLabelText("delegation source file");
+ const badge = await screen.findByLabelText("source de délégation file");
expect(badge.textContent?.toLowerCase()).toContain("file");
});
@@ -1103,8 +1103,8 @@ describe("AgentsPanel orchestration source badge (M4)", () => {
// Give the effect a tick; the map must stay empty → no badge for any source.
await waitFor(() => expect(screen.getByText("Requester")).toBeTruthy());
- expect(screen.queryByLabelText("delegation source mcp")).toBeNull();
- expect(screen.queryByLabelText("delegation source file")).toBeNull();
+ expect(screen.queryByLabelText("source de délégation mcp")).toBeNull();
+ expect(screen.queryByLabelText("source de délégation file")).toBeNull();
});
it("does not badge an agent whose id never matched a processed requester", async () => {
@@ -1120,6 +1120,6 @@ describe("AgentsPanel orchestration source badge (M4)", () => {
});
await waitFor(() => expect(screen.getByText("Requester")).toBeTruthy());
- expect(screen.queryByLabelText("delegation source mcp")).toBeNull();
+ expect(screen.queryByLabelText("source de délégation mcp")).toBeNull();
});
});
diff --git a/frontend/src/features/agents/resumeProject.test.tsx b/frontend/src/features/agents/resumeProject.test.tsx
index a211c8ec..54c36709 100644
--- a/frontend/src/features/agents/resumeProject.test.tsx
+++ b/frontend/src/features/agents/resumeProject.test.tsx
@@ -168,7 +168,7 @@ describe("ResumeProjectPanel — per-agent actions", () => {
]);
await screen.findByTestId("resume-project-panel");
- fireEvent.click(screen.getByLabelText("resume Architect"));
+ fireEvent.click(screen.getByLabelText("reprendre Architect"));
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
const [projectId, agentId, options] = launchSpy.mock.calls[0];
@@ -197,7 +197,7 @@ describe("ResumeProjectPanel — per-agent actions", () => {
"relance à neuf",
);
// And the primary button reads "Relancer à neuf" (FR), not "Reprendre".
- const btn = screen.getByLabelText("resume Legacy");
+ const btn = screen.getByLabelText("reprendre Legacy");
expect(btn.textContent).toBe("Relancer à neuf");
fireEvent.click(btn);
@@ -215,7 +215,7 @@ describe("ResumeProjectPanel — per-agent actions", () => {
]);
await screen.findByTestId("resume-project-panel");
- fireEvent.click(screen.getByLabelText("new conversation Architect"));
+ fireEvent.click(screen.getByLabelText("nouvelle conversation Architect"));
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
// setCellConversation cleared BEFORE the launch.
@@ -238,7 +238,7 @@ describe("ResumeProjectPanel — per-agent actions", () => {
]);
await screen.findByTestId("resume-project-panel");
- fireEvent.click(screen.getByLabelText("ignore Doomed"));
+ fireEvent.click(screen.getByLabelText("ignorer Doomed"));
await waitFor(() =>
expect(screen.queryByTestId("resume-row-a4")).toBeNull(),
@@ -255,7 +255,7 @@ describe("ResumeProjectPanel — global actions", () => {
]);
await screen.findByTestId("resume-project-panel");
- fireEvent.click(screen.getByLabelText("resume all agents"));
+ fireEvent.click(screen.getByLabelText("reprendre tous les agents"));
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(2));
const launchedAgentIds = launchSpy.mock.calls.map((c) => c[1]).sort();
@@ -273,7 +273,7 @@ describe("ResumeProjectPanel — global actions", () => {
]);
await screen.findByTestId("resume-project-panel");
- fireEvent.click(screen.getByLabelText("ignore all agents"));
+ fireEvent.click(screen.getByLabelText("ignorer tous les agents"));
await waitFor(() =>
expect(screen.queryByTestId("resume-project-panel")).toBeNull(),
diff --git a/frontend/src/features/appExit/AppExitConfirmDialog.tsx b/frontend/src/features/appExit/AppExitConfirmDialog.tsx
index 18b73d70..a340116b 100644
--- a/frontend/src/features/appExit/AppExitConfirmDialog.tsx
+++ b/frontend/src/features/appExit/AppExitConfirmDialog.tsx
@@ -23,39 +23,42 @@ import { useEffect, useRef, useState } from "react";
import type { AppExitWorkGuardDetail, AppExitWorkGuardState } from "@/domain";
import { useGateways } from "@/app/di";
import { Button, zIndex } from "@/shared";
+import { useI18n } from "@/i18n";
+
+type Translator = (french: string, english: string) => string;
const MAX_DETAIL_LINES = 5;
/** The narrative body sentence, per carnet #83's exact wording rules. */
-function bodyText(state: AppExitWorkGuardState): string {
+function bodyText(state: AppExitWorkGuardState, tr: Translator): string {
if (state.totalWorkCount === 1) {
- return "1 travail actif sera interrompu si vous quittez IdeA maintenant.";
+ return tr("1 travail actif sera interrompu si vous quittez IdeA maintenant.", "1 active job will be interrupted if you quit IdeA now.");
}
const parts: string[] = [];
if (state.busyAgentCount > 0) {
parts.push(
state.busyAgentCount === 1
- ? "1 agent travaille encore"
- : `${state.busyAgentCount} agents travaillent encore`,
+ ? tr("1 agent travaille encore", "1 agent is still working")
+ : tr(`${state.busyAgentCount} agents travaillent encore`, `${state.busyAgentCount} agents are still working`),
);
}
if (state.activeBackgroundTaskCount > 0) {
parts.push(
state.activeBackgroundTaskCount === 1
- ? "1 tâche de fond est encore active"
- : `${state.activeBackgroundTaskCount} tâches de fond sont encore actives`,
+ ? tr("1 tâche de fond est encore active", "1 background task is still active")
+ : tr(`${state.activeBackgroundTaskCount} tâches de fond sont encore actives`, `${state.activeBackgroundTaskCount} background tasks are still active`),
);
}
- return `${parts.join(" et ")}. Ces travaux seront interrompus si vous quittez IdeA maintenant.`;
+ return tr(`${parts.join(" et ")}. Ces travaux seront interrompus si vous quittez IdeA maintenant.`, `${parts.join(" and ")}. This work will be interrupted if you quit IdeA now.`);
}
/** One compact detail line, per carnet #83's exact line format. */
-function detailLine(detail: AppExitWorkGuardDetail): string {
+function detailLine(detail: AppExitWorkGuardDetail, tr: Translator): string {
if (detail.kind === "busyAgent") {
- return `Agent ${detail.agentName} — ${detail.projectName}`;
+ return `${tr("Agent", "Agent")} ${detail.agentName} — ${detail.projectName}`;
}
const shortTaskId = detail.taskId.slice(0, 8);
- return `Tâche de fond ${shortTaskId} — ${detail.projectName}`;
+ return `${tr("Tâche de fond", "Background task")} ${shortTaskId} — ${detail.projectName}`;
}
function describeError(e: unknown): string {
@@ -69,6 +72,7 @@ const TITLE_ID = "app-exit-confirm-title";
const DESC_ID = "app-exit-confirm-desc";
export function AppExitConfirmDialog() {
+ const { tr } = useI18n();
const { system } = useGateways();
const [guard, setGuard] = useState
(null);
const [closing, setClosing] = useState(false);
@@ -178,33 +182,33 @@ export function AppExitConfirmDialog() {
className="flex w-full max-w-[520px] min-w-[440px] flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl"
>
- Du travail est encore en cours
+ {tr("Du travail est encore en cours", "Work is still in progress")}
- {bodyText(guard)}
+ {bodyText(guard, tr)}
- Annulez la fermeture pour laisser les agents et les tâches se terminer.
+ {tr("Annulez la fermeture pour laisser les agents et les tâches se terminer.", "Cancel closing to let agents and tasks finish.")}
{shown.length > 0 && (
{shown.map((detail, i) => (
- - {`• ${detailLine(detail)}`}
+ - {`• ${detailLine(detail, tr)}`}
))}
- {remaining > 0 && - + {remaining} autre{remaining > 1 ? "s" : ""}
}
+ {remaining > 0 && - {tr(`+ ${remaining} autre${remaining > 1 ? "s" : ""}`, `+ ${remaining} more`)}
}
)}
{error && (
- IdeA n'a pas pu quitter correctement. Réessayez ou consultez les logs.
+ {tr("IdeA n'a pas pu quitter correctement. Réessayez ou consultez les logs.", "IdeA could not quit cleanly. Try again or check the logs.")}
)}
diff --git a/frontend/src/features/conversations/ConversationViewer.test.tsx b/frontend/src/features/conversations/ConversationViewer.test.tsx
index 2f66b353..b8f91f05 100644
--- a/frontend/src/features/conversations/ConversationViewer.test.tsx
+++ b/frontend/src/features/conversations/ConversationViewer.test.tsx
@@ -82,7 +82,7 @@ describe("ConversationViewer", () => {
expect(screen.getByText("deuxième")).toBeTruthy();
// Role labels are rendered.
expect(screen.getAllByText(/· Prompt/).length).toBeGreaterThan(0);
- expect(screen.getAllByText(/· Response/).length).toBeGreaterThan(0);
+ expect(screen.getAllByText(/· Réponse/).length).toBeGreaterThan(0);
});
it("titles the header User ↔ Agent and resolves the agent name from work-state", async () => {
@@ -95,7 +95,7 @@ describe("ConversationViewer", () => {
});
renderViewer({ conversation: gw, workState });
- expect(await screen.findByText("User ↔ Architecte")).toBeTruthy();
+ expect(await screen.findByText("Utilisateur ↔ Architecte")).toBeTruthy();
});
it("titles the header Agent ↔ Agent for a delegation thread", async () => {
@@ -119,7 +119,7 @@ describe("ConversationViewer", () => {
]);
renderViewer({ conversation: gw });
- const toggle = await screen.findByRole("button", { name: /Tool activity/ });
+ const toggle = await screen.findByRole("button", { name: /Activité d'outil/ });
// Collapsed by default ⇒ the tool text is hidden.
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByText("détails outillés secrets")).toBeNull();
diff --git a/frontend/src/features/conversations/ConversationViewer.tsx b/frontend/src/features/conversations/ConversationViewer.tsx
index 9b389f25..de0b2672 100644
--- a/frontend/src/features/conversations/ConversationViewer.tsx
+++ b/frontend/src/features/conversations/ConversationViewer.tsx
@@ -21,6 +21,9 @@ import type {
import { Button, EntryTimestamp, Spinner, cn } from "@/shared";
import { useProjectWorkState } from "@/features/workstate";
import { useConversationThread } from "./useConversationThread";
+import { useI18n } from "@/i18n";
+
+type Translator = (french: string, english: string) => string;
export interface ConversationViewerProps {
projectId: string;
@@ -38,14 +41,14 @@ function partyKey(source: TurnSource): string {
return source.kind === "human" ? "human" : `agent:${source.agentId}`;
}
-function roleLabel(role: TurnView["role"]): string {
+function roleLabel(role: TurnView["role"], tr: Translator): string {
switch (role) {
case "prompt":
- return "Prompt";
+ return tr("Prompt", "Prompt");
case "response":
- return "Response";
+ return tr("Réponse", "Response");
case "toolActivity":
- return "Tool";
+ return tr("Outil", "Tool");
}
}
@@ -80,13 +83,13 @@ function summaryHasVisibleContent(summary: ConversationWorkSummary): boolean {
);
}
-function activeConversations(state: ProjectWorkState | null): ConversationActivity[] {
+function activeConversations(state: ProjectWorkState | null, tr: Translator): ConversationActivity[] {
const out: ConversationActivity[] = [];
for (const agent of state?.agents ?? []) {
for (const ticket of agent.tickets ?? []) {
out.push({
conversationId: ticket.conversationId,
- label: `${agentLabel(agent)} · ${ticket.taskPreview || "ticket actif"}`,
+ label: `${agentLabel(agent)} · ${ticket.taskPreview || tr("ticket actif", "active ticket")}`,
priority: ticket.status === "inProgress" ? 0 : 1,
});
}
@@ -109,27 +112,26 @@ function activeConversations(state: ProjectWorkState | null): ConversationActivi
function emptyThreadState(
conversationId: string,
state: ProjectWorkState | null,
+ tr: Translator,
): EmptyThreadState {
const summaries = state?.conversations ?? [];
const currentSummary = summaries.find(
(summary) => summary.conversationId === conversationId,
);
- const activities = activeConversations(state);
+ const activities = activeConversations(state, tr);
const currentActivity = activities.find(
(activity) => activity.conversationId === conversationId,
);
if (currentActivity) {
return {
- message: "Agent actif, aucun message visible pour le moment.",
- detail:
- "Le fil est bien celui de l'activité en cours, mais le transcript n'a pas encore livré de tour affichable.",
+ message: tr("Agent actif, aucun message visible pour le moment.", "Agent active, no messages visible yet."),
+ detail: tr("Le fil est bien celui de l'activité en cours, mais le transcript n'a pas encore livré de tour affichable.", "This is the active thread, but its transcript has not provided a displayable turn yet."),
};
}
if (currentSummary && summaryHasVisibleContent(currentSummary)) {
return {
- message: "Activité détectée, transcript pas encore synchronisé.",
- detail:
- "Le résumé Work state connaît ce fil, mais la lecture paginée ne renvoie encore aucun tour.",
+ message: tr("Activité détectée, transcript pas encore synchronisé.", "Activity detected, transcript not synchronized yet."),
+ detail: tr("Le résumé Travail connaît ce fil, mais la lecture paginée ne renvoie encore aucun tour.", "The Work summary knows this thread, but paginated reading does not return any turns yet."),
};
}
@@ -138,11 +140,11 @@ function emptyThreadState(
);
if (otherActivity) {
return {
- message: "Aucun tour sur ce fil.",
- detail: `L'activité en cours semble être sur un autre fil: ${otherActivity.label}.`,
+ message: tr("Aucun tour sur ce fil.", "No turns in this thread."),
+ detail: tr(`L'activité en cours semble être sur un autre fil : ${otherActivity.label}.`, `The current activity seems to be in another thread: ${otherActivity.label}.`),
action: {
conversationId: otherActivity.conversationId,
- label: "Ouvrir le fil actif",
+ label: tr("Ouvrir le fil actif", "Open active thread"),
},
};
}
@@ -152,17 +154,17 @@ function emptyThreadState(
);
if (busyAgent) {
return {
- message: "Agent actif sans message visible.",
- detail:
- "Work state signale une activité, mais aucun ticket ou fil actif n'est encore associé au snapshot courant.",
+ message: tr("Agent actif sans message visible.", "Agent active with no visible message."),
+ detail: tr("L'état de travail signale une activité, mais aucun ticket ou fil actif n'est encore associé au snapshot courant.", "Work state reports activity, but no active ticket or thread is associated with the current snapshot yet."),
};
}
- return { message: "Aucun tour pour le moment." };
+ return { message: tr("Aucun tour pour le moment.", "No turns yet.") };
}
/** One collapsible tool-activity turn (dimmed, folded by default). */
function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) {
+ const { tr } = useI18n();
const [open, setOpen] = useState(false);
return (
@@ -174,8 +176,8 @@ function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) {
aria-expanded={open}
>
{open ? "▾" : "▸"}
- Tool activity
- · {turn.textLen} chars
+ {tr("Activité d'outil", "Tool activity")}
+ · {turn.textLen} {tr("car.", "chars")}
{open && (
@@ -201,6 +203,7 @@ export function ConversationViewer({
onClose,
onOpenConversation,
}: ConversationViewerProps) {
+ const { tr } = useI18n();
const vm = useConversationThread(projectId, conversationId);
// Reuse the already-loaded work-state inventory to resolve agent names.
const work = useProjectWorkState(projectId);
@@ -223,25 +226,25 @@ export function ConversationViewer({
const side: PartyMeta["side"] = order.length === 0 ? "left" : "right";
const label =
turn.source.kind === "human"
- ? "User"
+ ? tr("Utilisateur", "User")
: agentNames.get(turn.source.agentId) ??
`Agent ${shortId(turn.source.agentId)}`;
meta.set(key, { label, side });
order.push(key);
}
return { order, meta };
- }, [vm.turns, agentNames]);
+ }, [vm.turns, agentNames, tr]);
const title = useMemo(() => {
const labels = parties.order.map((k) => parties.meta.get(k)?.label ?? "?");
if (labels.length >= 2) return `${labels[0]} ↔ ${labels[1]}`;
if (labels.length === 1) return labels[0];
- return "Conversation";
- }, [parties]);
+ return tr("Conversation", "Conversation");
+ }, [parties, tr]);
const emptyState = useMemo(
- () => emptyThreadState(conversationId, work.state),
- [conversationId, work.state],
+ () => emptyThreadState(conversationId, work.state, tr),
+ [conversationId, work.state, tr],
);
function sideOf(source: TurnSource): "left" | "right" {
@@ -270,10 +273,10 @@ export function ConversationViewer({
onClick={() => void vm.refreshTail()}
loading={vm.busy}
>
- Refresh
+ {tr("Rafraîchir", "Refresh")}
@@ -284,14 +287,14 @@ export function ConversationViewer({
role="alert"
className="mx-4 mt-2 flex shrink-0 items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
-
Impossible de recharger la conversation.
+
{tr("Impossible de recharger la conversation.", "Unable to reload the conversation.")}
)}
@@ -306,7 +309,7 @@ export function ConversationViewer({
onClick={() => void vm.loadOlder()}
loading={vm.busy}
>
- Charger les tours précédents
+ {tr("Charger les tours précédents", "Load previous turns")}
)}
@@ -314,7 +317,7 @@ export function ConversationViewer({
{vm.busy && vm.turns.length === 0 ? (
- Chargement du fil…
+ {tr("Chargement du fil…", "Loading thread…")}
) : vm.turns.length === 0 ? (
@@ -359,7 +362,7 @@ export function ConversationViewer({
{party?.label ?? "?"}
- · {roleLabel(turn.role)}
+ · {roleLabel(turn.role, tr)}
{turn.text}
diff --git a/frontend/src/features/devices/ConfirmDialog.tsx b/frontend/src/features/devices/ConfirmDialog.tsx
index 1c2f14db..46a3d271 100644
--- a/frontend/src/features/devices/ConfirmDialog.tsx
+++ b/frontend/src/features/devices/ConfirmDialog.tsx
@@ -9,6 +9,7 @@
import { useEffect, useRef } from "react";
import { Button, zIndex } from "@/shared";
+import { useI18n } from "@/i18n";
interface ConfirmDialogProps {
title: string;
@@ -31,6 +32,7 @@ export function ConfirmDialog({
onCancel,
}: ConfirmDialogProps) {
const cancelRef = useRef(null);
+ const { tr } = useI18n();
// Mount-only: a confirmation opens focused on the safe choice. Depending on
// `onCancel` here would re-steal focus whenever the parent re-renders (#17).
@@ -63,7 +65,7 @@ export function ConfirmDialog({
{body}
@@ -209,6 +212,7 @@ function RenameForm({
onSubmit: (name: string) => Promise;
onCancel: () => void;
}) {
+ const { tr } = useI18n();
const [name, setName] = useState(device.name);
const trimmed = name.trim();
@@ -220,7 +224,7 @@ function RenameForm({
if (trimmed.length > 0) void onSubmit(trimmed);
}}
>
-
+
{({ id }) => (
- Renommer
+ {tr("Renommer", "Rename")}
- Annuler
+ {tr("Annuler", "Cancel")}
@@ -260,6 +264,7 @@ function RowMenu({
onRename: () => void;
onRevoke: () => void;
}) {
+ const { tr } = useI18n();
const [open, setOpen] = useState(false);
useEffect(() => {
@@ -277,7 +282,7 @@ function RowMenu({
type="button"
aria-haspopup="menu"
aria-expanded={open}
- aria-label={`Actions pour ${deviceName}`}
+ aria-label={tr(`Actions pour ${deviceName}`, `Actions for ${deviceName}`)}
onClick={() => setOpen((v) => !v)}
className="rounded-md px-2 py-1 text-sm text-muted transition-colors hover:bg-raised hover:text-content"
>
@@ -297,7 +302,7 @@ function RowMenu({
}}
className="px-3 py-1.5 text-left text-sm text-content transition-colors hover:bg-canvas"
>
- Renommer
+ {tr("Renommer", "Rename")}
- Révoquer
+ {tr("Révoquer", "Revoke")}
)}
diff --git a/frontend/src/features/devices/PairingCodePanel.tsx b/frontend/src/features/devices/PairingCodePanel.tsx
index 77e06741..8fd343f4 100644
--- a/frontend/src/features/devices/PairingCodePanel.tsx
+++ b/frontend/src/features/devices/PairingCodePanel.tsx
@@ -18,6 +18,7 @@ import { useEffect, useState } from "react";
import type { PairingCode } from "@/domain";
import { Button, Panel } from "@/shared";
import { formatExpiresIn } from "./formatActivity";
+import { useI18n } from "@/i18n";
interface PairingCodePanelProps {
code: PairingCode;
@@ -46,14 +47,15 @@ async function copyToClipboard(text: string): Promise {
}
export function PairingCodePanel({ code, onRegenerate, onDismiss }: PairingCodePanelProps) {
- const [remaining, setRemaining] = useState(() => formatExpiresIn(code.expiresAtMs));
+ const { tr, language } = useI18n();
+ const [remaining, setRemaining] = useState(() => formatExpiresIn(code.expiresAtMs, new Date(), language));
const [copied, setCopied] = useState(false);
useEffect(() => {
- setRemaining(formatExpiresIn(code.expiresAtMs));
- const timer = setInterval(() => setRemaining(formatExpiresIn(code.expiresAtMs)), 1000);
+ setRemaining(formatExpiresIn(code.expiresAtMs, new Date(), language));
+ const timer = setInterval(() => setRemaining(formatExpiresIn(code.expiresAtMs, new Date(), language)), 1000);
return () => clearInterval(timer);
- }, [code.expiresAtMs]);
+ }, [code.expiresAtMs, language]);
useEffect(() => {
setCopied(false);
@@ -71,16 +73,16 @@ export function PairingCodePanel({ code, onRegenerate, onDismiss }: PairingCodeP
{expired ? (
<>
- Ce code a expiré.
+ {tr("Ce code a expiré.", "This code has expired.")}
- Générer un nouveau code
+ {tr("Générer un nouveau code", "Generate a new code")}
>
) : (
<>
- Saisissez ce code sur le nouvel appareil.
+ {tr("Saisissez ce code sur le nouvel appareil.", "Enter this code on the new device.")}
setCopied(ok))
}
>
- {copied ? "Copié" : "Copier"}
+ {copied ? tr("Copié", "Copied") : tr("Copier", "Copy")}
{remaining}
- Fermer
+ {tr("Fermer", "Close")}
>
diff --git a/frontend/src/features/devices/formatActivity.ts b/frontend/src/features/devices/formatActivity.ts
index 52162018..b1404d4e 100644
--- a/frontend/src/features/devices/formatActivity.ts
+++ b/frontend/src/features/devices/formatActivity.ts
@@ -18,15 +18,15 @@ function startOfDay(d: Date): number {
}
/** `14:32` in 24-hour form. */
-function timeOfDay(d: Date): string {
- return d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
+function timeOfDay(d: Date, language: "fr" | "en" = "fr"): string {
+ return d.toLocaleTimeString(language === "fr" ? "fr-FR" : "en-US", { hour: "2-digit", minute: "2-digit" });
}
/** `12 juil.`, with the year appended once it is no longer the current one. */
-export function shortDate(ms: number, now: Date = new Date()): string {
+export function shortDate(ms: number, now: Date = new Date(), language: "fr" | "en" = "fr"): string {
const d = new Date(ms);
const sameYear = d.getFullYear() === now.getFullYear();
- return d.toLocaleDateString("fr-FR", {
+ return d.toLocaleDateString(language === "fr" ? "fr-FR" : "en-US", {
day: "numeric",
month: "short",
...(sameYear ? {} : { year: "numeric" }),
@@ -38,22 +38,22 @@ export function shortDate(ms: number, now: Date = new Date()): string {
* a short date. Days are compared as calendar days, not 24-hour spans — 23:59
* yesterday reads "Hier", not "Aujourd'hui".
*/
-export function formatLastSeen(ms: number, now: Date = new Date()): string {
+export function formatLastSeen(ms: number, now: Date = new Date(), language: "fr" | "en" = "fr"): string {
const delta = now.getTime() - ms;
// A clock skew that puts the server slightly ahead reads as "now", not as a
// date in the future.
- if (delta < JUST_NOW_MS) return "Actif à l'instant";
+ if (delta < JUST_NOW_MS) return language === "fr" ? "Actif à l'instant" : "Active just now";
const today = startOfDay(now);
const day = startOfDay(new Date(ms));
- if (day === today) return `Aujourd'hui à ${timeOfDay(new Date(ms))}`;
- if (day === today - 86_400_000) return "Hier";
- return shortDate(ms, now);
+ if (day === today) return language === "fr" ? `Aujourd'hui à ${timeOfDay(new Date(ms), language)}` : `Today at ${timeOfDay(new Date(ms), language)}`;
+ if (day === today - 86_400_000) return language === "fr" ? "Hier" : "Yesterday";
+ return shortDate(ms, now, language);
}
/** Secondary line: `Appairé le 12 juil.` */
-export function formatPairedAt(ms: number, now: Date = new Date()): string {
- return `Appairé le ${shortDate(ms, now)}`;
+export function formatPairedAt(ms: number, now: Date = new Date(), language: "fr" | "en" = "fr"): string {
+ return language === "fr" ? `Appairé le ${shortDate(ms, now, language)}` : `Paired on ${shortDate(ms, now, language)}`;
}
/**
@@ -61,10 +61,10 @@ export function formatPairedAt(ms: number, now: Date = new Date()): string {
* the last minute so the panel stays honest as it runs out. `null` once expired
* — the caller shows the expiry state instead.
*/
-export function formatExpiresIn(expiresAtMs: number, now: Date = new Date()): string | null {
+export function formatExpiresIn(expiresAtMs: number, now: Date = new Date(), language: "fr" | "en" = "fr"): string | null {
const remainingMs = expiresAtMs - now.getTime();
if (remainingMs <= 0) return null;
const seconds = Math.ceil(remainingMs / 1000);
- if (seconds < 60) return `Expire dans ${seconds} s`;
- return `Expire dans ${Math.ceil(seconds / 60)} min`;
+ if (seconds < 60) return language === "fr" ? `Expire dans ${seconds} s` : `Expires in ${seconds}s`;
+ return language === "fr" ? `Expire dans ${Math.ceil(seconds / 60)} min` : `Expires in ${Math.ceil(seconds / 60)} min`;
}
diff --git a/frontend/src/features/embedder/EmbedderSettings.tsx b/frontend/src/features/embedder/EmbedderSettings.tsx
index 80f9c722..0d8dbf5d 100644
--- a/frontend/src/features/embedder/EmbedderSettings.tsx
+++ b/frontend/src/features/embedder/EmbedderSettings.tsx
@@ -25,6 +25,7 @@ import type {
} from "@/domain";
import { Button, Field, Input, Panel, Spinner, cn } from "@/shared";
import { useEmbedder } from "./useEmbedder";
+import { useI18n } from "@/i18n";
/** Default endpoint placeholder for a local embedding server (Ollama). */
const LOCAL_SERVER_PLACEHOLDER = "http://localhost:11434/api/embeddings";
@@ -146,6 +147,7 @@ function strategyEnabled(
}
export function EmbedderSettings() {
+ const { tr } = useI18n();
const vm = useEmbedder();
// Selection cursor; pre-selected on the recommended ONNX engine. Writing
@@ -192,17 +194,17 @@ export function EmbedderSettings() {
const dimensionNum = Number.parseInt(draft.dimension, 10);
const validation = useMemo(() => {
if (selectedStrategy === "none") return { ok: true as const };
- if (!draft.id.trim()) return { ok: false as const, msg: "Id is required." };
+ if (!draft.id.trim()) return { ok: false as const, msg: tr("L'identifiant est requis.", "Id is required.") };
if (!draft.name.trim())
- return { ok: false as const, msg: "Name is required." };
+ return { ok: false as const, msg: tr("Le nom est requis.", "Name is required.") };
if (!Number.isFinite(dimensionNum) || dimensionNum <= 0)
- return { ok: false as const, msg: "Dimension must be a positive number." };
+ return { ok: false as const, msg: tr("La dimension doit être un nombre positif.", "Dimension must be a positive number.") };
if (selectedStrategy === "localServer" || selectedStrategy === "api") {
if (!draft.endpoint.trim())
- return { ok: false as const, msg: "Endpoint is required." };
+ return { ok: false as const, msg: tr("Le point de terminaison est requis.", "Endpoint is required.") };
}
return { ok: true as const };
- }, [selectedStrategy, draft, dimensionNum]);
+ }, [selectedStrategy, draft, dimensionNum, tr]);
function toProfile(): EmbedderProfile {
return {
@@ -244,7 +246,7 @@ export function EmbedderSettings() {
const hasActive = vm.active !== null;
return (
-
+
{vm.error && (
- The embedder change takes effect at the next app start.
+ {tr("Le changement d'embedder prendra effet au prochain démarrage de l'application.", "The embedder change takes effect at the next app start.")}
{/* ── Active tier (current) ── */}
- Current:{" "}
+ {tr("Actuel :", "Current:")}{" "}
{hasActive ? (
Vector — {vm.active!.strategy}
{vm.active!.model ? ` ${vm.active!.model}` : ""}
) : (
- Naïve (None)
+ {tr("Naïve (aucun)", "Naive (none)")}
)}
{/* ── Strategy chooser ── */}
-