From 41c0a96d39d0285f51e91461f67195feb0b1a4c1 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:44:54 +0100 Subject: [PATCH] =?UTF-8?q?=E2=8C=A8=EF=B8=8F=20feat:=20Add=20Shift-Key=20?= =?UTF-8?q?Shortcuts=20for=20Instant=20Conversation=20Actions=20(#10732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * đŸĒĻ refactor: Remove Legacy Code (#10533) * đŸ—‘ī¸ chore: Remove unused Legacy Provider clients and related helpers * Deleted OpenAIClient and GoogleClient files along with their associated tests. * Removed references to these clients in the clients index file. * Cleaned up typedefs by removing the OpenAISpecClient export. * Updated chat controllers to use the OpenAI SDK directly instead of the removed client classes. * chore/remove-openapi-specs * đŸ—‘ī¸ chore: Remove unused mergeSort and misc utility functions * Deleted mergeSort.js and misc.js files as they are no longer needed. * Removed references to cleanUpPrimaryKeyValue in messages.js and adjusted related logic. * Updated mongoMeili.ts to eliminate local implementations of removed functions. * chore: remove legacy endpoints * chore: remove all plugins endpoint related code * chore: remove unused prompt handling code and clean up imports * Deleted handleInputs.js and instructions.js files as they are no longer needed. * Removed references to these files in the prompts index.js. * Updated docker-compose.yml to simplify reverse proxy configuration. * chore: remove unused LightningIcon import from Icons.tsx * chore: clean up translation.json by removing deprecated and unused keys * chore: update Jest configuration and remove unused mock file * Simplified the setupFiles array in jest.config.js by removing the fetchEventSource mock. * Deleted the fetchEventSource.js mock file as it is no longer needed. * fix: simplify endpoint type check in Landing and ConversationStarters components * Updated the endpoint type check to use strict equality for better clarity and performance. * Ensured consistency in the handling of the azureOpenAI endpoint across both components. * chore: remove unused dependencies from package.json and package-lock.json * chore: remove legacy EditController, associated routes and imports * chore: update banResponse logic to refine request handling for banned users * chore: remove unused validateEndpoint middleware and its references * chore: remove unused 'res' parameter from initializeClient in multiple endpoint files * chore: remove unused 'isSmallScreen' prop from BookmarkNav and NewChat components; clean up imports in ArchivedChatsTable and useSetIndexOptions hooks; enhance localization in PromptVersions * chore: remove unused import of Constants and TMessage from MobileNav; retain only necessary QueryKeys import * chore: remove unused TResPlugin type and related references; clean up imports in types and schemas * đŸ“Ļ chore: Bump Express.js to v5 (#10671) * chore: update express to version 5.1.0 in package.json * chore: update express-rate-limit to version 8.2.1 in package.json and package-lock.json * fix: Enhance server startup error handling in experimental and index files * Added error handling for server startup in both experimental.js and index.js to log errors and exit the process if the server fails to start. * Updated comments in openidStrategy.js to clarify the purpose of the CustomOpenIDStrategy class and its relation to Express version changes. * chore: Implement rate limiting for all POST routes excluding /speech, required for express v5 * Added middleware to apply IP and user rate limiters to all POST requests, ensuring that the /speech route remains unaffected. * Enhanced code clarity with comments explaining the new rate limiting logic. * chore: Enable writable req.query for mongoSanitize compatibility in Express 5 * chore: Ensure req.body exists in multiple middleware and route files for Express 5 compatibility * đŸĒ¨ feat: Add PROXY support for AWS Bedrock endpoints (#8871) * feat: added PROXY support for AWS Bedrock endpoint * chore: explicit install of new packages required for bedrock proxy --------- Co-authored-by: Danny Avila * feat: add shift key tracking and instant delete functionality in conversation options * refactor(Convo): simplify classname logic * fix: restore package-lock after rebase --------- Co-authored-by: Danny Avila Co-authored-by: Arthur Barrett --- client/src/components/Conversations/Convo.tsx | 8 +- .../ConvoOptions/ConvoOptions.tsx | 170 +++++++++++++----- client/src/hooks/Generic/index.ts | 1 + client/src/hooks/Generic/useShiftKey.ts | 40 +++++ 4 files changed, 176 insertions(+), 43 deletions(-) create mode 100644 client/src/hooks/Generic/useShiftKey.ts 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; +}