mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
🔼 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:
parent
dd26a2fda5
commit
49f228de78
8 changed files with 615 additions and 100 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
416
client/src/hooks/Input/useHandleKeyUp.spec.ts
Normal file
416
client/src/hooks/Input/useHandleKeyUp.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
42
client/src/hooks/Input/useInitPopoverInput.ts
Normal file
42
client/src/hooks/Input/useInitPopoverInput.ts
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue