{children} diff --git a/client/src/components/Chat/TemporaryChat.tsx b/client/src/components/Chat/TemporaryChat.tsx index a134379497..75799516c0 100644 --- a/client/src/components/Chat/TemporaryChat.tsx +++ b/client/src/components/Chat/TemporaryChat.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { motion } from 'framer-motion'; import { TooltipAnchor } from '@librechat/client'; import { MessageCircleDashed } from 'lucide-react'; import { useRecoilState, useRecoilCallback } from 'recoil'; @@ -43,6 +42,7 @@ export function TemporaryChat() { diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index b16c6458c7..f0b05a5a00 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -1,22 +1,63 @@ -import { useMemo, memo, type FC, useCallback } from 'react'; +import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react'; import throttle from 'lodash/throttle'; +import { ChevronDown } from 'lucide-react'; +import { useRecoilValue } from 'recoil'; import { Spinner, useMediaQuery } from '@librechat/client'; import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; -import { TConversation } from 'librechat-data-provider'; -import { useLocalize, TranslationKeys } from '~/hooks'; -import { groupConversationsByDate } from '~/utils'; +import type { TConversation } from 'librechat-data-provider'; +import { useLocalize, TranslationKeys, useFavorites, useShowMarketplace } from '~/hooks'; +import FavoritesList from '~/components/Nav/Favorites/FavoritesList'; +import { useActiveJobs } from '~/data-provider'; +import { groupConversationsByDate, cn } from '~/utils'; import Convo from './Convo'; +import store from '~/store'; + +export type CellPosition = { + columnIndex: number; + rowIndex: number; +}; + +export type MeasuredCellParent = { + invalidateCellSizeAfterRender?: ((cell: CellPosition) => void) | undefined; + recomputeGridSize?: ((cell: CellPosition) => void) | undefined; +}; interface ConversationsProps { conversations: Array; moveToTop: () => void; toggleNav: () => void; - containerRef: React.RefObject; + containerRef: React.RefObject; loadMoreConversations: () => void; isLoading: boolean; isSearchLoading: boolean; + isChatsExpanded: boolean; + setIsChatsExpanded: (expanded: boolean) => void; } +interface MeasuredRowProps { + cache: CellMeasurerCache; + rowKey: string; + parent: MeasuredCellParent; + index: number; + style: React.CSSProperties; + children: React.ReactNode; +} + +/** Reusable wrapper for virtualized row measurement */ +const MeasuredRow: FC = memo( + ({ cache, rowKey, parent, index, style, children }) => ( + + {({ registerChild }) => ( +
} style={style}> + {children} +
+ )} +
+ ), +); + +MeasuredRow.displayName = 'MeasuredRow'; + const LoadingSpinner = memo(() => { const localize = useLocalize(); @@ -30,18 +71,47 @@ const LoadingSpinner = memo(() => { LoadingSpinner.displayName = 'LoadingSpinner'; -const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => { +interface ChatsHeaderProps { + isExpanded: boolean; + onToggle: () => void; +} + +/** Collapsible header for the Chats section */ +const ChatsHeader: FC = memo(({ isExpanded, onToggle }) => { const localize = useLocalize(); return ( -
+ + ); +}); + +ChatsHeader.displayName = 'ChatsHeader'; + +const DateLabel: FC<{ groupName: string; isFirst?: boolean }> = memo(({ groupName, isFirst }) => { + const localize = useLocalize(); + return ( +

{localize(groupName as TranslationKeys) || groupName} -

+ ); }); DateLabel.displayName = 'DateLabel'; type FlattenedItem = + | { type: 'favorites' } + | { type: 'chats-header' } | { type: 'header'; groupName: string } | { type: 'convo'; convo: TConversation } | { type: 'loading' }; @@ -51,18 +121,28 @@ const MemoizedConvo = memo( conversation, retainView, toggleNav, + isGenerating, }: { conversation: TConversation; retainView: () => void; toggleNav: () => void; + isGenerating: boolean; }) => { - return ; + return ( + + ); }, (prevProps, nextProps) => { return ( prevProps.conversation.conversationId === nextProps.conversation.conversationId && prevProps.conversation.title === nextProps.conversation.title && - prevProps.conversation.endpoint === nextProps.conversation.endpoint + prevProps.conversation.endpoint === nextProps.conversation.endpoint && + prevProps.isGenerating === nextProps.isGenerating ); }, ); @@ -75,10 +155,27 @@ const Conversations: FC = ({ loadMoreConversations, isLoading, isSearchLoading, + isChatsExpanded, + setIsChatsExpanded, }) => { const localize = useLocalize(); + const search = useRecoilValue(store.search); + const resumableEnabled = useRecoilValue(store.resumableStreams); + const { favorites, isLoading: isFavoritesLoading } = useFavorites(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const convoHeight = isSmallScreen ? 44 : 34; + const showAgentMarketplace = useShowMarketplace(); + + // Fetch active job IDs for showing generation indicators + const { data: activeJobsData } = useActiveJobs(resumableEnabled); + const activeJobIds = useMemo( + () => new Set(activeJobsData?.activeJobIds ?? []), + [activeJobsData?.activeJobIds], + ); + + // Determine if FavoritesList will render content + const shouldShowFavorites = + !search.query && (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace); const filteredConversations = useMemo( () => rawConversations.filter(Boolean) as TConversation[], @@ -92,72 +189,155 @@ const Conversations: FC = ({ const flattenedItems = useMemo(() => { const items: FlattenedItem[] = []; - groupedConversations.forEach(([groupName, convos]) => { - items.push({ type: 'header', groupName }); - items.push(...convos.map((convo) => ({ type: 'convo' as const, convo }))); - }); + // Only include favorites row if FavoritesList will render content + if (shouldShowFavorites) { + items.push({ type: 'favorites' }); + } + items.push({ type: 'chats-header' }); - if (isLoading) { - items.push({ type: 'loading' } as any); + if (isChatsExpanded) { + groupedConversations.forEach(([groupName, convos]) => { + items.push({ type: 'header', groupName }); + items.push(...convos.map((convo) => ({ type: 'convo' as const, convo }))); + }); + + if (isLoading) { + items.push({ type: 'loading' } as any); + } } return items; - }, [groupedConversations, isLoading]); + }, [groupedConversations, isLoading, isChatsExpanded, shouldShowFavorites]); + // Store flattenedItems in a ref for keyMapper to access without recreating cache + const flattenedItemsRef = useRef(flattenedItems); + flattenedItemsRef.current = flattenedItems; + + // Create a stable cache that doesn't depend on flattenedItems const cache = useMemo( () => new CellMeasurerCache({ fixedWidth: true, defaultHeight: convoHeight, keyMapper: (index) => { - const item = flattenedItems[index]; + const item = flattenedItemsRef.current[index]; + if (!item) { + return `unknown-${index}`; + } + if (item.type === 'favorites') { + return 'favorites'; + } + if (item.type === 'chats-header') { + return 'chats-header'; + } if (item.type === 'header') { - return `header-${index}`; + return `header-${item.groupName}`; } if (item.type === 'convo') { return `convo-${item.convo.conversationId}`; } if (item.type === 'loading') { - return `loading-${index}`; + return 'loading'; } return `unknown-${index}`; }, }), - [flattenedItems, convoHeight], + [convoHeight], ); + // Debounced function to clear cache and recompute heights + const clearFavoritesCache = useCallback(() => { + if (cache) { + cache.clear(0, 0); + if (containerRef.current && 'recomputeRowHeights' in containerRef.current) { + containerRef.current.recomputeRowHeights(0); + } + } + }, [cache, containerRef]); + + // Clear cache when favorites change + useEffect(() => { + const frameId = requestAnimationFrame(() => { + clearFavoritesCache(); + }); + return () => cancelAnimationFrame(frameId); + }, [favorites.length, isFavoritesLoading, clearFavoritesCache]); + const rowRenderer = useCallback( ({ index, key, parent, style }) => { const item = flattenedItems[index]; + const rowProps = { cache, rowKey: key, parent, index, style }; + if (item.type === 'loading') { return ( - - {({ registerChild }) => ( -
- -
- )} -
+ + + ); } - let rendering: JSX.Element; + + if (item.type === 'favorites') { + return ( + + + + ); + } + + if (item.type === 'chats-header') { + return ( + + setIsChatsExpanded(!isChatsExpanded)} + /> + + ); + } + if (item.type === 'header') { - rendering = ; - } else if (item.type === 'convo') { - rendering = ( - + // First date header index depends on whether favorites row is included + // With favorites: [favorites, chats-header, first-header] → index 2 + // Without favorites: [chats-header, first-header] → index 1 + const firstHeaderIndex = shouldShowFavorites ? 2 : 1; + return ( + + + ); } - return ( - - {({ registerChild }) => ( -
- {rendering} -
- )} -
- ); + + if (item.type === 'convo') { + const isGenerating = activeJobIds.has(item.convo.conversationId ?? ''); + return ( + + + + ); + } + + return null; }, - [cache, flattenedItems, moveToTop, toggleNav], + [ + cache, + flattenedItems, + moveToTop, + toggleNav, + clearFavoritesCache, + isSmallScreen, + isChatsExpanded, + setIsChatsExpanded, + shouldShowFavorites, + activeJobIds, + ], ); const getRowHeight = useCallback( @@ -180,7 +360,7 @@ const Conversations: FC = ({ ); return ( -
+
{isSearchLoading ? (
@@ -191,7 +371,7 @@ const Conversations: FC = ({ {({ width, height }) => ( } + ref={containerRef} width={width} height={height} deferredMeasurementCache={cache} @@ -199,12 +379,12 @@ const Conversations: FC = ({ rowHeight={getRowHeight} rowRenderer={rowRenderer} overscanRowCount={10} + aria-readonly={false} className="outline-none" - style={{ outline: 'none' }} - role="list" aria-label="Conversations" onRowsRendered={handleRowsRendered} tabIndex={-1} + style={{ outline: 'none', scrollbarGutter: 'stable' }} /> )} diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 048c2f129d..518d0e4a86 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -19,9 +19,15 @@ interface ConversationProps { conversation: TConversation; retainView: () => void; toggleNav: () => void; + isGenerating?: boolean; } -export default function Conversation({ conversation, retainView, toggleNav }: ConversationProps) { +export default function Conversation({ + conversation, + retainView, + toggleNav, + isGenerating = false, +}: ConversationProps) { const params = useParams(); const localize = useLocalize(); const { showToast } = useToastContext(); @@ -132,12 +138,16 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co return (
{ if (renaming) { return; @@ -150,6 +160,9 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co if (renaming) { return; } + if (e.target !== e.currentTarget) { + return; + } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleNavigation(false); @@ -169,17 +182,41 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co ) : ( - + {isGenerating ? ( + + + + + ) : ( + + )} )} diff --git a/client/src/components/Conversations/ConvoLink.tsx b/client/src/components/Conversations/ConvoLink.tsx index 68c16594a5..9eb0764a70 100644 --- a/client/src/components/Conversations/ConvoLink.tsx +++ b/client/src/components/Conversations/ConvoLink.tsx @@ -3,6 +3,7 @@ import { cn } from '~/utils'; interface ConvoLinkProps { isActiveConvo: boolean; + isPopoverActive: boolean; title: string | null; onRename: () => void; isSmallScreen: boolean; @@ -12,6 +13,7 @@ interface ConvoLinkProps { const ConvoLink: React.FC = ({ isActiveConvo, + isPopoverActive, title, onRename, isSmallScreen, @@ -22,7 +24,7 @@ const ConvoLink: React.FC = ({
= ({
(null); const deleteButtonRef = useRef(null); const [showShareDialog, setShowShareDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [announcement, setAnnouncement] = useState(''); const archiveConvoMutation = useArchiveConvoMutation(); @@ -76,11 +78,11 @@ function ConvoOptions({ const isDuplicateLoading = duplicateConversation.isLoading; const isArchiveLoading = archiveConvoMutation.isLoading; - const handleShareClick = useCallback(() => { + const shareHandler = useCallback(() => { setShowShareDialog(true); }, []); - const handleDeleteClick = useCallback(() => { + const deleteHandler = useCallback(() => { setShowDeleteDialog(true); }, []); @@ -94,6 +96,10 @@ function ConvoOptions({ { conversationId: convoId, isArchived: true }, { onSuccess: () => { + setAnnouncement(localize('com_ui_convo_archived')); + setTimeout(() => { + setAnnouncement(''); + }, 10000); if (currentConvoId === convoId || currentConvoId === 'new') { newConversation(); navigate('/c/new', { replace: true }); @@ -132,9 +138,12 @@ function ConvoOptions({ () => [ { label: localize('com_ui_share'), - onClick: handleShareClick, - icon: , + onClick: shareHandler, + icon:
+
{(() => { if (isLoading === true) { @@ -74,28 +81,42 @@ export default function ShareButton({ : localize('com_ui_share_create_message'); })()}
-
+
{showQR && (
- +
)} {shareId && (
{sharedLink}
+ + {announcement} +
)} diff --git a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx index 9a2b740985..0f89c62666 100644 --- a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx @@ -1,13 +1,17 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; +import { Trans } from 'react-i18next'; import { QrCode, RotateCw, Trash2 } from 'lucide-react'; import { - Button, - OGDialog, - Spinner, - TooltipAnchor, Label, - OGDialogTemplate, + Button, + Spinner, + OGDialog, + OGDialogClose, + TooltipAnchor, + OGDialogTitle, + OGDialogHeader, useToastContext, + OGDialogContent, } from '@librechat/client'; import type { TSharedLinkGetResponse } from 'librechat-data-provider'; import { @@ -22,7 +26,6 @@ export default function SharedLinkButton({ share, conversationId, targetMessageId, - setShareDialogOpen, showQR, setShowQR, setSharedLink, @@ -30,14 +33,15 @@ export default function SharedLinkButton({ share: TSharedLinkGetResponse | undefined; conversationId: string; targetMessageId?: string; - setShareDialogOpen: React.Dispatch>; showQR: boolean; setShowQR: (showQR: boolean) => void; setSharedLink: (sharedLink: string) => void; }) { const localize = useLocalize(); const { showToast } = useToastContext(); + const deleteButtonRef = useRef(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [announcement, setAnnouncement] = useState(''); const shareId = share?.shareId ?? ''; const { mutateAsync: mutate, isLoading: isCreateLoading } = useCreateSharedLinkMutation({ @@ -61,9 +65,16 @@ export default function SharedLinkButton({ }); const deleteMutation = useDeleteSharedLinkMutation({ - onSuccess: async () => { + onSuccess: () => { setShowDeleteDialog(false); - setShareDialogOpen(false); + setTimeout(() => { + const dialog = document + .getElementById('share-conversation-dialog') + ?.closest('[role="dialog"]'); + if (dialog instanceof HTMLElement) { + dialog.focus(); + } + }, 0); }, onError: (error) => { console.error('Delete error:', error); @@ -85,6 +96,10 @@ export default function SharedLinkButton({ const updateShare = await mutateAsync({ shareId }); const newLink = generateShareLink(updateShare.shareId); setSharedLink(newLink); + setAnnouncement(localize('com_ui_link_refreshed')); + setTimeout(() => { + setAnnouncement(''); + }, 1000); }; const createShareLink = async () => { @@ -113,6 +128,8 @@ export default function SharedLinkButton({ } }; + const qrCodeLabel = showQR ? localize('com_ui_hide_qr') : localize('com_ui_show_qr'); + return ( <>
@@ -127,26 +144,37 @@ export default function SharedLinkButton({ ( - + <> + + {announcement} + + + )} /> ( - )} /> @@ -154,39 +182,56 @@ export default function SharedLinkButton({ ( - )} />
)} - - -
-
- -
-
- - } - selection={{ - selectHandler: handleDelete, - selectClasses: - 'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white', - selectText: localize('com_ui_delete'), - }} - /> + + + + {localize('com_ui_delete_shared_link_heading')} + +
+
+ +
+
+
+ + + + +
+
diff --git a/client/src/components/Conversations/RenameForm.tsx b/client/src/components/Conversations/RenameForm.tsx index e392215dc4..641b2a6484 100644 --- a/client/src/components/Conversations/RenameForm.tsx +++ b/client/src/components/Conversations/RenameForm.tsx @@ -34,6 +34,8 @@ const RenameForm: React.FC = ({ case 'Enter': onSubmit(titleInput); break; + case 'Tab': + break; } }; @@ -50,22 +52,23 @@ const RenameForm: React.FC = ({ value={titleInput} onChange={(e) => setTitleInput(e.target.value)} onKeyDown={handleKeyDown} - onBlur={() => onSubmit(titleInput)} maxLength={100} aria-label={localize('com_ui_new_conversation_title')} />
diff --git a/client/src/components/Endpoints/ConvoIcon.tsx b/client/src/components/Endpoints/ConvoIcon.tsx index faed70a9b9..0ac36924a6 100644 --- a/client/src/components/Endpoints/ConvoIcon.tsx +++ b/client/src/components/Endpoints/ConvoIcon.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; +import { getEndpointField } from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; -import { getEndpointField, getIconKey, getEntity, getIconEndpoint } from '~/utils'; +import { getIconKey, getEntity, getIconEndpoint } from '~/utils'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; import { icons } from '~/hooks/Endpoint/Icons'; diff --git a/client/src/components/Endpoints/EndpointIcon.tsx b/client/src/components/Endpoints/EndpointIcon.tsx index f635388f0e..c32ea12369 100644 --- a/client/src/components/Endpoints/EndpointIcon.tsx +++ b/client/src/components/Endpoints/EndpointIcon.tsx @@ -1,13 +1,13 @@ -import { isAssistantsEndpoint } from 'librechat-data-provider'; +import { getEndpointField, isAssistantsEndpoint } from 'librechat-data-provider'; import type { - TConversation, - TEndpointsConfig, TPreset, + TConversation, TAssistantsMap, + TEndpointsConfig, } from 'librechat-data-provider'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; import MinimalIcon from '~/components/Endpoints/MinimalIcon'; -import { getEndpointField, getIconEndpoint } from '~/utils'; +import { getIconEndpoint } from '~/utils'; export default function EndpointIcon({ conversation, diff --git a/client/src/components/Endpoints/EndpointSettings.tsx b/client/src/components/Endpoints/EndpointSettings.tsx index ce8be90634..d470f53062 100644 --- a/client/src/components/Endpoints/EndpointSettings.tsx +++ b/client/src/components/Endpoints/EndpointSettings.tsx @@ -1,10 +1,11 @@ import { useRecoilValue } from 'recoil'; -import { SettingsViews, TConversation } from 'librechat-data-provider'; import { useGetModelsQuery } from 'librechat-data-provider/react-query'; +import { getEndpointField, SettingsViews } from 'librechat-data-provider'; +import type { TConversation } from 'librechat-data-provider'; import type { TSettingsProps } from '~/common'; import { useGetEndpointsQuery } from '~/data-provider'; -import { cn, getEndpointField } from '~/utils'; import { getSettings } from './Settings'; +import { cn } from '~/utils'; import store from '~/store'; export default function Settings({ diff --git a/client/src/components/Endpoints/MessageEndpointIcon.tsx b/client/src/components/Endpoints/MessageEndpointIcon.tsx index 0a9782ce99..d3539fd78d 100644 --- a/client/src/components/Endpoints/MessageEndpointIcon.tsx +++ b/client/src/components/Endpoints/MessageEndpointIcon.tsx @@ -25,7 +25,7 @@ type EndpointIcon = { function getOpenAIColor(_model: string | null | undefined) { const model = _model?.toLowerCase() ?? ''; - if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9]\b/i.test(model))) { + if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9](?:\.\d+)?\b/i.test(model))) { return '#000000'; } return model.includes('gpt-4') ? '#AB68FF' : '#19C37D'; @@ -57,16 +57,7 @@ function getGoogleModelName(model: string | null | undefined) { } const MessageEndpointIcon: React.FC = (props) => { - const { - error, - button, - iconURL = '', - endpoint, - size = 30, - model = '', - assistantName, - agentName, - } = props; + const { error, iconURL = '', endpoint, size = 30, model = '', assistantName, agentName } = props; const assistantsIcon = { icon: iconURL ? ( @@ -119,7 +110,7 @@ const MessageEndpointIcon: React.FC = (props) => { ) : (
- +
), @@ -142,11 +133,6 @@ const MessageEndpointIcon: React.FC = (props) => { bg: getOpenAIColor(model), name: 'ChatGPT', }, - [EModelEndpoint.gptPlugins]: { - icon: , - bg: `rgba(69, 89, 164, ${button === true ? 0.75 : 1})`, - name: 'Plugins', - }, [EModelEndpoint.google]: { icon: getGoogleIcon(model, size), name: getGoogleModelName(model), diff --git a/client/src/components/Endpoints/MinimalIcon.tsx b/client/src/components/Endpoints/MinimalIcon.tsx index f21cad1e08..4a85eb09ab 100644 --- a/client/src/components/Endpoints/MinimalIcon.tsx +++ b/client/src/components/Endpoints/MinimalIcon.tsx @@ -1,15 +1,13 @@ import { Feather } from 'lucide-react'; import { EModelEndpoint, alternateName } from 'librechat-data-provider'; import { + Sparkles, + BedrockIcon, + AnthropicIcon, AzureMinimalIcon, OpenAIMinimalIcon, - LightningIcon, - MinimalPlugin, GoogleMinimalIcon, CustomMinimalIcon, - AnthropicIcon, - BedrockIcon, - Sparkles, } from '@librechat/client'; import UnknownIcon from '~/hooks/Endpoint/UnknownIcon'; import { IconProps } from '~/common'; @@ -33,7 +31,6 @@ const MinimalIcon: React.FC = (props) => { icon: , name: props.chatGptLabel ?? 'ChatGPT', }, - [EModelEndpoint.gptPlugins]: { icon: , name: 'Plugins' }, [EModelEndpoint.google]: { icon: , name: props.modelLabel ?? 'Google' }, [EModelEndpoint.anthropic]: { icon: , @@ -43,11 +40,10 @@ const MinimalIcon: React.FC = (props) => { icon: , name: 'Custom', }, - [EModelEndpoint.chatGPTBrowser]: { icon: , name: 'ChatGPT' }, [EModelEndpoint.assistants]: { icon: , name: 'Assistant' }, [EModelEndpoint.azureAssistants]: { icon: , name: 'Assistant' }, [EModelEndpoint.agents]: { - icon: , + icon:

- Files + {localize('com_ui_files')}

(true); - const isSmallScreen = useMediaQuery('(max-width: 640px)'); - const availableTools = useRecoilValue(store.availableTools); - const { checkPluginSelection, setTools } = useSetIndexOptions(); - - const { data: allPlugins } = useAvailablePluginsQuery({ - enabled: !!user?.plugins, - select: selectPlugins, - }); - - useEffect(() => { - if (isSmallScreen) { - setVisibility(false); - } - }, [isSmallScreen]); - - const conversationTools: TPlugin[] = useMemo(() => { - if (!conversation?.tools) { - return []; - } - return processPlugins(conversation.tools, allPlugins?.map); - }, [conversation, allPlugins]); - - const availablePlugins = useMemo(() => { - if (!availableTools) { - return []; - } - - return Object.values(availableTools); - }, [availableTools]); - - if (!conversation) { - return null; - } - - const Menu = popover ? SelectDropDownPop : SelectDropDown; - const PluginsMenu = popover ? MultiSelectPop : MultiSelectDropDown; - - return ( - <> - - {visible && ( - <> - - - - )} - - ); -} diff --git a/client/src/components/Input/ModelSelect/options.ts b/client/src/components/Input/ModelSelect/options.ts index b93e2e7e2a..318da5b066 100644 --- a/client/src/components/Input/ModelSelect/options.ts +++ b/client/src/components/Input/ModelSelect/options.ts @@ -4,9 +4,7 @@ import type { FC } from 'react'; import OpenAI from './OpenAI'; import Google from './Google'; -import ChatGPT from './ChatGPT'; import Anthropic from './Anthropic'; -import PluginsByIndex from './PluginsByIndex'; export const options: { [key: string]: FC } = { [EModelEndpoint.openAI]: OpenAI, @@ -15,10 +13,8 @@ export const options: { [key: string]: FC } = { [EModelEndpoint.azureOpenAI]: OpenAI, [EModelEndpoint.google]: Google, [EModelEndpoint.anthropic]: Anthropic, - [EModelEndpoint.chatGPTBrowser]: ChatGPT, }; export const multiChatOptions = { ...options, - [EModelEndpoint.gptPlugins]: PluginsByIndex, }; diff --git a/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx b/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx index 9ea4249129..7fec25e4a5 100644 --- a/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx +++ b/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx @@ -1,25 +1,24 @@ import React, { useState } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; -import { - OGDialog, - OGDialogContent, - OGDialogHeader, - OGDialogTitle, - OGDialogFooter, - Dropdown, - useToastContext, - Button, - Label, - OGDialogTrigger, - Spinner, -} from '@librechat/client'; import { EModelEndpoint, alternateName, isAssistantsEndpoint } from 'librechat-data-provider'; import { - useRevokeAllUserKeysMutation, useRevokeUserKeyMutation, + useRevokeAllUserKeysMutation, } from 'librechat-data-provider/react-query'; +import { + Label, + Button, + Spinner, + OGDialog, + Dropdown, + OGDialogTitle, + OGDialogHeader, + OGDialogFooter, + OGDialogContent, + useToastContext, + OGDialogTrigger, +} from '@librechat/client'; import type { TDialogProps } from '~/common'; -import { useGetEndpointsQuery } from '~/data-provider'; import { useUserKey, useLocalize } from '~/hooks'; import { NotificationSeverity } from '~/common'; import CustomConfig from './CustomEndpoint'; @@ -34,7 +33,6 @@ const endpointComponents = { [EModelEndpoint.openAI]: OpenAIConfig, [EModelEndpoint.custom]: CustomConfig, [EModelEndpoint.azureOpenAI]: OpenAIConfig, - [EModelEndpoint.gptPlugins]: OpenAIConfig, [EModelEndpoint.assistants]: OpenAIConfig, [EModelEndpoint.azureAssistants]: OpenAIConfig, default: OtherConfig, @@ -44,7 +42,6 @@ const formSet: Set = new Set([ EModelEndpoint.openAI, EModelEndpoint.custom, EModelEndpoint.azureOpenAI, - EModelEndpoint.gptPlugins, EModelEndpoint.assistants, EModelEndpoint.azureAssistants, ]); @@ -174,7 +171,6 @@ const SetKeyDialog = ({ }); const [userKey, setUserKey] = useState(''); - const { data: endpointsConfig } = useGetEndpointsQuery(); const [expiresAtLabel, setExpiresAtLabel] = useState(EXPIRY.TWELVE_HOURS.label); const { getExpiry, saveUserKey } = useUserKey(endpoint); const { showToast } = useToastContext(); @@ -218,10 +214,7 @@ const SetKeyDialog = ({ methods.handleSubmit((data) => { const isAzure = endpoint === EModelEndpoint.azureOpenAI; const isOpenAIBase = - isAzure || - endpoint === EModelEndpoint.openAI || - endpoint === EModelEndpoint.gptPlugins || - isAssistantsEndpoint(endpoint); + isAzure || endpoint === EModelEndpoint.openAI || isAssistantsEndpoint(endpoint); if (isAzure) { data.apiKey = 'n/a'; } @@ -280,7 +273,6 @@ const SetKeyDialog = ({ const EndpointComponent = endpointComponents[endpointType ?? endpoint] ?? endpointComponents['default']; const expiryTime = getExpiry(); - const config = endpointsConfig?.[endpoint]; return ( @@ -310,12 +302,8 @@ const SetKeyDialog = ({ diff --git a/client/src/components/MCP/CustomUserVarsSection.tsx b/client/src/components/MCP/CustomUserVarsSection.tsx index 9c2f0870d4..987613e24c 100644 --- a/client/src/components/MCP/CustomUserVarsSection.tsx +++ b/client/src/components/MCP/CustomUserVarsSection.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; +import DOMPurify from 'dompurify'; import { useForm, Controller } from 'react-hook-form'; -import { Input, Label, Button, TooltipAnchor, CircleHelpIcon } from '@librechat/client'; +import { Input, Label, Button } from '@librechat/client'; import { useMCPAuthValuesQuery } from '~/data-provider/Tools/queries'; import { useLocalize } from '~/hooks'; @@ -26,33 +27,55 @@ interface AuthFieldProps { function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps) { const localize = useLocalize(); + const statusText = hasValue ? localize('com_ui_set') : localize('com_ui_unset'); + + const sanitizer = useMemo(() => { + const instance = DOMPurify(); + instance.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName && node.tagName === 'A') { + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer'); + } + }); + return instance; + }, []); + + const sanitizedDescription = useMemo(() => { + if (!config.description) { + return ''; + } + try { + return sanitizer.sanitize(config.description, { + ALLOWED_TAGS: ['a', 'strong', 'b', 'em', 'i', 'br', 'code'], + ALLOWED_ATTR: ['href', 'class', 'target', 'rel'], + ALLOW_DATA_ATTR: false, + ALLOW_ARIA_ATTR: false, + }); + } catch (error) { + console.error('Sanitization failed', error); + return config.description; + } + }, [config.description, sanitizer]); return (
- - - + + ); diff --git a/client/src/components/Nav/SettingsTabs/DangerButton.tsx b/client/src/components/Nav/SettingsTabs/DangerButton.tsx index 15084e9ba9..897e674d8b 100644 --- a/client/src/components/Nav/SettingsTabs/DangerButton.tsx +++ b/client/src/components/Nav/SettingsTabs/DangerButton.tsx @@ -55,7 +55,7 @@ const DangerButton = (props: TDangerButtonProps, ref: ForwardedRef - {renderMutation()} + {renderMutation(
) : ( diff --git a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx index 2d06b74392..3f0b288c93 100644 --- a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx @@ -9,12 +9,10 @@ import { useLocalize } from '~/hooks'; import { cn, logger } from '~/utils'; function ImportConversations() { - const queryClient = useQueryClient(); - const startupConfig = queryClient.getQueryData([QueryKeys.startupConfig]); const localize = useLocalize(); - const fileInputRef = useRef(null); + const queryClient = useQueryClient(); const { showToast } = useToastContext(); - + const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const handleSuccess = useCallback(() => { @@ -53,7 +51,8 @@ function ImportConversations() { const handleFileUpload = useCallback( async (file: File) => { try { - const maxFileSize = (startupConfig as any)?.conversationImportMaxFileSize; + const startupConfig = queryClient.getQueryData([QueryKeys.startupConfig]); + const maxFileSize = startupConfig?.conversationImportMaxFileSize; if (maxFileSize && file.size > maxFileSize) { const size = (maxFileSize / (1024 * 1024)).toFixed(2); showToast({ @@ -76,7 +75,7 @@ function ImportConversations() { }); } }, - [uploadFile, showToast, localize, startupConfig], + [uploadFile, showToast, localize, queryClient], ); const handleFileChange = useCallback( @@ -119,11 +118,16 @@ function ImportConversations() { aria-labelledby="import-conversation-label" > {isUploading ? ( - + <> + + {localize('com_ui_importing')} + ) : ( - + <> +
); @@ -205,25 +213,28 @@ export default function SharedLinks() { }, { accessorKey: 'createdAt', - header: () => { - const isSorted = queryParams.sortBy === 'createdAt'; - const sortDirection = queryParams.sortDirection; + header: ({ column }) => { + const sortState = column.getIsSorted(); + let SortIcon = ArrowUpDown; + let ariaSort: 'ascending' | 'descending' | 'none' = 'none'; + if (sortState === 'desc') { + SortIcon = ArrowDown; + ariaSort = 'descending'; + } else if (sortState === 'asc') { + SortIcon = ArrowUp; + ariaSort = 'ascending'; + } return ( ); }, @@ -247,22 +258,23 @@ export default function SharedLinks() { cell: ({ row }) => (
{ - window.open(`/c/${row.original.conversationId}`, '_blank'); - }} - title={localize('com_ui_view_source')} + - - + } /> - +
-
+

{localize('com_ui_oauth_success_title') || 'Authentication Successful'}

diff --git a/client/src/components/Plugins/Store/PluginAuthForm.tsx b/client/src/components/Plugins/Store/PluginAuthForm.tsx index f7a214e88b..f6eec0a6af 100644 --- a/client/src/components/Plugins/Store/PluginAuthForm.tsx +++ b/client/src/components/Plugins/Store/PluginAuthForm.tsx @@ -70,7 +70,7 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps {errors[authField] && ( - {errors[authField].message as string} + {errors?.[authField]?.message ?? ''} )}
@@ -93,7 +93,7 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps >
{localize('com_ui_save')} - +
diff --git a/client/src/components/Plugins/Store/PluginStoreDialog.tsx b/client/src/components/Plugins/Store/PluginStoreDialog.tsx deleted file mode 100644 index b54fdada34..0000000000 --- a/client/src/components/Plugins/Store/PluginStoreDialog.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { Search, X } from 'lucide-react'; -import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react'; -import { useState, useEffect, useCallback } from 'react'; -import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query'; -import type { TError, TPlugin, TPluginAction } from 'librechat-data-provider'; -import type { TPluginStoreDialogProps } from '~/common/types'; -import { - usePluginDialogHelpers, - useSetIndexOptions, - usePluginInstall, - useAuthContext, - useLocalize, -} from '~/hooks'; -import PluginPagination from './PluginPagination'; -import PluginStoreItem from './PluginStoreItem'; -import PluginAuthForm from './PluginAuthForm'; - -function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) { - const localize = useLocalize(); - const { user } = useAuthContext(); - const { data: availablePlugins } = useAvailablePluginsQuery(); - const { setTools } = useSetIndexOptions(); - - const [userPlugins, setUserPlugins] = useState([]); - - const { - maxPage, - setMaxPage, - currentPage, - setCurrentPage, - itemsPerPage, - searchChanged, - setSearchChanged, - searchValue, - setSearchValue, - gridRef, - handleSearch, - handleChangePage, - error, - setError, - errorMessage, - setErrorMessage, - showPluginAuthForm, - setShowPluginAuthForm, - selectedPlugin, - setSelectedPlugin, - } = usePluginDialogHelpers(); - - const handleInstallError = useCallback( - (error: TError) => { - setError(true); - if (error.response?.data?.message) { - setErrorMessage(error.response.data.message); - } - setTimeout(() => { - setError(false); - setErrorMessage(''); - }, 5000); - }, - [setError, setErrorMessage], - ); - - const { installPlugin, uninstallPlugin } = usePluginInstall({ - onInstallError: handleInstallError, - onUninstallError: handleInstallError, - onUninstallSuccess: (_data, variables) => { - setTools(variables.pluginKey, true); - }, - }); - - const handleInstall = (pluginAction: TPluginAction, plugin?: TPlugin) => { - if (!plugin) { - return; - } - installPlugin(pluginAction, plugin); - setShowPluginAuthForm(false); - }; - - const onPluginInstall = (pluginKey: string) => { - const plugin = availablePlugins?.find((p) => p.pluginKey === pluginKey); - if (!plugin) { - return; - } - setSelectedPlugin(plugin); - - const { authConfig, authenticated } = plugin ?? {}; - - if (authConfig && authConfig.length > 0 && !authenticated) { - setShowPluginAuthForm(true); - } else { - handleInstall({ pluginKey, action: 'install', auth: null }, plugin); - } - }; - - const filteredPlugins = availablePlugins?.filter((plugin) => - plugin.name.toLowerCase().includes(searchValue.toLowerCase()), - ); - - useEffect(() => { - if (user && user.plugins) { - setUserPlugins(user.plugins); - } - - if (filteredPlugins) { - setMaxPage(Math.ceil(filteredPlugins.length / itemsPerPage)); - if (searchChanged) { - setCurrentPage(1); - setSearchChanged(false); - } - } - }, [ - availablePlugins, - itemsPerPage, - user, - searchValue, - filteredPlugins, - searchChanged, - setMaxPage, - setCurrentPage, - setSearchChanged, - ]); - - return ( - { - setIsOpen(false); - setCurrentPage(1); - setSearchValue(''); - }} - className="relative z-[102]" - > - {/* The backdrop, rendered as a fixed sibling to the panel container */} -
- {/* Full-screen container to center the panel */} -
- -
-
-
- - {localize('com_nav_plugin_store')} - -
-
-
-
- -
-
-
- {error && ( -
- {localize('com_nav_plugin_auth_error')} {errorMessage} -
- )} - {showPluginAuthForm && ( -
- handleInstall(action, selectedPlugin)} - /> -
- )} -
-
-
-
- - -
-
-
- {filteredPlugins && - filteredPlugins - .slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage) - .map((plugin, index) => ( - onPluginInstall(plugin.pluginKey)} - onUninstall={() => uninstallPlugin(plugin.pluginKey)} - /> - ))} -
-
-
- {maxPage > 0 ? ( - - ) : ( -
- )} - {/* API not yet implemented: */} - {/*
- -
- -
- -
*/} -
-
-
-
-
- ); -} - -export default PluginStoreDialog; diff --git a/client/src/components/Plugins/Store/PluginStoreItem.tsx b/client/src/components/Plugins/Store/PluginStoreItem.tsx deleted file mode 100644 index 6221c8dead..0000000000 --- a/client/src/components/Plugins/Store/PluginStoreItem.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { TPlugin } from 'librechat-data-provider'; -import { XCircle, DownloadCloud } from 'lucide-react'; -import { useLocalize } from '~/hooks'; - -type TPluginStoreItemProps = { - plugin: TPlugin; - onInstall: () => void; - onUninstall: () => void; - isInstalled?: boolean; -}; - -function PluginStoreItem({ plugin, onInstall, onUninstall, isInstalled }: TPluginStoreItemProps) { - const localize = useLocalize(); - const handleClick = () => { - if (isInstalled) { - onUninstall(); - } else { - onInstall(); - } - }; - - return ( - <> -
-
-
-
- {`${plugin.name} -
-
-
-
-
- {plugin.name} -
- {!isInstalled ? ( - - ) : ( - - )} -
-
-
- {plugin.description} -
-
- - ); -} - -export default PluginStoreItem; diff --git a/client/src/components/Plugins/Store/PluginStoreLinkButton.tsx b/client/src/components/Plugins/Store/PluginStoreLinkButton.tsx deleted file mode 100644 index fba9b6da61..0000000000 --- a/client/src/components/Plugins/Store/PluginStoreLinkButton.tsx +++ /dev/null @@ -1,18 +0,0 @@ -type TPluginStoreLinkButtonProps = { - onClick: () => void; - label: string; -}; - -function PluginStoreLinkButton({ onClick, label }: TPluginStoreLinkButtonProps) { - return ( -
- {label} -
- ); -} - -export default PluginStoreLinkButton; diff --git a/client/src/components/Plugins/Store/PluginTooltip.tsx b/client/src/components/Plugins/Store/PluginTooltip.tsx index a383496f2f..378896fdec 100644 --- a/client/src/components/Plugins/Store/PluginTooltip.tsx +++ b/client/src/components/Plugins/Store/PluginTooltip.tsx @@ -1,5 +1,4 @@ import { HoverCardPortal, HoverCardContent } from '@librechat/client'; -import './styles.module.css'; type TPluginTooltipProps = { content: string; @@ -9,11 +8,9 @@ type TPluginTooltipProps = { function PluginTooltip({ content, position }: TPluginTooltipProps) { return ( - +
-
- {content} -
+
{content}
diff --git a/client/src/components/Plugins/Store/__tests__/PluginStoreDialog.spec.tsx b/client/src/components/Plugins/Store/__tests__/PluginStoreDialog.spec.tsx deleted file mode 100644 index 16b38661ec..0000000000 --- a/client/src/components/Plugins/Store/__tests__/PluginStoreDialog.spec.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { render, screen, fireEvent } from 'test/layout-test-utils'; -import PluginStoreDialog from '../PluginStoreDialog'; -import userEvent from '@testing-library/user-event'; -import * as mockDataProvider from 'librechat-data-provider/react-query'; -import * as authMutations from '~/data-provider/Auth/mutations'; -import * as authQueries from '~/data-provider/Auth/queries'; - -jest.mock('librechat-data-provider/react-query'); - -class ResizeObserver { - observe() { - // do nothing - } - unobserve() { - // do nothing - } - disconnect() { - // do nothing - } -} - -window.ResizeObserver = ResizeObserver; - -const pluginsQueryResult = [ - { - name: 'Google', - pluginKey: 'google', - description: 'Use Google Search to find information', - icon: 'https://i.imgur.com/SMmVkNB.png', - authConfig: [ - { - authField: 'GOOGLE_CSE_ID', - label: 'Google CSE ID', - description: 'This is your Google Custom Search Engine ID.', - }, - ], - }, - { - name: 'Wolfram', - pluginKey: 'wolfram', - description: - 'Access computation, math, curated knowledge & real-time data through Wolfram|Alpha and Wolfram Language.', - icon: 'https://www.wolframcdn.com/images/icons/Wolfram.png', - authConfig: [ - { - authField: 'WOLFRAM_APP_ID', - label: 'Wolfram App ID', - description: 'An AppID must be supplied in all calls to the Wolfram|Alpha API.', - }, - ], - }, - { - name: 'Calculator', - pluginKey: 'calculator', - description: 'A simple calculator plugin', - icon: 'https://i.imgur.com/SMmVkNB.png', - authConfig: [], - }, - { - name: 'Plugin 1', - pluginKey: 'plugin1', - description: 'description for Plugin 1.', - icon: 'mock-icon', - authConfig: [], - }, - { - name: 'Plugin 2', - pluginKey: 'plugin2', - description: 'description for Plugin 2.', - icon: 'mock-icon', - authConfig: [], - }, - { - name: 'Plugin 3', - pluginKey: 'plugin3', - description: 'description for Plugin 3.', - icon: 'mock-icon', - authConfig: [], - }, - { - name: 'Plugin 4', - pluginKey: 'plugin4', - description: 'description for Plugin 4.', - icon: 'mock-icon', - authConfig: [], - }, - { - name: 'Plugin 5', - pluginKey: 'plugin5', - description: 'description for Plugin 5.', - icon: 'mock-icon', - authConfig: [], - }, - { - name: 'Plugin 6', - pluginKey: 'plugin6', - description: 'description for Plugin 6.', - icon: 'mock-icon', - authConfig: [], - }, - { - name: 'Plugin 7', - pluginKey: 'plugin7', - description: 'description for Plugin 7.', - icon: 'mock-icon', - authConfig: [], - }, -]; - -const setup = ({ - useGetUserQueryReturnValue = { - isLoading: false, - isError: false, - data: { - plugins: ['wolfram'], - }, - }, - useRefreshTokenMutationReturnValue = { - isLoading: false, - isError: false, - mutate: jest.fn(), - data: { - token: 'mock-token', - user: {}, - }, - }, - useAvailablePluginsQueryReturnValue = { - isLoading: false, - isError: false, - data: pluginsQueryResult, - }, - useUpdateUserPluginsMutationReturnValue = { - isLoading: false, - isError: false, - mutate: jest.fn(), - data: {}, - }, -} = {}) => { - const mockUseAvailablePluginsQuery = jest - .spyOn(mockDataProvider, 'useAvailablePluginsQuery') - //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult - .mockReturnValue(useAvailablePluginsQueryReturnValue); - const mockUseUpdateUserPluginsMutation = jest - .spyOn(mockDataProvider, 'useUpdateUserPluginsMutation') - //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult - .mockReturnValue(useUpdateUserPluginsMutationReturnValue); - const mockUseGetUserQuery = jest - .spyOn(authQueries, 'useGetUserQuery') - //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult - .mockReturnValue(useGetUserQueryReturnValue); - const mockUseRefreshTokenMutation = jest - .spyOn(authMutations, 'useRefreshTokenMutation') - //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult - .mockReturnValue(useRefreshTokenMutationReturnValue); - const mockSetIsOpen = jest.fn(); - const renderResult = render(); - - return { - ...renderResult, - mockUseGetUserQuery, - mockUseAvailablePluginsQuery, - mockUseUpdateUserPluginsMutation, - mockUseRefreshTokenMutation, - mockSetIsOpen, - }; -}; - -test('renders plugin store dialog with plugins from the available plugins query and shows install/uninstall buttons based on user plugins', () => { - const { getByText, getByRole } = setup(); - expect(getByText(/Plugin Store/i)).toBeInTheDocument(); - expect(getByText(/Use Google Search to find information/i)).toBeInTheDocument(); - expect(getByRole('button', { name: 'Install Google' })).toBeInTheDocument(); - expect(getByRole('button', { name: 'Uninstall Wolfram' })).toBeInTheDocument(); -}); - -test('Displays the plugin auth form when installing a plugin with auth', async () => { - const { getByRole, getByText } = setup(); - const googleButton = getByRole('button', { name: 'Install Google' }); - await userEvent.click(googleButton); - expect(getByText(/Google CSE ID/i)).toBeInTheDocument(); - expect(getByRole('button', { name: 'Save' })).toBeInTheDocument(); -}); - -test('allows the user to navigate between pages', async () => { - const { getByRole, getByText } = setup(); - - expect(getByText('Google')).toBeInTheDocument(); - expect(getByText('Wolfram')).toBeInTheDocument(); - expect(getByText('Plugin 1')).toBeInTheDocument(); - - const nextPageButton = getByRole('button', { name: 'Next page' }); - await userEvent.click(nextPageButton); - - expect(getByText('Plugin 6')).toBeInTheDocument(); - expect(getByText('Plugin 7')).toBeInTheDocument(); - // expect(getByText('Plugin 3')).toBeInTheDocument(); - // expect(getByText('Plugin 4')).toBeInTheDocument(); - // expect(getByText('Plugin 5')).toBeInTheDocument(); - - const previousPageButton = getByRole('button', { name: 'Previous page' }); - await userEvent.click(previousPageButton); - - expect(getByText('Google')).toBeInTheDocument(); - expect(getByText('Wolfram')).toBeInTheDocument(); - expect(getByText('Plugin 1')).toBeInTheDocument(); -}); - -test('allows the user to search for plugins', async () => { - setup(); - - const searchInput = screen.getByPlaceholderText('Search plugins'); - fireEvent.change(searchInput, { target: { value: 'Google' } }); - - expect(screen.getByText('Google')).toBeInTheDocument(); - expect(screen.queryByText('Wolfram')).not.toBeInTheDocument(); - expect(screen.queryByText('Plugin 1')).not.toBeInTheDocument(); - - fireEvent.change(searchInput, { target: { value: 'Plugin 1' } }); - - expect(screen.getByText('Plugin 1')).toBeInTheDocument(); - expect(screen.queryByText('Google')).not.toBeInTheDocument(); - expect(screen.queryByText('Wolfram')).not.toBeInTheDocument(); -}); diff --git a/client/src/components/Plugins/Store/__tests__/PluginStoreItem.spec.tsx b/client/src/components/Plugins/Store/__tests__/PluginStoreItem.spec.tsx deleted file mode 100644 index ef2a861c97..0000000000 --- a/client/src/components/Plugins/Store/__tests__/PluginStoreItem.spec.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import 'test/matchMedia.mock'; -import { render, screen } from 'test/layout-test-utils'; -import userEvent from '@testing-library/user-event'; -import { TPlugin } from 'librechat-data-provider'; -import PluginStoreItem from '../PluginStoreItem'; - -const mockPlugin = { - name: 'Test Plugin', - description: 'This is a test plugin', - icon: 'test-icon.png', -}; - -describe('PluginStoreItem', () => { - it('renders the plugin name and description', () => { - render( - { - return; - }} - onUninstall={() => { - return; - }} - />, - ); - expect(screen.getByText('Test Plugin')).toBeInTheDocument(); - expect(screen.getByText('This is a test plugin')).toBeInTheDocument(); - }); - - it('calls onInstall when the install button is clicked', async () => { - const onInstall = jest.fn(); - render( - { - return; - }} - />, - ); - await userEvent.click(screen.getByText('Install')); - expect(onInstall).toHaveBeenCalled(); - }); - - it('calls onUninstall when the uninstall button is clicked', async () => { - const onUninstall = jest.fn(); - render( - { - return; - }} - onUninstall={onUninstall} - isInstalled - />, - ); - await userEvent.click(screen.getByText('Uninstall')); - expect(onUninstall).toHaveBeenCalled(); - }); -}); diff --git a/client/src/components/Plugins/Store/index.ts b/client/src/components/Plugins/Store/index.ts index 2f9a1d4807..53a8c86fae 100644 --- a/client/src/components/Plugins/Store/index.ts +++ b/client/src/components/Plugins/Store/index.ts @@ -1,6 +1,3 @@ -export { default as PluginStoreDialog } from './PluginStoreDialog'; -export { default as PluginStoreItem } from './PluginStoreItem'; export { default as PluginPagination } from './PluginPagination'; -export { default as PluginStoreLinkButton } from './PluginStoreLinkButton'; export { default as PluginAuthForm } from './PluginAuthForm'; export { default as PluginTooltip } from './PluginTooltip'; diff --git a/client/src/components/Plugins/Store/styles.module.css b/client/src/components/Plugins/Store/styles.module.css deleted file mode 100644 index acd6ab3c01..0000000000 --- a/client/src/components/Plugins/Store/styles.module.css +++ /dev/null @@ -1,4 +0,0 @@ -a { - text-decoration: underline; - color: white; -} diff --git a/client/src/components/Plugins/index.ts b/client/src/components/Plugins/index.ts deleted file mode 100644 index 47e0805c13..0000000000 --- a/client/src/components/Plugins/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Store'; diff --git a/client/src/components/Prompts/AdminSettings.tsx b/client/src/components/Prompts/AdminSettings.tsx index 6f1580800e..d9218229f0 100644 --- a/client/src/components/Prompts/AdminSettings.tsx +++ b/client/src/components/Prompts/AdminSettings.tsx @@ -53,6 +53,7 @@ const LabelController: React.FC = ({ } }} value={field.value.toString()} + aria-label={label} /> )} /> @@ -155,7 +156,7 @@ const AdminSettings = () => { - {`${localize('com_ui_admin_settings')} - ${localize('com_ui_prompts')}`} + {localize('com_ui_admin_settings_section', { section: localize('com_ui_prompts') })}
{/* Role selection dropdown */} @@ -207,7 +208,7 @@ const AdminSettings = () => { className="inline-flex items-center text-blue-500 underline" > {localize('com_ui_more_info')} - +
@@ -216,7 +217,12 @@ const AdminSettings = () => { ))}
-
diff --git a/client/src/components/Prompts/AdvancedSwitch.tsx b/client/src/components/Prompts/AdvancedSwitch.tsx index d8d6c219fc..b050f6b343 100644 --- a/client/src/components/Prompts/AdvancedSwitch.tsx +++ b/client/src/components/Prompts/AdvancedSwitch.tsx @@ -30,9 +30,11 @@ const AdvancedSwitch = () => { setAlwaysMakeProd(true); setMode(PromptsEditorMode.SIMPLE); }} - className={`relative z-10 flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-300 md:px-6 ${ + aria-pressed={mode === PromptsEditorMode.SIMPLE} + aria-label={localize('com_ui_simple')} + className={`relative z-10 flex-1 rounded-xl px-3 py-2 text-sm transition-all duration-300 md:px-6 ${ mode === PromptsEditorMode.SIMPLE - ? 'text-text-primary' + ? 'font-bold text-text-primary' : 'text-text-secondary hover:text-text-primary' }`} > @@ -43,9 +45,11 @@ const AdvancedSwitch = () => {
); }; diff --git a/client/src/components/Prompts/DeleteVersion.tsx b/client/src/components/Prompts/DeleteVersion.tsx index 8a7f6109b8..428be89c91 100644 --- a/client/src/components/Prompts/DeleteVersion.tsx +++ b/client/src/components/Prompts/DeleteVersion.tsx @@ -28,7 +28,7 @@ const DeleteConfirmDialog = ({ e.stopPropagation(); }} > - +
); }; diff --git a/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx b/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx index 17c82c648d..64d6bd60ec 100644 --- a/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx +++ b/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx @@ -28,7 +28,7 @@ export default function AlwaysMakeProd({ checked={alwaysMakeProd} onCheckedChange={handleCheckedChange} data-testid="alwaysMakeProd" - aria-label="Always make prompt production" + aria-label={localize('com_nav_always_make_prod')} />
{localize('com_nav_always_make_prod')}
diff --git a/client/src/components/Prompts/Groups/AutoSendPrompt.tsx b/client/src/components/Prompts/Groups/AutoSendPrompt.tsx index 430506a748..182580a49c 100644 --- a/client/src/components/Prompts/Groups/AutoSendPrompt.tsx +++ b/client/src/components/Prompts/Groups/AutoSendPrompt.tsx @@ -30,7 +30,7 @@ export default function AutoSendPrompt({ >
{localize('com_nav_auto_send_prompts')}
(null); + const onCardClick: React.MouseEventHandler = () => { const text = group.productionPrompt?.prompt; if (!text?.trim()) { @@ -53,30 +55,33 @@ function ChatGroupItem({ return ( <> - 0 - ? group.oneliner - : (group.productionPrompt?.prompt ?? '') - } - > -
- {groupIsGlobal === true && ( - - )} +
+ 0 + ? group.oneliner + : (group.productionPrompt?.prompt ?? '') + } + > + {groupIsGlobal === true && ( +
+ +
+ )} +
@@ -102,6 +107,9 @@ function ChatGroupItem({ e.stopPropagation(); setPreviewDialogOpen(true); }} + onKeyDown={(e) => { + e.stopPropagation(); + }} className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed" >
- - +
+ { + requestAnimationFrame(() => { + triggerButtonRef.current?.focus({ preventScroll: true }); + }); + }} + /> setVariableDialogOpen(false)} diff --git a/client/src/components/Prompts/Groups/CreatePromptForm.tsx b/client/src/components/Prompts/Groups/CreatePromptForm.tsx index c7bbbd7267..3f94932c68 100644 --- a/client/src/components/Prompts/Groups/CreatePromptForm.tsx +++ b/client/src/components/Prompts/Groups/CreatePromptForm.tsx @@ -104,6 +104,7 @@ const CreatePromptForm = ({ return (
+

{localize('com_ui_create_prompt_page')}

( -
+
+
(null); const [nameInputValue, setNameInputValue] = useState(group.name); - const { hasPermission } = useResourcePermissions('promptGroup', group._id || ''); + const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || ''); const canEdit = hasPermission(PermissionBits.EDIT); const canDelete = hasPermission(PermissionBits.DELETE); @@ -62,8 +62,8 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps }, [group._id, nameInputValue, updateGroup]); const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Enter') { + (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/d/prompts/${group._id}`, { replace: true }); } @@ -82,16 +82,26 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps return (
-
+ - - -
- setNameInputValue(e.target.value)} - className="w-full" - aria-label={localize('com_ui_rename_prompt') + ' ' + group.name} - /> -
-
- } - selection={{ - selectHandler: handleSaveRename, - selectClasses: - 'bg-surface-submit hover:bg-surface-submit-hover text-white disabled:hover:bg-surface-submit', - selectText: localize('com_ui_save'), - isLoading, - }} - /> - - )} - - {canDelete && ( - - - - - -
- -
-
- } - selection={{ - selectHandler: triggerDelete, - selectClasses: - 'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white', - selectText: localize('com_ui_delete'), - }} - /> - - )}
+ + +
+ {canEdit && ( + + + + + +
+ setNameInputValue(e.target.value)} + className="w-full" + aria-label={localize('com_ui_rename_prompt_name', { name: group.name })} + /> +
+
+ } + selection={{ + selectHandler: handleSaveRename, + selectClasses: + 'bg-surface-submit hover:bg-surface-submit-hover text-white disabled:hover:bg-surface-submit', + selectText: localize('com_ui_save'), + isLoading, + }} + /> + + )} + + {canDelete && ( + + + + + +
+ +
+
+ } + selection={{ + selectHandler: triggerDelete, + selectClasses: + 'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white', + selectText: localize('com_ui_delete'), + }} + /> + + )}
); diff --git a/client/src/components/Prompts/Groups/FilterPrompts.tsx b/client/src/components/Prompts/Groups/FilterPrompts.tsx index f74800bdf8..7c57d09f60 100644 --- a/client/src/components/Prompts/Groups/FilterPrompts.tsx +++ b/client/src/components/Prompts/Groups/FilterPrompts.tsx @@ -1,21 +1,22 @@ -import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { useRecoilState } from 'recoil'; import { ListFilter, User, Share2 } from 'lucide-react'; import { SystemCategories } from 'librechat-data-provider'; import { Dropdown, AnimatedSearchInput } from '@librechat/client'; import type { Option } from '~/common'; -import { useLocalize, useCategories } from '~/hooks'; +import { useLocalize, useCategories, useDebounce } from '~/hooks'; import { usePromptGroupsContext } from '~/Providers'; import { cn } from '~/utils'; import store from '~/store'; export default function FilterPrompts({ className = '' }: { className?: string }) { const localize = useLocalize(); - const { name, setName, hasAccess } = usePromptGroupsContext(); + const { name, setName, hasAccess, promptGroups } = usePromptGroupsContext(); const { categories } = useCategories({ className: 'h-4 w-4', hasAccess }); - const [displayName, setDisplayName] = useState(name || ''); - const [isSearching, setIsSearching] = useState(false); + const [searchTerm, setSearchTerm] = useState(name || ''); const [categoryFilter, setCategory] = useRecoilState(store.promptsCategory); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + const prevNameRef = useRef(name); const filterOptions = useMemo(() => { const baseOptions: Option[] = [ @@ -60,29 +61,37 @@ export default function FilterPrompts({ className = '' }: { className?: string } [setCategory], ); - // Sync displayName with name prop when it changes externally + // Sync searchTerm with name prop when it changes externally useEffect(() => { - setDisplayName(name || ''); + if (prevNameRef.current !== name) { + prevNameRef.current = name; + setSearchTerm(name || ''); + } }, [name]); useEffect(() => { - if (displayName === '') { - // Clear immediately when empty - setName(''); - setIsSearching(false); - return; - } + setName(debouncedSearchTerm); + }, [debouncedSearchTerm, setName]); - setIsSearching(true); - const timeout = setTimeout(() => { - setIsSearching(false); - setName(displayName); // Debounced setName call - }, 500); - return () => clearTimeout(timeout); - }, [displayName, setName]); + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + }, []); + + const isSearching = searchTerm !== debouncedSearchTerm; + + const resultCount = promptGroups?.length ?? 0; + const searchResultsAnnouncement = useMemo(() => { + if (!debouncedSearchTerm.trim()) { + return ''; + } + return resultCount === 1 ? `${resultCount} result found` : `${resultCount} results found`; + }, [debouncedSearchTerm, resultCount]); return ( -
+
+
+ {searchResultsAnnouncement} +
{ - setDisplayName(e.target.value); - }} + value={searchTerm} + onChange={handleSearchChange} isSearching={isSearching} placeholder={localize('com_ui_filter_prompts_name')} /> diff --git a/client/src/components/Prompts/Groups/GroupSidePanel.tsx b/client/src/components/Prompts/Groups/GroupSidePanel.tsx index fe9426fdae..1eca604af1 100644 --- a/client/src/components/Prompts/Groups/GroupSidePanel.tsx +++ b/client/src/components/Prompts/Groups/GroupSidePanel.tsx @@ -1,23 +1,26 @@ import { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; -import { useMediaQuery } from '@librechat/client'; +import { Button, Sidebar, TooltipAnchor } from '@librechat/client'; import ManagePrompts from '~/components/Prompts/ManagePrompts'; import { usePromptGroupsContext } from '~/Providers'; import List from '~/components/Prompts/Groups/List'; import PanelNavigation from './PanelNavigation'; +import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; export default function GroupSidePanel({ children, - isDetailView, className = '', + closePanelRef, + onClose, }: { children?: React.ReactNode; - isDetailView?: boolean; className?: string; + closePanelRef?: React.RefObject; + onClose?: () => void; }) { const location = useLocation(); - const isSmallerScreen = useMediaQuery('(max-width: 1024px)'); + const localize = useLocalize(); const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]); const { promptGroups, groupsQuery, nextPage, prevPage, hasNextPage, hasPreviousPage } = @@ -25,15 +28,42 @@ export default function GroupSidePanel({ return (
- {children} -
- + {onClose && ( +
+ + + + } + /> +
+ )} +
+ {children} +
+ +
navigate('/d/prompts/new')} + aria-label={localize('com_ui_create_prompt')} > - +
diff --git a/client/src/components/Prompts/Groups/ListCard.tsx b/client/src/components/Prompts/Groups/ListCard.tsx index 61f64fef7c..0503a69aa6 100644 --- a/client/src/components/Prompts/Groups/ListCard.tsx +++ b/client/src/components/Prompts/Groups/ListCard.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Label } from '@librechat/client'; import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon'; +import { useLocalize } from '~/hooks'; export default function ListCard({ category, @@ -15,6 +16,7 @@ export default function ListCard({ onClick?: React.MouseEventHandler; children?: React.ReactNode; }) { + const localize = useLocalize(); const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); @@ -26,12 +28,12 @@ export default function ListCard({
@@ -48,7 +50,7 @@ export default function ListCard({
{snippet}
diff --git a/client/src/components/Prompts/Groups/NoPromptGroup.tsx b/client/src/components/Prompts/Groups/NoPromptGroup.tsx index 4bfa446ea7..58a5e4816a 100644 --- a/client/src/components/Prompts/Groups/NoPromptGroup.tsx +++ b/client/src/components/Prompts/Groups/NoPromptGroup.tsx @@ -17,6 +17,7 @@ export default function NoPromptGroup() { onClick={() => { navigate('/d/prompts'); }} + aria-label={localize('com_ui_back_to_prompts')} > {localize('com_ui_back_to_prompts')} diff --git a/client/src/components/Prompts/Groups/PanelNavigation.tsx b/client/src/components/Prompts/Groups/PanelNavigation.tsx index 421d053aa2..ee03865345 100644 --- a/client/src/components/Prompts/Groups/PanelNavigation.tsx +++ b/client/src/components/Prompts/Groups/PanelNavigation.tsx @@ -23,7 +23,7 @@ function PanelNavigation({ return (
-
+
{!isChatRoute && } {children}
diff --git a/client/src/components/Prompts/Groups/VariableForm.tsx b/client/src/components/Prompts/Groups/VariableForm.tsx index d11bc041c6..f1f31162f4 100644 --- a/client/src/components/Prompts/Groups/VariableForm.tsx +++ b/client/src/components/Prompts/Groups/VariableForm.tsx @@ -140,7 +140,7 @@ export default function VariableForm({ return (
-
+
{fields.map((field, index) => ( -
+
+ <> + + + ); }} /> @@ -201,7 +210,7 @@ export default function VariableForm({ ))}
-
diff --git a/client/src/components/Prompts/PreviewPrompt.tsx b/client/src/components/Prompts/PreviewPrompt.tsx index 6193bd9e5d..bee4eb09f6 100644 --- a/client/src/components/Prompts/PreviewPrompt.tsx +++ b/client/src/components/Prompts/PreviewPrompt.tsx @@ -6,14 +6,19 @@ const PreviewPrompt = ({ group, open, onOpenChange, + onCloseAutoFocus, }: { group: TPromptGroup; open: boolean; onOpenChange: (open: boolean) => void; + onCloseAutoFocus?: () => void; }) => { return ( - +
diff --git a/client/src/components/Prompts/PromptEditor.tsx b/client/src/components/Prompts/PromptEditor.tsx index 3d7af5ec01..ea0d1ef15c 100644 --- a/client/src/components/Prompts/PromptEditor.tsx +++ b/client/src/components/Prompts/PromptEditor.tsx @@ -55,7 +55,8 @@ const PromptEditor: React.FC = ({ name, isEditing, setIsEditing }) => { return (
-

+

{localize('com_ui_control_bar')}

+
{localize('com_ui_prompt_text')} @@ -78,7 +79,7 @@ const PromptEditor: React.FC = ({ name, isEditing, setIsEditing }) => { />
- +
= ({ name, isEditing, setIsEditing }) => { setIsEditing(false); } }} + aria-label={localize('com_ui_prompt_input')} /> ) : (
- +