From f286001e083f59b32874aab67c6885e882e346ba Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:00:26 +0200 Subject: [PATCH 01/22] feat(client): Rebuild the chat list filter as a properties menu The old menu listed every option at once. It now opens on three rows that state where the list stands, Show, Sort and Filter, each with its current value and a submenu holding the choices. Reading the list's state no longer means reading the whole menu. Filter holds the five facets and a search field that looks inside them, so typing openai finds the endpoint rather than the category that contains it, and typing a category name offers everything under it. Reset sits in the header next to the title, and the active facet count rides on the trigger. The facets are one derived atom, so the query key and the request are built from the same description of the filter. Date cutoffs snap to local midnight: taken from the current instant they would differ on every render and refetch the list each time. The panels also fade their content at the foot when a list runs past the fold, and the chat list's loading label shimmers the way every other in-flight label in the app does. --- .../Conversations/ChatFilterMenu.tsx | 795 ++++++++++++++++-- .../Conversations/Conversations.tsx | 97 ++- .../__tests__/Conversations.test.tsx | 74 ++ .../Conversations/__tests__/facets.spec.ts | 130 +++ client/src/components/Conversations/facets.ts | 115 +++ .../UnifiedSidebar/ConversationsSection.tsx | 109 ++- .../__tests__/ConversationsSection.spec.tsx | 1 + client/src/data-provider/queries.ts | 36 +- client/src/locales/en/translation.json | 12 + 9 files changed, 1210 insertions(+), 159 deletions(-) create mode 100644 client/src/components/Conversations/__tests__/facets.spec.ts create mode 100644 client/src/components/Conversations/facets.ts diff --git a/client/src/components/Conversations/ChatFilterMenu.tsx b/client/src/components/Conversations/ChatFilterMenu.tsx index 7dd0b50bb2f..f65fa6d1dd1 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, @@ -28,13 +37,28 @@ import { sortFieldsFor, toggleChatFilterTagAtom, } from './chatFilters'; -import { useGetConversationTags } from '~/data-provider'; +import { + createdRangeAtom, + DATE_RANGE_OPTIONS, + endpointFilterAtom, + facetFilterCountAtom, + hasAttachmentsAtom, + resetFacetsAtom, + 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 +94,89 @@ const Choice = ({ label, icon, checked, onSelect }: ChoiceProps) => ( ); +type ToggleProps = { + label: string; + icon: ReactNode; + checked: boolean; + onSelect: () => void; +}; + +/** A facet that is simply on or off, so it needs no submenu of its own. */ +const Toggle = ({ label, icon, checked, onSelect }: 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 +194,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(() => { @@ -142,24 +255,495 @@ 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; +}; + +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)} + /> + ))} + + + ); +}; + +/** 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 endpoints = useMemo( + () => + Object.keys(endpointsConfig ?? {}) + .filter((endpoint) => endpointsConfig?.[endpoint] != null) + .map((endpoint) => ({ + value: endpoint, + label: (alternateName[endpoint] as string | undefined) ?? endpoint, + })), + [endpointsConfig], + ); + + 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)} + /> + ))} + + + ); +}); + +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. */ + const { data: bookmarkData } = useGetConversationTags(); + const { data: endpointsConfig } = useGetEndpointsQuery(); + /** 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'), + (bookmarkData ?? []) + .filter((bookmark) => bookmark.count > 0) + .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'), + Object.keys(endpointsConfig ?? {}) + .filter((endpoint) => endpointsConfig?.[endpoint] != null) + .map((endpoint) => ({ + id: `endpoint:${endpoint}`, + label: (alternateName[endpoint] as string | undefined) ?? endpoint, + checked: 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, + 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 +780,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 +811,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', )} >