diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 53384bb145..382eace7a8 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -6,7 +6,7 @@ import { useToastContext, useMediaQuery } from '@librechat/client'; import type { TConversation } from 'librechat-data-provider'; import { useUpdateConversationMutation } from '~/data-provider'; import EndpointIcon from '~/components/Endpoints/EndpointIcon'; -import { useNavigateToConvo, useLocalize } from '~/hooks'; +import { useNavigateToConvo, useLocalize, useShiftKey } from '~/hooks'; import { useGetEndpointsQuery } from '~/data-provider'; import { NotificationSeverity } from '~/common'; import { ConvoOptions } from './ConvoOptions'; @@ -31,6 +31,7 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? ''); const activeConvos = useRecoilValue(store.allConversationsSelector); const isSmallScreen = useMediaQuery('(max-width: 768px)'); + const isShiftHeld = useShiftKey(); const { conversationId, title = '' } = conversation; const [titleInput, setTitleInput] = useState(title || ''); @@ -191,8 +192,9 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co className={cn( 'mr-2 flex origin-left', isPopoverActive || isActiveConvo - ? 'pointer-events-auto max-w-[28px] scale-x-100 opacity-100' - : 'pointer-events-none max-w-0 scale-x-0 opacity-0 group-focus-within:pointer-events-auto group-focus-within:max-w-[28px] group-focus-within:scale-x-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:max-w-[28px] group-hover:scale-x-100 group-hover:opacity-100', + ? 'pointer-events-auto scale-x-100 opacity-100' + : 'pointer-events-none max-w-0 scale-x-0 opacity-0 group-focus-within:pointer-events-auto group-focus-within:max-w-[60px] group-focus-within:scale-x-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:max-w-[60px] group-hover:scale-x-100 group-hover:opacity-100', + (isPopoverActive || isActiveConvo) && (isShiftHeld ? 'max-w-[60px]' : 'max-w-[28px]'), )} // Removing aria-hidden to fix accessibility issue: ARIA hidden element must not be focusable or contain focusable elements // but not sure what its original purpose was, so leaving the property commented out until it can be cleared safe to delete. diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx index e732d82677..aa82b3eb1d 100644 --- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx +++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx @@ -1,15 +1,19 @@ import { useState, useId, useRef, memo, useCallback, useMemo } from 'react'; import * as Menu from '@ariakit/react/menu'; import { useParams, useNavigate } from 'react-router-dom'; +import { QueryKeys } from 'librechat-data-provider'; +import { useQueryClient } from '@tanstack/react-query'; import { DropdownPopup, Spinner, useToastContext } from '@librechat/client'; import { Ellipsis, Share2, CopyPlus, Archive, Pen, Trash } from 'lucide-react'; import type { MouseEvent } from 'react'; +import type { TMessage } from 'librechat-data-provider'; import { useDuplicateConversationMutation, + useDeleteConversationMutation, useGetStartupConfig, useArchiveConvoMutation, } from '~/data-provider'; -import { useLocalize, useNavigateToConvo, useNewConvo } from '~/hooks'; +import { useLocalize, useNavigateToConvo, useNewConvo, useShiftKey } from '~/hooks'; import { NotificationSeverity } from '~/common'; import { useChatContext } from '~/Providers'; import DeleteButton from './DeleteButton'; @@ -34,6 +38,8 @@ function ConvoOptions({ isActiveConvo: boolean; }) { const localize = useLocalize(); + const queryClient = useQueryClient(); + const isShiftHeld = useShiftKey(); const { index } = useChatContext(); const { data: startupConfig } = useGetStartupConfig(); const { navigateToConvo } = useNavigateToConvo(index); @@ -51,6 +57,28 @@ function ConvoOptions({ const archiveConvoMutation = useArchiveConvoMutation(); + const deleteMutation = useDeleteConversationMutation({ + onSuccess: () => { + if (currentConvoId === conversationId || currentConvoId === 'new') { + newConversation(); + navigate('/c/new', { replace: true }); + } + retainView(); + showToast({ + message: localize('com_ui_convo_delete_success'), + severity: NotificationSeverity.SUCCESS, + showIcon: true, + }); + }, + onError: () => { + showToast({ + message: localize('com_ui_convo_delete_error'), + severity: NotificationSeverity.ERROR, + showIcon: true, + }); + }, + }); + const duplicateConversation = useDuplicateConversationMutation({ onSuccess: (data) => { navigateToConvo(data.conversation); @@ -76,6 +104,7 @@ function ConvoOptions({ const isDuplicateLoading = duplicateConversation.isLoading; const isArchiveLoading = archiveConvoMutation.isLoading; + const isDeleteLoading = deleteMutation.isLoading; const handleShareClick = useCallback(() => { setShowShareDialog(true); @@ -85,47 +114,70 @@ function ConvoOptions({ setShowDeleteDialog(true); }, []); - const handleArchiveClick = useCallback(async () => { - const convoId = conversationId ?? ''; - if (!convoId) { - return; - } + const handleInstantDelete = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + const convoId = conversationId ?? ''; + if (!convoId) { + return; + } - archiveConvoMutation.mutate( - { conversationId: convoId, isArchived: true }, - { - onSuccess: () => { - setAnnouncement(localize('com_ui_convo_archived')); - setTimeout(() => { - setAnnouncement(''); - }, 10000); - if (currentConvoId === convoId || currentConvoId === 'new') { - newConversation(); - navigate('/c/new', { replace: true }); - } - retainView(); - setIsPopoverActive(false); + const messages = queryClient.getQueryData([QueryKeys.messages, convoId]); + const thread_id = messages?.[messages.length - 1]?.thread_id; + const endpoint = messages?.[messages.length - 1]?.endpoint; + + deleteMutation.mutate({ conversationId: convoId, thread_id, endpoint, source: 'button' }); + }, + [conversationId, deleteMutation, queryClient], + ); + + const handleArchiveClick = useCallback( + async (e?: MouseEvent) => { + e?.stopPropagation(); + const convoId = conversationId ?? ''; + if (!convoId) { + return; + } + + archiveConvoMutation.mutate( + { conversationId: convoId, isArchived: true }, + { + onSuccess: () => { + setAnnouncement(localize('com_ui_convo_archived')); + setTimeout(() => { + setAnnouncement(''); + }, 10000); + + 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, + }); + }, }, - onError: () => { - showToast({ - message: localize('com_ui_archive_error'), - severity: NotificationSeverity.ERROR, - showIcon: true, - }); - }, - }, - ); - }, [ - conversationId, - currentConvoId, - archiveConvoMutation, - navigate, - newConversation, - retainView, - setIsPopoverActive, - showToast, - localize, - ]); + ); + }, + [ + conversationId, + currentConvoId, + archiveConvoMutation, + navigate, + newConversation, + retainView, + setIsPopoverActive, + showToast, + localize, + ], + ); const handleDuplicateClick = useCallback(() => { duplicateConversation.mutate({ @@ -195,6 +247,44 @@ function ConvoOptions({ const menuId = useId(); + const buttonClassName = cn( + 'inline-flex h-7 w-7 items-center justify-center rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50', + isActiveConvo === true || isPopoverActive + ? 'opacity-100' + : 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100', + ); + + if (isShiftHeld) { + return ( +
+ + +
+ ); + } + return ( <> diff --git a/client/src/hooks/Generic/index.ts b/client/src/hooks/Generic/index.ts index 9d9d64459f..9674014afa 100644 --- a/client/src/hooks/Generic/index.ts +++ b/client/src/hooks/Generic/index.ts @@ -1 +1,2 @@ export * from './useLazyEffect'; +export { default as useShiftKey } from './useShiftKey'; diff --git a/client/src/hooks/Generic/useShiftKey.ts b/client/src/hooks/Generic/useShiftKey.ts new file mode 100644 index 0000000000..57fddee162 --- /dev/null +++ b/client/src/hooks/Generic/useShiftKey.ts @@ -0,0 +1,40 @@ +import { useState, useEffect } from 'react'; + +/** + * Hook to track whether the shift key is currently being held down + * @returns boolean indicating if shift key is pressed + */ +export default function useShiftKey(): boolean { + const [isShiftHeld, setIsShiftHeld] = useState(false); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setIsShiftHeld(true); + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setIsShiftHeld(false); + } + }; + + // Reset shift state when window loses focus + const handleBlur = () => { + setIsShiftHeld(false); + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + window.addEventListener('blur', handleBlur); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + window.removeEventListener('blur', handleBlur); + }; + }, []); + + return isShiftHeld; +}