@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
595 lines • 25.9 kB
JavaScript
import { useCallback, useEffect, useRef, useState } from 'react';
import { readUiActionText, uiActionSummary } from '../../../genui/actions.js';
import { attachmentFile } from '../attachments/files.js';
import { requireBackendMethod } from '../backend.js';
import { shareStructure } from '../genui/share-structure.js';
import { useAgentBackend } from '../provider.js';
import { buildTranscriptBlocks, describeTimestamp, describeUsage, extractMessageText, } from './model.js';
import { useStickToBottom } from './use-stick-to-bottom.js';
/** Most-recent messages mounted up front; older ones wait behind `window.loadEarlier`. */
const DEFAULT_VISIBLE_COUNT = 50;
const DEFAULT_LOAD_EARLIER_STEP = 50;
const DEFAULT_COPY_RESET_MS = 1500;
/**
* The model behind a chat transcript: messages normalized into items with their parts grouped into
* runs, per-message derived values (copyable text, usage labels, relative time), each action as a
* state machine rather than a button, list windowing, and stick-to-bottom. It renders nothing and
* names no class — `MessageList`/`MessageItem` are one renderer over it, and an app with its own
* design system writes another without reimplementing any of this.
*/
export function useChatTranscript(options) {
const { messages, status, initialVisibleCount = DEFAULT_VISIBLE_COUNT, followUps, stickToBottomThreshold, } = options;
const latest = useRef(options);
latest.current = options;
const [visibleCount, setVisibleCount] = useState(initialVisibleCount);
const [stopRequested, setStopRequested] = useState(false);
// A reconnecting stream is still the turn in flight, and its message is still being written.
const isStreaming = status === 'streaming' || status === 'reconnecting';
const isBusy = status === 'submitted' || isStreaming;
const isEmpty = messages.length === 0;
// A settled turn clears a pending stop, so the next turn's button starts from rest.
useEffect(() => {
if (!isBusy) {
setStopRequested(false);
}
}, [isBusy]);
const lastMessage = messages.at(-1);
const lastAssistant = findLastAssistant(messages);
const streamingMessageId = isStreaming ? (lastAssistant?.id ?? null) : null;
const visibleStart = Math.max(0, messages.length - visibleCount);
const items = useTranscriptItems({
messages,
visibleStart,
streamingMessageId,
options,
});
const stop = useCallback(() => {
const current = latest.current;
if (current.status !== 'submitted' &&
current.status !== 'streaming' &&
current.status !== 'reconnecting') {
return;
}
setStopRequested(true);
void current.onStop?.();
}, []);
const loadEarlier = useCallback(() => setVisibleCount((count) => count + (latest.current.loadEarlierStep ?? DEFAULT_LOAD_EARLIER_STEP)), []);
const showAll = useCallback(() => setVisibleCount(latest.current.messages.length), []);
const scroll = useStickToBottom({
contentKey: contentSignature(messages),
...(stickToBottomThreshold !== undefined ? { threshold: stickToBottomThreshold } : {}),
});
const showTypingIndicator = isBusy && lastMessage?.role !== 'assistant';
const queued = useQueuedItems(options.queue ?? null, options.readOnly === true);
return {
items,
status,
isEmpty,
isBusy,
isStreaming,
showEmptyState: isEmpty && status === 'ready',
showTypingIndicator,
showFollowUps: status === 'ready' &&
!showTypingIndicator &&
lastMessage?.role === 'assistant' &&
!!followUps &&
followUps.length > 0,
lastAssistantId: lastAssistant?.id ?? null,
window: {
visibleCount,
hiddenCount: visibleStart,
canLoadEarlier: visibleStart > 0,
loadEarlier,
showAll,
},
scroll,
stop: {
available: isBusy && options.readOnly !== true && options.onStop !== undefined,
isStopping: stopRequested,
stop,
},
queued,
queuePaused: options.queue?.paused ?? null,
};
}
/** The queue as transcript items. `remove` is cached per id, like every other item action. */
function useQueuedItems(queue, readOnly) {
const latestQueue = useRef(queue);
latestQueue.current = queue;
const removers = useRef(new Map());
const removerFor = useCallback((id) => {
let remover = removers.current.get(id);
if (remover === undefined) {
remover = () => {
void latestQueue.current?.remove?.(id);
};
removers.current.set(id, remover);
}
return remover;
}, []);
if (queue === null || queue === undefined) {
return [];
}
return queue.items.map((message, position) => {
const state = message.state === 'sending' ? 'sending' : queue.paused !== null ? 'paused' : 'queued';
return {
id: message.id,
role: 'user',
text: message.text,
files: (message.files ?? message.attachments.map(attachmentFile)).map((file) => ({
...file,
isImage: file.kind === 'image',
})),
state,
position,
isNext: position === 0,
interrupt: message.interrupt,
remove: {
available: !readOnly && state !== 'sending' && queue.remove !== undefined,
run: removerFor(message.id),
},
};
});
}
/**
* The single-message half of {@link useChatTranscript}, for a renderer that owns the list itself.
* `regeneratable` applies directly here: one assistant message is trivially the last one.
*/
export function useTranscriptItem(options) {
const { message, isStreaming = false } = options;
const messagesRef = useRef([message]);
// A fresh array each render would re-key nothing but does churn the items memo; the identity is
// kept stable while the message itself is unchanged.
if (messagesRef.current[0] !== message) {
messagesRef.current = [message];
}
const items = useTranscriptItems({
messages: messagesRef.current,
visibleStart: 0,
streamingMessageId: isStreaming ? message.id : null,
options,
});
// One message in, exactly one item out.
return items[0];
}
/**
* All per-message UI state lives here, keyed by message id, because the number of messages is not
* known at render time — a hook per item would break the rules of hooks the moment a turn arrives.
* Each item's callbacks are cached per id so a controlled `<textarea>`'s ref identity survives every
* keystroke; without that, React detaches and re-attaches the node on each render and the focus
* effect drags the caret back to the end mid-edit.
*/
function useTranscriptItems({ messages, visibleStart, streamingMessageId, options, }) {
const [drafts, setDrafts] = useState(() => new Map());
const [copied, setCopied] = useState(() => new Set());
const [openReasoning, setOpenReasoning] = useState(() => new Map());
// A question's picks, under `toolCallId|questionId`. Presence IS "the user touched this
// question" — an absent entry is what makes the submission omit it and take the server's default.
const [picks, setPicks] = useState(() => new Map());
// Which decision each parked call is sending, not merely that one is: the two affordances on a
// call are never in flight together, and only the pressed one should report progress.
const [settling, setSettling] = useState(() => new Map());
const [settleErrors, setSettleErrors] = useState(() => new Map());
const backend = useAgentBackend();
const handlers = settleHandlers(options, backend);
const latest = useRef({ options, handlers, drafts, picks, messages });
latest.current = { options, handlers, drafts, picks, messages };
const copyTimers = useRef(new Map());
useEffect(() => () => {
for (const timer of copyTimers.current.values()) {
clearTimeout(timer);
}
copyTimers.current.clear();
}, []);
const callbackCache = useRef(new Map());
const reasoningToggleCache = useRef(new Map());
const uiBlocks = useRef(new Map());
const toggleReasoning = useCallback((key, open) => {
setOpenReasoning((current) => {
const next = new Map(current);
next.set(key, open ?? !(current.get(key) ?? false));
return next;
});
}, []);
const stableToggle = useCallback((key) => {
let cached = reasoningToggleCache.current.get(key);
if (!cached) {
cached = (open) => toggleReasoning(key, open);
reasoningToggleCache.current.set(key, cached);
}
return cached;
}, [toggleReasoning]);
const pick = useCallback((toolCallId, questionId, values) => {
setPicks((current) => new Map(current).set(pickKey(toolCallId, questionId), values));
}, []);
/**
* Run one decision against the server. A rejection has to land somewhere the user can see: the
* call stays parked either way, and a form that silently does nothing on submit is
* indistinguishable from a broken button.
*/
const settle = useCallback((toolCallId, action, send) => {
setSettleErrors((current) => {
if (!current.has(toolCallId)) {
return current;
}
const next = new Map(current);
next.delete(toolCallId);
return next;
});
setSettling((current) => new Map(current).set(toolCallId, action));
const failed = (error) => {
setSettling((current) => {
const next = new Map(current);
next.delete(toolCallId);
return next;
});
setSettleErrors((current) => new Map(current).set(toolCallId, {
message: error instanceof Error ? error.message : 'Could not settle this',
code: settleErrorCode(error),
}));
};
// Sent in the click's own tick, not a microtask later — a synchronous throw and a rejected
// promise are the same failure to the person looking at the form.
try {
void Promise.resolve(send()).catch(failed);
}
catch (error) {
failed(error);
}
}, []);
const answer = useCallback((toolCallId) => {
const current = latest.current;
const onAnswer = current.handlers.onAnswer;
if (!onAnswer) {
return;
}
settle(toolCallId, 'answer', () => onAnswer({ toolCallId, answers: answersFor(current.picks, toolCallId) }));
}, [settle]);
const skip = useCallback((toolCallId) => {
const onSkip = latest.current.handlers.onSkip;
if (onSkip) {
settle(toolCallId, 'skip', () => onSkip({ toolCallId }));
}
}, [settle]);
const approve = useCallback((toolCallId, options, target) => {
const onApprove = latest.current.handlers.onApprove;
if (onApprove) {
settle(toolCallId, 'approve', () => onApprove({
toolCallId,
...(target === undefined ? {} : { target }),
...(options?.remember === true ? { remember: true } : {}),
}));
}
}, [settle]);
const reject = useCallback((toolCallId, target) => {
const onReject = latest.current.handlers.onReject;
if (onReject) {
settle(toolCallId, 'reject', () => onReject({ toolCallId, ...(target === undefined ? {} : { target }) }));
}
}, [settle]);
const callbacksFor = useCallback((id) => {
const cached = callbackCache.current.get(id);
if (cached) {
return cached;
}
const textFor = () => extractMessageText(latest.current.messages.find((message) => message.id === id)?.parts);
const created = {
copy: () => {
// A UI action copies as what its chip says, not as the JSON block the model reads.
const raw = textFor();
const message = latest.current.messages.find((candidate) => candidate.id === id);
const action = message?.role === 'user' ? readUiActionText(raw) : null;
const text = action !== null ? uiActionSummary(action) : raw;
if (!text) {
return;
}
const write = latest.current.options.writeClipboard ??
((value) => navigator.clipboard.writeText(value));
void Promise.resolve()
.then(() => write(text))
.then(() => {
setCopied((current) => new Set(current).add(id));
const existing = copyTimers.current.get(id);
if (existing) {
clearTimeout(existing);
}
copyTimers.current.set(id, setTimeout(() => {
copyTimers.current.delete(id);
setCopied((current) => {
const next = new Set(current);
next.delete(id);
return next;
});
}, latest.current.options.copyResetMs ?? DEFAULT_COPY_RESET_MS));
})
.catch(() => {
// Clipboard can be unavailable (insecure context / denied permission) — fail quietly.
});
},
startEdit: () => {
const text = textFor();
setDrafts((current) => new Map(current).set(id, text));
},
cancelEdit: () => {
setDrafts((current) => {
const next = new Map(current);
next.delete(id);
return next;
});
},
setDraft: (value) => {
setDrafts((current) => new Map(current).set(id, value));
},
save: () => {
const next = (latest.current.drafts.get(id) ?? '').trim();
if (!next) {
return;
}
created.cancelEdit();
void latest.current.options.onEditSubmit?.({ messageId: id, text: next });
},
fork: () => {
void latest.current.options.onFork?.({ messageId: id });
},
regenerate: () => {
void latest.current.options.onRegenerate?.({ messageId: id });
},
textareaRef: (element) => {
if (!element) {
return;
}
element.focus();
element.setSelectionRange(element.value.length, element.value.length);
},
onTextareaChange: (event) => {
created.setDraft(event.target.value);
},
onTextareaKeyDown: (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
created.save();
}
if (event.key === 'Escape') {
event.preventDefault();
created.cancelEdit();
}
},
};
callbackCache.current.set(id, created);
return created;
}, []);
const lastAssistantId = findLastAssistant(messages)?.id ?? null;
const items = [];
// Pushed components of the last render, by block key: an unchanged one keeps its object.
const previousUi = uiBlocks.current;
const keptUi = new Map();
const keepUiIdentity = (blocks) => blocks.map((block) => {
if (block.kind !== 'ui')
return block;
const kept = shareStructure(previousUi.get(block.key), block);
keptUi.set(block.key, kept);
return kept;
});
for (let index = visibleStart; index < messages.length; index++) {
const message = messages[index];
if (!message) {
continue;
}
const callbacks = callbacksFor(message.id);
const draft = drafts.get(message.id);
const isEditing = draft !== undefined;
const text = extractMessageText(message.parts);
const usage = (options.getUsage ?? usageFromMetadata)(message);
const isUser = message.role === 'user';
const uiAction = isUser ? readUiActionText(text) : null;
const isAssistant = message.role === 'assistant';
const isLastAssistant = isAssistant && message.id === lastAssistantId;
items.push({
id: message.id,
message,
role: message.role,
index,
isUser,
isAssistant,
isLastAssistant,
isStreaming: message.id === streamingMessageId,
uiAction,
blocks: keepUiIdentity(buildTranscriptBlocks(message, {
isReasoningOpen: (key, isStreamingRun) => openReasoning.get(key) ?? isStreamingRun,
toggleReasoning: (key, open) => stableToggle(key)(open),
...(options.sources !== undefined ? { sources: options.sources } : {}),
...(options.toolCatalog !== undefined ? { toolCatalog: options.toolCatalog } : {}),
...(options.hideRetriedFailures !== undefined
? { hideRetriedFailures: options.hideRetriedFailures }
: {}),
// Read-only still lifts question sets into their block, so the outcome shows.
...(handlers.onAnswer !== undefined ||
(options.readOnly === true && options.onAnswer !== null)
? {
elicitation: {
picked: (toolCallId, questionId) => picks.get(pickKey(toolCallId, questionId)),
pick,
canAnswer: handlers.onAnswer !== undefined,
canSkip: handlers.onSkip !== undefined,
answer,
skip,
submitting: (toolCallId) => settling.get(toolCallId) ?? null,
errorOf: (toolCallId) => settleErrors.get(toolCallId)?.message ?? null,
errorCodeOf: (toolCallId) => settleErrors.get(toolCallId)?.code ?? null,
},
}
: {}),
...(handlers.onApprove !== undefined || handlers.onReject !== undefined
? {
approval: {
canApprove: handlers.onApprove !== undefined,
canReject: handlers.onReject !== undefined,
approve,
reject,
submitting: (toolCallId) => settling.get(toolCallId) ?? null,
errorOf: (toolCallId) => settleErrors.get(toolCallId)?.message ?? null,
errorCodeOf: (toolCallId) => settleErrors.get(toolCallId)?.code ?? null,
},
}
: {}),
})),
text,
usage: usage ? describeUsage(usage) : null,
timestamp: describeTimestamp((options.getCreatedAt ?? createdAtFromMetadata)(message)),
copy: {
available: text.length > 0,
copied: copied.has(message.id),
copy: callbacks.copy,
},
edit: {
available: isUser &&
options.readOnly !== true &&
options.editable === true &&
options.onEditSubmit !== undefined,
isEditing,
draft: draft ?? text,
canSave: (draft ?? '').trim().length > 0,
start: callbacks.startEdit,
cancel: callbacks.cancelEdit,
setDraft: callbacks.setDraft,
save: callbacks.save,
getTextareaProps: () => ({
ref: callbacks.textareaRef,
value: draft ?? text,
onChange: callbacks.onTextareaChange,
onKeyDown: callbacks.onTextareaKeyDown,
}),
},
fork: {
available: options.readOnly !== true && options.onFork !== undefined,
run: callbacks.fork,
},
regenerate: {
available: isLastAssistant &&
options.readOnly !== true &&
options.regeneratable === true &&
options.onRegenerate !== undefined,
run: callbacks.regenerate,
},
});
}
uiBlocks.current = keptUi;
return items;
}
/**
* The handler for each human decision: the caller's, else the backend's own call — so a parked
* approval or question set is never a card nobody can act on. `null` opts a decision out.
*/
function settleHandlers(options, backend) {
const pick = (own, fallback) => options.readOnly === true || own === null ? undefined : (own ?? fallback);
return {
onAnswer: pick(options.onAnswer, backend.answerToolCall
? async (input) => {
await backend.answerToolCall?.(input);
}
: undefined),
onSkip: pick(options.onSkip, backend.skipToolCall
? async (input) => {
await backend.skipToolCall?.(input);
}
: undefined),
onApprove: pick(options.onApprove, backend.approveToolCall || backend.approveActionProposal
? async (input) => {
if (input.target?.kind === 'proposal') {
if (input.target.threadId === undefined)
throw new Error('A proposal decision requires its thread scope');
await requireBackendMethod(backend, 'approveActionProposal')({
threadId: input.target.threadId,
proposalId: input.target.proposalId,
...(input.remember === undefined ? {} : { remember: input.remember }),
});
}
else
await requireBackendMethod(backend, 'approveToolCall')(input);
}
: undefined),
onReject: pick(options.onReject, backend.rejectToolCall || backend.rejectActionProposal
? async (input) => {
if (input.target?.kind === 'proposal') {
if (input.target.threadId === undefined)
throw new Error('A proposal decision requires its thread scope');
await requireBackendMethod(backend, 'rejectActionProposal')({
threadId: input.target.threadId,
proposalId: input.target.proposalId,
...(input.reason === undefined ? {} : { reason: input.reason }),
});
}
else
await requireBackendMethod(backend, 'rejectToolCall')(input);
}
: undefined),
};
}
function metadataOf(message) {
const metadata = message.metadata;
return metadata !== null && typeof metadata === 'object'
? metadata
: {};
}
/** The default `getUsage`: what the library stamps on `metadata.usage`. */
function usageFromMetadata(message) {
const usage = metadataOf(message).usage;
if (usage === undefined)
return null;
return {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
costUsd: usage.costUsd ?? null,
};
}
/** The default `getCreatedAt`: what the library stamps on `metadata.createdAt`. */
function createdAtFromMetadata(message) {
return metadataOf(message).createdAt ?? null;
}
/** `|` never appears in either half: a tool-call id and a question id are both opaque tokens. */
function pickKey(toolCallId, questionId) {
return `${toolCallId}|${questionId}`;
}
/** The picks belonging to one question set, as the answers a submission carries. */
function answersFor(picks, toolCallId) {
const prefix = `${toolCallId}|`;
const answers = {};
for (const [key, values] of picks) {
if (key.startsWith(prefix)) {
answers[key.slice(prefix.length)] = values;
}
}
return answers;
}
function findLastAssistant(messages) {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message?.role === 'assistant') {
return message;
}
}
return undefined;
}
/**
* Changes as the transcript grows, including WITHIN the message being streamed — the array is
* mutated in place while tokens arrive, so its length alone never moves during a turn.
*/
function contentSignature(messages) {
const last = messages.at(-1);
if (!last) {
return '0';
}
let length = 0;
for (const part of last.parts ?? []) {
if ('text' in part && typeof part.text === 'string') {
length += part.text.length;
}
}
return `${messages.length}|${last.id}|${last.parts?.length ?? 0}|${length}`;
}
/** The server's machine-readable reason for refusing a decision (`run_not_active`, …), if any. */
function settleErrorCode(error) {
const code = error?.code;
return typeof code === 'string' && code !== '' ? code : null;
}
//# sourceMappingURL=use-chat-transcript.js.map