mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-31 15:48:51 +01:00
📜 refactor: Optimize Conversation History Nav with Cursor Pagination (#5785)
* ✨ feat: improve Nav/Conversations/Convo/NewChat component performance * ✨ feat: implement cursor-based pagination for conversations API * 🔧 refactor: remove createdAt from conversation selection in API and type definitions * 🔧 refactor: include createdAt in conversation selection and update related types * ✨ fix: search functionality and bugs with loadMoreConversations * feat: move ArchivedChats to cursor and DataTable standard * 🔧 refactor: add InfiniteQueryObserverResult type import in Nav component * feat: enhance conversation listing with pagination, sorting, and search capabilities * 🔧 refactor: remove unnecessary comment regarding lodash/debounce in ArchivedChatsTable * 🔧 refactor: remove unused translation keys for archived chats and search results * 🔧 fix: Archived Chats, Delete Convo, Duplicate Convo * 🔧 refactor: improve conversation components with layout adjustments and new translations * 🔧 refactor: simplify archive conversation mutation and improve unarchive handling; fix: update fork mutation * 🔧 refactor: decode search query parameter in conversation route; improve error handling in unarchive mutation; clean up DataTable component styles * 🔧 refactor: remove unused translation key for empty archived chats * 🚀 fix: `archivedConversation` query key not updated correctly while archiving * 🧠 feat: Bedrock Anthropic Reasoning & Update Endpoint Handling (#6163) * feat: Add thinking and thinkingBudget parameters for Bedrock Anthropic models * chore: Update @librechat/agents to version 2.1.8 * refactor: change region order in params * refactor: Add maxTokens parameter to conversation preset schema * refactor: Update agent client to use bedrockInputSchema and improve error handling for model parameters * refactor: streamline/optimize llmConfig initialization and saving for bedrock * fix: ensure config titleModel is used for all endpoints * refactor: enhance OpenAIClient and agent initialization to support endpoint checks for OpenRouter * chore: bump @google/generative-ai * ✨ feat: improve Nav/Conversations/Convo/NewChat component performance * 🔧 refactor: remove unnecessary comment regarding lodash/debounce in ArchivedChatsTable * 🔧 refactor: update translation keys for clarity; simplify conversation query parameters and improve sorting functionality in SharedLinks component * 🔧 refactor: optimize conversation loading logic and improve search handling in Nav component * fix: package-lock * fix: package-lock 2 * fix: package lock 3 * refactor: remove unused utility files and exports to clean up the codebase * refactor: remove i18n and useAuthRedirect modules to streamline codebase * refactor: optimize Conversations component and remove unused ToggleContext * refactor(Convo): add RenameForm and ConvoLink components; enhance Conversations component with responsive design * fix: add missing @azure/storage-blob dependency in package.json * refactor(Search): add error handling with toast notification for search errors * refactor: make createdAt and updatedAt fields of tConvoUpdateSchema less restrictive if timestamps are missing * chore: update @azure/storage-blob dependency to version 12.27.0, ensure package-lock is correct * refactor(Search): improve conversation handling server side * fix: eslint warning and errors * refactor(Search): improved search loading state and overall UX * Refactors conversation cache management Centralizes conversation mutation logic into dedicated utility functions for adding, updating, and removing conversations from query caches. Improves reliability and maintainability by: - Consolidating duplicate cache manipulation code - Adding type safety for infinite query data structures - Implementing consistent cache update patterns across all conversation operations - Removing obsolete conversation helper functions in favor of standardized utilities * fix: conversation handling and SSE event processing - Optimizes conversation state management with useMemo and proper hook ordering - Improves SSE event handler documentation and error handling - Adds reset guard flag for conversation changes - Removes redundant navigation call - Cleans up cursor handling logic and document structure Improves code maintainability and prevents potential race conditions in conversation state updates * refactor: add type for SearchBar `onChange` * fix: type tags * style: rounded to xl all Header buttons * fix: activeConvo in Convo not working * style(Bookmarks): improved UI * a11y(AccountSettings): fixed hover style not visible when using light theme * style(SettingsTabs): improved tab switchers and dropdowns * feat: add translations keys for Speech * chore: fix package-lock * fix(mutations): legacy import after rebase * feat: refactor conversation navigation for accessibility * fix(search): convo and message create/update date not returned * fix(search): show correct iconURL and endpoint for searched messages * fix: small UI improvements * chore: console.log cleanup * chore: fix tests * fix(ChatForm): improve conversation ID handling and clean up useMemo dependencies * chore: improve typing * chore: improve typing * fix(useSSE): clear conversation ID on submission to prevent draft restoration * refactor(OpenAIClient): clean up abort handler * refactor(abortMiddleware): change handleAbort to use function expression * feat: add PENDING_CONVO constant and update conversation ID checks * fix: final event handling on abort * fix: improve title sync and query cache sync on final event * fix: prevent overwriting cached conversation data if it already exists --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
77a21719fd
commit
650e9b4f6c
69 changed files with 3434 additions and 2139 deletions
|
|
@ -30,7 +30,7 @@ function AccountSettings() {
|
|||
<Select.Select
|
||||
aria-label={localize('com_nav_account_settings')}
|
||||
data-testid="nav-user"
|
||||
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-accent"
|
||||
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-hover"
|
||||
>
|
||||
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
|
||||
<div className="relative flex">
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: Boo
|
|||
data-testid="bookmark-menu"
|
||||
>
|
||||
<div className="h-7 w-7 flex-shrink-0">
|
||||
<div className="relative flex h-full items-center justify-center rounded-full border border-border-medium bg-surface-primary-alt text-text-primary">
|
||||
<div className="relative flex h-full items-center justify-center text-text-primary">
|
||||
{tags.length > 0 ? (
|
||||
<BookmarkFilledIcon className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
|
|
@ -45,7 +45,7 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: Boo
|
|||
{tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')}
|
||||
</div>
|
||||
</MenuButton>
|
||||
<MenuItems className="absolute left-0 top-full z-[100] mt-1 w-full translate-y-0 overflow-hidden rounded-lg bg-surface-active-alt p-1.5 shadow-lg outline-none">
|
||||
<MenuItems className="absolute left-0 top-full z-[100] mt-1 w-full translate-y-0 overflow-hidden rounded-lg bg-surface-secondary p-1.5 shadow-lg outline-none">
|
||||
{data && conversation && (
|
||||
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
|
||||
<BookmarkNavItems
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
import 'test/resizeObserver.mock';
|
||||
import 'test/matchMedia.mock';
|
||||
import 'test/localStorage.mock';
|
||||
|
||||
import React from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
||||
import { AuthContextProvider } from '~/hooks/AuthContext';
|
||||
import { SearchContext } from '~/Providers';
|
||||
import Nav from './Nav';
|
||||
|
||||
const renderNav = ({ search, navVisible, setNavVisible }) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<RecoilRoot>
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthContextProvider>
|
||||
<SearchContext.Provider value={search}>
|
||||
<Nav navVisible={navVisible} setNavVisible={setNavVisible} />
|
||||
</SearchContext.Provider>
|
||||
</AuthContextProvider>
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
};
|
||||
|
||||
const mockMatchMedia = (mediaQueryList?: string[]) => {
|
||||
mediaQueryList = mediaQueryList || [];
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: mediaQueryList.includes(query),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
describe('Nav', () => {
|
||||
beforeEach(() => {
|
||||
mockMatchMedia();
|
||||
});
|
||||
|
||||
it('renders visible', () => {
|
||||
const { getByTestId } = renderNav({
|
||||
search: { data: [], pageNumber: 1 },
|
||||
navVisible: true,
|
||||
setNavVisible: jest.fn(),
|
||||
});
|
||||
|
||||
expect(getByTestId('nav')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders hidden', async () => {
|
||||
const { getByTestId } = renderNav({
|
||||
search: { data: [], pageNumber: 1 },
|
||||
navVisible: false,
|
||||
setNavVisible: jest.fn(),
|
||||
});
|
||||
|
||||
expect(getByTestId('nav')).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('renders hidden when small screen is detected', async () => {
|
||||
mockMatchMedia(['(max-width: 768px)']);
|
||||
|
||||
const navVisible = true;
|
||||
const mockSetNavVisible = jest.fn();
|
||||
|
||||
const { getByTestId } = renderNav({
|
||||
search: { data: [], pageNumber: 1 },
|
||||
navVisible: navVisible,
|
||||
setNavVisible: mockSetNavVisible,
|
||||
});
|
||||
|
||||
// nav is initially visible
|
||||
expect(getByTestId('nav')).toBeVisible();
|
||||
|
||||
// when small screen is detected, the nav is hidden
|
||||
expect(mockSetNavVisible.mock.calls).toHaveLength(1);
|
||||
const updatedNavVisible = mockSetNavVisible.mock.calls[0][0](navVisible);
|
||||
expect(updatedNavVisible).not.toEqual(navVisible);
|
||||
expect(updatedNavVisible).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
import { useCallback, useEffect, useState, useMemo, memo } from 'react';
|
||||
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { ConversationListResponse } from 'librechat-data-provider';
|
||||
import type {
|
||||
TConversation,
|
||||
ConversationListResponse,
|
||||
SearchConversationListResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
|
||||
import {
|
||||
useLocalize,
|
||||
useHasAccess,
|
||||
|
|
@ -12,213 +17,260 @@ import {
|
|||
} from '~/hooks';
|
||||
import { useConversationsInfiniteQuery } from '~/data-provider';
|
||||
import { Conversations } from '~/components/Conversations';
|
||||
import BookmarkNav from './Bookmarks/BookmarkNav';
|
||||
import AccountSettings from './AccountSettings';
|
||||
import { useSearchContext } from '~/Providers';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import SearchBar from './SearchBar';
|
||||
import { Spinner } from '~/components';
|
||||
import NavToggle from './NavToggle';
|
||||
import SearchBar from './SearchBar';
|
||||
import NewChat from './NewChat';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const Nav = ({
|
||||
navVisible,
|
||||
setNavVisible,
|
||||
}: {
|
||||
navVisible: boolean;
|
||||
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
|
||||
const AccountSettings = lazy(() => import('./AccountSettings'));
|
||||
|
||||
const [navWidth, setNavWidth] = useState('260px');
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
||||
const [isToggleHovering, setIsToggleHovering] = useState(false);
|
||||
const NAV_WIDTH_DESKTOP = '260px';
|
||||
const NAV_WIDTH_MOBILE = '320px';
|
||||
|
||||
const hasAccessToBookmarks = useHasAccess({
|
||||
permissionType: PermissionTypes.BOOKMARKS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const NavMask = memo(
|
||||
({ navVisible, toggleNavVisible }: { navVisible: boolean; toggleNavVisible: () => void }) => (
|
||||
<div
|
||||
id="mobile-nav-mask-toggle"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`nav-mask ${navVisible ? 'active' : ''}`}
|
||||
onClick={toggleNavVisible}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
toggleNavVisible();
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle navigation"
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setIsHovering(true);
|
||||
}, []);
|
||||
const MemoNewChat = memo(NewChat);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsHovering(false);
|
||||
}, []);
|
||||
const Nav = memo(
|
||||
({
|
||||
navVisible,
|
||||
setNavVisible,
|
||||
}: {
|
||||
navVisible: boolean;
|
||||
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSmallScreen) {
|
||||
const savedNavVisible = localStorage.getItem('navVisible');
|
||||
if (savedNavVisible === null) {
|
||||
toggleNavVisible();
|
||||
}
|
||||
setNavWidth('320px');
|
||||
} else {
|
||||
setNavWidth('260px');
|
||||
}
|
||||
}, [isSmallScreen]);
|
||||
const [navWidth, setNavWidth] = useState(NAV_WIDTH_DESKTOP);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
||||
const [isToggleHovering, setIsToggleHovering] = useState(false);
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
const isSearchEnabled = useRecoilValue(store.isSearchEnabled);
|
||||
const hasAccessToBookmarks = useHasAccess({
|
||||
permissionType: PermissionTypes.BOOKMARKS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const { pageNumber, searchQuery, setPageNumber, searchQueryRes } = useSearchContext();
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } =
|
||||
useConversationsInfiniteQuery(
|
||||
const isSearchEnabled = useRecoilValue(store.isSearchEnabled);
|
||||
const isSearchTyping = useRecoilValue(store.isSearchTyping);
|
||||
const { searchQuery, searchQueryRes } = useSearchContext();
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage, refetch } = useConversationsInfiniteQuery(
|
||||
{
|
||||
pageNumber: pageNumber.toString(),
|
||||
isArchived: false,
|
||||
tags: tags.length === 0 ? undefined : tags,
|
||||
},
|
||||
{ enabled: isAuthenticated },
|
||||
{
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 30000,
|
||||
cacheTime: 300000,
|
||||
},
|
||||
);
|
||||
useEffect(() => {
|
||||
// When a tag is selected, refetch the list of conversations related to that tag
|
||||
refetch();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tags]);
|
||||
const { containerRef, moveToTop } = useNavScrolling<ConversationListResponse>({
|
||||
setShowLoading,
|
||||
hasNextPage: searchQuery ? searchQueryRes?.hasNextPage : hasNextPage,
|
||||
fetchNextPage: searchQuery ? searchQueryRes?.fetchNextPage : fetchNextPage,
|
||||
isFetchingNextPage: searchQuery
|
||||
? searchQueryRes?.isFetchingNextPage ?? false
|
||||
: isFetchingNextPage,
|
||||
});
|
||||
|
||||
const conversations = useMemo(
|
||||
() =>
|
||||
(searchQuery ? searchQueryRes?.data : data)?.pages.flatMap((page) => page.conversations) ||
|
||||
[],
|
||||
[data, searchQuery, searchQueryRes?.data],
|
||||
);
|
||||
const computedHasNextPage = useMemo(() => {
|
||||
if (searchQuery && searchQueryRes?.data) {
|
||||
const pages = searchQueryRes.data.pages;
|
||||
return pages[pages.length - 1]?.nextCursor !== null;
|
||||
} else if (data?.pages && data.pages.length > 0) {
|
||||
const lastPage: ConversationListResponse = data.pages[data.pages.length - 1];
|
||||
return lastPage.nextCursor !== null;
|
||||
}
|
||||
return false;
|
||||
}, [searchQuery, searchQueryRes?.data, data?.pages]);
|
||||
|
||||
const toggleNavVisible = () => {
|
||||
setNavVisible((prev: boolean) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
return !prev;
|
||||
});
|
||||
if (newUser) {
|
||||
setNewUser(false);
|
||||
}
|
||||
};
|
||||
const outerContainerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<any>(null);
|
||||
|
||||
const itemToggleNav = () => {
|
||||
if (isSmallScreen) {
|
||||
toggleNavVisible();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="nav"
|
||||
className={
|
||||
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt md:max-w-[260px]'
|
||||
const { moveToTop } = useNavScrolling<
|
||||
ConversationListResponse | SearchConversationListResponse
|
||||
>({
|
||||
setShowLoading,
|
||||
fetchNextPage: async (options?) => {
|
||||
if (computedHasNextPage) {
|
||||
if (searchQuery && searchQueryRes) {
|
||||
const pages = searchQueryRes.data?.pages;
|
||||
if (pages && pages.length > 0 && pages[pages.length - 1]?.nextCursor !== null) {
|
||||
return searchQueryRes.fetchNextPage(options);
|
||||
}
|
||||
} else {
|
||||
return fetchNextPage(options);
|
||||
}
|
||||
}
|
||||
style={{
|
||||
width: navVisible ? navWidth : '0px',
|
||||
visibility: navVisible ? 'visible' : 'hidden',
|
||||
transition: 'width 0.2s, visibility 0.2s',
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-[320px] md:w-[260px]">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full min-h-0 flex-col transition-opacity',
|
||||
isToggleHovering && !isSmallScreen ? 'opacity-50' : 'opacity-100',
|
||||
)}
|
||||
>
|
||||
return Promise.resolve(
|
||||
{} as InfiniteQueryObserverResult<
|
||||
SearchConversationListResponse | ConversationListResponse,
|
||||
unknown
|
||||
>,
|
||||
);
|
||||
},
|
||||
isFetchingNext: searchQuery
|
||||
? (searchQueryRes?.isFetchingNextPage ?? false)
|
||||
: isFetchingNextPage,
|
||||
});
|
||||
|
||||
const conversations = useMemo(() => {
|
||||
if (searchQuery && searchQueryRes?.data) {
|
||||
return searchQueryRes.data.pages.flatMap(
|
||||
(page) => page.conversations ?? [],
|
||||
) as TConversation[];
|
||||
}
|
||||
return data ? data.pages.flatMap((page) => page.conversations) : [];
|
||||
}, [data, searchQuery, searchQueryRes?.data]);
|
||||
|
||||
const toggleNavVisible = useCallback(() => {
|
||||
setNavVisible((prev: boolean) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
return !prev;
|
||||
});
|
||||
if (newUser) {
|
||||
setNewUser(false);
|
||||
}
|
||||
}, [newUser, setNavVisible, setNewUser]);
|
||||
|
||||
const itemToggleNav = useCallback(() => {
|
||||
if (isSmallScreen) {
|
||||
toggleNavVisible();
|
||||
}
|
||||
}, [isSmallScreen, toggleNavVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSmallScreen) {
|
||||
const savedNavVisible = localStorage.getItem('navVisible');
|
||||
if (savedNavVisible === null) {
|
||||
toggleNavVisible();
|
||||
}
|
||||
setNavWidth(NAV_WIDTH_MOBILE);
|
||||
} else {
|
||||
setNavWidth(NAV_WIDTH_DESKTOP);
|
||||
}
|
||||
}, [isSmallScreen, toggleNavVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [tags, refetch]);
|
||||
|
||||
const loadMoreConversations = useCallback(() => {
|
||||
if (isFetchingNextPage || !computedHasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchNextPage();
|
||||
}, [isFetchingNextPage, computedHasNextPage, fetchNextPage]);
|
||||
|
||||
const subHeaders = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{isSearchEnabled === true && <SearchBar isSmallScreen={isSmallScreen} />}
|
||||
{hasAccessToBookmarks && (
|
||||
<>
|
||||
<div className="mt-1.5" />
|
||||
<Suspense fallback={null}>
|
||||
<BookmarkNav tags={tags} setTags={setTags} isSmallScreen={isSmallScreen} />
|
||||
</Suspense>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[isSearchEnabled, hasAccessToBookmarks, isSmallScreen, tags, setTags],
|
||||
);
|
||||
|
||||
const isSearchLoading =
|
||||
!!searchQuery &&
|
||||
(isSearchTyping ||
|
||||
(searchQueryRes?.isLoading ?? false) ||
|
||||
(searchQueryRes?.isFetching ?? false));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="nav"
|
||||
className={cn(
|
||||
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt',
|
||||
'md:max-w-[260px]',
|
||||
)}
|
||||
style={{
|
||||
width: navVisible ? navWidth : '0px',
|
||||
visibility: navVisible ? 'visible' : 'hidden',
|
||||
transition: 'width 0.2s, visibility 0.2s',
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-[320px] md:w-[260px]">
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
'scrollbar-trigger relative h-full w-full flex-1 items-start border-white/20',
|
||||
'flex h-full flex-col transition-opacity',
|
||||
isToggleHovering && !isSmallScreen ? 'opacity-50' : 'opacity-100',
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
id="chat-history-nav"
|
||||
aria-label={localize('com_ui_chat_history')}
|
||||
className="flex h-full w-full flex-col px-3 pb-3.5"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'-mr-2 flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
|
||||
isHovering ? '' : 'scrollbar-transparent',
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={containerRef}
|
||||
<div className="flex h-full flex-col">
|
||||
<nav
|
||||
id="chat-history-nav"
|
||||
aria-label={localize('com_ui_chat_history')}
|
||||
className="flex h-full flex-col px-3 pb-3.5"
|
||||
>
|
||||
<NewChat
|
||||
toggleNav={itemToggleNav}
|
||||
isSmallScreen={isSmallScreen}
|
||||
subHeaders={
|
||||
<>
|
||||
{isSearchEnabled === true && (
|
||||
<SearchBar
|
||||
setPageNumber={setPageNumber}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
)}
|
||||
{hasAccessToBookmarks === true && (
|
||||
<>
|
||||
<div className="mt-1.5" />
|
||||
<BookmarkNav
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Conversations
|
||||
conversations={conversations}
|
||||
moveToTop={moveToTop}
|
||||
toggleNav={itemToggleNav}
|
||||
/>
|
||||
{(isFetchingNextPage || showLoading) && (
|
||||
<Spinner className={cn('m-1 mx-auto mb-4 h-4 w-4 text-text-primary')} />
|
||||
)}
|
||||
</div>
|
||||
<AccountSettings />
|
||||
</nav>
|
||||
<div className="flex flex-1 flex-col" ref={outerContainerRef}>
|
||||
<MemoNewChat
|
||||
toggleNav={itemToggleNav}
|
||||
isSmallScreen={isSmallScreen}
|
||||
subHeaders={subHeaders}
|
||||
/>
|
||||
<Conversations
|
||||
conversations={conversations}
|
||||
moveToTop={moveToTop}
|
||||
toggleNav={itemToggleNav}
|
||||
containerRef={listRef}
|
||||
loadMoreConversations={loadMoreConversations}
|
||||
isFetchingNextPage={isFetchingNextPage || showLoading}
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<AccountSettings />
|
||||
</Suspense>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavToggle
|
||||
isHovering={isToggleHovering}
|
||||
setIsHovering={setIsToggleHovering}
|
||||
onToggle={toggleNavVisible}
|
||||
navVisible={navVisible}
|
||||
className="fixed left-0 top-1/2 z-40 hidden md:flex"
|
||||
/>
|
||||
{isSmallScreen && (
|
||||
<div
|
||||
id="mobile-nav-mask-toggle"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`nav-mask ${navVisible ? 'active' : ''}`}
|
||||
onClick={toggleNavVisible}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
toggleNavVisible();
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle navigation"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Nav);
|
||||
<NavToggle
|
||||
isHovering={isToggleHovering}
|
||||
setIsHovering={setIsToggleHovering}
|
||||
onToggle={toggleNavVisible}
|
||||
navVisible={navVisible}
|
||||
className="fixed left-0 top-1/2 z-40 hidden md:flex"
|
||||
/>
|
||||
|
||||
{isSmallScreen && <NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Nav.displayName = 'Nav';
|
||||
|
||||
export default Nav;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import React, { useMemo, useCallback } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
|
@ -13,10 +14,24 @@ import { NewChatIcon } from '~/components/svg';
|
|||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | null }) => {
|
||||
const NewChatButtonIcon = React.memo(({ conversation }: { conversation: TConversation | null }) => {
|
||||
const searchQuery = useRecoilValue(store.searchQuery);
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
|
||||
const computedIcon = useMemo(() => {
|
||||
if (searchQuery) {
|
||||
return null;
|
||||
}
|
||||
let { endpoint = '' } = conversation ?? {};
|
||||
const iconURL = conversation?.iconURL ?? '';
|
||||
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
|
||||
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
|
||||
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
|
||||
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType, endpointIconURL });
|
||||
const Icon = icons[iconKey];
|
||||
return { iconURL, endpoint, endpointType, endpointIconURL, Icon };
|
||||
}, [searchQuery, conversation, endpointsConfig]);
|
||||
|
||||
if (searchQuery) {
|
||||
return (
|
||||
<div className="shadow-stroke relative flex h-7 w-7 items-center justify-center rounded-full bg-white text-black dark:bg-white">
|
||||
|
|
@ -25,14 +40,11 @@ const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | nul
|
|||
);
|
||||
}
|
||||
|
||||
let { endpoint = '' } = conversation ?? {};
|
||||
const iconURL = conversation?.iconURL ?? '';
|
||||
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
|
||||
if (!computedIcon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
|
||||
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
|
||||
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType, endpointIconURL });
|
||||
const Icon = icons[iconKey];
|
||||
const { iconURL, endpoint, endpointIconURL, Icon } = computedIcon;
|
||||
|
||||
return (
|
||||
<div className="h-7 w-7 flex-shrink-0">
|
||||
|
|
@ -45,13 +57,12 @@ const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | nul
|
|||
/>
|
||||
) : (
|
||||
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
|
||||
{endpoint && Icon != null && (
|
||||
{endpoint && Icon && (
|
||||
<Icon
|
||||
size={41}
|
||||
context="nav"
|
||||
className="h-2/3 w-2/3"
|
||||
endpoint={endpoint}
|
||||
endpointType={endpointType}
|
||||
iconURL={endpointIconURL}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -59,7 +70,7 @@ const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | nul
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default function NewChat({
|
||||
index = 0,
|
||||
|
|
@ -77,21 +88,23 @@ export default function NewChat({
|
|||
const { newConversation: newConvo } = useNewConvo(index);
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
|
||||
const { conversation } = store.useCreateConversationAtom(index);
|
||||
|
||||
const clickHandler = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
queryClient.setQueryData<TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
newConvo();
|
||||
navigate('/c/new');
|
||||
toggleNav();
|
||||
}
|
||||
};
|
||||
const clickHandler = useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
queryClient.setQueryData<TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
newConvo();
|
||||
navigate('/c/new');
|
||||
toggleNav();
|
||||
}
|
||||
},
|
||||
[queryClient, conversation, newConvo, navigate, toggleNav],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="sticky left-0 right-0 top-0 z-50 bg-surface-primary-alt pt-3.5">
|
||||
|
|
|
|||
|
|
@ -11,14 +11,13 @@ import store from '~/store';
|
|||
|
||||
type SearchBarProps = {
|
||||
isSmallScreen?: boolean;
|
||||
setPageNumber: React.Dispatch<React.SetStateAction<number>>;
|
||||
};
|
||||
|
||||
const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) => {
|
||||
const localize = useLocalize();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const { setPageNumber, isSmallScreen } = props;
|
||||
const { isSmallScreen } = props;
|
||||
|
||||
const [text, setText] = useState('');
|
||||
const [showClearIcon, setShowClearIcon] = useState(false);
|
||||
|
|
@ -27,13 +26,13 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
|
|||
const clearConvoState = store.useClearConvoState();
|
||||
const setSearchQuery = useSetRecoilState(store.searchQuery);
|
||||
const setIsSearching = useSetRecoilState(store.isSearching);
|
||||
const setIsSearchTyping = useSetRecoilState(store.isSearchTyping);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setPageNumber(1);
|
||||
if (location.pathname.includes('/search')) {
|
||||
newConversation({ disableFocus: true });
|
||||
}
|
||||
}, [newConversation, setPageNumber, location.pathname]);
|
||||
}, [newConversation, location.pathname]);
|
||||
|
||||
const clearText = useCallback(() => {
|
||||
setShowClearIcon(false);
|
||||
|
|
@ -61,15 +60,22 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
|
|||
[queryClient, clearConvoState, setSearchQuery],
|
||||
);
|
||||
|
||||
// TODO: make the debounce time configurable via yaml
|
||||
const debouncedSendRequest = useMemo(() => debounce(sendRequest, 350), [sendRequest]);
|
||||
const debouncedSendRequest = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
sendRequest(value);
|
||||
}, 350),
|
||||
[sendRequest, setIsSearchTyping],
|
||||
);
|
||||
|
||||
const onChange = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target as HTMLInputElement;
|
||||
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setShowClearIcon(value.length > 0);
|
||||
setText(value);
|
||||
setSearchQuery(value);
|
||||
setIsSearchTyping(true);
|
||||
// debounce only the API call
|
||||
debouncedSendRequest(value);
|
||||
setIsSearching(true);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -80,9 +86,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
|
|||
isSmallScreen === true ? 'mb-2 h-14 rounded-2xl' : '',
|
||||
)}
|
||||
>
|
||||
{
|
||||
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
|
||||
}
|
||||
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
|
||||
<input
|
||||
type="text"
|
||||
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const settingsTabs: { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[] = [
|
||||
const settingsTabs: {
|
||||
value: SettingsTabValues;
|
||||
icon: React.JSX.Element;
|
||||
label: TranslationKeys;
|
||||
}[] = [
|
||||
{
|
||||
value: SettingsTabValues.GENERAL,
|
||||
icon: <GearIcon />,
|
||||
|
|
@ -144,7 +148,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
<line x1="18" x2="6" y1="6" y2="18"></line>
|
||||
<line x1="6" x2="18" y1="6" y2="18"></line>
|
||||
</svg>
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{localize('com_ui_close')}</span>
|
||||
</button>
|
||||
</DialogTitle>
|
||||
<div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]">
|
||||
|
|
@ -168,10 +172,10 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
<Tabs.Trigger
|
||||
key={value}
|
||||
className={cn(
|
||||
'group relative z-10 m-1 flex items-center justify-start gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out',
|
||||
'group relative z-10 m-1 flex items-center justify-start gap-2 rounded-xl px-2 py-1.5 transition-all duration-200 ease-in-out',
|
||||
isSmallScreen
|
||||
? 'flex-1 justify-center text-nowrap rounded-xl p-1 px-3 text-sm text-text-secondary radix-state-active:bg-surface-hover radix-state-active:text-text-primary'
|
||||
: 'rounded-md bg-transparent text-text-primary radix-state-active:bg-surface-tertiary',
|
||||
? 'flex-1 justify-center text-nowrap p-1 px-3 text-sm text-text-secondary radix-state-active:bg-surface-hover radix-state-active:text-text-primary'
|
||||
: 'bg-transparent text-text-secondary radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary',
|
||||
)}
|
||||
value={value}
|
||||
ref={(el) => (tabRefs.current[value] = el)}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export default function FontSizeSelector() {
|
|||
onChange={handleChange}
|
||||
testId="font-size-selector"
|
||||
sizeClasses="w-[150px]"
|
||||
className="rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { TrashIcon, MessageSquare, ArrowUpDown } from 'lucide-react';
|
||||
import { TrashIcon, MessageSquare, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
|
|
@ -9,10 +9,11 @@ import {
|
|||
OGDialogContent,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
Button,
|
||||
TooltipAnchor,
|
||||
Button,
|
||||
Label,
|
||||
} from '~/components/ui';
|
||||
Spinner,
|
||||
} from '~/components';
|
||||
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { useLocalize, useMediaQuery } from '~/hooks';
|
||||
|
|
@ -20,7 +21,6 @@ import DataTable from '~/components/ui/DataTable';
|
|||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { formatDate } from '~/utils';
|
||||
import { Spinner } from '~/components/svg';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
|
|
@ -37,6 +37,7 @@ export default function SharedLinks() {
|
|||
const { showToast } = useToastContext();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
|
|
@ -144,8 +145,6 @@ export default function SharedLinks() {
|
|||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (deleteRow) {
|
||||
handleDelete([deleteRow]);
|
||||
|
|
@ -157,21 +156,30 @@ export default function SharedLinks() {
|
|||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: ({ column }) => {
|
||||
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', column.getIsSorted() === 'asc' ? 'desc' : 'asc')}
|
||||
onClick={() =>
|
||||
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
>
|
||||
{localize('com_ui_name')}
|
||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
{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 { title, shareId } = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
|
|
@ -193,17 +201,25 @@ export default function SharedLinks() {
|
|||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }) => {
|
||||
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', column.getIsSorted() === 'asc' ? 'desc' : 'asc')
|
||||
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
>
|
||||
{localize('com_ui_date')}
|
||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
{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>
|
||||
);
|
||||
},
|
||||
|
|
@ -240,7 +256,7 @@ export default function SharedLinks() {
|
|||
<MessageSquare className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
></TooltipAnchor>
|
||||
/>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
|
|
@ -256,12 +272,12 @@ export default function SharedLinks() {
|
|||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
></TooltipAnchor>
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[isSmallScreen, localize],
|
||||
[isSmallScreen, localize, queryParams, handleSort],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -291,6 +307,7 @@ export default function SharedLinks() {
|
|||
showCheckboxes={false}
|
||||
onFilterChange={debouncedFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { useLocalize } from '~/hooks';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { useState } from 'react';
|
||||
import { OGDialog, OGDialogTrigger, Button } from '~/components';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import ArchivedChatsTable from './ArchivedChatsTable';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function ArchivedChats() {
|
||||
const localize = useLocalize();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_archived_chats')}</div>
|
||||
<OGDialog>
|
||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button variant="outline" aria-label="Archived chats">
|
||||
{localize('com_ui_manage')}
|
||||
|
|
@ -19,7 +21,7 @@ export default function ArchivedChats() {
|
|||
title={localize('com_nav_archived_chats')}
|
||||
className="max-w-[1000px]"
|
||||
showCancelButton={false}
|
||||
main={<ArchivedChatsTable />}
|
||||
main={<ArchivedChatsTable isOpen={isOpen} onOpenChange={setIsOpen} />}
|
||||
/>
|
||||
</OGDialog>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,287 +1,308 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { TrashIcon, ArchiveRestore, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
|
||||
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
|
||||
import {
|
||||
Search,
|
||||
TrashIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
// ChevronsLeft,
|
||||
// ChevronsRight,
|
||||
MessageCircle,
|
||||
ArchiveRestore,
|
||||
} from 'lucide-react';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import {
|
||||
Table,
|
||||
Input,
|
||||
Button,
|
||||
TableRow,
|
||||
Skeleton,
|
||||
OGDialog,
|
||||
Separator,
|
||||
TableCell,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
OGDialogContent,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
Label,
|
||||
TooltipAnchor,
|
||||
OGDialogTrigger,
|
||||
Spinner,
|
||||
} from '~/components';
|
||||
import { useConversationsInfiniteQuery, useArchiveConvoMutation } from '~/data-provider';
|
||||
import { DeleteConversationDialog } from '~/components/Conversations/ConvoOptions';
|
||||
import { useAuthContext, useLocalize, useMediaQuery } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import {
|
||||
useArchiveConvoMutation,
|
||||
useConversationsInfiniteQuery,
|
||||
useDeleteConversationMutation,
|
||||
} from '~/data-provider';
|
||||
import { useLocalize, useMediaQuery } from '~/hooks';
|
||||
import { MinimalIcon } from '~/components/Endpoints';
|
||||
import DataTable from '~/components/ui/DataTable';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { formatDate } from '~/utils';
|
||||
|
||||
export default function ArchivedChatsTable() {
|
||||
const DEFAULT_PARAMS: ConversationListParams = {
|
||||
isArchived: true,
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'desc',
|
||||
search: '',
|
||||
};
|
||||
|
||||
export default function ArchivedChatsTable({
|
||||
onOpenChange,
|
||||
}: {
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } =
|
||||
useConversationsInfiniteQuery(
|
||||
{ pageNumber: currentPage.toString(), isArchived: true },
|
||||
{ enabled: isAuthenticated && isOpened },
|
||||
);
|
||||
const mutation = useArchiveConvoMutation();
|
||||
const handleUnarchive = useCallback(
|
||||
(conversationId: string) => {
|
||||
mutation.mutate({ conversationId, isArchived: false });
|
||||
},
|
||||
[mutation],
|
||||
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],
|
||||
);
|
||||
|
||||
const conversations = useMemo(
|
||||
() => data?.pages[currentPage - 1]?.conversations ?? [],
|
||||
[data, currentPage],
|
||||
);
|
||||
const totalPages = useMemo(() => Math.ceil(Number(data?.pages[0].pages ?? 1)) ?? 1, [data]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedFilterChange.cancel();
|
||||
};
|
||||
}, [debouncedFilterChange]);
|
||||
|
||||
const handleChatClick = useCallback((conversationId: string) => {
|
||||
if (!conversationId) {
|
||||
return;
|
||||
const allConversations = useMemo(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
}
|
||||
window.open(`/c/${conversationId}`, '_blank');
|
||||
}, []);
|
||||
return data.pages.flatMap((page) => page?.conversations?.filter(Boolean) ?? []);
|
||||
}, [data?.pages]);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
if (!(hasNextPage ?? false)) {
|
||||
return;
|
||||
}
|
||||
fetchNextPage({ pageParam: newPage });
|
||||
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,
|
||||
});
|
||||
},
|
||||
[fetchNextPage, hasNextPage],
|
||||
);
|
||||
|
||||
const handleSearch = useCallback((query: string) => {
|
||||
setSearchQuery(query);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const getRandomWidth = () => Math.floor(Math.random() * (400 - 170 + 1)) + 170;
|
||||
|
||||
const skeletons = Array.from({ length: 11 }, (_, index) => {
|
||||
const randomWidth = getRandomWidth();
|
||||
return (
|
||||
<div key={index} className="flex h-10 w-full items-center">
|
||||
<div className="flex w-[410px] items-center">
|
||||
<Skeleton className="h-4" style={{ width: `${randomWidth}px` }} />
|
||||
</div>
|
||||
<div className="flex flex-grow justify-center">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
<div className="mr-2 flex justify-end">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
if (isLoading || isFetchingNextPage) {
|
||||
return <div className="text-text-secondary">{skeletons}</div>;
|
||||
}
|
||||
const unarchiveMutation = useArchiveConvoMutation({
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
showToast({
|
||||
message: localize('com_ui_unarchive_error') as string,
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!data || (conversations.length === 0 && totalPages === 0)) {
|
||||
return <div className="text-text-secondary">{localize('com_nav_archived_chats_empty')}</div>;
|
||||
}
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'grid w-full gap-2',
|
||||
'flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
|
||||
'max-h-[629px]',
|
||||
)}
|
||||
onMouseEnter={() => setIsOpened(true)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Search className="size-4 text-text-secondary" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={localize('com_nav_search_placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full border-none placeholder:text-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
{conversations.length === 0 ? (
|
||||
<div className="mt-4 text-text-secondary">{localize('com_nav_no_search_results')}</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className={cn('p-4', isSmallScreen ? 'w-[70%]' : 'w-[50%]')}>
|
||||
{localize('com_nav_archive_name')}
|
||||
</TableHead>
|
||||
{!isSmallScreen && (
|
||||
<TableHead className="w-[35%] p-1">
|
||||
{localize('com_nav_archive_created_at')}
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className={cn('p-1 text-right', isSmallScreen ? 'w-[30%]' : 'w-[15%]')}>
|
||||
{localize('com_assistants_actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{conversations.map((conversation: TConversation) => (
|
||||
<TableRow key={conversation.conversationId} className="hover:bg-transparent">
|
||||
<TableCell className="py-3 text-text-primary">
|
||||
<button
|
||||
type="button"
|
||||
className="flex max-w-full"
|
||||
aria-label="Open conversation in a new tab"
|
||||
onClick={() => {
|
||||
const conversationId = conversation.conversationId ?? '';
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
handleChatClick(conversationId);
|
||||
}}
|
||||
>
|
||||
<MessageCircle className="mr-1 h-5 min-w-[20px]" />
|
||||
<u className="truncate">{conversation.title}</u>
|
||||
</button>
|
||||
</TableCell>
|
||||
{!isSmallScreen && (
|
||||
<TableCell className="p-1">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex justify-start text-text-secondary">
|
||||
{new Date(conversation.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell
|
||||
className={cn(
|
||||
'flex items-center gap-1 p-1',
|
||||
isSmallScreen ? 'justify-end' : 'justify-end gap-2',
|
||||
)}
|
||||
>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_unarchive')}
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Unarchive conversation"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-8', isSmallScreen && 'size-7')}
|
||||
onClick={() => {
|
||||
const conversationId = conversation.conversationId ?? '';
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
handleUnarchive(conversationId);
|
||||
}}
|
||||
>
|
||||
<ArchiveRestore className={cn('size-4', isSmallScreen && 'size-3.5')} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={allConversations}
|
||||
filterColumn="title"
|
||||
onFilterChange={debouncedFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isLoading={isLoading}
|
||||
showCheckboxes={false}
|
||||
manualSorting={true} // Ensures server-side sorting
|
||||
/>
|
||||
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Delete archived conversation"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-8', isSmallScreen && 'size-7')}
|
||||
>
|
||||
<TrashIcon className={cn('size-4', isSmallScreen && 'size-3.5')} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
<DeleteConversationDialog
|
||||
conversationId={conversation.conversationId ?? ''}
|
||||
retainView={refetch}
|
||||
title={conversation.title ?? ''}
|
||||
/>
|
||||
</OGDialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-end gap-6 px-2 py-4">
|
||||
<div className="text-sm font-bold text-text-primary">
|
||||
{localize('com_ui_page')} {currentPage} {localize('com_ui_of')} {totalPages}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{/* <Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Go to the previous 10 pages"
|
||||
onClick={() => handlePageChange(Math.max(currentPage - 10, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronsLeft className="size-4" />
|
||||
</Button> */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Go to the previous page"
|
||||
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Go to the next page"
|
||||
onClick={() => handlePageChange(Math.min(currentPage + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
{/* <Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Go to the next 10 pages"
|
||||
onClick={() => handlePageChange(Math.min(currentPage + 10, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronsRight className="size-4" />
|
||||
</Button> */}
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export const ThemeSelector = ({
|
|||
options={themeOptions}
|
||||
sizeClasses="w-[180px]"
|
||||
testId="theme-selector"
|
||||
className="rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -112,6 +113,7 @@ export const LangSelector = ({
|
|||
onChange={onChange}
|
||||
sizeClasses="[--anchor-max-height:256px]"
|
||||
options={languageOptions}
|
||||
className="rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
|
|||
options={endpointOptions}
|
||||
sizeClasses="w-[180px]"
|
||||
testId="EngineSTTDropdown"
|
||||
className="rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,12 +20,14 @@ import {
|
|||
EngineSTTDropdown,
|
||||
DecibelSelector,
|
||||
} from './STT';
|
||||
import { useOnClickOutside, useMediaQuery, useLocalize } from '~/hooks';
|
||||
import ConversationModeSwitch from './ConversationModeSwitch';
|
||||
import { useOnClickOutside, useMediaQuery } from '~/hooks';
|
||||
import { cn, logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
function Speech() {
|
||||
const localize = useLocalize();
|
||||
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const { data } = useGetCustomConfigSpeechQuery();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 767px)');
|
||||
|
|
@ -158,7 +160,7 @@ function Speech() {
|
|||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<Lightbulb />
|
||||
Simple
|
||||
{localize('com_ui_simple')}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
onClick={() => setAdvancedMode(true)}
|
||||
|
|
@ -171,7 +173,7 @@ function Speech() {
|
|||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<Cog />
|
||||
Advanced
|
||||
{localize('com_ui_advanced')}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
|
|||
sizeClasses="w-[180px]"
|
||||
anchor="bottom start"
|
||||
testId="EngineTTSDropdown"
|
||||
className="rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue