mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 08:25:23 +02:00
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
This commit is contained in:
parent
8e2721011e
commit
9776ae1a47
2 changed files with 325 additions and 0 deletions
319
client/src/hooks/useKeyboardShortcuts.ts
Normal file
319
client/src/hooks/useKeyboardShortcuts.ts
Normal file
|
|
@ -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<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+;',
|
||||
},
|
||||
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<HTMLButtonElement>(
|
||||
'[data-testid="model-selector-button"]',
|
||||
);
|
||||
if (modelButton) {
|
||||
modelButton.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFocusSearch = useCallback(() => {
|
||||
if (!sidebarExpanded) {
|
||||
setSidebarExpanded(true);
|
||||
setTimeout(() => {
|
||||
const searchInput = document.querySelector<HTMLInputElement>(
|
||||
'input[aria-label][placeholder*="earch"]',
|
||||
);
|
||||
searchInput?.focus();
|
||||
}, 350);
|
||||
} else {
|
||||
const searchInput = document.querySelector<HTMLInputElement>(
|
||||
'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<HTMLButtonElement>('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<HTMLElement>('[data-testid="nav-user"]');
|
||||
if (settingsButton) {
|
||||
settingsButton.click();
|
||||
setTimeout(() => {
|
||||
const settingsItem = document.querySelector<HTMLElement>(
|
||||
'[role="menuitem"][class*="select-item"]',
|
||||
);
|
||||
const items = document.querySelectorAll<HTMLElement>('[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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue