Files
kandev/apps/web/lib/plugins/conversation-host.tsx
2026-09-17 10:39:30 +01:00

961 lines
29 KiB
TypeScript

/* eslint-disable max-lines -- The facade hook centralizes ordered snapshot/live state coordination. */
"use client";
import * as React from "react";
import { useMessageFavoritesStore } from "@/lib/state/slices/message-favorites";
import {
compareConversationMessages,
eventMatchesTask,
eventString,
messageFromEvent,
turnFromEvent,
} from "./conversation-event-projection";
import type {
PluginConversationApi,
PluginConversationError,
PluginConversationMessage,
PluginConversationTurn,
PluginSessionMessagesQuery,
PluginSessionMessagesState,
PluginSessionTurnsState,
} from "./types";
import {
ConversationScopeContext,
parseConversationResponse,
pluginConversationUrl,
type ConversationScope,
type OrderedReady,
} from "./conversation-scope";
export { PluginConversationScopeProvider } from "./conversation-scope";
type MessagePage = {
messages: PluginConversationMessage[];
hasMore: boolean;
cursor: string | null;
epoch?: string;
revision?: string;
};
type TurnsPage = {
turns: PluginConversationTurn[];
hasMore?: boolean;
cursor?: string | null;
epoch?: string;
revision?: string;
};
const EMPTY_MESSAGES: PluginSessionMessagesState = {
messages: [],
loading: false,
hydrated: false,
loadingMore: false,
error: null,
hasMore: false,
removed: false,
loadMore: async () => 0,
retry: () => undefined,
};
const EMPTY_TURNS: PluginSessionTurnsState = {
turns: [],
loading: false,
hydrated: false,
error: null,
removed: false,
retry: () => undefined,
};
function conversationError(error: unknown): PluginConversationError {
if (typeof error === "object" && error !== null && "code" in error) {
return error as PluginConversationError;
}
return {
code: "upstream_failure",
// i18n-exempt: plugin-facing transport diagnostic; plugins render their own localized error UI.
message: error instanceof Error ? error.message : "Conversation request failed",
retryable: true,
};
}
function isRemovedConversationError(error: PluginConversationError): boolean {
return error.code === "not_found";
}
function resolveTaskId(
scope: ConversationScope,
taskId: string | null | undefined,
): { taskId: string | null; error: PluginConversationError | null } {
const resolved = taskId === undefined ? scope.taskId : taskId;
if (resolved === "" || (typeof resolved === "string" && resolved !== scope.taskId)) {
return {
taskId: null,
error: {
code: "invalid_query",
// i18n-exempt: plugin-facing programmer diagnostic; plugins render their own localized error UI.
message: "taskId must match the active task",
retryable: false,
},
};
}
return { taskId: resolved, error: null };
}
type MessagesState = Omit<PluginSessionMessagesState, "loadMore" | "retry">;
type MessagesSetter = React.Dispatch<React.SetStateAction<MessagesState>>;
// Session deletion emits child tombstones before its terminal marker. Keep
// rows removed by that ordered teardown so the terminal view can retain the
// committed conversation state.
function restoreDeletedMessages(
current: readonly PluginConversationMessage[],
deleted: ReadonlyMap<string, PluginConversationMessage>,
sort: "asc" | "desc",
): PluginConversationMessage[] {
const messages = [...current];
const currentIds = new Set(messages.map((message) => message.id));
for (const message of deleted.values()) {
if (currentIds.has(message.id)) continue;
messages.push(message);
}
messages.sort((left, right) => compareConversationMessages(left, right, sort));
return messages;
}
function useOrderedMessageEvents({
scope,
sessionId,
taskId,
authorsKey,
sort,
snapshotKey,
setState,
cursorRef,
messagesRef,
deletedMessagesRef,
}: {
scope: ConversationScope | null;
sessionId: string | null;
taskId: string | null;
authorsKey: string;
sort: "asc" | "desc";
snapshotKey: string;
setState: MessagesSetter;
cursorRef: React.MutableRefObject<string | null>;
messagesRef: React.MutableRefObject<readonly PluginConversationMessage[]>;
deletedMessagesRef: React.MutableRefObject<Map<string, PluginConversationMessage>>;
}) {
React.useEffect(() => {
if (!scope || !sessionId) return;
const allowedAuthorTypes = authorsKey ? new Set(authorsKey.split(",")) : null;
return scope.subscribe(
(event) => {
if (event.event_type === "session.removed") {
setState((current) => {
const messages = restoreDeletedMessages(
current.messages,
deletedMessagesRef.current,
sort,
);
messagesRef.current = messages;
return { ...current, messages, removed: true, hasMore: false, loading: false };
});
cursorRef.current = null;
return true;
}
if (!event.event_type.startsWith("message.") || !eventMatchesTask(event, taskId)) {
return true;
}
const messageId = eventString(event, "message_id");
if (!messageId) return false;
if (event.event_type === "message.deleted") {
setState((current) => {
const deleted = current.messages.find((message) => message.id === messageId);
if (deleted) deletedMessagesRef.current.set(messageId, deleted);
const messages = current.messages.filter((message) => message.id !== messageId);
messagesRef.current = messages;
return { ...current, messages };
});
return true;
}
const message = messageFromEvent(event);
if (!message) return false;
if (allowedAuthorTypes && !allowedAuthorTypes.has(message.authorType)) return true;
deletedMessagesRef.current.delete(message.id);
setState((current) => {
const messages = current.messages.filter((item) => item.id !== message.id);
messages.push(message);
messages.sort((left, right) => compareConversationMessages(left, right, sort));
messagesRef.current = messages;
return { ...current, messages };
});
return true;
},
"messages",
snapshotKey,
);
}, [
authorsKey,
cursorRef,
deletedMessagesRef,
messagesRef,
scope,
sessionId,
setState,
snapshotKey,
sort,
taskId,
]);
}
function useInitialMessagePage({
scope,
sessionId,
error,
revision,
loadPage,
setState,
cursorRef,
loadMoreRef,
messagesRef,
deletedMessagesRef,
requestRevisionRef,
snapshotKey,
}: {
scope: ConversationScope | null;
sessionId: string | null;
error: PluginConversationError | null;
revision: number;
loadPage: (cursor: string | null, append: boolean) => Promise<number>;
setState: MessagesSetter;
cursorRef: React.MutableRefObject<string | null>;
loadMoreRef: React.MutableRefObject<Promise<number> | null>;
messagesRef: React.MutableRefObject<readonly PluginConversationMessage[]>;
deletedMessagesRef: React.MutableRefObject<Map<string, PluginConversationMessage>>;
requestRevisionRef: React.MutableRefObject<number>;
snapshotKey: string;
}) {
React.useEffect(() => {
requestRevisionRef.current += 1;
cursorRef.current = null;
loadMoreRef.current = null;
messagesRef.current = [];
deletedMessagesRef.current.clear();
if (!scope || !sessionId) {
setState({ ...EMPTY_MESSAGES, messages: [] });
return;
}
if (error) {
setState({ ...EMPTY_MESSAGES, messages: [], error });
return;
}
setState({ ...EMPTY_MESSAGES, messages: [] });
void loadPage(null, false).catch((cause: unknown) => {
if (scope.signal.aborted || scope.isTerminal()) return;
const error = conversationError(cause);
if (isRemovedConversationError(error)) {
setState((current) => ({
...current,
loading: false,
loadingMore: false,
error: null,
hasMore: false,
removed: true,
}));
return;
}
setState({ ...EMPTY_MESSAGES, messages: [], error });
});
}, [
cursorRef,
deletedMessagesRef,
error,
loadMoreRef,
loadPage,
messagesRef,
requestRevisionRef,
revision,
scope,
sessionId,
setState,
snapshotKey,
]);
}
function invalidateFreshMessagesSnapshot(scope: ConversationScope, snapshotKey: string): void {
scope.invalidateSnapshot("messages", snapshotKey);
}
function isScopeRequestable(scope: ConversationScope): boolean {
return !scope.signal.aborted && !scope.isTerminal();
}
function canLoadMore(
scope: ConversationScope | null,
sessionId: string | null,
state: { removed: boolean; hasMore: boolean },
cursor: string | null,
): boolean {
return Boolean(
scope && sessionId && isScopeRequestable(scope) && !state.removed && state.hasMore && cursor,
);
}
type MessagePageLoaderOptions = {
scope: ConversationScope | null;
sessionId: string | null;
taskId: string | null;
error: PluginConversationError | null;
authorsKey: string;
sort: "asc" | "desc";
limit: number;
snapshotKey: string;
setState: MessagesSetter;
cursorRef: React.MutableRefObject<string | null>;
messagesRef: React.MutableRefObject<readonly PluginConversationMessage[]>;
requestRevisionRef: React.MutableRefObject<number>;
};
async function fetchMessagePage({
scope,
sessionId,
taskId,
authorsKey,
sort,
limit,
cursor,
binding,
}: {
scope: ConversationScope;
sessionId: string;
taskId: string | null;
authorsKey: string;
sort: "asc" | "desc";
limit: number;
cursor: string | null;
binding: Pick<OrderedReady, "bindingToken" | "snapshotToken" | "source" | "revision">;
}): Promise<MessagePage> {
const params = new URLSearchParams({ sort, limit: String(limit) });
if (taskId !== null) params.set("task_id", taskId);
for (const author of authorsKey ? authorsKey.split(",") : []) {
params.append("author_type", author);
}
if (cursor) params.set("cursor", cursor);
if (binding.source && binding.revision !== undefined) {
params.set("expected_revision", binding.revision);
}
const path = binding.source
? `/conversation/v2/task-sessions/${encodeURIComponent(sessionId)}/messages?${params}`
: `/conversation/task-sessions/${encodeURIComponent(sessionId)}/messages?${params}`;
const headers: Record<string, string> = {
"X-Kandev-Plugin-Binding": binding.bindingToken,
};
if (binding.snapshotToken) headers["X-Kandev-Snapshot-Token"] = binding.snapshotToken;
const response = await fetch(pluginConversationUrl(scope.pluginId, path), {
credentials: "include",
cache: "no-store",
headers,
signal: scope.signal,
});
return parseConversationResponse<MessagePage>(response);
}
async function fetchTurnPage({
scope,
sessionId,
taskId,
binding,
cursor,
}: {
cursor?: string;
scope: ConversationScope;
sessionId: string;
taskId: string | null;
binding: Pick<OrderedReady, "bindingToken" | "snapshotToken" | "source" | "revision">;
}): Promise<TurnsPage> {
const params = new URLSearchParams({ limit: "100" });
if (cursor) params.set("cursor", cursor);
if (taskId !== null) params.set("task_id", taskId);
if (binding.source && binding.revision !== undefined) {
params.set("expected_revision", binding.revision);
}
const queryString = params.size ? `?${params}` : "";
const path = binding.source
? `/conversation/v2/task-sessions/${encodeURIComponent(sessionId)}/turns${queryString}`
: `/conversation/task-sessions/${encodeURIComponent(sessionId)}/turns${queryString}`;
const headers: Record<string, string> = {
"X-Kandev-Plugin-Binding": binding.bindingToken,
};
if (binding.snapshotToken) headers["X-Kandev-Snapshot-Token"] = binding.snapshotToken;
const response = await fetch(pluginConversationUrl(scope.pluginId, path), {
credentials: "include",
cache: "no-store",
headers,
signal: scope.signal,
});
return parseConversationResponse<TurnsPage>(response);
}
async function fetchAllTurns(args: Parameters<typeof fetchTurnPage>[0]): Promise<TurnsPage> {
const first = await fetchTurnPage(args);
const turns = [...first.turns];
const cursors = new Set<string>();
let page = first;
while (page.hasMore) {
if (!page.cursor || cursors.has(page.cursor)) {
// i18n-exempt: internal protocol diagnostic rendered through structured API error handling.
throw new Error("Invalid conversation turn cursor");
}
cursors.add(page.cursor);
const binding = await args.scope.ready();
page = await fetchTurnPage({
...args,
cursor: page.cursor,
binding: { ...binding, revision: first.revision },
});
if (page.epoch !== first.epoch || page.revision !== first.revision) {
// i18n-exempt: internal protocol diagnostic rendered through structured API error handling.
throw Object.assign(new Error("Conversation changed during pagination"), {
code: "reconciliation_required",
retryable: true,
});
}
turns.push(...page.turns);
}
return { ...first, turns, hasMore: false, cursor: null };
}
function mergeMessagePage(
current: readonly PluginConversationMessage[],
page: readonly PluginConversationMessage[],
append: boolean,
sort: "asc" | "desc",
): { messages: PluginConversationMessage[]; additionCount: number } {
const existingIds = new Set(current.map((message) => message.id));
const messagesById = new Map<string, PluginConversationMessage>();
if (append) {
for (const message of current) messagesById.set(message.id, message);
}
for (const message of page) {
const previous = messagesById.get(message.id);
if (!previous || previous.updatedAt.localeCompare(message.updatedAt) < 0) {
messagesById.set(message.id, message);
}
}
const messages = Array.from(messagesById.values());
messages.sort((left, right) => compareConversationMessages(left, right, sort));
return {
messages,
additionCount: page.filter((message) => !existingIds.has(message.id)).length,
};
}
function useMessagePageLoader({
scope,
sessionId,
taskId,
error,
authorsKey,
sort,
limit,
snapshotKey,
setState,
cursorRef,
messagesRef,
requestRevisionRef,
}: MessagePageLoaderOptions) {
return React.useCallback(
async (cursor: string | null, append: boolean): Promise<number> => {
if (!scope || !sessionId || !isScopeRequestable(scope)) return 0;
if (error) throw error;
// A fresh full page or continuation must not let a concurrent live
// update project and then be overwritten by the stale page response.
// Continuations also invalidate their committed snapshot so deletions
// cannot be acknowledged while the page is in flight and resurrected
invalidateFreshMessagesSnapshot(scope, snapshotKey);
const continuationSnapshotInvalidated = append;
try {
let capturedRevision = requestRevisionRef.current;
let binding = await scope.ready();
let pageCursor = cursor;
if (pageCursor) {
const renewal = await scope.renewContinuation(pageCursor, snapshotKey);
if (renewal.recovered) {
capturedRevision = requestRevisionRef.current;
pageCursor = cursorRef.current;
if (!pageCursor) return 0;
} else {
pageCursor = renewal.cursor;
}
binding = renewal.binding;
}
const page = await fetchMessagePage({
scope,
sessionId,
taskId,
authorsKey,
sort,
limit,
cursor: pageCursor,
binding,
});
if (
requestRevisionRef.current !== capturedRevision ||
scope.signal.aborted ||
scope.isTerminal()
) {
if (continuationSnapshotInvalidated) scope.commitSnapshot("messages", snapshotKey);
return 0;
}
const { messages, additionCount } = mergeMessagePage(
messagesRef.current,
page.messages,
append,
sort,
);
messagesRef.current = messages;
setState((current) => ({
...current,
messages,
loading: false,
hydrated: true,
loadingMore: false,
error: null,
hasMore: page.hasMore,
}));
scope.setSourceSnapshot?.(page.epoch, page.revision);
scope.commitSnapshot("messages", snapshotKey, page.epoch, page.revision);
cursorRef.current = page.cursor;
return additionCount;
} catch (cause) {
if (continuationSnapshotInvalidated) scope.commitSnapshot("messages", snapshotKey);
throw cause;
}
},
[
authorsKey,
error,
limit,
messagesRef,
requestRevisionRef,
scope,
snapshotKey,
sessionId,
setState,
sort,
taskId,
],
);
}
function useMessageRebind({
scope,
sessionId,
error,
loadPage,
setState,
cursorRef,
deletedMessagesRef,
requestRevisionRef,
}: {
scope: ConversationScope | null;
sessionId: string | null;
error: PluginConversationError | null;
loadPage: (cursor: string | null, append: boolean) => Promise<number>;
setState: MessagesSetter;
cursorRef: React.MutableRefObject<string | null>;
deletedMessagesRef: React.MutableRefObject<Map<string, PluginConversationMessage>>;
requestRevisionRef: React.MutableRefObject<number>;
}) {
React.useEffect(() => {
if (!scope || !sessionId || error) return;
return scope.subscribeRebind(async () => {
requestRevisionRef.current += 1;
cursorRef.current = null;
deletedMessagesRef.current.clear();
setState((current) => ({ ...current, loading: true, loadingMore: false, error: null }));
try {
await loadPage(null, false);
} catch (cause) {
if (scope.signal.aborted || scope.isTerminal()) return;
setState((current) => ({
...current,
loading: false,
error: conversationError(cause),
}));
throw cause;
}
});
}, [
cursorRef,
deletedMessagesRef,
error,
loadPage,
requestRevisionRef,
scope,
sessionId,
setState,
]);
}
function useMessageControls({
scope,
sessionId,
state,
setState,
loadPage,
cursorRef,
loadMoreRef,
setRevision,
}: {
scope: ConversationScope | null;
sessionId: string | null;
state: MessagesState;
setState: MessagesSetter;
loadPage: (cursor: string | null, append: boolean) => Promise<number>;
cursorRef: React.MutableRefObject<string | null>;
loadMoreRef: React.MutableRefObject<Promise<number> | null>;
setRevision: React.Dispatch<React.SetStateAction<number>>;
}): PluginSessionMessagesState {
const loadMore = React.useCallback((): Promise<number> => {
if (!canLoadMore(scope, sessionId, state, cursorRef.current)) return Promise.resolve(0);
if (loadMoreRef.current) return loadMoreRef.current;
setState((current) => ({ ...current, loadingMore: true }));
const request = loadPage(cursorRef.current, true)
.catch((error: unknown) => {
setState((current) => ({
...current,
loadingMore: false,
error: conversationError(error),
}));
throw conversationError(error);
})
.finally(() => {
if (loadMoreRef.current === request) loadMoreRef.current = null;
});
loadMoreRef.current = request;
return request;
}, [cursorRef, loadMoreRef, loadPage, scope, sessionId, setState, state.hasMore, state.removed]);
const retry = React.useCallback(() => {
if (!state.error?.retryable || state.removed || !scope || scope.isTerminal() || !sessionId)
return;
setRevision((value) => value + 1);
}, [scope, sessionId, setRevision, state.error, state.removed]);
return React.useMemo(() => ({ ...state, loadMore, retry }), [loadMore, retry, state]);
}
function useSessionMessages(query: PluginSessionMessagesQuery): PluginSessionMessagesState {
const scope = React.useContext(ConversationScopeContext);
const [revision, setRevision] = React.useState(0);
const [state, setState] = React.useState<Omit<PluginSessionMessagesState, "loadMore" | "retry">>({
...EMPTY_MESSAGES,
});
const cursorRef = React.useRef<string | null>(null);
const loadMoreRef = React.useRef<Promise<number> | null>(null);
const messagesRef = React.useRef<readonly PluginConversationMessage[]>([]);
const deletedMessagesRef = React.useRef(new Map<string, PluginConversationMessage>());
const requestRevisionRef = React.useRef(0);
const resolved = React.useMemo(
() => (scope ? resolveTaskId(scope, query.taskId) : { taskId: null, error: null }),
[query.taskId, scope],
);
const authorsKey = [...(query.authorTypes ?? [])].join(",");
const sort = query.sort ?? "desc";
const limit = query.pageSize ?? 20;
const snapshotKey = JSON.stringify([query.sessionId, resolved.taskId, authorsKey, sort, limit]);
const loadPage = useMessagePageLoader({
scope,
sessionId: query.sessionId,
taskId: resolved.taskId,
error: resolved.error,
authorsKey,
sort,
limit,
snapshotKey,
setState,
cursorRef,
messagesRef,
requestRevisionRef,
});
useOrderedMessageEvents({
scope: resolved.error ? null : scope,
sessionId: query.sessionId,
taskId: resolved.taskId,
authorsKey,
sort,
snapshotKey,
setState,
cursorRef,
messagesRef,
deletedMessagesRef,
});
useInitialMessagePage({
scope,
sessionId: query.sessionId,
error: resolved.error,
revision,
loadPage,
setState,
cursorRef,
loadMoreRef,
messagesRef,
deletedMessagesRef,
requestRevisionRef,
snapshotKey,
});
useMessageRebind({
scope,
sessionId: query.sessionId,
error: resolved.error,
loadPage,
setState,
cursorRef,
deletedMessagesRef,
requestRevisionRef,
});
return useMessageControls({
scope,
sessionId: query.sessionId,
state,
setState,
loadPage,
cursorRef,
loadMoreRef,
setRevision,
});
}
type TurnsState = Omit<PluginSessionTurnsState, "retry">;
type TurnsSetter = React.Dispatch<React.SetStateAction<TurnsState>>;
function compareConversationTurns(
left: PluginConversationTurn,
right: PluginConversationTurn,
): number {
const started = left.startedAt.localeCompare(right.startedAt);
return started === 0 ? left.id.localeCompare(right.id) : started;
}
function restoreDeletedTurns(
current: readonly PluginConversationTurn[],
deleted: ReadonlyMap<string, PluginConversationTurn>,
): PluginConversationTurn[] {
const turns = [...current];
const currentIds = new Set(turns.map((turn) => turn.id));
for (const turn of deleted.values()) {
if (currentIds.has(turn.id)) continue;
turns.push(turn);
}
turns.sort(compareConversationTurns);
return turns;
}
function useOrderedTurnEvents({
scope,
sessionId,
taskId,
error,
snapshotKey,
setState,
deletedTurnsRef,
}: {
scope: ConversationScope | null;
sessionId: string | null;
taskId: string | null;
error: PluginConversationError | null;
snapshotKey: string;
setState: TurnsSetter;
deletedTurnsRef: React.MutableRefObject<Map<string, PluginConversationTurn>>;
}) {
React.useEffect(() => {
if (!scope || !sessionId || error) return;
return scope.subscribe(
(event) => {
if (event.event_type === "session.removed") {
setState((current) => ({
...current,
turns: restoreDeletedTurns(current.turns, deletedTurnsRef.current),
removed: true,
loading: false,
}));
return true;
}
if (!eventMatchesTask(event, taskId)) return true;
if (event.event_type === "session.turn.removed") {
const payload = event.payload;
const turnId =
payload &&
typeof payload === "object" &&
"id" in payload &&
typeof payload.id === "string"
? payload.id
: undefined;
if (!turnId) return false;
setState((current) => {
const deleted = current.turns.find((turn) => turn.id === turnId);
if (deleted) deletedTurnsRef.current.set(turnId, deleted);
return {
...current,
turns: current.turns.filter((item) => item.id !== turnId),
};
});
return true;
}
if (!event.event_type.startsWith("session.turn.")) return true;
const turn = turnFromEvent(event);
if (!turn) return false;
deletedTurnsRef.current.delete(turn.id);
setState((current) => {
const turns = current.turns.filter((item) => item.id !== turn.id);
turns.push(turn);
turns.sort(compareConversationTurns);
return { ...current, turns, hydrated: true };
});
return true;
},
"turns",
snapshotKey,
);
}, [deletedTurnsRef, error, scope, sessionId, setState, snapshotKey, taskId]);
}
// eslint-disable-next-line max-lines-per-function -- keeps turn snapshot lifecycle in one hook.
function useSessionTurns(
sessionId: string | null,
taskId?: string | null,
): PluginSessionTurnsState {
const scope = React.useContext(ConversationScopeContext);
const [revision, setRevision] = React.useState(0);
const [state, setState] = React.useState<Omit<PluginSessionTurnsState, "retry">>({
...EMPTY_TURNS,
turns: [],
});
const resolved = React.useMemo(
() => (scope ? resolveTaskId(scope, taskId) : { taskId: null, error: null }),
[scope, taskId],
);
const snapshotKey = JSON.stringify([sessionId, resolved.taskId]);
const turnsRequestRef = React.useRef(0);
const deletedTurnsRef = React.useRef(new Map<string, PluginConversationTurn>());
useOrderedTurnEvents({
scope,
sessionId,
taskId: resolved.taskId,
error: resolved.error,
snapshotKey,
setState,
deletedTurnsRef,
});
const loadTurns = React.useCallback(async (): Promise<void> => {
deletedTurnsRef.current.clear();
if (!scope || !sessionId) {
setState({ ...EMPTY_TURNS, turns: [] });
return;
}
if (resolved.error) {
setState({ ...EMPTY_TURNS, turns: [], error: resolved.error });
return;
}
const requestId = ++turnsRequestRef.current;
// A fresh turns page (initial load, retry, or binding refresh) must not
// let a concurrent live turn event project and then be overwritten by the
// stale page response: buffer live events until the new snapshot commits,
// then drain them on top.
scope.invalidateSnapshot("turns", snapshotKey);
setState((previous) =>
revision === 0
? { ...EMPTY_TURNS, turns: [], loading: true }
: { ...previous, loading: true, error: null },
);
try {
const binding = await scope.ready();
const page = await fetchAllTurns({
scope,
sessionId,
taskId: resolved.taskId,
binding,
});
if (turnsRequestRef.current !== requestId || scope.signal.aborted || scope.isTerminal()) {
return;
}
setState((previous) => ({
...previous,
turns: page.turns,
loading: false,
hydrated: true,
error: null,
}));
scope.setSourceSnapshot?.(page.epoch, page.revision);
scope.commitSnapshot("turns", snapshotKey, page.epoch, page.revision);
} catch (cause) {
if (turnsRequestRef.current !== requestId || scope.signal.aborted || scope.isTerminal()) {
return;
}
const error = conversationError(cause);
if (isRemovedConversationError(error)) {
setState((previous) => ({
...previous,
loading: false,
error: null,
removed: true,
}));
return;
}
setState((previous) => ({
...(revision === 0 ? { ...EMPTY_TURNS, turns: [] } : previous),
loading: false,
error,
}));
throw cause;
}
}, [
deletedTurnsRef,
resolved.error,
resolved.taskId,
revision,
scope,
sessionId,
setState,
snapshotKey,
]);
React.useEffect(() => {
void loadTurns().catch(() => {});
return () => {
turnsRequestRef.current += 1;
};
}, [loadTurns]);
React.useEffect(() => {
if (!scope || !sessionId || resolved.error) return;
return scope.subscribeRebind(() => loadTurns());
}, [loadTurns, resolved.error, scope, sessionId]);
const retry = React.useCallback(() => {
if (!state.error?.retryable || state.removed || !scope || !sessionId) return;
setRevision((value) => value + 1);
}, [scope, sessionId, state.error, state.removed]);
return React.useMemo(() => ({ ...state, retry }), [retry, state]);
}
function useMessageFavorite(sessionId: string | null, messageId: string): boolean {
const hydrateSession = useMessageFavoritesStore((state) => state.hydrateSession);
React.useEffect(() => {
if (sessionId) hydrateSession(sessionId);
}, [hydrateSession, sessionId]);
return useMessageFavoritesStore((state) =>
sessionId ? Boolean(state.bySession[sessionId]?.[messageId]) : false,
);
}
export const pluginConversationApi: PluginConversationApi = {
useSessionMessages,
useSessionTurns,
useMessageFavorite,
};