/** * `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 { TurnSource, TurnView } from "@/domain"; import { Button, Spinner, cn } from "@/shared"; import { useProjectWorkState } from "@/features/workstate"; import { useConversationThread } from "./useConversationThread"; export interface ConversationViewerProps { projectId: string; conversationId: string; onClose: () => 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 formatTime(atMs: number): string { if (!Number.isFinite(atMs) || atMs <= 0) return ""; try { return new Date(atMs).toLocaleString(); } catch { return ""; } } function roleLabel(role: TurnView["role"]): string { switch (role) { case "prompt": return "Prompt"; case "response": return "Response"; case "toolActivity": return "Tool"; } } /** One collapsible tool-activity turn (dimmed, folded by default). */ function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) { const [open, setOpen] = useState(false); return (
{open && (
            {turn.text}
          
)}
); } 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, }: ConversationViewerProps) { 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(); 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(); 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" ? "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]); 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]); function sideOf(source: TurnSource): "left" | "right" { return parties.meta.get(partyKey(source))?.side ?? "left"; } return (
{/* ── Header ── */}
{title} {shortId(conversationId)}
{/* ── Error ── */} {vm.error && (

{vm.error}

)} {/* ── Thread ── */}
{vm.hasMore && (
)} {vm.busy && vm.turns.length === 0 ? (
Chargement du fil…
) : vm.turns.length === 0 ? (

Aucun tour pour le moment.

) : ( vm.turns.map((turn) => { const side = sideOf(turn.source); const sideClass = side === "right" ? "items-end" : "items-start"; if (turn.role === "toolActivity") { return ( ); } const party = parties.meta.get(partyKey(turn.source)); const time = formatTime(turn.atMs); return (
{party?.label ?? "?"} · {roleLabel(turn.role)} {time && · {time}}

{turn.text}

); }) )}
); }