📏 fix: Scope Skill Command Query to Text Before the Caret (#14604)

This commit is contained in:
Danny Avila 2026-08-03 07:53:30 -04:00 committed by GitHub
parent 664290c653
commit 6bbbee7a78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 71 additions and 19 deletions

View file

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

View file

@ -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('$');

View file

@ -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', () => {

View file

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