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:
160
frontend/src/features/conversations/useConversationThread.ts
Normal file
160
frontend/src/features/conversations/useConversationThread.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user