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) => (
+
+
+ {icon}
+
+ {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}
+ >
+
+ {icon}
+
+ {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 }) => (
+
+ {direction === 'asc' ? '↑' : '↓'}
+
+);
+
/** 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 (
+
);
});
@@ -202,7 +211,19 @@ const Conversations: FC = ({
const isArchivedView = useAtomValue(isArchivedChatViewAtom);
const activeFilterCount = useAtomValue(chatFilterCountAtom);
const filterTags = useAtomValue(chatFilterTagsAtom);
+ /** Date, endpoint and attachment facets narrow the same list as the bookmark tags,
+ * so an empty result under either has to read as "nothing matched", not as an
+ * account with no chats in it. */
+ const facetFilterCount = useAtomValue(facetFilterCountAtom);
const resetFilters = useSetAtom(resetChatFiltersAtom);
+ const resetFacets = useSetAtom(resetFacetsAtom);
+ /** What the menu's Reset clears, counted the same way: the empty state offers the
+ * way out of every narrowing, not only of the ones the tag filters know about. */
+ const narrowedCount = activeFilterCount + facetFilterCount;
+ const clearNarrowing = useCallback(() => {
+ resetFilters();
+ resetFacets();
+ }, [resetFilters, resetFacets]);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
/* Dropping a chat on the Chats section makes it an ordinary chat: out of its
* project, and unpinned. A root-list chat that is not pinned already is one,
@@ -494,7 +515,11 @@ const Conversations: FC = ({
* it is empty and offer the way back. A drained page can still contain only pinned rows,
* which render in PinnedSection and do not make the account empty. */
const hasUnfilteredRows =
- !search.query && filterTags.length === 0 && !isArchivedView && filteredConversations.length > 0;
+ !search.query &&
+ filterTags.length === 0 &&
+ facetFilterCount === 0 &&
+ !isArchivedView &&
+ filteredConversations.length > 0;
const isEmpty =
isChatsExpanded &&
!isLoading &&
@@ -504,14 +529,24 @@ const Conversations: FC = ({
groupedConversations.length === 0 &&
!hasUnfilteredRows;
+ /** Which dead end this is decides both the line and the glyph above it: a search
+ * that found nothing, a filter that matched nothing, an empty archive, and an
+ * account with no chats yet are four different situations wearing one sentence. */
let emptyLabel: TranslationKeys = 'com_ui_no_chats';
+ let emptyIcon: LucideIcon = MessageSquareDashed;
if (search.query) {
emptyLabel = 'com_ui_no_search_results';
- } else if (filterTags.length > 0) {
+ emptyIcon = SearchX;
+ } else if (filterTags.length > 0 || facetFilterCount > 0) {
emptyLabel = 'com_ui_no_chats_match_filters';
+ emptyIcon = MessageSquareOff;
} else if (isArchivedView) {
emptyLabel = 'com_ui_no_archived_chats';
+ emptyIcon = Archive;
}
+ /** Nothing has been narrowed: the list is empty because the account is. That reads
+ * as a heading, where the three narrowed states are a single line under the glyph. */
+ const isUntouched = emptyLabel === 'com_ui_no_chats';
let body: ReactNode = (
@@ -540,44 +575,44 @@ const Conversations: FC = ({
body = (
+ {/* The last row fades rather than being cut off, so a list that continues
+ below the fold says so without a scrollbar having to appear. */}
+
);
diff --git a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
index a6738fec809..a5c6d958abf 100644
--- a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
+++ b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
@@ -71,6 +71,7 @@ jest.mock('~/hooks', () => ({
useAuthContext: () => ({ isAuthenticated: true }),
useLocalStorage: () => [true, mockSetChatsExpanded],
useNavScrolling: () => ({ moveToTop: mockMoveToTop }),
+ useScrollFade: () => ({ attach: jest.fn(), hasMore: false }),
useFavorites: () => mockUseFavorites(),
useShowMarketplace: () => false,
useNewConvo: () => ({ newConversation: jest.fn() }),
diff --git a/client/src/data-provider/queries.ts b/client/src/data-provider/queries.ts
index 2a27d50f8a1..2a3fd1c9c27 100644
--- a/client/src/data-provider/queries.ts
+++ b/client/src/data-provider/queries.ts
@@ -86,12 +86,39 @@ export const useConversationsInfiniteQuery = (
params: ConversationListParams,
config?: UseInfiniteQueryOptions,
) => {
- const { isArchived, sortBy, sortDirection, tags, search, projectId } = params;
+ const {
+ isArchived,
+ sortBy,
+ sortDirection,
+ tags,
+ search,
+ projectId,
+ updatedAfter,
+ createdAfter,
+ endpoints,
+ hasFiles,
+ sharedOnly,
+ } = params;
return useInfiniteQuery({
+ /* Every filter belongs in the key: a facet left out would serve one filter's pages
+ to another and, because the cursor is part of that cache entry, keep paging the
+ wrong list. */
queryKey: [
isArchived ? QueryKeys.archivedConversations : QueryKeys.allConversations,
- { isArchived, sortBy, sortDirection, tags, search, projectId },
+ {
+ isArchived,
+ sortBy,
+ sortDirection,
+ tags,
+ search,
+ projectId,
+ updatedAfter,
+ createdAfter,
+ endpoints,
+ hasFiles,
+ sharedOnly,
+ },
],
queryFn: async ({ pageParam }) => {
const page = await dataService.listConversations({
@@ -101,6 +128,11 @@ export const useConversationsInfiniteQuery = (
tags,
search,
projectId,
+ updatedAfter,
+ createdAfter,
+ endpoints,
+ hasFiles,
+ sharedOnly,
cursor: pageParam?.toString(),
});
/* A row's own `isArchived` decides what its menu offers, so a backend that predates
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 516a1970e7e..c9581f519b9 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1,4 +1,5 @@
{
+ "com_ui_attachments": "Attachments",
"com_ui_no_schedules_match": "No schedules match your search",
"com_ui_repository_instructions": "Repository instructions",
"com_ui_repository_instructions_prefer": "Prefer repository conventions",
@@ -921,6 +922,7 @@
"com_ui_accept": "I accept",
"com_ui_active": "Active",
"com_ui_active_chats": "Active chats",
+ "com_ui_active_count": "{{count}} active",
"com_ui_add": "Add",
"com_ui_add_description": "Add a description",
"com_ui_add_first_bookmark": "Click on a chat to add one",
@@ -1031,6 +1033,8 @@
"com_ui_always_interrupt": "Always interrupt instead",
"com_ui_analyzing": "Analyzing",
"com_ui_analyzing_finished": "Finished analyzing",
+ "com_ui_any": "Any",
+ "com_ui_any_time": "Any time",
"com_ui_api_key": "API Key",
"com_ui_api_key_create_error": "Failed to create API key",
"com_ui_api_key_created": "API key created successfully",
@@ -1192,6 +1196,7 @@
"com_ui_change_version": "Change Version",
"com_ui_chat": "Chat",
"com_ui_chat_history": "Chat History",
+ "com_ui_chat_list": "Chat list",
"com_ui_chats_load_error": "Couldn't load your chats",
"com_ui_chats": "Chats",
"com_ui_check_internet": "Check your internet connection",
@@ -1347,6 +1352,7 @@
"com_ui_date_october": "October",
"com_ui_date_previous_30_days": "Previous 30 days",
"com_ui_date_previous_7_days": "Previous 7 days",
+ "com_ui_date_previous_year": "Previous year",
"com_ui_date_september": "September",
"com_ui_date_sort": "Sort by Date",
"com_ui_date_today": "Today",
@@ -1519,6 +1525,7 @@
"com_ui_file_size": "File Size",
"com_ui_file_token_limit": "File Token Limit",
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
+ "com_ui_filter": "Filter",
"com_ui_filter_and_sort_chats": "Filter and sort chats",
"com_ui_filter_by": "Filter by {{title}}",
"com_ui_filter_mcp_servers": "Filter MCP servers by name",
@@ -1614,6 +1621,7 @@
"com_ui_handoff_field_instructions": "Instructions",
"com_ui_handoff_instructions": "Handoff instructions",
"com_ui_happy_birthday": "It's my 1st birthday!",
+ "com_ui_has_attachments": "Has attachments",
"com_ui_header_format": "Header Format",
"com_ui_hide": "Hide",
"com_ui_hide_code": "Hide Code",
@@ -1988,6 +1996,7 @@
"com_ui_permissions_failed_update": "Failed to update permissions. Please try again.",
"com_ui_permissions_updated_success": "Permissions updated successfully",
"com_ui_schedules_filter": "Filter schedules...",
+ "com_ui_sharing": "Sharing",
"com_ui_skills_toggle_files": "Toggle files for {{0}}",
"com_ui_view_agent_insights": "View insights",
"com_ui_view_agent_insights_description": "Allows this principal to view usage and conversation content for this agent.",
@@ -2256,6 +2265,7 @@
"com_ui_search_above_to_add_people": "Search above to add people",
"com_ui_search_agent_category": "Search categories...",
"com_ui_search_default_placeholder": "Search by name or email (min 2 chars)",
+ "com_ui_search_filters": "Search filters",
"com_ui_search_language": "Search languages...",
"com_ui_search_people_placeholder": "Search for people or groups by name or email",
"com_ui_search_projects": "Search projects",
@@ -2375,6 +2385,7 @@
"com_ui_shared_link": "shared link",
"com_ui_shared_link_bulk_delete_success": "Successfully deleted shared links",
"com_ui_shared_link_delete_success": "Successfully deleted shared link",
+ "com_ui_shared_only": "Shared only",
"com_ui_shared_link_manage_access": "Manage Access",
"com_ui_shared_link_not_found": "Shared link not found",
"com_ui_shared_link_updated": "This shared link was updated. Reloading the latest version, then try again",
@@ -2496,6 +2507,7 @@
"com_ui_skills_remove": "Remove {{name}}",
"com_ui_skip": "Skip",
"com_ui_something_else": "Something else...",
+ "com_ui_sort": "Sort",
"com_ui_sort_a_z": "A to Z",
"com_ui_sort_chats_by": "Sort chats by",
"com_ui_sort_created": "Created",
From 81ae98b9d0a1c6099493ad1acc8ecdc915a89542 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 02/22] feat(client): Show a project's chats under that project
only
A chat assigned to a project was listed twice in one sidebar: once under
the project and once in Chats. The chats list now asks for the chats that
belong to no project, which the list query and its cache helpers already
knew how to answer.
Search and the archived view keep asking for everything. Projects is not
rendered while a search is on, and an archived chat has no project row to
appear under, so excluding them from either would leave a chat with no way
back to it.
---
.../UnifiedSidebar/ConversationsSection.tsx | 5 ++
.../__tests__/ConversationsSection.spec.tsx | 63 ++++++++++++++++++-
2 files changed, 67 insertions(+), 1 deletion(-)
diff --git a/client/src/components/UnifiedSidebar/ConversationsSection.tsx b/client/src/components/UnifiedSidebar/ConversationsSection.tsx
index 0c043022cf9..539bdfd8218 100644
--- a/client/src/components/UnifiedSidebar/ConversationsSection.tsx
+++ b/client/src/components/UnifiedSidebar/ConversationsSection.tsx
@@ -68,6 +68,11 @@ const ConversationsSection = memo(() => {
sortDirection: sort.direction,
tags: tags.length === 0 ? undefined : tags,
search: search.debouncedQuery || undefined,
+ /** A chat that belongs to a project is shown under that project, not twice.
+ * Search and the archived view stay whole: both are places the user goes to
+ * find something, and a project chat that appears in neither list nor result
+ * would have no way back. */
+ projectId: isArchivedView || search.debouncedQuery ? undefined : 'unassigned',
...facetParams,
},
{
diff --git a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
index a5c6d958abf..701eae6f3e5 100644
--- a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
+++ b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
@@ -26,6 +26,8 @@ const mockUseFavorites = jest.fn(() => ({
}));
const mockUseGetConversationTags = jest.fn(() => ({ data: [] as unknown[] }));
const mockConversationsRender = jest.fn();
+/** What the chats list asks the server for, captured per render. */
+const mockListParams = jest.fn();
const mockSetChatsExpanded = jest.fn();
const mockMoveToTop = jest.fn();
const mockUseTitleGeneration = jest.fn(() => {
@@ -80,7 +82,10 @@ jest.mock('~/hooks', () => ({
jest.mock('~/data-provider', () => ({
__esModule: true,
- useConversationsInfiniteQuery: () => mockConversationsResult,
+ useConversationsInfiniteQuery: (params: Record) => {
+ mockListParams(params);
+ return mockConversationsResult;
+ },
usePinnedConversationsQuery: () => mockPinnedResult,
useTitleGeneration: () => mockUseTitleGeneration(),
useGetEndpointsQuery: () => ({ data: {}, isLoading: false }),
@@ -244,6 +249,62 @@ describe('ConversationsSection streaming re-renders', () => {
);
});
+describe('ConversationsSection project chats', () => {
+ beforeEach(() => {
+ mockListParams.mockClear();
+ });
+
+ /** A chat that belongs to a project is shown under that project. Listing it in
+ * Chats as well puts the same conversation in two places in one sidebar. */
+ it('asks only for chats that belong to no project', async () => {
+ renderSection();
+ await settleRenders();
+
+ expect(mockListParams).toHaveBeenCalled();
+ expect(mockListParams.mock.calls.at(-1)?.[0]).toMatchObject({ projectId: 'unassigned' });
+ });
+
+ /** Searching is how a chat is found, and Projects is not rendered while a search
+ * is on: excluding project chats there would make them unreachable. */
+ it('searches across every chat, project or not', async () => {
+ let setSearch: SetterOrUpdater;
+
+ function SearchController() {
+ setSearch = useSetRecoilState(store.search);
+ return null;
+ }
+
+ render(
+
+
+
+
+
+
+
+
+
+ ,
+ );
+ await settleRenders();
+
+ act(() => {
+ setSearch({
+ query: 'draft',
+ debouncedQuery: 'draft',
+ enabled: true,
+ isTyping: false,
+ isSearching: true,
+ });
+ });
+
+ expect(mockListParams.mock.calls.at(-1)?.[0]).toMatchObject({
+ search: 'draft',
+ projectId: undefined,
+ });
+ });
+});
+
describe('ConversationsSection shared scroll surface', () => {
/** Searching swaps what the one surface holds — Projects and Pinned leave,
* the chats become results — and a position kept from the previous contents
From 339adb9d64ef9e65105422d8b2e6bc85e7664757 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 24 Sep 2026 09:00:48 +0200
Subject: [PATCH 03/22] fix(client): Keep facet-filtered caches honest and the
facets session-scoped
Cache writers judged list membership only by project, archive, tags and
sort, so an SSE update or a fork could insert a chat the server's filter
had excluded. The endpoint and date facets are decided from the row
itself, and the attachment and sharing facets mark their variants for a
refetch, since only the server can place a row in them.
The date cutoffs now derive from a day anchor a mounted consumer
advances at local midnight, so a tab left open stops serving
yesterday's today. Facets reset at the authentication boundary with the
rest of the session state, the bookmark query stops firing for roles
without BOOKMARKS:USE, an empty Chats section under existing projects
says so instead of claiming the account is empty, and the design-rule
violations this branch had suppressed in Conversations and
ChatFilterMenu are fixed and their entries pruned.
---
.../Conversations/ChatFilterMenu.tsx | 9 +--
.../Conversations/Conversations.tsx | 34 +++++----
.../Conversations/__tests__/facets.spec.ts | 14 ++++
client/src/components/Conversations/facets.ts | 45 +++++++++++-
.../UnifiedSidebar/ConversationsSection.tsx | 24 +++++--
client/src/hooks/AuthContext.tsx | 2 +
client/src/locales/en/translation.json | 1 +
client/src/utils/convos.spec.ts | 70 +++++++++++++++++++
client/src/utils/convos.ts | 67 +++++++++++++++---
eslint-suppressions.json | 13 ----
10 files changed, 232 insertions(+), 47 deletions(-)
diff --git a/client/src/components/Conversations/ChatFilterMenu.tsx b/client/src/components/Conversations/ChatFilterMenu.tsx
index f65fa6d1dd1..82493235f35 100644
--- a/client/src/components/Conversations/ChatFilterMenu.tsx
+++ b/client/src/components/Conversations/ChatFilterMenu.tsx
@@ -438,8 +438,9 @@ const FilterFacets = ({
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();
+ * 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();
/** A deployment with sharing switched off has no shared chats to filter to. */
const { data: startupConfig } = useGetStartupConfig();
@@ -836,9 +837,9 @@ const ChatFilterMenu = () => {
gutter={8}
unmountOnHide={true}
aria-label={localize('com_ui_filter_and_sort_chats')}
- className="popover-ui max-w-80 min-w-60"
+ className="popover-ui pointer-events-auto max-w-80 min-w-60"
/** Portaled beside modal dialog layers, which disable pointer events on body. */
- style={{ zIndex, pointerEvents: 'auto' }}
+ style={{ zIndex }}
>
diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx
index 0d581af04bd..0d09ec07664 100644
--- a/client/src/components/Conversations/Conversations.tsx
+++ b/client/src/components/Conversations/Conversations.tsx
@@ -73,6 +73,10 @@ interface ConversationsProps {
/** Wrapper around everything inside that viewport, whose height changes when a
* section above the list expands or collapses. */
scrollContent: HTMLElement | null;
+ /** The account holds projects, rendered above this list. The Chats section asks for
+ * chats that belong to no project, so its emptiness then means "everything is filed"
+ * rather than "the account has nothing". */
+ accountHasProjects?: boolean;
}
interface MeasuredRowProps {
@@ -111,9 +115,9 @@ const LoadingSpinner = memo(() => {
const localize = useLocalize();
return (
-
-
- {localize('com_ui_loading')}
+
+
+ {localize('com_ui_loading')}
);
});
@@ -172,8 +176,10 @@ const DateLabel: FC<{ groupName: string; isFirst?: boolean; isAlphabetical?: boo
isAlphabetical ? 'com_a11y_chats_alpha_section' : 'com_a11y_chats_date_section',
isAlphabetical ? { letter: displayName } : { date: displayName },
)}
- className={cn('text-text-secondary pt-0.5 pl-1', isFirst === true ? 'mt-0' : 'mt-1.5')}
- style={{ fontSize: '0.7rem' }}
+ className={cn(
+ 'text-text-secondary pt-0.5 pl-1 text-xs',
+ isFirst === true ? 'mt-0' : 'mt-1.5',
+ )}
>
{displayName}
@@ -204,6 +210,7 @@ const Conversations: FC = ({
onRetry,
scrollViewport,
scrollContent,
+ accountHasProjects = false,
}) => {
const localize = useLocalize();
const search = useRecoilValue(store.search);
@@ -530,8 +537,9 @@ const Conversations: FC = ({
!hasUnfilteredRows;
/** Which dead end this is decides both the line and the glyph above it: a search
- * that found nothing, a filter that matched nothing, an empty archive, and an
- * account with no chats yet are four different situations wearing one sentence. */
+ * that found nothing, a filter that matched nothing, an empty archive, an account
+ * whose chats all live under projects, and an account with no chats yet are five
+ * different situations wearing one sentence. */
let emptyLabel: TranslationKeys = 'com_ui_no_chats';
let emptyIcon: LucideIcon = MessageSquareDashed;
if (search.query) {
@@ -543,9 +551,12 @@ const Conversations: FC = ({
} else if (isArchivedView) {
emptyLabel = 'com_ui_no_archived_chats';
emptyIcon = Archive;
+ } else if (accountHasProjects) {
+ emptyLabel = 'com_ui_no_unassigned_chats';
+ emptyIcon = MessageSquareDashed;
}
/** Nothing has been narrowed: the list is empty because the account is. That reads
- * as a heading, where the three narrowed states are a single line under the glyph. */
+ * as a heading, where the narrowed states are a single line under the glyph. */
const isUntouched = emptyLabel === 'com_ui_no_chats';
let body: ReactNode = (
@@ -566,16 +577,15 @@ const Conversations: FC = ({
aria-label="Conversations"
onRowsRendered={handleRowsRendered}
tabIndex={-1}
- style={{ outline: 'none' }}
containerRole="rowgroup"
/>
);
if (isSearchLoading) {
body = (
-
-
- {localize('com_ui_loading')}
+
+
+ {localize('com_ui_loading')}
);
} else if (isListError) {
diff --git a/client/src/components/Conversations/__tests__/facets.spec.ts b/client/src/components/Conversations/__tests__/facets.spec.ts
index 28f1ced288c..ce597d8d6b9 100644
--- a/client/src/components/Conversations/__tests__/facets.spec.ts
+++ b/client/src/components/Conversations/__tests__/facets.spec.ts
@@ -5,6 +5,7 @@ import {
endpointFilterAtom,
facetFilterCountAtom,
hasAttachmentsAtom,
+ localDayStartAtom,
rangeCutoff,
sharedOnlyAtom,
resetFacetsAtom,
@@ -90,6 +91,19 @@ describe('chatFacetParamsAtom', () => {
);
});
+ it('advances the cutoff when the local day anchor moves past midnight', () => {
+ /** A derived atom caches until a source changes, so the cutoff follows the day
+ * anchor rather than the clock: a tab open across midnight must not keep serving
+ * yesterday's "today". */
+ const store = createStore();
+ store.set(localDayStartAtom, new Date(2026, 8, 20).getTime());
+ store.set(updatedRangeAtom, 'today');
+ expect(store.get(chatFacetParamsAtom).updatedAfter).toBe(new Date(2026, 8, 20).toISOString());
+
+ store.set(localDayStartAtom, new Date(2026, 8, 21).getTime());
+ expect(store.get(chatFacetParamsAtom).updatedAfter).toBe(new Date(2026, 8, 21).toISOString());
+ });
+
it('toggles an endpoint off again rather than repeating it', () => {
const store = createStore();
store.set(toggleEndpointFilterAtom, 'openAI');
diff --git a/client/src/components/Conversations/facets.ts b/client/src/components/Conversations/facets.ts
index c5c7ff4b5b2..4bfaf649008 100644
--- a/client/src/components/Conversations/facets.ts
+++ b/client/src/components/Conversations/facets.ts
@@ -1,4 +1,5 @@
-import { atom } from 'jotai';
+import { useEffect } from 'react';
+import { atom, useSetAtom } from 'jotai';
import type { ConversationListParams } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
@@ -38,6 +39,41 @@ export function rangeCutoff(range: DateRange, now: Date = new Date()): Date | un
return startOfToday;
}
+const startOfLocalDay = (now: Date = new Date()): number =>
+ new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
+
+/**
+ * The local midnight the cutoffs are anchored to, as a timestamp. A derived atom would
+ * otherwise cache a cutoff computed yesterday and keep serving it until a facet changes,
+ * so the anchor is its own atom that a mounted consumer advances at midnight.
+ */
+export const localDayStartAtom = atom(startOfLocalDay());
+
+/**
+ * Advances the day anchor when the local day changes, then sleeps until the next
+ * midnight. One mounted consumer is enough for every reader of the cutoffs; a day with
+ * no date facet selected costs a single timer that fires once.
+ */
+export function useFreshLocalDay(): void {
+ const setDay = useSetAtom(localDayStartAtom);
+ useEffect(() => {
+ let timer: ReturnType | undefined;
+ const check = () => {
+ const now = new Date();
+ const todayStart = startOfLocalDay(now);
+ setDay((previous) => (previous === todayStart ? previous : todayStart));
+ const nextMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
+ timer = setTimeout(check, Math.max(0, nextMidnight.getTime() - Date.now()) + 500);
+ };
+ check();
+ return () => {
+ if (timer != null) {
+ clearTimeout(timer);
+ }
+ };
+ }, [setDay]);
+}
+
export const updatedRangeAtom = atom('any');
export const createdRangeAtom = atom('any');
@@ -91,8 +127,11 @@ export const chatFacetParamsAtom = atom<
'updatedAfter' | 'createdAfter' | 'endpoints' | 'hasFiles' | 'sharedOnly'
>
>((get) => {
- const updatedAfter = rangeCutoff(get(updatedRangeAtom));
- const createdAfter = rangeCutoff(get(createdRangeAtom));
+ /** Anchored to the advancing day rather than `new Date()`: the cutoff must be stable
+ * within a day (it is the query key) yet must not outlive the day it names. */
+ const day = new Date(get(localDayStartAtom));
+ const updatedAfter = rangeCutoff(get(updatedRangeAtom), day);
+ const createdAfter = rangeCutoff(get(createdRangeAtom), day);
const endpoints = get(endpointFilterAtom);
const hasFiles = get(hasAttachmentsAtom);
const sharedOnly = get(sharedOnlyAtom);
diff --git a/client/src/components/UnifiedSidebar/ConversationsSection.tsx b/client/src/components/UnifiedSidebar/ConversationsSection.tsx
index 539bdfd8218..5f8291fbf75 100644
--- a/client/src/components/UnifiedSidebar/ConversationsSection.tsx
+++ b/client/src/components/UnifiedSidebar/ConversationsSection.tsx
@@ -6,15 +6,16 @@ import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
import type { ConversationListResponse } from 'librechat-data-provider';
import type { List } from 'react-virtualized';
import {
- chatFilterTagsAtom,
- chatSortAtom,
- isArchivedChatViewAtom,
-} from '~/components/Conversations/chatFilters';
-import {
+ useProjectsInfiniteQuery,
useConversationsInfiniteQuery,
usePinnedConversationsQuery,
useTitleGeneration,
} from '~/data-provider';
+import {
+ chatFilterTagsAtom,
+ chatSortAtom,
+ isArchivedChatViewAtom,
+} from '~/components/Conversations/chatFilters';
import {
useLocalize,
useAuthContext,
@@ -22,8 +23,8 @@ import {
useNavScrolling,
useScrollFade,
} from '~/hooks';
+import { chatFacetParamsAtom, useFreshLocalDay } from '~/components/Conversations/facets';
import ProjectsSection from '~/components/Conversations/ProjectsSection';
-import { chatFacetParamsAtom } from '~/components/Conversations/facets';
import ChatFilterMenu from '~/components/Conversations/ChatFilterMenu';
import PinnedSection from '~/components/Conversations/PinnedSection';
import useSidebarToggle from '~/hooks/Nav/useSidebarToggle';
@@ -48,7 +49,17 @@ const ConversationsSection = memo(() => {
const isArchivedView = useAtomValue(isArchivedChatViewAtom);
/** Date, endpoint and attachment facets, already shaped as list parameters. */
const facetParams = useAtomValue(chatFacetParamsAtom);
+ /** Keeps the date facets' midnight anchor advancing while the list is mounted. */
+ useFreshLocalDay();
const search = useRecoilValue(store.search);
+ /** The same projects ProjectsSection reads, so an empty Chats list can tell "every
+ * chat lives under a project" from "this account has nothing yet". Shared key, so
+ * this costs no second request. */
+ const { data: projectsData } = useProjectsInfiniteQuery(
+ { sortBy: 'lastConversationAt', sortDirection: 'desc', limit: 25 },
+ { enabled: isAuthenticated, staleTime: 30000, cacheTime: 300000 },
+ );
+ const hasProjects = (projectsData?.pages[0]?.projects?.length ?? 0) > 0;
const {
data,
@@ -252,6 +263,7 @@ const ConversationsSection = memo(() => {
isError={isError}
onRetry={retryConversations}
chatsHeaderTrailing={chatsHeaderTrailing}
+ accountHasProjects={hasProjects}
scrollViewport={scrollViewport}
scrollContent={scrollContent}
/>
diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx
index 5ed99b72f56..09f9c95b6a3 100644
--- a/client/src/hooks/AuthContext.tsx
+++ b/client/src/hooks/AuthContext.tsx
@@ -38,6 +38,7 @@ import {
} from '~/data-provider';
import { resetChatFilterSessionAtom } from '~/components/Conversations/chatFilters';
import { TAuthConfig, TUserContext, TAuthContext, TResError } from '~/common';
+import { resetFacetsAtom } from '~/components/Conversations/facets';
import useTimeout from './useTimeout';
import store from '~/store';
@@ -55,6 +56,7 @@ if (import.meta.hot) {
* path the other was wired into. */
const endSessionClientState = (): void => {
getDefaultStore().set(resetChatFilterSessionAtom);
+ getDefaultStore().set(resetFacetsAtom);
clearRetainedFileDeletions();
clearComposerDraftStorage();
};
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index c9581f519b9..7bf6aad8d37 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1893,6 +1893,7 @@
"com_ui_no_category": "No category",
"com_ui_no_changes": "No changes were made",
"com_ui_no_chats": "No chats yet",
+ "com_ui_no_unassigned_chats": "No chats outside your projects yet",
"com_ui_no_chats_match_filters": "No chats match your filters",
"com_ui_no_data": "Nothing here yet",
"com_ui_no_individual_resource_access": "No individual users or groups have access",
diff --git a/client/src/utils/convos.spec.ts b/client/src/utils/convos.spec.ts
index 0e0dd5bfa22..d32de750a35 100644
--- a/client/src/utils/convos.spec.ts
+++ b/client/src/utils/convos.spec.ts
@@ -1560,5 +1560,75 @@ describe('Conversation Utilities', () => {
expect(queryClient.getQueryState(archivedKey)?.isInvalidated).toBe(true);
});
});
+
+ describe('addConversationToAllConversationsQueries with list facets', () => {
+ const emptyPages = { pages: [{ conversations: [], nextCursor: null }], pageParams: [] };
+ /** A row the endpoint and date facets can judge from its own fields. */
+ const facetConvo = {
+ conversationId: 'facet',
+ endpoint: 'openAI',
+ createdAt: '2026-09-20T10:00:00.000Z',
+ updatedAt: '2026-09-24T10:00:00.000Z',
+ isArchived: false,
+ } as TConversation;
+
+ it('skips a variant whose endpoint facet excludes the row', () => {
+ const queryClient = new QueryClient();
+ const key = ['allConversations', { endpoints: ['google'] }];
+ queryClient.setQueryData(key, emptyPages);
+
+ addConversationToAllConversationsQueries(queryClient, facetConvo);
+
+ expect(
+ queryClient.getQueryData>(key)!.pages[0].conversations,
+ ).toHaveLength(0);
+ });
+
+ it('skips a row older than the variant date cutoff', () => {
+ const queryClient = new QueryClient();
+ const key = ['allConversations', { updatedAfter: '2026-09-25T00:00:00.000Z' }];
+ queryClient.setQueryData(key, emptyPages);
+
+ addConversationToAllConversationsQueries(queryClient, facetConvo);
+
+ expect(
+ queryClient.getQueryData>(key)!.pages[0].conversations,
+ ).toHaveLength(0);
+ });
+
+ it('keeps inserting into a date facet the row satisfies', () => {
+ const queryClient = new QueryClient();
+ const key = ['allConversations', { updatedAfter: '2026-09-24T00:00:00.000Z' }];
+ queryClient.setQueryData(key, emptyPages);
+
+ addConversationToAllConversationsQueries(queryClient, facetConvo);
+
+ expect(
+ queryClient.getQueryData>(key)!.pages[0].conversations[0]
+ .conversationId,
+ ).toBe('facet');
+ });
+
+ /** Attachments and sharing live in collections the list row does not carry, so
+ * only the server can place a row in those variants. */
+ it.each([
+ ['attachments', { hasFiles: true }],
+ ['sharing', { sharedOnly: true }],
+ ])('refetches a variant filtered on %s instead of inserting', (_name, facet) => {
+ const queryClient = new QueryClient();
+ const key = ['allConversations', facet];
+ queryClient.setQueryData(key, emptyPages);
+ const invalidate = jest.spyOn(queryClient, 'invalidateQueries');
+
+ addConversationToAllConversationsQueries(queryClient, facetConvo);
+
+ expect(invalidate).toHaveBeenCalledWith(
+ expect.objectContaining({ queryKey: key, refetchType: 'active' }),
+ );
+ expect(
+ queryClient.getQueryData>(key)!.pages[0].conversations,
+ ).toHaveLength(0);
+ });
+ });
});
});
diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts
index 66cb9e137dc..998a8c62392 100644
--- a/client/src/utils/convos.ts
+++ b/client/src/utils/convos.ts
@@ -248,6 +248,11 @@ function getConversationListQueryParams(queryKey: readonly unknown[]): {
sortBy?: string;
sortDirection?: string;
isArchived?: boolean;
+ updatedAfter?: string;
+ createdAfter?: string;
+ endpoints?: string[];
+ hasFiles?: boolean;
+ sharedOnly?: boolean;
} {
const params = queryKey[1];
if (!params || typeof params !== 'object') {
@@ -259,6 +264,11 @@ function getConversationListQueryParams(queryKey: readonly unknown[]): {
sortBy?: string;
sortDirection?: string;
isArchived?: boolean;
+ updatedAfter?: string;
+ createdAfter?: string;
+ endpoints?: string[];
+ hasFiles?: boolean;
+ sharedOnly?: boolean;
};
}
@@ -334,42 +344,81 @@ function conversationBelongsToListQuery(
}
/**
- * Whether only the server can say what a variant holds after a write. Two things put it
+ * Whether only the server can say what a variant holds after a write. Three things put it
* out of the client's reach: an order keyed on something other than last activity, which
- * these writers cannot place a row against, and a search, which the server evaluates —
- * a title edit or a new message can make a row start or stop matching one.
+ * these writers cannot place a row against; a search, which the server evaluates —
+ * a title edit or a new message can make a row start or stop matching one; and the
+ * attachment and sharing facets, whose truth lives in collections the list row does
+ * not carry.
*/
function queryNeedsServerReconciliation(queryKey: readonly unknown[]): boolean {
if (!queryListsNewestFirst(queryKey)) {
return true;
}
- const { search } = getConversationListQueryParams(queryKey);
- return typeof search === 'string' && search.trim() !== '';
+ const { search, hasFiles, sharedOnly } = getConversationListQueryParams(queryKey);
+ if (typeof search === 'string' && search.trim() !== '') {
+ return true;
+ }
+ return hasFiles === true || sharedOnly === true;
}
/**
* What a writer may do with a row it wants to add to a variant.
*
* `skip` is only for a variant the row provably does not belong to, by the facets the
- * client decides: project, archive state, bookmarks. Anything left to the server is
- * refetched instead — skipping it silently would leave a mounted list missing a row.
+ * client decides: project, archive state, bookmarks, the endpoint it ran on, and the
+ * date cutoffs it carries. Anything left to the server — attachments, sharing — is
+ * refetched instead; skipping it silently would leave a mounted list missing a row.
*/
type ListInsertVerdict = 'insert' | 'skip' | 'refetch';
function conversationInsertVerdict(
queryKey: readonly unknown[],
- conversation: Pick,
+ conversation: Pick<
+ TConversation,
+ 'chatProjectId' | 'tags' | 'isArchived' | 'endpoint' | 'createdAt' | 'updatedAt'
+ >,
): ListInsertVerdict {
if (!conversationBelongsToListQuery(queryKey, conversation)) {
return 'skip';
}
- const { tags } = getConversationListQueryParams(queryKey);
+ const { tags, endpoints, updatedAfter, createdAfter } = getConversationListQueryParams(queryKey);
if (Array.isArray(tags) && tags.length > 0) {
const conversationTags = conversation.tags;
if (!Array.isArray(conversationTags) || !tags.some((tag) => conversationTags.includes(tag))) {
return 'skip';
}
}
+ if (Array.isArray(endpoints) && endpoints.length > 0) {
+ if (typeof conversation.endpoint === 'string') {
+ if (!endpoints.includes(conversation.endpoint)) {
+ return 'skip';
+ }
+ } else {
+ /* An endpoint-less row cannot be proven either way, and inserting it on a guess
+ * would put a chat the server excluded into the filtered list. */
+ return 'refetch';
+ }
+ }
+ const dateFacets: Array<[cutoff: string | undefined, at: string | null | undefined]> = [
+ [updatedAfter, conversation.updatedAt ?? null],
+ [createdAfter, conversation.createdAt ?? null],
+ ];
+ for (const [cutoff, at] of dateFacets) {
+ if (typeof cutoff !== 'string') {
+ continue;
+ }
+ const cutoffMs = Date.parse(cutoff);
+ if (Number.isNaN(cutoffMs)) {
+ continue;
+ }
+ if (at == null) {
+ return 'refetch';
+ }
+ if (Date.parse(at) < cutoffMs) {
+ return 'skip';
+ }
+ }
return queryNeedsServerReconciliation(queryKey) ? 'refetch' : 'insert';
}
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index c1f802edd97..fd34c67cc02 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -1050,19 +1050,6 @@
"count": 8
}
},
- "client/src/components/Conversations/ChatFilterMenu.tsx": {
- "shadcn/no-inline-styles": {
- "count": 1
- }
- },
- "client/src/components/Conversations/Conversations.tsx": {
- "shadcn/no-inline-styles": {
- "count": 2
- },
- "shadcn/no-restyle": {
- "count": 2
- }
- },
"client/src/components/Conversations/Convo.tsx": {
"shadcn/no-inline-styles": {
"count": 1
From 5943cd00c2fcefdee75900b131fb19300f27c344 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 24 Sep 2026 09:23:43 +0200
Subject: [PATCH 04/22] test(client): Mock the projects query the chats section
now reads
---
.../UnifiedSidebar/__tests__/ConversationsSection.spec.tsx | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
index 701eae6f3e5..8132ca48f4a 100644
--- a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
+++ b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
@@ -87,6 +87,11 @@ jest.mock('~/data-provider', () => ({
return mockConversationsResult;
},
usePinnedConversationsQuery: () => mockPinnedResult,
+ /** The section reads the same projects ProjectsSection does, to tell an empty
+ * unassigned list from an empty account; these specs carry no projects. */
+ useProjectsInfiniteQuery: () => ({
+ data: { pages: [{ projects: [], nextCursor: null }], pageParams: [undefined] },
+ }),
useTitleGeneration: () => mockUseTitleGeneration(),
useGetEndpointsQuery: () => ({ data: {}, isLoading: false }),
useGetStartupConfig: () => ({ data: { modelSpecs: { list: [] } } }),
From c2c7b12c5ee76cd165fe3b125fe7566739b9829e Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sat, 26 Sep 2026 17:13:59 +0200
Subject: [PATCH 05/22] test(e2e): Acceptance scenarios for the chat list
properties menu
---
.../mock/scenarios/chat-filter-menu.spec.ts | 380 ++++++++++++++++++
1 file changed, 380 insertions(+)
create mode 100644 e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
diff --git a/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
new file mode 100644
index 00000000000..e0d63ddfe99
--- /dev/null
+++ b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
@@ -0,0 +1,380 @@
+import { randomUUID } from 'node:crypto';
+import { expect, test } from '@playwright/test';
+import type { APIRequestContext, Locator, Page, Route } from '@playwright/test';
+import type { User } from '../../../types';
+import {
+ MOCK_ENDPOINTS,
+ NEW_CHAT_PATH,
+ mockReply,
+ selectMockEndpoint,
+ sendMessage,
+} from '../helpers';
+import { deleteConversations, withMongo } from '../db';
+import { getE2EUser } from '../../../setup/user';
+import cleanupUser from '../../../setup/cleanupUser';
+
+test.describe.configure({ timeout: 120_000 });
+
+type SeedRow = {
+ title: string;
+ files?: string[];
+ updatedAt?: Date;
+ chatProjectId?: string;
+ isArchived?: boolean;
+};
+
+const DAY = 24 * 60 * 60 * 1000;
+const userEmail = getE2EUser().email;
+const createdConversationIds: string[] = [];
+const createdProjectIds: string[] = [];
+
+/* Every spec in the run shares this user, so each title carries its own id: a row is
+ * then identified by its title alone, whatever else the list holds. */
+const uniqueTitle = (label: string) => `Menu ${label} ${randomUUID().slice(0, 8)}`;
+
+async function seedRows(rows: SeedRow[]): Promise {
+ await withMongo(async (db) => {
+ const user = await db.collection('users').findOne({ email: userEmail });
+ if (!user) throw new Error(`E2E seed: user "${userEmail}" not found`);
+ const docs = rows.map((row) => {
+ const conversationId = randomUUID();
+ createdConversationIds.push(conversationId);
+ const at = row.updatedAt ?? new Date();
+ return {
+ conversationId,
+ title: row.title,
+ user: user._id.toString(),
+ endpoint: 'openAI',
+ isArchived: row.isArchived ?? false,
+ ...(row.isArchived ? { archivedAt: at } : {}),
+ ...(row.files ? { files: row.files } : {}),
+ ...(row.chatProjectId ? { chatProjectId: row.chatProjectId } : {}),
+ createdAt: at,
+ updatedAt: at,
+ __v: 0,
+ };
+ });
+ await db.collection('conversations').insertMany(docs);
+ });
+}
+
+async function createProject(page: Page, name: string): Promise {
+ await page.goto('/projects', { timeout: 10000 });
+ await page.getByRole('button', { name: 'New project' }).first().click();
+ const dialog = page.getByRole('dialog');
+ await dialog.getByRole('textbox', { name: 'Project name' }).fill(name);
+ await dialog.getByRole('button', { name: 'Create project' }).click();
+ await expect(page.getByRole('heading', { name })).toBeVisible();
+ const projectId = new URL(page.url()).pathname.split('/projects/')[1];
+ expect(projectId).toBeTruthy();
+ createdProjectIds.push(projectId);
+ return projectId;
+}
+
+/** A row of the Chats section, as opposed to one filed under a project or pinned. */
+const chatsRow = (page: Page, title: string): Locator =>
+ page.getByTestId('convo-list-row').filter({ hasText: title });
+
+const trigger = (page: Page) => page.getByTestId('chat-filter-menu');
+const menu = (page: Page) => page.getByRole('menu', { name: 'Filter and sort chats' });
+
+const isPhone = (page: Page) => (page.viewportSize()?.width ?? 1280) < 768;
+
+/** Below `md` the sidebar is a drawer that starts closed; open it before reading the
+ * list. A no-op on desktop, where the list is already on screen. */
+async function showSidebar(page: Page): Promise {
+ if (isPhone(page) && !(await trigger(page).isVisible())) {
+ await page.getByTestId('header-open-sidebar-button').click();
+ }
+ await expect(trigger(page)).toBeVisible();
+}
+
+/** The drawer covers the composer on a phone, so it closes before a message is sent. */
+async function hideSidebar(page: Page): Promise {
+ if (isPhone(page) && (await trigger(page).isVisible())) {
+ await page.getByTestId('close-sidebar-button').click();
+ await expect(trigger(page)).toBeHidden();
+ }
+}
+
+async function openFilterSubmenu(page: Page): Promise {
+ if (!(await menu(page).isVisible())) {
+ await trigger(page).click();
+ }
+ await expect(menu(page)).toBeVisible();
+ await page.getByTestId('chat-filter-facets').click();
+ await expect(page.getByRole('menuitemcheckbox', { name: 'Has attachments' })).toBeVisible();
+}
+
+async function closeMenus(page: Page): Promise {
+ for (let i = 0; i < 3 && (await menu(page).isVisible()); i++) {
+ await page.keyboard.press('Escape');
+ }
+ await expect(menu(page)).toBeHidden();
+}
+
+async function toggleHasAttachments(page: Page): Promise {
+ await openFilterSubmenu(page);
+ await page.getByRole('menuitemcheckbox', { name: 'Has attachments' }).click();
+ await closeMenus(page);
+}
+
+test.afterEach(async () => {
+ const conversationIds = createdConversationIds.splice(0, createdConversationIds.length);
+ if (conversationIds.length > 0) {
+ await deleteConversations(conversationIds);
+ }
+ const projectIds = createdProjectIds.splice(0, createdProjectIds.length);
+ if (projectIds.length > 0) {
+ await withMongo(async (db) => {
+ const projects = await db
+ .collection('chatprojects')
+ .find({})
+ .project({ _id: 1, projectId: 1 })
+ .toArray();
+ const ids = projects
+ .filter((doc) => projectIds.includes(String(doc.projectId ?? doc._id)))
+ .map((doc) => doc._id);
+ await db.collection('chatprojects').deleteMany({ _id: { $in: ids } });
+ await db.collection('conversations').deleteMany({ chatProjectId: { $in: projectIds } });
+ });
+ }
+});
+
+test.describe('chat list properties menu', () => {
+ test('a facet chosen from the keyboard narrows the Chats list @scenario:chat-menu-facet-narrows-list', async ({
+ page,
+ }) => {
+ const withFile = uniqueTitle('file');
+ const withoutFile = uniqueTitle('plain');
+ await seedRows([{ title: withFile, files: [randomUUID()] }, { title: withoutFile }]);
+ await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
+ await showSidebar(page);
+ await expect(chatsRow(page, withFile)).toBeVisible();
+ await expect(chatsRow(page, withoutFile)).toBeVisible();
+
+ await trigger(page).focus();
+ await page.keyboard.press('Enter');
+ await expect(menu(page)).toBeVisible();
+ await expect(page.getByTestId('chat-filter-show')).toBeVisible();
+ await expect(page.getByTestId('chat-filter-sort')).toBeVisible();
+
+ const filterRow = page.getByTestId('chat-filter-facets');
+ for (let i = 0; i < 6; i++) {
+ if (await filterRow.evaluate((node) => node === document.activeElement)) {
+ break;
+ }
+ await page.keyboard.press('ArrowDown');
+ }
+ await expect(filterRow).toBeFocused();
+ await page.keyboard.press('ArrowRight');
+ await expect(
+ page
+ .getByRole('combobox', { name: 'Search filters' })
+ .or(page.getByRole('textbox', { name: 'Search filters' })),
+ ).toBeFocused();
+
+ await page.getByRole('menuitemcheckbox', { name: 'Has attachments' }).click();
+ await expect(page.getByRole('menuitemcheckbox', { name: 'Has attachments' })).toHaveAttribute(
+ 'aria-checked',
+ 'true',
+ );
+ await closeMenus(page);
+
+ await expect(chatsRow(page, withFile)).toBeVisible();
+ await expect(chatsRow(page, withoutFile)).toHaveCount(0);
+ await expect(trigger(page)).toHaveAttribute('aria-label', 'Filters active: 1');
+ await expect(trigger(page)).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ test('Reset in the menu restores the unfiltered list @scenario:chat-menu-reset-restores-list', async ({
+ page,
+ }) => {
+ const withFile = uniqueTitle('file');
+ const withoutFile = uniqueTitle('plain');
+ await seedRows([{ title: withFile, files: [randomUUID()] }, { title: withoutFile }]);
+ await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
+ await showSidebar(page);
+
+ await toggleHasAttachments(page);
+ await expect(chatsRow(page, withoutFile)).toHaveCount(0);
+
+ await trigger(page).click();
+ const reset = page.getByTestId('chat-filter-reset-all');
+ await expect(reset).not.toHaveAttribute('aria-disabled', 'true');
+ await reset.click();
+ await expect(reset).toHaveAttribute('aria-disabled', 'true');
+ await closeMenus(page);
+
+ await expect(chatsRow(page, withoutFile)).toBeVisible();
+ await expect(chatsRow(page, withFile)).toBeVisible();
+ await expect(trigger(page)).toHaveAttribute('aria-label', 'Filter and sort chats');
+ });
+
+ test('an Updated window of Today hides a chat last touched ten days ago @scenario:chat-date-window-hides-older', async ({
+ page,
+ }) => {
+ const recent = uniqueTitle('recent');
+ const older = uniqueTitle('older');
+ /* Both carry a file, so the attachment facet reduces the list to rows this
+ * test and its siblings seeded: the ten-day-old row is then on screen before
+ * the date window, however many chats the shared user has. */
+ await seedRows([
+ { title: recent, files: [randomUUID()] },
+ { title: older, files: [randomUUID()], updatedAt: new Date(Date.now() - 10 * DAY) },
+ ]);
+ await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
+ await showSidebar(page);
+ await toggleHasAttachments(page);
+ await expect(chatsRow(page, older)).toBeVisible();
+
+ await openFilterSubmenu(page);
+ await page.getByRole('menuitem', { name: /^Updated\b/ }).click();
+ await page.getByRole('menuitemradio', { name: 'Today' }).click();
+ await closeMenus(page);
+
+ await expect(chatsRow(page, recent)).toBeVisible();
+ await expect(chatsRow(page, older)).toHaveCount(0);
+ await expect(trigger(page)).toHaveAttribute('aria-label', 'Filters active: 2');
+ });
+
+ test('a project chat is listed under its project, not in Chats, and stays reachable from the archive @scenario:project-chat-listed-under-project-only', async ({
+ page,
+ }) => {
+ const projectName = `Menu project ${randomUUID().slice(0, 8)}`;
+ const projectId = await createProject(page, projectName);
+ const inProject = uniqueTitle('project');
+ const archivedInProject = uniqueTitle('archived');
+ const unassigned = uniqueTitle('unassigned');
+ await seedRows([
+ { title: inProject, chatProjectId: projectId },
+ { title: archivedInProject, chatProjectId: projectId, isArchived: true },
+ { title: unassigned },
+ ]);
+ await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
+ await showSidebar(page);
+
+ await expect(chatsRow(page, unassigned)).toBeVisible();
+ await expect(chatsRow(page, inProject)).toHaveCount(0);
+
+ const projectRow = page.getByRole('button', { name: projectName }).first();
+ if ((await projectRow.getAttribute('aria-expanded')) !== 'true') {
+ await projectRow.click();
+ }
+ await expect(
+ page.getByTestId(`project-chats-${projectId}`).getByTestId('convo-item').filter({
+ hasText: inProject,
+ }),
+ ).toBeVisible();
+
+ await trigger(page).click();
+ await page.getByTestId('chat-filter-show').click();
+ await page.getByRole('menuitemradio', { name: 'Archived chats' }).click();
+ await closeMenus(page);
+ await expect(chatsRow(page, archivedInProject)).toBeVisible();
+ });
+
+ test('a chat sent while a server-only facet is active is not added to the filtered list @scenario:live-chat-respects-active-facet', async ({
+ page,
+ }) => {
+ const withFile = uniqueTitle('file');
+ await seedRows([{ title: withFile, files: [randomUUID()] }]);
+ await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
+ await showSidebar(page);
+ await toggleHasAttachments(page);
+ await expect(chatsRow(page, withFile)).toBeVisible();
+ const rowsBefore = await page.getByTestId('convo-list-row').count();
+
+ /* The facet's own refetch is held until the list has been read: whatever the
+ * cache writers put in the list while the reply streamed is then still on
+ * screen, instead of being papered over by the server's answer. */
+ const held: Route[] = [];
+ let holding = true;
+ await page.route(
+ (url) => url.pathname === '/api/convos' && url.searchParams.get('hasFiles') === 'true',
+ async (route) => {
+ if (holding) {
+ held.push(route);
+ return;
+ }
+ await route.continue();
+ },
+ );
+
+ await hideSidebar(page);
+ await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
+ const response = await sendMessage(page, `no attachment ${randomUUID().slice(0, 8)}`);
+ expect(response.ok()).toBeTruthy();
+ await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
+ await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
+ const conversationId = new URL(page.url()).pathname.split('/c/')[1];
+ createdConversationIds.push(conversationId);
+ await showSidebar(page);
+
+ await expect(page.getByTestId('convo-list-row')).toHaveCount(rowsBefore);
+
+ holding = false;
+ await Promise.all(held.splice(0, held.length).map((route) => route.continue()));
+ await expect(chatsRow(page, withFile)).toBeVisible();
+ });
+
+ test('filters chosen by one account are gone after signing out and back in @scenario:facets-cleared-after-sign-out', async ({
+ browser,
+ baseURL,
+ }) => {
+ if (typeof baseURL !== 'string') {
+ throw new Error('baseURL must be configured for mock scenarios');
+ }
+ const user: User = {
+ name: 'Facet Reset',
+ email: `facet-reset-${randomUUID().slice(0, 8)}@example.com`,
+ password: `Pw-${randomUUID()}`,
+ };
+ await cleanupUser(user);
+ const context = await browser.newContext({ storageState: undefined, baseURL });
+ await context.addInitScript(() => {
+ localStorage.setItem('navVisible', 'true');
+ });
+ const page = await context.newPage();
+
+ const register = async (request: APIRequestContext) => {
+ const registered = await request.post('/api/auth/register', {
+ data: {
+ email: user.email,
+ name: user.name,
+ password: user.password,
+ confirm_password: user.password,
+ },
+ });
+ expect(registered.ok()).toBeTruthy();
+ };
+ const logIn = async () => {
+ await page.getByLabel('Email').fill(user.email);
+ await page.getByLabel('Password').fill(user.password);
+ await page.getByTestId('login-button').click();
+ await page.waitForURL(/\/c\/new/, { timeout: 10000 });
+ await showSidebar(page);
+ };
+
+ try {
+ await register(context.request);
+ await page.goto('/login', { timeout: 10000 });
+ await logIn();
+
+ await toggleHasAttachments(page);
+ await expect(trigger(page)).toHaveAttribute('aria-label', 'Filters active: 1');
+
+ await showSidebar(page);
+ await page.getByTestId('nav-user').click();
+ await page.getByRole('menuitem', { name: 'Log out' }).click();
+ await page.waitForURL(/\/login/, { timeout: 10000 });
+ await logIn();
+
+ await expect(trigger(page)).toHaveAttribute('aria-label', 'Filter and sort chats');
+ await expect(trigger(page)).toHaveAttribute('aria-pressed', 'false');
+ } finally {
+ await context.close().catch(() => undefined);
+ await cleanupUser(user);
+ }
+ });
+});
From d8015d5591034afe70af0e955a04b22823f4bbad Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sat, 26 Sep 2026 17:47:15 +0200
Subject: [PATCH 06/22] fix(client): Call the account empty only once its
projects have loaded
An empty Chats list read as "No chats yet" whenever the projects query
had not succeeded, so a slow or failed projects request claimed an
account was empty when its chats may all live under projects. The
account-level wording now needs a loaded, empty project list; otherwise
the section says only that nothing sits outside a project.
---
.../UnifiedSidebar/ConversationsSection.tsx | 7 ++-
.../__tests__/ConversationsSection.spec.tsx | 49 +++++++++++++++++--
2 files changed, 49 insertions(+), 7 deletions(-)
diff --git a/client/src/components/UnifiedSidebar/ConversationsSection.tsx b/client/src/components/UnifiedSidebar/ConversationsSection.tsx
index 5f8291fbf75..bb3fe687451 100644
--- a/client/src/components/UnifiedSidebar/ConversationsSection.tsx
+++ b/client/src/components/UnifiedSidebar/ConversationsSection.tsx
@@ -55,11 +55,14 @@ const ConversationsSection = memo(() => {
/** The same projects ProjectsSection reads, so an empty Chats list can tell "every
* chat lives under a project" from "this account has nothing yet". Shared key, so
* this costs no second request. */
- const { data: projectsData } = useProjectsInfiniteQuery(
+ const { data: projectsData, isSuccess: projectsLoaded } = useProjectsInfiniteQuery(
{ sortBy: 'lastConversationAt', sortDirection: 'desc', limit: 25 },
{ enabled: isAuthenticated, staleTime: 30000, cacheTime: 300000 },
);
- const hasProjects = (projectsData?.pages[0]?.projects?.length ?? 0) > 0;
+ /** Only a loaded, empty project list proves the account has nothing yet. While the
+ * projects are loading or failed to load, an empty Chats list claims no more than
+ * that nothing sits outside a project. */
+ const hasProjects = !projectsLoaded || (projectsData?.pages[0]?.projects?.length ?? 0) > 0;
const {
data,
diff --git a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
index 8132ca48f4a..5abaa036df2 100644
--- a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
+++ b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
@@ -26,6 +26,17 @@ const mockUseFavorites = jest.fn(() => ({
}));
const mockUseGetConversationTags = jest.fn(() => ({ data: [] as unknown[] }));
const mockConversationsRender = jest.fn();
+/** The projects the section reads to word an empty Chats list; none, loaded, by default. */
+type ProjectsResult = {
+ data?: { pages: { projects: unknown[]; nextCursor: null }[]; pageParams: undefined[] };
+ isSuccess: boolean;
+};
+const mockUseProjectsInfiniteQuery = jest.fn(
+ (): ProjectsResult => ({
+ data: { pages: [{ projects: [], nextCursor: null }], pageParams: [undefined] },
+ isSuccess: true,
+ }),
+);
/** What the chats list asks the server for, captured per render. */
const mockListParams = jest.fn();
const mockSetChatsExpanded = jest.fn();
@@ -89,9 +100,7 @@ jest.mock('~/data-provider', () => ({
usePinnedConversationsQuery: () => mockPinnedResult,
/** The section reads the same projects ProjectsSection does, to tell an empty
* unassigned list from an empty account; these specs carry no projects. */
- useProjectsInfiniteQuery: () => ({
- data: { pages: [{ projects: [], nextCursor: null }], pageParams: [undefined] },
- }),
+ useProjectsInfiniteQuery: () => mockUseProjectsInfiniteQuery(),
useTitleGeneration: () => mockUseTitleGeneration(),
useGetEndpointsQuery: () => ({ data: {}, isLoading: false }),
useGetStartupConfig: () => ({ data: { modelSpecs: { list: [] } } }),
@@ -111,8 +120,10 @@ jest.mock('~/hooks/Input/useSelectMention', () => ({
jest.mock('~/components/Conversations', () => {
const { memo } = jest.requireActual('react');
- const ConversationsStub = memo(function ConversationsStub() {
- mockConversationsRender();
+ const ConversationsStub = memo(function ConversationsStub(props: {
+ accountHasProjects?: boolean;
+ }) {
+ mockConversationsRender(props);
return ;
});
return { __esModule: true, Conversations: ConversationsStub };
@@ -310,6 +321,34 @@ describe('ConversationsSection project chats', () => {
});
});
+describe('ConversationsSection empty Chats wording', () => {
+ const lastAccountHasProjects = () =>
+ (mockConversationsRender.mock.calls.at(-1)?.[0] as { accountHasProjects?: boolean })
+ .accountHasProjects;
+
+ beforeEach(() => {
+ mockConversationsRender.mockClear();
+ mockUseProjectsInfiniteQuery.mockReset();
+ });
+
+ it('calls the account empty only once its projects have loaded and there are none', async () => {
+ mockUseProjectsInfiniteQuery.mockReturnValue({
+ data: { pages: [{ projects: [], nextCursor: null }], pageParams: [undefined] },
+ isSuccess: true,
+ });
+ renderSection();
+ await settleRenders();
+ expect(lastAccountHasProjects()).toBe(false);
+ });
+
+ it('does not call the account empty while its projects failed to load', async () => {
+ mockUseProjectsInfiniteQuery.mockReturnValue({ data: undefined, isSuccess: false });
+ renderSection();
+ await settleRenders();
+ expect(lastAccountHasProjects()).toBe(true);
+ });
+});
+
describe('ConversationsSection shared scroll surface', () => {
/** Searching swaps what the one surface holds — Projects and Pinned leave,
* the chats become results — and a position kept from the previous contents
From c6f1d99ca4a3a0f039e1a979019aa26b22458787 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sat, 26 Sep 2026 17:55:35 +0200
Subject: [PATCH 07/22] test(e2e): Scenario for the Filter submenu under a role
without bookmarks
---
.../mock/scenarios/chat-filter-menu.spec.ts | 91 +++++++++++++++++++
1 file changed, 91 insertions(+)
diff --git a/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
index e0d63ddfe99..e993f459574 100644
--- a/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
+++ b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
@@ -377,4 +377,95 @@ test.describe('chat list properties menu', () => {
await cleanupUser(user);
}
});
+
+ test('a role without bookmark access opens the Filter submenu without asking for bookmarks @scenario:bookmark-filter-quiet-without-access', async ({
+ browser,
+ baseURL,
+ }) => {
+ if (typeof baseURL !== 'string') {
+ throw new Error('baseURL must be configured for mock scenarios');
+ }
+ const suffix = randomUUID().slice(0, 8);
+ const roleName = `E2E_NO_BOOKMARKS_${suffix}`;
+ const user: User = {
+ name: 'No Bookmarks',
+ email: `no-bookmarks-${suffix}@example.com`,
+ password: `Pw-${randomUUID()}`,
+ };
+ await cleanupUser(user);
+ /* A copy of the default role with only bookmarks switched off, held by this test's
+ * own account: the role every other spec signs in with stays untouched. */
+ await withMongo(async (db) => {
+ const fields = await db
+ .collection('roles')
+ .findOne({ name: 'USER' }, { projection: { _id: 0 } });
+ if (!fields) throw new Error('E2E seed: USER role not found');
+ await db.collection('roles').insertOne({
+ ...fields,
+ name: roleName,
+ permissions: {
+ ...(fields.permissions ?? {}),
+ BOOKMARKS: { ...(fields.permissions?.BOOKMARKS ?? {}), USE: false },
+ },
+ });
+ });
+
+ const context = await browser.newContext({ storageState: undefined, baseURL });
+ await context.addInitScript(() => {
+ localStorage.setItem('navVisible', 'true');
+ });
+ const page = await context.newPage();
+ try {
+ const registered = await context.request.post('/api/auth/register', {
+ data: {
+ email: user.email,
+ name: user.name,
+ password: user.password,
+ confirm_password: user.password,
+ },
+ });
+ expect(registered.ok()).toBeTruthy();
+ await withMongo((db) =>
+ db.collection('users').updateOne({ email: user.email }, { $set: { role: roleName } }),
+ );
+
+ /* The page header asks for bookmarks on its own, gated or not, and retries the
+ * refusal; that request predates this menu. Its cycle is let to settle first, so
+ * what is counted afterwards is only what the Filter submenu asks for. */
+ let tagRequests = 0;
+ let lastTagRequestAt = Date.now();
+ page.on('request', (request) => {
+ if (new URL(request.url()).pathname.startsWith('/api/tags')) {
+ tagRequests += 1;
+ lastTagRequestAt = Date.now();
+ }
+ });
+
+ await page.goto('/login', { timeout: 10000 });
+ await page.getByLabel('Email').fill(user.email);
+ await page.getByLabel('Password').fill(user.password);
+ await page.getByTestId('login-button').click();
+ await page.waitForURL(/\/c\/new/, { timeout: 10000 });
+ await showSidebar(page);
+ await expect
+ .poll(() => Date.now() - lastTagRequestAt, { timeout: 30000, intervals: [500] })
+ .toBeGreaterThan(8000);
+ const settledRequests = tagRequests;
+
+ await openFilterSubmenu(page);
+ await expect(page.getByRole('menuitem', { name: /^Updated\b/ })).toBeVisible();
+ await expect(page.getByRole('menuitem', { name: /^Bookmarks\b/ })).toHaveCount(0);
+ await page.getByRole('menuitemcheckbox', { name: 'Has attachments' }).click();
+ await closeMenus(page);
+ await expect(trigger(page)).toHaveAttribute('aria-label', 'Filters active: 1');
+ await openFilterSubmenu(page);
+ await closeMenus(page);
+
+ expect(tagRequests).toBe(settledRequests);
+ } finally {
+ await context.close().catch(() => undefined);
+ await cleanupUser(user);
+ await withMongo((db) => db.collection('roles').deleteOne({ name: roleName }));
+ }
+ });
});
From a26b261816a47c722f1b761112fae0566f2c07bd Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sat, 26 Sep 2026 18:03:19 +0200
Subject: [PATCH 08/22] fix(client): Drop a cached chat from an endpoint filter
it no longer matches
The endpoint facet only guarded insertion, so a chat already in an
endpoint-filtered list stayed there after switching provider until the
next refetch. List membership now includes the endpoint for existing
rows as well; a row with no endpoint stays until the server decides.
---
client/src/utils/convos.spec.ts | 53 +++++++++++++++++++++++++++++++++
client/src/utils/convos.ts | 25 ++++++++++++----
2 files changed, 72 insertions(+), 6 deletions(-)
diff --git a/client/src/utils/convos.spec.ts b/client/src/utils/convos.spec.ts
index d32de750a35..539632dad0d 100644
--- a/client/src/utils/convos.spec.ts
+++ b/client/src/utils/convos.spec.ts
@@ -1,3 +1,4 @@
+import { EModelEndpoint } from 'librechat-data-provider';
import { QueryClient, InfiniteData } from '@tanstack/react-query';
import type { TConversation } from 'librechat-data-provider';
import type { ConversationCursorData } from './convos';
@@ -1609,6 +1610,58 @@ describe('Conversation Utilities', () => {
).toBe('facet');
});
+ it('drops a cached row from an endpoint facet once the chat moves to another provider', () => {
+ const queryClient = new QueryClient();
+ const key = ['allConversations', { endpoints: ['openAI'] }];
+ queryClient.setQueryData(key, {
+ pages: [{ conversations: [facetConvo], nextCursor: null }],
+ pageParams: [],
+ });
+
+ upsertConvoInAllQueries(queryClient, { ...facetConvo, endpoint: EModelEndpoint.google });
+
+ expect(
+ queryClient
+ .getQueryData>(key)!
+ .pages.flatMap((page) => page.conversations),
+ ).toHaveLength(0);
+ });
+
+ it('drops a row from an endpoint facet when an in-place update changes its provider', () => {
+ const queryClient = new QueryClient();
+ const key = ['allConversations', { endpoints: ['openAI'] }];
+ queryClient.setQueryData(key, {
+ pages: [{ conversations: [facetConvo], nextCursor: null }],
+ pageParams: [],
+ });
+
+ updateConvoInAllQueries(queryClient, 'facet', (convo) => ({
+ ...convo,
+ endpoint: EModelEndpoint.google,
+ }));
+
+ expect(
+ queryClient
+ .getQueryData>(key)!
+ .pages.flatMap((page) => page.conversations),
+ ).toHaveLength(0);
+ });
+
+ it('keeps a cached row whose update carries no endpoint to judge', () => {
+ const queryClient = new QueryClient();
+ const key = ['allConversations', { endpoints: ['openAI'] }];
+ queryClient.setQueryData(key, {
+ pages: [{ conversations: [{ ...facetConvo, endpoint: null }], nextCursor: null }],
+ pageParams: [],
+ });
+
+ upsertConvoInAllQueries(queryClient, { ...facetConvo, endpoint: null, title: 'Renamed' });
+
+ expect(
+ queryClient.getQueryData>(key)!.pages[0].conversations[0].title,
+ ).toBe('Renamed');
+ });
+
/** Attachments and sharing live in collections the list row does not carry, so
* only the server can place a row in those variants. */
it.each([
diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts
index 998a8c62392..33cd6609892 100644
--- a/client/src/utils/convos.ts
+++ b/client/src/utils/convos.ts
@@ -329,17 +329,30 @@ function queryListsArchived(queryKey: readonly unknown[]): boolean {
/**
* Whether a row still belongs in a variant at all, by the facets the client can decide:
- * its project and whether it is archived. Bookmark and search membership are deliberately
- * excluded — a search cache matches nothing client-side, so judging a row that is already
- * in one by that rule would evict every row it holds.
+ * its project, whether it is archived, and the endpoint it ran on. Bookmark and search
+ * membership are deliberately excluded — a search cache matches nothing client-side, so
+ * judging a row that is already in one by that rule would evict every row it holds. The
+ * date cutoffs are left out too: `updatedAt` only moves forward and `createdAt` never
+ * moves, so a row that matched them keeps matching.
*/
function conversationBelongsToListQuery(
queryKey: readonly unknown[],
- conversation: Pick,
+ conversation: Pick,
): boolean {
+ if (!conversationMatchesProjectQuery(queryKey, conversation)) {
+ return false;
+ }
+ if (queryListsArchived(queryKey) !== (conversation.isArchived === true)) {
+ return false;
+ }
+ /* A chat switched to another provider leaves an endpoint-filtered list; an endpoint-less
+ * row cannot be proven either way and stays until the server says otherwise. */
+ const { endpoints } = getConversationListQueryParams(queryKey);
return (
- conversationMatchesProjectQuery(queryKey, conversation) &&
- queryListsArchived(queryKey) === (conversation.isArchived === true)
+ !Array.isArray(endpoints) ||
+ endpoints.length === 0 ||
+ typeof conversation.endpoint !== 'string' ||
+ endpoints.includes(conversation.endpoint)
);
}
From 1e878933bff7fbf5a1c392b719cab29b364e3a80 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sat, 26 Sep 2026 18:03:31 +0200
Subject: [PATCH 09/22] fix(client): List every chat when the projects fail to
load
Scoping Chats to unassigned chats hid every project chat behind the
Projects section, which shows nothing when its request fails. A failed
project list without cached data now leaves Chats unscoped, so those
chats stay reachable, and an empty unscoped list reads as an empty
account.
---
.../UnifiedSidebar/ConversationsSection.tsx | 21 ++++++++++++-------
.../__tests__/ConversationsSection.spec.tsx | 18 +++++++++++++++-
2 files changed, 31 insertions(+), 8 deletions(-)
diff --git a/client/src/components/UnifiedSidebar/ConversationsSection.tsx b/client/src/components/UnifiedSidebar/ConversationsSection.tsx
index bb3fe687451..e7c81ecf311 100644
--- a/client/src/components/UnifiedSidebar/ConversationsSection.tsx
+++ b/client/src/components/UnifiedSidebar/ConversationsSection.tsx
@@ -55,7 +55,11 @@ const ConversationsSection = memo(() => {
/** The same projects ProjectsSection reads, so an empty Chats list can tell "every
* chat lives under a project" from "this account has nothing yet". Shared key, so
* this costs no second request. */
- const { data: projectsData, isSuccess: projectsLoaded } = useProjectsInfiniteQuery(
+ const {
+ data: projectsData,
+ isSuccess: projectsLoaded,
+ isError: projectsFailed,
+ } = useProjectsInfiniteQuery(
{ sortBy: 'lastConversationAt', sortDirection: 'desc', limit: 25 },
{ enabled: isAuthenticated, staleTime: 30000, cacheTime: 300000 },
);
@@ -63,6 +67,13 @@ const ConversationsSection = memo(() => {
* projects are loading or failed to load, an empty Chats list claims no more than
* that nothing sits outside a project. */
const hasProjects = !projectsLoaded || (projectsData?.pages[0]?.projects?.length ?? 0) > 0;
+ /** A chat that belongs to a project is shown under that project, not twice. Search and
+ * the archived view stay whole: both are places the user goes to find something, and a
+ * project chat that appears in neither list nor result would have no way back. So does
+ * a list whose projects failed to load, which would otherwise hide every project chat
+ * behind a section that cannot show them. */
+ const scopeToUnassigned =
+ !isArchivedView && !search.debouncedQuery && !(projectsFailed && projectsData == null);
const {
data,
@@ -82,11 +93,7 @@ const ConversationsSection = memo(() => {
sortDirection: sort.direction,
tags: tags.length === 0 ? undefined : tags,
search: search.debouncedQuery || undefined,
- /** A chat that belongs to a project is shown under that project, not twice.
- * Search and the archived view stay whole: both are places the user goes to
- * find something, and a project chat that appears in neither list nor result
- * would have no way back. */
- projectId: isArchivedView || search.debouncedQuery ? undefined : 'unassigned',
+ projectId: scopeToUnassigned ? 'unassigned' : undefined,
...facetParams,
},
{
@@ -266,7 +273,7 @@ const ConversationsSection = memo(() => {
isError={isError}
onRetry={retryConversations}
chatsHeaderTrailing={chatsHeaderTrailing}
- accountHasProjects={hasProjects}
+ accountHasProjects={scopeToUnassigned && hasProjects}
scrollViewport={scrollViewport}
scrollContent={scrollContent}
/>
diff --git a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
index 5abaa036df2..e1847fa284a 100644
--- a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
+++ b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx
@@ -30,6 +30,7 @@ const mockConversationsRender = jest.fn();
type ProjectsResult = {
data?: { pages: { projects: unknown[]; nextCursor: null }[]; pageParams: undefined[] };
isSuccess: boolean;
+ isError?: boolean;
};
const mockUseProjectsInfiniteQuery = jest.fn(
(): ProjectsResult => ({
@@ -341,12 +342,27 @@ describe('ConversationsSection empty Chats wording', () => {
expect(lastAccountHasProjects()).toBe(false);
});
- it('does not call the account empty while its projects failed to load', async () => {
+ it('does not call the account empty while its projects are still loading', async () => {
mockUseProjectsInfiniteQuery.mockReturnValue({ data: undefined, isSuccess: false });
renderSection();
await settleRenders();
expect(lastAccountHasProjects()).toBe(true);
});
+
+ it('lists every chat when the projects failed to load, so project chats keep a way back', async () => {
+ mockUseProjectsInfiniteQuery.mockReturnValue({
+ data: undefined,
+ isSuccess: false,
+ isError: true,
+ });
+ mockListParams.mockClear();
+ renderSection();
+ await settleRenders();
+ expect(mockListParams.mock.calls.at(-1)?.[0]).toEqual(
+ expect.objectContaining({ projectId: undefined }),
+ );
+ expect(lastAccountHasProjects()).toBe(false);
+ });
});
describe('ConversationsSection shared scroll surface', () => {
From 14ae6a37c92a66c1901abaebef62ac967e1b548d Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sat, 26 Sep 2026 18:03:41 +0200
Subject: [PATCH 10/22] test(e2e): Scenario for project chats when the projects
fail to load
---
.../mock/scenarios/chat-filter-menu.spec.ts | 25 +++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
index e993f459574..2b1d4a9c3c3 100644
--- a/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
+++ b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
@@ -274,6 +274,31 @@ test.describe('chat list properties menu', () => {
await expect(chatsRow(page, archivedInProject)).toBeVisible();
});
+ test('a project chat stays in Chats when the projects fail to load @scenario:project-chats-reachable-when-projects-fail', async ({
+ page,
+ }) => {
+ const inProject = uniqueTitle('orphaned');
+ const unassigned = uniqueTitle('unassigned');
+ /* No project document is needed: the list request is what hides a project chat, and
+ * the project list it depends on is made to fail below. */
+ await seedRows([
+ { title: inProject, chatProjectId: randomUUID().replace(/-/g, '').slice(0, 24) },
+ { title: unassigned },
+ ]);
+ await page.route(
+ (url) => url.pathname === '/api/projects',
+ (route) =>
+ route.request().method() === 'GET'
+ ? route.fulfill({ status: 500, body: '{}' })
+ : route.continue(),
+ );
+ await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
+ await showSidebar(page);
+
+ await expect(chatsRow(page, unassigned)).toBeVisible();
+ await expect(chatsRow(page, inProject)).toBeVisible({ timeout: 30000 });
+ });
+
test('a chat sent while a server-only facet is active is not added to the filtered list @scenario:live-chat-respects-active-facet', async ({
page,
}) => {
From fe32448e0fac41440b40eb890df42e3eaaa5b4be Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sat, 26 Sep 2026 18:24:39 +0200
Subject: [PATCH 11/22] fix(client): Count the bookmark filter once in the menu
trigger
The trigger added one per selected bookmark while the Filter row counts
the whole bookmark group as one, so a screen reader heard two different
totals for the same state. Bookmarks now count once, like the endpoint
facet.
---
.../__tests__/chatFilters.spec.ts | 21 +++++++++++++++++++
.../components/Conversations/chatFilters.ts | 4 +++-
2 files changed, 24 insertions(+), 1 deletion(-)
create mode 100644 client/src/components/Conversations/__tests__/chatFilters.spec.ts
diff --git a/client/src/components/Conversations/__tests__/chatFilters.spec.ts b/client/src/components/Conversations/__tests__/chatFilters.spec.ts
new file mode 100644
index 00000000000..a4516e50187
--- /dev/null
+++ b/client/src/components/Conversations/__tests__/chatFilters.spec.ts
@@ -0,0 +1,21 @@
+import { createStore } from 'jotai';
+import { chatFilterCountAtom, chatFilterStatusAtom, chatFilterTagsAtom } from '../chatFilters';
+
+describe('chatFilterCountAtom', () => {
+ it('counts nothing while every property is at its default', () => {
+ expect(createStore().get(chatFilterCountAtom)).toBe(0);
+ });
+
+ it('counts bookmarks once, however many are selected', () => {
+ const store = createStore();
+ store.set(chatFilterTagsAtom, ['work', 'travel', 'ideas']);
+ expect(store.get(chatFilterCountAtom)).toBe(1);
+ });
+
+ it('adds the archived view to the bookmark group', () => {
+ const store = createStore();
+ store.set(chatFilterTagsAtom, ['work', 'travel']);
+ store.set(chatFilterStatusAtom, 'archived');
+ expect(store.get(chatFilterCountAtom)).toBe(2);
+ });
+});
diff --git a/client/src/components/Conversations/chatFilters.ts b/client/src/components/Conversations/chatFilters.ts
index 2afe31095e4..4c671f96a03 100644
--- a/client/src/components/Conversations/chatFilters.ts
+++ b/client/src/components/Conversations/chatFilters.ts
@@ -70,9 +70,11 @@ export const chatSortAtom = atom(
export const isArchivedChatViewAtom = atom((get) => get(chatFilterStatusAtom) === 'archived');
/** Drives the trigger's badge: how many choices differ from the default list. */
+/** Each property that departs from the default counts once, however many values it
+ * carries: bookmarks count as one, the way the Filter row and the endpoint facet count. */
export const chatFilterCountAtom = atom((get) => {
const sort = get(chatSortAtom);
- let count = get(chatFilterTagsAtom).length;
+ let count = get(chatFilterTagsAtom).length > 0 ? 1 : 0;
if (get(chatFilterStatusAtom) !== 'active') {
count += 1;
}
From 8be99c88f82df35c421e938cb7cede907ce72df3 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sun, 27 Sep 2026 02:42:43 +0200
Subject: [PATCH 12/22] fix(client): Stop the endpoint filter at the configured
limit
The Endpoint facet let a user pick more endpoints than
conversationList.maxEndpointFilters allows, and the list request past it
answered 400, leaving Chats in its error state. The post-login startup
config now publishes the limit, resolved by the same helper the list
parser uses, and the facet disables further endpoints once the
selection reaches it, with a note saying why.
---
api/server/routes/__tests__/config.spec.js | 26 ++++++++++++
api/server/routes/config.js | 3 ++
.../Conversations/ChatFilterMenu.tsx | 40 ++++++++++++++++++-
client/src/locales/en/translation.json | 1 +
packages/api/src/conversations/filters.ts | 16 ++++++--
packages/data-provider/src/config.ts | 3 ++
6 files changed, 83 insertions(+), 6 deletions(-)
diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js
index 018ddeea7a8..8059ad6ceea 100644
--- a/api/server/routes/__tests__/config.spec.js
+++ b/api/server/routes/__tests__/config.spec.js
@@ -1096,3 +1096,29 @@ describe('passkey enrollment cap', () => {
expect(anonymous.body).not.toHaveProperty('maxPasskeysPerUser');
});
});
+
+describe('chat list endpoint filter limit', () => {
+ it('publishes the configured limit only after authentication', async () => {
+ const { AppService } = require('@librechat/data-schemas');
+ const appConfig = await AppService({
+ config: { conversationList: { maxEndpointFilters: 3 } },
+ });
+ mockGetAppConfig.mockResolvedValue(appConfig);
+
+ const authenticated = await request(createApp(mockUser)).get('/api/config');
+ expect(authenticated.status).toBe(200);
+ expect(authenticated.body.maxEndpointFilters).toBe(3);
+
+ const anonymous = await request(createApp()).get('/api/config');
+ expect(anonymous.status).toBe(200);
+ expect(anonymous.body).not.toHaveProperty('maxEndpointFilters');
+ });
+
+ it('publishes the schema default 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.maxEndpointFilters).toBe(50);
+ });
+});
diff --git a/api/server/routes/config.js b/api/server/routes/config.js
index 7d6eef14fe1..280157597ec 100644
--- a/api/server/routes/config.js
+++ b/api/server/routes/config.js
@@ -19,6 +19,7 @@ const {
isPasskeyEnabled,
buildPreLoginInterface,
resolveMaxPasskeysPerUser,
+ resolveConversationListLimits,
} = require('@librechat/api');
const {
DEFAULT_MCP_APP_CSP_LIMITS,
@@ -161,6 +162,8 @@ function buildPostLoginPayload(appConfig) {
isEnabled(process.env.ALLOW_ACCOUNT_DELETION),
allowEmailChange: resolveEmailChangeSettings(appConfig?.emailChange).enabled,
maxPasskeysPerUser: resolveMaxPasskeysPerUser(appConfig?.passkeys),
+ maxEndpointFilters: resolveConversationListLimits(appConfig?.conversationList)
+ .maxEndpointFilters,
};
return payload;
diff --git a/client/src/components/Conversations/ChatFilterMenu.tsx b/client/src/components/Conversations/ChatFilterMenu.tsx
index 82493235f35..92ad5dbb020 100644
--- a/client/src/components/Conversations/ChatFilterMenu.tsx
+++ b/client/src/components/Conversations/ChatFilterMenu.tsx
@@ -99,16 +99,20 @@ type ToggleProps = {
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 }: ToggleProps) => (
+const Toggle = ({ label, icon, checked, onSelect, disabled = false }: ToggleProps) => (
{icon}
@@ -264,6 +268,7 @@ type FilterOption = {
multiple: boolean;
icon: ReactNode;
onSelect: () => void;
+ disabled?: boolean;
};
type DateFacetProps = {
@@ -296,6 +301,30 @@ const DateFacet = ({ label, icon, value, onSelect }: DateFacetProps) => {
);
};
+/**
+ * Whether the endpoint selection has reached what the server accepts in one request
+ * (`conversationList.maxEndpointFilters`). Past it the list request would be refused and
+ * the Chats section would show its error, so further endpoints are offered disabled.
+ */
+const useEndpointFilterLimit = (selectedCount: number) => {
+ const { data: startupConfig } = useGetStartupConfig();
+ const limit = startupConfig?.maxEndpointFilters;
+ return { limit, atLimit: 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(() => {
@@ -303,6 +332,7 @@ const EndpointFacet = memo(() => {
const selected = useAtomValue(endpointFilterAtom);
const toggleEndpoint = useSetAtom(toggleEndpointFilterAtom);
const { data: endpointsConfig } = useGetEndpointsQuery();
+ const { limit, atLimit } = useEndpointFilterLimit(selected.length);
const endpoints = useMemo(
() =>
@@ -351,8 +381,10 @@ const EndpointFacet = memo(() => {
}
checked={selected.includes(endpoint.value)}
onSelect={() => toggleEndpoint(endpoint.value)}
+ disabled={atLimit && !selected.includes(endpoint.value)}
/>
))}
+ {atLimit && limit != null && }
);
@@ -442,6 +474,7 @@ const FilterFacets = ({
* 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 } = 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;
@@ -518,6 +551,7 @@ const FilterFacets = ({
id: `endpoint:${endpoint}`,
label: (alternateName[endpoint] as string | undefined) ?? endpoint,
checked: selectedEndpoints.includes(endpoint),
+ disabled: endpointsAtLimit && !selectedEndpoints.includes(endpoint),
multiple: true,
icon: (
) : (
{
const { conversationList } =
query.endpoints == null ? {} : await getAppConfig({ baseOnly: true });
- return parseConversationListFilters(
- query,
- conversationList ?? conversationListConfigSchema.parse({}),
- );
+ return parseConversationListFilters(query, resolveConversationListLimits(conversationList));
+}
+
+/**
+ * The limits a deployment enforces on list filters, with the schema defaults standing in for
+ * an unconfigured one. The startup config publishes the same values, so the sidebar stops a
+ * selection where this parser would refuse it.
+ */
+export function resolveConversationListLimits(
+ conversationList?: TConversationListConfig,
+): TConversationListConfig {
+ return conversationList ?? conversationListConfigSchema.parse({});
}
diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts
index e54497177fc..6d0ce18142c 100644
--- a/packages/data-provider/src/config.ts
+++ b/packages/data-provider/src/config.ts
@@ -2605,6 +2605,9 @@ export type TStartupConfig = {
passkeyLoginEnabled: boolean;
/** Per-account passkey enrollment cap, from `passkeys.perUserMax`; post-login only. */
maxPasskeysPerUser?: number;
+ /** How many endpoints one chat list filter may name, from
+ * `conversationList.maxEndpointFilters`; post-login only. */
+ maxEndpointFilters?: number;
openidLabel: string;
openidImageUrl: string;
openidAutoRedirect: boolean;
From 5e7dfc1d74f7ee0e1ce57be07315705e3c79f8c8 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sun, 27 Sep 2026 02:42:55 +0200
Subject: [PATCH 13/22] test(e2e): Scenario for the endpoint filter limit
---
.../mock/scenarios/chat-filter-menu.spec.ts | 33 +++++++++++++++++++
1 file changed, 33 insertions(+)
diff --git a/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
index 2b1d4a9c3c3..976ecdfa24e 100644
--- a/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
+++ b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
@@ -299,6 +299,39 @@ test.describe('chat list properties menu', () => {
await expect(chatsRow(page, inProject)).toBeVisible({ timeout: 30000 });
});
+ test('the Endpoint facet stops at the limit the deployment publishes @scenario:endpoint-filter-stops-at-limit', async ({
+ page,
+ }) => {
+ /* The harness serves one librechat.yaml to every spec, so the published limit is set
+ * on the startup config this page reads; the route spec covers the server side. */
+ await page.route(
+ (url) => url.pathname === '/api/config',
+ async (route) => {
+ const response = await route.fetch();
+ const body = await response.json();
+ await route.fulfill({ response, json: { ...body, maxEndpointFilters: 1 } });
+ },
+ );
+ await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
+ await showSidebar(page);
+
+ await openFilterSubmenu(page);
+ await page.getByRole('menuitem', { name: /^Endpoint\b/ }).click();
+ const endpoints = page.getByRole('menu').last().getByRole('menuitemcheckbox');
+ await expect(endpoints.nth(1)).toBeVisible();
+ await endpoints.first().click();
+ await expect(endpoints.first()).toHaveAttribute('aria-checked', 'true');
+
+ await expect(endpoints.nth(1)).toHaveAttribute('aria-disabled', 'true');
+ await expect(page.getByText('Endpoint limit reached (1)')).toBeVisible();
+ await endpoints.nth(1).click({ force: true });
+ await expect(endpoints.nth(1)).toHaveAttribute('aria-checked', 'false');
+
+ await closeMenus(page);
+ await expect(trigger(page)).toHaveAttribute('aria-label', 'Filters active: 1');
+ await expect(page.getByTestId('convo-list-error')).toHaveCount(0);
+ });
+
test('a chat sent while a server-only facet is active is not added to the filtered list @scenario:live-chat-respects-active-facet', async ({
page,
}) => {
From 9b82761916f515ccec235c49d2a37983e59d2e0c Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sun, 27 Sep 2026 13:06:38 +0200
Subject: [PATCH 14/22] fix(client): Hold the endpoint filter until its limit
is known
The sidebar renders before the post-login startup config resolves, and
until then the Endpoint facet had no limit to enforce, so a selection
made in that window could pass conversationList.maxEndpointFilters.
Endpoints are now offered disabled until that config has loaded.
---
.../Conversations/ChatFilterMenu.tsx | 8 +++-
.../mock/scenarios/chat-filter-menu.spec.ts | 40 +++++++++++++++++--
2 files changed, 42 insertions(+), 6 deletions(-)
diff --git a/client/src/components/Conversations/ChatFilterMenu.tsx b/client/src/components/Conversations/ChatFilterMenu.tsx
index 92ad5dbb020..708f322bf7e 100644
--- a/client/src/components/Conversations/ChatFilterMenu.tsx
+++ b/client/src/components/Conversations/ChatFilterMenu.tsx
@@ -304,12 +304,16 @@ const DateFacet = ({ label, icon, value, onSelect }: DateFacetProps) => {
/**
* Whether the endpoint selection has reached what the server accepts in one request
* (`conversationList.maxEndpointFilters`). Past it the list request would be refused and
- * the Chats section would show its error, so further endpoints are offered disabled.
+ * the Chats section would show its error, so further endpoints are offered disabled, as
+ * they are until the post-login config that carries the limit has loaded.
*/
const useEndpointFilterLimit = (selectedCount: number) => {
const { data: startupConfig } = useGetStartupConfig();
const limit = startupConfig?.maxEndpointFilters;
- return { limit, atLimit: limit != null && selectedCount >= limit };
+ return {
+ limit,
+ atLimit: startupConfig == null || (limit != null && selectedCount >= limit),
+ };
};
const EndpointLimitNote = ({ limit }: { limit: number }) => {
diff --git a/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
index 976ecdfa24e..a7bcd6ff239 100644
--- a/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
+++ b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
@@ -303,22 +303,54 @@ test.describe('chat list properties menu', () => {
page,
}) => {
/* The harness serves one librechat.yaml to every spec, so the published limit is set
- * on the startup config this page reads; the route spec covers the server side. */
+ * on the startup config this page reads; the route spec covers the server side. The
+ * response is held so the facet is seen before the limit is known. */
+ let releaseConfig = () => {};
+ const configHeld = new Promise((resolve) => {
+ releaseConfig = resolve;
+ });
await page.route(
(url) => url.pathname === '/api/config',
async (route) => {
const response = await route.fetch();
const body = await response.json();
+ await configHeld;
await route.fulfill({ response, json: { ...body, maxEndpointFilters: 1 } });
},
);
+ /* A phone's header waits for the config before it offers the sidebar, so the
+ * unknown-limit window is only reachable on a desktop layout. */
+ const sidebarWaitsForConfig = test.info().project.name === 'mobile';
+ if (sidebarWaitsForConfig) {
+ releaseConfig();
+ }
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await showSidebar(page);
- await openFilterSubmenu(page);
- await page.getByRole('menuitem', { name: /^Endpoint\b/ }).click();
const endpoints = page.getByRole('menu').last().getByRole('menuitemcheckbox');
- await expect(endpoints.nth(1)).toBeVisible();
+ const openEndpointFacet = async () => {
+ await openFilterSubmenu(page);
+ await page.getByRole('menuitem', { name: /^Endpoint\b/ }).click();
+ await expect(endpoints.nth(1)).toBeVisible();
+ };
+
+ if (!sidebarWaitsForConfig) {
+ await openEndpointFacet();
+ await expect(endpoints.first()).toHaveAttribute('aria-disabled', 'true');
+ await endpoints.first().click({ force: true });
+ await expect(endpoints.first()).toHaveAttribute('aria-checked', 'false');
+
+ /* The arriving config rebuilds the shell, so the menu is opened afresh once it lands. */
+ await closeMenus(page);
+ const configLoaded = page.waitForResponse((response) =>
+ response.url().endsWith('/api/config'),
+ );
+ releaseConfig();
+ await configLoaded;
+ }
+
+ await openEndpointFacet();
+ await expect(endpoints.first()).not.toHaveAttribute('aria-disabled', 'true');
await endpoints.first().click();
await expect(endpoints.first()).toHaveAttribute('aria-checked', 'true');
From f35c75250d741c77882cfec340ca9365d7221c78 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sun, 27 Sep 2026 13:49:07 +0200
Subject: [PATCH 15/22] fix(client): Keep a chosen bookmark removable and a
failed config unlocked
A selected bookmark whose last chat dropped it fell out of both bookmark
lists, so only Reset could clear it; a selected tag now stays listed.
The Endpoint facet waits only while the startup config is loading, so a
config that failed no longer locks every endpoint without saying why.
---
.../Conversations/ChatFilterMenu.tsx | 20 ++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/client/src/components/Conversations/ChatFilterMenu.tsx b/client/src/components/Conversations/ChatFilterMenu.tsx
index 708f322bf7e..a875828d977 100644
--- a/client/src/components/Conversations/ChatFilterMenu.tsx
+++ b/client/src/components/Conversations/ChatFilterMenu.tsx
@@ -212,8 +212,12 @@ 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]);
+ /** A bookmark no chat carries filters the list down to nothing, unless it is already
+ * selected, where it stays so it can be turned off. */
+ const bookmarks = useMemo(
+ () => data?.filter((tag) => tag.count > 0 || tags.includes(tag.tag)) ?? [],
+ [data, tags],
+ );
if (bookmarks.length === 0) {
return (
@@ -305,15 +309,13 @@ const DateFacet = ({ label, icon, value, onSelect }: DateFacetProps) => {
* Whether the endpoint selection has reached what the server accepts in one request
* (`conversationList.maxEndpointFilters`). Past it the list request would be refused and
* the Chats section would show its error, so further endpoints are offered disabled, as
- * they are until the post-login config that carries the limit has loaded.
+ * they are while the post-login config that carries the limit is loading. A config that
+ * failed leaves the server's own validation as the only check.
*/
const useEndpointFilterLimit = (selectedCount: number) => {
- const { data: startupConfig } = useGetStartupConfig();
+ const { data: startupConfig, isLoading } = useGetStartupConfig();
const limit = startupConfig?.maxEndpointFilters;
- return {
- limit,
- atLimit: startupConfig == null || (limit != null && selectedCount >= limit),
- };
+ return { limit, atLimit: isLoading || (limit != null && selectedCount >= limit) };
};
const EndpointLimitNote = ({ limit }: { limit: number }) => {
@@ -510,7 +512,7 @@ const FilterFacets = ({
collect(
localize('com_ui_bookmarks'),
(bookmarkData ?? [])
- .filter((bookmark) => bookmark.count > 0)
+ .filter((bookmark) => bookmark.count > 0 || tags.includes(bookmark.tag))
.map((bookmark) => ({
id: `bookmark:${bookmark.tag}`,
label: bookmark.tag,
From 99e1c6a896402f9f45b5a586e9ea0a2837a6ec78 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sun, 27 Sep 2026 13:49:15 +0200
Subject: [PATCH 16/22] test(e2e): Scenario for a chosen bookmark whose count
drops to zero
---
.../mock/scenarios/chat-filter-menu.spec.ts | 86 +++++++++++++++++++
1 file changed, 86 insertions(+)
diff --git a/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
index a7bcd6ff239..b21abdbbef7 100644
--- a/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
+++ b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
@@ -468,6 +468,92 @@ test.describe('chat list properties menu', () => {
}
});
+ test('a chosen bookmark stays in the Filter submenu after its last chat drops it @scenario:selected-bookmark-stays-removable', async ({
+ page,
+ }) => {
+ const tag = `Menu tag ${randomUUID().slice(0, 8)}`;
+ const title = uniqueTitle('bookmarked');
+ const conversationId = randomUUID();
+ createdConversationIds.push(conversationId);
+ await withMongo(async (db) => {
+ const user = await db.collection('users').findOne({ email: userEmail });
+ if (!user) throw new Error(`E2E seed: user "${userEmail}" not found`);
+ const now = new Date();
+ await db.collection('conversations').insertOne({
+ conversationId,
+ title,
+ user: user._id.toString(),
+ endpoint: 'openAI',
+ isArchived: false,
+ tags: [tag],
+ createdAt: now,
+ updatedAt: now,
+ __v: 0,
+ });
+ await db.collection('conversationtags').insertOne({
+ tag,
+ user: user._id.toString(),
+ count: 1,
+ position: 0,
+ createdAt: now,
+ updatedAt: now,
+ __v: 0,
+ });
+ });
+
+ /* Only the header's bookmark menu, shown from `md` up, can drop a bookmark while this
+ * list is open, so a phone cannot reach this state and the phone project runs wide. */
+ const viewport = page.viewportSize();
+ if (isPhone(page) && viewport) {
+ await page.setViewportSize({ width: 1280, height: viewport.height });
+ }
+
+ try {
+ await page.goto(`/c/${conversationId}`, { timeout: 10000 });
+ await showSidebar(page);
+ await openFilterSubmenu(page);
+ await page.getByRole('menuitem', { name: /^Bookmarks\b/ }).click();
+ /* A choice is named by its tag and then its count. */
+ const choice = page
+ .getByRole('menu')
+ .last()
+ .getByRole('menuitemcheckbox', {
+ name: new RegExp(`^${tag} \\d+$`),
+ });
+ await choice.click();
+ await expect(choice).toHaveAttribute('aria-checked', 'true');
+ await closeMenus(page);
+ await expect(trigger(page)).toHaveAttribute('aria-label', 'Filters active: 1');
+
+ /* Removing the bookmark from its only chat takes its count to zero. */
+ await hideSidebar(page);
+ const bookmarkButton = page.locator('#bookmark-menu-button');
+ await bookmarkButton.click();
+ const [untagged] = await Promise.all([
+ page.waitForResponse(
+ (r) =>
+ r.request().method() === 'PUT' &&
+ new URL(r.url()).pathname.startsWith('/api/tags/convo/'),
+ ),
+ page.getByRole('menuitemcheckbox', { name: tag, exact: true }).click(),
+ ]);
+ expect(untagged.ok()).toBeTruthy();
+ await page.keyboard.press('Escape');
+
+ await showSidebar(page);
+ await openFilterSubmenu(page);
+ await page.getByRole('menuitem', { name: /^Bookmarks\b/ }).click();
+ await expect(choice).toHaveAttribute('aria-checked', 'true');
+ /* Once turned off it is an unused bookmark again, which the list leaves out. */
+ await choice.click();
+ await expect(choice).toHaveCount(0);
+ await closeMenus(page);
+ await expect(trigger(page)).toHaveAttribute('aria-label', 'Filter and sort chats');
+ } finally {
+ await withMongo((db) => db.collection('conversationtags').deleteMany({ tag }));
+ }
+ });
+
test('a role without bookmark access opens the Filter submenu without asking for bookmarks @scenario:bookmark-filter-quiet-without-access', async ({
browser,
baseURL,
From 6f85d9d7597a14ef449a8d78088991757abecc35 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sun, 27 Sep 2026 14:17:42 +0200
Subject: [PATCH 17/22] fix(client): Keep a chosen endpoint removable when the
deployment stops listing it
An endpoint that left the endpoints config, or a config that failed to
load, dropped out of both endpoint lists while it stayed selected, so it
kept narrowing the chats and could not be turned off. Both lists now come
from one helper that keeps the selection listed.
---
.../Conversations/ChatFilterMenu.tsx | 73 +++++++++----------
.../Conversations/__tests__/facets.spec.ts | 22 ++++++
client/src/components/Conversations/facets.ts | 15 +++-
3 files changed, 71 insertions(+), 39 deletions(-)
diff --git a/client/src/components/Conversations/ChatFilterMenu.tsx b/client/src/components/Conversations/ChatFilterMenu.tsx
index a875828d977..97a16ab5425 100644
--- a/client/src/components/Conversations/ChatFilterMenu.tsx
+++ b/client/src/components/Conversations/ChatFilterMenu.tsx
@@ -26,17 +26,6 @@ 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,
- chatFilterTagsAtom,
- chatSortAtom,
- isAlphabeticalSort,
- resetChatFiltersAtom,
- setChatFilterStatusAtom,
- sortFieldsFor,
- toggleChatFilterTagAtom,
-} from './chatFilters';
import {
createdRangeAtom,
DATE_RANGE_OPTIONS,
@@ -44,10 +33,22 @@ import {
facetFilterCountAtom,
hasAttachmentsAtom,
resetFacetsAtom,
+ selectableEndpoints,
sharedOnlyAtom,
toggleEndpointFilterAtom,
updatedRangeAtom,
} from './facets';
+import {
+ chatFilterCountAtom,
+ chatFilterStatusAtom,
+ chatFilterTagsAtom,
+ chatSortAtom,
+ isAlphabeticalSort,
+ resetChatFiltersAtom,
+ setChatFilterStatusAtom,
+ sortFieldsFor,
+ toggleChatFilterTagAtom,
+} from './chatFilters';
import { useGetConversationTags, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
import { useHasAccess, useLocalize } from '~/hooks';
@@ -342,13 +343,11 @@ const EndpointFacet = memo(() => {
const endpoints = useMemo(
() =>
- Object.keys(endpointsConfig ?? {})
- .filter((endpoint) => endpointsConfig?.[endpoint] != null)
- .map((endpoint) => ({
- value: endpoint,
- label: (alternateName[endpoint] as string | undefined) ?? endpoint,
- })),
- [endpointsConfig],
+ selectableEndpoints(endpointsConfig, selected).map((endpoint) => ({
+ value: endpoint,
+ label: (alternateName[endpoint] as string | undefined) ?? endpoint,
+ })),
+ [endpointsConfig, selected],
);
const value = useMemo(() => {
@@ -551,26 +550,24 @@ const FilterFacets = ({
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),
- disabled: endpointsAtLimit && !selectedEndpoints.includes(endpoint),
- multiple: true,
- icon: (
-
- ),
- onSelect: () => toggleEndpoint(endpoint),
- })),
+ selectableEndpoints(endpointsConfig, selectedEndpoints).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. */
diff --git a/client/src/components/Conversations/__tests__/facets.spec.ts b/client/src/components/Conversations/__tests__/facets.spec.ts
index ce597d8d6b9..0d9b89be355 100644
--- a/client/src/components/Conversations/__tests__/facets.spec.ts
+++ b/client/src/components/Conversations/__tests__/facets.spec.ts
@@ -1,4 +1,5 @@
import { createStore } from 'jotai';
+import type { TEndpointsConfig } from 'librechat-data-provider';
import {
chatFacetParamsAtom,
createdRangeAtom,
@@ -9,6 +10,7 @@ import {
rangeCutoff,
sharedOnlyAtom,
resetFacetsAtom,
+ selectableEndpoints,
toggleEndpointFilterAtom,
updatedRangeAtom,
} from '../facets';
@@ -142,3 +144,23 @@ describe('chatFacetParamsAtom', () => {
});
});
});
+
+describe('selectableEndpoints', () => {
+ const served: TEndpointsConfig = { openAI: { order: 0 }, anthropic: { order: 1 }, google: null };
+
+ it('lists the endpoints the deployment serves', () => {
+ expect(selectableEndpoints(served, [])).toEqual(['openAI', 'anthropic']);
+ });
+
+ it('keeps a selected endpoint the deployment no longer serves, so it can be turned off', () => {
+ expect(selectableEndpoints(served, ['anthropic', 'bedrock'])).toEqual([
+ 'openAI',
+ 'anthropic',
+ 'bedrock',
+ ]);
+ });
+
+ it('still lists the selection when the endpoints are unavailable', () => {
+ expect(selectableEndpoints(undefined, ['openAI'])).toEqual(['openAI']);
+ });
+});
diff --git a/client/src/components/Conversations/facets.ts b/client/src/components/Conversations/facets.ts
index 4bfaf649008..7b98ef647ad 100644
--- a/client/src/components/Conversations/facets.ts
+++ b/client/src/components/Conversations/facets.ts
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { atom, useSetAtom } from 'jotai';
-import type { ConversationListParams } from 'librechat-data-provider';
+import type { ConversationListParams, TEndpointsConfig } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
/** Coarse windows, matching the headings the list already groups chats under. */
@@ -152,3 +152,16 @@ export const resetFacetsAtom = atom(null, (_get, set) => {
set(hasAttachmentsAtom, false);
set(sharedOnlyAtom, false);
});
+
+/** The endpoints this deployment serves, plus any still selected that it no longer lists,
+ * so a chosen endpoint can always be turned off. */
+export const selectableEndpoints = (
+ endpointsConfig: TEndpointsConfig | undefined,
+ selected: string[],
+): string[] => {
+ const served = Object.keys(endpointsConfig ?? {}).filter(
+ (endpoint) => endpointsConfig?.[endpoint] != null,
+ );
+ const known = new Set(served);
+ return served.concat(selected.filter((endpoint) => !known.has(endpoint)));
+};
From 4d48f28d6294e213d96f93c6b455dd408f5a2b4b Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sun, 27 Sep 2026 14:30:03 +0200
Subject: [PATCH 18/22] docs(client): Punctuate the list membership comments
without dashes
---
client/src/utils/convos.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts
index 33cd6609892..dfa67474cb0 100644
--- a/client/src/utils/convos.ts
+++ b/client/src/utils/convos.ts
@@ -330,7 +330,7 @@ function queryListsArchived(queryKey: readonly unknown[]): boolean {
/**
* Whether a row still belongs in a variant at all, by the facets the client can decide:
* its project, whether it is archived, and the endpoint it ran on. Bookmark and search
- * membership are deliberately excluded — a search cache matches nothing client-side, so
+ * membership are deliberately excluded: a search cache matches nothing client-side, so
* judging a row that is already in one by that rule would evict every row it holds. The
* date cutoffs are left out too: `updatedAt` only moves forward and `createdAt` never
* moves, so a row that matched them keeps matching.
@@ -359,8 +359,8 @@ function conversationBelongsToListQuery(
/**
* Whether only the server can say what a variant holds after a write. Three things put it
* out of the client's reach: an order keyed on something other than last activity, which
- * these writers cannot place a row against; a search, which the server evaluates —
- * a title edit or a new message can make a row start or stop matching one; and the
+ * these writers cannot place a row against; a search, which the server evaluates,
+ * since a title edit or a new message can make a row start or stop matching one; and the
* attachment and sharing facets, whose truth lives in collections the list row does
* not carry.
*/
@@ -380,7 +380,7 @@ function queryNeedsServerReconciliation(queryKey: readonly unknown[]): boolean {
*
* `skip` is only for a variant the row provably does not belong to, by the facets the
* client decides: project, archive state, bookmarks, the endpoint it ran on, and the
- * date cutoffs it carries. Anything left to the server — attachments, sharing — is
+ * date cutoffs it carries. Anything left to the server (attachments, sharing) is
* refetched instead; skipping it silently would leave a mounted list missing a row.
*/
type ListInsertVerdict = 'insert' | 'skip' | 'refetch';
From ba0fe4d4f828a534a1bca7232d344f54618d62dc Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sun, 27 Sep 2026 15:18:20 +0200
Subject: [PATCH 19/22] fix: Publish the chat list filter limits the list route
enforces
The startup config published maxEndpointFilters from the caller's merged
config while the list route reads the deployment's base config, so a
principal override could let the sidebar allow a selection the route
refused, or disable one it accepted; and the endpoint name limit was not
published at all, so a long custom endpoint name could be selected and
turn the list into a 400. Both limits now come from one loader the list
route also uses and are published together as conversationListLimits;
the Endpoint facet leaves out names past the length limit.
---
api/server/routes/__tests__/config.spec.js | 39 ++++++++---
api/server/routes/config.js | 16 +++--
.../Conversations/ChatFilterMenu.tsx | 67 +++++++++++--------
.../Conversations/__tests__/facets.spec.ts | 7 ++
client/src/components/Conversations/facets.ts | 12 +++-
.../mock/scenarios/chat-filter-menu.spec.ts | 8 ++-
packages/api/src/conversations/filters.ts | 36 +++++-----
packages/data-provider/src/config.ts | 6 +-
8 files changed, 124 insertions(+), 67 deletions(-)
diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js
index 8059ad6ceea..3cbd4f1868b 100644
--- a/api/server/routes/__tests__/config.spec.js
+++ b/api/server/routes/__tests__/config.spec.js
@@ -1098,27 +1098,50 @@ describe('passkey enrollment cap', () => {
});
describe('chat list endpoint filter limit', () => {
- it('publishes the configured limit only after authentication', async () => {
+ it('publishes the base config limits only after authentication', async () => {
const { AppService } = require('@librechat/data-schemas');
- const appConfig = await AppService({
- config: { conversationList: { maxEndpointFilters: 3 } },
+ const baseConfig = await AppService({
+ config: { conversationList: { maxEndpointFilters: 3, maxEndpointNameLength: 40 } },
});
- mockGetAppConfig.mockResolvedValue(appConfig);
+ mockGetAppConfig.mockResolvedValue(baseConfig);
const authenticated = await request(createApp(mockUser)).get('/api/config');
expect(authenticated.status).toBe(200);
- expect(authenticated.body.maxEndpointFilters).toBe(3);
+ 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('maxEndpointFilters');
+ expect(anonymous.body).not.toHaveProperty('conversationListLimits');
});
- it('publishes the schema default for a deployment that sets none', async () => {
+ 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.maxEndpointFilters).toBe(50);
+ expect(response.body.conversationListLimits).toEqual({
+ maxEndpointFilters: 50,
+ maxEndpointNameLength: 128,
+ });
});
});
diff --git a/api/server/routes/config.js b/api/server/routes/config.js
index 280157597ec..8522fde4716 100644
--- a/api/server/routes/config.js
+++ b/api/server/routes/config.js
@@ -19,7 +19,7 @@ const {
isPasskeyEnabled,
buildPreLoginInterface,
resolveMaxPasskeysPerUser,
- resolveConversationListLimits,
+ loadConversationListLimits,
} = require('@librechat/api');
const {
DEFAULT_MCP_APP_CSP_LIMITS,
@@ -162,8 +162,6 @@ function buildPostLoginPayload(appConfig) {
isEnabled(process.env.ALLOW_ACCOUNT_DELETION),
allowEmailChange: resolveEmailChangeSettings(appConfig?.emailChange).enabled,
maxPasskeysPerUser: resolveMaxPasskeysPerUser(appConfig?.passkeys),
- maxEndpointFilters: resolveConversationListLimits(appConfig?.conversationList)
- .maxEndpointFilters,
};
return payload;
@@ -253,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,
);
@@ -298,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 97a16ab5425..0e3fff896de 100644
--- a/client/src/components/Conversations/ChatFilterMenu.tsx
+++ b/client/src/components/Conversations/ChatFilterMenu.tsx
@@ -307,16 +307,20 @@ const DateFacet = ({ label, icon, value, onSelect }: DateFacetProps) => {
};
/**
- * Whether the endpoint selection has reached what the server accepts in one request
- * (`conversationList.maxEndpointFilters`). Past it the list 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 limit is loading. A config that
- * failed leaves the server's own validation as the only check.
+ * 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 limit = startupConfig?.maxEndpointFilters;
- return { limit, atLimit: isLoading || (limit != null && selectedCount >= limit) };
+ const limits = startupConfig?.conversationListLimits;
+ const limit = limits?.maxEndpointFilters;
+ return {
+ limit,
+ maxNameLength: limits?.maxEndpointNameLength,
+ atLimit: isLoading || (limit != null && selectedCount >= limit),
+ };
};
const EndpointLimitNote = ({ limit }: { limit: number }) => {
@@ -339,15 +343,15 @@ const EndpointFacet = memo(() => {
const selected = useAtomValue(endpointFilterAtom);
const toggleEndpoint = useSetAtom(toggleEndpointFilterAtom);
const { data: endpointsConfig } = useGetEndpointsQuery();
- const { limit, atLimit } = useEndpointFilterLimit(selected.length);
+ const { limit, maxNameLength, atLimit } = useEndpointFilterLimit(selected.length);
const endpoints = useMemo(
() =>
- selectableEndpoints(endpointsConfig, selected).map((endpoint) => ({
+ selectableEndpoints(endpointsConfig, selected, maxNameLength).map((endpoint) => ({
value: endpoint,
label: (alternateName[endpoint] as string | undefined) ?? endpoint,
})),
- [endpointsConfig, selected],
+ [endpointsConfig, selected, maxNameLength],
);
const value = useMemo(() => {
@@ -479,7 +483,9 @@ const FilterFacets = ({
* 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 } = useEndpointFilterLimit(selectedEndpoints.length);
+ 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;
@@ -550,24 +556,26 @@ const FilterFacets = ({
collect(
localize('com_ui_endpoint'),
- selectableEndpoints(endpointsConfig, selectedEndpoints).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),
- })),
+ 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. */
@@ -610,6 +618,7 @@ const FilterFacets = ({
endpointsConfig,
selectedEndpoints,
endpointsAtLimit,
+ endpointNameLimit,
toggleEndpoint,
hasAttachments,
setHasAttachments,
diff --git a/client/src/components/Conversations/__tests__/facets.spec.ts b/client/src/components/Conversations/__tests__/facets.spec.ts
index 0d9b89be355..db2154fd381 100644
--- a/client/src/components/Conversations/__tests__/facets.spec.ts
+++ b/client/src/components/Conversations/__tests__/facets.spec.ts
@@ -160,6 +160,13 @@ describe('selectableEndpoints', () => {
]);
});
+ it('leaves out a served name longer than the list route accepts, unless it is selected', () => {
+ const long = 'x'.repeat(12);
+ const withLong: TEndpointsConfig = { openAI: { order: 0 }, [long]: { order: 1 } };
+ expect(selectableEndpoints(withLong, [], 10)).toEqual(['openAI']);
+ expect(selectableEndpoints(withLong, [long], 10)).toEqual(['openAI', long]);
+ });
+
it('still lists the selection when the endpoints are unavailable', () => {
expect(selectableEndpoints(undefined, ['openAI'])).toEqual(['openAI']);
});
diff --git a/client/src/components/Conversations/facets.ts b/client/src/components/Conversations/facets.ts
index 7b98ef647ad..a9797eb8d88 100644
--- a/client/src/components/Conversations/facets.ts
+++ b/client/src/components/Conversations/facets.ts
@@ -153,14 +153,20 @@ export const resetFacetsAtom = atom(null, (_get, set) => {
set(sharedOnlyAtom, false);
});
-/** The endpoints this deployment serves, plus any still selected that it no longer lists,
- * so a chosen endpoint can always be turned off. */
+/**
+ * The endpoints this deployment serves whose names the list route accepts
+ * (`conversationList.maxEndpointNameLength`), plus any still selected that are not among
+ * them, so a chosen endpoint can always be turned off.
+ */
export const selectableEndpoints = (
endpointsConfig: TEndpointsConfig | undefined,
selected: string[],
+ maxNameLength?: number,
): string[] => {
const served = Object.keys(endpointsConfig ?? {}).filter(
- (endpoint) => endpointsConfig?.[endpoint] != null,
+ (endpoint) =>
+ endpointsConfig?.[endpoint] != null &&
+ (maxNameLength == null || endpoint.length <= maxNameLength),
);
const known = new Set(served);
return served.concat(selected.filter((endpoint) => !known.has(endpoint)));
diff --git a/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
index b21abdbbef7..347b2005c90 100644
--- a/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
+++ b/e2e/specs/mock/scenarios/chat-filter-menu.spec.ts
@@ -315,7 +315,13 @@ test.describe('chat list properties menu', () => {
const response = await route.fetch();
const body = await response.json();
await configHeld;
- await route.fulfill({ response, json: { ...body, maxEndpointFilters: 1 } });
+ await route.fulfill({
+ response,
+ json: {
+ ...body,
+ conversationListLimits: { ...body.conversationListLimits, maxEndpointFilters: 1 },
+ },
+ });
},
);
/* A phone's header waits for the config before it offers the sidebar, so the
diff --git a/packages/api/src/conversations/filters.ts b/packages/api/src/conversations/filters.ts
index 6de016da9a4..3757355b9cc 100644
--- a/packages/api/src/conversations/filters.ts
+++ b/packages/api/src/conversations/filters.ts
@@ -21,10 +21,11 @@ export interface ConversationListFilterResult {
error?: string;
}
-/** Reads the deployment config; injected so this module does not reach for the app's. */
+/** Reads the deployment config; injected so this module does not reach for the app's. A
+ * config not yet loaded reads as none, so the schema defaults apply. */
export type ConversationListConfigReader = (options: {
baseOnly: true;
-}) => Promise<{ conversationList?: TConversationListConfig }>;
+}) => Promise<{ conversationList?: TConversationListConfig } | undefined>;
const firstValue = (value: unknown): unknown => (Array.isArray(value) ? value[0] : value);
@@ -193,27 +194,30 @@ export function parseConversationListFilters(
}
/**
- * The list route's entry point. The limits are deployment-level, so they come from the
- * base config rather than the caller's merged config, and only a request naming endpoints
- * reads it at all: the limits bound nothing else, and an unfiltered sidebar request should
- * not wait on, or fail with, a config cache it does not use.
+ * The list route's entry point. Only a request naming endpoints reads the limits at all:
+ * they bound nothing else, and an unfiltered sidebar request should not wait on, or fail
+ * with, a config cache it does not use.
*/
export async function resolveConversationListFilters(
query: Request['query'],
getAppConfig: ConversationListConfigReader,
): Promise {
- const { conversationList } =
- query.endpoints == null ? {} : await getAppConfig({ baseOnly: true });
- return parseConversationListFilters(query, resolveConversationListLimits(conversationList));
+ const limits =
+ query.endpoints == null
+ ? conversationListConfigSchema.parse({})
+ : await loadConversationListLimits(getAppConfig);
+ return parseConversationListFilters(query, limits);
}
/**
- * The limits a deployment enforces on list filters, with the schema defaults standing in for
- * an unconfigured one. The startup config publishes the same values, so the sidebar stops a
- * selection where this parser would refuse it.
+ * The limits a deployment enforces on list filters. They are deployment-level, so they come
+ * from the base config rather than a caller's merged one, with the schema defaults standing
+ * in for an unconfigured deployment. The startup config publishes what this returns, so the
+ * sidebar stops a selection exactly where the list route would refuse it.
*/
-export function resolveConversationListLimits(
- conversationList?: TConversationListConfig,
-): TConversationListConfig {
- return conversationList ?? conversationListConfigSchema.parse({});
+export async function loadConversationListLimits(
+ getAppConfig: ConversationListConfigReader,
+): Promise {
+ const config = await getAppConfig({ baseOnly: true });
+ return config?.conversationList ?? conversationListConfigSchema.parse({});
}
diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts
index 6d0ce18142c..9a3c38de8b6 100644
--- a/packages/data-provider/src/config.ts
+++ b/packages/data-provider/src/config.ts
@@ -2605,9 +2605,9 @@ export type TStartupConfig = {
passkeyLoginEnabled: boolean;
/** Per-account passkey enrollment cap, from `passkeys.perUserMax`; post-login only. */
maxPasskeysPerUser?: number;
- /** How many endpoints one chat list filter may name, from
- * `conversationList.maxEndpointFilters`; post-login only. */
- maxEndpointFilters?: number;
+ /** What one chat list filter request may name (`conversationList`), resolved from the
+ * deployment's base config as the list route resolves it; post-login only. */
+ conversationListLimits?: TConversationListConfig;
openidLabel: string;
openidImageUrl: string;
openidAutoRedirect: boolean;
From 7a6cb82a15959ba8bf06a02db47e220607554cc7 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sun, 27 Sep 2026 15:36:31 +0200
Subject: [PATCH 20/22] fix(client): Keep a deleted bookmark removable from the
filter
A selected bookmark that was deleted no longer came back from the tags
query, so neither bookmark list could show it and only Reset cleared it.
Both lists now come from one helper that keeps every selected bookmark.
---
.../Conversations/ChatFilterMenu.tsx | 48 ++++++++-----------
.../__tests__/chatFilters.spec.ts | 37 +++++++++++++-
.../components/Conversations/chatFilters.ts | 25 +++++++++-
3 files changed, 81 insertions(+), 29 deletions(-)
diff --git a/client/src/components/Conversations/ChatFilterMenu.tsx b/client/src/components/Conversations/ChatFilterMenu.tsx
index 0e3fff896de..91262abe891 100644
--- a/client/src/components/Conversations/ChatFilterMenu.tsx
+++ b/client/src/components/Conversations/ChatFilterMenu.tsx
@@ -26,6 +26,18 @@ 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,
+ chatFilterTagsAtom,
+ chatSortAtom,
+ isAlphabeticalSort,
+ resetChatFiltersAtom,
+ selectableBookmarks,
+ setChatFilterStatusAtom,
+ sortFieldsFor,
+ toggleChatFilterTagAtom,
+} from './chatFilters';
import {
createdRangeAtom,
DATE_RANGE_OPTIONS,
@@ -38,17 +50,6 @@ import {
toggleEndpointFilterAtom,
updatedRangeAtom,
} from './facets';
-import {
- chatFilterCountAtom,
- chatFilterStatusAtom,
- chatFilterTagsAtom,
- chatSortAtom,
- isAlphabeticalSort,
- resetChatFiltersAtom,
- setChatFilterStatusAtom,
- sortFieldsFor,
- toggleChatFilterTagAtom,
-} from './chatFilters';
import { useGetConversationTags, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
import { useHasAccess, useLocalize } from '~/hooks';
@@ -213,12 +214,7 @@ const BookmarkChoices = memo(() => {
const toggleTag = useSetAtom(toggleChatFilterTagAtom);
const { data } = useGetConversationTags();
- /** A bookmark no chat carries filters the list down to nothing, unless it is already
- * selected, where it stays so it can be turned off. */
- const bookmarks = useMemo(
- () => data?.filter((tag) => tag.count > 0 || tags.includes(tag.tag)) ?? [],
- [data, tags],
- );
+ const bookmarks = useMemo(() => selectableBookmarks(data, tags), [data, tags]);
if (bookmarks.length === 0) {
return (
@@ -516,16 +512,14 @@ const FilterFacets = ({
if (showBookmarks) {
collect(
localize('com_ui_bookmarks'),
- (bookmarkData ?? [])
- .filter((bookmark) => bookmark.count > 0 || tags.includes(bookmark.tag))
- .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),
- })),
+ 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),
+ })),
);
}
diff --git a/client/src/components/Conversations/__tests__/chatFilters.spec.ts b/client/src/components/Conversations/__tests__/chatFilters.spec.ts
index a4516e50187..a61ea659c31 100644
--- a/client/src/components/Conversations/__tests__/chatFilters.spec.ts
+++ b/client/src/components/Conversations/__tests__/chatFilters.spec.ts
@@ -1,5 +1,11 @@
import { createStore } from 'jotai';
-import { chatFilterCountAtom, chatFilterStatusAtom, chatFilterTagsAtom } from '../chatFilters';
+import type { TConversationTag } from 'librechat-data-provider';
+import {
+ chatFilterCountAtom,
+ chatFilterStatusAtom,
+ chatFilterTagsAtom,
+ selectableBookmarks,
+} from '../chatFilters';
describe('chatFilterCountAtom', () => {
it('counts nothing while every property is at its default', () => {
@@ -19,3 +25,32 @@ describe('chatFilterCountAtom', () => {
expect(store.get(chatFilterCountAtom)).toBe(2);
});
});
+
+describe('selectableBookmarks', () => {
+ const tag = (name: string, count: number): TConversationTag => ({
+ _id: name,
+ user: 'user',
+ tag: name,
+ count,
+ position: 0,
+ createdAt: '',
+ updatedAt: '',
+ });
+
+ it('offers the bookmarks some chat carries', () => {
+ const choices = selectableBookmarks([tag('work', 2), tag('unused', 0)], []);
+ expect(choices.map((choice) => choice.tag)).toEqual(['work']);
+ });
+
+ it('keeps a selected bookmark no chat carries any more', () => {
+ const choices = selectableBookmarks([tag('work', 2), tag('unused', 0)], ['unused']);
+ expect(choices.map((choice) => choice.tag)).toEqual(['work', 'unused']);
+ });
+
+ it('keeps a selected bookmark that was deleted', () => {
+ expect(selectableBookmarks([tag('work', 2)], ['gone'])).toEqual([
+ tag('work', 2),
+ { tag: 'gone', count: 0 },
+ ]);
+ });
+});
diff --git a/client/src/components/Conversations/chatFilters.ts b/client/src/components/Conversations/chatFilters.ts
index 4c671f96a03..b4770235e03 100644
--- a/client/src/components/Conversations/chatFilters.ts
+++ b/client/src/components/Conversations/chatFilters.ts
@@ -1,5 +1,5 @@
import { atom } from 'jotai';
-import type { ConversationListParams } from 'librechat-data-provider';
+import type { ConversationListParams, TConversationTag } from 'librechat-data-provider';
import { createStorageAtom } from '~/store/jotai-utils';
/** Which slice of the user's chats the sidebar list is showing. */
@@ -113,3 +113,26 @@ export const toggleChatFilterTagAtom = atom(null, (get, set, tag: string) => {
tags.includes(tag) ? tags.filter((current) => current !== tag) : [...tags, tag],
);
});
+
+export type BookmarkChoice = Pick;
+
+/**
+ * The bookmarks worth offering as filters: those some chat carries, since one no chat
+ * carries filters the list down to nothing, plus every selected one, including a bookmark
+ * no chat carries any more or one that was deleted, so a chosen bookmark can always be
+ * turned off.
+ */
+export const selectableBookmarks = (
+ bookmarks: TConversationTag[] | undefined,
+ selected: string[],
+): BookmarkChoice[] => {
+ const known = new Set();
+ const listed: BookmarkChoice[] = [];
+ for (const bookmark of bookmarks ?? []) {
+ known.add(bookmark.tag);
+ if (bookmark.count > 0 || selected.includes(bookmark.tag)) {
+ listed.push(bookmark);
+ }
+ }
+ return listed.concat(selected.filter((tag) => !known.has(tag)).map((tag) => ({ tag, count: 0 })));
+};
From c4bb4c285cd139a08f41f80f0f989b77c618a86c Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sun, 27 Sep 2026 16:18:42 +0200
Subject: [PATCH 21/22] fix(client): Offer a retry when a project's chats fail
to load
Chats lists only chats outside any project, so a project whose own chat
request failed hid its chats behind the empty state. The project now says
its chats could not load and offers a retry.
---
.../Conversations/ProjectsSection.tsx | 19 ++++++++++++++++++-
1 file changed, 18 insertions(+), 1 deletion(-)
diff --git a/client/src/components/Conversations/ProjectsSection.tsx b/client/src/components/Conversations/ProjectsSection.tsx
index 554f9492450..108bde61fd0 100644
--- a/client/src/components/Conversations/ProjectsSection.tsx
+++ b/client/src/components/Conversations/ProjectsSection.tsx
@@ -89,7 +89,7 @@ const ProjectChatsInline = memo(function ProjectChatsInline({
);
/** Collapse keeps its children mounted, so without this every project row in
* the sidebar would fetch its chats on load whether or not it is open. */
- const { data, isLoading } = useConversationsInfiniteQuery(
+ const { data, isLoading, isError, refetch } = useConversationsInfiniteQuery(
{ projectId, sortBy: 'updatedAt', sortDirection: 'desc' },
{ staleTime: 30000, cacheTime: 300000, enabled: expanded },
);
@@ -115,6 +115,23 @@ const ProjectChatsInline = memo(function ProjectChatsInline({
);
}
+ /** Chats scopes itself to chats outside any project, so a project whose own list failed
+ * says so and offers a retry rather than reading as empty. */
+ if (isError && conversations.length === 0) {
+ return (
+