📜 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:
Marco Beretta 2025-04-15 10:04:00 +02:00 committed by GitHub
parent 77a21719fd
commit 650e9b4f6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 3434 additions and 2139 deletions

View file

@ -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<HTMLInputElement>;
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<HTMLInputElement | null>(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<HTMLAnchorElement>) => {
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<HTMLButtonElement> | FocusEvent<HTMLInputElement> | 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<HTMLButtonElement>) => {
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 (
<div
className={cn(
'group relative mt-2 flex h-9 w-full items-center rounded-lg hover:bg-surface-active-alt',
isActiveConvo ? 'bg-surface-active-alt' : '',
isSmallScreen ? 'h-12' : '',
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
)}
role="listitem"
tabIndex={0}
onClick={(e) => {
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 ? (
<div className="absolute inset-0 z-20 flex w-full items-center rounded-lg bg-surface-active-alt p-1.5">
<input
ref={inputRef}
type="text"
className="w-full rounded bg-transparent p-0.5 text-sm leading-tight focus-visible:outline-none"
value={titleInput ?? ''}
onChange={(e) => setTitleInput(e.target.value)}
onKeyDown={handleKeyDown}
aria-label={`${localize('com_ui_rename')} ${localize('com_ui_chat')}`}
/>
<div className="flex gap-1">
<button
onClick={cancelRename}
aria-label={`${localize('com_ui_cancel')} ${localize('com_ui_rename')}`}
>
<X
aria-hidden={true}
className="h-4 w-4 transition-colors duration-200 ease-in-out hover:opacity-70"
/>
</button>
<button
onClick={onRename}
aria-label={`${localize('com_ui_submit')} ${localize('com_ui_rename')}`}
>
<Check
aria-hidden={true}
className="h-4 w-4 transition-colors duration-200 ease-in-out hover:opacity-70"
/>
</button>
</div>
</div>
<RenameForm
titleInput={titleInput}
setTitleInput={setTitleInput}
onSubmit={handleRenameSubmit}
onCancel={handleCancelRename}
localize={localize}
/>
) : (
<a
href={`/c/${conversationId}`}
data-testid="convo-item"
onClick={clickHandler}
className={cn(
'flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg px-2 py-2',
isActiveConvo ? 'bg-surface-active-alt' : '',
)}
title={title ?? ''}
<ConvoLink
isActiveConvo={isActiveConvo}
title={title}
onRename={handleRename}
isSmallScreen={isSmallScreen}
localize={localize}
>
<EndpointIcon
conversation={conversation}
@ -198,23 +180,7 @@ export default function Conversation({
size={20}
context="menu-item"
/>
<div
className="relative line-clamp-1 flex-1 grow overflow-hidden"
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTitleInput(title);
setRenaming(true);
}}
>
{title}
</div>
{isActiveConvo ? (
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l" />
) : (
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l from-surface-primary-alt from-0% to-transparent group-hover:from-surface-active-alt group-hover:from-40%" />
)}
</a>
</ConvoLink>
)}
<div
className={cn(
@ -224,17 +190,7 @@ export default function Conversation({
: 'hidden group-focus-within:flex group-hover:flex',
)}
>
{!renaming && (
<ConvoOptions
title={title}
retainView={retainView}
renameHandler={renameHandler}
isActiveConvo={isActiveConvo}
conversationId={conversationId}
isPopoverActive={isPopoverActive}
setIsPopoverActive={setIsPopoverActive}
/>
)}
{!renaming && <ConvoOptions {...convoOptionsProps} />}
</div>
</div>
);