@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
244 lines • 10.1 kB
JavaScript
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { applyCompletion, filterAutocompleteItems, findActiveTrigger, } from './model.js';
/**
* The state machine behind a `/`-style autocomplete in a chat composer: which trigger the caret is
* inside, what the query is, which items answer it, which one is highlighted, and what accepting
* one does to the text and the caret. It renders nothing and knows nothing about skills, agents or
* threads — a host supplies sources, and the same machine drives `@` as drives `/`.
*
* The keys the open menu owns (↑ ↓ Enter Tab Escape) are consumed with `preventDefault`, which is
* how a composer that sends on Enter knows to stand down: call this handler first and return early
* when the event comes back `defaultPrevented`.
*/
export function useComposerAutocomplete(options) {
const { value, sources, acceptOnTab = true } = options;
const latest = useRef(options);
latest.current = options;
const inputRef = useRef(null);
const [caret, setCaret] = useState(value.length);
const [items, setItems] = useState([]);
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [activeIndex, setActiveIndex] = useState(0);
// The trigger index Escape closed. Cleared the moment the caret leaves that token, so deleting
// the trigger and typing it again offers the menu rather than staying silently dismissed.
const [dismissedIndex, setDismissedIndex] = useState(null);
// Clamped: a host that resets the draft externally leaves the caret past the end of the text.
const safeCaret = Math.min(caret, value.length);
const match = useMemo(() => findActiveTrigger(value, safeCaret, sources), [value, safeCaret, sources]);
const isActive = match !== null && dismissedIndex !== match.index;
const matchRef = useRef(match);
matchRef.current = match;
const caretRef = useRef(safeCaret);
caretRef.current = safeCaret;
const itemsRef = useRef(items);
itemsRef.current = items;
const activeIndexRef = useRef(activeIndex);
activeIndexRef.current = activeIndex;
// Identifies the question being asked, so a re-render that changes neither the source nor the
// query does not re-ask — an async source would otherwise fetch on every unrelated render. The
// NUL separator cannot appear in either half, so no pair of them collides.
const requestKey = isActive && match ? `${match.source.id}\u0000${match.query}` : null;
useEffect(() => {
const current = matchRef.current;
if (requestKey === null || current === null) {
setItems([]);
setLoading(false);
setError(null);
return;
}
const { source, query } = current;
const controller = new AbortController();
// The cleanup below runs before the next query's effect, so a slow answer to an abandoned
// query finds `superseded` already true and is dropped instead of overwriting a newer list.
let superseded = false;
const settle = (next) => {
if (superseded)
return;
setItems((source.filter ?? filterAutocompleteItems)(next, query));
setActiveIndex(0);
setLoading(false);
setError(null);
};
const failed = (cause) => {
if (superseded)
return;
setItems([]);
setLoading(false);
setError(cause instanceof Error ? cause.message : 'Could not load suggestions');
latest.current.onError?.(cause, source);
};
let result;
try {
result = source.getItems(query, controller.signal);
}
catch (cause) {
failed(cause);
return;
}
// A static source answers in the same tick: settling synchronously spares the list a frame of
// emptiness between the trigger being typed and its items appearing.
if (!isPromise(result)) {
settle(result);
return;
}
setLoading(true);
setError(null);
result.then(settle, failed);
return () => {
superseded = true;
controller.abort();
};
}, [requestKey]);
// Open the moment the caret is inside a live trigger token, before any items are in: an empty
// menu that says "no matches" is an answer, whereas a menu that never appears reads as broken.
// The keys the menu owns are still only taken when there is something to take them for.
const isOpen = isActive;
const activeItem = isOpen ? (items[activeIndex] ?? null) : null;
const reactId = useId();
const listboxId = options.id ?? `agent-autocomplete-${reactId}`;
const optionId = useCallback((index) => `${listboxId}-option-${index}`, [listboxId]);
const highlight = useCallback((index) => {
const total = itemsRef.current.length;
if (total === 0)
return;
setActiveIndex(((index % total) + total) % total);
}, []);
const moveHighlight = useCallback((delta) => highlight(activeIndexRef.current + delta), [highlight]);
const dismiss = useCallback(() => {
const current = matchRef.current;
if (current)
setDismissedIndex(current.index);
}, []);
// Set after an accept and consumed on the next commit: the textarea is controlled, so React
// writes the new value only once this render lands and any caret set before that is overwritten.
const pendingCaret = useRef(null);
const accept = useCallback((item) => {
const current = matchRef.current;
if (!current)
return;
const chosen = item ?? itemsRef.current[activeIndexRef.current];
if (!chosen)
return;
const edit = applyCompletion(latest.current.value, caretRef.current, current, chosen);
latest.current.onValueChange(edit.text);
setCaret(edit.caret);
setDismissedIndex(null);
pendingCaret.current = edit.caret;
}, []);
useLayoutEffect(() => {
const position = pendingCaret.current;
if (position === null)
return;
pendingCaret.current = null;
inputRef.current?.setSelectionRange(position, position);
});
const readCaret = useCallback((element) => {
setCaret(element.selectionStart ?? element.value.length);
}, []);
const onKeyDown = useCallback((event) => {
if (!isOpen)
return;
const hasItems = itemsRef.current.length > 0;
switch (event.key) {
case 'ArrowDown':
if (!hasItems)
return;
event.preventDefault();
moveHighlight(1);
return;
case 'ArrowUp':
if (!hasItems)
return;
event.preventDefault();
moveHighlight(-1);
return;
case 'Enter':
if (!hasItems)
return;
event.preventDefault();
accept();
return;
case 'Tab':
// Shift+Tab stays a backwards focus move: a completion menu is not a focus trap.
if (!acceptOnTab || event.shiftKey || !hasItems)
return;
event.preventDefault();
accept();
return;
case 'Escape':
event.preventDefault();
dismiss();
return;
default:
return;
}
}, [isOpen, acceptOnTab, accept, dismiss, moveHighlight]);
const onChange = useCallback((event) => {
readCaret(event.target);
// A token the user has walked away from is no longer dismissed — it is gone.
const next = findActiveTrigger(event.target.value, event.target.selectionStart ?? event.target.value.length, latest.current.sources);
setDismissedIndex((current) => current !== null && next?.index === current ? current : null);
}, [readCaret]);
const onSelect = useCallback((event) => readCaret(event.currentTarget), [readCaret]);
const onClick = useCallback((event) => readCaret(event.currentTarget), [readCaret]);
const setInput = useCallback((element) => {
inputRef.current = element;
}, []);
const getInputProps = useCallback(() => ({
ref: setInput,
onChange,
onKeyDown,
onSelect,
onClick,
role: 'combobox',
'aria-expanded': isOpen,
'aria-autocomplete': 'list',
'aria-haspopup': 'listbox',
...(isOpen ? { 'aria-controls': listboxId } : {}),
...(activeItem ? { 'aria-activedescendant': optionId(activeIndex) } : {}),
}), [
setInput,
onChange,
onKeyDown,
onSelect,
onClick,
isOpen,
listboxId,
activeItem,
activeIndex,
optionId,
]);
const getListboxProps = useCallback(() => ({
id: listboxId,
role: 'listbox',
'aria-label': match?.source.label ?? match?.source.id ?? 'Suggestions',
}), [listboxId, match]);
const getOptionProps = useCallback((index) => ({
id: optionId(index),
role: 'option',
'aria-selected': index === activeIndex,
}), [optionId, activeIndex]);
return {
isOpen,
source: isOpen && match ? match.source : null,
trigger: isOpen && match ? match.source.trigger : null,
query: match?.query ?? '',
items,
activeIndex,
activeItem,
isLoading,
error,
highlight,
moveHighlight,
accept,
dismiss,
getInputProps,
getListboxProps,
getOptionProps,
};
}
function isPromise(value) {
return typeof value.then === 'function';
}
//# sourceMappingURL=use-composer-autocomplete.js.map