Files
IdeA/frontend/src/features/conversations/ConversationViewer.tsx
Blomios 1a1429f10e 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.
2026-09-11 21:53:56 +02:00

378 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* `ConversationViewer` — human, read-only transcript of one conversation pair
* (LS7). Sober by directive: one bubble per turn, full text, oldest→newest, with
* a discreet User↔Agent / Agent↔Agent distinction (two neutral tints, no
* avatars). Tool activity is dimmed and collapsed by default.
*
* Cold human surface: it lives behind a click in the Work panel and swaps the
* terminal grid in local state — zero impact on the agent hot path.
*/
import { useMemo, useState } from "react";
import type {
AgentWorkState,
BackgroundCompletion,
ConversationWorkSummary,
ProjectWorkState,
TurnSource,
TurnView,
} from "@/domain";
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;
conversationId: string;
onClose: () => void;
onOpenConversation?: (conversationId: string) => void;
}
function shortId(id: string): string {
return id.length <= 8 ? id : id.slice(0, 8);
}
/** Stable key for a turn's party (the conversation has at most two). */
function partyKey(source: TurnSource): string {
return source.kind === "human" ? "human" : `agent:${source.agentId}`;
}
function roleLabel(role: TurnView["role"], tr: Translator): string {
switch (role) {
case "prompt":
return tr("Prompt", "Prompt");
case "response":
return tr("Réponse", "Response");
case "toolActivity":
return tr("Outil", "Tool");
}
}
interface ConversationActivity {
conversationId: string;
label: string;
priority: number;
}
interface EmptyThreadState {
message: string;
detail?: string;
action?: {
conversationId: string;
label: string;
};
}
function taskIsActive(status: BackgroundCompletion["status"]): boolean {
return status === "running" || status === "pending" || status === "cancelling";
}
function agentLabel(agent: AgentWorkState): string {
return agent.name.trim() || `Agent ${shortId(agent.agentId)}`;
}
function summaryHasVisibleContent(summary: ConversationWorkSummary): boolean {
return Boolean(
summary.objectivePreview ||
summary.summaryPreview ||
(summary.recentTurns ?? []).length > 0,
);
}
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 || tr("ticket actif", "active ticket")}`,
priority: ticket.status === "inProgress" ? 0 : 1,
});
}
for (const task of agent.backgroundTasks ?? []) {
if (!task.conversationId || !taskIsActive(task.status)) continue;
out.push({
conversationId: task.conversationId,
label: `${agentLabel(agent)} · ${task.kind}`,
priority: 2,
});
}
}
return out.sort(
(a, b) =>
a.priority - b.priority ||
a.conversationId.localeCompare(b.conversationId),
);
}
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, tr);
const currentActivity = activities.find(
(activity) => activity.conversationId === conversationId,
);
if (currentActivity) {
return {
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: 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."),
};
}
const otherActivity = activities.find(
(activity) => activity.conversationId !== conversationId,
);
if (otherActivity) {
return {
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: tr("Ouvrir le fil actif", "Open active thread"),
},
};
}
const busyAgent = (state?.agents ?? []).find(
(agent) => agent.busy?.state === "busy",
);
if (busyAgent) {
return {
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: 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)}>
<div className="max-w-[80%] rounded-md border border-border bg-surface/60 px-3 py-1.5 text-xs text-muted">
<button
type="button"
className="flex items-center gap-1.5 font-medium hover:text-content"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span aria-hidden>{open ? "▾" : "▸"}</span>
{tr("Activité d'outil", "Tool activity")}
<span className="text-faint">· {turn.textLen} {tr("car.", "chars")}</span>
<EntryTimestamp atMs={turn.atMs} />
</button>
{open && (
<pre className="mt-1.5 whitespace-pre-wrap break-words font-mono text-[11px] text-muted">
{turn.text}
</pre>
)}
</div>
</div>
);
}
interface PartyMeta {
/** "User" or the resolved agent name / short id. */
label: string;
/** Visual side: first party seen is left, second is right. */
side: "left" | "right";
}
export function ConversationViewer({
projectId,
conversationId,
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);
const agentNames = useMemo(() => {
const map = new Map<string, string>();
for (const agent of work.state?.agents ?? []) {
if (agent.name.trim()) map.set(agent.agentId, agent.name.trim());
}
return map;
}, [work.state]);
// Derive the (at most two) parties from the turns, in first-appearance order.
const parties = useMemo(() => {
const order: string[] = [];
const meta = new Map<string, PartyMeta>();
for (const turn of vm.turns) {
const key = partyKey(turn.source);
if (meta.has(key)) continue;
const side: PartyMeta["side"] = order.length === 0 ? "left" : "right";
const label =
turn.source.kind === "human"
? 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, 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 tr("Conversation", "Conversation");
}, [parties, tr]);
const emptyState = useMemo(
() => emptyThreadState(conversationId, work.state, tr),
[conversationId, work.state, tr],
);
function sideOf(source: TurnSource): "left" | "right" {
return parties.meta.get(partyKey(source))?.side ?? "left";
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* ── Header ── */}
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border bg-surface px-4 py-2">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-content">
{title}
</span>
<code
className="truncate text-xs text-muted"
title={conversationId}
>
{shortId(conversationId)}
</code>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => void vm.refreshTail()}
loading={vm.busy}
>
{tr("Rafraîchir", "Refresh")}
</Button>
<Button size="sm" variant="ghost" onClick={onClose}>
{tr("← Retour aux terminaux", "← Back to terminals")}
</Button>
</div>
</div>
{/* ── Error ── */}
{vm.error && (
<div
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>{tr("Impossible de recharger la conversation.", "Unable to reload the conversation.")}</span>
<Button
size="sm"
variant="ghost"
onClick={() => void vm.refreshTail()}
loading={vm.busy}
>
{tr("Réessayer", "Retry")}
</Button>
</div>
)}
{/* ── Thread ── */}
<div className="flex flex-1 flex-col gap-2 overflow-auto px-4 py-3">
{vm.hasMore && (
<div className="flex justify-center">
<Button
size="sm"
variant="ghost"
onClick={() => void vm.loadOlder()}
loading={vm.busy}
>
{tr("Charger les tours précédents", "Load previous turns")}
</Button>
</div>
)}
{vm.busy && vm.turns.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>{tr("Chargement du fil…", "Loading thread…")}</span>
</div>
) : vm.turns.length === 0 ? (
<div className="max-w-xl text-sm text-muted">
<p>{emptyState.message}</p>
{emptyState.detail && (
<p className="mt-1 text-xs text-faint">{emptyState.detail}</p>
)}
{emptyState.action && onOpenConversation && (
<Button
size="sm"
variant="ghost"
className="mt-2"
onClick={() =>
onOpenConversation(emptyState.action!.conversationId)
}
>
{emptyState.action.label}
</Button>
)}
</div>
) : (
vm.turns.map((turn) => {
const side = sideOf(turn.source);
const sideClass = side === "right" ? "items-end" : "items-start";
if (turn.role === "toolActivity") {
return (
<ToolTurn key={turn.id} turn={turn} sideClass={sideClass} />
);
}
const party = parties.meta.get(partyKey(turn.source));
return (
<div key={turn.id} className={cn("flex flex-col", sideClass)}>
<div
className={cn(
"max-w-[80%] rounded-lg border px-3 py-2 text-sm",
side === "right"
? "border-primary/20 bg-primary/10 text-content"
: "border-border bg-raised text-content",
)}
>
<div className="mb-1 flex items-baseline gap-2 text-xs text-muted">
<span className="font-medium text-content">
{party?.label ?? "?"}
</span>
<span>· {roleLabel(turn.role, tr)}</span>
<EntryTimestamp atMs={turn.atMs} />
</div>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
</div>
</div>
);
})
)}
</div>
</div>
);
}