diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx
index 87ceccdc71..50f2076dd0 100644
--- a/client/src/components/Chat/Input/ChatForm.tsx
+++ b/client/src/components/Chat/Input/ChatForm.tsx
@@ -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}
/>
+
) => void;
index: number;
- type?: 'prompt' | 'mention' | 'add-convo';
+ type?: 'prompt' | 'mention' | 'add-convo' | 'skill';
icon?: React.ReactNode;
isActive?: boolean;
description?: string;
diff --git a/client/src/components/Chat/Input/SkillsCommand.tsx b/client/src/components/Chat/Input/SkillsCommand.tsx
new file mode 100644
index 0000000000..1ed63fafaf
--- /dev/null
+++ b/client/src/components/Chat/Input/SkillsCommand.tsx
@@ -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 =
;
+
+/**
+ * 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
;
+ 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((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(null);
+ const inputRef = useRef(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 (
+ {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ timeoutRef.current = null;
+ handleSelect(mention);
+ }}
+ name={mention.label ?? ''}
+ icon={mention.icon}
+ description={mention.description}
+ isActive={index === activeIndex}
+ />
+ );
+ };
+
+ return (
+
+
+
{
+ 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 && (
+
+
+
+ )}
+ {open && isError && (
+
+ {localize('com_ui_skills_load_error')}
+
+ )}
+ {open && !isLoading && !isFetchingNextPage && !isError && matches.length === 0 && (
+
+ {localize(searchValue ? 'com_ui_no_skills_found' : 'com_ui_skills_empty')}
+
+ )}
+ {open && matches.length > 0 && (
+
+
+ {({ width }) => (
+
+ )}
+
+
+ )}
+
+
+ );
+}
+
+const SkillsCommand = memo(function SkillsCommand({
+ index,
+ textAreaRef,
+ conversationId,
+}: {
+ index: number;
+ textAreaRef: React.MutableRefObject;
+ conversationId: string;
+}) {
+ const show = useRecoilValue(store.showSkillsPopoverFamily(index));
+ if (!show) {
+ return null;
+ }
+ return (
+
+ );
+});
+
+export default SkillsCommand;
diff --git a/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx b/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx
new file mode 100644
index 0000000000..27d665a674
--- /dev/null
+++ b/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx
@@ -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 ;
+ },
+}));
+
+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;
+};
+
+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(
+ ,
+ );
+ 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();
+
+ 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);
+ });
+});
diff --git a/client/src/hooks/Config/useClearStates.ts b/client/src/hooks/Config/useClearStates.ts
index 140f83e924..46ec9476c7 100644
--- a/client/src/hooks/Config/useClearStates.ts
+++ b/client/src/hooks/Config/useClearStates.ts
@@ -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));
diff --git a/client/src/hooks/Input/useHandleKeyUp.spec.ts b/client/src/hooks/Input/useHandleKeyUp.spec.ts
index 79bbae5994..c9ea83ea90 100644
--- a/client/src/hooks/Input/useHandleKeyUp.spec.ts
+++ b/client/src/hooks/Input/useHandleKeyUp.spec.ts
@@ -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);
+ });
});
});
diff --git a/client/src/hooks/Input/useHandleKeyUp.ts b/client/src/hooks/Input/useHandleKeyUp.ts
index a2a8d2aa1d..fca2ee248a 100644
--- a/client/src/hooks/Input/useHandleKeyUp.ts
+++ b/client/src/hooks/Input/useHandleKeyUp.ts
@@ -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(
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index eb2122fc6c..aa09d8da93 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -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",
diff --git a/client/src/store/families.ts b/client/src/store/families.ts
index 5a28ab707e..f35bebc0d6 100644
--- a/client/src/store/families.ts
+++ b/client/src/store/families.ts
@@ -300,6 +300,26 @@ const showPromptsPopoverFamily = atomFamily({
default: false,
});
+const showSkillsPopoverFamily = atomFamily({
+ 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({
+ key: 'pendingManualSkillsByConvoId',
+ default: [],
+});
+
const globalAudioURLFamily = atomFamily({
key: 'globalAudioURLByIndex',
default: null,
@@ -497,5 +517,7 @@ export default {
useClearSubmissionState,
useClearLatestMessages,
showPromptsPopoverFamily,
+ showSkillsPopoverFamily,
+ pendingManualSkillsByConvoId,
updateConversationSelector,
};
diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts
index 84bb815019..b0e2eef254 100644
--- a/client/src/store/settings.ts
+++ b/client/src/store/settings.ts
@@ -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),