📜 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

@ -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"
/>
</div>
);
@ -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"
/>
</div>
);

View file

@ -34,19 +34,22 @@ const BookmarkItem: FC<MenuItemProps> = ({ tag, selected, handleSubmit, icon, ..
if (icon != null) {
return icon;
}
if (isLoading) {
return <Spinner className="size-4" />;
}
if (selected) {
return <BookmarkFilledIcon className="size-4" />;
}
return <BookmarkIcon className="size-4" />;
};
return (
<MenuItem
aria-label={tag as string}
className="group flex w-full gap-2 rounded-lg p-2.5 text-sm text-text-primary transition-colors duration-200 focus:outline-none data-[focus]:bg-surface-secondary data-[focus]:ring-2 data-[focus]:ring-primary"
className="group flex w-full gap-2 rounded-lg p-2.5 text-sm text-text-primary transition-colors duration-200 focus:outline-none data-[focus]:bg-surface-hover data-[focus-visible]:ring-2 data-[focus-visible]:ring-primary"
{...rest}
as="button"
onClick={clickHandler}

View file

@ -12,7 +12,6 @@ function AddMultiConvo() {
const localize = useLocalize();
const clickHandler = () => {
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"
>
<PlusCircle size={16} aria-label="Plus Icon" />
</TooltipAnchor>

View file

@ -79,7 +79,7 @@ export default function ExportAndShareMenu({
<Ariakit.MenuButton
id="export-menu-button"
aria-label="Export options"
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"
>
<Upload
className="icon-md text-text-secondary"

View file

@ -85,8 +85,15 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
() => 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 }) => {
</div>
<BadgeRow
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
conversationId={conversation?.conversationId ?? Constants.NEW_CONVO}
conversationId={conversationId}
onChange={setBadges}
isInChat={
Array.isArray(conversation?.messages) && conversation.messages.length >= 1

View file

@ -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"

View file

@ -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"
>
<BookCopy size={16} aria-label="Preset Icon" />
</TooltipAnchor>

View file

@ -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<HTMLButtonElement>) => {
event.preventDefault();
const conversation = getConversationById(searchQueryRes?.data, conversationId);
const conversation = findConversationInInfinite(searchQueryRes?.data, conversationId);
if (!conversation) {
return;
}

View file

@ -10,7 +10,30 @@ import SubRow from './SubRow';
import { cn } from '~/utils';
import store from '~/store';
export default function Message({ message }: Pick<TMessageProps, 'message'>) {
const MessageAvatar = ({ iconData }: { iconData: TMessageIcon }) => (
<div className="relative flex flex-shrink-0 flex-col items-end">
<div className="pt-0.5">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Icon iconData={iconData} />
</div>
</div>
</div>
);
const MessageBody = ({ message, messageLabel, fontSize }) => (
<div
className={cn('relative flex w-11/12 flex-col', message.isCreatedByUser ? '' : 'agent-turn')}
>
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
<SearchContent message={message} />
<SubRow classes="text-xs">
<MinimalHoverButtons message={message} />
<SearchButtons message={message} />
</SubRow>
</div>
);
export default function SearchMessage({ message }: Pick<TMessageProps, 'message'>) {
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
const fontSize = useRecoilValue(store.fontSize);
const { user } = useAuthContext();
@ -18,60 +41,42 @@ export default function Message({ message }: Pick<TMessageProps, 'message'>) {
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 (
<>
<div className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent">
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<div className="final-completion group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<div className="relative flex flex-shrink-0 flex-col items-end">
<div>
<div className="pt-0.5">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Icon iconData={iconData} />
</div>
</div>
</div>
</div>
<div
className={cn('relative flex w-11/12 flex-col', isCreatedByUser ? '' : 'agent-turn')}
>
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
<div className="flex-col gap-1 md:gap-3">
<div className="flex max-w-full flex-grow flex-col gap-0">
<SearchContent message={message} />
</div>
</div>
<SubRow classes="text-xs">
<MinimalHoverButtons message={message} />
<SearchButtons message={message} />
</SubRow>
</div>
</div>
<div className="text-token-text-primary w-full bg-transparent">
<div className="m-auto p-4 py-2 md:gap-6">
<div className="final-completion group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<MessageAvatar iconData={iconData} />
<MessageBody message={message} messageLabel={messageLabel} fontSize={fontSize} />
</div>
</div>
</>
</div>
);
}

View file

@ -37,34 +37,28 @@ export function TemporaryChat() {
return (
<div className="relative flex flex-wrap items-center gap-2">
<div className="badge-icon h-full">
<TooltipAnchor
description={localize(temporaryBadge.label)}
render={
<motion.button
onClick={handleBadgeToggle}
<TooltipAnchor
description={localize(temporaryBadge.label)}
render={
<button
onClick={handleBadgeToggle}
aria-label={localize(temporaryBadge.label)}
className={cn(
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg 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:scale-95 active:shadow-inner',
)}
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
>
{temporaryBadge.icon && (
<temporaryBadge.icon
className={cn(
'relative h-5 w-5 md:h-4 md:w-4',
!temporaryBadge.label && 'mx-auto',
)}
/>
)}
</motion.button>
}
/>
</div>
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 && (
<temporaryBadge.icon
className={cn('relative h-5 w-5 md:h-4 md:w-4', !temporaryBadge.label && 'mx-auto')}
/>
)}
</button>
}
/>
</div>
);
}

View file

@ -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<TConversation | null>;
moveToTop: () => void;
toggleNav: () => void;
}) => {
containerRef: React.RefObject<HTMLDivElement | List>;
loadMoreConversations: () => void;
isFetchingNextPage: boolean;
isSearchLoading: boolean;
}
const LoadingSpinner = memo(() => (
<Spinner className="m-1 mx-auto mb-4 h-4 w-4 text-text-primary" />
));
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
const localize = useLocalize();
const groupedConversations = useMemo(
() => groupConversationsByDate(conversations),
[conversations],
return (
<div className="mt-2 pl-2 pt-1 text-text-secondary" style={{ fontSize: '0.7rem' }}>
{localize(groupName as TranslationKeys) || groupName}
</div>
);
});
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 (
<Convo
conversation={conversation}
retainView={retainView}
toggleNav={toggleNav}
isLatestConvo={isLatestConvo}
/>
);
},
(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<ConversationsProps> = ({
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 (
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
{({ registerChild }) => (
<div ref={registerChild} style={style}>
{item.type === 'header' ? (
<DateLabel groupName={item.groupName} />
) : (
<MemoizedConvo
conversation={item.convo}
retainView={moveToTop}
toggleNav={toggleNav}
isLatestConvo={item.convo.conversationId === firstTodayConvoId}
/>
)}
</div>
)}
</CellMeasurer>
);
},
[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 (
<div className="text-token-text-primary flex flex-col gap-2 pb-2 text-sm">
<div>
<span>
{groupedConversations.map(([groupName, convos]) => (
<div key={groupName}>
<div
className="text-text-secondary"
style={{
fontSize: '0.7rem',
marginTop: '20px',
marginBottom: '5px',
paddingLeft: '10px',
}}
>
{localize(groupName as TranslationKeys) || groupName}
</div>
{convos.map((convo, i) => (
<Convo
key={`${groupName}-${convo.conversationId}-${i}`}
isLatestConvo={convo.conversationId === firstTodayConvoId}
conversation={convo}
retainView={moveToTop}
toggleNav={toggleNav}
/>
))}
<div
style={{
marginTop: '5px',
marginBottom: '5px',
}}
<div className="relative flex h-full flex-col pb-2 text-sm text-text-primary">
{isSearchLoading ? (
<div className="flex flex-1 items-center justify-center">
<Spinner className="text-text-primary" />
<span className="ml-2 text-text-primary">Loading...</span>
</div>
) : (
<div className="flex-1">
<AutoSizer>
{({ width, height }) => (
<List
ref={containerRef as React.RefObject<List>}
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}
/>
</div>
))}
</span>
</div>
)}
</AutoSizer>
</div>
)}
{isFetchingNextPage && !isSearchLoading && (
<div className="mt-2">
<LoadingSpinner />
</div>
)}
</div>
);
};

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>
);

View file

@ -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<ConvoLinkProps> = ({
isActiveConvo,
title,
onRename,
isSmallScreen,
localize,
children,
}) => {
return (
<div
className={cn(
'flex grow items-center gap-2 overflow-hidden rounded-lg px-2',
isActiveConvo ? 'bg-surface-active-alt' : '',
)}
title={title ?? undefined}
aria-current={isActiveConvo ? 'page' : undefined}
style={{ width: '100%' }}
>
{children}
<div
className="relative flex-1 grow overflow-hidden whitespace-nowrap"
style={{ textOverflow: 'clip' }}
onDoubleClick={(e) => {
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')}
</div>
<div
className={cn(
'absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l',
isActiveConvo
? 'from-surface-active-alt'
: 'from-surface-primary-alt from-0% to-transparent group-hover:from-surface-active-alt group-hover:from-40%',
)}
aria-hidden="true"
/>
</div>
);
};
export default ConvoLink;

View file

@ -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<HTMLButtonElement>(null);
const deleteButtonRef = useRef<HTMLButtonElement>(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: <Share2 className="icon-sm mr-2 text-text-primary" />,
show: startupConfig && startupConfig.sharedLinksEnabled,
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
hideOnClick: false,
ref: shareButtonRef,
render: (props) => <button {...props} />,
},
{
label: localize('com_ui_rename'),
onClick: renameHandler,
icon: <Pen className="icon-sm mr-2 text-text-primary" />,
},
{
label: localize('com_ui_duplicate'),
onClick: duplicateHandler,
icon: <Copy className="icon-sm mr-2 text-text-primary" />,
},
{
label: localize('com_ui_archive'),
onClick: archiveHandler,
icon: <Archive className="icon-sm mr-2 text-text-primary" />,
},
{
label: localize('com_ui_delete'),
onClick: deleteHandler,
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
hideOnClick: false,
ref: deleteButtonRef,
render: (props) => <button {...props} />,
},
];
const dropdownItems = useMemo(
() => [
{
label: localize('com_ui_share'),
onClick: handleShareClick,
icon: <Share2 className="icon-sm mr-2 text-text-primary" />,
show: startupConfig && startupConfig.sharedLinksEnabled,
hideOnClick: false,
ref: shareButtonRef,
render: (props) => <button {...props} />,
},
{
label: localize('com_ui_rename'),
onClick: renameHandler,
icon: <Pen className="icon-sm mr-2 text-text-primary" />,
},
{
label: localize('com_ui_duplicate'),
onClick: handleDuplicateClick,
hideOnClick: false,
icon: isDuplicateLoading ? (
<Spinner className="size-4" />
) : (
<Copy className="icon-sm mr-2 text-text-primary" />
),
},
{
label: localize('com_ui_archive'),
onClick: handleArchiveClick,
hideOnClick: false,
icon: isArchiveLoading ? (
<Spinner className="size-4" />
) : (
<Archive className="icon-sm mr-2 text-text-primary" />
),
},
{
label: localize('com_ui_delete'),
onClick: handleDeleteClick,
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
hideOnClick: false,
ref: deleteButtonRef,
render: (props) => <button {...props} />,
},
],
[
localize,
handleShareClick,
startupConfig,
renameHandler,
handleDuplicateClick,
isDuplicateLoading,
handleArchiveClick,
isArchiveLoading,
handleDeleteClick,
],
);
const menuId = useId();
@ -129,6 +232,7 @@ function ConvoOptions({
? 'opacity-100'
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
)}
onClick={(e) => e.stopPropagation()}
>
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
</Menu.MenuButton>
@ -158,4 +262,11 @@ function ConvoOptions({
);
}
export default memo(ConvoOptions);
export default memo(ConvoOptions, (prevProps, nextProps) => {
return (
prevProps.conversationId === nextProps.conversationId &&
prevProps.title === nextProps.title &&
prevProps.isPopoverActive === nextProps.isPopoverActive &&
prevProps.isActiveConvo === nextProps.isActiveConvo
);
});

View file

@ -1,12 +1,21 @@
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import type { TMessage } from 'librechat-data-provider';
import {
Label,
OGDialog,
OGDialogTitle,
OGDialogContent,
OGDialogHeader,
Button,
Spinner,
} from '~/components';
import { useDeleteConversationMutation } from '~/data-provider';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useNewConvo } from '~/hooks';
import { OGDialog, Label } from '~/components';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
type DeleteButtonProps = {
conversationId: string;
@ -18,10 +27,12 @@ type DeleteButtonProps = {
};
export function DeleteConversationDialog({
setShowDeleteDialog,
conversationId,
retainView,
title,
}: {
setShowDeleteDialog: (value: boolean) => void;
conversationId: string;
retainView: () => void;
title: string;
@ -29,17 +40,26 @@ export function DeleteConversationDialog({
const localize = useLocalize();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const { newConversation } = useNewConvo();
const { conversationId: currentConvoId } = useParams();
const deleteConvoMutation = useDeleteConversationMutation({
const deleteMutation = useDeleteConversationMutation({
onSuccess: () => {
setShowDeleteDialog(false);
if (currentConvoId === conversationId || currentConvoId === 'new') {
newConversation();
navigate('/c/new', { replace: true });
}
retainView();
},
onError: () => {
showToast({
message: localize('com_ui_convo_delete_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
});
const confirmDelete = useCallback(() => {
@ -47,32 +67,29 @@ export function DeleteConversationDialog({
const thread_id = messages?.[messages.length - 1]?.thread_id;
const endpoint = messages?.[messages.length - 1]?.endpoint;
deleteConvoMutation.mutate({ conversationId, thread_id, endpoint, source: 'button' });
}, [conversationId, deleteConvoMutation, queryClient]);
deleteMutation.mutate({ conversationId, thread_id, endpoint, source: 'button' });
}, [conversationId, deleteMutation, queryClient]);
return (
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_conversation')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} <strong>{title}</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: confirmDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
<OGDialogContent
title={localize('com_ui_delete_confirm') + ' ' + title}
className="w-11/12 max-w-md"
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle>
</OGDialogHeader>
<div>
{localize('com_ui_delete_confirm')} <strong>{title}</strong> ?
</div>
<div className="flex justify-end gap-4 pt-4">
<Button aria-label="cancel" variant="outline" onClick={() => setShowDeleteDialog(false)}>
{localize('com_ui_cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteMutation.isLoading}>
{deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete')}
</Button>
</div>
</OGDialogContent>
);
}
@ -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 (
<OGDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog} triggerRef={triggerRef}>
<OGDialog open={showDeleteDialog!} onOpenChange={setShowDeleteDialog!} triggerRef={triggerRef}>
<DeleteConversationDialog
setShowDeleteDialog={setShowDeleteDialog}
conversationId={conversationId}
retainView={retainView}
title={title}

View file

@ -0,0 +1,4 @@
export * from './DeleteButton';
export { default as ShareButton } from './ShareButton';
export { default as SharedLinkButton } from './SharedLinkButton';
export { default as ConvoOptions } from './ConvoOptions';

View file

@ -0,0 +1,77 @@
import React, { useEffect, useRef } from 'react';
import { Check, X } from 'lucide-react';
import type { KeyboardEvent } from 'react';
interface RenameFormProps {
titleInput: string;
setTitleInput: (value: string) => void;
onSubmit: (title: string) => void;
onCancel: () => void;
localize: (key: any, options?: any) => string;
}
const RenameForm: React.FC<RenameFormProps> = ({
titleInput,
setTitleInput,
onSubmit,
onCancel,
localize,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, []);
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'Escape':
onCancel();
break;
case 'Enter':
onSubmit(titleInput);
break;
}
};
return (
<div
className="absolute inset-0 z-20 flex w-full items-center rounded-lg bg-surface-active-alt p-1.5"
role="form"
aria-label={localize('com_ui_rename_conversation')}
>
<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}
onBlur={() => onSubmit(titleInput)}
maxLength={100}
aria-label={localize('com_ui_new_conversation_title')}
/>
<div className="flex gap-1" role="toolbar">
<button
onClick={() => onCancel()}
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
aria-label={localize('com_ui_cancel')}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
<button
onClick={() => onSubmit(titleInput)}
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
aria-label={localize('com_ui_save')}
>
<Check className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
);
};
export default RenameForm;

View file

@ -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">

View file

@ -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

View file

@ -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();
});
});

View file

@ -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;

View file

@ -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">

View file

@ -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"

View file

@ -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)}

View file

@ -30,6 +30,7 @@ export default function FontSizeSelector() {
onChange={handleChange}
testId="font-size-selector"
sizeClasses="w-[150px]"
className="rounded-xl"
/>
</div>
);

View file

@ -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>

View file

@ -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>

View file

@ -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>
</>
);
}

View file

@ -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>
);

View file

@ -32,6 +32,7 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
options={endpointOptions}
sizeClasses="w-[180px]"
testId="EngineSTTDropdown"
className="rounded-xl"
/>
</div>
);

View file

@ -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>

View file

@ -33,6 +33,7 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
sizeClasses="w-[180px]"
anchor="bottom start"
testId="EngineTTSDropdown"
className="rounded-xl"
/>
</div>
);

View file

@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
Row,
@ -23,11 +24,13 @@ import {
TableHead,
TableHeader,
AnimatedSearchInput,
Skeleton,
} from './';
import { TrashIcon, Spinner } from '~/components/svg';
import { useLocalize, useMediaQuery } from '~/hooks';
import { cn } from '~/utils';
import { LocalizeFunction } from '~/common';
import { cn } from '~/utils';
import store from '~/store';
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
meta?: {
@ -77,6 +80,7 @@ interface DataTableProps<TData, TValue> {
showCheckboxes?: boolean;
onFilterChange?: (value: string) => void;
filterValue?: string;
isLoading?: boolean;
}
const TableRowComponent = <TData, TValue>({
@ -103,17 +107,11 @@ const TableRowComponent = <TData, TValue>({
return (
<TableRow
data-state={row.getIsSelected() ? 'selected' : undefined}
className={`
motion-safe:animate-fadeIn border-b
border-border-light transition-all duration-300
ease-out
hover:bg-surface-secondary
${isSearching ? 'opacity-50' : 'opacity-100'}
${isSearching ? 'scale-98' : 'scale-100'}
`}
className="motion-safe:animate-fadeIn border-b border-border-light transition-all duration-300 ease-out hover:bg-surface-secondary"
style={{
animationDelay: `${index * 20}ms`,
transform: `translateY(${isSearching ? '4px' : '0'})`,
opacity: isSearching ? 0.5 : 1,
}}
>
{row.getVisibleCells().map((cell) => {
@ -132,12 +130,7 @@ const TableRowComponent = <TData, TValue>({
return (
<TableCell
key={cell.id}
className={`
w-0 max-w-0 px-2 py-1 align-middle text-xs
transition-all duration-300 sm:px-4
sm:py-2 sm:text-sm
${isSearching ? 'blur-[0.3px]' : 'blur-0'}
`}
className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm"
style={getColumnStyle(
cell.column.columnDef as TableColumn<TData, TValue>,
isSmallScreen,
@ -178,7 +171,7 @@ const DeleteButton = memo(
isDeleting: boolean;
disabled: boolean;
isSmallScreen: boolean;
localize:LocalizeFunction;
localize: LocalizeFunction;
}) => {
if (!onDelete) {
return null;
@ -217,12 +210,14 @@ export default function DataTable<TData, TValue>({
showCheckboxes = true,
onFilterChange,
filterValue,
isLoading,
}: DataTableProps<TData, TValue>) {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const tableContainerRef = useRef<HTMLDivElement>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isSearchEnabled = useRecoilValue(store.isSearchEnabled);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [sorting, setSorting] = useState<SortingState>(defaultSort);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@ -342,10 +337,35 @@ export default function DataTable<TData, TValue>({
}
}, [onDelete, table]);
const getRandomWidth = () => Math.floor(Math.random() * (410 - 170 + 1)) + 170;
const skeletons = Array.from({ length: 13 }, (_, index) => {
const randomWidth = getRandomWidth();
const firstDataColumnIndex = tableColumns[0]?.id === 'select' ? 1 : 0;
return (
<TableRow key={index} className="motion-safe:animate-fadeIn border-b border-border-light">
{tableColumns.map((column, columnIndex) => {
const style = getColumnStyle(column as TableColumn<TData, TValue>, isSmallScreen);
const isFirstDataColumn = columnIndex === firstDataColumnIndex;
return (
<TableCell key={column.id} className="px-2 py-1 sm:px-4 sm:py-2" style={style}>
<Skeleton
className="h-6"
style={isFirstDataColumn ? { width: `${randomWidth}px` } : { width: '100%' }}
/>
</TableCell>
);
})}
</TableRow>
);
});
return (
<div className={cn('flex h-full flex-col gap-4', className)}>
{/* Table controls */}
<div className="flex flex-wrap items-center gap-2 py-2 sm:gap-4 sm:py-4">
<div className="flex flex-wrap items-center gap-2 sm:gap-4">
{enableRowSelection && showCheckboxes && (
<DeleteButton
onDelete={handleDelete}
@ -355,7 +375,7 @@ export default function DataTable<TData, TValue>({
localize={localize}
/>
)}
{filterColumn !== undefined && table.getColumn(filterColumn) && (
{filterColumn !== undefined && table.getColumn(filterColumn) && isSearchEnabled && (
<div className="relative flex-1">
<AnimatedSearchInput
value={searchTerm}
@ -411,6 +431,8 @@ export default function DataTable<TData, TValue>({
</tr>
)}
{isLoading && skeletons}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (