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