Refactor Chat Input File Table Headers to Use SortFilterHeader Component

- Replaced button-based sorting headers in the Chat Input Files Table with a new SortFilterHeader component for better code organization and consistency.
- Updated the header for filename, updatedAt, and bytes columns to utilize the new component.

Enhance Navigation Component with Skeleton Loading States

- Added Skeleton loading states to the Nav component for better user experience during data fetching.
- Updated Suspense fallbacks for AgentMarketplaceButton and BookmarkNav components to display Skeletons.

Refactor Avatar Component for Improved UI

- Enhanced the Avatar component by adding a Label for drag-and-drop functionality.
- Improved styling and structure for the file upload area.

Update Shared Links Component for Better Error Handling and Sorting

- Improved error handling in the Shared Links component for fetching next pages and deleting shared links.
- Simplified the header rendering for sorting columns and added sorting functionality to the title and createdAt columns.

Refactor Archived Chats Component

- Merged ArchivedChats and ArchivedChatsTable components into a single ArchivedChats component for better maintainability.
- Implemented sorting and searching functionality with debouncing for improved performance.
- Enhanced the UI with better loading states and error handling.

Update DataTable Component for Sorting Icons

- Added sorting icons (ChevronUp, ChevronDown, ChevronsUpDown) to the DataTable headers for better visual feedback on sorting state.

Localization Updates

- Updated translation.json to fix missing translations and improve existing ones for better user experience.
This commit is contained in:
Marco Beretta 2025-09-08 23:24:47 +02:00
parent ae74ede48a
commit 7252b83235
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
9 changed files with 428 additions and 461 deletions

View file

@ -61,16 +61,7 @@ export const columns: ColumnDef<TFile>[] = [
accessorKey: 'filename',
header: ({ column }) => {
const localize = useLocalize();
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
{localize('com_ui_name')}
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
);
return <SortFilterHeader column={column} title={localize('com_ui_name')} />;
},
cell: ({ row }) => {
const file = row.original;
@ -100,16 +91,7 @@ export const columns: ColumnDef<TFile>[] = [
accessorKey: 'updatedAt',
header: ({ column }) => {
const localize = useLocalize();
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
>
{localize('com_ui_date')}
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
);
return <SortFilterHeader column={column} title={localize('com_ui_date')} />;
},
cell: ({ row }) => {
const isSmallScreen = useMediaQuery('(max-width: 768px)');
@ -197,16 +179,7 @@ export const columns: ColumnDef<TFile>[] = [
accessorKey: 'bytes',
header: ({ column }) => {
const localize = useLocalize();
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
{localize('com_ui_size')}
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
);
return <SortFilterHeader column={column} title={localize('com_ui_size')} />;
},
cell: ({ row }) => {
const suffix = ' MB';

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { useMediaQuery } from '@librechat/client';
import { Skeleton, useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { ConversationListResponse } from 'librechat-data-provider';
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
@ -158,13 +158,12 @@ const Nav = memo(
const headerButtons = useMemo(
() => (
<>
<Suspense fallback={null}>
<Suspense fallback={<Skeleton className="h-10 w-10 rounded-xl" />}>
<AgentMarketplaceButton isSmallScreen={isSmallScreen} toggleNav={toggleNavVisible} />
</Suspense>
{hasAccessToBookmarks && (
<>
<div className="mt-1.5" />
<Suspense fallback={null}>
<Suspense fallback={<Skeleton className="h-10 w-10 rounded-xl" />}>
<BookmarkNav tags={tags} setTags={setTags} isSmallScreen={isSmallScreen} />
</Suspense>
</>
@ -229,7 +228,7 @@ const Nav = memo(
isSearchLoading={isSearchLoading}
/>
</div>
<Suspense fallback={null}>
<Suspense fallback={<Skeleton className="mt-1 h-12 w-full rounded-xl" />}>
<AccountSettings />
</Suspense>
</nav>

View file

@ -4,6 +4,7 @@ import AvatarEditor from 'react-avatar-editor';
import { FileImage, RotateCw, Upload } from 'lucide-react';
import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider';
import {
Label,
Slider,
Button,
Spinner,
@ -199,25 +200,27 @@ function Avatar() {
</Button>
</>
) : (
<div
className="flex h-64 w-11/12 flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-transparent dark:border-gray-600"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<FileImage className="mb-4 size-12 text-gray-400" />
<p className="mb-2 text-center text-sm text-gray-500 dark:text-gray-400">
{localize('com_ui_drag_drop')}
</p>
<Button variant="secondary" onClick={openFileDialog}>
<div className="flex w-full flex-col items-center space-y-4">
<div
className="flex h-64 w-11/12 flex-col items-center justify-center rounded-lg border-2 border-dashed border-border-light bg-transparent"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<FileImage className="mb-4 size-12 text-text-tertiary" />
<Label className="mb-2 px-2 text-center text-sm text-text-tertiary">
{localize('com_ui_drag_drop_image')}
</Label>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".png, .jpg, .jpeg"
onChange={handleFileChange}
/>
</div>
<Button variant="secondary" onClick={openFileDialog} className="w-11/12">
{localize('com_ui_select_file')}
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".png, .jpg, .jpeg"
onChange={handleFileChange}
/>
</div>
)}
</div>

View file

@ -1,8 +1,8 @@
import { useCallback, useState, useMemo, useEffect } from 'react';
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { Link } from 'react-router-dom';
import { TrashIcon, MessageSquare, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import { TrashIcon, MessageSquare } from 'lucide-react';
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
import {
OGDialog,
@ -20,15 +20,13 @@ import {
Label,
} from '@librechat/client';
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
import { formatDate } from '~/utils';
import store from '~/store';
const PAGE_SIZE = 25;
const DEFAULT_PARAMS: SharedLinksListParams = {
pageSize: PAGE_SIZE,
pageSize: 25,
isPublic: true,
sortBy: 'createdAt',
sortDirection: 'desc',
@ -44,16 +42,33 @@ export default function SharedLinks() {
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const prevSortRef = useRef({
sortBy: DEFAULT_PARAMS.sortBy,
sortDirection: DEFAULT_PARAMS.sortDirection,
});
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
useSharedLinksQuery(queryParams, {
enabled: isOpen,
staleTime: 0,
staleTime: 30 * 1000,
cacheTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: false,
keepPreviousData: false,
});
useEffect(() => {
if (!isOpen) return;
const { sortBy, sortDirection } = queryParams;
const prevSort = prevSortRef.current;
if (sortBy !== prevSort.sortBy || sortDirection !== prevSort.sortDirection) {
refetch();
prevSortRef.current = { sortBy, sortDirection };
}
}, [queryParams, isOpen, refetch]);
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
setQueryParams((prev) => ({
...prev,
@ -71,7 +86,7 @@ export default function SharedLinks() {
}, []);
const debouncedFilterChange = useMemo(
() => debounce(handleFilterChange, 300),
() => debounce(handleFilterChange, 500), // Increased debounce time to 500ms
[handleFilterChange],
);
@ -134,7 +149,7 @@ export default function SharedLinks() {
} catch (error) {
console.error('Failed to delete shared links:', error);
showToast({
message: localize('com_ui_bulk_delete_error'),
message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR,
});
}
@ -146,8 +161,17 @@ export default function SharedLinks() {
if (hasNextPage !== true || isFetchingNextPage) {
return;
}
await fetchNextPage();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
try {
await fetchNextPage();
} catch (error) {
console.error('Failed to fetch next page:', error);
showToast({
message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR,
});
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage, showToast, localize]);
const confirmDelete = useCallback(() => {
if (deleteRow) {
@ -160,28 +184,7 @@ export default function SharedLinks() {
() => [
{
accessorKey: 'title',
header: () => {
const isSorted = queryParams.sortBy === 'title';
const sortDirection = queryParams.sortDirection;
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() =>
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
>
{localize('com_ui_name')}
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
</Button>
);
},
header: () => <span className="text-xs sm:text-sm">{localize('com_ui_name')}</span>,
cell: ({ row }) => {
const { title, shareId } = row.original;
return (
@ -201,36 +204,17 @@ export default function SharedLinks() {
meta: {
size: '35%',
mobileSize: '50%',
enableSorting: true,
},
},
{
accessorKey: 'createdAt',
header: () => {
const isSorted = queryParams.sortBy === 'createdAt';
const sortDirection = queryParams.sortDirection;
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() =>
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
>
{localize('com_ui_date')}
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
</Button>
);
},
header: () => <span className="text-xs sm:text-sm">{localize('com_ui_date')}</span>,
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
meta: {
size: '10%',
mobileSize: '20%',
enableSorting: true,
},
},
{
@ -243,6 +227,7 @@ export default function SharedLinks() {
meta: {
size: '7%',
mobileSize: '25%',
enableSorting: false,
},
cell: ({ row }) => (
<div className="flex items-center gap-2">
@ -281,22 +266,17 @@ export default function SharedLinks() {
),
},
],
[isSmallScreen, localize, queryParams, handleSort],
[isSmallScreen, localize],
);
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_shared_links')}</div>
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
<Button variant="outline">{localize('com_ui_manage')}</Button>
</OGDialogTrigger>
<OGDialogContent
title={localize('com_nav_my_files')}
className="w-11/12 max-w-5xl bg-background text-text-primary shadow-2xl"
>
<OGDialogContent className="w-11/12 max-w-5xl">
<OGDialogHeader>
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
</OGDialogHeader>
@ -312,7 +292,10 @@ export default function SharedLinks() {
onFilterChange={debouncedFilterChange}
filterValue={queryParams.search}
isLoading={isLoading}
enableSearch={isSearchEnabled}
enableSearch={!!isSearchEnabled}
onSortChange={handleSort}
sortBy={queryParams.sortBy}
sortDirection={queryParams.sortDirection}
/>
</OGDialogContent>
</OGDialog>
@ -320,7 +303,7 @@ export default function SharedLinks() {
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_shared_link')}
className="max-w-[450px]"
className="w-11/12 max-w-md"
main={
<>
<div className="flex w-full flex-col items-center gap-2">

View file

@ -1,11 +1,275 @@
import { useState } from 'react';
import { OGDialogTemplate, OGDialog, OGDialogTrigger, Button } from '@librechat/client';
import ArchivedChatsTable from './ArchivedChatsTable';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { TrashIcon, ArchiveRestore } from 'lucide-react';
import {
Button,
OGDialog,
OGDialogTrigger,
OGDialogTemplate,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
Label,
TooltipAnchor,
Spinner,
DataTable,
useToastContext,
useMediaQuery,
} from '@librechat/client';
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
import {
useArchiveConvoMutation,
useConversationsInfiniteQuery,
useDeleteConversationMutation,
} from '~/data-provider';
import { MinimalIcon } from '~/components/Endpoints';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
import { formatDate } from '~/utils';
import store from '~/store';
export default function ArchivedChats() {
const DEFAULT_PARAMS: ConversationListParams = {
isArchived: true,
sortBy: 'createdAt',
sortDirection: 'desc',
search: '',
};
type SortField = 'title' | 'createdAt';
export default function ArchivedChatsTable() {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { showToast } = useToastContext();
const isSearchEnabled = useRecoilValue(store.search);
const [isOpen, setIsOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [deleteRow, setDeleteRow] = useState<TConversation | null>(null);
const [unarchivingId, setUnarchivingId] = useState<string | null>(null);
const prevSortRef = useRef({
sortBy: DEFAULT_PARAMS.sortBy,
sortDirection: DEFAULT_PARAMS.sortDirection,
});
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
const [searchInput, setSearchInput] = useState('');
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
useConversationsInfiniteQuery(queryParams, {
enabled: isOpen,
staleTime: 30 * 1000,
cacheTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: false,
keepPreviousData: false,
});
const handleSort = useCallback((field: string, direction: 'asc' | 'desc') => {
setQueryParams((prev) => ({
...prev,
sortBy: field as SortField,
sortDirection: direction,
}));
}, []);
// Trigger refetch when sort parameters change
useEffect(() => {
if (!isOpen) return; // Only refetch if dialog is open
const { sortBy, sortDirection } = queryParams;
const prevSort = prevSortRef.current;
if (sortBy !== prevSort.sortBy || sortDirection !== prevSort.sortDirection) {
console.log('Sort changed, refetching...', { from: prevSort, to: { sortBy, sortDirection } });
refetch();
prevSortRef.current = { sortBy, sortDirection };
}
}, [queryParams, isOpen, refetch]);
const debouncedApplySearch = useMemo(
() =>
debounce((value: string) => {
setQueryParams((prev) => ({
...prev,
search: encodeURIComponent(value.trim()),
}));
}, 500), // Increased debounce time to 500ms for better UX
[],
);
const onFilterChange = useCallback(
(value: string) => {
setSearchInput(value);
debouncedApplySearch(value);
},
[debouncedApplySearch],
);
useEffect(() => {
return () => {
debouncedApplySearch.cancel();
};
}, [debouncedApplySearch]);
const allConversations = useMemo(() => {
if (!data?.pages) return [];
return data.pages.flatMap((page) => page?.conversations?.filter(Boolean) ?? []);
}, [data?.pages]);
const unarchiveMutation = useArchiveConvoMutation({
onSuccess: async () => {
await refetch();
},
onError: () => {
showToast({
message: localize('com_ui_unarchive_error'),
severity: NotificationSeverity.ERROR,
});
},
});
const deleteMutation = useDeleteConversationMutation({
onSuccess: async () => {
showToast({
message: localize('com_ui_archived_conversation_delete_success'),
severity: NotificationSeverity.SUCCESS,
});
setIsDeleteOpen(false);
await refetch();
},
onError: () => {
showToast({
message: localize('com_ui_archive_delete_error'),
severity: NotificationSeverity.ERROR,
});
},
});
const handleFetchNextPage = useCallback(async () => {
if (!hasNextPage || isFetchingNextPage) return;
try {
await fetchNextPage();
} catch (error) {
console.error('Failed to fetch next page:', error);
showToast({
message: localize('com_ui_unarchive_error'),
severity: NotificationSeverity.ERROR,
});
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage, showToast, localize]);
const confirmDelete = useCallback(() => {
if (!deleteRow?.conversationId) return;
deleteMutation.mutate({ conversationId: deleteRow.conversationId });
}, [deleteMutation, deleteRow]);
const { sortBy, sortDirection } = queryParams;
const columns = useMemo(
() => [
{
accessorKey: 'title',
header: () => (
<span className="text-xs sm:text-sm">{localize('com_nav_archive_name')}</span>
),
cell: ({ row }) => {
const { conversationId, title } = row.original;
return (
<button
type="button"
className="flex items-center gap-2 truncate"
onClick={() => window.open(`/c/${conversationId}`, '_blank')}
>
<MinimalIcon
endpoint={row.original.endpoint}
size={28}
isCreatedByUser={false}
iconClassName="size-4"
/>
<span className="underline">{title}</span>
</button>
);
},
meta: {
size: isSmallScreen ? '70%' : '50%',
mobileSize: '70%',
enableSorting: true,
},
},
{
accessorKey: 'createdAt',
header: () => (
<span className="text-xs sm:text-sm">{localize('com_nav_archive_created_at')}</span>
),
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
meta: {
size: isSmallScreen ? '30%' : '35%',
mobileSize: '30%',
enableSorting: true,
},
},
{
accessorKey: 'actions',
header: () => (
<Label className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm">
{localize('com_assistants_actions')}
</Label>
),
cell: ({ row }) => {
const conversation = row.original;
const isRowUnarchiving = unarchivingId === conversation.conversationId;
return (
<div className="flex items-center gap-2">
<TooltipAnchor
description={localize('com_ui_unarchive')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setUnarchivingId(conversation.conversationId);
unarchiveMutation.mutate(
{ conversationId: conversation.conversationId, isArchived: false },
{ onSettled: () => setUnarchivingId(null) },
);
}}
disabled={isRowUnarchiving}
>
{isRowUnarchiving ? <Spinner /> : <ArchiveRestore className="size-4" />}
</Button>
}
/>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setDeleteRow(row.original);
setIsDeleteOpen(true);
}}
>
<TrashIcon className="size-4" />
</Button>
}
/>
</div>
);
},
meta: {
size: '15%',
mobileSize: '25%',
enableSorting: false,
},
},
],
[isSmallScreen, localize, unarchivingId, unarchiveMutation],
);
return (
<div className="flex items-center justify-between">
@ -16,11 +280,49 @@ export default function ArchivedChats() {
{localize('com_ui_manage')}
</Button>
</OGDialogTrigger>
<OGDialogContent className="w-11/12 max-w-5xl">
<OGDialogHeader>
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>
</OGDialogHeader>
<DataTable
columns={columns}
data={allConversations}
filterColumn="title"
onFilterChange={onFilterChange}
filterValue={searchInput}
fetchNextPage={handleFetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
showCheckboxes={false}
enableSearch={!!isSearchEnabled}
onSortChange={handleSort}
sortBy={sortBy}
sortDirection={sortDirection}
/>
</OGDialogContent>
</OGDialog>
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<OGDialogTemplate
title={localize('com_nav_archived_chats')}
className="max-w-[1000px]"
showCancelButton={false}
main={<ArchivedChatsTable isOpen={isOpen} onOpenChange={setIsOpen} />}
showCloseButton={false}
title={localize('com_ui_delete_archived_chats')}
className="w-11/12 max-w-md"
main={
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
</Label>
</div>
</div>
}
selection={{
selectHandler: confirmDelete,
selectClasses: `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white ${
deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : ''
}`,
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
}}
/>
</OGDialog>
</div>

View file

@ -1,311 +0,0 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { TrashIcon, ArchiveRestore, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
import {
Button,
OGDialog,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
Label,
TooltipAnchor,
Spinner,
DataTable,
useToastContext,
useMediaQuery,
} from '@librechat/client';
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
import {
useArchiveConvoMutation,
useConversationsInfiniteQuery,
useDeleteConversationMutation,
} from '~/data-provider';
import { MinimalIcon } from '~/components/Endpoints';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
import { formatDate } from '~/utils';
import store from '~/store';
const DEFAULT_PARAMS: ConversationListParams = {
isArchived: true,
sortBy: 'createdAt',
sortDirection: 'desc',
search: '',
};
export default function ArchivedChatsTable({
onOpenChange,
}: {
onOpenChange: (isOpen: boolean) => void;
}) {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { showToast } = useToastContext();
const isSearchEnabled = useRecoilValue(store.search);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
const [deleteConversation, setDeleteConversation] = useState<TConversation | null>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
useConversationsInfiniteQuery(queryParams, {
staleTime: 0,
cacheTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
setQueryParams((prev) => ({
...prev,
sortBy: sortField as 'title' | 'createdAt',
sortDirection: sortOrder,
}));
}, []);
const handleFilterChange = useCallback((value: string) => {
const encodedValue = encodeURIComponent(value.trim());
setQueryParams((prev) => ({
...prev,
search: encodedValue,
}));
}, []);
const debouncedFilterChange = useMemo(
() => debounce(handleFilterChange, 300),
[handleFilterChange],
);
useEffect(() => {
return () => {
debouncedFilterChange.cancel();
};
}, [debouncedFilterChange]);
const allConversations = useMemo(() => {
if (!data?.pages) {
return [];
}
return data.pages.flatMap((page) => page?.conversations?.filter(Boolean) ?? []);
}, [data?.pages]);
const deleteMutation = useDeleteConversationMutation({
onSuccess: async () => {
setIsDeleteOpen(false);
await refetch();
},
onError: (error: unknown) => {
showToast({
message: localize('com_ui_archive_delete_error') as string,
severity: NotificationSeverity.ERROR,
});
},
});
const unarchiveMutation = useArchiveConvoMutation({
onSuccess: async () => {
await refetch();
},
onError: (error: unknown) => {
showToast({
message: localize('com_ui_unarchive_error') as string,
severity: NotificationSeverity.ERROR,
});
},
});
const handleFetchNextPage = useCallback(async () => {
if (!hasNextPage || isFetchingNextPage) {
return;
}
await fetchNextPage();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const columns = useMemo(
() => [
{
accessorKey: 'title',
header: () => {
const isSorted = queryParams.sortBy === 'title';
const sortDirection = queryParams.sortDirection;
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() =>
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
>
{localize('com_nav_archive_name')}
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
</Button>
);
},
cell: ({ row }) => {
const { conversationId, title } = row.original;
return (
<button
type="button"
className="flex items-center gap-2 truncate"
onClick={() => window.open(`/c/${conversationId}`, '_blank')}
>
<MinimalIcon
endpoint={row.original.endpoint}
size={28}
isCreatedByUser={false}
iconClassName="size-4"
/>
<span className="underline">{title}</span>
</button>
);
},
meta: {
size: isSmallScreen ? '70%' : '50%',
mobileSize: '70%',
},
},
{
accessorKey: 'createdAt',
header: () => {
const isSorted = queryParams.sortBy === 'createdAt';
const sortDirection = queryParams.sortDirection;
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() =>
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
>
{localize('com_nav_archive_created_at')}
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
</Button>
);
},
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
meta: {
size: isSmallScreen ? '30%' : '35%',
mobileSize: '30%',
},
},
{
accessorKey: 'actions',
header: () => (
<Label className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm">
{localize('com_assistants_actions')}
</Label>
),
cell: ({ row }) => {
const conversation = row.original;
return (
<div className="flex items-center gap-2">
<TooltipAnchor
description={localize('com_ui_unarchive')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() =>
unarchiveMutation.mutate({
conversationId: conversation.conversationId,
isArchived: false,
})
}
title={localize('com_ui_unarchive')}
disabled={unarchiveMutation.isLoading}
>
{unarchiveMutation.isLoading ? (
<Spinner />
) : (
<ArchiveRestore className="size-4" />
)}
</Button>
}
/>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setDeleteConversation(row.original);
setIsDeleteOpen(true);
}}
title={localize('com_ui_delete')}
>
<TrashIcon className="size-4" />
</Button>
}
/>
</div>
);
},
meta: {
size: '15%',
mobileSize: '25%',
},
},
],
[handleSort, isSmallScreen, localize, queryParams, unarchiveMutation],
);
return (
<>
<DataTable
columns={columns}
data={allConversations}
filterColumn="title"
onFilterChange={debouncedFilterChange}
filterValue={queryParams.search}
fetchNextPage={handleFetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
showCheckboxes={false}
enableSearch={isSearchEnabled}
/>
<OGDialog open={isDeleteOpen} onOpenChange={onOpenChange}>
<OGDialogContent
title={localize('com_ui_delete_confirm') + ' ' + (deleteConversation?.title ?? '')}
className="w-11/12 max-w-md"
>
<OGDialogHeader>
<OGDialogTitle>
{localize('com_ui_delete_confirm')} <strong>{deleteConversation?.title}</strong>
</OGDialogTitle>
</OGDialogHeader>
<div className="flex justify-end gap-4 pt-4">
<Button aria-label="cancel" variant="outline" onClick={() => setIsDeleteOpen(false)}>
{localize('com_ui_cancel')}
</Button>
<Button
variant="destructive"
onClick={() =>
deleteMutation.mutate({
conversationId: deleteConversation?.conversationId ?? '',
})
}
disabled={deleteMutation.isLoading}
>
{deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete')}
</Button>
</div>
</OGDialogContent>
</OGDialog>
</>
);
}

View file

@ -1,6 +1,6 @@
{
"chat_direction_left_to_right": "something needs to go here. was empty",
"chat_direction_right_to_left": "something needs to go here. was empty",
"chat_direction_left_to_right": "Chat direction set to left to right",
"chat_direction_right_to_left": "Chat direction set to right to left",
"com_a11y_ai_composing": "The AI is still composing.",
"com_a11y_end": "The AI has finished their reply.",
"com_a11y_start": "The AI has started their reply.",
@ -389,7 +389,7 @@
"com_files_number_selected": "{{0}} of {{1}} items selected",
"com_files_preparing_download": "Preparing download...",
"com_files_sharepoint_picker_title": "Pick Files",
"com_files_table": "something needs to go here. was empty",
"com_files_table": "Files Table",
"com_files_upload_local_machine": "From Local Computer",
"com_files_upload_sharepoint": "From SharePoint",
"com_generated_files": "Generated files:",
@ -747,7 +747,8 @@
"com_ui_bookmarks_title": "Title",
"com_ui_bookmarks_update_error": "There was an error updating the bookmark",
"com_ui_bookmarks_update_success": "Bookmark updated successfully",
"com_ui_bulk_delete_error": "Failed to delete shared links",
"com_ui_shared_link_delete_error": "Failed to delete shared link",
"com_ui_archived_chat_delete_error": "Failed to delete archived chat",
"com_ui_callback_url": "Callback URL",
"com_ui_cancel": "Cancel",
"com_ui_cancelled": "Cancelled",
@ -830,6 +831,7 @@
"com_ui_delete_not_allowed": "Delete operation is not allowed",
"com_ui_delete_prompt": "Delete Prompt?",
"com_ui_delete_shared_link": "Delete shared link?",
"com_ui_delete_archived_chats": "Delete archived chat?",
"com_ui_delete_success": "Successfully deleted",
"com_ui_delete_tool": "Delete Tool",
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
@ -848,7 +850,7 @@
"com_ui_download_backup": "Download Backup Codes",
"com_ui_download_backup_tooltip": "Before you continue, download your backup codes. You will need them to regain access if you lose your authenticator device",
"com_ui_download_error": "Error downloading file. The file may have been deleted.",
"com_ui_drag_drop": "something needs to go here. was empty",
"com_ui_drag_drop_image": "Drag and drop an image here",
"com_ui_dropdown_variables": "Dropdown variables:",
"com_ui_dropdown_variables_info": "Create custom dropdown menus for your prompts: `{{variable_name:option1|option2|option3}}`",
"com_ui_duplicate": "Duplicate",
@ -1025,7 +1027,7 @@
"com_ui_no_categories": "No categories available",
"com_ui_no_category": "No category",
"com_ui_no_changes": "No changes were made",
"com_ui_no_data": "something needs to go here. was empty",
"com_ui_no_data": "No data",
"com_ui_no_individual_access": "No individual users or groups have access to this agent",
"com_ui_no_personalization_available": "No personalization options are currently available",
"com_ui_no_read_access": "You don't have permission to view memories",
@ -1160,6 +1162,7 @@
"com_ui_share_var": "Share {{0}}",
"com_ui_shared_link_bulk_delete_success": "Successfully deleted shared links",
"com_ui_shared_link_delete_success": "Successfully deleted shared link",
"com_ui_archived_conversation_delete_success": "Successfully deleted archived conversation",
"com_ui_shared_link_not_found": "Shared link not found",
"com_ui_shared_prompts": "Shared Prompts",
"com_ui_shop": "Shopping",

View file

@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
Row,
@ -387,25 +388,38 @@ export default function DataTable<TData, TValue>({
<TableHeader className="sticky top-0 z-50 bg-surface-secondary">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-b border-border-light">
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary sm:px-4"
style={getColumnStyle(
header.column.columnDef as TableColumn<TData, TValue>,
isSmallScreen,
)}
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
{headerGroup.headers.map((header) => {
const sortDir = header.column.getIsSorted();
const canSort = header.column.getCanSort();
return (
<TableHead
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
key={header.id}
className="relative cursor-pointer whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary hover:bg-surface-hover sm:px-4"
style={getColumnStyle(
header.column.columnDef as TableColumn<TData, TValue>,
isSmallScreen,
)}
>
<div className="flex items-center">
<span className="flex-1 text-left">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</span>
{canSort && (
<span className="ml-1">
{sortDir === false && (
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
)}
{sortDir === 'asc' && <ChevronUp className="h-4 w-4 text-primary" />}
{sortDir === 'desc' && <ChevronDown className="h-4 w-4 text-primary" />}
</span>
)}
</div>
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>

View file

@ -1,4 +1,5 @@
{
"com_ui_cancel": "Cancel",
"com_ui_no_options": "No options available"
"com_ui_no_options": "No options available",
"com_ui_no_data": "No Data Available"
}