/** * `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 (
{turn.text}
)}
{shortId(conversationId)}
{emptyState.message}
{emptyState.detail && ({emptyState.detail}
)} {emptyState.action && onOpenConversation && ( )}{turn.text}