This commit is contained in:
Marco Beretta 2026-04-05 00:41:18 +02:00 committed by GitHub
commit d9ab86e98e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 676 additions and 2 deletions

View file

@ -67,6 +67,7 @@ function ModelSelectorContent() {
description={localize('com_ui_select_model')}
render={
<button
data-testid="model-selector-button"
className="my-1 flex h-9 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-presentation px-3 py-2 text-sm text-text-primary hover:bg-surface-active-alt"
aria-label={localize('com_ui_select_model')}
>

View file

@ -1,12 +1,14 @@
import { useState, memo, useRef } from 'react';
import * as Menu from '@ariakit/react/menu';
import { FileText, LogOut } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import { FileText, LogOut, Keyboard } from 'lucide-react';
import { LinkIcon, GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client';
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
import { useAuthContext } from '~/hooks/AuthContext';
import { useLocalize } from '~/hooks';
import Settings from './Settings';
import store from '~/store';
function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
const localize = useLocalize();
@ -17,6 +19,7 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
});
const [showSettings, setShowSettings] = useState(false);
const [showFiles, setShowFiles] = useState(false);
const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog);
const accountSettingsButtonRef = useRef<HTMLButtonElement>(null);
return (
@ -82,7 +85,15 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
{localize('com_nav_help_faq')}
</Menu.MenuItem>
)}
<Menu.MenuItem onClick={() => setShowSettings(true)} className="select-item text-sm">
<Menu.MenuItem onClick={() => setShowShortcutsDialog(true)} className="select-item text-sm">
<Keyboard className="icon-md" aria-hidden="true" />
{localize('com_shortcut_keyboard_shortcuts')}
</Menu.MenuItem>
<Menu.MenuItem
onClick={() => setShowSettings(true)}
className="select-item text-sm"
data-testid="nav-settings"
>
<GearIcon className="icon-md" aria-hidden="true" />
{localize('com_nav_settings')}
</Menu.MenuItem>

View file

@ -0,0 +1,148 @@
import { memo, useMemo } from 'react';
import { X } from 'lucide-react';
import { useRecoilState } from 'recoil';
import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogClose } from '@librechat/client';
import type { ShortcutDefinition } from '~/hooks/useKeyboardShortcuts';
import type { TranslationKeys } from '~/hooks/useLocalize';
import { shortcutDefinitions, isMac } from '~/hooks/useKeyboardShortcuts';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
type GroupedShortcuts = Record<string, Array<ShortcutDefinition & { id: string }>>;
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-flex h-[22px] min-w-[22px] items-center justify-center rounded-md border border-border-light bg-surface-secondary px-1.5 text-[11px] font-medium leading-none text-text-secondary shadow-[0_1px_0_0_rgba(0,0,0,0.08)] dark:shadow-none">
{children}
</kbd>
);
}
function KeyCombo({ keys }: { keys: string[] }) {
return (
<div className="flex items-center gap-1">
{keys.map((key, idx) => (
<Kbd key={`${key}-${idx}`}>{key}</Kbd>
))}
</div>
);
}
function ShortcutRow({ label, keys }: { label: string; keys: string[] }) {
return (
<div className="flex items-center justify-between gap-3 py-[5px]">
<span className="truncate text-[13px] text-text-primary">{label}</span>
<KeyCombo keys={keys} />
</div>
);
}
function parseKeys(display: string): string[] {
return display.split(/([+\s]+)/).filter((k) => k.trim().length > 0 && k !== '+');
}
function ShortcutGroup({
groupKey,
shortcuts,
isFirst,
}: {
groupKey: string;
shortcuts: Array<ShortcutDefinition & { id: string }>;
isFirst: boolean;
}) {
const localize = useLocalize();
return (
<div className={cn(!isFirst && 'mt-2 border-t border-border-light pt-2')}>
<h3 className="mb-0.5 text-[11px] font-medium uppercase tracking-widest text-text-secondary">
{localize(groupKey as TranslationKeys)}
</h3>
{shortcuts.map((shortcut) => (
<ShortcutRow
key={shortcut.id}
label={localize(shortcut.labelKey as TranslationKeys)}
keys={parseKeys(isMac ? shortcut.displayMac : shortcut.displayOther)}
/>
))}
</div>
);
}
function KeyboardShortcutsDialog() {
const localize = useLocalize();
const [open, setOpen] = useRecoilState(store.showShortcutsDialog);
const grouped = useMemo<GroupedShortcuts>(() => {
const groups: GroupedShortcuts = {};
for (const [id, def] of Object.entries(shortcutDefinitions)) {
const group = def.groupKey;
if (!groups[group]) {
groups[group] = [];
}
groups[group].push({ ...def, id });
}
return groups;
}, []);
const groupEntries = useMemo(() => Object.entries(grouped), [grouped]);
const leftColumn = useMemo(
() => groupEntries.filter(([key]) => key !== 'com_shortcut_group_chat'),
[groupEntries],
);
const rightColumn = useMemo(
() => groupEntries.filter(([key]) => key === 'com_shortcut_group_chat'),
[groupEntries],
);
return (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogContent
showCloseButton={false}
className="w-11/12 max-w-4xl overflow-hidden px-6 py-1"
>
<div className="flex items-center justify-between pb-0 pt-5">
<OGDialogTitle>{localize('com_shortcut_keyboard_shortcuts')}</OGDialogTitle>
<OGDialogClose>
<X className="h-4 w-4" />
<span className="sr-only">{localize('com_ui_close')}</span>
</OGDialogClose>
</div>
<div className="grid grid-cols-2 gap-x-6 overflow-y-auto pb-4 pt-2">
<div>
{leftColumn.map(([groupKey, shortcuts], idx) => (
<ShortcutGroup
key={groupKey}
groupKey={groupKey}
shortcuts={shortcuts}
isFirst={idx === 0}
/>
))}
</div>
<div>
{rightColumn.map(([groupKey, shortcuts], idx) => (
<ShortcutGroup
key={groupKey}
groupKey={groupKey}
shortcuts={shortcuts}
isFirst={idx === 0}
/>
))}
</div>
</div>
<div className="border-t border-border-light py-2.5">
<div className="flex items-center justify-between">
<span className="text-xs text-text-secondary">
{localize('com_shortcut_show_shortcuts')}
</span>
<KeyCombo keys={[isMac ? '⌘' : 'Ctrl', '⇧', '/']} />
</div>
</div>
</OGDialogContent>
</OGDialog>
);
}
export default memo(KeyboardShortcutsDialog);

View file

@ -0,0 +1,480 @@
import { useEffect, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { QueryKeys } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import { useArchiveConvoMutation, useDeleteConversationMutation } from '~/data-provider';
import { mainTextareaId } from '~/common';
import { clearMessagesCache } from '~/utils';
import useNewConvo from './useNewConvo';
import store from '~/store';
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
export type ShortcutDefinition = {
/** Translation key for the shortcut label */
labelKey: string;
/** Translation key for the shortcut group/category */
groupKey: string;
/** Human-readable key combo for display (Mac) */
displayMac: string;
/** Human-readable key combo for display (non-Mac) */
displayOther: string;
};
export const shortcutDefinitions: Record<string, ShortcutDefinition> = {
newChat: {
labelKey: 'com_ui_new_chat',
groupKey: 'com_shortcut_group_general',
displayMac: '⌘ ⇧ O',
displayOther: 'Ctrl+Shift+O',
},
focusChat: {
labelKey: 'com_shortcut_focus_chat_input',
groupKey: 'com_shortcut_group_general',
displayMac: '⇧ Esc',
displayOther: 'Shift+Esc',
},
copyLastResponse: {
labelKey: 'com_shortcut_copy_last_response',
groupKey: 'com_shortcut_group_general',
displayMac: '⌘ ⇧ ;',
displayOther: 'Ctrl+Shift+;',
},
uploadFile: {
labelKey: 'com_shortcut_upload_file',
groupKey: 'com_shortcut_group_general',
displayMac: '⌘ ⇧ U',
displayOther: 'Ctrl+Shift+U',
},
toggleSidebar: {
labelKey: 'com_shortcut_toggle_sidebar',
groupKey: 'com_shortcut_group_navigation',
displayMac: '⌘ ⇧ S',
displayOther: 'Ctrl+Shift+S',
},
toggleRightSidebar: {
labelKey: 'com_shortcut_toggle_right_sidebar',
groupKey: 'com_shortcut_group_navigation',
displayMac: '⌘ ⇧ R',
displayOther: 'Ctrl+Shift+R',
},
openModelSelector: {
labelKey: 'com_shortcut_open_model_selector',
groupKey: 'com_shortcut_group_navigation',
displayMac: '⌘ ⇧ M',
displayOther: 'Ctrl+Shift+M',
},
focusSearch: {
labelKey: 'com_shortcut_focus_search',
groupKey: 'com_shortcut_group_navigation',
displayMac: '⌘ /',
displayOther: 'Ctrl+/',
},
openSettings: {
labelKey: 'com_nav_settings',
groupKey: 'com_shortcut_group_navigation',
displayMac: '⌘ ⇧ ,',
displayOther: 'Ctrl+Shift+,',
},
stopGenerating: {
labelKey: 'com_nav_stop_generating',
groupKey: 'com_shortcut_group_chat',
displayMac: '⌘ ⇧ X',
displayOther: 'Ctrl+Shift+X',
},
regenerateResponse: {
labelKey: 'com_shortcut_regenerate_response',
groupKey: 'com_shortcut_group_chat',
displayMac: '⌘ ⇧ E',
displayOther: 'Ctrl+Shift+E',
},
editLastMessage: {
labelKey: 'com_shortcut_edit_last_message',
groupKey: 'com_shortcut_group_chat',
displayMac: '⌘ ⇧ I',
displayOther: 'Ctrl+Shift+I',
},
copyLastCode: {
labelKey: 'com_shortcut_copy_last_code',
groupKey: 'com_shortcut_group_chat',
displayMac: '⌘ ⇧ K',
displayOther: 'Ctrl+Shift+K',
},
scrollToTop: {
labelKey: 'com_shortcut_scroll_to_top',
groupKey: 'com_shortcut_group_chat',
displayMac: '⌘ ⇧ ↑',
displayOther: 'Ctrl+Shift+↑',
},
scrollToBottom: {
labelKey: 'com_shortcut_scroll_to_bottom',
groupKey: 'com_shortcut_group_chat',
displayMac: '⌘ ⇧ ↓',
displayOther: 'Ctrl+Shift+↓',
},
toggleTemporaryChat: {
labelKey: 'com_ui_temporary',
groupKey: 'com_shortcut_group_chat',
displayMac: '⌘ ⇧ T',
displayOther: 'Ctrl+Shift+T',
},
archiveConversation: {
labelKey: 'com_shortcut_archive_conversation',
groupKey: 'com_shortcut_group_chat',
displayMac: '⌘ ⇧ A',
displayOther: 'Ctrl+Shift+A',
},
deleteConversation: {
labelKey: 'com_shortcut_delete_conversation',
groupKey: 'com_shortcut_group_chat',
displayMac: '⌘ ⇧ ⌫',
displayOther: 'Ctrl+Shift+Backspace',
},
};
function getMainScrollContainer(): Element | null {
return document.querySelector('main[role="main"]');
}
export default function useKeyboardShortcuts() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
const { conversationId: currentConvoId } = useParams();
const conversation = useRecoilValue(store.conversationByIndex(0));
const [sidebarExpanded, setSidebarExpanded] = useRecoilState(store.sidebarExpanded);
const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog);
const setIsTemporary = useSetRecoilState(store.isTemporary);
const archiveMutation = useArchiveConvoMutation();
const deleteMutation = useDeleteConversationMutation();
const handleNewChat = useCallback(() => {
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
}, [queryClient, conversation?.conversationId, newConversation]);
const handleFocusChatInput = useCallback(() => {
const textarea = document.getElementById(mainTextareaId) as HTMLTextAreaElement | null;
textarea?.focus();
}, []);
const handleToggleSidebar = useCallback(() => {
setSidebarExpanded((prev) => !prev);
}, [setSidebarExpanded]);
const handleToggleRightSidebar = useCallback(() => {
const btn = document.querySelector<HTMLButtonElement>('[data-testid="parameters-button"]');
btn?.click();
}, []);
const handleOpenModelSelector = useCallback(() => {
const btn = document.querySelector<HTMLButtonElement>('[data-testid="model-selector-button"]');
btn?.click();
}, []);
const handleFocusSearch = useCallback(() => {
if (!sidebarExpanded) {
setSidebarExpanded(true);
setTimeout(() => {
const input = document.querySelector<HTMLInputElement>(
'input[aria-label][placeholder*="earch"]',
);
input?.focus();
}, 350);
} else {
const input = document.querySelector<HTMLInputElement>(
'input[aria-label][placeholder*="earch"]',
);
input?.focus();
}
}, [sidebarExpanded, setSidebarExpanded]);
const handleShowShortcuts = useCallback(() => {
setShowShortcutsDialog((prev) => !prev);
}, [setShowShortcutsDialog]);
const handleCopyLastResponse = useCallback(() => {
const turns = document.querySelectorAll('.agent-turn');
if (turns.length === 0) {
return;
}
const last = turns[turns.length - 1];
const markdown = last.querySelector('.markdown');
const text = (markdown ?? last).textContent ?? '';
if (text.trim()) {
navigator.clipboard.writeText(text.trim());
}
}, []);
const handleCopyLastCode = useCallback(() => {
const blocks = document.querySelectorAll('.agent-turn pre code');
if (blocks.length === 0) {
return;
}
const last = blocks[blocks.length - 1];
const text = last.textContent ?? '';
if (text.trim()) {
navigator.clipboard.writeText(text.trim());
}
}, []);
const handleStopGenerating = useCallback(() => {
const btn = document.querySelector<HTMLButtonElement>('[data-testid="stop-generation-button"]');
btn?.click();
}, []);
const handleRegenerateResponse = useCallback(() => {
const btn = document.querySelector<HTMLButtonElement>(
'[data-testid="regenerate-generation-button"]',
);
btn?.click();
}, []);
const handleEditLastMessage = useCallback(() => {
const userTurns = document.querySelectorAll('.user-turn');
if (userTurns.length === 0) {
return;
}
const last = userTurns[userTurns.length - 1];
const editBtn = last.querySelector<HTMLButtonElement>('button[id^="edit-"]');
editBtn?.click();
}, []);
const handleScrollToTop = useCallback(() => {
const container = getMainScrollContainer();
if (container) {
container.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
window.scrollTo({ top: 0, behavior: 'smooth' });
}, []);
const handleScrollToBottom = useCallback(() => {
const container = getMainScrollContainer();
if (container) {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
return;
}
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}, []);
const handleOpenSettings = useCallback(() => {
const btn = document.querySelector<HTMLElement>('[data-testid="nav-user"]');
if (!btn) {
return;
}
btn.click();
setTimeout(() => {
const settingsItem = document.querySelector<HTMLElement>('[data-testid="nav-settings"]');
settingsItem?.click();
}, 150);
}, []);
const handleToggleTemporaryChat = useCallback(() => {
setIsTemporary((prev) => !prev);
}, [setIsTemporary]);
const handleUploadFile = useCallback(() => {
const btn =
document.querySelector<HTMLButtonElement>('#attach-file-menu-button') ??
document.querySelector<HTMLButtonElement>('#attach-file');
btn?.click();
}, []);
const handleArchiveConversation = useCallback(() => {
const convoId = conversation?.conversationId;
if (!convoId || convoId === 'new') {
return;
}
archiveMutation.mutate(
{ conversationId: convoId, isArchived: true },
{
onSuccess: () => {
if (currentConvoId === convoId || currentConvoId === 'new') {
newConversation();
navigate('/c/new', { replace: true });
}
},
},
);
}, [conversation?.conversationId, currentConvoId, archiveMutation, newConversation, navigate]);
const handleDeleteConversation = useCallback(() => {
const convoId = conversation?.conversationId;
if (!convoId || convoId === 'new') {
return;
}
const messages = queryClient.getQueryData<TMessage[]>([QueryKeys.messages, convoId]);
const lastMessage = messages?.[messages.length - 1];
deleteMutation.mutate(
{
conversationId: convoId,
thread_id: lastMessage?.thread_id,
endpoint: lastMessage?.endpoint,
source: 'keyboard',
},
{
onSuccess: () => {
if (currentConvoId === convoId || currentConvoId === 'new') {
newConversation();
navigate('/c/new', { replace: true });
}
},
},
);
}, [
conversation?.conversationId,
currentConvoId,
queryClient,
deleteMutation,
newConversation,
navigate,
]);
const handler = useCallback(
(e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const tagName = target?.tagName;
const isEditing =
tagName === 'INPUT' || tagName === 'TEXTAREA' || target?.isContentEditable === true;
const mod = isMac ? e.metaKey : e.ctrlKey;
// Shift + Escape → Focus Chat Input (works anywhere)
if (e.shiftKey && e.key === 'Escape') {
e.preventDefault();
handleFocusChatInput();
return;
}
// All remaining shortcuts require mod key
if (!mod) {
return;
}
// Non-shift shortcuts
if (!e.shiftKey) {
// Cmd/Ctrl + / → Focus Search
if (e.key === '/') {
e.preventDefault();
handleFocusSearch();
}
return;
}
// Cmd/Ctrl + Shift + / (?) → Show Keyboard Shortcuts (works even when editing)
if (e.key === '?') {
e.preventDefault();
handleShowShortcuts();
return;
}
// Remaining Cmd/Ctrl+Shift shortcuts should not fire when editing text
if (isEditing) {
return;
}
switch (e.key) {
case 'O':
e.preventDefault();
handleNewChat();
break;
case 'S':
case 's':
e.preventDefault();
handleToggleSidebar();
break;
case 'R':
e.preventDefault();
handleToggleRightSidebar();
break;
case 'M':
e.preventDefault();
handleOpenModelSelector();
break;
case ':':
case ';':
e.preventDefault();
handleCopyLastResponse();
break;
case 'U':
e.preventDefault();
handleUploadFile();
break;
case 'X':
e.preventDefault();
handleStopGenerating();
break;
case 'E':
e.preventDefault();
handleRegenerateResponse();
break;
case 'I':
e.preventDefault();
handleEditLastMessage();
break;
case 'K':
e.preventDefault();
handleCopyLastCode();
break;
case 'ArrowUp':
e.preventDefault();
handleScrollToTop();
break;
case 'ArrowDown':
e.preventDefault();
handleScrollToBottom();
break;
case '<':
case ',':
e.preventDefault();
handleOpenSettings();
break;
case 'T':
e.preventDefault();
handleToggleTemporaryChat();
break;
case 'A':
e.preventDefault();
handleArchiveConversation();
break;
case 'Backspace':
e.preventDefault();
handleDeleteConversation();
break;
}
},
[
handleNewChat,
handleFocusChatInput,
handleToggleSidebar,
handleToggleRightSidebar,
handleOpenModelSelector,
handleFocusSearch,
handleShowShortcuts,
handleCopyLastResponse,
handleCopyLastCode,
handleUploadFile,
handleStopGenerating,
handleRegenerateResponse,
handleEditLastMessage,
handleScrollToTop,
handleScrollToBottom,
handleOpenSettings,
handleToggleTemporaryChat,
handleArchiveConversation,
handleDeleteConversation,
],
);
useEffect(() => {
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [handler]);
}
export { isMac };

View file

@ -606,6 +606,25 @@
"com_nav_user_msg_markdown": "Render user messages as markdown",
"com_nav_user_name_display": "Display username in messages",
"com_nav_voice_select": "Voice",
"com_shortcut_archive_conversation": "Archive conversation",
"com_shortcut_copy_last_code": "Copy last code block",
"com_shortcut_copy_last_response": "Copy last response",
"com_shortcut_delete_conversation": "Delete conversation",
"com_shortcut_edit_last_message": "Edit last message",
"com_shortcut_focus_chat_input": "Focus chat input",
"com_shortcut_focus_search": "Focus search",
"com_shortcut_group_chat": "Chat",
"com_shortcut_group_general": "General",
"com_shortcut_group_navigation": "Navigation",
"com_shortcut_keyboard_shortcuts": "Keyboard Shortcuts",
"com_shortcut_open_model_selector": "Open model selector",
"com_shortcut_regenerate_response": "Regenerate response",
"com_shortcut_scroll_to_bottom": "Scroll to bottom",
"com_shortcut_scroll_to_top": "Scroll to top",
"com_shortcut_show_shortcuts": "Show keyboard shortcuts",
"com_shortcut_toggle_right_sidebar": "Toggle right sidebar",
"com_shortcut_toggle_sidebar": "Toggle sidebar",
"com_shortcut_upload_file": "Upload file",
"com_show_examples": "Show Examples",
"com_sidepanel_agent_builder": "Agent Builder",
"com_sidepanel_assistant_builder": "Assistant Builder",

View file

@ -18,10 +18,18 @@ import {
FileMapContext,
} from '~/Providers';
import { useUserTermsQuery, useGetStartupConfig } from '~/data-provider';
import KeyboardShortcutsDialog from '~/components/Nav/KeyboardShortcutsDialog';
import { UnifiedSidebar } from '~/components/UnifiedSidebar';
import { TermsAndConditionsModal } from '~/components/ui';
import { useHealthCheck } from '~/data-provider';
import { Banner } from '~/components/Banners';
import useKeyboardShortcuts from '~/hooks/useKeyboardShortcuts';
/** Isolates keyboard shortcut listeners so they only mount after auth. */
function KeyboardShortcutsProvider() {
useKeyboardShortcuts();
return <KeyboardShortcutsDialog />;
}
export default function Root() {
const [showTerms, setShowTerms] = useState(false);
@ -98,6 +106,7 @@ export default function Root() {
modalContent={config.interface.termsOfService.modalContent}
/>
)}
<KeyboardShortcutsProvider />
</AssistantsMapContext.Provider>
</FileMapContext.Provider>
</SetConvoProvider>

View file

@ -57,6 +57,11 @@ const isEditingBadges = atom<boolean>({
default: false,
});
const showShortcutsDialog = atom<boolean>({
key: 'showShortcutsDialog',
default: false,
});
const chatBadges = atomWithLocalStorage<Pick<BadgeItem, 'id'>[]>('chatBadges', [
// When adding new badges, make sure to add them to useChatBadges.ts as well and add them as last item
// DO NOT CHANGE THE ORDER OF THE BADGES ALREADY IN THE ARRAY
@ -70,5 +75,6 @@ export default {
conversationAttachmentsSelector,
queriesEnabled,
isEditingBadges,
showShortcutsDialog,
chatBadges,
};