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:
@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user