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,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 };
}