{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 b6a7032e9f..64b804b2d6 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -1,22 +1,62 @@ -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 { 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 +70,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' }; @@ -75,10 +144,19 @@ const Conversations: FC = ({ loadMoreConversations, isLoading, isSearchLoading, + isChatsExpanded, + setIsChatsExpanded, }) => { const localize = useLocalize(); + const search = useRecoilValue(store.search); + const { favorites, isLoading: isFavoritesLoading } = useFavorites(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const convoHeight = isSmallScreen ? 44 : 34; + const showAgentMarketplace = useShowMarketplace(); + + // 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 +170,148 @@ 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') { + return ( + + + + ); + } + + return null; }, - [cache, flattenedItems, moveToTop, toggleNav], + [ + cache, + flattenedItems, + moveToTop, + toggleNav, + clearFavoritesCache, + isSmallScreen, + isChatsExpanded, + setIsChatsExpanded, + shouldShowFavorites, + ], ); const getRowHeight = useCallback( @@ -180,7 +334,7 @@ const Conversations: FC = ({ ); return ( -
+
{isSearchLoading ? (
@@ -191,7 +345,7 @@ const Conversations: FC = ({ {({ width, height }) => ( } + ref={containerRef} width={width} height={height} deferredMeasurementCache={cache} @@ -199,11 +353,12 @@ const Conversations: FC = ({ rowHeight={getRowHeight} rowRenderer={rowRenderer} overscanRowCount={10} + aria-readonly={false} className="outline-none" - style={{ outline: 'none' }} 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..e85b341d5e 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -132,12 +132,16 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co return (
{ if (renaming) { return; @@ -150,6 +154,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,6 +176,7 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co ) : (
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:

- 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/Prompts/Groups/ChatGroupItem.tsx b/client/src/components/Prompts/Groups/ChatGroupItem.tsx index ae60436de1..be4b8ae3cf 100644 --- a/client/src/components/Prompts/Groups/ChatGroupItem.tsx +++ b/client/src/components/Prompts/Groups/ChatGroupItem.tsx @@ -1,4 +1,5 @@ -import { useState, useMemo, memo } from 'react'; +import { useState, useMemo, memo, useRef } from 'react'; +import { PermissionBits, ResourceType } from 'librechat-data-provider'; import { Menu as MenuIcon, Edit as EditIcon, EarthIcon, TextSearch } from 'lucide-react'; import { DropdownMenu, @@ -7,7 +8,6 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@librechat/client'; -import { PermissionBits } from 'librechat-data-provider'; import type { TPromptGroup } from 'librechat-data-provider'; import { useLocalize, useSubmitMessage, useCustomLink, useResourcePermissions } from '~/hooks'; import VariableDialog from '~/components/Prompts/Groups/VariableDialog'; @@ -34,9 +34,11 @@ function ChatGroupItem({ ); // Check permissions for the promptGroup - const { hasPermission } = useResourcePermissions('promptGroup', group._id || ''); + const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || ''); const canEdit = hasPermission(PermissionBits.EDIT); + const triggerButtonRef = useRef(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 && ( +
+ +
+ )} +
@@ -131,8 +136,17 @@ function ChatGroupItem({
- - +
+ { + 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 0db5864c7c..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 9f041ad7fe..0503a69aa6 100644 --- a/client/src/components/Prompts/Groups/ListCard.tsx +++ b/client/src/components/Prompts/Groups/ListCard.tsx @@ -28,7 +28,7 @@ export default function ListCard({
{snippet}
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 a7e38830b3..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) => ( -
+
+ <> + + + ); }} /> 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 eeeab60d5b..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 }) => { />
- +
- +