diff --git a/client/src/components/Chat/Input/SkillsCommand.tsx b/client/src/components/Chat/Input/SkillsCommand.tsx index f204acf714..1d2604e78a 100644 --- a/client/src/components/Chat/Input/SkillsCommand.tsx +++ b/client/src/components/Chat/Input/SkillsCommand.tsx @@ -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; diff --git a/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx b/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx index a4951798dd..e032206950 100644 --- a/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx @@ -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; }; @@ -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(); + + 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(); + + 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( + , + ); + const nextTextAreaRef = { + current: textAreaRef.current, + } as React.MutableRefObject; + + rerender(); + + 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('$'); diff --git a/client/src/hooks/Input/useHandleKeyUp.spec.ts b/client/src/hooks/Input/useHandleKeyUp.spec.ts index c50b63e8d5..fdfe836e0d 100644 --- a/client/src/hooks/Input/useHandleKeyUp.spec.ts +++ b/client/src/hooks/Input/useHandleKeyUp.spec.ts @@ -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', () => { diff --git a/client/src/hooks/Input/useInitPopoverInput.ts b/client/src/hooks/Input/useInitPopoverInput.ts index 8fd2507396..a3a28a2633 100644 --- a/client/src/hooks/Input/useInitPopoverInput.ts +++ b/client/src/hooks/Input/useInitPopoverInput.ts @@ -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; textAreaRef: React.MutableRefObject; 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;