mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-03 00:58:50 +01:00
📜 refactor: Optimize Conversation History Nav with Cursor Pagination (#5785)
* ✨ feat: improve Nav/Conversations/Convo/NewChat component performance * ✨ feat: implement cursor-based pagination for conversations API * 🔧 refactor: remove createdAt from conversation selection in API and type definitions * 🔧 refactor: include createdAt in conversation selection and update related types * ✨ fix: search functionality and bugs with loadMoreConversations * feat: move ArchivedChats to cursor and DataTable standard * 🔧 refactor: add InfiniteQueryObserverResult type import in Nav component * feat: enhance conversation listing with pagination, sorting, and search capabilities * 🔧 refactor: remove unnecessary comment regarding lodash/debounce in ArchivedChatsTable * 🔧 refactor: remove unused translation keys for archived chats and search results * 🔧 fix: Archived Chats, Delete Convo, Duplicate Convo * 🔧 refactor: improve conversation components with layout adjustments and new translations * 🔧 refactor: simplify archive conversation mutation and improve unarchive handling; fix: update fork mutation * 🔧 refactor: decode search query parameter in conversation route; improve error handling in unarchive mutation; clean up DataTable component styles * 🔧 refactor: remove unused translation key for empty archived chats * 🚀 fix: `archivedConversation` query key not updated correctly while archiving * 🧠 feat: Bedrock Anthropic Reasoning & Update Endpoint Handling (#6163) * feat: Add thinking and thinkingBudget parameters for Bedrock Anthropic models * chore: Update @librechat/agents to version 2.1.8 * refactor: change region order in params * refactor: Add maxTokens parameter to conversation preset schema * refactor: Update agent client to use bedrockInputSchema and improve error handling for model parameters * refactor: streamline/optimize llmConfig initialization and saving for bedrock * fix: ensure config titleModel is used for all endpoints * refactor: enhance OpenAIClient and agent initialization to support endpoint checks for OpenRouter * chore: bump @google/generative-ai * ✨ feat: improve Nav/Conversations/Convo/NewChat component performance * 🔧 refactor: remove unnecessary comment regarding lodash/debounce in ArchivedChatsTable * 🔧 refactor: update translation keys for clarity; simplify conversation query parameters and improve sorting functionality in SharedLinks component * 🔧 refactor: optimize conversation loading logic and improve search handling in Nav component * fix: package-lock * fix: package-lock 2 * fix: package lock 3 * refactor: remove unused utility files and exports to clean up the codebase * refactor: remove i18n and useAuthRedirect modules to streamline codebase * refactor: optimize Conversations component and remove unused ToggleContext * refactor(Convo): add RenameForm and ConvoLink components; enhance Conversations component with responsive design * fix: add missing @azure/storage-blob dependency in package.json * refactor(Search): add error handling with toast notification for search errors * refactor: make createdAt and updatedAt fields of tConvoUpdateSchema less restrictive if timestamps are missing * chore: update @azure/storage-blob dependency to version 12.27.0, ensure package-lock is correct * refactor(Search): improve conversation handling server side * fix: eslint warning and errors * refactor(Search): improved search loading state and overall UX * Refactors conversation cache management Centralizes conversation mutation logic into dedicated utility functions for adding, updating, and removing conversations from query caches. Improves reliability and maintainability by: - Consolidating duplicate cache manipulation code - Adding type safety for infinite query data structures - Implementing consistent cache update patterns across all conversation operations - Removing obsolete conversation helper functions in favor of standardized utilities * fix: conversation handling and SSE event processing - Optimizes conversation state management with useMemo and proper hook ordering - Improves SSE event handler documentation and error handling - Adds reset guard flag for conversation changes - Removes redundant navigation call - Cleans up cursor handling logic and document structure Improves code maintainability and prevents potential race conditions in conversation state updates * refactor: add type for SearchBar `onChange` * fix: type tags * style: rounded to xl all Header buttons * fix: activeConvo in Convo not working * style(Bookmarks): improved UI * a11y(AccountSettings): fixed hover style not visible when using light theme * style(SettingsTabs): improved tab switchers and dropdowns * feat: add translations keys for Speech * chore: fix package-lock * fix(mutations): legacy import after rebase * feat: refactor conversation navigation for accessibility * fix(search): convo and message create/update date not returned * fix(search): show correct iconURL and endpoint for searched messages * fix: small UI improvements * chore: console.log cleanup * chore: fix tests * fix(ChatForm): improve conversation ID handling and clean up useMemo dependencies * chore: improve typing * chore: improve typing * fix(useSSE): clear conversation ID on submission to prevent draft restoration * refactor(OpenAIClient): clean up abort handler * refactor(abortMiddleware): change handleAbort to use function expression * feat: add PENDING_CONVO constant and update conversation ID checks * fix: final event handling on abort * fix: improve title sync and query cache sync on final event * fix: prevent overwriting cached conversation data if it already exists --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
77a21719fd
commit
650e9b4f6c
69 changed files with 3434 additions and 2139 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
61
client/src/components/Conversations/ConvoLink.tsx
Normal file
61
client/src/components/Conversations/ConvoLink.tsx
Normal 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;
|
||||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
77
client/src/components/Conversations/RenameForm.tsx
Normal file
77
client/src/components/Conversations/RenameForm.tsx
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue