diff --git a/client/package.json b/client/package.json index 6046baf8fb..9b2477ed79 100644 --- a/client/package.json +++ b/client/package.json @@ -28,7 +28,7 @@ }, "homepage": "https://librechat.ai", "dependencies": { - "@ariakit/react": "^0.4.5", + "@ariakit/react": "^0.4.8", "@dicebear/collection": "^7.0.4", "@dicebear/core": "^7.0.4", "@headlessui/react": "^2.1.2", diff --git a/client/src/components/SidePanel/AssistantSwitcher.tsx b/client/src/components/SidePanel/AssistantSwitcher.tsx index 1348f6feff..26f253f76e 100644 --- a/client/src/components/SidePanel/AssistantSwitcher.tsx +++ b/client/src/components/SidePanel/AssistantSwitcher.tsx @@ -1,10 +1,10 @@ import { useEffect, useMemo } from 'react'; -import { Combobox } from '~/components/ui'; import { isAssistantsEndpoint, LocalStorageKeys } from 'librechat-data-provider'; import type { AssistantsEndpoint } from 'librechat-data-provider'; import type { SwitcherProps, AssistantListItem } from '~/common'; import { useSetIndexOptions, useSelectAssistant, useLocalize, useAssistantListMap } from '~/hooks'; import { useChatContext, useAssistantsMapContext } from '~/Providers'; +import ControlCombobox from '~/components/ui/ControlCombobox'; import Icon from '~/components/Endpoints/Icon'; export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) { @@ -31,7 +31,7 @@ export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) { localStorage.getItem(`${LocalStorageKeys.ASST_ID_PREFIX}${index}${endpoint}`) ?? assistants[0]?.id ?? ''; - const assistant = assistantMap?.[endpoint ?? '']?.[assistant_id]; + const assistant = assistantMap[endpoint ?? ''][assistant_id]; if (!assistant) { return; @@ -51,14 +51,14 @@ export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) { const assistantOptions = useMemo(() => { return assistants.map((assistant) => { return { - label: assistant.name ?? '', + label: (assistant.name as string | null) ?? '', value: assistant.id, icon: ( ), }; @@ -66,7 +66,7 @@ export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) { }, [assistants, endpoint]); return ( - assistant.id === selectedAssistant)?.name ?? @@ -83,7 +83,7 @@ export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) { isCreatedByUser={false} endpoint={endpoint} assistantName={currentAssistant?.name ?? ''} - iconURL={(currentAssistant?.metadata?.avatar as string) ?? ''} + iconURL={currentAssistant?.metadata?.avatar ?? ''} /> } /> diff --git a/client/src/components/SidePanel/ModelSwitcher.tsx b/client/src/components/SidePanel/ModelSwitcher.tsx index cff1342bec..6c8d79a38e 100644 --- a/client/src/components/SidePanel/ModelSwitcher.tsx +++ b/client/src/components/SidePanel/ModelSwitcher.tsx @@ -1,10 +1,10 @@ import { useMemo, useRef, useCallback } from 'react'; import { useGetModelsQuery } from 'librechat-data-provider/react-query'; +import type { SwitcherProps } from '~/common'; +import ControlCombobox from '~/components/ui/ControlCombobox'; import MinimalIcon from '~/components/Endpoints/MinimalIcon'; import { useSetIndexOptions, useLocalize } from '~/hooks'; -import type { SwitcherProps } from '~/common'; import { useChatContext } from '~/Providers'; -import { Combobox } from '~/components/ui'; import { mainTextareaId } from '~/common'; export default function ModelSwitcher({ isCollapsed }: SwitcherProps) { @@ -16,7 +16,10 @@ export default function ModelSwitcher({ isCollapsed }: SwitcherProps) { const { endpoint, model = null } = conversation ?? {}; const models = useMemo(() => { - return modelsQuery?.data?.[endpoint ?? ''] ?? []; + return (modelsQuery.data?.[endpoint ?? ''] ?? []).map((model) => ({ + label: model, + value: model, + })); }, [modelsQuery, endpoint]); const setModel = useCallback( @@ -34,7 +37,8 @@ export default function ModelSwitcher({ isCollapsed }: SwitcherProps) { ); return ( - - + ); } else if (isAssistantsEndpoint(props.endpoint)) { @@ -19,7 +19,7 @@ export default function Switcher(props: SwitcherProps) { return ( <> - + ); } diff --git a/client/src/components/ui/Combobox.tsx b/client/src/components/ui/Combobox.tsx index e8843284e1..0bc1cc8ecd 100644 --- a/client/src/components/ui/Combobox.tsx +++ b/client/src/components/ui/Combobox.tsx @@ -1,4 +1,4 @@ -import { startTransition, useMemo } from 'react'; +import { startTransition } from 'react'; import { Search as SearchIcon } from 'lucide-react'; import * as RadixSelect from '@radix-ui/react-select'; import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons'; @@ -52,7 +52,16 @@ export default function ComboboxComponent({ value={selectedValue} onValueChange={setValue} open={open} - onOpenChange={setOpen} + /** Hacky fix for radix-ui Android issue: https://github.com/radix-ui/primitives/issues/1658 */ + onOpenChange={() => { + if (open === true) { + setOpen(false); + return; + } + setTimeout(() => { + setOpen(!open); + }, 75); + }} > { + setValue(`${value ?? ''}`); + setOpen(false); + }} > diff --git a/client/src/components/ui/ControlCombobox.tsx b/client/src/components/ui/ControlCombobox.tsx new file mode 100644 index 0000000000..abd8d69700 --- /dev/null +++ b/client/src/components/ui/ControlCombobox.tsx @@ -0,0 +1,123 @@ +import * as Ariakit from '@ariakit/react'; +import { matchSorter } from 'match-sorter'; +import { startTransition, useMemo, useState, useEffect, useRef } from 'react'; +import { cn } from '~/utils'; +import type { OptionWithIcon } from '~/common'; +import { Search } from 'lucide-react'; + +interface ControlComboboxProps { + selectedValue: string; + displayValue?: string; + items: OptionWithIcon[]; + setValue: (value: string) => void; + ariaLabel: string; + searchPlaceholder?: string; + selectPlaceholder?: string; + isCollapsed: boolean; + SelectIcon?: React.ReactNode; +} + +export default function ControlCombobox({ + selectedValue, + displayValue, + items, + setValue, + ariaLabel, + searchPlaceholder, + selectPlaceholder, + isCollapsed, + SelectIcon, +}: ControlComboboxProps) { + const [searchValue, setSearchValue] = useState(''); + const buttonRef = useRef(null); + const [buttonWidth, setButtonWidth] = useState(null); + + const matches = useMemo(() => { + return matchSorter(items, searchValue, { + keys: ['value', 'label'], + baseSort: (a, b) => (a.index < b.index ? -1 : 1), + }); + }, [searchValue, items]); + + useEffect(() => { + if (buttonRef.current && !isCollapsed) { + setButtonWidth(buttonRef.current.offsetWidth); + } + }, [isCollapsed]); + + return ( +
+ { + startTransition(() => { + setSearchValue(value); + }); + }} + > + + {ariaLabel} + + {SelectIcon != null && ( +
+ {SelectIcon} +
+ )} + {!isCollapsed && ( + + {displayValue ?? selectPlaceholder} + + )} +
+ +
+
+ + +
+
+ + {matches.map((item) => ( + } + > + {item.icon != null && ( +
+ {item.icon} +
+ )} + {item.label} +
+ ))} +
+
+
+
+
+ ); +} diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 4cc9309370..395ff9693d 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -1,7 +1,8 @@ import { v4 } from 'uuid'; +import { useCallback } from 'react'; +import { useSetRecoilState } from 'recoil'; import { useParams } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; -import { useCallback } from 'react'; import { QueryKeys, Constants, @@ -29,6 +30,7 @@ import useContentHandler from '~/hooks/SSE/useContentHandler'; import type { TGenTitleMutation } from '~/data-provider'; import { useAuthContext } from '~/hooks/AuthContext'; import { useLiveAnnouncer } from '~/Providers'; +import store from '~/store'; type TSyncData = { sync: boolean; @@ -65,6 +67,7 @@ export default function useEventHandlers({ resetLatestMessage, }: EventHandlerParams) { const queryClient = useQueryClient(); + const setAbortScroll = useSetRecoilState(store.abortScroll); const { announcePolite, announceAssertive } = useLiveAnnouncer(); const { conversationId: paramId } = useParams(); @@ -306,15 +309,16 @@ export default function useEventHandlers({ resetLatestMessage(); } - scrollToEnd(); + scrollToEnd(() => setAbortScroll(false)); }, [ setMessages, - setConversation, queryClient, + setAbortScroll, isAddedRequest, - resetLatestMessage, + setConversation, announceAssertive, + resetLatestMessage, ], ); diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index 88ab105866..b5131c00e1 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -44,9 +44,12 @@ export const getTextKey = (message?: TMessage | null, convoId?: string | null) = }${message.conversationId ?? convoId}`; }; -export const scrollToEnd = () => { +export const scrollToEnd = (callback?: () => void) => { const messagesEndElement = document.getElementById('messages-end'); if (messagesEndElement) { messagesEndElement.scrollIntoView({ behavior: 'instant' }); + if (callback) { + callback(); + } } }; diff --git a/package-lock.json b/package-lock.json index 7110c93f78..037ab4f114 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1117,7 +1117,7 @@ "version": "0.7.4", "license": "ISC", "dependencies": { - "@ariakit/react": "^0.4.5", + "@ariakit/react": "^0.4.8", "@dicebear/collection": "^7.0.4", "@dicebear/core": "^7.0.4", "@headlessui/react": "^2.1.2", @@ -1289,38 +1289,38 @@ } }, "node_modules/@ariakit/core": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.5.tgz", - "integrity": "sha512-e294+bEcyzt/H/kO4fS5/czLAlkF7PY+Kul3q2z54VY+GGay8NlVs9UezAB7L4jUBlYRAXwp7/1Sq3R7b+MZ7w==" + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.8.tgz", + "integrity": "sha512-HQS+9CI7pMqqVlAt5bPGenT0/e65UxXY+PKtgU7Y+0UToBDBRolO5S9+UUSDm8OmJHSnq24owEGm1Mv28l5XCQ==" }, "node_modules/@ariakit/react": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.5.tgz", - "integrity": "sha512-GUHxaOY1JZrJUHkuV20IY4NWcgknhqTQM0qCQcVZDCi+pJiWchUjTG+UyIr/Of02hU569qnQ7yovskCf+V3tNg==", + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.8.tgz", + "integrity": "sha512-Bb1vOrp0X52hxi1wE9TEHjjZ/Y08tVq2ZH+RFDwRQB3g04uVwrrhnTccHepC6rsObrDpAOV3/YlJCi4k/lSUaQ==", "dependencies": { - "@ariakit/react-core": "0.4.5" + "@ariakit/react-core": "0.4.8" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/ariakit" }, "peerDependencies": { - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@ariakit/react-core": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.5.tgz", - "integrity": "sha512-ciTYPwpj/+mdA+EstveEnoygbx5e4PXQJxfkLKy4lkTkDJJUS9GcbYhdnIFJVUta6P1YFvzkIKo+/y9mcbMKJg==", + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.8.tgz", + "integrity": "sha512-TzsddUWQwWYhrEVWHA/Gf7KCGx8rwFohAHfuljjqidKeZi2kUmuRAImCTG9oga34FWHFf4AdXQbBKclMNt0nrQ==", "dependencies": { - "@ariakit/core": "0.4.5", + "@ariakit/core": "0.4.8", "@floating-ui/dom": "^1.0.0", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@aws-crypto/crc32": {