feat(frontend): viewer humain fil-par-paire en lecture seule (LS7)

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>
This commit is contained in:
2026-06-22 17:30:51 +02:00
parent 40ca3e522f
commit c19e375849
17 changed files with 1552 additions and 5 deletions

View File

@ -0,0 +1,175 @@
/**
* LS7 — `ConversationViewer`: chronological turns, role/source labels, header
* User↔Agent vs Agent↔Agent, collapsible tool activity, empty/error/loading
* states, scroll-up "load older" trigger, and the back button (`onClose`).
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { ReactNode } from "react";
import { DIProvider } from "@/app/di";
import { MockConversationGateway, MockWorkStateGateway } from "@/adapters/mock";
import type { ConversationGateway, Gateways } from "@/ports";
import type { TurnView } from "@/domain";
import { ConversationViewer } from "./ConversationViewer";
const P = "proj";
const C = "conv-abcdef123456";
function human(n: number, text = `humain ${n}`): TurnView {
return {
id: `t${n}`,
atMs: 1_700_000_000_000 + n,
role: "prompt",
source: { kind: "human" },
text,
textLen: text.length,
};
}
function agent(n: number, agentId: string, text = `agent ${n}`): TurnView {
return {
id: `t${n}`,
atMs: 1_700_000_000_000 + n,
role: "response",
source: { kind: "agent", agentId },
text,
textLen: text.length,
};
}
function renderViewer(opts: {
conversation: ConversationGateway;
workState?: MockWorkStateGateway;
onClose?: () => void;
}) {
const workState = opts.workState ?? new MockWorkStateGateway();
const gateways = {
conversation: opts.conversation,
workState,
} as unknown as Gateways;
const onClose = opts.onClose ?? vi.fn();
const ui = (children: ReactNode) => (
<DIProvider gateways={gateways}>{children}</DIProvider>
);
return {
onClose,
...render(
ui(
<ConversationViewer
projectId={P}
conversationId={C}
onClose={onClose}
/>,
),
),
};
}
function seeded(turns: TurnView[]): MockConversationGateway {
const gw = new MockConversationGateway();
gw._setTurns(P, C, turns);
return gw;
}
describe("ConversationViewer", () => {
it("renders turns chronologically with their full text", async () => {
const gw = seeded([human(1, "première"), agent(2, "a7", "deuxième")]);
renderViewer({ conversation: gw });
expect(await screen.findByText("première")).toBeTruthy();
expect(screen.getByText("deuxième")).toBeTruthy();
// Role labels are rendered.
expect(screen.getAllByText(/· Prompt/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/· Response/).length).toBeGreaterThan(0);
});
it("titles the header User ↔ Agent and resolves the agent name from work-state", async () => {
const gw = seeded([human(1), agent(2, "agent-7")]);
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(P, {
agents: [
{ agentId: "agent-7", name: "Architecte", profileId: "x", busy: { state: "idle" } },
],
});
renderViewer({ conversation: gw, workState });
expect(await screen.findByText("User ↔ Architecte")).toBeTruthy();
});
it("titles the header Agent ↔ Agent for a delegation thread", async () => {
const gw = seeded([agent(1, "agent-a"), agent(2, "agent-b")]);
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(P, {
agents: [
{ agentId: "agent-a", name: "Alpha", profileId: "x", busy: { state: "idle" } },
{ agentId: "agent-b", name: "Bravo", profileId: "x", busy: { state: "idle" } },
],
});
renderViewer({ conversation: gw, workState });
expect(await screen.findByText("Alpha ↔ Bravo")).toBeTruthy();
});
it("dims and collapses tool activity, expanding on click", async () => {
const gw = seeded([
human(1),
{ ...agent(2, "a7"), role: "toolActivity", text: "détails outillés secrets" },
]);
renderViewer({ conversation: gw });
const toggle = await screen.findByRole("button", { name: /Tool activity/ });
// Collapsed by default ⇒ the tool text is hidden.
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByText("détails outillés secrets")).toBeNull();
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(screen.getByText("détails outillés secrets")).toBeTruthy();
});
it("shows the empty state for a conversation with no turns", async () => {
renderViewer({ conversation: seeded([]) });
expect(await screen.findByText("Aucun tour pour le moment.")).toBeTruthy();
});
it("shows the loading state while the first page is in flight", async () => {
const gated: ConversationGateway = { readPage: () => new Promise(() => {}) };
renderViewer({ conversation: gated });
expect(await screen.findByText("Chargement du fil…")).toBeTruthy();
});
it("shows an error alert when the gateway rejects", async () => {
const failing: ConversationGateway = {
readPage: () => Promise.reject(new Error("lecture impossible")),
};
renderViewer({ conversation: failing });
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("lecture impossible");
});
it("offers a 'load older' control when more turns exist and triggers loadOlder", async () => {
// 120 turns ⇒ the first page (latest 50) has older turns ⇒ the button appears.
const gw = seeded(
Array.from({ length: 120 }, (_, i) => human(i + 1, `tour ${i + 1}`)),
);
renderViewer({ conversation: gw });
const older = await screen.findByRole("button", {
name: "Charger les tours précédents",
});
// Before: the oldest visible is t71.
expect(screen.queryByText("tour 21")).toBeNull();
fireEvent.click(older);
// After loadOlder: older turns (e.g. tour 21) are prepended into the view.
expect(await screen.findByText("tour 21")).toBeTruthy();
});
it("calls onClose when the back button is clicked", async () => {
const gw = seeded([human(1)]);
const { onClose } = renderViewer({ conversation: gw });
fireEvent.click(
await screen.findByRole("button", { name: "← Retour aux terminaux" }),
);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,232 @@
/**
* `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>
);
}

View File

@ -0,0 +1,6 @@
/** Conversations feature: read-only human transcript viewer (LS7). */
export { ConversationViewer } from "./ConversationViewer";
export type { ConversationViewerProps } from "./ConversationViewer";
export { useConversationThread } from "./useConversationThread";
export type { ConversationThreadViewModel } from "./useConversationThread";

View File

@ -0,0 +1,160 @@
/**
* LS7 — `useConversationThread`: initial load = latest page, `loadOlder` prepends
* (no dup, chronological), `hasMore=false` stops, `refreshTail` appends new turns
* (no dup), and `busy`/`error` states.
*/
import { describe, it, expect } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import type { ReactNode } from "react";
import { DIProvider } from "@/app/di";
import { MockConversationGateway } from "@/adapters/mock";
import type { ConversationGateway, Gateways } from "@/ports";
import type { TurnView } from "@/domain";
import { useConversationThread } from "./useConversationThread";
const P = "proj";
const C = "conv";
function turn(n: number): TurnView {
return {
id: `t${n}`,
atMs: 1_700_000_000_000 + n,
role: n % 2 === 0 ? "response" : "prompt",
source: { kind: "human" },
text: `texte ${n}`,
textLen: 8,
};
}
function wrapperFor(conversation: ConversationGateway) {
const gateways = { conversation } as unknown as Gateways;
return function Wrapper({ children }: { children: ReactNode }) {
return <DIProvider gateways={gateways}>{children}</DIProvider>;
};
}
function seededGateway(count: number): MockConversationGateway {
const gw = new MockConversationGateway();
gw._setTurns(
P,
C,
Array.from({ length: count }, (_, i) => turn(i + 1)),
);
return gw;
}
describe("useConversationThread", () => {
it("loads the latest page on open (backward, most recent)", async () => {
const gw = seededGateway(120);
const { result } = renderHook(() => useConversationThread(P, C), {
wrapper: wrapperFor(gw),
});
await waitFor(() => expect(result.current.turns.length).toBe(50));
// Most recent 50 (t71..t120), ascending, ending on the newest.
expect(result.current.turns[0].id).toBe("t71");
expect(result.current.turns.at(-1)?.id).toBe("t120");
expect(result.current.hasMore).toBe(true);
expect(result.current.busy).toBe(false);
expect(result.current.error).toBeNull();
});
it("loadOlder prepends older turns without duplicates, preserving order", async () => {
const gw = seededGateway(120);
const { result } = renderHook(() => useConversationThread(P, C), {
wrapper: wrapperFor(gw),
});
await waitFor(() => expect(result.current.turns.length).toBe(50));
await act(async () => {
await result.current.loadOlder();
});
// t21..t70 prepended before t71..t120 ⇒ t21..t120 (100), strictly ascending.
expect(result.current.turns.length).toBe(100);
expect(result.current.turns[0].id).toBe("t21");
expect(result.current.turns.at(-1)?.id).toBe("t120");
// No duplicate ids, order is the numeric order.
const ids = result.current.turns.map((t) => t.id);
expect(new Set(ids).size).toBe(100);
const nums = ids.map((id) => Number(id.slice(1)));
expect(nums).toEqual([...nums].sort((a, b) => a - b));
});
it("loadOlder is a no-op (no growth) when there is nothing older", async () => {
const gw = seededGateway(30); // fits in one default page (50)
const { result } = renderHook(() => useConversationThread(P, C), {
wrapper: wrapperFor(gw),
});
await waitFor(() => expect(result.current.turns.length).toBe(30));
expect(result.current.hasMore).toBe(false);
await act(async () => {
await result.current.loadOlder();
});
// Nothing strictly before the oldest ⇒ buffer unchanged, hasMore stays false.
expect(result.current.turns.length).toBe(30);
expect(result.current.hasMore).toBe(false);
});
it("refreshTail appends newly-arrived turns without duplicates", async () => {
const gw = seededGateway(10);
const { result } = renderHook(() => useConversationThread(P, C), {
wrapper: wrapperFor(gw),
});
await waitFor(() => expect(result.current.turns.length).toBe(10));
// Five new turns arrive at the tail of the thread.
gw._setTurns(
P,
C,
Array.from({ length: 15 }, (_, i) => turn(i + 1)),
);
await act(async () => {
await result.current.refreshTail();
});
// t11..t15 appended after t1..t10 ⇒ t1..t15, no duplicate.
expect(result.current.turns.map((t) => t.id)).toEqual(
Array.from({ length: 15 }, (_, i) => `t${i + 1}`),
);
});
it("surfaces an error and clears busy when the gateway rejects", async () => {
const failing: ConversationGateway = {
readPage: () => Promise.reject(new Error("boom-store")),
};
const { result } = renderHook(() => useConversationThread(P, C), {
wrapper: wrapperFor(failing),
});
await waitFor(() => expect(result.current.error).toContain("boom-store"));
expect(result.current.busy).toBe(false);
expect(result.current.turns).toEqual([]);
});
it("toggles busy while a page request is in flight", async () => {
let release!: (page: { turns: TurnView[]; hasMore: boolean }) => void;
const gated: ConversationGateway = {
readPage: () =>
new Promise((resolve) => {
release = resolve;
}),
};
const { result } = renderHook(() => useConversationThread(P, C), {
wrapper: wrapperFor(gated),
});
// The open() call is pending ⇒ busy is true.
await waitFor(() => expect(result.current.busy).toBe(true));
await act(async () => {
release({ turns: [turn(1)], hasMore: false });
});
await waitFor(() => expect(result.current.busy).toBe(false));
expect(result.current.turns.map((t) => t.id)).toEqual(["t1"]);
});
});

View File

@ -0,0 +1,160 @@
/**
* View-model hook for the human conversation transcript viewer (LS7).
*
* Read-only and cold (a human action, never on the agent hot path): it consumes
* the {@link ConversationGateway} port only. The buffer is kept in chronological
* ascending order and deduplicated by turn id:
*
* - **open** → `readPage({ direction: "backward" })` yields the latest page
* (bottom of the thread);
* - **loadOlder** → `readPage` backward anchored on the *oldest* buffered turn,
* **prepended** without duplicates (scroll-up);
* - **refreshTail** → `readPage` forward anchored on the *newest* buffered turn,
* appended; loops until the tail is exhausted. Triggered manually and on the
* same domain events the Work panel already listens to.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { GatewayError, TurnView } from "@/domain";
import { useGateways } from "@/app/di";
export interface ConversationThreadViewModel {
/** Buffered turns, oldest → newest. */
turns: TurnView[];
/** A page request is in flight. */
busy: boolean;
/** Last error message, or `null`. */
error: string | null;
/** Whether older turns exist before the buffer (scroll-up sentinel). */
hasMore: boolean;
/** Loads the page of turns just before the oldest buffered one (prepend). */
loadOlder: () => Promise<void>;
/** Catches the buffer up to the newest turns of the thread (append). */
refreshTail: () => Promise<void>;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
/** Dedup by id, preserving order (incoming pages never reorder the thread). */
function dedupById(turns: TurnView[]): TurnView[] {
const seen = new Set<string>();
const out: TurnView[] = [];
for (const turn of turns) {
if (seen.has(turn.id)) continue;
seen.add(turn.id);
out.push(turn);
}
return out;
}
export function useConversationThread(
projectId: string,
conversationId: string,
): ConversationThreadViewModel {
const { conversation } = useGateways();
const [turns, setTurns] = useState<TurnView[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
// Mirror the buffer in a ref so callbacks can anchor without re-creating on
// every turn change (and without racing concurrent loads).
const turnsRef = useRef<TurnView[]>([]);
const inFlight = useRef(false);
const setBuffer = useCallback((next: TurnView[]) => {
turnsRef.current = next;
setTurns(next);
}, []);
const open = useCallback(async () => {
if (inFlight.current) return;
inFlight.current = true;
setBusy(true);
setError(null);
try {
const page = await conversation.readPage(projectId, conversationId, {
direction: "backward",
});
setBuffer(dedupById(page.turns));
setHasMore(page.hasMore);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
inFlight.current = false;
}
}, [conversation, projectId, conversationId, setBuffer]);
const loadOlder = useCallback(async () => {
if (inFlight.current) return;
const oldest = turnsRef.current[0];
if (!oldest) {
await open();
return;
}
inFlight.current = true;
setBusy(true);
setError(null);
try {
const page = await conversation.readPage(projectId, conversationId, {
direction: "backward",
anchor: oldest.id,
});
if (page.turns.length > 0) {
setBuffer(dedupById([...page.turns, ...turnsRef.current]));
}
setHasMore(page.hasMore);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
inFlight.current = false;
}
}, [conversation, projectId, conversationId, open, setBuffer]);
const refreshTail = useCallback(async () => {
if (inFlight.current) return;
if (turnsRef.current.length === 0) {
await open();
return;
}
inFlight.current = true;
setBusy(true);
setError(null);
try {
// Walk forward until the tail is exhausted (bounded by the thread length).
// `hasMore` (older side) is intentionally left untouched by a tail refresh.
for (;;) {
const newest = turnsRef.current.at(-1);
if (!newest) break;
const page = await conversation.readPage(projectId, conversationId, {
direction: "forward",
anchor: newest.id,
});
if (page.turns.length === 0) break;
setBuffer(dedupById([...turnsRef.current, ...page.turns]));
if (!page.hasMore) break;
}
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
inFlight.current = false;
}
}, [conversation, projectId, conversationId, open, setBuffer]);
// (Re)open whenever the target conversation changes.
useEffect(() => {
setBuffer([]);
setHasMore(false);
void open();
}, [open, setBuffer]);
return { turns, busy, error, hasMore, loadOlder, refreshTail };
}