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

@ -21,6 +21,9 @@ import type {
import { Button, EntryTimestamp, Spinner, cn } from "@/shared";
import { useProjectWorkState } from "@/features/workstate";
import { useConversationThread } from "./useConversationThread";
import { useI18n } from "@/i18n";
type Translator = (french: string, english: string) => string;
export interface ConversationViewerProps {
projectId: string;
@ -38,14 +41,14 @@ function partyKey(source: TurnSource): string {
return source.kind === "human" ? "human" : `agent:${source.agentId}`;
}
function roleLabel(role: TurnView["role"]): string {
function roleLabel(role: TurnView["role"], tr: Translator): string {
switch (role) {
case "prompt":
return "Prompt";
return tr("Prompt", "Prompt");
case "response":
return "Response";
return tr("Réponse", "Response");
case "toolActivity":
return "Tool";
return tr("Outil", "Tool");
}
}
@ -80,13 +83,13 @@ function summaryHasVisibleContent(summary: ConversationWorkSummary): boolean {
);
}
function activeConversations(state: ProjectWorkState | null): ConversationActivity[] {
function activeConversations(state: ProjectWorkState | null, tr: Translator): ConversationActivity[] {
const out: ConversationActivity[] = [];
for (const agent of state?.agents ?? []) {
for (const ticket of agent.tickets ?? []) {
out.push({
conversationId: ticket.conversationId,
label: `${agentLabel(agent)} · ${ticket.taskPreview || "ticket actif"}`,
label: `${agentLabel(agent)} · ${ticket.taskPreview || tr("ticket actif", "active ticket")}`,
priority: ticket.status === "inProgress" ? 0 : 1,
});
}
@ -109,27 +112,26 @@ function activeConversations(state: ProjectWorkState | null): ConversationActivi
function emptyThreadState(
conversationId: string,
state: ProjectWorkState | null,
tr: Translator,
): EmptyThreadState {
const summaries = state?.conversations ?? [];
const currentSummary = summaries.find(
(summary) => summary.conversationId === conversationId,
);
const activities = activeConversations(state);
const activities = activeConversations(state, tr);
const currentActivity = activities.find(
(activity) => activity.conversationId === conversationId,
);
if (currentActivity) {
return {
message: "Agent actif, aucun message visible pour le moment.",
detail:
"Le fil est bien celui de l'activité en cours, mais le transcript n'a pas encore livré de tour affichable.",
message: tr("Agent actif, aucun message visible pour le moment.", "Agent active, no messages visible yet."),
detail: tr("Le fil est bien celui de l'activité en cours, mais le transcript n'a pas encore livré de tour affichable.", "This is the active thread, but its transcript has not provided a displayable turn yet."),
};
}
if (currentSummary && summaryHasVisibleContent(currentSummary)) {
return {
message: "Activité détectée, transcript pas encore synchronisé.",
detail:
"Le résumé Work state connaît ce fil, mais la lecture paginée ne renvoie encore aucun tour.",
message: tr("Activité détectée, transcript pas encore synchronisé.", "Activity detected, transcript not synchronized yet."),
detail: tr("Le résumé Travail connaît ce fil, mais la lecture paginée ne renvoie encore aucun tour.", "The Work summary knows this thread, but paginated reading does not return any turns yet."),
};
}
@ -138,11 +140,11 @@ function emptyThreadState(
);
if (otherActivity) {
return {
message: "Aucun tour sur ce fil.",
detail: `L'activité en cours semble être sur un autre fil: ${otherActivity.label}.`,
message: tr("Aucun tour sur ce fil.", "No turns in this thread."),
detail: tr(`L'activité en cours semble être sur un autre fil : ${otherActivity.label}.`, `The current activity seems to be in another thread: ${otherActivity.label}.`),
action: {
conversationId: otherActivity.conversationId,
label: "Ouvrir le fil actif",
label: tr("Ouvrir le fil actif", "Open active thread"),
},
};
}
@ -152,17 +154,17 @@ function emptyThreadState(
);
if (busyAgent) {
return {
message: "Agent actif sans message visible.",
detail:
"Work state signale une activité, mais aucun ticket ou fil actif n'est encore associé au snapshot courant.",
message: tr("Agent actif sans message visible.", "Agent active with no visible message."),
detail: tr("L'état de travail signale une activité, mais aucun ticket ou fil actif n'est encore associé au snapshot courant.", "Work state reports activity, but no active ticket or thread is associated with the current snapshot yet."),
};
}
return { message: "Aucun tour pour le moment." };
return { message: tr("Aucun tour pour le moment.", "No turns yet.") };
}
/** One collapsible tool-activity turn (dimmed, folded by default). */
function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) {
const { tr } = useI18n();
const [open, setOpen] = useState(false);
return (
<div className={cn("flex flex-col", sideClass)}>
@ -174,8 +176,8 @@ function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) {
aria-expanded={open}
>
<span aria-hidden>{open ? "▾" : "▸"}</span>
Tool activity
<span className="text-faint">· {turn.textLen} chars</span>
{tr("Activité d'outil", "Tool activity")}
<span className="text-faint">· {turn.textLen} {tr("car.", "chars")}</span>
<EntryTimestamp atMs={turn.atMs} />
</button>
{open && (
@ -201,6 +203,7 @@ export function ConversationViewer({
onClose,
onOpenConversation,
}: ConversationViewerProps) {
const { tr } = useI18n();
const vm = useConversationThread(projectId, conversationId);
// Reuse the already-loaded work-state inventory to resolve agent names.
const work = useProjectWorkState(projectId);
@ -223,25 +226,25 @@ export function ConversationViewer({
const side: PartyMeta["side"] = order.length === 0 ? "left" : "right";
const label =
turn.source.kind === "human"
? "User"
? tr("Utilisateur", "User")
: agentNames.get(turn.source.agentId) ??
`Agent ${shortId(turn.source.agentId)}`;
meta.set(key, { label, side });
order.push(key);
}
return { order, meta };
}, [vm.turns, agentNames]);
}, [vm.turns, agentNames, tr]);
const title = useMemo(() => {
const labels = parties.order.map((k) => parties.meta.get(k)?.label ?? "?");
if (labels.length >= 2) return `${labels[0]} ↔ ${labels[1]}`;
if (labels.length === 1) return labels[0];
return "Conversation";
}, [parties]);
return tr("Conversation", "Conversation");
}, [parties, tr]);
const emptyState = useMemo(
() => emptyThreadState(conversationId, work.state),
[conversationId, work.state],
() => emptyThreadState(conversationId, work.state, tr),
[conversationId, work.state, tr],
);
function sideOf(source: TurnSource): "left" | "right" {
@ -270,10 +273,10 @@ export function ConversationViewer({
onClick={() => void vm.refreshTail()}
loading={vm.busy}
>
Refresh
{tr("Rafraîchir", "Refresh")}
</Button>
<Button size="sm" variant="ghost" onClick={onClose}>
← Retour aux terminaux
{tr("← Retour aux terminaux", "← Back to terminals")}
</Button>
</div>
</div>
@ -284,14 +287,14 @@ export function ConversationViewer({
role="alert"
className="mx-4 mt-2 flex shrink-0 items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
<span>Impossible de recharger la conversation.</span>
<span>{tr("Impossible de recharger la conversation.", "Unable to reload the conversation.")}</span>
<Button
size="sm"
variant="ghost"
onClick={() => void vm.refreshTail()}
loading={vm.busy}
>
Réessayer
{tr("Réessayer", "Retry")}
</Button>
</div>
)}
@ -306,7 +309,7 @@ export function ConversationViewer({
onClick={() => void vm.loadOlder()}
loading={vm.busy}
>
Charger les tours précédents
{tr("Charger les tours précédents", "Load previous turns")}
</Button>
</div>
)}
@ -314,7 +317,7 @@ export function ConversationViewer({
{vm.busy && vm.turns.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Chargement du fil…</span>
<span>{tr("Chargement du fil…", "Loading thread…")}</span>
</div>
) : vm.turns.length === 0 ? (
<div className="max-w-xl text-sm text-muted">
@ -359,7 +362,7 @@ export function ConversationViewer({
<span className="font-medium text-content">
{party?.label ?? "?"}
</span>
<span>· {roleLabel(turn.role)}</span>
<span>· {roleLabel(turn.role, tr)}</span>
<EntryTimestamp atMs={turn.atMs} />
</div>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>