💲 feat: Manual Skill Invocation via $ Command Popover (UI only) (#12690)

* feat: add $ command popover for manual skill invocation

Adds a new `$` command trigger in the chat textarea that opens a
searchable popover listing user-invocable skills. Selecting a skill
inserts `$skill-name` into the message and enables the skills badge
on the ephemeral agent. Follows the same patterns as `@` mentions
and `/` prompts.

* test: update useHandleKeyUp tests for $ skill command

Add showSkillsPopoverFamily, dollarCommand, and SKILLS permission
to the store/recoil/access mocks. Include test cases for the $
trigger, toggle gating, and permission gating.

* fix: address review findings in SkillsCommand

- Exclude auto-mode skills from popover (isUserInvocable now
  returns false for InvocationMode.auto)
- Rewrite JSDoc to match the corrected filter logic
- Guard ArrowUp/ArrowDown against NaN when matches is empty
- Replace useRecoilState with useSetRecoilState for ephemeralAgent
  to avoid subscribing to unrelated agent state changes
- Move skills guard into setter callback and drop ephemeralAgent
  from handleSelect dependency array
- Add e.preventDefault() for Tab key alongside Enter
- Render error state (com_ui_skills_load_error) on query failure
- Render empty state (com_ui_skills_empty) when no matches
- Hoist ScrollText icon to module-level constant
- Export isUserInvocable for testability

* fix: address follow-up review findings

- Differentiate empty-catalog ("No skills yet") from no-match
  search ("No skills found") using existing com_ui_no_skills_found
- Clamp activeIndex when matches shrink from search filtering to
  prevent silent Enter/Tab failures
- Add early return after Escape handler to skip redundant checks
- Reorder package imports shortest-to-longest after react
- Add fast-typing test case for $ command ("$sk" at position 3)

* fix: gate $ command on assistants endpoint, fix Tab on empty matches

- Block $ popover on assistants/azureAssistants endpoints where
  ephemeralAgent is not sent in the submission payload
- Allow Tab to close the popover when matches is empty instead of
  trapping keyboard focus
- Add endpoint gating tests for $ on both assistants endpoints

* fix: reset skills popover on assistants switch + paginate skills query

- Close $ skills popover when endpoint switches to assistants or
  azureAssistants, mirroring the existing + command reset
- Switch SkillsCommand from a single-page list query to the
  cursor-paginated useSkillsInfiniteQuery and auto-fetch all pages
  so client-side search covers the full catalog instead of only
  the first 100 entries
- Show the spinner during background page fetches and suppress the
  empty-state copy until paging completes
- Add test for popover reset on endpoint switch

* fix: prevent currency hijack and form submit on $ command

- Reject the $ trigger when fast-typed text after $ does not start
  with a lowercase letter, so currency input like $100 or $5.99
  no longer opens the skills popover and clears the textarea
- preventDefault() on Enter when the popover has no matches so
  the surrounding chat form does not submit when the user dismisses
  the popover via Enter
- Add tests for $100 and $5.99 currency inputs

* fix: defer $ popover to second keystroke and stop pagination on errors

- Defer opening the skills popover until a letter follows the $
  character. Bare $ no longer triggers the popover, so starting a
  message with $100, $5.99, or $EUR is fully preserved (the
  textarea is not cleared by useInitPopoverInput on the first
  keystroke). $a, $skill, $my-skill still open as expected.
- Add a sticky paginationBlockedRef circuit breaker on the auto
  fetchNextPage effect so a transient page request error cannot
  spin into an unbounded retry loop when isError flips back to
  false on the next attempt.
- Update tests: bare $ no longer triggers, $a does, currency cases
  remain blocked.

* feat: scaffold structured manual-skill channel for follow-up PR

Add a per-conversation pendingManualSkillsByConvoId atom family
that SkillsCommand appends to on selection. This is the writer
half of the structured channel that will let a follow-up PR
deterministically prime SKILL.md as a meta user message before
the LLM turn (mirroring Claude Code's `/skill` invocation), so
the backend never has to regex-parse `$name` out of user text.

The submit pipeline does not yet read this atom; the textual
`$skill-name ` insertion remains the authoritative signal until
the follow-up wires the read + reset on submit. Reset is already
wired into useClearStates so the atom does not leak across
conversation switches.

* test: cover SkillsCommand selection-flow contract

Add a component test that locks in the contract the follow-up
manualSkills PR has to honor when a user picks a skill in the $
popover:

- Pushes the skill name onto pendingManualSkillsByConvoId (the
  per-conversation structured channel), with dedup
- Flips ephemeralAgent.skills to true via the callback-form setter
- Inserts $skill-name into the textarea as cosmetic confirmation
- Closes the popover via setShowSkillsPopover(false)
- Renders nothing when the popover atom is false

Mocks recoil setters and the skills query so the test exercises
the real component logic without spinning up the full provider
stack.

* revert: drop $ defer-until-letter guard for sibling consistency

Bring $ in line with @, /, and +: open the popover on the bare
trigger character. The earlier defer guard kept currency input
like \$100 from clobbering the textarea, but it broke the natural
"type \$ to browse skills" UX and was inconsistent with the other
trigger chars, none of which gate on the follow-up character
(/path/to/file, @username, +1 all clear the textarea the same way).
The currency hijack remains recoverable via Escape.

Drop the corresponding paste-protection tests for \$100 and \$5.99,
restore the bare-\$ trigger test.
This commit is contained in:
Danny Avila 2026-04-16 17:27:32 -04:00
parent 3b820415ad
commit 9b4ae068b2
10 changed files with 650 additions and 4 deletions

View file

@ -26,6 +26,7 @@ import AttachFileChat from './Files/AttachFileChat';
import FileFormChat from './Files/FileFormChat';
import { cn, removeFocusRings } from '~/utils';
import TextareaHeader from './TextareaHeader';
import SkillsCommand from './SkillsCommand';
import PromptsCommand from './PromptsCommand';
import AudioRecorder from './AudioRecorder';
import CollapseChat from './CollapseChat';
@ -254,6 +255,7 @@ const ChatForm = memo(function ChatForm({
textAreaRef={textAreaRef}
/>
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<SkillsCommand index={index} textAreaRef={textAreaRef} conversationId={conversationId} />
<div
onClick={handleContainerClick}
className={cn(

View file

@ -6,7 +6,7 @@ export interface MentionItemProps {
name: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
index: number;
type?: 'prompt' | 'mention' | 'add-convo';
type?: 'prompt' | 'mention' | 'add-convo' | 'skill';
icon?: React.ReactNode;
isActive?: boolean;
description?: string;

View file

@ -0,0 +1,331 @@
import { memo, useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { ScrollText } from 'lucide-react';
import { AutoSizer, List } from 'react-virtualized';
import { Spinner, useCombobox } from '@librechat/client';
import { InvocationMode } from 'librechat-data-provider';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import type { TSkillSummary } from 'librechat-data-provider';
import type { MentionOption } from '~/common';
import useInitPopoverInput from '~/hooks/Input/useInitPopoverInput';
import { useSkillsInfiniteQuery } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
import { removeCharIfLast } from '~/utils';
import MentionItem from './MentionItem';
import { useLocalize } from '~/hooks';
import store from '~/store';
const commandChar = '$';
const ROW_HEIGHT = 44;
const skillIcon = <ScrollText className="icon-md text-cyan-500" />;
/**
* Determines whether a skill should appear in the `$` command popover.
* `manual` and `both` are user-invocable. `auto` is model-only and hidden.
* Skills without an explicit mode (undefined) default to visible for
* backward compatibility until the backend persists `invocationMode`.
*/
export function isUserInvocable(skill: TSkillSummary): boolean {
const mode = skill.invocationMode;
if (mode == null || mode === InvocationMode.both) {
return true;
}
return mode === InvocationMode.manual;
}
function SkillsCommandContent({
index,
textAreaRef,
conversationId,
}: {
index: number;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
conversationId: string;
}) {
const localize = useLocalize();
const setShowSkillsPopover = useSetRecoilState(store.showSkillsPopoverFamily(index));
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
const setPendingManualSkills = useSetRecoilState(
store.pendingManualSkillsByConvoId(conversationId),
);
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
useSkillsInfiniteQuery({ limit: 50 });
/* Sticky circuit breaker: once any page request fails, stop auto-fetching
for the lifetime of the popover so a transient API error does not turn
into an unbounded retry loop (isError can flip back to false on the
next attempt, which would otherwise re-arm the auto-fetch effect). */
const paginationBlockedRef = useRef(false);
useEffect(() => {
if (isError) {
paginationBlockedRef.current = true;
}
}, [isError]);
/* Auto-fetch all pages so client-side search covers the full catalog,
not just the first page. The skills API is server-side capped. */
useEffect(() => {
if (paginationBlockedRef.current || isError) {
return;
}
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, isError, fetchNextPage]);
const skillOptions: MentionOption[] = useMemo(() => {
if (!data?.pages) {
return [];
}
return data.pages.reduce<MentionOption[]>((acc, page) => {
for (const skill of page.skills) {
if (isUserInvocable(skill)) {
acc.push({
label: skill.displayTitle ?? skill.name,
value: skill.name,
description: skill.description,
type: 'skill',
icon: skillIcon,
});
}
}
return acc;
}, []);
}, [data?.pages]);
const [activeIndex, setActiveIndex] = useState(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const { open, setOpen, searchValue, setSearchValue, matches } = useCombobox({
value: '',
options: skillOptions,
});
const initInputRef = useInitPopoverInput({
inputRef,
textAreaRef,
commandChar,
setSearchValue,
setOpen,
});
const handleSelect = useCallback(
(mention?: MentionOption) => {
if (!mention) {
return;
}
setSearchValue('');
setOpen(false);
setShowSkillsPopover(false);
if (textAreaRef.current) {
removeCharIfLast(textAreaRef.current, commandChar);
}
setEphemeralAgent((prev) => {
if (prev?.skills) {
return prev;
}
return { ...(prev || {}), skills: true };
});
/* Structured channel for manual skill invocations. The follow-up PR
will read this in the submit pipeline and prime the corresponding
SKILL.md as a meta user message before the LLM turn, mirroring
Claude Code's `/skill` deterministic injection. The textual
`$skill-name ` insertion below remains as user-visible confirmation
and is treated as cosmetic by that future pipeline. */
setPendingManualSkills((prev) =>
prev.includes(mention.value) ? prev : [...prev, mention.value],
);
const textarea = textAreaRef.current;
if (textarea) {
const insertion = `$${mention.value} `;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(textarea, insertion);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
textarea.focus();
textarea.setSelectionRange(insertion.length, insertion.length);
}
},
[
setSearchValue,
setOpen,
setShowSkillsPopover,
textAreaRef,
setEphemeralAgent,
setPendingManualSkills,
],
);
useEffect(() => {
if (!open) {
setActiveIndex(0);
}
}, [open]);
useEffect(() => {
setActiveIndex((prev) => Math.min(prev, Math.max(matches.length - 1, 0)));
}, [matches.length]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
useEffect(() => {
const el = document.getElementById(`skill-item-${activeIndex}`);
el?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
}, [activeIndex]);
const rowRenderer = ({
index,
key,
style,
}: {
index: number;
key: string;
style: React.CSSProperties;
}) => {
const mention = matches[index] as MentionOption;
return (
<MentionItem
index={index}
type="skill"
key={key}
style={style}
onClick={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = null;
handleSelect(mention);
}}
name={mention.label ?? ''}
icon={mention.icon}
description={mention.description}
isActive={index === activeIndex}
/>
);
};
return (
<div className="absolute bottom-28 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
<input
ref={initInputRef}
placeholder={localize('com_ui_skills_command_placeholder')}
className="mb-1 w-full border-0 bg-surface-tertiary-alt p-2 text-sm focus:outline-none dark:text-gray-200"
autoComplete="off"
value={searchValue}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setOpen(false);
setShowSkillsPopover(false);
textAreaRef.current?.focus();
return;
}
if (e.key === 'ArrowDown') {
if (matches.length === 0) {
return;
}
setActiveIndex((prevIndex) => (prevIndex + 1) % matches.length);
} else if (e.key === 'ArrowUp') {
if (matches.length === 0) {
return;
}
setActiveIndex((prevIndex) => (prevIndex - 1 + matches.length) % matches.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (matches.length === 0) {
if (e.key === 'Enter') {
e.preventDefault();
}
setOpen(false);
setShowSkillsPopover(false);
textAreaRef.current?.focus();
return;
}
e.preventDefault();
handleSelect(matches[activeIndex] as MentionOption | undefined);
} else if (e.key === 'Backspace' && searchValue === '') {
setOpen(false);
setShowSkillsPopover(false);
textAreaRef.current?.focus();
}
}}
onChange={(e) => setSearchValue(e.target.value)}
onFocus={() => setOpen(true)}
onBlur={() => {
timeoutRef.current = setTimeout(() => {
setOpen(false);
setShowSkillsPopover(false);
}, 150);
}}
/>
{open && (isLoading || isFetchingNextPage) && matches.length === 0 && (
<div className="flex h-32 items-center justify-center text-text-primary">
<Spinner />
</div>
)}
{open && isError && (
<div className="p-4 text-center text-sm text-text-secondary">
{localize('com_ui_skills_load_error')}
</div>
)}
{open && !isLoading && !isFetchingNextPage && !isError && matches.length === 0 && (
<div className="p-4 text-center text-sm text-text-secondary">
{localize(searchValue ? 'com_ui_no_skills_found' : 'com_ui_skills_empty')}
</div>
)}
{open && matches.length > 0 && (
<div className="max-h-40">
<AutoSizer disableHeight>
{({ width }) => (
<List
width={width}
overscanRowCount={5}
rowHeight={ROW_HEIGHT}
rowCount={matches.length}
rowRenderer={rowRenderer}
scrollToIndex={activeIndex}
height={Math.min(matches.length * ROW_HEIGHT, 160)}
/>
)}
</AutoSizer>
</div>
)}
</div>
</div>
);
}
const SkillsCommand = memo(function SkillsCommand({
index,
textAreaRef,
conversationId,
}: {
index: number;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
conversationId: string;
}) {
const show = useRecoilValue(store.showSkillsPopoverFamily(index));
if (!show) {
return null;
}
return (
<SkillsCommandContent index={index} textAreaRef={textAreaRef} conversationId={conversationId} />
);
});
export default SkillsCommand;

View file

@ -0,0 +1,187 @@
/**
* Locks in the selection-flow contract that the follow-up `manualSkills`
* 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.
*/
import React from 'react';
import { act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
const CONVO_ID = 'convo-1';
const mockSetShowSkillsPopover = jest.fn();
const mockSetEphemeralAgent = jest.fn();
const mockSetPendingManualSkills = jest.fn();
const mockShowSkillsPopover = { current: true };
jest.mock('recoil', () => {
const actual = jest.requireActual('recoil');
return {
...actual,
useRecoilValue: jest.fn((atom: unknown) => {
if (atom === 'show-skills-popover') {
return mockShowSkillsPopover.current;
}
return undefined;
}),
useRecoilState: jest.fn(() => [null, jest.fn()]),
useSetRecoilState: jest.fn((atom: unknown) => {
if (atom === 'show-skills-popover') {
return mockSetShowSkillsPopover;
}
if (atom === 'ephemeral-agent') {
return mockSetEphemeralAgent;
}
if (atom === 'pending-manual-skills') {
return mockSetPendingManualSkills;
}
return jest.fn();
}),
};
});
jest.mock('~/store', () => ({
__esModule: true,
default: {
showSkillsPopoverFamily: () => 'show-skills-popover',
pendingManualSkillsByConvoId: () => 'pending-manual-skills',
},
ephemeralAgentByConvoId: () => 'ephemeral-agent',
pendingManualSkillsByConvoId: () => 'pending-manual-skills',
}));
const mockUseSkillsInfiniteQuery = jest.fn();
jest.mock('~/data-provider', () => ({
useSkillsInfiniteQuery: () => mockUseSkillsInfiniteQuery(),
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('@librechat/client', () => {
const actual = jest.requireActual('@librechat/client');
return {
...actual,
Spinner: () => null,
};
});
/* react-virtualized renders nothing in jsdom without measured size; replace
AutoSizer + List with a flat ul so row clicks are exercised normally. */
jest.mock('react-virtualized', () => ({
...jest.requireActual('react-virtualized'),
AutoSizer: ({ children }: { children: (size: { width: number }) => React.ReactNode }) =>
children({ width: 320 }),
List: ({
rowCount,
rowRenderer,
}: {
rowCount: number;
rowRenderer: (args: {
index: number;
key: string;
style: React.CSSProperties;
}) => React.ReactNode;
}) => {
const rows: React.ReactNode[] = [];
for (let i = 0; i < rowCount; i++) {
rows.push(rowRenderer({ index: i, key: `row-${i}`, style: {} }));
}
return <ul data-testid="skills-list">{rows}</ul>;
},
}));
import SkillsCommand from '../SkillsCommand';
const makeTextarea = (initial = '$') => {
const textarea = document.createElement('textarea');
textarea.value = initial;
document.body.appendChild(textarea);
return { current: textarea } as React.MutableRefObject<HTMLTextAreaElement | null>;
};
const skillsResponse = {
pages: [
{
skills: [
{
_id: '1',
name: 'brand-guidelines',
displayTitle: 'Brand Guidelines',
description: 'Apply brand styling',
author: 'u',
authorName: 'U',
version: 1,
source: 'inline',
fileCount: 0,
createdAt: '',
updatedAt: '',
},
],
has_more: false,
after: null,
},
],
};
beforeEach(() => {
jest.clearAllMocks();
document.body.innerHTML = '';
mockShowSkillsPopover.current = true;
mockUseSkillsInfiniteQuery.mockReturnValue({
data: skillsResponse,
isLoading: false,
isError: false,
fetchNextPage: jest.fn(),
hasNextPage: false,
isFetchingNextPage: false,
});
});
describe('SkillsCommand', () => {
it('renders nothing when the popover atom is false', () => {
mockShowSkillsPopover.current = false;
const textAreaRef = makeTextarea();
const { container } = render(
<SkillsCommand index={0} textAreaRef={textAreaRef} conversationId={CONVO_ID} />,
);
expect(container).toBeEmptyDOMElement();
});
it('selecting a skill pushes to pendingManualSkillsByConvoId, flips ephemeralAgent.skills, inserts $name into textarea, and closes the popover', async () => {
const user = userEvent.setup();
const textAreaRef = makeTextarea('$');
render(<SkillsCommand index={0} textAreaRef={textAreaRef} conversationId={CONVO_ID} />);
const skillButton = await screen.findByRole('button', { name: /Brand Guidelines/i });
await act(async () => {
await user.click(skillButton);
});
/* Structured channel: the skill name is pushed into the per-convo atom,
which is the contract the follow-up PR depends on. */
expect(mockSetPendingManualSkills).toHaveBeenCalledTimes(1);
const updater = mockSetPendingManualSkills.mock.calls[0][0] as (prev: string[]) => string[];
expect(updater([])).toEqual(['brand-guidelines']);
expect(updater(['brand-guidelines'])).toEqual(['brand-guidelines']);
/* Ephemeral agent gets skills enabled so the badge lights up and the
backend includes the skill catalog. */
expect(mockSetEphemeralAgent).toHaveBeenCalledTimes(1);
const agentUpdater = mockSetEphemeralAgent.mock.calls[0][0] as (
prev: { skills?: boolean } | null,
) => { skills?: boolean };
expect(agentUpdater(null)).toEqual({ skills: true });
expect(agentUpdater({ skills: true })).toEqual({ skills: true });
/* Cosmetic textarea insertion remains in place for user feedback. */
expect(textAreaRef.current?.value).toBe('$brand-guidelines ');
/* Popover dismisses on selection. */
expect(mockSetShowSkillsPopover).toHaveBeenCalledWith(false);
});
});

View file

@ -32,6 +32,8 @@ export default function useClearStates() {
reset(store.showMentionPopoverFamily(key));
reset(store.showPlusPopoverFamily(key));
reset(store.showPromptsPopoverFamily(key));
reset(store.showSkillsPopoverFamily(key));
reset(store.pendingManualSkillsByConvoId(key.toString()));
reset(store.activePromptByIndex(key));
reset(store.globalAudioURLFamily(key));
reset(store.globalAudioFetchingFamily(key));

View file

@ -1,10 +1,12 @@
const mockSetShowMentionPopover = jest.fn();
const mockSetShowPlusPopover = jest.fn();
const mockSetShowPromptsPopover = jest.fn();
const mockSetShowSkillsPopover = jest.fn();
const mockHasPromptsAccess = { current: true };
const mockHasMultiConvoAccess = { current: true };
const mockHasSkillsAccess = { current: true };
const mockEndpoint = { current: 'openAI' as string | null };
const mockCommandToggles = { at: true, plus: true, slash: true };
const mockCommandToggles = { at: true, plus: true, slash: true, dollar: true };
jest.mock('recoil', () => ({
...jest.requireActual('recoil'),
@ -24,6 +26,9 @@ jest.mock('recoil', () => ({
if (atom === 'slashCommand') {
return mockCommandToggles.slash;
}
if (atom === 'dollarCommand') {
return mockCommandToggles.dollar;
}
return undefined;
}),
useSetRecoilState: jest.fn((atom: string) => {
@ -36,6 +41,9 @@ jest.mock('recoil', () => ({
if (atom === 'showPromptsPopoverFamily-0') {
return mockSetShowPromptsPopover;
}
if (atom === 'showSkillsPopoverFamily-0') {
return mockSetShowSkillsPopover;
}
return jest.fn();
}),
}));
@ -44,11 +52,13 @@ jest.mock('~/store', () => ({
showPromptsPopoverFamily: (idx: number) => `showPromptsPopoverFamily-${idx}`,
showMentionPopoverFamily: (idx: number) => `showMentionPopoverFamily-${idx}`,
showPlusPopoverFamily: (idx: number) => `showPlusPopoverFamily-${idx}`,
showSkillsPopoverFamily: (idx: number) => `showSkillsPopoverFamily-${idx}`,
effectiveEndpointByIndex: (idx: number) => `effectiveEndpointByIndex-${idx}`,
latestMessageFamily: (idx: number) => `latestMessageFamily-${idx}`,
atCommand: 'atCommand',
plusCommand: 'plusCommand',
slashCommand: 'slashCommand',
dollarCommand: 'dollarCommand',
}));
jest.mock('~/hooks/Roles/useHasAccess', () =>
@ -59,6 +69,9 @@ jest.mock('~/hooks/Roles/useHasAccess', () =>
if (permissionType === 'MULTI_CONVO') {
return mockHasMultiConvoAccess.current;
}
if (permissionType === 'SKILLS') {
return mockHasSkillsAccess.current;
}
return false;
}),
);
@ -96,6 +109,7 @@ const renderUseHandleKeyUp = (
setShowMentionPopover: mockSetShowMentionPopover,
setShowPlusPopover: mockSetShowPlusPopover,
setShowPromptsPopover: mockSetShowPromptsPopover,
setShowSkillsPopover: mockSetShowSkillsPopover,
};
};
@ -103,10 +117,12 @@ beforeEach(() => {
jest.clearAllMocks();
mockHasPromptsAccess.current = true;
mockHasMultiConvoAccess.current = true;
mockHasSkillsAccess.current = true;
mockEndpoint.current = 'openAI';
mockCommandToggles.at = true;
mockCommandToggles.plus = true;
mockCommandToggles.slash = true;
mockCommandToggles.dollar = true;
});
describe('useHandleKeyUp', () => {
@ -137,6 +153,15 @@ describe('useHandleKeyUp', () => {
expect(setShowPlusPopover).toHaveBeenCalledWith(true);
});
it('triggers $ skill command for "$" at position 1', () => {
const ref = makeTextAreaRef('$', 1);
const { handleKeyUp, setShowSkillsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('$')));
expect(setShowSkillsPopover).toHaveBeenCalledWith(true);
});
});
describe('fast typing — cursor past position 1 but text is short', () => {
@ -167,6 +192,15 @@ describe('useHandleKeyUp', () => {
expect(setShowPromptsPopover).toHaveBeenCalledWith(true);
});
it('triggers $ skill command for "$sk" (fast typed)', () => {
const ref = makeTextAreaRef('$sk', 3);
const { handleKeyUp, setShowSkillsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('k')));
expect(setShowSkillsPopover).toHaveBeenCalledWith(true);
});
it('does NOT trigger for text exceeding MAX_COMMAND_TRIGGER_LENGTH', () => {
const ref = makeTextAreaRef('/abcde', 6);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
@ -337,6 +371,16 @@ describe('useHandleKeyUp', () => {
expect(setShowPlusPopover).not.toHaveBeenCalled();
});
it('does NOT trigger $ skill command when dollarCommand toggle is disabled', () => {
mockCommandToggles.dollar = false;
const ref = makeTextAreaRef('$', 1);
const { handleKeyUp, setShowSkillsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('$')));
expect(setShowSkillsPopover).not.toHaveBeenCalled();
});
});
describe('permission gating', () => {
@ -370,6 +414,16 @@ describe('useHandleKeyUp', () => {
expect(setShowMentionPopover).toHaveBeenCalledWith(true);
});
it('does NOT trigger $ skill command without SKILLS access', () => {
mockHasSkillsAccess.current = false;
const ref = makeTextAreaRef('$', 1);
const { handleKeyUp, setShowSkillsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('$')));
expect(setShowSkillsPopover).not.toHaveBeenCalled();
});
});
describe('endpoint gating', () => {
@ -412,5 +466,33 @@ describe('useHandleKeyUp', () => {
expect(setShowPlusPopover).toHaveBeenCalledWith(true);
});
it('does NOT trigger $ skill command on assistants endpoint', () => {
mockEndpoint.current = 'assistants';
const ref = makeTextAreaRef('$', 1);
const { handleKeyUp, setShowSkillsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('$')));
expect(setShowSkillsPopover).not.toHaveBeenCalledWith(true);
});
it('does NOT trigger $ skill command on azureAssistants endpoint', () => {
mockEndpoint.current = 'azureAssistants';
const ref = makeTextAreaRef('$', 1);
const { handleKeyUp, setShowSkillsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('$')));
expect(setShowSkillsPopover).not.toHaveBeenCalledWith(true);
});
it('resets $ skills popover when endpoint switches to assistants', () => {
mockEndpoint.current = 'assistants';
const ref = makeTextAreaRef('', 0);
const { setShowSkillsPopover } = renderUseHandleKeyUp(ref);
expect(setShowSkillsPopover).toHaveBeenCalledWith(false);
});
});
});

View file

@ -61,21 +61,28 @@ const useHandleKeyUp = ({
permissionType: PermissionTypes.MULTI_CONVO,
permission: Permissions.USE,
});
const hasSkillsAccess = useHasAccess({
permissionType: PermissionTypes.SKILLS,
permission: Permissions.USE,
});
const latestMessage = useRecoilValue(store.latestMessageFamily(index));
const endpoint = useRecoilValue(store.effectiveEndpointByIndex(index));
const setShowMentionPopover = useSetRecoilState(store.showMentionPopoverFamily(index));
const setShowPlusPopover = useSetRecoilState(store.showPlusPopoverFamily(index));
const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index));
const setShowSkillsPopover = useSetRecoilState(store.showSkillsPopoverFamily(index));
const atCommandEnabled = useRecoilValue(store.atCommand);
const plusCommandEnabled = useRecoilValue(store.plusCommand);
const slashCommandEnabled = useRecoilValue(store.slashCommand);
const dollarCommandEnabled = useRecoilValue(store.dollarCommand);
useEffect(() => {
if (isAssistantsEndpoint(endpoint)) {
setShowPlusPopover(false);
setShowSkillsPopover(false);
}
}, [endpoint, setShowPlusPopover]);
}, [endpoint, setShowPlusPopover, setShowSkillsPopover]);
const handleAtCommand = useCallback(() => {
if (atCommandEnabled && shouldTriggerCommand(textAreaRef, '@')) {
@ -101,13 +108,23 @@ const useHandleKeyUp = ({
}
}, [textAreaRef, hasPromptsAccess, setShowPromptsPopover, slashCommandEnabled]);
const handleSkillsCommand = useCallback(() => {
if (!hasSkillsAccess || !dollarCommandEnabled || isAssistantsEndpoint(endpoint)) {
return;
}
if (shouldTriggerCommand(textAreaRef, '$')) {
setShowSkillsPopover(true);
}
}, [textAreaRef, hasSkillsAccess, setShowSkillsPopover, dollarCommandEnabled, endpoint]);
const commandHandlers = useMemo(
() => ({
'@': handleAtCommand,
'+': handlePlusCommand,
'/': handlePromptsCommand,
$: handleSkillsCommand,
}),
[handleAtCommand, handlePlusCommand, handlePromptsCommand],
[handleAtCommand, handlePlusCommand, handlePromptsCommand, handleSkillsCommand],
);
const handleUpArrow = useCallback(

View file

@ -1518,7 +1518,9 @@
"com_ui_skills_allow_share": "Allow sharing Skills",
"com_ui_skills_allow_share_public": "Allow sharing Skills publicly",
"com_ui_skills_allow_use": "Allow using Skills",
"com_ui_skills_command_placeholder": "Select a Skill by name",
"com_ui_skills_empty": "No skills yet",
"com_ui_skills_load_error": "Failed to load skills",
"com_ui_sr_public_skill": "Public skill",
"com_ui_special": "special",
"com_ui_special_var_current_date": "Current Date",

View file

@ -300,6 +300,26 @@ const showPromptsPopoverFamily = atomFamily<boolean, string | number | null>({
default: false,
});
const showSkillsPopoverFamily = atomFamily<boolean, string | number | null>({
key: 'showSkillsPopoverByIndex',
default: false,
});
/**
* Per-conversation queue of skill names the user invoked manually via the
* `$` popover for the next submission. Acts as the structured channel
* paired with the cosmetic `$skill-name ` text inserted into the textarea.
*
* Phase 1: only the writer (SkillsCommand) is wired; the submit pipeline
* does not yet read or clear this atom. The follow-up PR will read this
* at `ask()` time, attach to the payload, and reset to `[]`. Until then
* the backend continues to receive only the textual `$name` reference.
*/
const pendingManualSkillsByConvoId = atomFamily<string[], string>({
key: 'pendingManualSkillsByConvoId',
default: [],
});
const globalAudioURLFamily = atomFamily<string | null, string | number | null>({
key: 'globalAudioURLByIndex',
default: null,
@ -497,5 +517,7 @@ export default {
useClearSubmissionState,
useClearLatestMessages,
showPromptsPopoverFamily,
showSkillsPopoverFamily,
pendingManualSkillsByConvoId,
updateConversationSelector,
};

View file

@ -50,6 +50,7 @@ const localStorageAtoms = {
atCommand: atomWithLocalStorage('atCommand', true),
plusCommand: atomWithLocalStorage('plusCommand', true),
slashCommand: atomWithLocalStorage('slashCommand', true),
dollarCommand: atomWithLocalStorage('dollarCommand', true),
// Speech settings
conversationMode: atomWithLocalStorage('conversationMode', false),