⌨️ feat: Add Shift-Key Shortcuts for Instant Conversation Actions (#10732)

* 🪦 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 <danny@librechat.ai>

* 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 <danny@librechat.ai>
Co-authored-by: Arthur Barrett <abarrett@fas.harvard.edu>
This commit is contained in:
Marco Beretta 2025-12-04 20:44:54 +01:00 committed by Danny Avila
parent 470a73b406
commit 41c0a96d39
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
4 changed files with 176 additions and 43 deletions

View file

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

View file

@ -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<TMessage[]>([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 (
<div className="flex items-center gap-0.5">
<button
aria-label={localize('com_ui_archive')}
className={cn(buttonClassName, 'hover:bg-surface-hover')}
onClick={handleArchiveClick}
disabled={isArchiveLoading}
>
{isArchiveLoading ? (
<Spinner className="size-4" />
) : (
<Archive className="icon-md text-text-secondary" aria-hidden={true} />
)}
</button>
<button
aria-label={localize('com_ui_delete')}
className={cn(buttonClassName, 'hover:bg-surface-hover')}
onClick={handleInstantDelete}
disabled={isDeleteLoading}
>
{isDeleteLoading ? (
<Spinner className="size-4" />
) : (
<Trash className="icon-md text-text-secondary" aria-hidden={true} />
)}
</button>
</div>
);
}
return (
<>
<span className="sr-only" aria-live="polite" aria-atomic="true">

View file

@ -1 +1,2 @@
export * from './useLazyEffect';
export { default as useShiftKey } from './useShiftKey';

View file

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