Frontend pur, lecture seule, consomme read_conversation_page (LS6) ; aucun changement backend. - adapters : conversation.ts (gateway) + conversationNormalization.ts. - features/conversations : useConversationThread, ConversationViewer, index. - domain (types LS7), ports (port + Gateways.conversation), adapters/index, mock (+ clé inventaire). - features/workstate : ProjectWorkStatePanel prop onOpenConversation. - features/projects : ProjectsView swap viewer. - tests (QA, verts) : conversationNormalization, mock/conversationGateway, useConversationThread, ConversationViewer, ProjectsView.ls7. tsc 0, vitest 443/443 (+36). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
233 lines
7.6 KiB
TypeScript
233 lines
7.6 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 { 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 (
|
|
<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>
|
|
Tool activity
|
|
<span className="text-faint">· {turn.textLen} chars</span>
|
|
</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,
|
|
}: 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<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"
|
|
? "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 (
|
|
<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}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={onClose}>
|
|
← Retour aux terminaux
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Error ── */}
|
|
{vm.error && (
|
|
<p
|
|
role="alert"
|
|
className="mx-4 mt-2 shrink-0 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
|
>
|
|
{vm.error}
|
|
</p>
|
|
)}
|
|
|
|
{/* ── 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}
|
|
>
|
|
Charger les tours précédents
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{vm.busy && vm.turns.length === 0 ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted">
|
|
<Spinner size={14} />
|
|
<span>Chargement du fil…</span>
|
|
</div>
|
|
) : vm.turns.length === 0 ? (
|
|
<p className="text-sm text-muted">Aucun tour pour le moment.</p>
|
|
) : (
|
|
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));
|
|
const time = formatTime(turn.atMs);
|
|
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)}</span>
|
|
{time && <span className="text-faint">· {time}</span>}
|
|
</div>
|
|
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|