/**
* `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 (