feat: Implement Conversation Duplication & UI Improvements (#5036)

* feat(ui): enhance conversation components and add duplication

- feat: add conversation duplication functionality
- fix: resolve OGDialogTemplate display issues
- style: improve mobile dropdown component design
- chore: standardize shared link title formatting

* style: update active item background color in select-item

* feat(conversation): add duplicate conversation functionality and UI integration

* feat(conversation): enable title renaming on double-click and improve input focus styles

* fix(conversation): remove "(Copy)" suffix from duplicated conversation title in logging

* fix(RevokeKeysButton): correct className duration property for smoother transitions

* refactor(conversation): ensure proper parent-child relationships and timestamps when message cloning

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
Danny Avila 2024-12-18 11:10:34 -05:00 committed by GitHub
parent 649c7a6032
commit e8bde332c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 717 additions and 85 deletions

View file

@ -82,7 +82,7 @@ const AttachFile = ({ isRTL, disabled, setToolResource, handleFileChange }: Atta
return (
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>
<div className="relative">
<div className="relative select-none">
<DropdownPopup
menuId="attach-file-menu"
isOpen={isPopoverActive}

View file

@ -53,6 +53,7 @@ export default function Conversation({
}
event.preventDefault();
if (currentConvoId === conversationId || isPopoverActive) {
return;
}
@ -155,7 +156,7 @@ export default function Conversation({
<input
ref={inputRef}
type="text"
className="w-full rounded bg-transparent p-0.5 text-sm leading-tight outline-none"
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}
@ -199,7 +200,17 @@ export default function Conversation({
size={20}
context="menu-item"
/>
<div className="relative line-clamp-1 flex-1 grow overflow-hidden">{title}</div>
<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" />
) : (
@ -215,16 +226,17 @@ export default function Conversation({
: 'hidden group-focus-within:flex group-hover:flex',
)}
>
<ConvoOptions
title={title}
renaming={renaming}
retainView={retainView}
renameHandler={renameHandler}
isActiveConvo={isActiveConvo}
conversationId={conversationId}
isPopoverActive={isPopoverActive}
setIsPopoverActive={setIsPopoverActive}
/>
{!renaming && (
<ConvoOptions
title={title}
retainView={retainView}
renameHandler={renameHandler}
isActiveConvo={isActiveConvo}
conversationId={conversationId}
isPopoverActive={isPopoverActive}
setIsPopoverActive={setIsPopoverActive}
/>
)}
</div>
</div>
);

View file

@ -1,9 +1,11 @@
import { useState, useId } from 'react';
import * as Ariakit from '@ariakit/react';
import { Ellipsis, Share2, Archive, Pen, Trash } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import { Ellipsis, Share2, Copy, Archive, Pen, Trash } from 'lucide-react';
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import type { MouseEvent } from 'react';
import { useLocalize, useArchiveHandler } from '~/hooks';
import { useLocalize, useArchiveHandler, useNavigateToConvo } from '~/hooks';
import { useToastContext, useChatContext } from '~/Providers';
import { useDuplicateConversationMutation } from '~/data-provider';
import { DropdownPopup } from '~/components/ui';
import DeleteButton from './DeleteButton';
import ShareButton from './ShareButton';
@ -12,7 +14,6 @@ import { cn } from '~/utils';
export default function ConvoOptions({
conversationId,
title,
renaming,
retainView,
renameHandler,
isPopoverActive,
@ -21,7 +22,6 @@ export default function ConvoOptions({
}: {
conversationId: string | null;
title: string | null;
renaming: boolean;
retainView: () => void;
renameHandler: (e: MouseEvent) => void;
isPopoverActive: boolean;
@ -29,10 +29,37 @@ export default function ConvoOptions({
isActiveConvo: boolean;
}) {
const localize = useLocalize();
const { index } = useChatContext();
const { data: startupConfig } = useGetStartupConfig();
const archiveHandler = useArchiveHandler(conversationId, true, retainView);
const { navigateToConvo } = useNavigateToConvo(index);
const { showToast } = useToastContext();
const [showShareDialog, setShowShareDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const archiveHandler = useArchiveHandler(conversationId, true, retainView);
const duplicateConversation = useDuplicateConversationMutation({
onSuccess: (data) => {
if (data != null) {
navigateToConvo(data.conversation);
showToast({
message: localize('com_ui_duplication_success'),
status: 'success',
});
}
},
onMutate: () => {
showToast({
message: localize('com_ui_duplication_processing'),
status: 'info',
});
},
onError: () => {
showToast({
message: localize('com_ui_duplication_error'),
status: 'error',
});
},
});
const shareHandler = () => {
setIsPopoverActive(false);
@ -44,27 +71,39 @@ export default function ConvoOptions({
setShowDeleteDialog(true);
};
const duplicateHandler = () => {
setIsPopoverActive(false);
duplicateConversation.mutate({
conversationId: conversationId ?? '',
});
};
const dropdownItems = [
{
label: localize('com_ui_rename'),
onClick: renameHandler,
icon: <Pen className="icon-md mr-2 text-text-secondary" />,
},
{
label: localize('com_ui_share'),
onClick: shareHandler,
icon: <Share2 className="icon-md mr-2 text-text-secondary" />,
icon: <Share2 className="icon-sm mr-2 text-text-primary" />,
show: startupConfig && startupConfig.sharedLinksEnabled,
},
{
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-md mr-2 text-text-secondary" />,
icon: <Archive className="icon-sm mr-2 text-text-primary" />,
},
{
label: localize('com_ui_delete'),
onClick: deleteHandler,
icon: <Trash className="icon-md mr-2 text-text-secondary" />,
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
},
];
@ -76,7 +115,7 @@ export default function ConvoOptions({
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={
<Ariakit.MenuButton
<Menu.MenuButton
id="conversation-menu-button"
aria-label={localize('com_nav_convo_menu_options')}
className={cn(
@ -84,11 +123,10 @@ export default function ConvoOptions({
isActiveConvo === true
? 'opacity-100'
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
renaming === true ? 'pointer-events-none opacity-0' : '',
)}
>
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
</Ariakit.MenuButton>
</Menu.MenuButton>
}
items={dropdownItems}
menuId={menuId}

View file

@ -6,7 +6,6 @@ import { useUpdateSharedLinkMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { Button } from '~/components/ui';
import { useLocalize } from '~/hooks';
export default function SharedLinkButton({
@ -112,7 +111,7 @@ export default function SharedLinkButton({
onClick={() => {
handlers.handler();
}}
className="btn btn-primary flex items-center"
className="btn btn-primary flex items-center justify-center"
>
{isCopying && (
<>

View file

@ -177,11 +177,14 @@ const Nav = ({
<SearchBar clearSearch={clearSearch} isSmallScreen={isSmallScreen} />
)}
{hasAccessToBookmarks === true && (
<BookmarkNav
tags={tags}
setTags={setTags}
isSmallScreen={isSmallScreen}
/>
<>
<div className="mt-1.5" />
<BookmarkNav
tags={tags}
setTags={setTags}
isSmallScreen={isSmallScreen}
/>
</>
)}
</>
}

View file

@ -73,7 +73,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
}
<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 outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
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"
value={text}
onChange={onChange}
onKeyDown={(e) => {

View file

@ -88,7 +88,7 @@ function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_conversation')}
title={localize('com_ui_delete_shared_link')}
className="max-w-[450px]"
main={
<>

View file

@ -21,7 +21,7 @@ export default function SharedLinks() {
title={localize('com_nav_shared_links')}
className="max-w-[1000px]"
showCancelButton={false}
main={<ShareLinkTable />}
main={<ShareLinkTable className="w-full" />}
/>
</OGDialog>
</div>

View file

@ -166,8 +166,7 @@ const AdminSettings = () => {
</Ariakit.MenuButton>
}
items={roleDropdownItems}
className="border border-border-light bg-surface-primary"
itemClassName="hover:bg-surface-tertiary items-center justify-center"
itemClassName="items-center justify-center"
sameWidth={true}
/>
</div>

View file

@ -166,8 +166,7 @@ const AdminSettings = () => {
</Ariakit.MenuButton>
}
items={roleDropdownItems}
className="border border-border-light bg-surface-primary"
itemClassName="hover:bg-surface-tertiary items-center justify-center"
itemClassName="items-center justify-center"
sameWidth={true}
/>
</div>

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import * as Select from '@ariakit/react/select';
import { cn } from '~/utils/';
import type { Option } from '~/common';
import { cn } from '~/utils/';
interface DropdownProps {
value: string;

View file

@ -45,10 +45,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({
{trigger}
<Ariakit.Menu
id={menuId}
className={cn(
'absolute z-50 mt-2 overflow-hidden rounded-lg bg-header-primary p-1.5 shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
className,
)}
className={cn('popover-ui z-50', className)}
gutter={gutter}
modal={modal}
sameWidth={sameWidth}
@ -62,7 +59,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({
<Ariakit.MenuItem
key={index}
className={cn(
'group flex w-full cursor-pointer items-center gap-2 rounded-lg p-2.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover',
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
itemClassName,
)}
disabled={item.disabled}
@ -75,7 +72,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({
}}
>
{item.icon != null && (
<span className={cn('mr-2 h-5 w-5', iconClassName)} aria-hidden="true">
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
{item.icon}
</span>
)}

View file

@ -59,29 +59,33 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDi
overlayClassName={overlayClassName}
showCloseButton={showCloseButton}
ref={ref}
className={cn('border-none bg-background text-foreground', className ?? '')}
className={cn('w-11/12 border-none bg-background text-foreground', className ?? '')}
onClick={(e) => e.stopPropagation()}
>
<OGDialogHeader className={cn(headerClassName ?? '')}>
<OGDialogTitle>{title}</OGDialogTitle>
{description && <OGDialogDescription className="">{description}</OGDialogDescription>}
{description && (
<OGDialogDescription className="items-center justify-center">
{description}
</OGDialogDescription>
)}
</OGDialogHeader>
<div className={cn('px-0', mainClassName)}>{main != null ? main : null}</div>
<div className={cn('px-0 py-2', mainClassName)}>{main != null ? main : null}</div>
<OGDialogFooter className={footerClassName}>
<div>{leftButtons != null ? leftButtons : null}</div>
<div className="flex h-auto gap-3">
<div>{leftButtons != null ? <div className="mt-3 sm:mt-0">{leftButtons}</div> : null}</div>
<div className="flex h-auto gap-3 max-sm:w-full max-sm:flex-col sm:flex-row">
{buttons != null ? buttons : null}
{showCancelButton && (
<OGDialogClose className="btn btn-neutral border-token-border-light relative rounded-lg text-sm ring-offset-2 focus:ring-2 focus:ring-black dark:ring-offset-0">
<OGDialogClose className="btn btn-neutral border-token-border-light relative justify-center rounded-lg text-sm ring-offset-2 focus:ring-2 focus:ring-black dark:ring-offset-0 max-sm:order-last max-sm:w-full sm:order-first">
{Cancel}
</OGDialogClose>
)}
{buttons != null ? buttons : null}
{selection ? (
<OGDialogClose
onClick={selectHandler}
className={`${
selectClasses ?? defaultSelect
} flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm`}
} flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm max-sm:order-first max-sm:w-full sm:order-none`}
>
{selectText}
</OGDialogClose>

View file

@ -573,6 +573,43 @@ export const useDeleteConversationMutation = (
);
};
export const useDuplicateConversationMutation = (
options?: t.DuplicateConvoOptions,
): UseMutationResult<t.TDuplicateConvoResponse, unknown, t.TDuplicateConvoRequest, unknown> => {
const queryClient = useQueryClient();
const { onSuccess, ..._options } = options ?? {};
return useMutation(
(payload: t.TDuplicateConvoRequest) => dataService.duplicateConversation(payload),
{
onSuccess: (data, vars, context) => {
const originalId = vars.conversationId ?? '';
if (originalId.length === 0) {
return;
}
if (data == null) {
return;
}
queryClient.setQueryData(
[QueryKeys.conversation, data.conversation.conversationId],
data.conversation,
);
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
if (!convoData) {
return convoData;
}
return addConversation(convoData, data.conversation);
});
queryClient.setQueryData<t.TMessage[]>(
[QueryKeys.messages, data.conversation.conversationId],
data.messages,
);
onSuccess?.(data, vars, context);
},
..._options,
},
);
};
export const useForkConvoMutation = (
options?: t.ForkConvoOptions,
): UseMutationResult<t.TForkConvoResponse, unknown, t.TForkConvoRequest, unknown> => {

View file

@ -297,6 +297,9 @@ export default {
com_ui_mention: 'Mention an endpoint, assistant, or preset to quickly switch to it',
com_ui_add_model_preset: 'Add a model or preset for an additional response',
com_assistants_max_starters_reached: 'Max number of conversation starters reached',
com_ui_duplication_success: 'Successfully duplicated conversation',
com_ui_duplication_processing: 'Duplicating conversation...',
com_ui_duplication_error: 'There was an error duplicating the conversation',
com_ui_regenerate: 'Regenerate',
com_ui_continue: 'Continue',
com_ui_edit: 'Edit',
@ -392,6 +395,7 @@ export default {
'Are you sure you want to delete this Assistant? This cannot be undone.',
com_ui_rename: 'Rename',
com_ui_archive: 'Archive',
com_ui_duplicate: 'Duplicate',
com_ui_archive_error: 'Failed to archive conversation',
com_ui_unarchive: 'Unarchive',
com_ui_unarchive_error: 'Failed to unarchive conversation',
@ -430,7 +434,6 @@ export default {
com_ui_no_bookmarks: 'it seems like you have no bookmarks yet. Click on a chat and add a new one',
com_ui_no_conversation_id: 'No conversation ID found',
com_ui_add_multi_conversation: 'Add multi-conversation',
com_ui_duplicate: 'Duplicate',
com_ui_duplicate_agent_confirm: 'Are you sure you want to duplicate this agent?',
com_auth_error_login:
'Unable to login with the information provided. Please check your credentials and try again.',
@ -743,6 +746,7 @@ export default {
com_nav_export_recursive: 'Recursive',
com_nav_export_conversation: 'Export conversation',
com_nav_export: 'Export',
com_ui_delete_shared_link: 'Delete shared link?',
com_nav_shared_links: 'Shared links',
com_nav_shared_links_manage: 'Manage',
com_nav_shared_links_empty: 'You have no shared links.',

View file

@ -2371,7 +2371,7 @@ button.scroll-convo {
}
.popover-ui:where(.dark, .dark *) {
background-color: hsl(var(--background));
background-color: hsl(var(--secondary));
color: var(--text-secondary);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.25), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
@ -2392,7 +2392,7 @@ button.scroll-convo {
}
.select-item[data-active-item] {
background-color: hsl(var(--accent));
background-color: var(--surface-hover);
color: var(--text-primary);
}