/** * 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; /** Catches the buffer up to the newest turns of the thread (append). */ refreshTail: () => Promise; } 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(); 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([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(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([]); 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(() => { setHasMore(false); void open(); }, [open]); return { turns, busy, error, hasMore, loadOlder, refreshTail }; }