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

@ -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 && (