diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 6cebdff146..91c99e438e 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -1469,6 +1469,11 @@ ${convo} }); } + if (openai.abortHandler && abortController.signal) { + abortController.signal.removeEventListener('abort', openai.abortHandler); + openai.abortHandler = undefined; + } + if (!chatCompletion && UnexpectedRoleError) { throw new Error( 'OpenAI error: Invalid final message: OpenAI expects final message to include role=assistant', diff --git a/api/models/Conversation.js b/api/models/Conversation.js index dd6ef9bde1..1515a28259 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -88,11 +88,13 @@ module.exports = { */ saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => { try { - if (metadata && metadata?.context) { + if (metadata?.context) { logger.debug(`[saveConvo] ${metadata.context}`); } + const messages = await getMessages({ conversationId }, '_id'); const update = { ...convo, messages, user: req.user.id }; + if (newConversationId) { update.conversationId = newConversationId; } @@ -148,75 +150,100 @@ module.exports = { throw new Error('Failed to save conversations in bulk.'); } }, - getConvosByPage: async (user, pageNumber = 1, pageSize = 25, isArchived = false, tags) => { - const query = { user }; + getConvosByCursor: async ( + user, + { cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {}, + ) => { + const filters = [{ user }]; + if (isArchived) { - query.isArchived = true; + filters.push({ isArchived: true }); } else { - query.$or = [{ isArchived: false }, { isArchived: { $exists: false } }]; - } - if (Array.isArray(tags) && tags.length > 0) { - query.tags = { $in: tags }; + filters.push({ $or: [{ isArchived: false }, { isArchived: { $exists: false } }] }); } - query.$and = [{ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] }]; + if (Array.isArray(tags) && tags.length > 0) { + filters.push({ tags: { $in: tags } }); + } + + filters.push({ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] }); + + if (search) { + try { + const meiliResults = await Conversation.meiliSearch(search); + const matchingIds = Array.isArray(meiliResults.hits) + ? meiliResults.hits.map((result) => result.conversationId) + : []; + if (!matchingIds.length) { + return { conversations: [], nextCursor: null }; + } + filters.push({ conversationId: { $in: matchingIds } }); + } catch (error) { + logger.error('[getConvosByCursor] Error during meiliSearch', error); + return { message: 'Error during meiliSearch' }; + } + } + + if (cursor) { + filters.push({ updatedAt: { $lt: new Date(cursor) } }); + } + + const query = filters.length === 1 ? filters[0] : { $and: filters }; try { - const totalConvos = (await Conversation.countDocuments(query)) || 1; - const totalPages = Math.ceil(totalConvos / pageSize); const convos = await Conversation.find(query) - .sort({ updatedAt: -1 }) - .skip((pageNumber - 1) * pageSize) - .limit(pageSize) + .select('conversationId endpoint title createdAt updatedAt user') + .sort({ updatedAt: order === 'asc' ? 1 : -1 }) + .limit(limit + 1) .lean(); - return { conversations: convos, pages: totalPages, pageNumber, pageSize }; + + let nextCursor = null; + if (convos.length > limit) { + const lastConvo = convos.pop(); + nextCursor = lastConvo.updatedAt.toISOString(); + } + + return { conversations: convos, nextCursor }; } catch (error) { - logger.error('[getConvosByPage] Error getting conversations', error); + logger.error('[getConvosByCursor] Error getting conversations', error); return { message: 'Error getting conversations' }; } }, - getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 25) => { + getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => { try { - if (!convoIds || convoIds.length === 0) { - return { conversations: [], pages: 1, pageNumber, pageSize }; + if (!convoIds?.length) { + return { conversations: [], nextCursor: null, convoMap: {} }; + } + + const conversationIds = convoIds.map((convo) => convo.conversationId); + + const results = await Conversation.find({ + user, + conversationId: { $in: conversationIds }, + $or: [{ expiredAt: { $exists: false } }, { expiredAt: null }], + }).lean(); + + results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + + let filtered = results; + if (cursor && cursor !== 'start') { + const cursorDate = new Date(cursor); + filtered = results.filter((convo) => new Date(convo.updatedAt) < cursorDate); + } + + const limited = filtered.slice(0, limit + 1); + let nextCursor = null; + if (limited.length > limit) { + const lastConvo = limited.pop(); + nextCursor = lastConvo.updatedAt.toISOString(); } - const cache = {}; const convoMap = {}; - const promises = []; - - convoIds.forEach((convo) => - promises.push( - Conversation.findOne({ - user, - conversationId: convo.conversationId, - $or: [{ expiredAt: { $exists: false } }, { expiredAt: null }], - }).lean(), - ), - ); - - const results = (await Promise.all(promises)).filter(Boolean); - - results.forEach((convo, i) => { - const page = Math.floor(i / pageSize) + 1; - if (!cache[page]) { - cache[page] = []; - } - cache[page].push(convo); + limited.forEach((convo) => { convoMap[convo.conversationId] = convo; }); - const totalPages = Math.ceil(results.length / pageSize); - cache.pages = totalPages; - cache.pageSize = pageSize; - return { - cache, - conversations: cache[pageNumber] || [], - pages: totalPages || 1, - pageNumber, - pageSize, - convoMap, - }; + return { conversations: limited, nextCursor, convoMap }; } catch (error) { logger.error('[getConvosQueried] Error getting conversations', error); return { message: 'Error fetching conversations' }; @@ -257,10 +284,26 @@ module.exports = { * logger.error(result); // { n: 5, ok: 1, deletedCount: 5, messages: { n: 10, ok: 1, deletedCount: 10 } } */ deleteConvos: async (user, filter) => { - let toRemove = await Conversation.find({ ...filter, user }).select('conversationId'); - const ids = toRemove.map((instance) => instance.conversationId); - let deleteCount = await Conversation.deleteMany({ ...filter, user }); - deleteCount.messages = await deleteMessages({ conversationId: { $in: ids } }); - return deleteCount; + try { + const userFilter = { ...filter, user }; + + const conversations = await Conversation.find(userFilter).select('conversationId'); + const conversationIds = conversations.map((c) => c.conversationId); + + if (!conversationIds.length) { + throw new Error('Conversation not found or already deleted.'); + } + + const deleteConvoResult = await Conversation.deleteMany(userFilter); + + const deleteMessagesResult = await deleteMessages({ + conversationId: { $in: conversationIds }, + }); + + return { ...deleteConvoResult, messages: deleteMessagesResult }; + } catch (error) { + logger.error('[deleteConvos] Error deleting conversations and messages', error); + throw error; + } }, }; diff --git a/api/package.json b/api/package.json index 33fb734122..90e6d3d40a 100644 --- a/api/package.json +++ b/api/package.json @@ -39,7 +39,7 @@ "@aws-sdk/s3-request-presigner": "^3.758.0", "@azure/identity": "^4.7.0", "@azure/search-documents": "^12.0.0", - "@azure/storage-blob": "^12.26.0", + "@azure/storage-blob": "^12.27.0", "@google/generative-ai": "^0.23.0", "@googleapis/youtube": "^20.0.0", "@keyv/redis": "^4.3.3", @@ -48,7 +48,7 @@ "@langchain/google-genai": "^0.2.2", "@langchain/google-vertexai": "^0.2.3", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.17", + "@librechat/agents": "^2.4.20", "@librechat/data-schemas": "*", "@waylaidwanderer/fetch-event-source": "^3.0.1", "axios": "^1.8.2", diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index b10f6586f9..b5a7c4eb90 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -108,8 +108,8 @@ async function abortMessage(req, res) { res.send(JSON.stringify(finalEvent)); } -const handleAbort = () => { - return async (req, res) => { +const handleAbort = function () { + return async function (req, res) { try { if (isEnabled(process.env.LIMIT_CONCURRENT_MESSAGES)) { await clearPendingReq({ userId: req.user.id }); diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index a4d81e24e6..2473eb68f9 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -1,16 +1,17 @@ const multer = require('multer'); const express = require('express'); const { CacheKeys, EModelEndpoint } = require('librechat-data-provider'); -const { getConvosByPage, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation'); +const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation'); const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork'); const { storage, importFileFilter } = require('~/server/routes/files/multer'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); const { importConversations } = require('~/server/utils/import'); const { createImportLimiters } = require('~/server/middleware'); const { deleteToolCalls } = require('~/models/ToolCall'); +const { isEnabled, sleep } = require('~/server/utils'); const getLogStores = require('~/cache/getLogStores'); -const { sleep } = require('~/server/utils'); const { logger } = require('~/config'); + const assistantClients = { [EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'), [EModelEndpoint.assistants]: require('~/server/services/Endpoints/assistants'), @@ -20,28 +21,30 @@ const router = express.Router(); router.use(requireJwtAuth); router.get('/', async (req, res) => { - let pageNumber = req.query.pageNumber || 1; - pageNumber = parseInt(pageNumber, 10); + const limit = parseInt(req.query.limit, 10) || 25; + const cursor = req.query.cursor; + const isArchived = isEnabled(req.query.isArchived); + const search = req.query.search ? decodeURIComponent(req.query.search) : undefined; + const order = req.query.order || 'desc'; - if (isNaN(pageNumber) || pageNumber < 1) { - return res.status(400).json({ error: 'Invalid page number' }); - } - - let pageSize = req.query.pageSize || 25; - pageSize = parseInt(pageSize, 10); - - if (isNaN(pageSize) || pageSize < 1) { - return res.status(400).json({ error: 'Invalid page size' }); - } - const isArchived = req.query.isArchived === 'true'; let tags; if (req.query.tags) { tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags]; - } else { - tags = undefined; } - res.status(200).send(await getConvosByPage(req.user.id, pageNumber, pageSize, isArchived, tags)); + try { + const result = await getConvosByCursor(req.user.id, { + cursor, + limit, + isArchived, + tags, + search, + order, + }); + res.status(200).json(result); + } catch (error) { + res.status(500).json({ error: 'Error fetching conversations' }); + } }); router.get('/:conversationId', async (req, res) => { @@ -76,22 +79,28 @@ router.post('/gen_title', async (req, res) => { } }); -router.post('/clear', async (req, res) => { +router.delete('/', async (req, res) => { let filter = {}; const { conversationId, source, thread_id, endpoint } = req.body.arg; - if (conversationId) { - filter = { conversationId }; + + // Prevent deletion of all conversations + if (!conversationId && !source && !thread_id && !endpoint) { + return res.status(400).json({ + error: 'no parameters provided', + }); } - if (source === 'button' && !conversationId) { + if (conversationId) { + filter = { conversationId }; + } else if (source === 'button') { return res.status(200).send('No conversationId provided'); } if ( - typeof endpoint != 'undefined' && + typeof endpoint !== 'undefined' && Object.prototype.propertyIsEnumerable.call(assistantClients, endpoint) ) { - /** @type {{ openai: OpenAI}} */ + /** @type {{ openai: OpenAI }} */ const { openai } = await assistantClients[endpoint].initializeClient({ req, res }); try { const response = await openai.beta.threads.del(thread_id); @@ -101,9 +110,6 @@ router.post('/clear', async (req, res) => { } } - // for debugging deletion source - // logger.debug('source:', source); - try { const dbResponse = await deleteConvos(req.user.id, filter); await deleteToolCalls(req.user.id, filter.conversationId); @@ -114,6 +120,17 @@ router.post('/clear', async (req, res) => { } }); +router.delete('/all', async (req, res) => { + try { + const dbResponse = await deleteConvos(req.user.id, {}); + await deleteToolCalls(req.user.id); + res.status(201).json(dbResponse); + } catch (error) { + logger.error('Error clearing conversations', error); + res.status(500).send('Error clearing conversations'); + } +}); + router.post('/update', async (req, res) => { const update = req.body.arg; diff --git a/api/server/routes/search.js b/api/server/routes/search.js index 0341a241df..109fc7996a 100644 --- a/api/server/routes/search.js +++ b/api/server/routes/search.js @@ -4,9 +4,9 @@ const { MeiliSearch } = require('meilisearch'); const { Conversation, getConvosQueried } = require('~/models/Conversation'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc'); +const { Message, getMessage } = require('~/models/Message'); const { reduceHits } = require('~/lib/utils/reduceHits'); const { isEnabled } = require('~/server/utils'); -const { Message } = require('~/models/Message'); const keyvRedis = require('~/cache/keyvRedis'); const { logger } = require('~/config'); @@ -27,25 +27,24 @@ router.get('/sync', async function (req, res) { router.get('/', async function (req, res) { try { - let user = req.user.id ?? ''; - const { q } = req.query; - const pageNumber = req.query.pageNumber || 1; - const key = `${user}:search:${q}`; + const user = req.user.id ?? ''; + const { q, cursor = 'start' } = req.query; + const key = `${user}:search:${q}:${cursor}`; const cached = await cache.get(key); if (cached) { logger.debug('[/search] cache hit: ' + key); - const { pages, pageSize, messages } = cached; - res - .status(200) - .send({ conversations: cached[pageNumber], pages, pageNumber, pageSize, messages }); - return; + return res.status(200).send(cached); } - const messages = (await Message.meiliSearch(q, undefined, true)).hits; - const titles = (await Conversation.meiliSearch(q)).hits; + const [messageResults, titleResults] = await Promise.all([ + Message.meiliSearch(q, undefined, true), + Conversation.meiliSearch(q), + ]); + const messages = messageResults.hits; + const titles = titleResults.hits; const sortedHits = reduceHits(messages, titles); - const result = await getConvosQueried(user, sortedHits, pageNumber); + const result = await getConvosQueried(user, sortedHits, cursor); const activeMessages = []; for (let i = 0; i < messages.length; i++) { @@ -55,20 +54,53 @@ router.get('/', async function (req, res) { } if (result.convoMap[message.conversationId]) { const convo = result.convoMap[message.conversationId]; - const { title, chatGptLabel, model } = convo; - message = { ...message, ...{ title, chatGptLabel, model } }; - activeMessages.push(message); + + const dbMessage = await getMessage({ user, messageId: message.messageId }); + activeMessages.push({ + ...message, + title: convo.title, + conversationId: message.conversationId, + model: convo.model, + isCreatedByUser: dbMessage?.isCreatedByUser, + endpoint: dbMessage?.endpoint, + iconURL: dbMessage?.iconURL, + }); } } - result.messages = activeMessages; + + const activeConversations = []; + for (const convId in result.convoMap) { + const convo = result.convoMap[convId]; + + if (convo.isArchived) { + continue; + } + + activeConversations.push({ + title: convo.title, + user: convo.user, + conversationId: convo.conversationId, + endpoint: convo.endpoint, + endpointType: convo.endpointType, + model: convo.model, + createdAt: convo.createdAt, + updatedAt: convo.updatedAt, + }); + } + if (result.cache) { result.cache.messages = activeMessages; + result.cache.conversations = activeConversations; cache.set(key, result.cache, expiration); - delete result.cache; } - delete result.convoMap; - res.status(200).send(result); + const response = { + nextCursor: result.nextCursor ?? null, + messages: activeMessages, + conversations: activeConversations, + }; + + res.status(200).send(response); } catch (error) { logger.error('[/search] Error while searching messages & conversations', error); res.status(500).send({ message: 'Error searching' }); diff --git a/client/package.json b/client/package.json index db82362a18..52dc95a581 100644 --- a/client/package.json +++ b/client/package.json @@ -118,6 +118,7 @@ "@testing-library/user-event": "^14.4.3", "@types/jest": "^29.5.14", "@types/js-cookie": "^3.0.6", + "@types/lodash": "^4.17.15", "@types/node": "^20.3.0", "@types/react": "^18.2.11", "@types/react-dom": "^18.2.4", diff --git a/client/src/Providers/SearchContext.tsx b/client/src/Providers/SearchContext.tsx index 678818aa18..b767be2ce1 100644 --- a/client/src/Providers/SearchContext.tsx +++ b/client/src/Providers/SearchContext.tsx @@ -1,6 +1,5 @@ import { createContext, useContext } from 'react'; -import useSearch from '~/hooks/Conversations/useSearch'; -type SearchContextType = ReturnType; +import { UseSearchMessagesResult } from '~/hooks/Conversations/useSearch'; -export const SearchContext = createContext({} as SearchContextType); +export const SearchContext = createContext({} as UseSearchMessagesResult); export const useSearchContext = () => useContext(SearchContext); diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 3551436ee8..02eeccb626 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -548,7 +548,8 @@ export type TResData = TBaseResData & { responseMessage: t.TMessage; }; -export type TFinalResData = TBaseResData & { +export type TFinalResData = Omit & { + conversation: Partial & Pick; requestMessage?: t.TMessage; responseMessage?: t.TMessage; }; diff --git a/client/src/components/Audio/Voices.tsx b/client/src/components/Audio/Voices.tsx index acd07a26c6..7d8b3f9655 100644 --- a/client/src/components/Audio/Voices.tsx +++ b/client/src/components/Audio/Voices.tsx @@ -29,6 +29,7 @@ export function BrowserVoiceDropdown() { onChange={handleVoiceChange} sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]" testId="BrowserVoiceDropdown" + className="rounded-xl" /> ); @@ -57,6 +58,7 @@ export function ExternalVoiceDropdown() { onChange={handleVoiceChange} sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]" testId="ExternalVoiceDropdown" + className="rounded-xl" /> ); diff --git a/client/src/components/Bookmarks/BookmarkItem.tsx b/client/src/components/Bookmarks/BookmarkItem.tsx index 92a6df0b54..60698a3165 100644 --- a/client/src/components/Bookmarks/BookmarkItem.tsx +++ b/client/src/components/Bookmarks/BookmarkItem.tsx @@ -34,19 +34,22 @@ const BookmarkItem: FC = ({ tag, selected, handleSubmit, icon, .. if (icon != null) { return icon; } + if (isLoading) { return ; } + if (selected) { return ; } + return ; }; return ( { - const { title: _t, ...convo } = conversation ?? ({} as TConversation); setAddedConvo({ ...convo, @@ -42,7 +41,7 @@ function AddMultiConvo() { role="button" onClick={clickHandler} data-testid="parameters-button" - className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary" + className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary" > diff --git a/client/src/components/Chat/ExportAndShareMenu.tsx b/client/src/components/Chat/ExportAndShareMenu.tsx index 954393abaa..7f37ebbc72 100644 --- a/client/src/components/Chat/ExportAndShareMenu.tsx +++ b/client/src/components/Chat/ExportAndShareMenu.tsx @@ -79,7 +79,7 @@ export default function ExportAndShareMenu({ { () => conversation?.endpointType ?? conversation?.endpoint, [conversation?.endpointType, conversation?.endpoint], ); + const conversationId = useMemo( + () => conversation?.conversationId ?? Constants.NEW_CONVO, + [conversation?.conversationId], + ); - const isRTL = useMemo(() => chatDirection === 'rtl', [chatDirection.toLowerCase()]); + const isRTL = useMemo( + () => (chatDirection != null ? chatDirection?.toLowerCase() === 'rtl' : false), + [chatDirection], + ); const invalidAssistant = useMemo( () => isAssistantsEndpoint(endpoint) && @@ -110,10 +117,10 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { }, [isCollapsed]); useAutoSave({ - conversationId: conversation?.conversationId, - textAreaRef, files, setFiles, + textAreaRef, + conversationId, }); const { submitMessage, submitPrompt } = useSubmitMessage(); @@ -166,7 +173,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { const handleSaveBadges = useCallback(() => { setIsEditingBadges(false); setBackupBadges([]); - }, []); + }, [setIsEditingBadges, setBackupBadges]); const handleCancelBadges = useCallback(() => { if (backupBadges.length > 0) { @@ -174,7 +181,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { } setIsEditingBadges(false); setBackupBadges([]); - }, [backupBadges, setBadges]); + }, [backupBadges, setBadges, setIsEditingBadges]); const isMoreThanThreeRows = visualRowCount > 3; @@ -195,8 +202,9 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { 'mx-auto flex flex-row gap-3 sm:px-2', maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl', centerFormOnLanding && - (!conversation?.conversationId || conversation?.conversationId === Constants.NEW_CONVO) && - !isSubmitting + (conversationId == null || conversationId === Constants.NEW_CONVO) && + !isSubmitting && + conversation?.messages?.length === 0 ? 'transition-all duration-200 sm:mb-28' : 'sm:mb-10', )} @@ -290,7 +298,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { = 1 diff --git a/client/src/components/Chat/Menus/BookmarkMenu.tsx b/client/src/components/Chat/Menus/BookmarkMenu.tsx index 326d1dcd4a..48945a577e 100644 --- a/client/src/components/Chat/Menus/BookmarkMenu.tsx +++ b/client/src/components/Chat/Menus/BookmarkMenu.tsx @@ -170,7 +170,7 @@ const BookmarkMenu: FC = () => { id="bookmark-menu-button" aria-label={localize('com_ui_bookmarks_add')} className={cn( - 'mt-text-sm flex size-10 flex-shrink-0 items-center justify-center gap-2 rounded-lg border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover', + 'mt-text-sm flex size-10 flex-shrink-0 items-center justify-center gap-2 rounded-xl border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover', isMenuOpen ? 'bg-surface-hover' : '', )} data-testid="bookmark-menu" diff --git a/client/src/components/Chat/Menus/PresetsMenu.tsx b/client/src/components/Chat/Menus/PresetsMenu.tsx index 224a006d59..a0d0790f99 100644 --- a/client/src/components/Chat/Menus/PresetsMenu.tsx +++ b/client/src/components/Chat/Menus/PresetsMenu.tsx @@ -30,7 +30,7 @@ const PresetsMenu: FC = () => { tabIndex={0} role="button" data-testid="presets-button" - className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary" + className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary" > diff --git a/client/src/components/Chat/Messages/SearchButtons.tsx b/client/src/components/Chat/Messages/SearchButtons.tsx index c9feada2fa..9707767ad6 100644 --- a/client/src/components/Chat/Messages/SearchButtons.tsx +++ b/client/src/components/Chat/Messages/SearchButtons.tsx @@ -1,8 +1,8 @@ import { Link } from 'lucide-react'; import type { TMessage } from 'librechat-data-provider'; import { useLocalize, useNavigateToConvo } from '~/hooks'; +import { findConversationInInfinite } from '~/utils'; import { useSearchContext } from '~/Providers'; -import { getConversationById } from '~/utils'; export default function SearchButtons({ message }: { message: TMessage }) { const localize = useLocalize(); @@ -17,7 +17,7 @@ export default function SearchButtons({ message }: { message: TMessage }) { const clickHandler = (event: React.MouseEvent) => { event.preventDefault(); - const conversation = getConversationById(searchQueryRes?.data, conversationId); + const conversation = findConversationInInfinite(searchQueryRes?.data, conversationId); if (!conversation) { return; } diff --git a/client/src/components/Chat/Messages/SearchMessage.tsx b/client/src/components/Chat/Messages/SearchMessage.tsx index 30db3ac092..c7ac2c69c3 100644 --- a/client/src/components/Chat/Messages/SearchMessage.tsx +++ b/client/src/components/Chat/Messages/SearchMessage.tsx @@ -10,7 +10,30 @@ import SubRow from './SubRow'; import { cn } from '~/utils'; import store from '~/store'; -export default function Message({ message }: Pick) { +const MessageAvatar = ({ iconData }: { iconData: TMessageIcon }) => ( +
+
+
+ +
+
+
+); + +const MessageBody = ({ message, messageLabel, fontSize }) => ( +
+
{messageLabel}
+ + + + + +
+); + +export default function SearchMessage({ message }: Pick) { const UsernameDisplay = useRecoilValue(store.UsernameDisplay); const fontSize = useRecoilValue(store.fontSize); const { user } = useAuthContext(); @@ -18,60 +41,42 @@ export default function Message({ message }: Pick) { const iconData: TMessageIcon = useMemo( () => ({ - endpoint: message?.endpoint, - model: message?.model, + endpoint: message?.endpoint ?? '', + model: message?.model ?? '', iconURL: message?.iconURL ?? '', - isCreatedByUser: message?.isCreatedByUser, + isCreatedByUser: message?.isCreatedByUser ?? false, }), - [message?.model, message?.iconURL, message?.endpoint, message?.isCreatedByUser], + [message?.endpoint, message?.model, message?.iconURL, message?.isCreatedByUser], ); + const messageLabel = useMemo(() => { + if (message?.isCreatedByUser) { + return UsernameDisplay + ? (user?.name ?? '') || (user?.username ?? '') + : localize('com_user_message'); + } + return message?.sender ?? ''; + }, [ + message?.isCreatedByUser, + message?.sender, + UsernameDisplay, + user?.name, + user?.username, + localize, + ]); + if (!message) { return null; } - const { isCreatedByUser } = message; - - let messageLabel = ''; - if (isCreatedByUser) { - messageLabel = UsernameDisplay - ? (user?.name ?? '') || (user?.username ?? '') - : localize('com_user_message'); - } else { - messageLabel = message.sender ?? ''; - } - return ( - <> -
-
-
-
-
-
-
- -
-
-
-
-
-
{messageLabel}
-
-
- -
-
- - - - -
-
+
+
+
+ +
- +
); } diff --git a/client/src/components/Chat/TemporaryChat.tsx b/client/src/components/Chat/TemporaryChat.tsx index 13c05bee6c..25985e8192 100644 --- a/client/src/components/Chat/TemporaryChat.tsx +++ b/client/src/components/Chat/TemporaryChat.tsx @@ -37,34 +37,28 @@ export function TemporaryChat() { return (
-
- - {temporaryBadge.icon && ( - - )} - - } - /> -
+ className={cn( + 'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary', + isTemporary + ? 'bg-surface-active shadow-md' + : 'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md', + 'active:shadow-inner', + )} + > + {temporaryBadge.icon && ( + + )} + + } + />
); } diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 67d79c704c..cec289405a 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -1,67 +1,203 @@ -import { useMemo, memo } from 'react'; +import { useMemo, memo, type FC, useCallback } from 'react'; +import throttle from 'lodash/throttle'; import { parseISO, isToday } from 'date-fns'; +import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; +import { useLocalize, TranslationKeys, useMediaQuery } from '~/hooks'; import { TConversation } from 'librechat-data-provider'; -import { useLocalize, TranslationKeys } from '~/hooks'; import { groupConversationsByDate } from '~/utils'; +import { Spinner } from '~/components/svg'; import Convo from './Convo'; -const Conversations = ({ - conversations, - moveToTop, - toggleNav, -}: { +interface ConversationsProps { conversations: Array; moveToTop: () => void; toggleNav: () => void; -}) => { + containerRef: React.RefObject; + loadMoreConversations: () => void; + isFetchingNextPage: boolean; + isSearchLoading: boolean; +} + +const LoadingSpinner = memo(() => ( + +)); + +const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => { const localize = useLocalize(); - const groupedConversations = useMemo( - () => groupConversationsByDate(conversations), - [conversations], + return ( +
+ {localize(groupName as TranslationKeys) || groupName} +
); +}); + +DateLabel.displayName = 'DateLabel'; + +type FlattenedItem = + | { type: 'header'; groupName: string } + | { type: 'convo'; convo: TConversation }; + +const MemoizedConvo = memo( + ({ + conversation, + retainView, + toggleNav, + isLatestConvo, + }: { + conversation: TConversation; + retainView: () => void; + toggleNav: () => void; + isLatestConvo: boolean; + }) => { + return ( + + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.conversation.conversationId === nextProps.conversation.conversationId && + prevProps.conversation.title === nextProps.conversation.title && + prevProps.isLatestConvo === nextProps.isLatestConvo && + prevProps.conversation.endpoint === nextProps.conversation.endpoint + ); + }, +); + +const Conversations: FC = ({ + conversations: rawConversations, + moveToTop, + toggleNav, + containerRef, + loadMoreConversations, + isFetchingNextPage, + isSearchLoading, +}) => { + const isSmallScreen = useMediaQuery('(max-width: 768px)'); + const convoHeight = isSmallScreen ? 44 : 34; + + const filteredConversations = useMemo( + () => rawConversations.filter(Boolean) as TConversation[], + [rawConversations], + ); + + const groupedConversations = useMemo( + () => groupConversationsByDate(filteredConversations), + [filteredConversations], + ); + const firstTodayConvoId = useMemo( () => - conversations.find((convo) => convo && convo.updatedAt && isToday(parseISO(convo.updatedAt))) - ?.conversationId, - [conversations], + filteredConversations.find((convo) => convo.updatedAt && isToday(parseISO(convo.updatedAt))) + ?.conversationId ?? undefined, + [filteredConversations], + ); + + const flattenedItems = useMemo(() => { + const items: FlattenedItem[] = []; + groupedConversations.forEach(([groupName, convos]) => { + items.push({ type: 'header', groupName }); + items.push(...convos.map((convo) => ({ type: 'convo' as const, convo }))); + }); + return items; + }, [groupedConversations]); + + const cache = useMemo( + () => + new CellMeasurerCache({ + fixedWidth: true, + defaultHeight: convoHeight, + keyMapper: (index) => { + const item = flattenedItems[index]; + return item.type === 'header' ? `header-${index}` : `convo-${item.convo.conversationId}`; + }, + }), + [flattenedItems, convoHeight], + ); + + const rowRenderer = useCallback( + ({ index, key, parent, style }) => { + const item = flattenedItems[index]; + return ( + + {({ registerChild }) => ( +
+ {item.type === 'header' ? ( + + ) : ( + + )} +
+ )} +
+ ); + }, + [cache, flattenedItems, firstTodayConvoId, moveToTop, toggleNav], + ); + + const getRowHeight = useCallback( + ({ index }: { index: number }) => cache.getHeight(index, 0), + [cache], + ); + + const throttledLoadMore = useMemo( + () => throttle(loadMoreConversations, 300), + [loadMoreConversations], + ); + + const handleRowsRendered = useCallback( + ({ stopIndex }: { stopIndex: number }) => { + if (stopIndex >= flattenedItems.length - 2) { + throttledLoadMore(); + } + }, + [flattenedItems.length, throttledLoadMore], ); return ( -
-
- - {groupedConversations.map(([groupName, convos]) => ( -
-
- {localize(groupName as TranslationKeys) || groupName} -
- {convos.map((convo, i) => ( - - ))} -
+ {isSearchLoading ? ( +
+ + Loading... +
+ ) : ( +
+ + {({ width, height }) => ( + } + width={width} + height={height} + deferredMeasurementCache={cache} + rowCount={flattenedItems.length} + rowHeight={getRowHeight} + rowRenderer={rowRenderer} + overscanRowCount={10} + className="outline-none" + style={{ outline: 'none' }} + role="list" + aria-label="Conversations" + onRowsRendered={handleRowsRendered} /> -
- ))} - -
+ )} + +
+ )} + {isFetchingNextPage && !isSearchLoading && ( +
+ +
+ )}
); }; diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index b0e6e06636..b31cfb5a55 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -1,28 +1,26 @@ -import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { useRecoilValue } from 'recoil'; -import { Check, X } from 'lucide-react'; import { useParams } from 'react-router-dom'; import { Constants } from 'librechat-data-provider'; -import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react'; import type { TConversation } from 'librechat-data-provider'; import { useNavigateToConvo, useMediaQuery, useLocalize } from '~/hooks'; import { useUpdateConversationMutation } from '~/data-provider'; import EndpointIcon from '~/components/Endpoints/EndpointIcon'; import { useGetEndpointsQuery } from '~/data-provider'; import { NotificationSeverity } from '~/common'; -import { useToastContext } from '~/Providers'; import { ConvoOptions } from './ConvoOptions'; +import { useToastContext } from '~/Providers'; +import RenameForm from './RenameForm'; +import ConvoLink from './ConvoLink'; import { cn } from '~/utils'; import store from '~/store'; -type KeyEvent = KeyboardEvent; - -type ConversationProps = { +interface ConversationProps { conversation: TConversation; retainView: () => void; toggleNav: () => void; isLatestConvo: boolean; -}; +} export default function Conversation({ conversation, @@ -31,27 +29,81 @@ export default function Conversation({ isLatestConvo, }: ConversationProps) { const params = useParams(); + const localize = useLocalize(); + const { showToast } = useToastContext(); const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]); const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? ''); const activeConvos = useRecoilValue(store.allConversationsSelector); const { data: endpointsConfig } = useGetEndpointsQuery(); const { navigateWithLastTools } = useNavigateToConvo(); - const { showToast } = useToastContext(); - const { conversationId, title } = conversation; - const inputRef = useRef(null); - const [titleInput, setTitleInput] = useState(title); + const isSmallScreen = useMediaQuery('(max-width: 768px)'); + const { conversationId, title = '' } = conversation; + + const [titleInput, setTitleInput] = useState(title || ''); const [renaming, setRenaming] = useState(false); const [isPopoverActive, setIsPopoverActive] = useState(false); - const isSmallScreen = useMediaQuery('(max-width: 768px)'); - const localize = useLocalize(); - const clickHandler = async (event: MouseEvent) => { - if (event.button === 0 && (event.ctrlKey || event.metaKey)) { - toggleNav(); + const previousTitle = useRef(title); + + useEffect(() => { + if (title !== previousTitle.current) { + setTitleInput(title as string); + previousTitle.current = title; + } + }, [title]); + + const isActiveConvo = useMemo(() => { + if (conversationId === Constants.NEW_CONVO) { + return currentConvoId === Constants.NEW_CONVO; + } + + if (currentConvoId !== Constants.NEW_CONVO) { + return currentConvoId === conversationId; + } else { + const latestConvo = activeConvos?.[0]; + return latestConvo === conversationId; + } + }, [currentConvoId, conversationId, activeConvos]); + + const handleRename = () => { + setIsPopoverActive(false); + setTitleInput(title as string); + setRenaming(true); + }; + + const handleRenameSubmit = async (newTitle: string) => { + if (!conversationId || newTitle === title) { + setRenaming(false); return; } - event.preventDefault(); + try { + await updateConvoMutation.mutateAsync({ + conversationId, + title: newTitle.trim() || localize('com_ui_untitled'), + }); + setRenaming(false); + } catch (error) { + setTitleInput(title as string); + showToast({ + message: localize('com_ui_rename_failed'), + severity: NotificationSeverity.ERROR, + showIcon: true, + }); + setRenaming(false); + } + }; + + const handleCancelRename = () => { + setTitleInput(title as string); + setRenaming(false); + }; + + const handleNavigation = (ctrlOrMetaKey: boolean) => { + if (ctrlOrMetaKey) { + toggleNav(); + return; + } if (currentConvoId === conversationId || isPopoverActive) { return; @@ -59,138 +111,68 @@ export default function Conversation({ toggleNav(); - // set document title if (typeof title === 'string' && title.length > 0) { document.title = title; } - /* Note: Latest Message should not be reset if existing convo */ + navigateWithLastTools( conversation, !(conversationId ?? '') || conversationId === Constants.NEW_CONVO, ); }; - const renameHandler = useCallback(() => { - setIsPopoverActive(false); - setTitleInput(title); - setRenaming(true); - }, [title]); - - useEffect(() => { - if (renaming && inputRef.current) { - inputRef.current.focus(); - } - }, [renaming]); - - const onRename = useCallback( - (e: MouseEvent | FocusEvent | KeyEvent) => { - e.preventDefault(); - setRenaming(false); - if (titleInput === title) { - return; - } - if (typeof conversationId !== 'string' || conversationId === '') { - return; - } - - updateConvoMutation.mutate( - { conversationId, title: titleInput ?? '' }, - { - onError: () => { - setTitleInput(title); - showToast({ - message: 'Failed to rename conversation', - severity: NotificationSeverity.ERROR, - showIcon: true, - }); - }, - }, - ); - }, - [title, titleInput, conversationId, showToast, updateConvoMutation], - ); - - const handleKeyDown = useCallback( - (e: KeyEvent) => { - if (e.key === 'Escape') { - setTitleInput(title); - setRenaming(false); - } else if (e.key === 'Enter') { - onRename(e); - } - }, - [title, onRename], - ); - - const cancelRename = useCallback( - (e: MouseEvent) => { - e.preventDefault(); - setTitleInput(title); - setRenaming(false); - }, - [title], - ); - - const isActiveConvo: boolean = useMemo( - () => - currentConvoId === conversationId || - (isLatestConvo && - currentConvoId === 'new' && - activeConvos[0] != null && - activeConvos[0] !== 'new'), - [currentConvoId, conversationId, isLatestConvo, activeConvos], - ); + const convoOptionsProps = { + title, + retainView, + renameHandler: handleRename, + isActiveConvo, + conversationId, + isPopoverActive, + setIsPopoverActive, + }; return (
{ + if (renaming) { + return; + } + if (e.button === 0) { + handleNavigation(e.ctrlKey || e.metaKey); + } + }} + onKeyDown={(e) => { + if (renaming) { + return; + } + if (e.key === 'Enter') { + handleNavigation(false); + } + }} + style={{ cursor: renaming ? 'default' : 'pointer' }} + data-testid="convo-item" > {renaming ? ( -
- setTitleInput(e.target.value)} - onKeyDown={handleKeyDown} - aria-label={`${localize('com_ui_rename')} ${localize('com_ui_chat')}`} - /> -
- - -
-
+ ) : ( - -
{ - e.preventDefault(); - e.stopPropagation(); - setTitleInput(title); - setRenaming(true); - }} - > - {title} -
- {isActiveConvo ? ( -
- ) : ( -
- )} - + )}
- {!renaming && ( - - )} + {!renaming && }
); diff --git a/client/src/components/Conversations/ConvoLink.tsx b/client/src/components/Conversations/ConvoLink.tsx new file mode 100644 index 0000000000..4e8d5888e4 --- /dev/null +++ b/client/src/components/Conversations/ConvoLink.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { cn } from '~/utils'; + +interface ConvoLinkProps { + isActiveConvo: boolean; + title: string | null; + onRename: () => void; + isSmallScreen: boolean; + localize: (key: any, options?: any) => string; + children: React.ReactNode; +} + +const ConvoLink: React.FC = ({ + isActiveConvo, + title, + onRename, + isSmallScreen, + localize, + children, +}) => { + return ( +
+ {children} +
{ + if (isSmallScreen) { + return; + } + e.preventDefault(); + e.stopPropagation(); + onRename(); + }} + role="button" + aria-label={isSmallScreen ? undefined : localize('com_ui_double_click_to_rename')} + > + {title || localize('com_ui_untitled')} +
+ + ); +}; + +export default ConvoLink; diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx index c9b697ece3..d367dd3963 100644 --- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx +++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx @@ -1,12 +1,17 @@ -import { useState, useId, useRef, memo } from 'react'; +import { useState, useId, useRef, memo, useCallback, useMemo } from 'react'; import * as Menu from '@ariakit/react/menu'; +import { useParams, useNavigate } from 'react-router-dom'; import { Ellipsis, Share2, Copy, Archive, Pen, Trash } from 'lucide-react'; import type { MouseEvent } from 'react'; -import type * as t from '~/common'; -import { useDuplicateConversationMutation, useGetStartupConfig } from '~/data-provider'; -import { useLocalize, useArchiveHandler, useNavigateToConvo } from '~/hooks'; +import { + useDuplicateConversationMutation, + useGetStartupConfig, + useArchiveConvoMutation, +} from '~/data-provider'; +import { useLocalize, useNavigateToConvo, useNewConvo } from '~/hooks'; import { useToastContext, useChatContext } from '~/Providers'; -import { DropdownPopup } from '~/components/ui'; +import { DropdownPopup, Spinner } from '~/components'; +import { NotificationSeverity } from '~/common'; import DeleteButton from './DeleteButton'; import ShareButton from './ShareButton'; import { cn } from '~/utils'; @@ -31,14 +36,49 @@ function ConvoOptions({ const localize = useLocalize(); const { index } = useChatContext(); const { data: startupConfig } = useGetStartupConfig(); - const archiveHandler = useArchiveHandler(conversationId, true, retainView); const { navigateToConvo } = useNavigateToConvo(index); const { showToast } = useToastContext(); + + const navigate = useNavigate(); + const { conversationId: currentConvoId } = useParams(); + const { newConversation } = useNewConvo(); + const shareButtonRef = useRef(null); const deleteButtonRef = useRef(null); const [showShareDialog, setShowShareDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const archiveConvoMutation = useArchiveConvoMutation(); + + const archiveHandler = async () => { + const convoId = conversationId ?? ''; + + if (!convoId) { + return; + } + + archiveConvoMutation.mutate( + { conversationId: convoId, isArchived: true }, + { + onSuccess: () => { + if (currentConvoId === convoId || currentConvoId === 'new') { + newConversation(); + navigate('/c/new', { replace: true }); + } + retainView(); + setIsPopoverActive(false); + }, + onError: () => { + showToast({ + message: localize('com_ui_archive_error'), + severity: NotificationSeverity.ERROR, + showIcon: true, + }); + }, + }, + ); + }; + const duplicateConversation = useDuplicateConversationMutation({ onSuccess: (data) => { navigateToConvo(data.conversation); @@ -46,6 +86,7 @@ function ConvoOptions({ message: localize('com_ui_duplication_success'), status: 'success', }); + setIsPopoverActive(false); }, onMutate: () => { showToast({ @@ -61,56 +102,118 @@ function ConvoOptions({ }, }); - const shareHandler = () => { + const isDuplicateLoading = duplicateConversation.isLoading; + const isArchiveLoading = archiveConvoMutation.isLoading; + + const handleShareClick = useCallback(() => { setShowShareDialog(true); - }; + }, []); - const deleteHandler = () => { + const handleDeleteClick = useCallback(() => { setShowDeleteDialog(true); - }; + }, []); - const duplicateHandler = () => { - setIsPopoverActive(false); + const handleArchiveClick = useCallback(async () => { + const convoId = conversationId ?? ''; + if (!convoId) { + return; + } + + archiveConvoMutation.mutate( + { conversationId: convoId, isArchived: true }, + { + onSuccess: () => { + if (currentConvoId === convoId || currentConvoId === 'new') { + newConversation(); + navigate('/c/new', { replace: true }); + } + retainView(); + setIsPopoverActive(false); + }, + onError: () => { + showToast({ + message: localize('com_ui_archive_error'), + severity: NotificationSeverity.ERROR, + showIcon: true, + }); + }, + }, + ); + }, [ + conversationId, + currentConvoId, + archiveConvoMutation, + navigate, + newConversation, + retainView, + setIsPopoverActive, + showToast, + localize, + ]); + + const handleDuplicateClick = useCallback(() => { duplicateConversation.mutate({ conversationId: conversationId ?? '', }); - }; + }, [conversationId, duplicateConversation]); - const dropdownItems: t.MenuItemProps[] = [ - { - label: localize('com_ui_share'), - onClick: shareHandler, - icon: , - show: startupConfig && startupConfig.sharedLinksEnabled, - /** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */ - hideOnClick: false, - ref: shareButtonRef, - render: (props) => + +
+ ); } @@ -84,7 +101,7 @@ export default function DeleteButton({ setShowDeleteDialog, triggerRef, }: DeleteButtonProps) { - if (showDeleteDialog === undefined && setShowDeleteDialog === undefined) { + if (showDeleteDialog === undefined || setShowDeleteDialog === undefined) { return null; } @@ -93,8 +110,9 @@ export default function DeleteButton({ } return ( - + void; + onSubmit: (title: string) => void; + onCancel: () => void; + localize: (key: any, options?: any) => string; +} + +const RenameForm: React.FC = ({ + titleInput, + setTitleInput, + onSubmit, + onCancel, + localize, +}) => { + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, []); + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Escape': + onCancel(); + break; + case 'Enter': + onSubmit(titleInput); + break; + } + }; + + return ( +
+ setTitleInput(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => onSubmit(titleInput)} + maxLength={100} + aria-label={localize('com_ui_new_conversation_title')} + /> +
+ + +
+
+ ); +}; + +export default RenameForm; diff --git a/client/src/components/Nav/AccountSettings.tsx b/client/src/components/Nav/AccountSettings.tsx index 70bf90d7c4..7d4e6576c2 100644 --- a/client/src/components/Nav/AccountSettings.tsx +++ b/client/src/components/Nav/AccountSettings.tsx @@ -30,7 +30,7 @@ function AccountSettings() {
diff --git a/client/src/components/Nav/Bookmarks/BookmarkNav.tsx b/client/src/components/Nav/Bookmarks/BookmarkNav.tsx index c1de20f1ad..fa8ab06d6a 100644 --- a/client/src/components/Nav/Bookmarks/BookmarkNav.tsx +++ b/client/src/components/Nav/Bookmarks/BookmarkNav.tsx @@ -33,7 +33,7 @@ const BookmarkNav: FC = ({ tags, setTags, isSmallScreen }: Boo data-testid="bookmark-menu" >
-
+
{tags.length > 0 ? (
- + {data && conversation && ( tag.count > 0) }}> { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - - return render( - - - - - -