@assistant-ui/react
Version:
Open-source TypeScript/React library for building production-grade AI chat experiences
116 lines (115 loc) • 4.27 kB
JavaScript
"use client";
import { useTriggerPopoverRootContextOptional } from "../primitives/composer/trigger/TriggerPopoverRootContext.js";
import { getThreadMessageText } from "@assistant-ui/core/internal";
import { useCallback, useEffect, useMemo, useRef } from "@assistant-ui/tap/react-shim";
import { useAui } from "@assistant-ui/store";
import { flushTapSync } from "@assistant-ui/tap";
// src/unstable/useComposerInputHistory.ts
const deriveHistory = (messages) => {
const entries = [];
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role !== "user") continue;
const text = getThreadMessageText(message).trim();
if (!text) continue;
if (entries[entries.length - 1] === text) continue;
entries.push(text);
}
return entries;
};
const isOnFirstLine = (value, caret) => !value.slice(0, caret).includes("\n");
const isOnLastLine = (value, caret) => !value.slice(caret).includes("\n");
/**
* Terminal-style input history for the thread composer: ArrowUp on an
* empty draft recalls previously sent user messages (newest first),
* ArrowDown steps back toward the newest and finally restores the draft
* that was being typed when browsing started.
*
* Recall only triggers when the caret is on the first/last line with no
* selection, so multi-line editing keeps native arrow behavior. The
* handler yields to an open mention/slash popover, to IME composition,
* to modifier keys, and to consumer handlers that already called
* `preventDefault`. It is inert on edit composers.
*
* @deprecated Experimental since 2026-06-11. Not scheduled for removal; the API may change in any release.
*
* @example
* ```tsx
* const history = unstable_useComposerInputHistory();
* <ComposerPrimitive.Input {...history} />
* ```
*/
function unstable_useComposerInputHistory() {
const aui = useAui();
const popoverCtx = useTriggerPopoverRootContextOptional();
const browseRef = useRef(null);
useEffect(() => {
if (aui.composer.getState().type !== "thread" || !aui.optional.threads) return void 0;
return aui.on("threads.selectionChanged", () => {
browseRef.current = null;
});
}, [aui]);
const onKeyDown = useCallback((e) => {
if (e.defaultPrevented) return;
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
if (e.nativeEvent.isComposing) return;
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
if (popoverCtx && popoverCtx.getActiveAria() !== null) return;
if (aui.composer.getState().type !== "thread") return;
const textarea = e.currentTarget;
const { selectionStart, selectionEnd, value } = textarea;
if (selectionStart !== selectionEnd) return;
if (browseRef.current && value !== browseRef.current.lastRecalledText) browseRef.current = null;
const browse = browseRef.current;
const commitText = (text) => {
flushTapSync(() => aui.composer.setText(text, { origin: "recall" }));
requestAnimationFrame(() => {
textarea.setSelectionRange(text.length, text.length);
});
e.preventDefault();
};
const recall = (history, cursor, draftSnapshot) => {
const entry = history[cursor];
if (entry === void 0) {
e.preventDefault();
return;
}
browseRef.current = {
cursor,
draftSnapshot,
lastRecalledText: entry
};
commitText(entry);
};
if (e.key === "ArrowUp") {
if (!isOnFirstLine(value, selectionStart)) return;
if (!browse) {
if (value.trim() !== "") return;
const history = deriveHistory(aui.thread.getState().messages);
if (history.length === 0) return;
recall(history, 0, value);
return;
}
const history = deriveHistory(aui.thread.getState().messages);
const next = browse.cursor + 1;
if (next >= history.length) {
e.preventDefault();
return;
}
recall(history, next, browse.draftSnapshot);
return;
}
if (!browse) return;
if (!isOnLastLine(value, selectionEnd)) return;
const next = browse.cursor - 1;
if (next < 0) {
browseRef.current = null;
commitText(browse.draftSnapshot);
return;
}
recall(deriveHistory(aui.thread.getState().messages), next, browse.draftSnapshot);
}, [aui, popoverCtx]);
return useMemo(() => ({ onKeyDown }), [onKeyDown]);
}
export { unstable_useComposerInputHistory };
//# sourceMappingURL=useComposerInputHistory.js.map