160 lines
5.0 KiB
TypeScript
160 lines
5.0 KiB
TypeScript
/**
|
|
* 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(() => {
|
|
setHasMore(false);
|
|
void open();
|
|
}, [open]);
|
|
|
|
return { turns, busy, error, hasMore, loadOlder, refreshTail };
|
|
}
|