diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 018ddeea7a8..3cbd4f1868b 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -1096,3 +1096,52 @@ describe('passkey enrollment cap', () => { expect(anonymous.body).not.toHaveProperty('maxPasskeysPerUser'); }); }); + +describe('chat list endpoint filter limit', () => { + it('publishes the base config limits only after authentication', async () => { + const { AppService } = require('@librechat/data-schemas'); + const baseConfig = await AppService({ + config: { conversationList: { maxEndpointFilters: 3, maxEndpointNameLength: 40 } }, + }); + mockGetAppConfig.mockResolvedValue(baseConfig); + + const authenticated = await request(createApp(mockUser)).get('/api/config'); + expect(authenticated.status).toBe(200); + expect(authenticated.body.conversationListLimits).toEqual({ + maxEndpointFilters: 3, + maxEndpointNameLength: 40, + }); + expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + + const anonymous = await request(createApp()).get('/api/config'); + expect(anonymous.status).toBe(200); + expect(anonymous.body).not.toHaveProperty('conversationListLimits'); + }); + + it('publishes the limits the list route enforces, not a principal override', async () => { + const { AppService } = require('@librechat/data-schemas'); + const baseConfig = await AppService({ + config: { conversationList: { maxEndpointFilters: 2 } }, + }); + const mergedConfig = await AppService({ + config: { conversationList: { maxEndpointFilters: 9 } }, + }); + mockGetAppConfig.mockImplementation(async (options) => + options?.baseOnly === true ? baseConfig : mergedConfig, + ); + + const response = await request(createApp(mockUser)).get('/api/config'); + expect(response.body.conversationListLimits.maxEndpointFilters).toBe(2); + }); + + it('publishes the schema defaults for a deployment that sets none', async () => { + const { AppService } = require('@librechat/data-schemas'); + mockGetAppConfig.mockResolvedValue(await AppService({ config: {} })); + + const response = await request(createApp(mockUser)).get('/api/config'); + expect(response.body.conversationListLimits).toEqual({ + maxEndpointFilters: 50, + maxEndpointNameLength: 128, + }); + }); +}); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 7d6eef14fe1..8522fde4716 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -19,6 +19,7 @@ const { isPasskeyEnabled, buildPreLoginInterface, resolveMaxPasskeysPerUser, + loadConversationListLimits, } = require('@librechat/api'); const { DEFAULT_MCP_APP_CSP_LIMITS, @@ -250,10 +251,13 @@ router.get('/', async function (req, res) { return res.status(200).send(payload); } - const appConfig = await getAppConfig({ - ...getAppConfigOptionsFromUser(req.user), - failClosed: true, - }); + const [appConfig, conversationListLimits] = await Promise.all([ + getAppConfig({ + ...getAppConfigOptionsFromUser(req.user), + failClosed: true, + }), + loadConversationListLimits(getAppConfig), + ]); const codeEnvironmentDecisionVersion = resolveCodeEnvironmentDecisionVersion( process.env.CODE_ENVIRONMENT_DECISION_VERSION, ); @@ -295,6 +299,7 @@ router.get('/', async function (req, res) { ...preLoginPayload, ...publicSharePayload, ...buildPostLoginPayload(appConfig), + conversationListLimits, sharedLinksSnapshotFilesEnabled: sharedLinksEnabled && isFileSnapshotEnabled(appConfig), socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins, interface: appConfig?.interfaceConfig, diff --git a/client/src/components/Conversations/ChatFilterMenu.tsx b/client/src/components/Conversations/ChatFilterMenu.tsx index 7dd0b50bb2f..91262abe891 100644 --- a/client/src/components/Conversations/ChatFilterMenu.tsx +++ b/client/src/components/Conversations/ChatFilterMenu.tsx @@ -1,22 +1,31 @@ -import { memo, useCallback, useId, useMemo, useState } from 'react'; +import { memo, useCallback, useId, useMemo, useRef, useState } from 'react'; import * as Ariakit from '@ariakit/react'; import { useAtomValue, useSetAtom } from 'jotai'; -import { PermissionTypes, Permissions } from 'librechat-data-provider'; import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons'; import { TooltipAnchor, buttonVariants, usePopoverZIndex } from '@librechat/client'; +import { alternateName, PermissionTypes, Permissions } from 'librechat-data-provider'; import { Archive, ArrowDownAZ, + ArrowUpDown, CalendarPlus, + CalendarRange, Check, + ChevronRight, Clock, ListFilter, MessagesSquare, + Paperclip, + Search, + Share2, + Plug, RotateCcw, + SlidersHorizontal, } from 'lucide-react'; -import type { ReactNode } from 'react'; +import type { ReactNode, RefObject } from 'react'; import type { ChatFilterStatus, ChatSortDirection, ChatSortField } from './chatFilters'; import type { TranslationKeys } from '~/hooks'; +import type { DateRange } from './facets'; import { chatFilterCountAtom, chatFilterStatusAtom, @@ -24,17 +33,34 @@ import { chatSortAtom, isAlphabeticalSort, resetChatFiltersAtom, + selectableBookmarks, setChatFilterStatusAtom, sortFieldsFor, toggleChatFilterTagAtom, } from './chatFilters'; -import { useGetConversationTags } from '~/data-provider'; +import { + createdRangeAtom, + DATE_RANGE_OPTIONS, + endpointFilterAtom, + facetFilterCountAtom, + hasAttachmentsAtom, + resetFacetsAtom, + selectableEndpoints, + sharedOnlyAtom, + toggleEndpointFilterAtom, + updatedRangeAtom, +} from './facets'; +import { useGetConversationTags, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; +import MinimalIcon from '~/components/Endpoints/MinimalIcon'; import { useHasAccess, useLocalize } from '~/hooks'; import { cn } from '~/utils'; const itemClassName = 'flex w-full cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-2 text-sm text-text-primary outline-hidden data-[active-item]:bg-surface-hover md:py-1.5'; +/** A property row: name on the left, its current value on the right, submenu behind it. */ +const rowClassName = cn(itemClassName, 'aria-expanded:bg-surface-hover'); + const groupLabelClassName = 'px-2 pb-1 pt-1.5 text-xs font-medium text-text-secondary'; /** Keeps every row's label on the same x, checked or not. */ @@ -70,6 +96,93 @@ const Choice = ({ label, icon, checked, onSelect }: ChoiceProps) => ( ); +type ToggleProps = { + label: string; + icon: ReactNode; + checked: boolean; + onSelect: () => void; + /** Still announced, so a screen reader hears the option and why it cannot be taken. */ + disabled?: boolean; +}; + +/** A facet that is simply on or off, so it needs no submenu of its own. */ +const Toggle = ({ label, icon, checked, onSelect, disabled = false }: ToggleProps) => ( + + + {label} + + +); + +type PropertyRowProps = { + icon: ReactNode; + label: string; + value: string; + valueIcon?: ReactNode; + testId?: string; + initialFocus?: RefObject; + children: ReactNode; +}; + +/** + * One facet of the list, collapsed to a single row that reads as a sentence: + * "Sort: Updated". The choices live in the submenu, so the menu opens showing + * what the list is doing rather than every option it could take. + */ +const PropertyRow = ({ + icon, + label, + value, + valueIcon, + testId, + initialFocus, + children, +}: PropertyRowProps) => { + const zIndex = usePopoverZIndex(); + + return ( + + } + data-testid={testId} + className={rowClassName} + > + + {label} + + {valueIcon} + {value} + + + + {children} + + + ); +}; + const STATUS_OPTIONS: Array<{ value: ChatFilterStatus; label: TranslationKeys; icon: ReactNode }> = [ { @@ -87,6 +200,12 @@ const SORT_OPTIONS: Record }, }; +const DirectionGlyph = ({ direction }: { direction: ChatSortDirection }) => ( + +); + /** Bookmarks are their own query and their own permission, so they mount with the * open menu rather than with the sidebar. */ const BookmarkChoices = memo(() => { @@ -95,8 +214,7 @@ const BookmarkChoices = memo(() => { const toggleTag = useSetAtom(toggleChatFilterTagAtom); const { data } = useGetConversationTags(); - /** A bookmark no chat carries filters the list down to nothing. */ - const bookmarks = useMemo(() => data?.filter((tag) => tag.count > 0) ?? [], [data]); + const bookmarks = useMemo(() => selectableBookmarks(data, tags), [data, tags]); if (bookmarks.length === 0) { return ( @@ -142,24 +260,533 @@ const BookmarkChoices = memo(() => { BookmarkChoices.displayName = 'BookmarkChoices'; +/** One selectable value inside a facet, as the cross-category search sees it. */ +type FilterOption = { + id: string; + label: string; + checked: boolean; + /** Multi-select facets render as checkboxes; a date window is one of a set. */ + multiple: boolean; + icon: ReactNode; + onSelect: () => void; + disabled?: boolean; +}; + +type DateFacetProps = { + label: string; + icon: ReactNode; + value: DateRange; + onSelect: (value: DateRange) => void; +}; + +/** Updated and Created take the same windows, so they are one component twice. */ +const DateFacet = ({ label, icon, value, onSelect }: DateFacetProps) => { + const localize = useLocalize(); + const selected = DATE_RANGE_OPTIONS.find((option) => option.value === value); + + return ( + + + {label} + {DATE_RANGE_OPTIONS.map((option) => ( + } + checked={value === option.value} + onSelect={() => onSelect(option.value)} + /> + ))} + + + ); +}; + +/** + * What the list route accepts in one request (`conversationList`). Past `maxEndpointFilters` + * the request would be refused and the Chats section would show its error, so further + * endpoints are offered disabled, as they are while the post-login config that carries the + * limits is loading. A config that failed leaves the server's own validation as the only check. + */ +const useEndpointFilterLimit = (selectedCount: number) => { + const { data: startupConfig, isLoading } = useGetStartupConfig(); + const limits = startupConfig?.conversationListLimits; + const limit = limits?.maxEndpointFilters; + return { + limit, + maxNameLength: limits?.maxEndpointNameLength, + atLimit: isLoading || (limit != null && selectedCount >= limit), + }; +}; + +const EndpointLimitNote = ({ limit }: { limit: number }) => { + const localize = useLocalize(); + return ( + + {localize('com_ui_endpoint_filter_limit', { count: limit })} + + ); +}; + +/** The endpoints this deployment actually serves, named the way the rest of the app + * names them. A chat matches if it used any of the chosen ones. */ +const EndpointFacet = memo(() => { + const localize = useLocalize(); + const selected = useAtomValue(endpointFilterAtom); + const toggleEndpoint = useSetAtom(toggleEndpointFilterAtom); + const { data: endpointsConfig } = useGetEndpointsQuery(); + const { limit, maxNameLength, atLimit } = useEndpointFilterLimit(selected.length); + + const endpoints = useMemo( + () => + selectableEndpoints(endpointsConfig, selected, maxNameLength).map((endpoint) => ({ + value: endpoint, + label: (alternateName[endpoint] as string | undefined) ?? endpoint, + })), + [endpointsConfig, selected, maxNameLength], + ); + + const value = useMemo(() => { + if (selected.length === 0) { + return localize('com_ui_any'); + } + if (selected.length === 1) { + return (alternateName[selected[0]] as string | undefined) ?? selected[0]; + } + return localize('com_ui_selected_count', { count: selected.length }); + }, [localize, selected]); + + return ( + } + label={localize('com_ui_endpoint')} + value={value} + > + + + {localize('com_ui_endpoint')} + + {endpoints.map((endpoint) => ( + + } + checked={selected.includes(endpoint.value)} + onSelect={() => toggleEndpoint(endpoint.value)} + disabled={atLimit && !selected.includes(endpoint.value)} + /> + ))} + {atLimit && limit != null && } + + + ); +}); + +EndpointFacet.displayName = 'EndpointFacet'; + +/** + * A facet list long enough to scan is long enough to search. The field owns focus + * when the submenu opens, so typing narrows immediately, and ArrowDown hands the + * keyboard back to the rows rather than stranding it in the input. + */ +const FacetSearch = ({ + value, + onChange, + inputRef, +}: { + value: string; + onChange: (next: string) => void; + inputRef?: RefObject; +}) => { + const localize = useLocalize(); + const menu = Ariakit.useMenuContext(); + const inputId = useId(); + + return ( +
+ +
+
+
+ ); +}; + +/** + * Everything that narrows the list, one level in: bookmarks, the two date windows, + * the endpoint a chat ran on, and the two flags. Keeping them here rather than at the + * root is what lets the menu grow a facet without growing a screenful. + */ +const FilterFacets = ({ + showBookmarks, + searchRef, +}: { + showBookmarks: boolean; + searchRef?: RefObject; +}) => { + const localize = useLocalize(); + const [search, setSearch] = useState(''); + const updatedRange = useAtomValue(updatedRangeAtom); + const createdRange = useAtomValue(createdRangeAtom); + const hasAttachments = useAtomValue(hasAttachmentsAtom); + const sharedOnly = useAtomValue(sharedOnlyAtom); + const tags = useAtomValue(chatFilterTagsAtom); + const facetCount = useAtomValue(facetFilterCountAtom); + const selectedEndpoints = useAtomValue(endpointFilterAtom); + const setUpdatedRange = useSetAtom(updatedRangeAtom); + const setCreatedRange = useSetAtom(createdRangeAtom); + const setHasAttachments = useSetAtom(hasAttachmentsAtom); + const setSharedOnly = useSetAtom(sharedOnlyAtom); + const setTags = useSetAtom(chatFilterTagsAtom); + const toggleTag = useSetAtom(toggleChatFilterTagAtom); + const toggleEndpoint = useSetAtom(toggleEndpointFilterAtom); + const resetFacets = useSetAtom(resetFacetsAtom); + + /** Searching reaches into the categories, so both lists have to be here rather than + * inside the submenu that normally owns them. Both are cached queries. The bookmark + * one stays off without `BOOKMARKS:USE`, whose route would only answer 403. */ + const { data: bookmarkData } = useGetConversationTags({ enabled: showBookmarks }); + const { data: endpointsConfig } = useGetEndpointsQuery(); + const { atLimit: endpointsAtLimit, maxNameLength: endpointNameLimit } = useEndpointFilterLimit( + selectedEndpoints.length, + ); + /** A deployment with sharing switched off has no shared chats to filter to. */ + const { data: startupConfig } = useGetStartupConfig(); + const showShared = startupConfig?.sharedLinksEnabled === true; + + const query = search.trim().toLowerCase(); + + /** + * Every value a person can filter by, flattened. A query matches an option by its own + * name or by the name of the category it belongs to, so "endpoint" offers every + * endpoint and "anthropic" offers the one. + */ + const results = useMemo(() => { + if (query === '') { + return []; + } + + const groups: Array<{ category: string; options: FilterOption[] }> = []; + const collect = (category: string, options: FilterOption[]) => { + const categoryMatches = category.toLowerCase().includes(query); + const matched = categoryMatches + ? options + : options.filter((option) => option.label.toLowerCase().includes(query)); + if (matched.length > 0) { + groups.push({ category, options: matched }); + } + }; + + if (showBookmarks) { + collect( + localize('com_ui_bookmarks'), + selectableBookmarks(bookmarkData, tags).map((bookmark) => ({ + id: `bookmark:${bookmark.tag}`, + label: bookmark.tag, + checked: tags.includes(bookmark.tag), + multiple: true, + icon: tags.includes(bookmark.tag) ? : , + onSelect: () => toggleTag(bookmark.tag), + })), + ); + } + + const dateOptions = (value: DateRange, onSelect: (next: DateRange) => void) => + DATE_RANGE_OPTIONS.map((option) => ({ + id: `${option.value}`, + label: localize(option.label), + checked: value === option.value, + multiple: false, + icon: , + onSelect: () => onSelect(option.value), + })); + + collect( + localize('com_ui_sort_updated'), + dateOptions(updatedRange, setUpdatedRange).map((option) => ({ + ...option, + id: `updated:${option.id}`, + })), + ); + collect( + localize('com_ui_sort_created'), + dateOptions(createdRange, setCreatedRange).map((option) => ({ + ...option, + id: `created:${option.id}`, + })), + ); + + collect( + localize('com_ui_endpoint'), + selectableEndpoints(endpointsConfig, selectedEndpoints, endpointNameLimit).map( + (endpoint) => ({ + id: `endpoint:${endpoint}`, + label: (alternateName[endpoint] as string | undefined) ?? endpoint, + checked: selectedEndpoints.includes(endpoint), + disabled: endpointsAtLimit && !selectedEndpoints.includes(endpoint), + multiple: true, + icon: ( + + ), + onSelect: () => toggleEndpoint(endpoint), + }), + ), + ); + + /** A flag is its own category and its own single option. */ + collect(localize('com_ui_attachments'), [ + { + id: 'has-attachments', + label: localize('com_ui_has_attachments'), + checked: hasAttachments, + multiple: true, + icon: , + onSelect: () => setHasAttachments(!hasAttachments), + }, + ]); + + if (showShared) { + collect(localize('com_ui_sharing'), [ + { + id: 'shared-only', + label: localize('com_ui_shared_only'), + checked: sharedOnly, + multiple: true, + icon: , + onSelect: () => setSharedOnly(!sharedOnly), + }, + ]); + } + + return groups; + }, [ + query, + showBookmarks, + localize, + bookmarkData, + tags, + toggleTag, + updatedRange, + setUpdatedRange, + createdRange, + setCreatedRange, + endpointsConfig, + selectedEndpoints, + endpointsAtLimit, + endpointNameLimit, + toggleEndpoint, + hasAttachments, + setHasAttachments, + showShared, + sharedOnly, + setSharedOnly, + ]); + + const facets = [ + showBookmarks ? : null, + } + value={updatedRange} + onSelect={setUpdatedRange} + />, + } + value={createdRange} + onSelect={setCreatedRange} + />, + , + ].filter(Boolean); + + const flags = [ + } + checked={hasAttachments} + onSelect={() => setHasAttachments(!hasAttachments)} + />, + showShared ? ( + } + checked={sharedOnly} + onSelect={() => setSharedOnly(!sharedOnly)} + /> + ) : null, + ].filter(Boolean); + + /** The reset belongs to what this submenu owns: the facets and the bookmarks, + * not the sort and the view sitting a level up. */ + const narrowingCount = facetCount + (tags.length > 0 ? 1 : 0); + const clearFilters = () => { + resetFacets(); + setTags([]); + }; + + return ( + <> + + + {query === '' ? ( + <> + {facets} + + {flags} + + ) : ( + <> + {results.map((group) => ( + + + {group.category} + + {group.options.map((option) => + option.multiple ? ( + + ) : ( + + ), + )} + + ))} + {results.length === 0 && ( + + {localize('com_ui_no_results_found')} + + )} + + )} + + + + + + ); +}; + +/** Bookmarks keep their own row so the count of chosen tags stays visible one level up. */ +const BookmarkFacet = () => { + const localize = useLocalize(); + const tags = useAtomValue(chatFilterTagsAtom); + + /** One bookmark names itself; several are worth a count, none reads as "None". */ + const value = useMemo(() => { + if (tags.length === 0) { + return localize('com_ui_none'); + } + if (tags.length === 1) { + return tags[0]; + } + return localize('com_ui_selected_count', { count: tags.length }); + }, [localize, tags]); + + return ( + } + label={localize('com_ui_bookmarks')} + value={value} + testId="chat-filter-bookmarks" + > + + + {localize('com_ui_bookmarks')} + + + + + ); +}; + /** * Every way the chats list can be narrowed or reordered, in one menu beside the - * Chats heading: which chats (active or archived), what orders them, and which - * bookmarks they must carry. It replaces the bookmark-only control that used to sit - * next to the search field, so filtering lives where the list it filters is labelled. + * Chats heading: which chats (active or archived), what orders them, and what they + * must match. Each facet is a row carrying its current value, so the menu is read at + * a glance and only the facet being changed expands. */ const ChatFilterMenu = () => { const localize = useLocalize(); const menuId = useId(); const zIndex = usePopoverZIndex(); + const facetSearchRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const status = useAtomValue(chatFilterStatusAtom); const sort = useAtomValue(chatSortAtom); + const tags = useAtomValue(chatFilterTagsAtom); const activeCount = useAtomValue(chatFilterCountAtom); + const facetCount = useAtomValue(facetFilterCountAtom); const setStatus = useSetAtom(setChatFilterStatusAtom); const setSort = useSetAtom(chatSortAtom); const resetFilters = useSetAtom(resetChatFiltersAtom); + const resetAllFacets = useSetAtom(resetFacetsAtom); const hasAccessToBookmarks = useHasAccess({ permissionType: PermissionTypes.BOOKMARKS, @@ -196,9 +823,27 @@ const ChatFilterMenu = () => { [setSort, sort.direction, sort.field], ); + const statusOption = STATUS_OPTIONS.find((option) => option.value === status); + + /** Bookmarks sit inside Filter now, so the row counts them alongside the rest. */ + const narrowingCount = facetCount + (tags.length > 0 ? 1 : 0); + const filterValue = + narrowingCount === 0 + ? localize('com_ui_none') + : localize('com_ui_active_count', { count: narrowingCount }); + + const totalCount = activeCount + facetCount; + + /** The header's Reset undoes the whole menu, sort and view included; the one inside + * Filter clears only what that submenu owns. */ + const resetAll = useCallback(() => { + resetFilters(); + resetAllFacets(); + }, [resetAllFacets, resetFilters]); + const triggerLabel = - activeCount > 0 - ? localize('com_ui_filters_active', { count: activeCount }) + totalCount > 0 + ? localize('com_ui_filters_active', { count: totalCount }) : localize('com_ui_filter_and_sort_chats'); return ( @@ -209,17 +854,17 @@ const ChatFilterMenu = () => { 0} + aria-pressed={totalCount > 0} data-testid="chat-filter-menu" /** Matches the Projects heading's actions — it sits beside a section heading too. */ className={cn( buttonVariants({ variant: 'section-action', size: 'icon-xs' }), 'relative shrink-0', - (isOpen || activeCount > 0) && 'bg-surface-active-alt text-text-primary', + (isOpen || totalCount > 0) && 'bg-surface-active-alt text-text-primary', )} >