- {hasAccessToBookmarks && (
- <>
-
-
-
- {localize('com_ui_bookmarks')}
-
-
-
- >
- )}
-
-
- resetFilters()}
- className={cn(itemClassName, 'text-text-secondary aria-disabled:opacity-50')}
- data-testid="chat-filter-reset"
+ }
+ label={localize('com_ui_show')}
+ value={statusOption ? localize(statusOption.label) : ''}
+ testId="chat-filter-show"
+ >
+
+
+ {localize('com_ui_show')}
+
+ {STATUS_OPTIONS.map((option) => (
+ setStatus(option.value)}
+ />
+ ))}
+
+
+
+ }
+ label={localize('com_ui_sort')}
+ value={localize(SORT_OPTIONS[sort.field].label)}
+ valueIcon={}
+ testId="chat-filter-sort"
+ >
+
+
+ {localize('com_ui_sort_chats_by')}
+
+ {sortFieldsFor(status).map((field) => (
+ selectField(field)}
+ />
+ ))}
+
+
+
+
+
+
+ {localize('com_ui_sort_order')}
+
+ {directionOptions.map((option) => (
+ }
+ checked={sort.direction === option.value}
+ onSelect={() => setSort({ field: sort.field, direction: option.value })}
+ />
+ ))}
+
+
+
+ }
+ label={localize('com_ui_filter')}
+ value={filterValue}
+ testId="chat-filter-facets"
+ initialFocus={facetSearchRef}
>
-
- {localize('com_ui_clear_filters')}
-
+
+
);
diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx
index c02df43424f..0d09ec07664 100644
--- a/client/src/components/Conversations/Conversations.tsx
+++ b/client/src/components/Conversations/Conversations.tsx
@@ -2,11 +2,19 @@ import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react';
import { useDrop } from 'react-dnd';
import throttle from 'lodash/throttle';
import { useRecoilValue } from 'recoil';
-import { ChevronDown } from 'lucide-react';
import { useAtomValue, useSetAtom } from 'jotai';
import { List, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
-import { Spinner, useMediaQuery, buttonVariants } from '@librechat/client';
+import { Button, EmptyState, Spinner, useMediaQuery, buttonVariants } from '@librechat/client';
+import {
+ Archive,
+ ChevronDown,
+ MessageSquareDashed,
+ MessageSquareOff,
+ SearchX,
+ TriangleAlert,
+} from 'lucide-react';
import type { TConversation } from 'librechat-data-provider';
+import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import type { ConversationDragItem } from './dnd';
import {
@@ -25,6 +33,7 @@ import {
useUnpinDroppedConversation,
} from './dnd';
import { useLocalize, TranslationKeys, useElementSize, useOuterScrollWindow } from '~/hooks';
+import { facetFilterCountAtom, resetFacetsAtom } from './facets';
import { groupConversations, cn } from '~/utils';
import { useActiveJobs } from '~/data-provider';
import Convo from './Convo';
@@ -64,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 {
@@ -102,9 +115,9 @@ const LoadingSpinner = memo(() => {
const localize = useLocalize();
return (
-
-
- {localize('com_ui_loading')}
+
+
+ {localize('com_ui_loading')}
);
});
@@ -163,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}
@@ -195,6 +210,7 @@ const Conversations: FC = ({
onRetry,
scrollViewport,
scrollContent,
+ accountHasProjects = false,
}) => {
const localize = useLocalize();
const search = useRecoilValue(store.search);
@@ -202,7 +218,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 +522,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 +536,28 @@ 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, 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) {
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;
+ } 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 narrowed states are a single line under the glyph. */
+ const isUntouched = emptyLabel === 'com_ui_no_chats';
let body: ReactNode = (
);
}
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 (
+