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.
378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
/**
|
||
* `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>
|
||
);
|
||
}
|