🔼 refactor: Improve UX for Command Popovers (#12677)

* refactor: Improve UX for Command Popovers

* Added loading state handling in Mention and PromptsCommand components to display a spinner when data is being fetched.
* Refactored onFocus logic to clear the textarea and set the search value based on command character input.
* Introduced a new `isLoading` state in the useMentions hook to manage loading indicators across multiple data queries.
* Added unit tests for the useHandleKeyUp hook to ensure command triggering works correctly under various conditions.

* ci: useHandleKeyUp tests for command navigation

* Added tests to ensure that the command popovers do not trigger when the cursor is mid-text after pressing ArrowLeft or Delete.
* Updated the shouldTriggerCommand function to refine the conditions under which commands are triggered based on cursor position.
* Improved agent query handling in useMentions hook for better performance and clarity.

* refactor: Optimize Mention and PromptsCommand Components

* Refactored Mention and PromptsCommand components to utilize Recoil state for popover visibility, improving state management and reducing prop drilling.
* Simplified onFocus logic to enhance user experience when interacting with command inputs.
* Added unit tests for useHandleKeyUp to ensure proper command handling and popover visibility based on user input.
* Improved performance by memoizing popover state and reducing unnecessary re-renders.

* fix: Address review findings for command popover refactor

- Fix endpointType regression: add effectiveEndpointByIndex selector
  that returns endpointType ?? endpoint, matching the original ChatForm
  guard for custom endpoints proxying assistants
- Extract duplicated initInputRef callback into shared useInitPopoverInput
  hook, used by both Mention and PromptsCommand
- Add navigation keys (ArrowLeft, ArrowRight, ArrowDown, Home, End,
  Delete) to invalidKeys to prevent false popover triggers
- Add endpoint gating tests for assistants/azureAssistants blocking the
  + command
- Remove unused _index param from MentionContent
This commit is contained in:
Danny Avila 2026-04-15 17:47:24 -04:00 committed by GitHub
parent dd26a2fda5
commit 49f228de78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 615 additions and 100 deletions

View file

@ -83,10 +83,8 @@ const ChatForm = memo(function ChatForm({
const [badges, setBadges] = useRecoilState(store.chatBadges);
const [isEditingBadges, setIsEditingBadges] = useRecoilState(store.isEditingBadges);
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
const [showPlusPopover, setShowPlusPopover] = useRecoilState(store.showPlusPopoverFamily(index));
const [showMentionPopover, setShowMentionPopover] = useRecoilState(
store.showMentionPopoverFamily(index),
);
const plusPopoverAtom = useMemo(() => store.showPlusPopoverFamily(index), [index]);
const mentionPopoverAtom = useMemo(() => store.showMentionPopoverFamily(index), [index]);
const { requiresKey } = useRequiresKey();
const methods = useChatFormContext();
@ -158,8 +156,6 @@ const ChatForm = memo(function ChatForm({
const handleKeyUp = useHandleKeyUp({
index,
textAreaRef,
setShowPlusPopover,
setShowMentionPopover,
});
const {
isNotAppendable,
@ -242,23 +238,21 @@ const ChatForm = memo(function ChatForm({
>
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
<Mention
setShowMentionPopover={setShowPlusPopover}
newConversation={generateConversation}
textAreaRef={textAreaRef}
commandChar="+"
placeholder="com_ui_add_model_preset"
includeAssistants={false}
/>
)}
{showMentionPopover && (
<Mention
setShowMentionPopover={setShowMentionPopover}
newConversation={newConversation}
textAreaRef={textAreaRef}
/>
)}
<Mention
index={index}
popoverAtom={plusPopoverAtom}
newConversation={generateConversation}
textAreaRef={textAreaRef}
commandChar="+"
placeholder="com_ui_add_model_preset"
includeAssistants={false}
/>
<Mention
index={index}
popoverAtom={mentionPopoverAtom}
newConversation={newConversation}
textAreaRef={textAreaRef}
/>
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div
onClick={handleContainerClick}

View file

@ -1,10 +1,12 @@
import { useState, useRef, useEffect } from 'react';
import { useCombobox } from '@librechat/client';
import { memo, useState, useRef, useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { AutoSizer, List } from 'react-virtualized';
import { Spinner, useCombobox } from '@librechat/client';
import { EModelEndpoint } from 'librechat-data-provider';
import type { RecoilState } from 'recoil';
import type { MentionOption, ConvoGenerator } from '~/common';
import type { SetterOrUpdater } from 'recoil';
import { useGetConversation, useLocalize, TranslationKeys } from '~/hooks';
import useInitPopoverInput from '~/hooks/Input/useInitPopoverInput';
import useSelectMention from '~/hooks/Input/useSelectMention';
import { useAssistantsMapContext } from '~/Providers';
import useMentions from '~/hooks/Input/useMentions';
@ -13,27 +15,32 @@ import MentionItem from './MentionItem';
const ROW_HEIGHT = 44;
export default function Mention({
setShowMentionPopover,
newConversation,
textAreaRef,
commandChar = '@',
placeholder = 'com_ui_mention',
includeAssistants = true,
}: {
setShowMentionPopover: SetterOrUpdater<boolean>;
type MentionProps = {
index: number;
popoverAtom: RecoilState<boolean>;
newConversation: ConvoGenerator;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
commandChar?: string;
placeholder?: TranslationKeys;
includeAssistants?: boolean;
}) {
};
function MentionContent({
popoverAtom,
newConversation,
textAreaRef,
commandChar = '@',
placeholder = 'com_ui_mention',
includeAssistants = true,
}: Omit<MentionProps, 'index'>) {
const localize = useLocalize();
const getConversation = useGetConversation(0);
const assistantsMap = useAssistantsMapContext();
const setShowPopover = useSetRecoilState(popoverAtom);
const {
options,
presets,
isLoading,
modelSpecs,
agentsList,
modelsConfig,
@ -59,6 +66,14 @@ export default function Mention({
options: inputOptions,
});
const initInputRef = useInitPopoverInput({
inputRef,
textAreaRef,
commandChar,
setSearchValue,
setOpen,
});
const handleSelect = (mention?: MentionOption) => {
if (!mention) {
return;
@ -67,7 +82,7 @@ export default function Mention({
const defaultSelect = () => {
setSearchValue('');
setOpen(false);
setShowMentionPopover(false);
setShowPopover(false);
onSelectMention?.(mention);
if (textAreaRef.current) {
@ -164,10 +179,7 @@ export default function Mention({
<div className="absolute bottom-28 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-white p-2 shadow-lg dark:bg-gray-700">
<input
// The user expects focus to transition to the input field when the popover is opened
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
ref={inputRef}
ref={initInputRef}
placeholder={localize(placeholder)}
className="mb-1 w-full border-0 bg-white p-2 text-sm focus:outline-none dark:bg-gray-700 dark:text-gray-200"
autoComplete="off"
@ -175,7 +187,7 @@ export default function Mention({
onKeyDown={(e) => {
if (e.key === 'Escape') {
setOpen(false);
setShowMentionPopover(false);
setShowPopover(false);
textAreaRef.current?.focus();
}
if (e.key === 'ArrowDown') {
@ -192,7 +204,7 @@ export default function Mention({
handleSelect(matches[activeIndex] as MentionOption);
} else if (e.key === 'Backspace' && searchValue === '') {
setOpen(false);
setShowMentionPopover(false);
setShowPopover(false);
textAreaRef.current?.focus();
}
}}
@ -201,11 +213,16 @@ export default function Mention({
onBlur={() => {
timeoutRef.current = setTimeout(() => {
setOpen(false);
setShowMentionPopover(false);
setShowPopover(false);
}, 150);
}}
/>
{open && (
{open && isLoading && matches.length === 0 && (
<div className="flex h-32 items-center justify-center text-text-primary">
<Spinner />
</div>
)}
{open && matches.length > 0 && (
<div className="max-h-40">
<AutoSizer disableHeight>
{({ width }) => (
@ -226,3 +243,17 @@ export default function Mention({
</div>
);
}
const MentionPopoverContainer = memo(function MentionPopoverContainer({
index: _index,
popoverAtom,
...rest
}: MentionProps) {
const show = useRecoilValue(popoverAtom);
if (!show) {
return null;
}
return <MentionContent popoverAtom={popoverAtom} {...rest} />;
});
export default MentionPopoverContainer;

View file

@ -4,6 +4,7 @@ import { Spinner, useCombobox } from '@librechat/client';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import type { TPromptGroup } from 'librechat-data-provider';
import type { PromptOption } from '~/common';
import useInitPopoverInput from '~/hooks/Input/useInitPopoverInput';
import { removeCharIfLast, detectVariables } from '~/utils';
import { useRecordPromptUsage } from '~/data-provider';
import { VariableDialog } from '~/components/Prompts';
@ -81,6 +82,14 @@ function PromptsCommand({
options: prompts ?? [],
});
const initInputRef = useInitPopoverInput({
inputRef,
textAreaRef,
commandChar,
setSearchValue,
setOpen,
});
const handleSelect = useCallback(
(mention?: PromptOption, e?: React.KeyboardEvent<HTMLInputElement>) => {
if (!mention) {
@ -193,10 +202,7 @@ function PromptsCommand({
<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
// The user expects focus to transition to the input field when the popover is opened
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
ref={inputRef}
ref={initInputRef}
placeholder={localize('com_ui_command_usage_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"
@ -231,38 +237,28 @@ function PromptsCommand({
}, 150);
}}
/>
<div className="max-h-40 overflow-y-auto">
{(() => {
if (isLoading && open) {
return (
<div className="flex h-32 items-center justify-center text-text-primary">
<Spinner />
</div>
);
}
if (!isLoading && open) {
return (
<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>
);
}
return null;
})()}
</div>
{open && isLoading && matches.length === 0 && (
<div className="flex h-32 items-center justify-center text-text-primary">
<Spinner />
</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>
</PopoverContainer>

View file

@ -0,0 +1,416 @@
const mockSetShowMentionPopover = jest.fn();
const mockSetShowPlusPopover = jest.fn();
const mockSetShowPromptsPopover = jest.fn();
const mockHasPromptsAccess = { current: true };
const mockHasMultiConvoAccess = { current: true };
const mockEndpoint = { current: 'openAI' as string | null };
const mockCommandToggles = { at: true, plus: true, slash: true };
jest.mock('recoil', () => ({
...jest.requireActual('recoil'),
useRecoilValue: jest.fn((atom) => {
if (atom === 'latestMessageFamily-0') {
return null;
}
if (atom === 'effectiveEndpointByIndex-0') {
return mockEndpoint.current;
}
if (atom === 'atCommand') {
return mockCommandToggles.at;
}
if (atom === 'plusCommand') {
return mockCommandToggles.plus;
}
if (atom === 'slashCommand') {
return mockCommandToggles.slash;
}
return undefined;
}),
useSetRecoilState: jest.fn((atom: string) => {
if (atom === 'showMentionPopoverFamily-0') {
return mockSetShowMentionPopover;
}
if (atom === 'showPlusPopoverFamily-0') {
return mockSetShowPlusPopover;
}
if (atom === 'showPromptsPopoverFamily-0') {
return mockSetShowPromptsPopover;
}
return jest.fn();
}),
}));
jest.mock('~/store', () => ({
showPromptsPopoverFamily: (idx: number) => `showPromptsPopoverFamily-${idx}`,
showMentionPopoverFamily: (idx: number) => `showMentionPopoverFamily-${idx}`,
showPlusPopoverFamily: (idx: number) => `showPlusPopoverFamily-${idx}`,
effectiveEndpointByIndex: (idx: number) => `effectiveEndpointByIndex-${idx}`,
latestMessageFamily: (idx: number) => `latestMessageFamily-${idx}`,
atCommand: 'atCommand',
plusCommand: 'plusCommand',
slashCommand: 'slashCommand',
}));
jest.mock('~/hooks/Roles/useHasAccess', () =>
jest.fn(({ permissionType }: { permissionType: string }) => {
if (permissionType === 'PROMPTS') {
return mockHasPromptsAccess.current;
}
if (permissionType === 'MULTI_CONVO') {
return mockHasMultiConvoAccess.current;
}
return false;
}),
);
import React from 'react';
import { renderHook, act } from '@testing-library/react';
import useHandleKeyUp from './useHandleKeyUp';
const makeTextAreaRef = (value = '', selectionStart?: number) => {
const ref = {
current: {
value,
selectionStart: selectionStart ?? value.length,
},
} as unknown as React.RefObject<HTMLTextAreaElement>;
return ref;
};
const makeKeyEvent = (key: string) =>
({ key, preventDefault: jest.fn() }) as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
const renderUseHandleKeyUp = (
textAreaRef: React.RefObject<HTMLTextAreaElement>,
overrides?: { index?: number },
) => {
const { result } = renderHook(() =>
useHandleKeyUp({
index: overrides?.index ?? 0,
textAreaRef,
}),
);
return {
handleKeyUp: result.current,
setShowMentionPopover: mockSetShowMentionPopover,
setShowPlusPopover: mockSetShowPlusPopover,
setShowPromptsPopover: mockSetShowPromptsPopover,
};
};
beforeEach(() => {
jest.clearAllMocks();
mockHasPromptsAccess.current = true;
mockHasMultiConvoAccess.current = true;
mockEndpoint.current = 'openAI';
mockCommandToggles.at = true;
mockCommandToggles.plus = true;
mockCommandToggles.slash = true;
});
describe('useHandleKeyUp', () => {
describe('command triggering — normal typing speed (cursor at position 1)', () => {
it('triggers slash command for "/" at position 1', () => {
const ref = makeTextAreaRef('/', 1);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('/')));
expect(setShowPromptsPopover).toHaveBeenCalledWith(true);
});
it('triggers @ mention for "@" at position 1', () => {
const ref = makeTextAreaRef('@', 1);
const { handleKeyUp, setShowMentionPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('@')));
expect(setShowMentionPopover).toHaveBeenCalledWith(true);
});
it('triggers + command for "+" at position 1', () => {
const ref = makeTextAreaRef('+', 1);
const { handleKeyUp, setShowPlusPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('+')));
expect(setShowPlusPopover).toHaveBeenCalledWith(true);
});
});
describe('fast typing — cursor past position 1 but text is short', () => {
it('triggers slash command for "/sc" (fast typed)', () => {
const ref = makeTextAreaRef('/sc', 3);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('c')));
expect(setShowPromptsPopover).toHaveBeenCalledWith(true);
});
it('triggers @ mention for "@bo" (fast typed)', () => {
const ref = makeTextAreaRef('@bo', 3);
const { handleKeyUp, setShowMentionPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('o')));
expect(setShowMentionPopover).toHaveBeenCalledWith(true);
});
it('triggers for text up to MAX_COMMAND_TRIGGER_LENGTH (5 chars)', () => {
const ref = makeTextAreaRef('/abcd', 5);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('d')));
expect(setShowPromptsPopover).toHaveBeenCalledWith(true);
});
it('does NOT trigger for text exceeding MAX_COMMAND_TRIGGER_LENGTH', () => {
const ref = makeTextAreaRef('/abcde', 6);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('e')));
expect(setShowPromptsPopover).not.toHaveBeenCalled();
});
});
describe('navigation keys — should never trigger', () => {
it('does NOT trigger when cursor is mid-text after ArrowLeft', () => {
const ref = makeTextAreaRef('/abc', 2);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('ArrowLeft')));
expect(setShowPromptsPopover).not.toHaveBeenCalled();
});
it('does NOT trigger when cursor is mid-text after Delete', () => {
const ref = makeTextAreaRef('@bo', 2);
const { handleKeyUp, setShowMentionPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('Delete')));
expect(setShowMentionPopover).not.toHaveBeenCalled();
});
it('does NOT trigger when ArrowRight lands at end of short command text', () => {
const ref = makeTextAreaRef('/ab', 3);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('ArrowRight')));
expect(setShowPromptsPopover).not.toHaveBeenCalled();
});
it('does NOT trigger when Home key is pressed on command text', () => {
const ref = makeTextAreaRef('/abc', 0);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('Home')));
expect(setShowPromptsPopover).not.toHaveBeenCalled();
});
it('does NOT trigger when End key lands at end of short command text', () => {
const ref = makeTextAreaRef('+ab', 3);
const { handleKeyUp, setShowPlusPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('End')));
expect(setShowPlusPopover).not.toHaveBeenCalled();
});
it('does NOT trigger when ArrowUp is pressed on non-empty command text', () => {
const ref = makeTextAreaRef('/ab', 3);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('ArrowUp')));
expect(setShowPromptsPopover).not.toHaveBeenCalled();
});
});
describe('paste protection — long text starting with command char', () => {
it('does NOT trigger for pasted "/api/v1/users"', () => {
const ref = makeTextAreaRef('/api/v1/users', 13);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('v')));
expect(setShowPromptsPopover).not.toHaveBeenCalled();
});
it('does NOT trigger for pasted "@username mentioned in a long message"', () => {
const ref = makeTextAreaRef('@username mentioned in a long message', 37);
const { handleKeyUp, setShowMentionPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('v')));
expect(setShowMentionPopover).not.toHaveBeenCalled();
});
});
describe('non-command text', () => {
it('does NOT trigger when text does not start with a command char', () => {
const ref = makeTextAreaRef('hello', 5);
const { handleKeyUp, setShowPromptsPopover, setShowMentionPopover, setShowPlusPopover } =
renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('o')));
expect(setShowPromptsPopover).not.toHaveBeenCalled();
expect(setShowMentionPopover).not.toHaveBeenCalled();
expect(setShowPlusPopover).not.toHaveBeenCalled();
});
it('does NOT trigger when text is empty', () => {
const ref = makeTextAreaRef('', 0);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('a')));
expect(setShowPromptsPopover).not.toHaveBeenCalled();
});
it('does NOT trigger for command char in the middle of text', () => {
const ref = makeTextAreaRef('hello /world', 12);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('d')));
expect(setShowPromptsPopover).not.toHaveBeenCalled();
});
});
describe('invalid keys', () => {
it.each([
'Escape',
'Backspace',
'Enter',
'ArrowUp',
'ArrowLeft',
'ArrowRight',
'ArrowDown',
'Home',
'End',
'Delete',
])('does NOT trigger on %s key', (key) => {
const ref = makeTextAreaRef('/', 1);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent(key)));
expect(setShowPromptsPopover).not.toHaveBeenCalled();
});
});
describe('command toggles', () => {
it('does NOT trigger slash command when slashCommand toggle is disabled', () => {
mockCommandToggles.slash = false;
const ref = makeTextAreaRef('/', 1);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('/')));
expect(setShowPromptsPopover).not.toHaveBeenCalled();
});
it('does NOT trigger @ mention when atCommand toggle is disabled', () => {
mockCommandToggles.at = false;
const ref = makeTextAreaRef('@', 1);
const { handleKeyUp, setShowMentionPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('@')));
expect(setShowMentionPopover).not.toHaveBeenCalled();
});
it('does NOT trigger + command when plusCommand toggle is disabled', () => {
mockCommandToggles.plus = false;
const ref = makeTextAreaRef('+', 1);
const { handleKeyUp, setShowPlusPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('+')));
expect(setShowPlusPopover).not.toHaveBeenCalled();
});
});
describe('permission gating', () => {
it('does NOT trigger slash command without PROMPTS access', () => {
mockHasPromptsAccess.current = false;
const ref = makeTextAreaRef('/', 1);
const { handleKeyUp, setShowPromptsPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('/')));
expect(setShowPromptsPopover).not.toHaveBeenCalled();
});
it('does NOT trigger + command without MULTI_CONVO access', () => {
mockHasMultiConvoAccess.current = false;
const ref = makeTextAreaRef('+', 1);
const { handleKeyUp, setShowPlusPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('+')));
expect(setShowPlusPopover).not.toHaveBeenCalled();
});
it('triggers @ mention regardless of other permissions', () => {
mockHasPromptsAccess.current = false;
mockHasMultiConvoAccess.current = false;
const ref = makeTextAreaRef('@', 1);
const { handleKeyUp, setShowMentionPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('@')));
expect(setShowMentionPopover).toHaveBeenCalledWith(true);
});
});
describe('endpoint gating', () => {
it('does NOT trigger + command on assistants endpoint', () => {
mockEndpoint.current = 'assistants';
const ref = makeTextAreaRef('+', 1);
const { handleKeyUp, setShowPlusPopover } = renderUseHandleKeyUp(ref);
setShowPlusPopover.mockClear();
act(() => handleKeyUp(makeKeyEvent('+')));
expect(setShowPlusPopover).not.toHaveBeenCalledWith(true);
});
it('does NOT trigger + command on azureAssistants endpoint', () => {
mockEndpoint.current = 'azureAssistants';
const ref = makeTextAreaRef('+', 1);
const { handleKeyUp, setShowPlusPopover } = renderUseHandleKeyUp(ref);
setShowPlusPopover.mockClear();
act(() => handleKeyUp(makeKeyEvent('+')));
expect(setShowPlusPopover).not.toHaveBeenCalledWith(true);
});
it('resets + popover when endpoint switches to assistants', () => {
mockEndpoint.current = 'assistants';
const ref = makeTextAreaRef('', 0);
const { setShowPlusPopover } = renderUseHandleKeyUp(ref);
expect(setShowPlusPopover).toHaveBeenCalledWith(false);
});
it('triggers + command on non-assistants endpoint', () => {
mockEndpoint.current = 'openAI';
const ref = makeTextAreaRef('+', 1);
const { handleKeyUp, setShowPlusPopover } = renderUseHandleKeyUp(ref);
act(() => handleKeyUp(makeKeyEvent('+')));
expect(setShowPlusPopover).toHaveBeenCalledWith(true);
});
});
});

View file

@ -1,20 +1,31 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import { PermissionTypes, Permissions, isAssistantsEndpoint } from 'librechat-data-provider';
import useHasAccess from '~/hooks/Roles/useHasAccess';
import store from '~/store';
/** Event Keys that shouldn't trigger a command */
/** Event keys that shouldn't trigger a command */
const invalidKeys = {
Escape: true,
Backspace: true,
Enter: true,
ArrowUp: true,
ArrowLeft: true,
ArrowRight: true,
ArrowDown: true,
Home: true,
End: true,
Delete: true,
};
/**
* Utility function to determine if a command should trigger.
* Determines if a command popover should trigger.
* Uses `startPos === 1` for normal typing speed (cursor right after the command char)
* and a short text-length fallback for fast typists whose keyup fires after the cursor
* has already moved past position 1. The length cap prevents false triggers from
* pasted content that happens to start with a command character.
*/
const MAX_COMMAND_TRIGGER_LENGTH = 5;
const shouldTriggerCommand = (
textAreaRef: React.RefObject<HTMLTextAreaElement>,
commandChar: string,
@ -29,7 +40,7 @@ const shouldTriggerCommand = (
return false;
}
return startPos === 1;
return startPos === 1 || (startPos === text.length && text.length <= MAX_COMMAND_TRIGGER_LENGTH);
};
/**
@ -38,13 +49,9 @@ const shouldTriggerCommand = (
const useHandleKeyUp = ({
index,
textAreaRef,
setShowPlusPopover,
setShowMentionPopover,
}: {
index: number;
textAreaRef: React.RefObject<HTMLTextAreaElement>;
setShowPlusPopover: SetterOrUpdater<boolean>;
setShowMentionPopover: SetterOrUpdater<boolean>;
}) => {
const hasPromptsAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
@ -55,13 +62,21 @@ const useHandleKeyUp = ({
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));
// Get the current state of command toggles
const atCommandEnabled = useRecoilValue(store.atCommand);
const plusCommandEnabled = useRecoilValue(store.plusCommand);
const slashCommandEnabled = useRecoilValue(store.slashCommand);
useEffect(() => {
if (isAssistantsEndpoint(endpoint)) {
setShowPlusPopover(false);
}
}, [endpoint, setShowPlusPopover]);
const handleAtCommand = useCallback(() => {
if (atCommandEnabled && shouldTriggerCommand(textAreaRef, '@')) {
setShowMentionPopover(true);
@ -69,13 +84,13 @@ const useHandleKeyUp = ({
}, [textAreaRef, setShowMentionPopover, atCommandEnabled]);
const handlePlusCommand = useCallback(() => {
if (!hasMultiConvoAccess || !plusCommandEnabled) {
if (!hasMultiConvoAccess || !plusCommandEnabled || isAssistantsEndpoint(endpoint)) {
return;
}
if (shouldTriggerCommand(textAreaRef, '+')) {
setShowPlusPopover(true);
}
}, [textAreaRef, setShowPlusPopover, plusCommandEnabled, hasMultiConvoAccess]);
}, [textAreaRef, setShowPlusPopover, plusCommandEnabled, hasMultiConvoAccess, endpoint]);
const handlePromptsCommand = useCallback(() => {
if (!hasPromptsAccess || !slashCommandEnabled) {

View file

@ -0,0 +1,42 @@
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. */
const useInitPopoverInput = ({
inputRef,
textAreaRef,
commandChar,
setSearchValue,
setOpen,
}: {
inputRef: React.MutableRefObject<HTMLInputElement | null>;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
commandChar: string;
setSearchValue: (value: string) => void;
setOpen: (value: boolean) => void;
}) =>
useCallback(
(node: HTMLInputElement | null) => {
inputRef.current = node;
if (!node) {
return;
}
node.focus();
setOpen(true);
const textarea = textAreaRef.current;
if (!textarea) {
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 }));
}
},
[inputRef, textAreaRef, commandChar, setSearchValue, setOpen],
);
export default useInitPopoverInput;

View file

@ -64,10 +64,10 @@ export default function useMentions({
});
const agentsMap = useAgentsMapContext();
const { data: presets } = useGetPresetsQuery();
const { data: modelsConfig } = useGetModelsQuery();
const { data: startupConfig } = useGetStartupConfig();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { data: presets, isLoading: isLoadingPresets } = useGetPresetsQuery();
const { data: modelsConfig, isLoading: isLoadingModels } = useGetModelsQuery();
const { data: startupConfig, isLoading: isLoadingStartup } = useGetStartupConfig();
const { data: endpointsConfig, isLoading: isLoadingEndpoints } = useGetEndpointsQuery();
const { data: endpoints = [] } = useGetEndpointsQuery({
select: mapEndpoints,
});
@ -82,10 +82,11 @@ export default function useMentions({
() => startupConfig?.interface ?? defaultInterface,
[startupConfig?.interface],
);
const { data: agentsList = null } = useListAgentsQuery(
const agentQueryEnabled = hasAgentAccess && interfaceConfig.modelSelect === true;
const { data: agentsList = null, isLoading: isLoadingAgents } = useListAgentsQuery(
{ requiredPermission: PermissionBits.VIEW },
{
enabled: hasAgentAccess && interfaceConfig.modelSelect === true,
enabled: agentQueryEnabled,
select: (res) => {
const { data } = res;
return data.map(({ id, name, avatar }) => ({
@ -252,9 +253,17 @@ export default function useMentions({
interfaceConfig.modelSelect,
]);
const isLoading =
isLoadingPresets ||
isLoadingModels ||
isLoadingStartup ||
isLoadingEndpoints ||
(agentQueryEnabled && isLoadingAgents);
return {
options,
presets,
isLoading,
modelSpecs,
agentsList,
modelsConfig,

View file

@ -172,6 +172,17 @@ const conversationEndpointByIndex = selectorFamily<EModelEndpoint | null, string
get(conversationByIndex(index))?.endpoint ?? null,
});
/** Returns `endpointType ?? endpoint`, matching the effective endpoint used for feature gating. */
const effectiveEndpointByIndex = selectorFamily<EModelEndpoint | null, string | number>({
key: 'effectiveEndpointByIndex',
get:
(index: string | number) =>
({ get }) => {
const convo = get(conversationByIndex(index));
return convo?.endpointType ?? convo?.endpoint ?? null;
},
});
const conversationModelByIndex = selectorFamily<string | null, string | number>({
key: 'conversationModelByIndex',
get:
@ -466,6 +477,7 @@ export default {
allConversationsSelector,
conversationIdByIndex,
conversationEndpointByIndex,
effectiveEndpointByIndex,
conversationModelByIndex,
conversationSpecByIndex,
conversationAgentIdByIndex,