diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 9e0ad7f382..87ceccdc71 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -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({ >
- {showPlusPopover && !isAssistantsEndpoint(endpoint) && ( - - )} - {showMentionPopover && ( - - )} + +
; +type MentionProps = { + index: number; + popoverAtom: RecoilState; newConversation: ConvoGenerator; textAreaRef: React.MutableRefObject; commandChar?: string; placeholder?: TranslationKeys; includeAssistants?: boolean; -}) { +}; + +function MentionContent({ + popoverAtom, + newConversation, + textAreaRef, + commandChar = '@', + placeholder = 'com_ui_mention', + includeAssistants = true, +}: Omit) { 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({
{ 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 && ( +
+ +
+ )} + {open && matches.length > 0 && (
{({ width }) => ( @@ -226,3 +243,17 @@ export default function Mention({
); } + +const MentionPopoverContainer = memo(function MentionPopoverContainer({ + index: _index, + popoverAtom, + ...rest +}: MentionProps) { + const show = useRecoilValue(popoverAtom); + if (!show) { + return null; + } + return ; +}); + +export default MentionPopoverContainer; diff --git a/client/src/components/Chat/Input/PromptsCommand.tsx b/client/src/components/Chat/Input/PromptsCommand.tsx index defe7b8d31..6db9eb62bc 100644 --- a/client/src/components/Chat/Input/PromptsCommand.tsx +++ b/client/src/components/Chat/Input/PromptsCommand.tsx @@ -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) => { if (!mention) { @@ -193,10 +202,7 @@ function PromptsCommand({
-
- {(() => { - if (isLoading && open) { - return ( -
- -
- ); - } - - if (!isLoading && open) { - return ( -
- - {({ width }) => ( - - )} - -
- ); - } - return null; - })()} -
+ {open && isLoading && matches.length === 0 && ( +
+ +
+ )} + {open && matches.length > 0 && ( +
+ + {({ width }) => ( + + )} + +
+ )}
diff --git a/client/src/hooks/Input/useHandleKeyUp.spec.ts b/client/src/hooks/Input/useHandleKeyUp.spec.ts new file mode 100644 index 0000000000..79bbae5994 --- /dev/null +++ b/client/src/hooks/Input/useHandleKeyUp.spec.ts @@ -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; + return ref; +}; + +const makeKeyEvent = (key: string) => + ({ key, preventDefault: jest.fn() }) as unknown as React.KeyboardEvent; + +const renderUseHandleKeyUp = ( + textAreaRef: React.RefObject, + 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); + }); + }); +}); diff --git a/client/src/hooks/Input/useHandleKeyUp.ts b/client/src/hooks/Input/useHandleKeyUp.ts index 423719563a..a2a8d2aa1d 100644 --- a/client/src/hooks/Input/useHandleKeyUp.ts +++ b/client/src/hooks/Input/useHandleKeyUp.ts @@ -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, 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; - setShowPlusPopover: SetterOrUpdater; - setShowMentionPopover: SetterOrUpdater; }) => { 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) { diff --git a/client/src/hooks/Input/useInitPopoverInput.ts b/client/src/hooks/Input/useInitPopoverInput.ts new file mode 100644 index 0000000000..8fd2507396 --- /dev/null +++ b/client/src/hooks/Input/useInitPopoverInput.ts @@ -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; + textAreaRef: React.MutableRefObject; + 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; diff --git a/client/src/hooks/Input/useMentions.ts b/client/src/hooks/Input/useMentions.ts index 50c1929943..0c16bcc1ce 100644 --- a/client/src/hooks/Input/useMentions.ts +++ b/client/src/hooks/Input/useMentions.ts @@ -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, diff --git a/client/src/store/families.ts b/client/src/store/families.ts index 30b8211ab5..5a28ab707e 100644 --- a/client/src/store/families.ts +++ b/client/src/store/families.ts @@ -172,6 +172,17 @@ const conversationEndpointByIndex = selectorFamily({ + key: 'effectiveEndpointByIndex', + get: + (index: string | number) => + ({ get }) => { + const convo = get(conversationByIndex(index)); + return convo?.endpointType ?? convo?.endpoint ?? null; + }, +}); + const conversationModelByIndex = selectorFamily({ key: 'conversationModelByIndex', get: @@ -466,6 +477,7 @@ export default { allConversationsSelector, conversationIdByIndex, conversationEndpointByIndex, + effectiveEndpointByIndex, conversationModelByIndex, conversationSpecByIndex, conversationAgentIdByIndex,