diff --git a/api/models/Conversation.js b/api/models/Conversation.js index d6365e99ce4..1fd60a99547 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -87,11 +87,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; } @@ -141,75 +143,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 } }]; + filters.push({ $or: [{ isArchived: false }, { isArchived: { $exists: false } }] }); } + if (Array.isArray(tags) && tags.length > 0) { - query.tags = { $in: tags }; + filters.push({ tags: { $in: tags } }); } - query.$and = [{ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] }]; + 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 cache = {}; - const convoMap = {}; - const promises = []; - - convoIds.forEach((convo) => - promises.push( - Conversation.findOne({ - user, - conversationId: convo.conversationId, - $or: [{ expiredAt: { $exists: false } }, { expiredAt: null }], - }).lean(), - ), - ); + const conversationIds = convoIds.map((convo) => convo.conversationId); - const results = (await Promise.all(promises)).filter(Boolean); + const results = await Conversation.find({ + user, + conversationId: { $in: conversationIds }, + $or: [{ expiredAt: { $exists: false } }, { expiredAt: null }], + }).lean(); - results.forEach((convo, i) => { - const page = Math.floor(i / pageSize) + 1; - if (!cache[page]) { - cache[page] = []; - } - cache[page].push(convo); + 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 convoMap = {}; + 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' }; @@ -250,10 +277,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/server/routes/convos.js b/api/server/routes/convos.js index a4d81e24e63..2473eb68f98 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); - - if (isNaN(pageNumber) || pageNumber < 1) { - return res.status(400).json({ error: 'Invalid page number' }); - } - - let pageSize = req.query.pageSize || 25; - pageSize = parseInt(pageSize, 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(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 68cff7532b8..abdd66a1fa0 100644 --- a/api/server/routes/search.js +++ b/api/server/routes/search.js @@ -27,48 +27,72 @@ 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++) { let message = messages[i]; + if (message.conversationId.includes('--')) { message.conversationId = cleanUpPrimaryKeyValue(message.conversationId); } + if (result.convoMap[message.conversationId]) { const convo = result.convoMap[message.conversationId]; - const { title, chatGptLabel, model } = convo; - message = { ...message, ...{ title, chatGptLabel, model } }; - activeMessages.push(message); + activeMessages.push({ + ...message, + title: convo.title, + conversationId: message.conversationId, + cleanedConversationId: cleanUpPrimaryKeyValue(message.conversationId), + searchMetadata: { + matchScore: message._score, + highlightedContent: message._formatted?.text, + matchedTerms: message._matchesInfo, + }, + }); } } - result.messages = activeMessages; + + const activeConversations = []; + for (const convId in result.convoMap) { + const convo = result.convoMap[convId]; + activeConversations.push({ + title: convo.title, + user: convo.user, + conversationId: convo.conversationId, + }); + } + 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 df85c2521c2..7cbb6cd88a3 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/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 67d79c704cd..eb37bac035d 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -1,66 +1,180 @@ -import { useMemo, memo } from 'react'; +import { useMemo, memo, type FC, useCallback } from 'react'; +import { throttle } from 'lodash'; import { parseISO, isToday } from 'date-fns'; +import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; 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; +} + +const LoadingSpinner = memo(() => ( +
+ +
+)); +LoadingSpinner.displayName = 'LoadingSpinner'; + +const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => { const localize = useLocalize(); + return ( +
+ {localize(groupName as TranslationKeys) || groupName} +
+ ); +}); +DateLabel.displayName = 'DateLabel'; + +type FlattenedItem = + | { type: 'header'; groupName: string } + | { type: 'convo'; convo: TConversation } + | { type: 'spinner' }; + +const Conversations: FC = ({ + conversations: rawConversations, + moveToTop, + toggleNav, + containerRef, + loadMoreConversations, + isFetchingNextPage, +}) => { + const filteredConversations = useMemo( + () => rawConversations.filter(Boolean) as TConversation[], + [rawConversations], + ); + const groupedConversations = useMemo( - () => groupConversationsByDate(conversations), - [conversations], + () => 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], ); - return ( -
-
- - {groupedConversations.map(([groupName, convos]) => ( -
-
- {localize(groupName as TranslationKeys) || groupName} -
- {convos.map((convo, i) => ( - - ))} -
{ + const items: FlattenedItem[] = []; + groupedConversations.forEach(([groupName, convos]) => { + items.push({ type: 'header', groupName }); + items.push(...convos.map((convo) => ({ type: 'convo' as const, convo }))); + }); + if (isFetchingNextPage) { + items.push({ type: 'spinner' }); + } + return items; + }, [groupedConversations, isFetchingNextPage]); + + const cache = useMemo( + () => + new CellMeasurerCache({ + fixedWidth: true, + defaultHeight: 34, + keyMapper: (index) => { + const item = flattenedItems[index]; + if (item.type === 'header') { + return `header-${index}`; + } + if (item.type === 'spinner') { + return 'spinner'; + } + return `convo-${item.convo.conversationId}`; + }, + }), + [flattenedItems], + ); + + const rowRenderer = useCallback( + ({ index, key, parent, style }) => { + const item = flattenedItems[index]; + + const renderContent = () => { + switch (item.type) { + case 'header': + return ; + case 'spinner': + return ; + case 'convo': + return ( + + ); + default: + return null; + } + }; + + return ( + + {({ registerChild }) => ( +
+
{renderContent()}
- ))} - + )} +
+ ); + }, + [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 ( +
+
+ + {({ width, height }) => ( + + )} +
); diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index b0e6e066360..438a0aae70a 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -17,12 +17,12 @@ import store from '~/store'; type KeyEvent = KeyboardEvent; -type ConversationProps = { +interface ConversationProps { conversation: TConversation; retainView: () => void; toggleNav: () => void; isLatestConvo: boolean; -}; +} export default function Conversation({ conversation, @@ -30,167 +30,196 @@ export default function Conversation({ toggleNav, isLatestConvo, }: ConversationProps) { + const { conversationId, title = '' } = conversation; + const params = useParams(); - const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]); + const currentConvoId = 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 [renaming, setRenaming] = useState(false); - const [isPopoverActive, setIsPopoverActive] = useState(false); - const isSmallScreen = useMediaQuery('(max-width: 768px)'); const localize = useLocalize(); + const isSmallScreen = useMediaQuery('(max-width: 768px)'); - const clickHandler = async (event: MouseEvent) => { - if (event.button === 0 && (event.ctrlKey || event.metaKey)) { - toggleNav(); - return; - } - - event.preventDefault(); + const [titleInput, setTitleInput] = useState(title || ''); + const [renaming, setRenaming] = useState(false); + const [isPopoverActive, setIsPopoverActive] = useState(false); - if (currentConvoId === conversationId || isPopoverActive) { - return; - } + const inputRef = useRef(null); + const previousTitle = useRef(title); - toggleNav(); + const isActiveConvo = useMemo( + () => + currentConvoId === conversationId || + (isLatestConvo && + currentConvoId === 'new' && + activeConvos[0] != null && + activeConvos[0] !== 'new'), + [currentConvoId, conversationId, isLatestConvo, activeConvos], + ); - // set document title - if (typeof title === 'string' && title.length > 0) { - document.title = title; + useEffect(() => { + if (title !== previousTitle.current) { + setTitleInput(title as string); + previousTitle.current = 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(); + inputRef.current.select(); } }, [renaming]); - const onRename = useCallback( - (e: MouseEvent | FocusEvent | KeyEvent) => { - e.preventDefault(); - setRenaming(false); - if (titleInput === title) { + const handleClick = useCallback( + async (event: MouseEvent) => { + if (event.button === 0 && (event.ctrlKey || event.metaKey)) { + toggleNav(); return; } - if (typeof conversationId !== 'string' || conversationId === '') { + + event.preventDefault(); + + if (currentConvoId === conversationId || isPopoverActive) { return; } - updateConvoMutation.mutate( - { conversationId, title: titleInput ?? '' }, - { - onError: () => { - setTitleInput(title); - showToast({ - message: 'Failed to rename conversation', - severity: NotificationSeverity.ERROR, - showIcon: true, - }); - }, - }, + toggleNav(); + + if (typeof title === 'string' && title.length > 0) { + document.title = title; + } + + navigateWithLastTools( + conversation, + !(conversationId ?? '') || conversationId === Constants.NEW_CONVO, ); }, - [title, titleInput, conversationId, showToast, updateConvoMutation], + [ + currentConvoId, + conversationId, + isPopoverActive, + toggleNav, + title, + conversation, + navigateWithLastTools, + ], + ); + + const handleRename = useCallback(() => { + setIsPopoverActive(false); + setTitleInput(title as string); + setRenaming(true); + }, [title]); + + const handleRenameSubmit = useCallback( + async (e: MouseEvent | FocusEvent | KeyEvent) => { + e.preventDefault(); + + if (!conversationId || titleInput === title) { + setRenaming(false); + return; + } + + try { + await updateConvoMutation.mutateAsync({ + conversationId, + title: titleInput.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); + } + }, + [conversationId, title, titleInput, updateConvoMutation, showToast, localize], ); const handleKeyDown = useCallback( (e: KeyEvent) => { - if (e.key === 'Escape') { - setTitleInput(title); - setRenaming(false); - } else if (e.key === 'Enter') { - onRename(e); + switch (e.key) { + case 'Escape': + setTitleInput(title as string); + setRenaming(false); + break; + case 'Enter': + handleRenameSubmit(e); + break; } }, - [title, onRename], + [title, handleRenameSubmit], ); - const cancelRename = useCallback( + const handleCancelRename = useCallback( (e: MouseEvent) => { e.preventDefault(); - setTitleInput(title); + setTitleInput(title as string); setRenaming(false); }, [title], ); - const isActiveConvo: boolean = useMemo( - () => - currentConvoId === conversationId || - (isLatestConvo && - currentConvoId === 'new' && - activeConvos[0] != null && - activeConvos[0] !== 'new'), - [currentConvoId, conversationId, isLatestConvo, activeConvos], - ); - return (
{renaming ? ( -
+
setTitleInput(e.target.value)} onKeyDown={handleKeyDown} - aria-label={`${localize('com_ui_rename')} ${localize('com_ui_chat')}`} + onBlur={handleRenameSubmit} + maxLength={100} + aria-label={localize('com_ui_new_conversation_title')} /> -
+
) : (
{ e.preventDefault(); e.stopPropagation(); - setTitleInput(title); - setRenaming(true); + handleRename(); }} > - {title} + {title || localize('com_ui_untitled')}
- {isActiveConvo ? ( -
- ) : ( -
- )} + +