UNPKG

@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
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