feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)

Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
This commit is contained in:
2026-09-11 21:53:56 +02:00
parent cd744b5f18
commit 1a1429f10e
157 changed files with 4531 additions and 3129 deletions

View File

@ -124,7 +124,7 @@ describe("AgentLimitBadge (render)", () => {
const expected = `limité jusqu'à ${formatResetTime(at)}`;
expect(screen.getByText(expected)).toBeTruthy();
// No countdown / cancel button without an armed resume.
expect(screen.queryByLabelText("cancel resume")).toBeNull();
expect(screen.queryByLabelText("annuler la reprise")).toBeNull();
});
it("shows plain 'limité' when no reset time is known", () => {
@ -145,8 +145,8 @@ describe("AgentLimitBadge (render)", () => {
expect(screen.getByText("limité")).toBeTruthy();
expect(screen.getByText(/heure inconnue/)).toBeTruthy();
// The human-net form: a resume-time input + a "schedule resume" button.
expect(screen.getByLabelText("resume time")).toBeTruthy();
expect(screen.getByLabelText("schedule resume")).toBeTruthy();
expect(screen.getByLabelText("heure de reprise")).toBeTruthy();
expect(screen.getByLabelText("programmer la reprise")).toBeTruthy();
});
it("does NOT show the resume-time form when a suspected limit has a time", () => {
@ -160,7 +160,7 @@ describe("AgentLimitBadge (render)", () => {
);
expect(screen.getByText(`limité jusqu'à ${formatResetTime(at)}`)).toBeTruthy();
expect(screen.queryByText(/heure inconnue/)).toBeNull();
expect(screen.queryByLabelText("resume time")).toBeNull();
expect(screen.queryByLabelText("heure de reprise")).toBeNull();
});
it("submitting the resume-time form arms a resume at the chosen epoch-ms", () => {
@ -172,10 +172,10 @@ describe("AgentLimitBadge (render)", () => {
onSetResumeAt={onSetResumeAt}
/>,
);
fireEvent.change(screen.getByLabelText("resume time"), {
fireEvent.change(screen.getByLabelText("heure de reprise"), {
target: { value: "23:45" },
});
fireEvent.click(screen.getByLabelText("schedule resume"));
fireEvent.click(screen.getByLabelText("programmer la reprise"));
expect(onSetResumeAt).toHaveBeenCalledTimes(1);
const ms = onSetResumeAt.mock.calls[0][0] as number;
const d = new Date(ms);
@ -193,7 +193,7 @@ describe("AgentLimitBadge (render)", () => {
/>,
);
expect(
(screen.getByLabelText("schedule resume") as HTMLButtonElement).disabled,
(screen.getByLabelText("programmer la reprise") as HTMLButtonElement).disabled,
).toBe(true);
expect(onSetResumeAt).not.toHaveBeenCalled();
});
@ -209,11 +209,11 @@ describe("AgentLimitBadge (render)", () => {
/>,
);
// Countdown label present (≈ "reprise dans 1m 30s").
expect(screen.getByLabelText("resume countdown").textContent).toMatch(
expect(screen.getByLabelText("compte à rebours de reprise").textContent).toMatch(
/reprise dans/,
);
const btn = screen.getByLabelText("cancel resume");
const btn = screen.getByLabelText("annuler la reprise");
fireEvent.click(btn);
expect(onCancelResume).toHaveBeenCalledTimes(1);
});
@ -228,7 +228,7 @@ describe("AgentLimitBadge (render)", () => {
/>,
);
expect(
(screen.getByLabelText("cancel resume") as HTMLButtonElement).disabled,
(screen.getByLabelText("annuler la reprise") as HTMLButtonElement).disabled,
).toBe(true);
});
});

View File

@ -14,6 +14,7 @@
import { useEffect, useState } from "react";
import { Button, Input, cn } from "@/shared";
import { useI18n } from "@/i18n";
import type { AgentLimitState } from "./useAgents";
/**
@ -75,6 +76,7 @@ export function AgentLimitBadge({
onSetResumeAt,
busy = false,
}: AgentLimitBadgeProps) {
const { tr } = useI18n();
const { limitedUntil, resumeFireAt, suspected } = state;
// Tick a local clock once per second only while a resume countdown is armed,
@ -106,17 +108,17 @@ export function AgentLimitBadge({
const limitLabel =
limitedUntil !== undefined
? `limité jusqu'à ${formatResetTime(limitedUntil)}`
: "limité";
? tr(`limité jusqu'à ${formatResetTime(limitedUntil)}`, `limited until ${formatResetTime(limitedUntil)}`)
: tr("limité", "limited");
return (
<span className="flex min-w-0 flex-wrap items-center gap-2">
<span
aria-label="session limit"
aria-label={tr("limite de session", "session limit")}
title={
suspected
? "Limite de session détectée sans heure fiable"
: "Limite de session atteinte"
? tr("Limite de session détectée sans heure fiable", "Session limit detected without a reliable time")
: tr("Limite de session atteinte", "Session limit reached")
}
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
@ -134,14 +136,14 @@ export function AgentLimitBadge({
<form
onSubmit={handleSubmitResumeTime}
className="flex items-center gap-2"
aria-label="resume time form"
aria-label={tr("formulaire d'heure de reprise", "resume time form")}
>
<span className="text-xs text-muted" role="note">
heure inconnue
{tr("heure inconnue", "unknown time")}
</span>
<Input
type="time"
aria-label="resume time"
aria-label={tr("heure de reprise", "resume time")}
value={resumeTime}
disabled={busy}
onChange={(e) => setResumeTime(e.target.value)}
@ -151,27 +153,27 @@ export function AgentLimitBadge({
type="submit"
size="sm"
variant="primary"
aria-label="schedule resume"
aria-label={tr("programmer la reprise", "schedule resume")}
disabled={busy || resumeTime.trim() === ""}
>
Programmer la reprise
{tr("Programmer la reprise", "Schedule resume")}
</Button>
</form>
)}
{resumeFireAt !== undefined && (
<span className="flex items-center gap-2">
<span aria-label="resume countdown" className="text-xs text-muted">
reprise dans {formatCountdown(resumeFireAt - now)}
<span aria-label={tr("compte à rebours de reprise", "resume countdown")} className="text-xs text-muted">
{tr("reprise dans", "resuming in")} {formatCountdown(resumeFireAt - now)}
</span>
<Button
size="sm"
variant="ghost"
aria-label="cancel resume"
aria-label={tr("annuler la reprise", "cancel resume")}
disabled={busy}
onClick={onCancelResume}
>
Annuler la reprise
{tr("Annuler la reprise", "Cancel resume")}
</Button>
</span>
)}

View File

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

View File

@ -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(<DIProvider gateways={{ agent, project, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId="chat-1" conversationId="conversation-1" onSessionId={vi.fn()} onConversationId={vi.fn()} />
</DIProvider>);
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", () => {
</DIProvider>,
);
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", () => {
</DIProvider>,
);
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", () => {
</DIProvider>,
);
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", () => {
</DIProvider>,
);
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(<Harness />);
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", () => {
</DIProvider>,
);
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", () => {
</DIProvider>,
);
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", () => {
</DIProvider>,
);
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();

View File

@ -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<ChatErrorState | null>(null);
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const chatRootRef = useRef<HTMLDivElement | null>(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 && (
<div data-testid="project-icon-watermark" aria-hidden className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center overflow-hidden text-content opacity-[0.035]" style={{ pointerEvents: "none" }}>
<ProjectIcon icon={projectIcon} size={260} decorative />
</div>
)}
<div
role="toolbar"
aria-label="custom agent chat actions"
aria-label={t("custom.actions")}
className="relative z-20 flex shrink-0 items-center gap-2 overflow-hidden border-b border-border px-3 py-2"
>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="truncate text-sm font-medium">{agentName}</div>
<div className="truncate text-xs text-muted">
CLI custom · {profile.name}
{tr("CLI personnalisée", "Custom CLI")} · {profile.name}
</div>
</div>
</div>
{!supported && (
<p role="alert" className="m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
CLI custom indisponible pour ce profil ou ce transport. Utilisez la TUI native.
{t("custom.unsupported")}
</p>
)}
{error && (
@ -2438,10 +2459,12 @@ export function CustomAgentChatView({
>
{error.openFailure ? (
<div className="flex min-w-0 flex-col gap-2">
<div className="font-medium">Le CLI custom n'a pas pu démarrer.</div>
<div className="break-words">
Cause: {error.message}
</div>
<div className="font-medium">{t("custom.startFailure")}</div>
{(!mobile || !/invalid node id/i.test(error.message)) && (
<div className="break-words">
{tr("Cause", "Cause")}: {error.message}
</div>
)}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
@ -2449,7 +2472,7 @@ export function CustomAgentChatView({
disabled={opening}
onClick={() => setOpenRetryNonce((value) => value + 1)}
>
Réessayer
{t("common.retry")}
</Button>
{onOpenNativeTui && (
<Button
@ -2457,7 +2480,7 @@ export function CustomAgentChatView({
variant="secondary"
onClick={onOpenNativeTui}
>
Ouvrir en TUI native
{t("custom.openTui")}
</Button>
)}
</div>
@ -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")}
</h2>
<p className="mt-1 text-xs text-muted">
Valider ce changement va réinitialiser la session courante. L'historique de cette CLI custom sera perdu.
{t("custom.profile.warning")}
</p>
</div>
<Button
@ -2497,14 +2520,14 @@ export function CustomAgentChatView({
disabled={profileDialog.busy}
onClick={() => setProfileDialog(null)}
>
Fermer
{t("common.close")}
</Button>
</div>
<div className="mt-3 flex min-w-0 flex-col gap-2">
<label className="flex min-w-0 flex-col gap-1 text-xs text-muted">
Profil
{t("custom.profile")}
<select
aria-label="profil cible"
aria-label={tr("Profil cible", "Target profile")}
value={profileDialog.selectedProfileId}
disabled={profileDialog.busy}
className="rounded-md border border-border bg-raised px-2 py-1 text-sm text-content"
@ -2538,7 +2561,7 @@ export function CustomAgentChatView({
)
}
/>
<span>Je confirme que la session courante sera reset.</span>
<span>{tr("Je confirme que la session courante sera réinitialisée.", "I confirm that the current session will be reset.")}</span>
</label>
{profileDialog.error && (
<p role="alert" className="text-xs text-danger">
@ -2552,7 +2575,7 @@ export function CustomAgentChatView({
disabled={profileDialog.busy}
onClick={() => setProfileDialog(null)}
>
Annuler
{tr("Annuler", "Cancel")}
</Button>
<Button
size="sm"
@ -2561,7 +2584,7 @@ export function CustomAgentChatView({
loading={profileDialog.busy}
onClick={() => void confirmProfileCommandChange()}
>
Reset et changer le profil
{tr("Réinitialiser et changer le profil", "Reset and change profile")}
</Button>
</div>
</div>
@ -2584,18 +2607,18 @@ export function CustomAgentChatView({
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
{externalSessionId ? t("custom.resume") : t("custom.opening")}
</span>
</div>
) : unavailableLinkedTranscript ? (
<div className="m-auto flex max-w-md flex-col items-center gap-3 rounded-md border border-warning/40 bg-warning/10 p-4 text-center">
<p className="text-sm font-medium text-content">
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.")}
</p>
<p className="text-xs text-muted">
{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.")}
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button
@ -2604,16 +2627,16 @@ export function CustomAgentChatView({
disabled={opening}
onClick={retryUnavailableTranscript}
>
Réessayer
{t("common.retry")}
</Button>
<Button size="sm" variant="secondary" onClick={unlinkUnavailableConversation}>
Délier la conversation
{tr("Délier la conversation", "Unlink conversation")}
</Button>
</div>
</div>
) : displayTurns.length === 0 ? (
<p className="m-auto text-xs text-muted">
Envoyez un message pour démarrer la conversation structurée.
{t("custom.empty")}
</p>
) : (
displayTurns.map((turn, index) => (
@ -2661,14 +2684,14 @@ export function CustomAgentChatView({
className="h-8 w-8 shrink-0 rounded border border-border object-cover"
/>
)}
<span className="min-w-0 truncate">Fichier joint: {item.label}</span>
<span className="min-w-0 truncate">{t("custom.attachment")}: {item.label}</span>
<button
type="button"
aria-label={`retirer ${item.label}`}
aria-label={tr(`Retirer ${item.label}`, `Remove ${item.label}`)}
className="shrink-0 text-content"
onClick={() => removeAttachment(item)}
>
Retirer
{t("custom.removeAttachment")}
</button>
</div>
))}
@ -2678,7 +2701,7 @@ export function CustomAgentChatView({
<div
id={`slash-command-menu-${domId}`}
role="listbox"
aria-label="suggestions commandes slash"
aria-label={t("custom.slashSuggestions")}
className="max-h-48 overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-lg"
>
{slashCommands.map((command, index) => {
@ -2705,7 +2728,7 @@ export function CustomAgentChatView({
{command.shortDescription}
</span>
{command.requiresConfirmation && (
<span className="shrink-0 text-muted">confirm</span>
<span className="shrink-0 text-muted">{tr("confirmation", "confirm")}</span>
)}
</button>
);
@ -2721,7 +2744,7 @@ export function CustomAgentChatView({
<div className="relative flex min-w-0 flex-1">
<textarea
ref={composerRef}
aria-label={`message CLI custom ${domId}`}
aria-label={`${tr("Message CLI personnalisée", "Custom CLI message")} ${domId}`}
aria-controls={slashMenuOpen ? `slash-command-menu-${domId}` : undefined}
aria-activedescendant={
slashMenuOpen ? `slash-command-${domId}-${slashActiveIndex}` : undefined
@ -2733,7 +2756,7 @@ export function CustomAgentChatView({
rows={2}
value={draft}
disabled={!supported || opening || effectiveBusy}
placeholder="Message à l'agent…"
placeholder={t("custom.message")}
style={{ height: clampComposerHeight(composerHeightPx) }}
onChange={(e) => editDraft(e.target.value)}
onPaste={(e) => void pasteClipboardFiles(e)}
@ -2772,8 +2795,8 @@ export function CustomAgentChatView({
/>
<button
type="button"
aria-label={`redimensionner le composer ${domId}`}
title="Redimensionner le composer"
aria-label={`${tr("Redimensionner l’éditeur", "Resize composer")} ${domId}`}
title={t("custom.resize")}
className="absolute right-1.5 top-1.5 h-4 w-4 rounded-sm border border-border bg-raised text-[9px] leading-3 text-muted hover:text-content focus:border-primary focus:outline-none"
style={{ cursor: "ns-resize" }}
onMouseDown={startComposerResize}
@ -2793,7 +2816,7 @@ export function CustomAgentChatView({
disabled={!supported || opening || effectiveBusy}
onClick={() => void pickAttachment()}
>
Joindre
{t("custom.attach")}
</Button>
<Button
size="sm"
@ -2805,11 +2828,11 @@ export function CustomAgentChatView({
: !canSend
}
loading={stopping}
title={effectiveBusy ? "Arrêter la demande en cours" : undefined}
aria-label={effectiveBusy ? "Stop" : undefined}
title={effectiveBusy ? t("custom.stop") : undefined}
aria-label={effectiveBusy ? t("custom.stop") : undefined}
onClick={() => (effectiveBusy ? void stopCurrentRequest() : void send())}
>
{stopping ? "Stopping..." : effectiveBusy ? "Stop" : "Envoyer"}
{stopping ? t("custom.stopping") : effectiveBusy ? t("custom.stop") : t("custom.send")}
</Button>
</div>
</div>
@ -2830,6 +2853,7 @@ function ChatBubble({
expandedProgressKeys: Set<string>;
onToggleProgressExpanded: (key: string) => void;
}) {
const { tr } = useI18n();
if (turn.role === "restart") {
return (
<div
@ -2851,7 +2875,7 @@ function ChatBubble({
return (
<div className="flex min-w-0 justify-center">
<span className="flex max-w-[80%] items-baseline gap-2 truncate rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted">
<span className="truncate">{turn.label}</span>
<span className="truncate">{turn.label === "Activité" ? tr("Activité", "Activity") : turn.label}</span>
<EntryTimestamp atMs={turn.receivedAtMs} />
</span>
</div>
@ -2874,7 +2898,7 @@ function ChatBubble({
<div className="flex min-w-0 justify-end">
<div className="min-w-0 max-w-[82%] overflow-hidden rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
<ChatMessageMeta
label="Terminé"
label={tr("Terminé", "Completed")}
receivedAtMs={turn.receivedAtMs}
labelClassName="font-semibold text-success"
/>
@ -2889,7 +2913,7 @@ function ChatBubble({
<div className="min-w-0 max-w-[86%] overflow-hidden rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">
{turn.role === "error" && (
<ChatMessageMeta
label="Échec"
label={tr("Échec", "Failed")}
receivedAtMs={turn.receivedAtMs}
labelClassName="font-semibold text-danger"
/>
@ -2897,8 +2921,8 @@ function ChatBubble({
{turn.role === "error" && sessionLabel && (
<p className="mb-1 font-medium text-danger/85">{sessionLabel}</p>
)}
{turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
<span className="whitespace-pre-wrap break-words">{turn.text}</span>
{turn.role === "unknown" ? tr("Fragment inconnu reçu : ", "Unknown chunk received: ") : ""}
<span className="whitespace-pre-wrap break-words">{turn.text === "Erreur agent" ? tr("Erreur agent", "Agent error") : turn.text}</span>
</div>
</div>
);
@ -2914,7 +2938,7 @@ function ChatBubble({
)}
>
<ChatMessageMeta
label={user ? "Vous" : requesterName}
label={user ? tr("Vous", "You") : requesterName}
receivedAtMs={turn.receivedAtMs}
/>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
@ -2922,7 +2946,7 @@ function ChatBubble({
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
{turn.attachments.map((attachment) => (
<p key={attachment} className="truncate">
Fichier: {attachment}
{tr("Fichier", "File")}: {attachment}
</p>
))}
</div>
@ -2930,7 +2954,7 @@ function ChatBubble({
{!user && turn.pending && (
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">
<Spinner size={12} />
Réponse en cours
{tr("Réponse en cours", "Reply in progress")}
</span>
)}
</div>
@ -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<HTMLParagraphElement>(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")}
</button>
)}
</div>
@ -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 (
<div data-testid="chat-turn-marker" className="flex min-w-0 justify-center">
<div className="flex max-w-[72%] items-center gap-2 overflow-hidden text-[11px] text-muted">
@ -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 (
<div data-testid="chat-tool-progress" className="flex min-w-0 justify-center">
<div
@ -3233,9 +3262,9 @@ function ToolProgressCard({
)}
{(progress.source || progress.nativeEvent || progress.provider) && (
<div className="mt-0.5 flex min-w-0 flex-wrap justify-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
<span>{progressSourceLabel(progress)}</span>
<span>{progressSourceLabel(progress, tr)}</span>
{progress.nativeEvent && (
<span className="truncate">event: {progress.nativeEvent}</span>
<span className="truncate">{tr("événement", "event")}: {progress.nativeEvent}</span>
)}
</div>
)}
@ -3266,6 +3295,7 @@ function ProgressBubble({
expanded: boolean;
onToggleExpanded: () => void;
}) {
const { tr } = useI18n();
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
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 (
<div data-testid="chat-progress-status" className="flex min-w-0 justify-end">
@ -3299,7 +3329,7 @@ function ProgressBubble({
<div className="mt-0.5 flex min-w-0 flex-wrap justify-end gap-x-2 gap-y-0.5 text-[11px] text-muted">
<span>{source}</span>
{progress.nativeEvent && (
<span className="truncate">event: {progress.nativeEvent}</span>
<span className="truncate">{tr("événement", "event")}: {progress.nativeEvent}</span>
)}
</div>
{detail && (

View File

@ -27,11 +27,11 @@ describe("ModelServerLaunchBadge (F35.1)", () => {
const { rerender } = render(
<ModelServerLaunchBadge status={{ state: "probing" }} />,
);
expect(screen.getByLabelText("model server status").textContent).toMatch(
expect(screen.getByLabelText("état du serveur de modèle").textContent).toMatch(
/vérification/i,
);
rerender(<ModelServerLaunchBadge status={{ state: "starting" }} />);
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(
<ModelServerLaunchBadge status={{ state: "ready", reused: true }} />,
);
expect(screen.getByLabelText("model server status").textContent).toMatch(
expect(screen.getByLabelText("état du serveur de modèle").textContent).toMatch(
/réutilisé/i,
);
rerender(
<ModelServerLaunchBadge status={{ state: "ready", reused: false }} />,
);
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/);
});

View File

@ -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({
<span className="flex min-w-0 flex-wrap items-center gap-2">
<span
role="alert"
aria-label="model server launch failed"
aria-label={tr("échec du lancement du serveur de modèle", "model server launch failed")}
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
"bg-danger/20 text-danger",
)}
>
Échec du lancement : {failed.message}
{tr("Échec du lancement :", "Launch failed:")} {failed.message}
</span>
<button
type="button"
aria-label="toggle launch failure detail"
aria-label={tr("afficher ou masquer le détail de l'échec", "toggle launch failure detail")}
onClick={() => setShowDetail((v) => !v)}
className="text-xs text-muted underline hover:text-content"
>
Détails
{tr("Détails", "Details")}
</button>
{showDetail && (
<span aria-label="launch failure detail" className="text-xs text-faint">
code&nbsp;{failed.code}
{failed.cause ? ` · cause ${failed.cause}` : ""}
<span aria-label={tr("détail de l'échec du lancement", "launch failure detail")} className="text-xs text-faint">
{tr("code", "code")}&nbsp;{failed.code}
{failed.cause ? ` · ${tr("cause", "cause")} ${failed.cause}` : ""}
</span>
)}
</span>
@ -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 (
<span
role={described.role}
aria-label="model server status"
aria-label={tr("état du serveur de modèle", "model server status")}
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
described.tone,

View File

@ -17,6 +17,7 @@
*/
import { Button, cn } from "@/shared";
import { useI18n } from "@/i18n";
import { useResumeProject } from "./useResumeProject";
export interface ResumeProjectPanelProps {
@ -27,6 +28,7 @@ export interface ResumeProjectPanelProps {
}
export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps) {
const { tr } = useI18n();
const vm = useResumeProject(projectId, cwd);
// No resumable agents ⇒ no panel at all.
@ -36,17 +38,16 @@ export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps)
<div
role="dialog"
aria-modal="true"
aria-label="Reprise des agents"
aria-label={tr("Reprise des agents", "Resume agents")}
data-testid="resume-project-panel"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
>
<div className="flex max-h-[80vh] w-full max-w-lg flex-col rounded-lg border border-border bg-surface p-5 shadow-xl">
<h4 className="text-sm font-semibold text-content">
Reprendre les agents&nbsp;?
{tr("Reprendre les agents ?", "Resume agents?")}
</h4>
<p className="mt-1 text-sm text-muted">
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.")}
</p>
{vm.error && (
@ -61,7 +62,7 @@ export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps)
{/* ── Per-agent rows ── */}
<ul className="mt-4 flex flex-col gap-2 overflow-auto">
{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 (
<li
key={a.agentId}
@ -90,7 +91,7 @@ export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps)
data-testid={`resume-fresh-note-${a.agentId}`}
className="text-xs text-muted"
>
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.")}
</p>
)}
@ -98,28 +99,28 @@ export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps)
<Button
size="sm"
variant="primary"
aria-label={`resume ${a.name}`}
aria-label={tr(`reprendre ${a.name}`, `resume ${a.name}`)}
disabled={vm.busy}
onClick={() => void vm.resume(a)}
>
{a.resumeSupported ? "Reprendre" : "Relancer à neuf"}
{a.resumeSupported ? tr("Reprendre", "Resume") : tr("Relancer à neuf", "Start fresh")}
</Button>
<Button
size="sm"
aria-label={`new conversation ${a.name}`}
aria-label={tr(`nouvelle conversation ${a.name}`, `new conversation ${a.name}`)}
disabled={vm.busy}
onClick={() => void vm.newConversation(a)}
>
Nouvelle conversation
{tr("Nouvelle conversation", "New conversation")}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`ignore ${a.name}`}
aria-label={tr(`ignorer ${a.name}`, `ignore ${a.name}`)}
disabled={vm.busy}
onClick={() => vm.ignore(a.agentId)}
>
Ignorer
{tr("Ignorer", "Ignore")}
</Button>
</div>
</li>
@ -131,20 +132,20 @@ export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps)
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
aria-label="ignore all agents"
aria-label={tr("ignorer tous les agents", "ignore all agents")}
disabled={vm.busy}
onClick={() => vm.ignoreAll()}
>
Tout ignorer
{tr("Tout ignorer", "Ignore all")}
</Button>
<Button
variant="primary"
aria-label="resume all agents"
aria-label={tr("reprendre tous les agents", "resume all agents")}
loading={vm.busy}
disabled={vm.busy}
onClick={() => void vm.resumeAll()}
>
Tout reprendre
{tr("Tout reprendre", "Resume all")}
</Button>
</div>
</div>

View File

@ -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();
});
});

View File

@ -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(),