From 9776ae1a479b460b552ee4aec2a3ea8187602825 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:46:18 +0100 Subject: [PATCH 01/12] feat: add useKeyboardShortcuts hook and showShortcutsDialog atom Implements the core keyboard shortcuts hook with 11 shortcuts: - General: new chat, focus input, copy last response - Navigation: toggle sidebar, model selector, search, settings - Chat: stop generating, scroll to bottom, temporary chat, copy code Also adds the showShortcutsDialog atom to control dialog visibility. Closes #3664 --- client/src/hooks/useKeyboardShortcuts.ts | 319 +++++++++++++++++++++++ client/src/store/misc.ts | 6 + 2 files changed, 325 insertions(+) create mode 100644 client/src/hooks/useKeyboardShortcuts.ts diff --git a/client/src/hooks/useKeyboardShortcuts.ts b/client/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000000..22e4ab179c --- /dev/null +++ b/client/src/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,319 @@ +import { useEffect, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; +import { QueryKeys } from 'librechat-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 = { + 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+;', + }, + toggleSidebar: { + labelKey: 'com_shortcut_toggle_sidebar', + groupKey: 'com_shortcut_group_navigation', + displayMac: '⌘ ⇧ S', + displayOther: 'Ctrl+Shift+S', + }, + 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', + }, + 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', + }, + copyLastCode: { + labelKey: 'com_shortcut_copy_last_code', + groupKey: 'com_shortcut_group_chat', + displayMac: '⌘ ⇧ K', + displayOther: 'Ctrl+Shift+K', + }, +}; + +export default function useKeyboardShortcuts() { + const queryClient = useQueryClient(); + const { newConversation } = useNewConvo(); + const conversation = useRecoilValue(store.conversationByIndex(0)); + const [sidebarExpanded, setSidebarExpanded] = useRecoilState(store.sidebarExpanded); + const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog); + const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary); + + 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 handleOpenModelSelector = useCallback(() => { + const modelButton = document.querySelector( + '[data-testid="model-selector-button"]', + ); + if (modelButton) { + modelButton.click(); + } + }, []); + + const handleFocusSearch = useCallback(() => { + if (!sidebarExpanded) { + setSidebarExpanded(true); + setTimeout(() => { + const searchInput = document.querySelector( + 'input[aria-label][placeholder*="earch"]', + ); + searchInput?.focus(); + }, 350); + } else { + const searchInput = document.querySelector( + 'input[aria-label][placeholder*="earch"]', + ); + searchInput?.focus(); + } + }, [sidebarExpanded, setSidebarExpanded]); + + const handleShowShortcuts = useCallback(() => { + setShowShortcutsDialog((prev) => !prev); + }, [setShowShortcutsDialog]); + + const handleCopyLastResponse = useCallback(() => { + const agentTurns = document.querySelectorAll('.agent-turn'); + if (agentTurns.length === 0) { + return; + } + const last = agentTurns[agentTurns.length - 1]; + const markdown = last.querySelector('.markdown'); + const text = (markdown ?? last).textContent ?? ''; + if (text.trim()) { + navigator.clipboard.writeText(text.trim()); + } + }, []); + + const handleStopGenerating = useCallback(() => { + const stopButton = document.querySelector('button[aria-label*="top"]'); + if (stopButton) { + stopButton.click(); + } + }, []); + + const handleScrollToBottom = useCallback(() => { + const container = document.querySelector('[class*="overflow-y-auto"][class*="flex-col"]'); + if (container) { + container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); + return; + } + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + }, []); + + const handleOpenSettings = useCallback(() => { + const settingsButton = document.querySelector('[data-testid="nav-user"]'); + if (settingsButton) { + settingsButton.click(); + setTimeout(() => { + const settingsItem = document.querySelector( + '[role="menuitem"][class*="select-item"]', + ); + const items = document.querySelectorAll('[role="menuitem"]'); + for (const item of items) { + if (item.textContent?.includes('Settings') && !item.textContent?.includes('Keyboard')) { + item.click(); + return; + } + } + }, 150); + } + }, []); + + const handleToggleTemporaryChat = useCallback(() => { + setIsTemporary((prev) => !prev); + }, [setIsTemporary]); + + const handleCopyLastCode = useCallback(() => { + const codeBlocks = document.querySelectorAll('.agent-turn pre code'); + if (codeBlocks.length === 0) { + return; + } + const last = codeBlocks[codeBlocks.length - 1]; + const text = last.textContent ?? ''; + if (text.trim()) { + navigator.clipboard.writeText(text.trim()); + } + }, []); + + const handler = useCallback( + (e: KeyboardEvent) => { + const mod = isMac ? e.metaKey : e.ctrlKey; + + // Cmd/Ctrl + Shift + O → New Chat + if (mod && e.shiftKey && e.key === 'O') { + e.preventDefault(); + handleNewChat(); + return; + } + + // Shift + Escape → Focus Chat Input + if (e.shiftKey && e.key === 'Escape') { + e.preventDefault(); + handleFocusChatInput(); + return; + } + + // Cmd/Ctrl + Shift + S → Toggle Sidebar + if (mod && e.shiftKey && (e.key === 'S' || e.key === 's')) { + e.preventDefault(); + handleToggleSidebar(); + return; + } + + // Cmd/Ctrl + Shift + M → Open Model Selector + if (mod && e.shiftKey && e.key === 'M') { + e.preventDefault(); + handleOpenModelSelector(); + return; + } + + // Cmd/Ctrl + / → Focus Search + if (mod && !e.shiftKey && e.key === '/') { + e.preventDefault(); + handleFocusSearch(); + return; + } + + // Cmd/Ctrl + Shift + ; → Copy Last Response + if (mod && e.shiftKey && (e.key === ':' || e.key === ';')) { + e.preventDefault(); + handleCopyLastResponse(); + return; + } + + // Cmd/Ctrl + Shift + X → Stop Generating + if (mod && e.shiftKey && e.key === 'X') { + e.preventDefault(); + handleStopGenerating(); + return; + } + + // Cmd/Ctrl + Shift + ↓ → Scroll to Bottom + if (mod && e.shiftKey && e.key === 'ArrowDown') { + e.preventDefault(); + handleScrollToBottom(); + return; + } + + // Cmd/Ctrl + Shift + , → Open Settings + if (mod && e.shiftKey && (e.key === '<' || e.key === ',')) { + e.preventDefault(); + handleOpenSettings(); + return; + } + + // Cmd/Ctrl + Shift + T → Toggle Temporary Chat + if (mod && e.shiftKey && e.key === 'T') { + e.preventDefault(); + handleToggleTemporaryChat(); + return; + } + + // Cmd/Ctrl + Shift + K → Copy Last Code Block + if (mod && e.shiftKey && e.key === 'K') { + e.preventDefault(); + handleCopyLastCode(); + return; + } + + // Cmd/Ctrl + Shift + / (Cmd/Ctrl + ?) → Show Keyboard Shortcuts + if (mod && e.shiftKey && e.key === '?') { + e.preventDefault(); + handleShowShortcuts(); + return; + } + }, + [ + handleNewChat, + handleFocusChatInput, + handleToggleSidebar, + handleOpenModelSelector, + handleFocusSearch, + handleShowShortcuts, + handleCopyLastResponse, + handleStopGenerating, + handleScrollToBottom, + handleOpenSettings, + handleToggleTemporaryChat, + handleCopyLastCode, + ], + ); + + useEffect(() => { + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [handler]); +} + +export { isMac }; diff --git a/client/src/store/misc.ts b/client/src/store/misc.ts index 7bd571eaca..5bc90e0539 100644 --- a/client/src/store/misc.ts +++ b/client/src/store/misc.ts @@ -57,6 +57,11 @@ const isEditingBadges = atom({ default: false, }); +const showShortcutsDialog = atom({ + key: 'showShortcutsDialog', + default: false, +}); + const chatBadges = atomWithLocalStorage[]>('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, }; From 08a54273fa5b2c406522192c22483715b9243f60 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:46:39 +0100 Subject: [PATCH 02/12] feat: add KeyboardShortcutsDialog component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a modal dialog listing all available keyboard shortcuts grouped by category (General, Navigation, Chat). Features: - Platform-aware key labels (⌘ on Mac, Ctrl on others) - Clean kbd-style key badges with subtle shadows - Grouped sections with separators - Sticky footer with shortcut to open the dialog itself - Single close button, Escape to dismiss --- .../Nav/KeyboardShortcutsDialog.tsx | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 client/src/components/Nav/KeyboardShortcutsDialog.tsx diff --git a/client/src/components/Nav/KeyboardShortcutsDialog.tsx b/client/src/components/Nav/KeyboardShortcutsDialog.tsx new file mode 100644 index 0000000000..0934bb70b1 --- /dev/null +++ b/client/src/components/Nav/KeyboardShortcutsDialog.tsx @@ -0,0 +1,112 @@ +import { memo, useMemo } from 'react'; +import { useRecoilState } from 'recoil'; +import { X } from 'lucide-react'; +import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogClose } from '@librechat/client'; +import type { TranslationKeys } from '~/hooks/useLocalize'; +import { shortcutDefinitions, isMac } from '~/hooks/useKeyboardShortcuts'; +import type { ShortcutDefinition } from '~/hooks/useKeyboardShortcuts'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; +import store from '~/store'; + +type GroupedShortcuts = Record>; + +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function KeyCombo({ keys }: { keys: string[] }) { + return ( +
+ {keys.map((key, idx) => ( + {key} + ))} +
+ ); +} + +function ShortcutRow({ label, keys }: { label: string; keys: string[] }) { + return ( +
+ {label} + +
+ ); +} + +function parseKeys(display: string): string[] { + return display.split(/([+\s]+)/).filter((k) => k.trim().length > 0 && k !== '+'); +} + +function KeyboardShortcutsDialog() { + const localize = useLocalize(); + const [open, setOpen] = useRecoilState(store.showShortcutsDialog); + + const grouped = useMemo(() => { + 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]); + + return ( + + +
+ + {localize('com_shortcut_keyboard_shortcuts')} + + + + {localize('com_ui_close')} + +
+ +
+ {groupEntries.map(([groupKey, shortcuts], groupIdx) => ( +
0 && 'border-border-light/60 mt-3 border-t pt-3')} + > +

+ {localize(groupKey as TranslationKeys)} +

+ {shortcuts.map((shortcut) => ( + + ))} +
+ ))} +
+ +
+
+ + {localize('com_shortcut_show_shortcuts')} + + +
+
+
+
+ ); +} + +export default memo(KeyboardShortcutsDialog); From 1d0827ff798919fab497d3ca1ed89fac8d7bceb3 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:47:11 +0100 Subject: [PATCH 03/12] feat: integrate keyboard shortcuts into Root layout and account menu - Mount useKeyboardShortcuts and KeyboardShortcutsDialog in Root.tsx via a KeyboardShortcutsProvider wrapper (only renders post-auth) - Add 'Keyboard Shortcuts' menu item with Keyboard icon to the account settings popover for discoverability --- client/src/components/Nav/AccountSettings.tsx | 9 ++++++++- client/src/routes/Root.tsx | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/client/src/components/Nav/AccountSettings.tsx b/client/src/components/Nav/AccountSettings.tsx index 01417ef362..529ab1b87d 100644 --- a/client/src/components/Nav/AccountSettings.tsx +++ b/client/src/components/Nav/AccountSettings.tsx @@ -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(null); return ( @@ -82,6 +85,10 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) { {localize('com_nav_help_faq')} )} + setShowShortcutsDialog(true)} className="select-item text-sm"> + setShowSettings(true)} className="select-item text-sm">