mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
📏 fix: Scope Skill Command Query to Text Before the Caret (#14604)
This commit is contained in:
parent
664290c653
commit
6bbbee7a78
4 changed files with 71 additions and 19 deletions
|
|
@ -11,7 +11,6 @@ import { useSkillsInfiniteQuery } from '~/data-provider';
|
|||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import { isEphemeralAgent } from '~/common';
|
||||
import { removeCharIfLast } from '~/utils';
|
||||
import MentionItem from './MentionItem';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -176,6 +175,7 @@ function SkillsCommandContent({
|
|||
inputRef,
|
||||
textAreaRef,
|
||||
commandChar,
|
||||
preserveTextAfterCursor: true,
|
||||
setSearchValue,
|
||||
setOpen,
|
||||
});
|
||||
|
|
@ -190,10 +190,6 @@ function SkillsCommandContent({
|
|||
setOpen(false);
|
||||
setShowSkillsPopover(false);
|
||||
|
||||
if (textAreaRef.current) {
|
||||
removeCharIfLast(textAreaRef.current, commandChar);
|
||||
}
|
||||
|
||||
setEphemeralAgent((prev) => {
|
||||
if (prev?.skills) {
|
||||
return prev;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* PR has to honor: when a user picks a skill in the `$` popover the
|
||||
* component must (a) push the skill name onto the per-conversation
|
||||
* `pendingManualSkillsByConvoId` atom, (b) flip `ephemeralAgent.skills`
|
||||
* to true, and (c) insert `$skill-name ` into the textarea.
|
||||
* to true, and (c) consume only the `$` command prefix from the textarea.
|
||||
*
|
||||
* Also covers the Phase 2 filter composition: per-agent skill scope
|
||||
* intersects with the ACL catalog, and per-user active-state toggles
|
||||
|
|
@ -115,9 +115,10 @@ jest.mock('react-virtualized', () => ({
|
|||
|
||||
import SkillsCommand, { filterSkillsForPopover } from '../SkillsCommand';
|
||||
|
||||
const makeTextarea = (initial = '$') => {
|
||||
const makeTextarea = (initial = '$', selectionStart = initial.length) => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = initial;
|
||||
textarea.setSelectionRange(selectionStart, selectionStart);
|
||||
document.body.appendChild(textarea);
|
||||
return { current: textarea } as React.MutableRefObject<HTMLTextAreaElement | null>;
|
||||
};
|
||||
|
|
@ -189,6 +190,47 @@ describe('SkillsCommand', () => {
|
|||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('preserves an existing draft when $ is inserted at the beginning', async () => {
|
||||
const user = userEvent.setup();
|
||||
const textAreaRef = makeTextarea('$Keep this draft', 1);
|
||||
|
||||
render(<SkillsCommand index={0} textAreaRef={textAreaRef} conversationId={CONVO_ID} />);
|
||||
|
||||
expect(screen.getByPlaceholderText('com_ui_skills_command_placeholder')).toHaveValue('');
|
||||
expect(textAreaRef.current).toHaveValue('Keep this draft');
|
||||
expect(textAreaRef.current?.selectionStart).toBe(0);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Brand Guidelines/i }));
|
||||
|
||||
expect(textAreaRef.current).toHaveValue('Keep this draft');
|
||||
expect(document.activeElement).toBe(textAreaRef.current);
|
||||
});
|
||||
|
||||
it('preserves a trailing $ in the existing draft after selecting a skill', async () => {
|
||||
const user = userEvent.setup();
|
||||
const textAreaRef = makeTextarea('$Keep this $', 1);
|
||||
|
||||
render(<SkillsCommand index={0} textAreaRef={textAreaRef} conversationId={CONVO_ID} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Brand Guidelines/i }));
|
||||
|
||||
expect(textAreaRef.current).toHaveValue('Keep this $');
|
||||
});
|
||||
|
||||
it('does not consume a preserved leading $ when the popover input ref reattaches', () => {
|
||||
const textAreaRef = makeTextarea('$$100', 1);
|
||||
const { rerender } = render(
|
||||
<SkillsCommand index={0} textAreaRef={textAreaRef} conversationId={CONVO_ID} />,
|
||||
);
|
||||
const nextTextAreaRef = {
|
||||
current: textAreaRef.current,
|
||||
} as React.MutableRefObject<HTMLTextAreaElement | null>;
|
||||
|
||||
rerender(<SkillsCommand index={0} textAreaRef={nextTextAreaRef} conversationId={CONVO_ID} />);
|
||||
|
||||
expect(nextTextAreaRef.current).toHaveValue('$100');
|
||||
});
|
||||
|
||||
it('selecting a skill pushes to pendingManualSkillsByConvoId, flips ephemeralAgent.skills, strips the $ trigger from the textarea, and closes the popover', async () => {
|
||||
const user = userEvent.setup();
|
||||
const textAreaRef = makeTextarea('$');
|
||||
|
|
|
|||
|
|
@ -177,6 +177,15 @@ describe('useHandleKeyUp', () => {
|
|||
|
||||
expect(setShowSkillsPopover).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('triggers $ skill command when $ is inserted before an existing draft', () => {
|
||||
const ref = makeTextAreaRef('$Keep this draft', 1);
|
||||
const { handleKeyUp, setShowSkillsPopover } = renderUseHandleKeyUp(ref);
|
||||
|
||||
act(() => handleKeyUp(makeKeyEvent('$')));
|
||||
|
||||
expect(setShowSkillsPopover).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fast typing — cursor past position 1 but text is short', () => {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
/** Creates a callback ref that focuses the popover input, transfers the command text as a search prefix, and clears the textarea. */
|
||||
/** Creates a callback ref that focuses the popover input and transfers command text into search. */
|
||||
const useInitPopoverInput = ({
|
||||
inputRef,
|
||||
textAreaRef,
|
||||
commandChar,
|
||||
preserveTextAfterCursor = false,
|
||||
setSearchValue,
|
||||
setOpen,
|
||||
}: {
|
||||
inputRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
|
||||
commandChar: string;
|
||||
preserveTextAfterCursor?: boolean;
|
||||
setSearchValue: (value: string) => void;
|
||||
setOpen: (value: boolean) => void;
|
||||
}) =>
|
||||
|
|
@ -20,23 +22,26 @@ const useInitPopoverInput = ({
|
|||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const textarea = textAreaRef.current;
|
||||
const text = textarea?.value;
|
||||
const selectionStart = textarea?.selectionStart;
|
||||
node.focus();
|
||||
setOpen(true);
|
||||
const textarea = textAreaRef.current;
|
||||
if (!textarea) {
|
||||
if (!textarea || typeof text !== 'string' || typeof selectionStart !== 'number') {
|
||||
return;
|
||||
}
|
||||
const text = textarea.value;
|
||||
if (text.length > 0 && text[0] === commandChar) {
|
||||
if (text.length > 1) {
|
||||
setSearchValue(text.slice(1));
|
||||
}
|
||||
textarea.value = '';
|
||||
textarea.setSelectionRange(0, 0);
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
if (!text.startsWith(commandChar) || selectionStart < commandChar.length) {
|
||||
return;
|
||||
}
|
||||
const commandEnd = preserveTextAfterCursor ? selectionStart : text.length;
|
||||
if (commandEnd > commandChar.length) {
|
||||
setSearchValue(text.slice(commandChar.length, commandEnd));
|
||||
}
|
||||
textarea.value = preserveTextAfterCursor ? text.slice(selectionStart) : '';
|
||||
textarea.setSelectionRange(0, 0);
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
},
|
||||
[inputRef, textAreaRef, commandChar, setSearchValue, setOpen],
|
||||
[inputRef, textAreaRef, commandChar, preserveTextAfterCursor, setSearchValue, setOpen],
|
||||
);
|
||||
|
||||
export default useInitPopoverInput;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue