From ebb4f15dbee7a458377decf5c15f694504499f29 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:02:46 +0200 Subject: [PATCH] =?UTF-8?q?=E2=8C=A8=EF=B8=8F=20feat:=20Keyboard=20Shortcu?= =?UTF-8?q?ts=20(#12425)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * feat: add KeyboardShortcutsDialog component 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 * 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 * chore: add data-testid to model selector button Adds data-testid="model-selector-button" to the model selector trigger for reliable DOM targeting by keyboard shortcuts and tests. * i18n: add keyboard shortcuts localization keys Adds 12 new com_shortcut_* translation keys for the keyboard shortcuts feature: group labels, action labels, and dialog title. * style: fix keyboard shortcuts dialog dark mode Replace token-based dark mode styling with explicit white-alpha values for kbd badges, borders, and separators: - Kbd: dark:bg-white/[0.06] dark:border-white/[0.08] dark:shadow-none - Separators: dark:border-white/[0.06] - Dialog border: dark:border-white/[0.06] dark:shadow-2xl Ensures the key badges blend naturally into the dark surface instead of appearing as harsh bright rectangles. * feat(shortcuts): add definitions for 8 new keyboard shortcuts Add shortcut definitions and localization keys for: - Upload file (Cmd/Ctrl+Shift+U) - Toggle right sidebar (Cmd/Ctrl+Shift+R) - Regenerate response (Cmd/Ctrl+Shift+E) - Edit last message (Cmd/Ctrl+Shift+I) - Scroll to top (Cmd/Ctrl+Shift+↑) - Archive conversation (Cmd/Ctrl+Shift+A) - Delete conversation (Cmd/Ctrl+Shift+Backspace) Addresses #3664 * feat(shortcuts): implement handlers for all new shortcuts New handlers: - Upload file: triggers attach-file button click - Toggle right sidebar: clicks parameters-button - Regenerate response: clicks regenerate-generation-button - Edit last message: finds last user-turn and clicks edit button - Scroll to top: scrolls main[role=main] to top - Archive conversation: calls archive mutation + navigates to new chat - Delete conversation: calls delete mutation + navigates to new chat Improvements: - Use getMainScrollContainer() helper targeting main[role=main] instead of fragile class-based selectors - Use data-testid selectors instead of aria-label substring matching for stop-generation and model-selector buttons - Use id-based selectors (button[id^=edit-]) for edit buttons - Add isEditing guard to skip shortcuts when user is typing in inputs, textareas, or contentEditable elements - Refactor handler from if/return chain to switch statement for cleaner flow control * fix(shortcuts): increase dialog scroll height for expanded shortcut list With 20 shortcuts across 3 groups, the previous 480px max was tight. Increase to 560px / 70vh so all shortcuts are visible without excessive scrolling. * refactor(shortcuts): use data-testid selectors for reliable targeting Add data-testid="nav-settings" to the Settings menu item in AccountSettings so the open-settings shortcut no longer relies on fragile text-content matching ('Settings' but not 'Keyboard'). * refactor(shortcuts): two-column layout for shortcuts dialog Split the shortcuts dialog into a two-column grid layout: - Left column: General + Navigation groups - Right column: Chat group (which has the most shortcuts) Reduces vertical height so the full list is visible without scrolling. Widen dialog to max-w-4xl (w-11/12) to accommodate both columns. Simplify Kbd/group styling for cleaner visual density. * refactor(shortcuts): adjust padding in KeyboardShortcutsDialog content * feat(shortcuts): customizable keyboard shortcuts with recorder UI Add per-shortcut overrides stored in localStorage, a recorder component for capturing new key combos with conflict detection, and a per-row edit/reset affordance in the shortcuts dialog. * test(shortcuts): fix specs broken by keyboard shortcut hooks - ExpandedPanel: add customShortcuts atom to the store mock so useShortcutDisplay/useShortcutAriaKey can read state - AttachFileMenu: update queries to the new 'Attach Files' aria-label - Button (Generations): wrap renders in RecoilRoot now that the component reads shortcut state * feat(shortcuts): add panel/submit/bookmark/continue/read-aloud shortcuts - Wire stop, regenerate, continue, and read-aloud handlers to existing buttons via data-testid, fixing handlers that previously queried selectors with no matching DOM nodes. - Add data-testid='nav-panel-${id}' to expanded sidebar nav buttons so the panel-opener shortcuts can target them. - Add new shortcut definitions and handlers: submitMessage, bookmarkConversation, continueResponse, readAloudLastResponse, and the open* panel openers (assistants, agents, prompts, memories, parameters, files, bookmarks, MCP). - Drop the toggleRightSidebar shortcut — there is no right sidebar to toggle in this codebase. - Refresh the KeyboardShortcutsDialog layout and ShortcutRecorder for the new groups, tighten ShortcutKeyCombo styling, and surface the shortcuts hint chips in the account menu. * chore(shortcuts): remove unused translation keys Drop com_shortcut_dialog_subtitle, com_shortcut_not_set, and com_shortcut_reset_aria — no remaining references in the codebase. * fix(shortcuts): resolve keyboard shortcut and footer regressions - Guard the temporary-chat toggle so the shortcut mirrors the UI, only toggling when the conversation has no messages and is not submitting. - Stop Ctrl/Cmd+Enter from double-submitting: the main chat textarea already submits via its own handler, and submit is blocked from unrelated inputs while still working in the chat box. - Ignore repeated keydown events (e.repeat) so held keys no longer re-run toggles or destructive actions. - Scope archive/delete shortcuts to the conversation in the active route using useMatch, preventing mutations of a stale background conversation on non-chat routes. - Keep the recorder conflict controls clickable by including the whole editing row in the outside-click containment check. - Restore privacy policy and terms of service links on public share pages via an opt-in Footer prop. - Expand the sidebar before activating panel shortcuts so they are visible on mobile, and avoid toggling an already-active panel. * fix(shortcuts): reject bare non-printable shortcut bindings A recorded non-printable key (Tab, Enter, Backspace, Delete, arrows, Space) with no Cmd/Ctrl/Alt was treated as valid, so it could be saved and then hijack navigation or fire destructive actions since the global handler preventDefaults it outside text inputs. Require Shift at minimum for these keys, which keeps Shift+Escape (focusChat) valid while rejecting bare single-key bindings. * style: fix import order drift across keyboard shortcut files * fix(shortcuts): guard actions behind dialog and resolve reset conflicts - Ignore global shortcut actions while the shortcuts dialog is open (except the toggle that closes it), so a combo like delete/archive can no longer fire on the conversation behind the modal. - When resetting a shortcut to its default, unbind any other action whose custom binding collides with that restored default, so Reset after a Replace can't leave two rows sharing one binding with one action unreachable. * fix(shortcuts): keep attach menu button accessible name stable The shortcut pass changed the attach menu button's aria-label from the hardcoded "Attach File Options" to localize('com_sidepanel_attach_files') ("Attach Files"), which changed its accessible name and broke the provider-file e2e specs that locate it by name. Restore the original label and keep only the added aria-keyshortcuts. * fix(shortcuts): gate temporary chat toggle to chat routes The Root-level listener runs on non-chat routes (search, settings, panels) where the last loaded conversation may be empty, so Ctrl/Cmd+Shift+T could flip the hidden isTemporary state without the TemporaryChat control being visible. Require an active chat route (routeConvoId) before toggling. * test(shortcuts): align attach menu spec with button accessible name The attach menu button's aria-label was restored to "Attach File Options" (matching dev and the provider-file e2e specs), so update the unit test's button queries from /attach files/i to /attach file options/i. All 26 cases pass. * fix(shortcuts): target conversation bookmark and reveal search panel - Bookmark: query the unique #bookmark-menu-button so the shortcut bookmarks the current conversation. The previous querySelector('[data-testid="bookmark-menu"]') matched the sidebar tag-filter button first (same testid, earlier in the DOM), toggling the filter instead of bookmarking. - Focus search: activate the conversations panel before focusing, since the search input only mounts there and the sidebar renders just the active panel. Route through the nav-panel-conversations button (the listener is outside ActivePanelProvider) and settle before focusing, so Ctrl/Cmd+/ works from any panel. * fix(shortcuts): preserve footer links, cross-platform bindings, modal guard - restore unconditional legal footer links (drop showLegalLinks gate) - keep untouched platform's default when customizing a binding - round-trip bindings whose key is the plus character - suppress global shortcuts while any modal dialog is open - tag read-aloud test id only on assistant turns * fix(shortcuts): include non-Radix dialogs in the modal guard The guard only matched Radix dialogs via data-state="open", missing Headless UI dialogs (e.g. the redesigned Settings modal) that render role="dialog" without data-state. Iterate all dialog/alertdialog nodes and treat one as open unless it is inert or data-state="closed", which also avoids false positives from always-mounted inert panels. * fix(shortcuts): gate temporary chat toggle behind TEMPORARY_CHAT permission * fix(shortcuts): only prevent native key event when shortcut action runs * fix(shortcuts): rebind temporary chat, open settings without toggling menu, release no-op keys * fix(shortcuts): confirm conversation delete, use clipboard fallback, add tests * fix(shortcuts): navigate to new chat after keyboard-confirmed delete * fix(shortcuts): copy last response via message button, guard unavailable controls * fix(shortcuts): keep custom Enter-based submit bindings working in the composer * fix(shortcuts): restrict shift-only bindings to safe keys * fix(shortcuts): submit custom Enter chords in the composer without inserting a newline * fix(shortcuts): block global shortcuts while a menu overlay is focused * fix(shortcuts): rebind archive off the browser-reserved Ctrl+Shift+A * fix(shortcuts): honor submitMessage overrides in the composer --- .gitignore | 1 + .../Chat/Input/Files/AttachFile.tsx | 6 +- .../Chat/Input/Files/AttachFileMenu.tsx | 6 +- .../Files/__tests__/AttachFileMenu.spec.tsx | 2 +- .../src/components/Chat/Input/StopButton.tsx | 1 + .../Chat/Menus/Endpoints/ModelSelector.tsx | 7 +- .../src/components/Chat/Menus/OpenSidebar.tsx | 6 +- .../components/Chat/Messages/HoverButtons.tsx | 8 + client/src/components/Chat/TemporaryChat.tsx | 6 +- .../ConvoOptions/DeleteButton.tsx | 8 +- .../components/Input/Generations/Button.tsx | 17 +- .../Input/Generations/Regenerate.tsx | 2 +- .../src/components/Input/Generations/Stop.tsx | 2 +- .../Generations/__tests__/Button.spec.tsx | 8 +- client/src/components/Nav/AccountSettings.tsx | 109 +- .../components/Nav/KeyboardDeleteDialog.tsx | 34 + .../Nav/KeyboardShortcutsDialog.tsx | 403 +++++++ client/src/components/Nav/NavToggle.tsx | 7 +- client/src/components/Nav/SearchBar.tsx | 4 + .../src/components/Nav/ShortcutKeyCombo.tsx | 43 + .../src/components/Nav/ShortcutRecorder.tsx | 267 +++++ .../UnifiedSidebar/ExpandedPanel.tsx | 16 +- .../__tests__/ExpandedPanel.spec.tsx | 5 + client/src/hooks/Input/useTextarea.ts | 64 +- .../src/hooks/useKeyboardShortcuts.spec.tsx | 341 ++++++ client/src/hooks/useKeyboardShortcuts.ts | 1009 +++++++++++++++++ client/src/locales/en/translation.json | 44 + client/src/routes/Root.tsx | 31 +- client/src/store/misc.ts | 28 + client/src/style.css | 9 + client/src/utils/shortcuts.spec.ts | 248 ++++ client/src/utils/shortcuts.ts | 266 +++++ client/tailwind.config.cjs | 6 + 33 files changed, 2970 insertions(+), 44 deletions(-) create mode 100644 client/src/components/Nav/KeyboardDeleteDialog.tsx create mode 100644 client/src/components/Nav/KeyboardShortcutsDialog.tsx create mode 100644 client/src/components/Nav/ShortcutKeyCombo.tsx create mode 100644 client/src/components/Nav/ShortcutRecorder.tsx create mode 100644 client/src/hooks/useKeyboardShortcuts.spec.tsx create mode 100644 client/src/hooks/useKeyboardShortcuts.ts create mode 100644 client/src/utils/shortcuts.spec.ts create mode 100644 client/src/utils/shortcuts.ts diff --git a/.gitignore b/.gitignore index 4fd8ecb2db..15aa483faa 100644 --- a/.gitignore +++ b/.gitignore @@ -179,3 +179,4 @@ claude-flow hive-mind-prompt-*.txt CLAUDE.md .gsd +codedb.snapshot diff --git a/client/src/components/Chat/Input/Files/AttachFile.tsx b/client/src/components/Chat/Input/Files/AttachFile.tsx index 098fa2c4c3..413caa21b2 100644 --- a/client/src/components/Chat/Input/Files/AttachFile.tsx +++ b/client/src/components/Chat/Input/Files/AttachFile.tsx @@ -2,6 +2,7 @@ import React, { useRef } from 'react'; import { FileUpload, TooltipAnchor, AttachmentIcon } from '@librechat/client'; import type { TConversation } from 'librechat-data-provider'; import type { ExtendedFile, FileSetter } from '~/common'; +import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts'; import { useFileHandlingNoChatContext, useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -21,6 +22,8 @@ const AttachFile = ({ const localize = useLocalize(); const inputRef = useRef(null); const isUploadDisabled = disabled ?? false; + const tooltipDescription = useShortcutHint('uploadFile', localize('com_sidepanel_attach_files')); + const ariaKey = useShortcutAriaKey('uploadFile'); const { handleFileChange } = useFileHandlingNoChatContext(undefined, { files, @@ -32,13 +35,14 @@ const AttachFile = ({ return ( (null); const [isPopoverActive, setIsPopoverActive] = useState(false); + const uploadFileTooltip = useShortcutHint('uploadFile', localize('com_sidepanel_attach_files')); + const uploadFileAriaKey = useShortcutAriaKey('uploadFile'); const [ephemeralAgent, setEphemeralAgent] = useRecoilState( ephemeralAgentByConvoId(conversationId), ); @@ -277,6 +280,7 @@ const AttachFileMenu = ({ disabled={isUploadDisabled} id="attach-file-menu-button" aria-label="Attach File Options" + aria-keyshortcuts={uploadFileAriaKey} className={cn( 'flex size-9 items-center justify-center rounded-full p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50', isPopoverActive && 'bg-surface-hover', @@ -288,7 +292,7 @@ const AttachFileMenu = ({ } id="attach-file-menu-button" - description={localize('com_sidepanel_attach_files')} + description={uploadFileTooltip} disabled={isUploadDisabled} /> ); diff --git a/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx b/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx index 25b2014d51..3f86cb5102 100644 --- a/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx +++ b/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; +import { render, screen, fireEvent } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { EModelEndpoint, EToolResources, Providers } from 'librechat-data-provider'; import AttachFileMenu from '../AttachFileMenu'; diff --git a/client/src/components/Chat/Input/StopButton.tsx b/client/src/components/Chat/Input/StopButton.tsx index fd94ba806c..cc45348072 100644 --- a/client/src/components/Chat/Input/StopButton.tsx +++ b/client/src/components/Chat/Input/StopButton.tsx @@ -18,6 +18,7 @@ export default memo(function StopButton({ render={ ); } diff --git a/client/src/components/Input/Generations/Regenerate.tsx b/client/src/components/Input/Generations/Regenerate.tsx index 715168cb92..7eff9f0eae 100644 --- a/client/src/components/Input/Generations/Regenerate.tsx +++ b/client/src/components/Input/Generations/Regenerate.tsx @@ -7,7 +7,7 @@ export default function Regenerate({ onClick }: TGenButtonProps) { const localize = useLocalize(); return ( - diff --git a/client/src/components/Input/Generations/Stop.tsx b/client/src/components/Input/Generations/Stop.tsx index d66baa9450..44ce392d48 100644 --- a/client/src/components/Input/Generations/Stop.tsx +++ b/client/src/components/Input/Generations/Stop.tsx @@ -7,7 +7,7 @@ export default function Stop({ onClick }: TGenButtonProps) { const localize = useLocalize(); return ( - diff --git a/client/src/components/Input/Generations/__tests__/Button.spec.tsx b/client/src/components/Input/Generations/__tests__/Button.spec.tsx index 77c71a1353..f4f6629ea8 100644 --- a/client/src/components/Input/Generations/__tests__/Button.spec.tsx +++ b/client/src/components/Input/Generations/__tests__/Button.spec.tsx @@ -1,9 +1,13 @@ +/* eslint-disable i18next/no-literal-string */ +import { RecoilRoot } from 'recoil'; import { render, fireEvent } from '@testing-library/react'; import Button from '../Button'; +const renderWithRecoil = (ui: React.ReactElement) => render({ui}); + describe('Button', () => { it('renders with the correct type and children', () => { - const { getByTestId, getByText } = render( + const { getByTestId, getByText } = renderWithRecoil( , diff --git a/client/src/components/Nav/AccountSettings.tsx b/client/src/components/Nav/AccountSettings.tsx index efd0657542..d5c3765261 100644 --- a/client/src/components/Nav/AccountSettings.tsx +++ b/client/src/components/Nav/AccountSettings.tsx @@ -1,13 +1,96 @@ import { useState, memo, useRef } from 'react'; +import { useSetRecoilState } from 'recoil'; import * as Menu from '@ariakit/react/menu'; -import { FileText, Archive, LogOut } from 'lucide-react'; -import { LinkIcon, GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client'; +import { GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client'; +import { + Archive, + ChevronRight, + CircleHelp, + FileText, + Keyboard, + LifeBuoy, + LogOut, + Scale, + ShieldCheck, +} from 'lucide-react'; import { ArchivedChatsModal } from '~/components/Nav/SettingsTabs/General/ArchivedChatsModal'; 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 HelpSubmenu({ + helpAndFaqURL, + termsOfServiceURL, + privacyPolicyURL, + onShowShortcuts, +}: { + helpAndFaqURL?: string; + termsOfServiceURL?: string; + privacyPolicyURL?: string; + onShowShortcuts: () => void; +}) { + const localize = useLocalize(); + const hasHelpFaq = !!helpAndFaqURL && helpAndFaqURL !== '/'; + const hasTos = !!termsOfServiceURL; + const hasPrivacy = !!privacyPolicyURL; + const showLegalDivider = (hasHelpFaq || true) && (hasTos || hasPrivacy); + + return ( + + + } + > + + + {hasHelpFaq && ( + window.open(helpAndFaqURL, '_blank', 'noopener,noreferrer')} + className="select-item text-sm" + > + + )} + + + {showLegalDivider && (hasTos || hasPrivacy) && } + {hasTos && ( + window.open(termsOfServiceURL, '_blank', 'noopener,noreferrer')} + className="select-item text-sm" + > + + )} + {hasPrivacy && ( + window.open(privacyPolicyURL, '_blank', 'noopener,noreferrer')} + className="select-item text-sm" + > + + )} + + + ); +} function AccountSettings({ collapsed = false }: { collapsed?: boolean }) { const localize = useLocalize(); @@ -18,6 +101,7 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) { }); const [showSettings, setShowSettings] = useState(false); const [showFiles, setShowFiles] = useState(false); + const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog); const [showArchived, setShowArchived] = useState(false); const accountSettingsButtonRef = useRef(null); @@ -70,6 +154,12 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) { )} + setShowShortcutsDialog(true)} + /> setShowFiles(true)} className="select-item text-sm"> - {startupConfig?.helpAndFaqURL !== '/' && ( - window.open(startupConfig?.helpAndFaqURL, '_blank')} - className="select-item text-sm" - > - - )} - setShowSettings(true)} className="select-item text-sm"> + setShowSettings(true)} + className="select-item text-sm" + data-testid="nav-settings" + > diff --git a/client/src/components/Nav/KeyboardDeleteDialog.tsx b/client/src/components/Nav/KeyboardDeleteDialog.tsx new file mode 100644 index 0000000000..9b8d460074 --- /dev/null +++ b/client/src/components/Nav/KeyboardDeleteDialog.tsx @@ -0,0 +1,34 @@ +import { useCallback } from 'react'; +import { useRecoilState } from 'recoil'; +import DeleteButton from '~/components/Conversations/ConvoOptions/DeleteButton'; +import store from '~/store'; + +const retainView = () => {}; + +export default function KeyboardDeleteDialog() { + const [target, setTarget] = useRecoilState(store.keyboardDeleteTarget); + + const setShowDeleteDialog = useCallback( + (open: boolean) => { + if (!open) { + setTarget(null); + } + }, + [setTarget], + ); + + if (!target) { + return null; + } + + return ( + + ); +} diff --git a/client/src/components/Nav/KeyboardShortcutsDialog.tsx b/client/src/components/Nav/KeyboardShortcutsDialog.tsx new file mode 100644 index 0000000000..cc49b8596b --- /dev/null +++ b/client/src/components/Nav/KeyboardShortcutsDialog.tsx @@ -0,0 +1,403 @@ +import { memo, useCallback, useMemo, useState } from 'react'; +import { Plus, X } from 'lucide-react'; +import { useRecoilState } from 'recoil'; +import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogClose } from '@librechat/client'; +import type { ShortcutActionId, ShortcutBindingInfo } from '~/hooks/useKeyboardShortcuts'; +import type { TranslationKeys } from '~/hooks/useLocalize'; +import type { ShortcutBinding } from '~/utils/shortcuts'; +import { RecorderInfo, RecorderPill, useShortcutRecorder } from './ShortcutRecorder'; +import { isMac, useShortcutBindings } from '~/hooks/useKeyboardShortcuts'; +import { bindingDisplayKeys } from '~/utils/shortcuts'; +import ShortcutKeyCombo from './ShortcutKeyCombo'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; +import store from '~/store'; + +type GroupedBindings = Record; + +const PANELS_GROUP = 'com_shortcut_group_panels'; + +function EditingRow({ + info, + label, + bindingMap, + getActionLabel, + setBinding, + onStopEdit, +}: { + info: ShortcutBindingInfo; + label: string; + bindingMap: Map; + getActionLabel: (id: string) => string; + setBinding: (id: ShortcutActionId, binding: ShortcutBinding | null) => void; + onStopEdit: () => void; +}) { + const localize = useLocalize(); + + const handleSave = useCallback( + (binding: ShortcutBinding) => { + setBinding(info.id, binding); + onStopEdit(); + }, + [info.id, setBinding, onStopEdit], + ); + + const handleSaveReplacing = useCallback( + (binding: ShortcutBinding, conflictId: string) => { + setBinding(conflictId as ShortcutActionId, null); + setBinding(info.id, binding); + onStopEdit(); + }, + [info.id, setBinding, onStopEdit], + ); + + const recorder = useShortcutRecorder({ + initial: info.binding, + bindingMap: bindingMap as Map, + ownerId: info.id, + getActionLabel, + onSave: handleSave, + onCancel: onStopEdit, + }); + + return ( +
+
+ {label} + +
+ +
+ ); +} + +function ShortcutRow({ + info, + isEditing, + onStartEdit, + onStopEdit, + bindingMap, + getActionLabel, + setBinding, + resetBinding, +}: { + info: ShortcutBindingInfo; + isEditing: boolean; + onStartEdit: (id: ShortcutActionId) => void; + onStopEdit: () => void; + bindingMap: Map; + getActionLabel: (id: string) => string; + setBinding: (id: ShortcutActionId, binding: ShortcutBinding | null) => void; + resetBinding: (id: ShortcutActionId) => void; +}) { + const localize = useLocalize(); + const label = localize(info.labelKey as TranslationKeys); + const displayKeys = useMemo(() => bindingDisplayKeys(info.binding, isMac), [info.binding]); + const editAriaLabel = localize('com_shortcut_edit_aria', { 0: label }); + const isUnset = displayKeys.length === 0; + + if (isEditing) { + return ( +
+ +
+ ); + } + + return ( +
+ + {label} + +
+ {info.isCustom && ( + + )} + {isUnset ? ( + + ) : ( + + )} +
+
+ ); +} + +function ShortcutGroup({ + groupKey, + bindings, + editingId, + onStartEdit, + onStopEdit, + bindingMap, + getActionLabel, + setBinding, + resetBinding, +}: { + groupKey: string; + bindings: ShortcutBindingInfo[]; + editingId: ShortcutActionId | null; + onStartEdit: (id: ShortcutActionId) => void; + onStopEdit: () => void; + bindingMap: Map; + getActionLabel: (id: string) => string; + setBinding: (id: ShortcutActionId, binding: ShortcutBinding | null) => void; + resetBinding: (id: ShortcutActionId) => void; +}) { + const localize = useLocalize(); + return ( +
+

+ {localize(groupKey as TranslationKeys)} +

+
+ {bindings.map((info) => ( + + ))} +
+
+ ); +} + +function PanelsSection({ + bindings, + editingId, + onStartEdit, + onStopEdit, + bindingMap, + getActionLabel, + setBinding, + resetBinding, +}: { + bindings: ShortcutBindingInfo[]; + editingId: ShortcutActionId | null; + onStartEdit: (id: ShortcutActionId) => void; + onStopEdit: () => void; + bindingMap: Map; + getActionLabel: (id: string) => string; + setBinding: (id: ShortcutActionId, binding: ShortcutBinding | null) => void; + resetBinding: (id: ShortcutActionId) => void; +}) { + const localize = useLocalize(); + return ( +
+
+

+ {localize('com_shortcut_group_panels')} +

+

+ {localize('com_shortcut_group_panels_hint')} +

+
+
+ {bindings.map((info) => ( + + ))} +
+
+ ); +} + +function KeyboardShortcutsDialog() { + const localize = useLocalize(); + const { bindings, bindingMap, setBinding, resetBinding, resetAll } = useShortcutBindings(); + const [open, setOpen] = useRecoilState(store.showShortcutsDialog); + const [editingId, setEditingId] = useState(null); + + const grouped = useMemo(() => { + const groups: GroupedBindings = {}; + for (const info of bindings) { + const group = info.groupKey; + if (!groups[group]) { + groups[group] = []; + } + groups[group].push(info); + } + return groups; + }, [bindings]); + + const groupEntries = useMemo(() => Object.entries(grouped), [grouped]); + + const leftColumn = useMemo( + () => groupEntries.filter(([key]) => key !== 'com_shortcut_group_chat' && key !== PANELS_GROUP), + [groupEntries], + ); + const rightColumn = useMemo( + () => groupEntries.filter(([key]) => key === 'com_shortcut_group_chat'), + [groupEntries], + ); + const panelEntries = useMemo(() => grouped[PANELS_GROUP] ?? [], [grouped]); + + const labelMap = useMemo>(() => { + const map = new Map(); + for (const info of bindings) { + map.set(info.id, localize(info.labelKey as TranslationKeys)); + } + return map; + }, [bindings, localize]); + + const getActionLabel = useCallback((id: string) => labelMap.get(id) ?? id, [labelMap]); + + const handleStartEdit = useCallback((id: ShortcutActionId) => { + setEditingId(id); + }, []); + const handleStopEdit = useCallback(() => { + setEditingId(null); + }, []); + + const hasAnyCustom = useMemo(() => bindings.some((b) => b.isCustom), [bindings]); + + return ( + { + if (!next) { + setEditingId(null); + } + setOpen(next); + }} + > + +
+ + {localize('com_shortcut_keyboard_shortcuts')} + + + + {localize('com_ui_close')} + +
+ +
+
+
+ {leftColumn.map(([groupKey, items]) => ( + + ))} +
+
+ {rightColumn.map(([groupKey, items]) => ( + + ))} +
+
+ {panelEntries.length > 0 && ( + + )} +
+ + {hasAnyCustom && ( +
+ +
+ )} +
+
+ ); +} + +export default memo(KeyboardShortcutsDialog); diff --git a/client/src/components/Nav/NavToggle.tsx b/client/src/components/Nav/NavToggle.tsx index 301ccdfe8c..07a4fdccf9 100644 --- a/client/src/components/Nav/NavToggle.tsx +++ b/client/src/components/Nav/NavToggle.tsx @@ -1,4 +1,5 @@ import { TooltipAnchor } from '@librechat/client'; +import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -45,6 +46,9 @@ export default function NavToggle({ } const ariaDescription = localize(actionKey, { 0: sidebarLabel }); + const shortcutId = side === 'left' ? 'toggleSidebar' : undefined; + const tooltipDescription = useShortcutHint(shortcutId, ariaDescription); + const ariaKey = useShortcutAriaKey(shortcutId); return (
diff --git a/client/src/components/Nav/SearchBar.tsx b/client/src/components/Nav/SearchBar.tsx index 84648ac67d..3bbcacb506 100644 --- a/client/src/components/Nav/SearchBar.tsx +++ b/client/src/components/Nav/SearchBar.tsx @@ -5,6 +5,7 @@ import { Search, X } from 'lucide-react'; import { QueryKeys } from 'librechat-data-provider'; import { useQueryClient } from '@tanstack/react-query'; import { useLocation, useNavigate } from 'react-router-dom'; +import { useShortcutAriaKey } from '~/hooks/useKeyboardShortcuts'; import { useLocalize, useNewConvo } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; @@ -23,6 +24,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref(null); const [showClearIcon, setShowClearIcon] = useState(false); + const focusSearchAriaKey = useShortcutAriaKey('focusSearch'); const { newConversation: newConvo } = useNewConvo(); const [search, setSearchState] = useRecoilState(store.search); @@ -117,6 +119,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref setSearchState((prev) => ({ ...prev, isSearching: true }))} diff --git a/client/src/components/Nav/ShortcutKeyCombo.tsx b/client/src/components/Nav/ShortcutKeyCombo.tsx new file mode 100644 index 0000000000..c36b05daad --- /dev/null +++ b/client/src/components/Nav/ShortcutKeyCombo.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from 'react'; +import { cn } from '~/utils'; + +export function parseShortcutKeys(display: string): string[] { + return display.split(/([+\s]+)/).filter((key) => key.trim().length > 0 && key !== '+'); +} + +function ShortcutKbd({ children, className = '' }: { children: ReactNode; className?: string }) { + return ( + + {children} + + ); +} + +export default function ShortcutKeyCombo({ + display, + keys, + className = '', + keyClassName = '', +}: { + display?: string; + keys?: string[]; + className?: string; + keyClassName?: string; +}) { + const shortcutKeys = keys ?? parseShortcutKeys(display ?? ''); + + return ( +
+ {shortcutKeys.map((key, idx) => ( + + {key} + + ))} +
+ ); +} diff --git a/client/src/components/Nav/ShortcutRecorder.tsx b/client/src/components/Nav/ShortcutRecorder.tsx new file mode 100644 index 0000000000..9c151482b0 --- /dev/null +++ b/client/src/components/Nav/ShortcutRecorder.tsx @@ -0,0 +1,267 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { X } from 'lucide-react'; +import type { RefObject } from 'react'; +import type { ShortcutBinding } from '~/utils/shortcuts'; +import { + bindingDisplayKeys, + bindingHash, + isCancelKey, + isModifierKey, + isValidBinding, + normalizeKey, +} from '~/utils/shortcuts'; +import { isMac } from '~/hooks/useKeyboardShortcuts'; +import ShortcutKeyCombo from './ShortcutKeyCombo'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +type ConflictInfo = { + conflictId: string; + conflictLabel: string; + binding: ShortcutBinding; +}; + +type RecorderState = { + containerRef: RefObject; + boundaryRef: RefObject; + previewKeys: string[]; + hasConflict: boolean; + conflict: ConflictInfo | null; + showInvalid: boolean; + showHint: boolean; + onKeyDown: (e: React.KeyboardEvent) => void; + onKeyUp: () => void; + onTryAgain: () => void; +}; + +type Options = { + initial: ShortcutBinding | null; + bindingMap: Map; + ownerId: string; + getActionLabel: (id: string) => string; + onSave: (binding: ShortcutBinding) => void; + onCancel: () => void; +}; + +export function useShortcutRecorder({ + initial, + bindingMap, + ownerId, + getActionLabel, + onSave, + onCancel, +}: Options): RecorderState { + const containerRef = useRef(null); + const boundaryRef = useRef(null); + const [pending, setPending] = useState(null); + const [draft, setDraft] = useState(initial); + const [error, setError] = useState<'noModifier' | null>(null); + const [conflict, setConflict] = useState(null); + + const previewKeys = useMemo(() => { + const source = pending ?? draft; + if (!source) return []; + return bindingDisplayKeys(source, isMac); + }, [pending, draft]); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (isCancelKey(e.nativeEvent)) { + onCancel(); + return; + } + + const previewBinding: ShortcutBinding = { + meta: e.metaKey, + ctrl: e.ctrlKey, + alt: e.altKey, + shift: e.shiftKey, + key: isModifierKey(e.key) ? '' : normalizeKey(e.key, e.shiftKey), + }; + + if (!previewBinding.key) { + setPending({ ...previewBinding, key: '…' }); + return; + } + + setPending(null); + const validation = isValidBinding(previewBinding); + if (!validation.valid) { + setDraft(previewBinding); + setError('noModifier'); + setConflict(null); + return; + } + setError(null); + const hash = bindingHash(previewBinding); + const conflictId = bindingMap.get(hash); + if (conflictId && conflictId !== ownerId) { + setDraft(previewBinding); + setConflict({ + conflictId, + conflictLabel: getActionLabel(conflictId), + binding: previewBinding, + }); + return; + } + setConflict(null); + onSave(previewBinding); + }, + [bindingMap, getActionLabel, onCancel, onSave, ownerId], + ); + + const onKeyUp = useCallback(() => { + setPending(null); + }, []); + + const onTryAgain = useCallback(() => { + setDraft(initial); + setConflict(null); + setError(null); + containerRef.current?.focus(); + }, [initial]); + + useEffect(() => { + containerRef.current?.focus(); + }, []); + + useEffect(() => { + function onPointerDown(e: PointerEvent) { + const root = boundaryRef.current ?? containerRef.current; + if (root && !root.contains(e.target as Node)) { + onCancel(); + } + } + document.addEventListener('pointerdown', onPointerDown); + return () => document.removeEventListener('pointerdown', onPointerDown); + }, [onCancel]); + + return { + containerRef, + boundaryRef, + previewKeys, + hasConflict: !!conflict, + conflict, + showInvalid: error === 'noModifier', + showHint: previewKeys.length === 0, + onKeyDown, + onKeyUp, + onTryAgain, + }; +} + +export function RecorderPill({ + state, + ariaLabel, + ownerId, +}: { + state: RecorderState; + ariaLabel: string; + ownerId: string; +}) { + const localize = useLocalize(); + const { containerRef, previewKeys, hasConflict, showInvalid, showHint, onKeyDown, onKeyUp } = + state; + let stateBorder = 'border-border-medium'; + if (hasConflict) { + stateBorder = 'border-amber-500/60'; + } else if (showInvalid) { + stateBorder = 'animate-shortcut-shake border-red-500/60'; + } + return ( +
+ {showHint ? ( + + {localize('com_shortcut_recorder_placeholder')} + + ) : ( + + )} +
+ ); +} + +export function RecorderInfo({ + state, + ownerId, + onCancel, + onSaveReplacing, +}: { + state: RecorderState; + ownerId: string; + onCancel: () => void; + onSaveReplacing: (binding: ShortcutBinding, conflictId: string) => void; +}) { + const localize = useLocalize(); + const { hasConflict, conflict, showInvalid, onTryAgain } = state; + + if (hasConflict && conflict) { + return ( +
+ + {localize('com_shortcut_recorder_conflict_prefix')}{' '} + {conflict.conflictLabel} + +
+ + +
+
+ ); + } + + return ( +
+ + {showInvalid + ? localize('com_shortcut_recorder_needs_modifier') + : localize('com_shortcut_recorder_hint')} + + +
+ ); +} diff --git a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx index c0d3f165d5..46aebd240e 100644 --- a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx +++ b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx @@ -1,12 +1,13 @@ import { memo, useCallback, lazy, Suspense } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; import { useRecoilValue } from 'recoil'; import { SquarePen } from 'lucide-react'; import { QueryKeys } from 'librechat-data-provider'; +import { useQueryClient } from '@tanstack/react-query'; import { Skeleton, Sidebar, Button, TooltipAnchor } from '@librechat/client'; import type { NavLink } from '~/common'; -import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar'; +import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts'; import { useActivePanel, resolveActivePanel, DEFAULT_PANEL } from '~/Providers'; +import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar'; import { useLocalize, useNewConvo } from '~/hooks'; import { clearMessagesCache, cn } from '~/utils'; import store from '~/store'; @@ -23,6 +24,8 @@ const NewChatButton = memo(function NewChatButton({ const { newConversation } = useNewConvo(); const conversation = useRecoilValue(store.conversationByIndex(0)); const switchToHistory = useRecoilValue(store.newChatSwitchToHistory); + const tooltipDescription = useShortcutHint('newChat', localize('com_ui_new_chat')); + const ariaKey = useShortcutAriaKey('newChat'); const handleClick = useCallback( (e: React.MouseEvent) => { @@ -42,12 +45,13 @@ const NewChatButton = memo(function NewChatButton({ return ( @@ -105,6 +109,7 @@ const NavIconButton = memo(function NavIconButton({ variant="ghost" aria-label={localize(link.title)} aria-pressed={isActive} + data-testid={`nav-panel-${link.id}`} className={cn( 'h-9 w-9 rounded-lg', isActive ? 'bg-surface-active-alt text-text-primary' : 'text-text-secondary', @@ -135,12 +140,14 @@ function ExpandedPanel({ const toggleLabel = expanded ? 'com_nav_close_sidebar' : 'com_nav_open_sidebar'; const toggleClick = expanded ? onCollapse : onExpand; + const toggleSidebarHint = useShortcutHint('toggleSidebar', localize(toggleLabel)); + const toggleSidebarAriaKey = useShortcutAriaKey('toggleSidebar'); return (
diff --git a/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx index a79ce80c81..73f17d3508 100644 --- a/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx +++ b/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx @@ -17,12 +17,17 @@ jest.mock('~/store', () => { key: 'mock-newChatSwitchToHistory', default: true, }); + const customShortcutsAtom = atom({ + key: 'mock-customShortcuts', + default: {}, + }); return { __esModule: true, default: { conversationByIndex: () => atom({ key: `mock-conversationByIndex-${counter++}`, default: null }), newChatSwitchToHistory: switchAtom, + customShortcuts: customShortcutsAtom, }, }; }); diff --git a/client/src/hooks/Input/useTextarea.ts b/client/src/hooks/Input/useTextarea.ts index b8cd22cfbc..0e542705ee 100644 --- a/client/src/hooks/Input/useTextarea.ts +++ b/client/src/hooks/Input/useTextarea.ts @@ -1,8 +1,14 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef, useCallback, useMemo } from 'react'; import debounce from 'lodash/debounce'; import { useRecoilValue, useRecoilState } from 'recoil'; import type { TEndpointOption } from 'librechat-data-provider'; import type { KeyboardEvent } from 'react'; +import { + parseBinding, + isMacPlatform, + bindingFromEvent, + resolveSubmitOverrideAction, +} from '~/utils/shortcuts'; import { forceResize, insertTextAtCursor, @@ -44,6 +50,21 @@ export default function useTextarea({ const assistantMap = useAssistantsMapContext(); const checkHealth = useInteractionHealthCheck(); const enterToSend = useRecoilValue(store.enterToSend); + const customShortcuts = useRecoilValue(store.customShortcuts); + + /** + * Effective `submitMessage` override: `undefined` when unset (default Ctrl/Cmd+Enter applies), + * `null` when explicitly unbound, otherwise the rebound chord. When present, the composer + * honors it instead of the hard-coded Ctrl/Cmd+Enter so the shortcut can be replaced or + * disabled in the main place it is used. + */ + const submitOverride = useMemo(() => { + const override = customShortcuts['submitMessage']; + if (!override) { + return undefined; + } + return parseBinding(isMacPlatform ? override.mac : override.other); + }, [customShortcuts]); const { index, conversation, isSubmitting, filesLoading, setFilesLoading } = useChatContext(); const latestMessage = useLatestMessage(index); @@ -165,6 +186,39 @@ export default function useTextarea({ // NOTE: isComposing and e.key behave differently in Safari compared to other browsers, forcing us to use e.keyCode instead const isComposingInput = isComposing.current || e.key === 'Process' || e.keyCode === 229; + const submitMessage = () => { + const globalAudio = document.getElementById(globalAudioId) as HTMLAudioElement | undefined; + if (globalAudio) { + console.log('Unmuting global audio'); + globalAudio.muted = false; + } + submitButtonRef.current?.click(); + }; + + // A rebound (or unbound) submitMessage shortcut takes over Enter handling in the composer + // so the default Ctrl/Cmd+Enter no longer submits once the user has replaced or disabled it. + if (submitOverride !== undefined) { + if (isComposingInput) { + return; + } + const action = resolveSubmitOverrideAction( + bindingFromEvent(e.nativeEvent), + submitOverride, + enterToSend, + ); + if (action === 'submit') { + e.preventDefault(); + submitMessage(); + return; + } + if (action === 'newline' && textAreaRef.current) { + e.preventDefault(); + insertTextAtCursor(textAreaRef.current, '\n'); + forceResize(textAreaRef.current); + } + return; + } + if (isNonShiftEnter && filesLoading) { e.preventDefault(); } @@ -187,12 +241,7 @@ export default function useTextarea({ } if ((isNonShiftEnter || isCtrlEnter) && !isComposingInput) { - const globalAudio = document.getElementById(globalAudioId) as HTMLAudioElement | undefined; - if (globalAudio) { - console.log('Unmuting global audio'); - globalAudio.muted = false; - } - submitButtonRef.current?.click(); + submitMessage(); } }, [ @@ -200,6 +249,7 @@ export default function useTextarea({ checkHealth, filesLoading, enterToSend, + submitOverride, setIsScrollable, textAreaRef, submitButtonRef, diff --git a/client/src/hooks/useKeyboardShortcuts.spec.tsx b/client/src/hooks/useKeyboardShortcuts.spec.tsx new file mode 100644 index 0000000000..9826962b18 --- /dev/null +++ b/client/src/hooks/useKeyboardShortcuts.spec.tsx @@ -0,0 +1,341 @@ +import copy from 'copy-to-clipboard'; +import { MemoryRouter } from 'react-router-dom'; +import { RecoilRoot, useRecoilValue } from 'recoil'; +import { render, act, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { TConversation } from 'librechat-data-provider'; +import type { MutableSnapshot } from 'recoil'; +import type { ReactNode } from 'react'; +import useKeyboardShortcuts, { + isOverridden, + effectiveBinding, + getShortcutDisplay, + getShortcutAriaKey, +} from './useKeyboardShortcuts'; +import store from '~/store'; + +jest.mock('copy-to-clipboard', () => ({ + __esModule: true, + default: jest.fn(() => true), +})); + +jest.mock('./useNewConvo', () => ({ + __esModule: true, + default: () => ({ newConversation: jest.fn() }), +})); + +const STORAGE_KEY = 'customKeyboardShortcuts'; +const copyMock = copy as jest.MockedFunction; + +function buildConversation(conversationId: string, title: string): TConversation { + return { conversationId, title, endpoint: 'agents' } as TConversation; +} + +function dispatchKey(init: KeyboardEventInit, target: EventTarget = document): KeyboardEvent { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }); + act(() => { + target.dispatchEvent(event); + }); + return event; +} + +function Harness() { + useKeyboardShortcuts(); + const deleteTarget = useRecoilValue(store.keyboardDeleteTarget); + const sidebarExpanded = useRecoilValue(store.sidebarExpanded); + return ( + <> + {deleteTarget?.conversationId ?? 'none'} + {String(sidebarExpanded)} + + ); +} + +function renderHarness(conversation?: TConversation, route = '/c/test-convo') { + const initializeState = (snapshot: MutableSnapshot) => { + if (conversation) { + snapshot.set(store.conversationByIndex(0), conversation); + } + }; + return render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + + + {children} + + + ), + }); +} + +beforeEach(() => { + window.localStorage.clear(); + copyMock.mockClear(); +}); + +afterEach(() => { + cleanup(); + document.body.replaceChildren(); +}); + +function appendCodeBlock(code: string) { + const turn = document.createElement('div'); + turn.className = 'agent-turn'; + const pre = document.createElement('pre'); + const codeEl = document.createElement('code'); + codeEl.textContent = code; + pre.appendChild(codeEl); + turn.appendChild(pre); + document.body.appendChild(turn); +} + +function appendResponseCopyButton(onClick: () => void) { + const button = document.createElement('button'); + button.dataset.testid = 'copy-response-button'; + button.addEventListener('click', onClick); + document.body.appendChild(button); +} + +describe('binding resolution helpers', () => { + it('falls back to the default binding when there is no override', () => { + const binding = effectiveBinding('newChat'); + expect(binding).toMatchObject({ ctrl: true, shift: true, key: 'O' }); + expect(getShortcutDisplay('newChat')).toBe('Ctrl+Shift+O'); + expect(getShortcutAriaKey('newChat')).toBe('Control+Shift+O'); + }); + + it('honors a stored custom binding for the current platform', () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ newChat: { mac: 'Meta+Shift+P', other: 'Control+Shift+P' } }), + ); + expect(effectiveBinding('newChat')).toMatchObject({ ctrl: true, shift: true, key: 'P' }); + expect(getShortcutAriaKey('newChat')).toBe('Control+Shift+P'); + }); + + it('treats a null platform override as an unbound shortcut', () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ newChat: { mac: null, other: null } }), + ); + expect(effectiveBinding('newChat')).toBeNull(); + expect(getShortcutDisplay('newChat')).toBe(''); + }); + + it('detects whether an override diverges from the default', () => { + expect(isOverridden('newChat', undefined)).toBe(false); + expect(isOverridden('newChat', { mac: 'Meta+Shift+O', other: 'Control+Shift+O' })).toBe(false); + expect(isOverridden('newChat', { mac: null, other: null })).toBe(true); + expect(isOverridden('newChat', { mac: 'Meta+Shift+P', other: 'Control+Shift+P' })).toBe(true); + }); +}); + +describe('global shortcut dispatch', () => { + it('runs the matched action and prevents the native event', () => { + const { getByTestId } = renderHarness(); + const before = getByTestId('sidebar').textContent; + + const event = dispatchKey({ key: 's', ctrlKey: true, shiftKey: true }); + + expect(event.defaultPrevented).toBe(true); + expect(getByTestId('sidebar').textContent).not.toBe(before); + }); + + it('ignores shortcuts while a modal dialog is open', () => { + renderHarness(); + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + document.body.appendChild(dialog); + + const event = dispatchKey({ key: 's', ctrlKey: true, shiftKey: true }); + + expect(event.defaultPrevented).toBe(false); + }); + + it('ignores non-allowed shortcuts while typing in an input', () => { + renderHarness(); + const input = document.createElement('input'); + document.body.appendChild(input); + + const event = dispatchKey({ key: 's', ctrlKey: true, shiftKey: true }, input); + + expect(event.defaultPrevented).toBe(false); + }); + + it('ignores shortcuts while focus is inside an open menu overlay', () => { + const { getByTestId } = renderHarness(); + const before = getByTestId('sidebar').textContent; + const menu = document.createElement('div'); + menu.setAttribute('role', 'menu'); + const item = document.createElement('button'); + item.setAttribute('role', 'menuitem'); + menu.appendChild(item); + document.body.appendChild(menu); + + const event = dispatchKey({ key: 's', ctrlKey: true, shiftKey: true }, item); + + expect(event.defaultPrevented).toBe(false); + expect(getByTestId('sidebar').textContent).toBe(before); + }); + + it('does not prevent the native event when the action is a no-op', () => { + renderHarness(); + + // focusChat (Shift+Escape) with no chat textarea present is a no-op. + const event = dispatchKey({ key: 'Escape', shiftKey: true }); + + expect(event.defaultPrevented).toBe(false); + }); + + it('focuses the chat input and prevents the event when the textarea exists', () => { + renderHarness(); + const textarea = document.createElement('textarea'); + textarea.id = 'prompt-textarea'; + document.body.appendChild(textarea); + + const event = dispatchKey({ key: 'Escape', shiftKey: true }); + + expect(event.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(textarea); + }); +}); + +describe('clipboard shortcuts', () => { + it('copies the last response through the existing message copy button', () => { + const firstCopy = jest.fn(); + const secondCopy = jest.fn(); + renderHarness(); + appendResponseCopyButton(firstCopy); + appendResponseCopyButton(secondCopy); + + const event = dispatchKey({ key: ';', ctrlKey: true, shiftKey: true }); + + expect(firstCopy).not.toHaveBeenCalled(); + expect(secondCopy).toHaveBeenCalledTimes(1); + expect(copyMock).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + }); + + it('copies the last code block through the clipboard fallback helper', () => { + renderHarness(); + appendCodeBlock('const x = 1;'); + + const event = dispatchKey({ key: 'k', ctrlKey: true, shiftKey: true }); + + expect(copyMock).toHaveBeenCalledWith('const x = 1;', { format: 'text/plain' }); + expect(event.defaultPrevented).toBe(true); + }); + + it('does not copy or prevent the event when there is no code to copy', () => { + renderHarness(); + + const event = dispatchKey({ key: 'k', ctrlKey: true, shiftKey: true }); + + expect(copyMock).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); +}); + +describe('no-op shortcuts', () => { + it('does not prevent submit shortcut when the send button is unavailable', () => { + renderHarness(); + + const event = dispatchKey({ key: 'Enter', ctrlKey: true }); + + expect(event.defaultPrevented).toBe(false); + }); + + it('does not prevent submit shortcut when the send button is disabled', () => { + renderHarness(); + const button = document.createElement('button'); + button.dataset.testid = 'send-button'; + button.disabled = true; + document.body.appendChild(button); + + const event = dispatchKey({ key: 'Enter', ctrlKey: true }); + + expect(event.defaultPrevented).toBe(false); + }); +}); + +function appendSendButton(): jest.Mock { + const onClick = jest.fn(); + const button = document.createElement('button'); + button.dataset.testid = 'send-button'; + button.addEventListener('click', onClick); + document.body.appendChild(button); + return onClick; +} + +function appendMainTextarea(): HTMLTextAreaElement { + const textarea = document.createElement('textarea'); + textarea.id = 'prompt-textarea'; + document.body.appendChild(textarea); + return textarea; +} + +function bindSubmitMessage(binding: string) { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ submitMessage: { mac: binding, other: binding } }), + ); +} + +describe('composer submit shortcuts', () => { + it('defers a custom Alt+Enter submit binding in the composer to the textarea', () => { + window.localStorage.setItem('enterToSend', 'false'); + bindSubmitMessage('Alt+Enter'); + renderHarness(); + const sendClick = appendSendButton(); + const textarea = appendMainTextarea(); + + const event = dispatchKey({ key: 'Enter', altKey: true }, textarea); + + expect(sendClick).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it('defers Ctrl/Cmd+Enter in the composer to the native textarea submit', () => { + renderHarness(); + const sendClick = appendSendButton(); + const textarea = appendMainTextarea(); + + const event = dispatchKey({ key: 'Enter', ctrlKey: true }, textarea); + + expect(sendClick).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it('runs a custom Alt+Enter submit binding outside the composer', () => { + bindSubmitMessage('Alt+Enter'); + renderHarness(); + const sendClick = appendSendButton(); + + const event = dispatchKey({ key: 'Enter', altKey: true }); + + expect(sendClick).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(true); + }); +}); + +describe('delete shortcut confirmation', () => { + it('opens the delete confirmation instead of deleting immediately', () => { + const conversation = buildConversation('test-convo', 'My Chat'); + const { getByTestId } = renderHarness(conversation, '/c/test-convo'); + + const event = dispatchKey({ key: 'Backspace', ctrlKey: true, shiftKey: true }); + + expect(event.defaultPrevented).toBe(true); + expect(getByTestId('delete-target').textContent).toBe('test-convo'); + }); + + it('is a no-op when the active conversation is not the routed one', () => { + const conversation = buildConversation('other-convo', 'Other'); + const { getByTestId } = renderHarness(conversation, '/c/test-convo'); + + const event = dispatchKey({ key: 'Backspace', ctrlKey: true, shiftKey: true }); + + expect(event.defaultPrevented).toBe(false); + expect(getByTestId('delete-target').textContent).toBe('none'); + }); +}); diff --git a/client/src/hooks/useKeyboardShortcuts.ts b/client/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000000..203efb7720 --- /dev/null +++ b/client/src/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,1009 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import copy from 'copy-to-clipboard'; +import { useToastContext } from '@librechat/client'; +import { useQueryClient } from '@tanstack/react-query'; +import { useMatch, useNavigate } from 'react-router-dom'; +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; +import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider'; +import type { ShortcutBinding } from '~/utils/shortcuts'; +import type { ShortcutOverride } from '~/store/misc'; +import { + bindingDisplayString, + bindingFromEvent, + bindingHash, + bindingToString, + isMacPlatform, + parseBinding, +} from '~/utils/shortcuts'; +import { mainTextareaId, NotificationSeverity } from '~/common'; +import { useArchiveConvoMutation } from '~/data-provider'; +import { useHasAccess, useLocalize } from '~/hooks'; +import { clearMessagesCache } from '~/utils'; +import useNewConvo from './useNewConvo'; +import store from '~/store'; + +const isMac = isMacPlatform; +const CUSTOM_STORAGE_KEY = 'customKeyboardShortcuts'; + +export type ShortcutDefinition = { + labelKey: string; + groupKey: string; + displayMac: string; + displayOther: string; + ariaMac: string; + ariaOther: string; +}; + +export const shortcutDefinitions = { + showShortcuts: { + labelKey: 'com_shortcut_show_shortcuts', + groupKey: 'com_shortcut_group_general', + displayMac: '⌘ ⇧ /', + displayOther: 'Ctrl+Shift+/', + ariaMac: 'Meta+Shift+/', + ariaOther: 'Control+Shift+/', + }, + newChat: { + labelKey: 'com_ui_new_chat', + groupKey: 'com_shortcut_group_general', + displayMac: '⌘ ⇧ O', + displayOther: 'Ctrl+Shift+O', + ariaMac: 'Meta+Shift+O', + ariaOther: 'Control+Shift+O', + }, + focusChat: { + labelKey: 'com_shortcut_focus_chat_input', + groupKey: 'com_shortcut_group_general', + displayMac: '⇧ Esc', + displayOther: 'Shift+Esc', + ariaMac: 'Shift+Escape', + ariaOther: 'Shift+Escape', + }, + copyLastResponse: { + labelKey: 'com_shortcut_copy_last_response', + groupKey: 'com_shortcut_group_general', + displayMac: '⌘ ⇧ ;', + displayOther: 'Ctrl+Shift+;', + ariaMac: 'Meta+Shift+;', + ariaOther: 'Control+Shift+;', + }, + uploadFile: { + labelKey: 'com_shortcut_upload_file', + groupKey: 'com_shortcut_group_general', + displayMac: '⌘ ⇧ U', + displayOther: 'Ctrl+Shift+U', + ariaMac: 'Meta+Shift+U', + ariaOther: 'Control+Shift+U', + }, + toggleSidebar: { + labelKey: 'com_shortcut_toggle_sidebar', + groupKey: 'com_shortcut_group_navigation', + displayMac: '⌘ ⇧ S', + displayOther: 'Ctrl+Shift+S', + ariaMac: 'Meta+Shift+S', + ariaOther: 'Control+Shift+S', + }, + openModelSelector: { + labelKey: 'com_shortcut_open_model_selector', + groupKey: 'com_shortcut_group_navigation', + displayMac: '⌘ ⇧ M', + displayOther: 'Ctrl+Shift+M', + ariaMac: 'Meta+Shift+M', + ariaOther: 'Control+Shift+M', + }, + focusSearch: { + labelKey: 'com_shortcut_focus_search', + groupKey: 'com_shortcut_group_navigation', + displayMac: '⌘ /', + displayOther: 'Ctrl+/', + ariaMac: 'Meta+/', + ariaOther: 'Control+/', + }, + openSettings: { + labelKey: 'com_nav_settings', + groupKey: 'com_shortcut_group_navigation', + displayMac: '⌘ ⇧ ,', + displayOther: 'Ctrl+Shift+,', + ariaMac: 'Meta+Shift+,', + ariaOther: 'Control+Shift+,', + }, + stopGenerating: { + labelKey: 'com_nav_stop_generating', + groupKey: 'com_shortcut_group_chat', + displayMac: '⌘ ⇧ X', + displayOther: 'Ctrl+Shift+X', + ariaMac: 'Meta+Shift+X', + ariaOther: 'Control+Shift+X', + }, + regenerateResponse: { + labelKey: 'com_shortcut_regenerate_response', + groupKey: 'com_shortcut_group_chat', + displayMac: '⌘ ⇧ E', + displayOther: 'Ctrl+Shift+E', + ariaMac: 'Meta+Shift+E', + ariaOther: 'Control+Shift+E', + }, + editLastMessage: { + labelKey: 'com_shortcut_edit_last_message', + groupKey: 'com_shortcut_group_chat', + displayMac: '⌘ ⇧ I', + displayOther: 'Ctrl+Shift+I', + ariaMac: 'Meta+Shift+I', + ariaOther: 'Control+Shift+I', + }, + copyLastCode: { + labelKey: 'com_shortcut_copy_last_code', + groupKey: 'com_shortcut_group_chat', + displayMac: '⌘ ⇧ K', + displayOther: 'Ctrl+Shift+K', + ariaMac: 'Meta+Shift+K', + ariaOther: 'Control+Shift+K', + }, + scrollToTop: { + labelKey: 'com_shortcut_scroll_to_top', + groupKey: 'com_shortcut_group_chat', + displayMac: '⌘ ⇧ ↑', + displayOther: 'Ctrl+Shift+↑', + ariaMac: 'Meta+Shift+ArrowUp', + ariaOther: 'Control+Shift+ArrowUp', + }, + scrollToBottom: { + labelKey: 'com_shortcut_scroll_to_bottom', + groupKey: 'com_shortcut_group_chat', + displayMac: '⌘ ⇧ ↓', + displayOther: 'Ctrl+Shift+↓', + ariaMac: 'Meta+Shift+ArrowDown', + ariaOther: 'Control+Shift+ArrowDown', + }, + toggleTemporaryChat: { + labelKey: 'com_ui_temporary', + groupKey: 'com_shortcut_group_chat', + displayMac: '⌘ ⇧ Y', + displayOther: 'Ctrl+Shift+Y', + ariaMac: 'Meta+Shift+Y', + ariaOther: 'Control+Shift+Y', + }, + archiveConversation: { + labelKey: 'com_shortcut_archive_conversation', + groupKey: 'com_shortcut_group_chat', + displayMac: '⌘ ⇧ L', + displayOther: 'Ctrl+Shift+L', + ariaMac: 'Meta+Shift+L', + ariaOther: 'Control+Shift+L', + }, + deleteConversation: { + labelKey: 'com_shortcut_delete_conversation', + groupKey: 'com_shortcut_group_chat', + displayMac: '⌘ ⇧ ⌫', + displayOther: 'Ctrl+Shift+Backspace', + ariaMac: 'Meta+Shift+Backspace', + ariaOther: 'Control+Shift+Backspace', + }, + submitMessage: { + labelKey: 'com_shortcut_submit_message', + groupKey: 'com_shortcut_group_general', + displayMac: '⌘ ↵', + displayOther: 'Ctrl+Enter', + ariaMac: 'Meta+Enter', + ariaOther: 'Control+Enter', + }, + bookmarkConversation: { + labelKey: 'com_shortcut_bookmark_conversation', + groupKey: 'com_shortcut_group_navigation', + displayMac: '⌘ ⇧ B', + displayOther: 'Ctrl+Shift+B', + ariaMac: 'Meta+Shift+B', + ariaOther: 'Control+Shift+B', + }, + continueResponse: { + labelKey: 'com_shortcut_continue_response', + groupKey: 'com_shortcut_group_chat', + displayMac: '⌘ ⇧ C', + displayOther: 'Ctrl+Shift+C', + ariaMac: 'Meta+Shift+C', + ariaOther: 'Control+Shift+C', + }, + readAloudLastResponse: { + labelKey: 'com_shortcut_read_aloud', + groupKey: 'com_shortcut_group_chat', + displayMac: '⌘ ⇧ V', + displayOther: 'Ctrl+Shift+V', + ariaMac: 'Meta+Shift+V', + ariaOther: 'Control+Shift+V', + }, + openAssistants: { + labelKey: 'com_shortcut_open_assistants', + groupKey: 'com_shortcut_group_panels', + displayMac: '', + displayOther: '', + ariaMac: '', + ariaOther: '', + }, + openAgents: { + labelKey: 'com_shortcut_open_agents', + groupKey: 'com_shortcut_group_panels', + displayMac: '', + displayOther: '', + ariaMac: '', + ariaOther: '', + }, + openPrompts: { + labelKey: 'com_shortcut_open_prompts', + groupKey: 'com_shortcut_group_panels', + displayMac: '', + displayOther: '', + ariaMac: '', + ariaOther: '', + }, + openMemories: { + labelKey: 'com_shortcut_open_memories', + groupKey: 'com_shortcut_group_panels', + displayMac: '', + displayOther: '', + ariaMac: '', + ariaOther: '', + }, + openParameters: { + labelKey: 'com_shortcut_open_parameters', + groupKey: 'com_shortcut_group_panels', + displayMac: '', + displayOther: '', + ariaMac: '', + ariaOther: '', + }, + openFiles: { + labelKey: 'com_shortcut_open_files', + groupKey: 'com_shortcut_group_panels', + displayMac: '', + displayOther: '', + ariaMac: '', + ariaOther: '', + }, + openBookmarks: { + labelKey: 'com_shortcut_open_bookmarks', + groupKey: 'com_shortcut_group_panels', + displayMac: '', + displayOther: '', + ariaMac: '', + ariaOther: '', + }, + openMCP: { + labelKey: 'com_shortcut_open_mcp', + groupKey: 'com_shortcut_group_panels', + displayMac: '', + displayOther: '', + ariaMac: '', + ariaOther: '', + }, +} as const satisfies Record; + +export type ShortcutActionId = keyof typeof shortcutDefinitions; +export type ShortcutAction = ShortcutDefinition & { + id: ShortcutActionId; + /** Returns `false` when the action was a no-op so the native key event is not prevented. */ + run: () => boolean | void; +}; + +const shortcutActionIds = Object.keys(shortcutDefinitions) as ShortcutActionId[]; + +function getMainScrollContainer(): Element | null { + const end = document.getElementById('messages-end'); + let node: HTMLElement | null = end?.parentElement ?? null; + while (node) { + const overflowY = getComputedStyle(node).overflowY; + if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) { + return node; + } + node = node.parentElement; + } + return document.querySelector('main[role="main"]'); +} + +function anyModalOpen(): boolean { + const dialogs = document.querySelectorAll('[role="dialog"], [role="alertdialog"]'); + for (let i = 0; i < dialogs.length; i++) { + const dialog = dialogs[i]; + if (dialog.hasAttribute('inert')) { + continue; + } + if (dialog.getAttribute('data-state') === 'closed') { + continue; + } + return true; + } + return false; +} + +/** + * Ariakit menus/popovers render a focus-trapped `role="menu"`/`role="listbox"` overlay rather + * than a dialog, so they slip past {@link anyModalOpen}. When one is open its focused item is + * the key event target, so checking the target's ancestry catches it without false positives + * from hidden menus elsewhere in the tree. + */ +function isWithinOpenMenu(target: HTMLElement | null): boolean { + return target instanceof Element && target.closest('[role="menu"], [role="listbox"]') != null; +} + +function isUnavailableElement(el: HTMLElement): boolean { + return ( + el.getAttribute('aria-disabled') === 'true' || + (el instanceof HTMLButtonElement && el.disabled) || + (el instanceof HTMLInputElement && el.disabled) + ); +} + +function clickTarget(el: HTMLElement | null | undefined): boolean { + if (!el || isUnavailableElement(el)) { + return false; + } + el.click(); + return true; +} + +function clickElement(selector: string): boolean { + return clickTarget(document.querySelector(selector)); +} + +function clickLastElement(selector: string): boolean { + const elements = document.querySelectorAll(selector); + return clickTarget(elements[elements.length - 1]); +} + +function defaultAria(actionId: ShortcutActionId): string { + const def = shortcutDefinitions[actionId]; + return isMac ? def.ariaMac : def.ariaOther; +} + +function readOverridesFromStorage(): Record { + if (typeof window === 'undefined') { + return {}; + } + try { + const raw = window.localStorage.getItem(CUSTOM_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +function effectiveBindingString( + actionId: ShortcutActionId, + overrides: Record, +): string | null { + const override = overrides[actionId]; + if (override) { + const platformValue = isMac ? override.mac : override.other; + if (platformValue === null) { + return null; + } + if (typeof platformValue === 'string') { + return platformValue; + } + } + return defaultAria(actionId); +} + +export function effectiveBinding( + actionId: ShortcutActionId, + overrides?: Record, +): ShortcutBinding | null { + const map = overrides ?? readOverridesFromStorage(); + return parseBinding(effectiveBindingString(actionId, map)); +} + +export function getShortcutDisplay(actionId: ShortcutActionId): string { + const binding = effectiveBinding(actionId); + if (!binding) { + return ''; + } + return bindingDisplayString(binding, isMac); +} + +export function getShortcutAriaKey(actionId: ShortcutActionId): string { + const binding = effectiveBinding(actionId); + if (!binding) { + return ''; + } + return bindingToString(binding) ?? ''; +} + +export function getShortcutHint(actionId: ShortcutActionId, label: string): string { + const display = getShortcutDisplay(actionId); + return display ? `${label} (${display})` : label; +} + +export function isOverridden(actionId: ShortcutActionId, override?: ShortcutOverride): boolean { + if (!override) return false; + const platformValue = isMac ? override.mac : override.other; + if (platformValue === null) return true; + if (typeof platformValue !== 'string') return false; + return platformValue !== defaultAria(actionId); +} + +export function useShortcutActions(): ShortcutAction[] { + const navigate = useNavigate(); + const localize = useLocalize(); + const queryClient = useQueryClient(); + const { newConversation } = useNewConvo(); + const { showToast } = useToastContext(); + const routeMatch = useMatch('/c/:conversationId'); + const routeConvoId = routeMatch?.params.conversationId ?? null; + const conversation = useRecoilValue(store.conversationByIndex(0)); + const isSubmitting = useRecoilValue(store.isSubmittingFamily(0)); + const [sidebarExpanded, setSidebarExpanded] = useRecoilState(store.sidebarExpanded); + const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog); + const setIsTemporary = useSetRecoilState(store.isTemporary); + const setDeleteTarget = useSetRecoilState(store.keyboardDeleteTarget); + const hasAccessToTemporaryChat = useHasAccess({ + permissionType: PermissionTypes.TEMPORARY_CHAT, + permission: Permissions.USE, + }); + + const archiveMutation = useArchiveConvoMutation(); + + const handleShowShortcuts = useCallback(() => { + setShowShortcutsDialog((prev) => !prev); + return true; + }, [setShowShortcutsDialog]); + + const handleNewChat = useCallback(() => { + clearMessagesCache(queryClient, conversation?.conversationId); + queryClient.invalidateQueries([QueryKeys.messages]); + newConversation(); + return true; + }, [queryClient, conversation?.conversationId, newConversation]); + + const handleFocusChatInput = useCallback(() => { + const textarea = document.getElementById(mainTextareaId) as HTMLTextAreaElement | null; + if (!textarea) { + return false; + } + textarea.focus(); + return true; + }, []); + + const handleToggleSidebar = useCallback(() => { + setSidebarExpanded((prev) => !prev); + return true; + }, [setSidebarExpanded]); + + const handleOpenModelSelector = useCallback( + () => clickElement('[data-testid="model-selector-button"]'), + [], + ); + + const handleFocusSearch = useCallback(() => { + const focusSearchInput = () => { + const input = document.querySelector( + 'input[data-testid="nav-search-input"]', + ); + if (!input) { + return false; + } + input.focus(); + return true; + }; + + const panelButton = document.querySelector( + '[data-testid="nav-panel-conversations"]', + ); + let switchedPanel = false; + if ( + panelButton && + !isUnavailableElement(panelButton) && + panelButton.getAttribute('aria-pressed') !== 'true' + ) { + switchedPanel = true; + panelButton.click(); + } + + if (!sidebarExpanded) { + setSidebarExpanded(true); + } + + if (!sidebarExpanded || switchedPanel) { + setTimeout(focusSearchInput, 350); + return true; + } + + return focusSearchInput(); + }, [sidebarExpanded, setSidebarExpanded]); + + const handleCopyLastResponse = useCallback(() => { + return clickLastElement('[data-testid="copy-response-button"]'); + }, []); + + const handleCopyLastCode = useCallback(() => { + const blocks = document.querySelectorAll('.agent-turn pre code'); + if (blocks.length === 0) { + return false; + } + const last = blocks[blocks.length - 1]; + const text = last.textContent ?? ''; + if (!text.trim()) { + return false; + } + return copy(text.trim(), { format: 'text/plain' }); + }, []); + + const handleStopGenerating = useCallback( + () => clickElement('[data-testid="stop-generation-button"]'), + [], + ); + + const handleRegenerateResponse = useCallback( + () => clickElement('[data-testid="regenerate-generation-button"]'), + [], + ); + + const handleEditLastMessage = useCallback(() => { + const userTurns = document.querySelectorAll('.user-turn'); + if (userTurns.length === 0) { + return false; + } + const last = userTurns[userTurns.length - 1]; + const editBtn = last.querySelector('button[id^="edit-"]'); + if (!editBtn) { + return false; + } + editBtn.click(); + return true; + }, []); + + const handleScrollToTop = useCallback(() => { + const container = getMainScrollContainer(); + if (container) { + container.scrollTo({ top: 0, behavior: 'smooth' }); + return true; + } + window.scrollTo({ top: 0, behavior: 'smooth' }); + return true; + }, []); + + const handleScrollToBottom = useCallback(() => { + const container = getMainScrollContainer(); + if (container) { + container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); + return true; + } + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + return true; + }, []); + + const handleOpenSettings = useCallback(() => { + const btn = document.querySelector('[data-testid="nav-user"]'); + if (!btn || isUnavailableElement(btn)) { + return false; + } + const openSettingsItem = () => { + const settingsItem = document.querySelector('[data-testid="nav-settings"]'); + return clickTarget(settingsItem); + }; + if (btn.getAttribute('aria-expanded') === 'true') { + return openSettingsItem(); + } + btn.click(); + setTimeout(openSettingsItem, 150); + return true; + }, []); + + const handleToggleTemporaryChat = useCallback(() => { + if (hasAccessToTemporaryChat !== true) { + return false; + } + if (!routeConvoId) { + return false; + } + const hasMessages = Array.isArray(conversation?.messages) && conversation.messages.length >= 1; + if (hasMessages || isSubmitting) { + return false; + } + setIsTemporary((prev) => !prev); + return true; + }, [ + hasAccessToTemporaryChat, + routeConvoId, + conversation?.messages, + isSubmitting, + setIsTemporary, + ]); + + const handleUploadFile = useCallback(() => { + const btn = + document.querySelector('#attach-file-menu-button') ?? + document.querySelector('#attach-file'); + return clickTarget(btn); + }, []); + + const handleArchiveConversation = useCallback(() => { + const convoId = conversation?.conversationId; + if (!convoId || convoId === 'new') { + return false; + } + if (routeConvoId !== convoId) { + return false; + } + if (archiveMutation.isLoading) { + return false; + } + archiveMutation.mutate( + { conversationId: convoId, isArchived: true }, + { + onSuccess: () => { + newConversation(); + navigate('/c/new', { replace: true }); + }, + onError: () => { + showToast({ + message: localize('com_ui_archive_error'), + severity: NotificationSeverity.ERROR, + showIcon: true, + }); + }, + }, + ); + return true; + }, [ + localize, + showToast, + routeConvoId, + archiveMutation, + newConversation, + navigate, + conversation?.conversationId, + ]); + + const handleDeleteConversation = useCallback(() => { + const convoId = conversation?.conversationId; + if (!convoId || convoId === 'new') { + return false; + } + if (routeConvoId !== convoId) { + return false; + } + setDeleteTarget({ conversationId: convoId, title: conversation?.title ?? '' }); + return true; + }, [conversation?.conversationId, conversation?.title, routeConvoId, setDeleteTarget]); + + const handleSubmitMessage = useCallback(() => { + const btn = document.querySelector('[data-testid="send-button"]'); + return clickTarget(btn); + }, []); + + const handleContinueResponse = useCallback( + () => clickElement('[data-testid="continue-generation-button"]'), + [], + ); + + const handleReadAloudLastResponse = useCallback( + () => clickElement('[data-testid="read-aloud-button"]'), + [], + ); + + const handleBookmarkConversation = useCallback(() => clickElement('#bookmark-menu-button'), []); + + const handleOpenPanel = useCallback( + (panelId: string) => { + const activatePanel = () => { + const btn = document.querySelector( + `[data-testid="nav-panel-${panelId}"]`, + ); + if (!btn || isUnavailableElement(btn)) { + return false; + } + if (btn.getAttribute('aria-pressed') !== 'true') { + btn.click(); + return true; + } + return false; + }; + + const btn = document.querySelector(`[data-testid="nav-panel-${panelId}"]`); + if (!btn || isUnavailableElement(btn)) { + return false; + } + + if (!sidebarExpanded) { + setSidebarExpanded(true); + setTimeout(activatePanel, 350); + return true; + } + + return activatePanel(); + }, + [sidebarExpanded, setSidebarExpanded], + ); + + const handleOpenAssistants = useCallback(() => handleOpenPanel('assistants'), [handleOpenPanel]); + const handleOpenAgents = useCallback(() => handleOpenPanel('agents'), [handleOpenPanel]); + const handleOpenPrompts = useCallback(() => handleOpenPanel('prompts'), [handleOpenPanel]); + const handleOpenMemories = useCallback(() => handleOpenPanel('memories'), [handleOpenPanel]); + const handleOpenParameters = useCallback(() => handleOpenPanel('parameters'), [handleOpenPanel]); + const handleOpenFiles = useCallback(() => handleOpenPanel('files'), [handleOpenPanel]); + const handleOpenBookmarks = useCallback(() => handleOpenPanel('bookmarks'), [handleOpenPanel]); + const handleOpenMCP = useCallback(() => handleOpenPanel('mcp-builder'), [handleOpenPanel]); + + const handlers = useMemo boolean | void>>( + () => ({ + showShortcuts: handleShowShortcuts, + newChat: handleNewChat, + focusChat: handleFocusChatInput, + copyLastResponse: handleCopyLastResponse, + uploadFile: handleUploadFile, + toggleSidebar: handleToggleSidebar, + openModelSelector: handleOpenModelSelector, + focusSearch: handleFocusSearch, + openSettings: handleOpenSettings, + stopGenerating: handleStopGenerating, + regenerateResponse: handleRegenerateResponse, + editLastMessage: handleEditLastMessage, + copyLastCode: handleCopyLastCode, + scrollToTop: handleScrollToTop, + scrollToBottom: handleScrollToBottom, + toggleTemporaryChat: handleToggleTemporaryChat, + archiveConversation: handleArchiveConversation, + deleteConversation: handleDeleteConversation, + submitMessage: handleSubmitMessage, + bookmarkConversation: handleBookmarkConversation, + continueResponse: handleContinueResponse, + readAloudLastResponse: handleReadAloudLastResponse, + openAssistants: handleOpenAssistants, + openAgents: handleOpenAgents, + openPrompts: handleOpenPrompts, + openMemories: handleOpenMemories, + openParameters: handleOpenParameters, + openFiles: handleOpenFiles, + openBookmarks: handleOpenBookmarks, + openMCP: handleOpenMCP, + }), + [ + handleShowShortcuts, + handleNewChat, + handleFocusChatInput, + handleCopyLastResponse, + handleUploadFile, + handleToggleSidebar, + handleOpenModelSelector, + handleFocusSearch, + handleOpenSettings, + handleStopGenerating, + handleRegenerateResponse, + handleEditLastMessage, + handleCopyLastCode, + handleScrollToTop, + handleScrollToBottom, + handleToggleTemporaryChat, + handleArchiveConversation, + handleDeleteConversation, + handleSubmitMessage, + handleBookmarkConversation, + handleContinueResponse, + handleReadAloudLastResponse, + handleOpenAssistants, + handleOpenAgents, + handleOpenPrompts, + handleOpenMemories, + handleOpenParameters, + handleOpenFiles, + handleOpenBookmarks, + handleOpenMCP, + ], + ); + + return useMemo( + () => + shortcutActionIds.map((id) => ({ + id, + ...shortcutDefinitions[id], + run: handlers[id], + })), + [handlers], + ); +} + +export function useShortcutDisplay(actionId?: ShortcutActionId): string { + const overrides = useRecoilValue(store.customShortcuts); + return useMemo(() => { + if (!actionId) return ''; + const binding = parseBinding(effectiveBindingString(actionId, overrides)); + return binding ? bindingDisplayString(binding, isMac) : ''; + }, [actionId, overrides]); +} + +export function useShortcutAriaKey(actionId?: ShortcutActionId): string | undefined { + const overrides = useRecoilValue(store.customShortcuts); + return useMemo(() => { + if (!actionId) return undefined; + const binding = parseBinding(effectiveBindingString(actionId, overrides)); + return binding ? (bindingToString(binding) ?? undefined) : undefined; + }, [actionId, overrides]); +} + +export function useShortcutHint(actionId: ShortcutActionId | undefined, label: string): string { + const display = useShortcutDisplay(actionId); + return display ? `${label} (${display})` : label; +} + +export type ShortcutBindingInfo = { + id: ShortcutActionId; + binding: ShortcutBinding | null; + isCustom: boolean; + groupKey: string; + labelKey: string; +}; + +export function useShortcutBindings(): { + bindings: ShortcutBindingInfo[]; + bindingMap: Map; + setBinding: (id: ShortcutActionId, binding: ShortcutBinding | null) => void; + resetBinding: (id: ShortcutActionId) => void; + resetAll: () => void; +} { + const [overrides, setOverrides] = useRecoilState(store.customShortcuts); + + const bindings = useMemo( + () => + shortcutActionIds.map((id) => { + const def = shortcutDefinitions[id]; + const override = overrides[id]; + const binding = parseBinding(effectiveBindingString(id, overrides)); + return { + id, + binding, + isCustom: isOverridden(id, override), + groupKey: def.groupKey, + labelKey: def.labelKey, + }; + }), + [overrides], + ); + + const bindingMap = useMemo>(() => { + const map = new Map(); + for (const info of bindings) { + if (info.binding) { + map.set(bindingHash(info.binding), info.id); + } + } + return map; + }, [bindings]); + + const setBinding = useCallback( + (id: ShortcutActionId, binding: ShortcutBinding | null) => { + setOverrides((prev) => { + const next = { ...prev }; + const def = shortcutDefinitions[id]; + const platformKey: keyof ShortcutOverride = isMac ? 'mac' : 'other'; + const existing = next[id] ?? { mac: def.ariaMac, other: def.ariaOther }; + const updated: ShortcutOverride = { ...existing }; + updated[platformKey] = binding ? bindingToString(binding) : null; + + const matchesDefault = updated.mac === def.ariaMac && updated.other === def.ariaOther; + + if (matchesDefault) { + delete next[id]; + } else { + next[id] = updated; + } + return next; + }); + }, + [setOverrides], + ); + + const resetBinding = useCallback( + (id: ShortcutActionId) => { + setOverrides((prev) => { + if (!prev[id]) return prev; + const next = { ...prev }; + delete next[id]; + + const restored = parseBinding(effectiveBindingString(id, next)); + if (restored) { + const restoredHash = bindingHash(restored); + const platformKey: keyof ShortcutOverride = isMac ? 'mac' : 'other'; + for (const otherId of Object.keys(next) as ShortcutActionId[]) { + const otherBinding = parseBinding(effectiveBindingString(otherId, next)); + if (otherBinding && bindingHash(otherBinding) === restoredHash) { + next[otherId] = { ...next[otherId], [platformKey]: null }; + } + } + } + return next; + }); + }, + [setOverrides], + ); + + const resetAll = useCallback(() => { + setOverrides({}); + }, [setOverrides]); + + return { bindings, bindingMap, setBinding, resetBinding, resetAll }; +} + +export default function useKeyboardShortcuts() { + const actions = useShortcutActions(); + const overrides = useRecoilValue(store.customShortcuts); + const shortcutsDialogOpen = useRecoilValue(store.showShortcutsDialog); + + const actionMap = useMemo(() => new Map(actions.map((action) => [action.id, action])), [actions]); + + const bindingMap = useMemo>(() => { + const map = new Map(); + for (const id of shortcutActionIds) { + const binding = parseBinding(effectiveBindingString(id, overrides)); + if (binding) { + map.set(bindingHash(binding), id); + } + } + return map; + }, [overrides]); + + const handler = useCallback( + (e: KeyboardEvent) => { + if (e.repeat) { + return; + } + + const binding = bindingFromEvent(e); + if (!binding) { + return; + } + + const matchedId = bindingMap.get(bindingHash(binding)); + if (!matchedId) { + return; + } + + const target = e.target as HTMLElement | null; + + if (shortcutsDialogOpen) { + if (matchedId !== 'showShortcuts') { + return; + } + } else if (anyModalOpen() || isWithinOpenMenu(target)) { + return; + } + + const tagName = target?.tagName; + const isEditing = + tagName === 'INPUT' || tagName === 'TEXTAREA' || target?.isContentEditable === true; + const isMainTextarea = target?.id === mainTextareaId; + + // The composer owns every Enter-based submit chord (native and custom), so defer all + // Enter presses there; other editing contexts handle their own submit too. + if ( + matchedId === 'submitMessage' && + ((isMainTextarea && e.key === 'Enter') || (isEditing && !isMainTextarea)) + ) { + return; + } + + const allowedWhileEditing: ShortcutActionId[] = [ + 'focusChat', + 'focusSearch', + 'showShortcuts', + 'submitMessage', + ]; + if (isEditing && !allowedWhileEditing.includes(matchedId)) { + return; + } + + const handled = actionMap.get(matchedId)?.run(); + if (handled !== false) { + e.preventDefault(); + } + }, + [actionMap, bindingMap, shortcutsDialogOpen], + ); + + useEffect(() => { + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [handler]); +} + +export { isMac }; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 9c2c0e3d59..84e8dcc38d 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -498,6 +498,7 @@ "com_nav_font_size_sm": "Small", "com_nav_font_size_xl": "Extra Large", "com_nav_font_size_xs": "Extra Small", + "com_nav_help": "Help", "com_nav_help_faq": "Help & FAQ", "com_nav_info_balance": "Balance shows how many token credits you have left to use. Token credits translate to monetary value (e.g., 1000 credits = $0.001 USD)", "com_nav_info_code_artifacts": "Enables the display of experimental code artifacts next to the chat", @@ -619,6 +620,49 @@ "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_bookmark_conversation": "Bookmark conversation", + "com_shortcut_continue_response": "Continue response", + "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_aria": "Customize shortcut for {{0}}", + "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_group_panels": "Open panels", + "com_shortcut_group_panels_hint": "Click any row to assign", + "com_shortcut_keyboard_shortcuts": "Keyboard Shortcuts", + "com_shortcut_open_agents": "Open agents", + "com_shortcut_open_assistants": "Open assistants", + "com_shortcut_open_bookmarks": "Open bookmarks", + "com_shortcut_open_files": "Open files", + "com_shortcut_open_mcp": "Open MCP settings", + "com_shortcut_open_memories": "Open memories", + "com_shortcut_open_model_selector": "Open model selector", + "com_shortcut_open_parameters": "Open parameters", + "com_shortcut_open_prompts": "Open prompts", + "com_shortcut_read_aloud": "Read last response aloud", + "com_shortcut_recorder_conflict": "Already used by \"{{0}}\".", + "com_shortcut_recorder_conflict_prefix": "Conflicts with", + "com_shortcut_recorder_hint": "Press a combination — Esc to cancel", + "com_shortcut_recorder_needs_modifier": "Add Cmd, Ctrl, or Alt to your combo", + "com_shortcut_recorder_placeholder": "Press a combination…", + "com_shortcut_recorder_replace": "Replace", + "com_shortcut_recorder_try_again": "Try again", + "com_shortcut_regenerate_response": "Regenerate response", + "com_shortcut_reset": "Reset", + "com_shortcut_reset_all": "Reset all to defaults", + "com_shortcut_scroll_to_bottom": "Scroll to bottom", + "com_shortcut_scroll_to_top": "Scroll to top", + "com_shortcut_set": "Set shortcut", + "com_shortcut_show_shortcuts": "Show keyboard shortcuts", + "com_shortcut_submit_message": "Submit message", + "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", diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index 21a2cdf047..d792cae0bd 100644 --- a/client/src/routes/Root.tsx +++ b/client/src/routes/Root.tsx @@ -2,14 +2,6 @@ import { useState, useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { Outlet } from 'react-router-dom'; import { useMediaQuery } from '@librechat/client'; -import { - useSearchEnabled, - useAssistantsMap, - useAuthContext, - useAgentsMap, - useFileMap, -} from '~/hooks'; -import store from '~/store'; import { PromptGroupsProvider, AssistantsMapContext, @@ -17,11 +9,33 @@ import { SetConvoProvider, FileMapContext, } from '~/Providers'; +import { + useSearchEnabled, + useAssistantsMap, + useAuthContext, + useAgentsMap, + useFileMap, +} from '~/hooks'; +import KeyboardShortcutsDialog from '~/components/Nav/KeyboardShortcutsDialog'; +import KeyboardDeleteDialog from '~/components/Nav/KeyboardDeleteDialog'; import { useUserTermsQuery, useGetStartupConfig } from '~/data-provider'; +import useKeyboardShortcuts from '~/hooks/useKeyboardShortcuts'; import { UnifiedSidebar } from '~/components/UnifiedSidebar'; import { TermsAndConditionsModal } from '~/components/ui'; import { useHealthCheck } from '~/data-provider'; import { Banner } from '~/components/Banners'; +import store from '~/store'; + +/** Isolates keyboard shortcut listeners so they only mount after auth. */ +function KeyboardShortcutsProvider() { + useKeyboardShortcuts(); + return ( + <> + + + + ); +} export default function Root() { const [showTerms, setShowTerms] = useState(false); @@ -98,6 +112,7 @@ export default function Root() { modalContent={config.interface.termsOfService.modalContent} /> )} + diff --git a/client/src/store/misc.ts b/client/src/store/misc.ts index 7bd571eaca..0fa2b32900 100644 --- a/client/src/store/misc.ts +++ b/client/src/store/misc.ts @@ -57,6 +57,31 @@ const isEditingBadges = atom({ default: false, }); +const showShortcutsDialog = atom({ + key: 'showShortcutsDialog', + default: false, +}); + +export type KeyboardDeleteTarget = { + conversationId: string; + title: string; +}; + +const keyboardDeleteTarget = atom({ + key: 'keyboardDeleteTarget', + default: null, +}); + +export type ShortcutOverride = { + mac: string | null; + other: string | null; +}; + +const customShortcuts = atomWithLocalStorage>( + 'customKeyboardShortcuts', + {}, +); + 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 +95,8 @@ export default { conversationAttachmentsSelector, queriesEnabled, isEditingBadges, + showShortcutsDialog, + keyboardDeleteTarget, + customShortcuts, chatBadges, }; diff --git a/client/src/style.css b/client/src/style.css index bac4f5e22e..17fa19f7c6 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -2709,6 +2709,15 @@ html { translate: 0; } +.popover-ui.popover-from-left { + transform-origin: left; + translate: -0.5rem 0; + margin-top: 0; +} +.popover-ui.popover-from-left[data-enter] { + translate: 0 0; +} + .animate-popover-top, .animate-popover { transform-origin: top; diff --git a/client/src/utils/shortcuts.spec.ts b/client/src/utils/shortcuts.spec.ts new file mode 100644 index 0000000000..d1bfe8a139 --- /dev/null +++ b/client/src/utils/shortcuts.spec.ts @@ -0,0 +1,248 @@ +import type { ShortcutBinding } from './shortcuts'; +import { + hasModifier, + isCancelKey, + bindingHash, + normalizeKey, + parseBinding, + isModifierKey, + isValidBinding, + bindingTokens, + bindingToString, + resolveSubmitOverrideAction, + bindingFromEvent, + bindingDisplayKeys, + bindingDisplayString, +} from './shortcuts'; + +function makeBinding(overrides: Partial = {}): ShortcutBinding { + return { meta: false, ctrl: false, alt: false, shift: false, key: '', ...overrides }; +} + +describe('normalizeKey', () => { + it('maps special keys to their canonical token', () => { + expect(normalizeKey('Backspace')).toBe('Backspace'); + expect(normalizeKey(' ')).toBe('Space'); + expect(normalizeKey('ArrowUp')).toBe('ArrowUp'); + }); + + it('uppercases single character keys', () => { + expect(normalizeKey('k')).toBe('K'); + expect(normalizeKey('a')).toBe('A'); + }); + + it('resolves shifted punctuation back to the base key when shift is held', () => { + expect(normalizeKey('?', true)).toBe('/'); + expect(normalizeKey(':', true)).toBe(';'); + expect(normalizeKey('?', false)).toBe('?'); + }); + + it('passes through multi-character non-special keys unchanged', () => { + expect(normalizeKey('F5')).toBe('F5'); + }); +}); + +describe('isModifierKey', () => { + it('recognizes modifier keys', () => { + expect(isModifierKey('Meta')).toBe(true); + expect(isModifierKey('Control')).toBe(true); + expect(isModifierKey('Shift')).toBe(true); + expect(isModifierKey('Alt')).toBe(true); + expect(isModifierKey('k')).toBe(false); + }); +}); + +describe('bindingFromEvent', () => { + it('returns null when the pressed key is itself a modifier', () => { + const event = new KeyboardEvent('keydown', { key: 'Shift', shiftKey: true }); + expect(bindingFromEvent(event)).toBeNull(); + }); + + it('captures active modifiers and the normalized key', () => { + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true, + shiftKey: true, + }); + expect(bindingFromEvent(event)).toEqual(makeBinding({ ctrl: true, shift: true, key: 'K' })); + }); + + it('normalizes special keys from the event', () => { + const event = new KeyboardEvent('keydown', { key: 'Backspace', metaKey: true, shiftKey: true }); + expect(bindingFromEvent(event)).toEqual( + makeBinding({ meta: true, shift: true, key: 'Backspace' }), + ); + }); +}); + +describe('parseBinding', () => { + it('returns null for empty input', () => { + expect(parseBinding('')).toBeNull(); + expect(parseBinding(null)).toBeNull(); + expect(parseBinding(undefined)).toBeNull(); + }); + + it('parses modifier tokens and the trailing key', () => { + expect(parseBinding('Meta+Shift+T')).toEqual( + makeBinding({ meta: true, shift: true, key: 'T' }), + ); + expect(parseBinding('Control+Shift+Backspace')).toEqual( + makeBinding({ ctrl: true, shift: true, key: 'Backspace' }), + ); + }); + + it('accepts aliased modifier tokens', () => { + expect(parseBinding('Cmd+Option+/')).toEqual(makeBinding({ meta: true, alt: true, key: '/' })); + }); + + it('returns null when the binding has no concrete key', () => { + expect(parseBinding('Meta+Shift')).toBeNull(); + }); + + it('round-trips with bindingToString', () => { + const binding = makeBinding({ ctrl: true, shift: true, key: 'K' }); + expect(parseBinding(bindingToString(binding))).toEqual(binding); + }); +}); + +describe('bindingToString', () => { + it('returns null for a null binding', () => { + expect(bindingToString(null)).toBeNull(); + }); + + it('serializes modifiers in a stable order', () => { + expect(bindingToString(makeBinding({ meta: true, ctrl: true, shift: true, key: 'A' }))).toBe( + 'Meta+Control+Shift+A', + ); + }); +}); + +describe('bindingHash', () => { + it('produces identical hashes for equivalent bindings', () => { + const a = makeBinding({ meta: true, shift: true, key: 'T' }); + const b = makeBinding({ meta: true, shift: true, key: 'T' }); + expect(bindingHash(a)).toBe(bindingHash(b)); + }); + + it('differs when modifiers or keys differ', () => { + expect(bindingHash(makeBinding({ meta: true, key: 'T' }))).not.toBe( + bindingHash(makeBinding({ ctrl: true, key: 'T' })), + ); + expect(bindingHash(makeBinding({ meta: true, key: 'T' }))).not.toBe( + bindingHash(makeBinding({ meta: true, key: 'Y' })), + ); + }); +}); + +describe('hasModifier', () => { + it('is true when any non-shift modifier is present', () => { + expect(hasModifier(makeBinding({ meta: true, key: 'T' }))).toBe(true); + expect(hasModifier(makeBinding({ ctrl: true, key: 'T' }))).toBe(true); + expect(hasModifier(makeBinding({ alt: true, key: 'T' }))).toBe(true); + }); + + it('is false for shift-only or unmodified bindings', () => { + expect(hasModifier(makeBinding({ shift: true, key: 'T' }))).toBe(false); + expect(hasModifier(makeBinding({ key: 'T' }))).toBe(false); + }); +}); + +describe('isValidBinding', () => { + it('accepts any key combined with a non-shift modifier', () => { + expect(isValidBinding(makeBinding({ meta: true, key: 'T' })).valid).toBe(true); + expect(isValidBinding(makeBinding({ ctrl: true, key: 'Tab' })).valid).toBe(true); + expect(isValidBinding(makeBinding({ alt: true, shift: true, key: 'Enter' })).valid).toBe(true); + }); + + it('accepts shift with a known-safe key', () => { + expect(isValidBinding(makeBinding({ shift: true, key: 'Escape' })).valid).toBe(true); + }); + + it('rejects shift-only chords on focus/navigation keys', () => { + for (const key of ['Tab', 'ArrowUp', 'ArrowDown', 'Backspace', 'Enter', 'Space']) { + const result = isValidBinding(makeBinding({ shift: true, key })); + expect(result.valid).toBe(false); + expect(result.reason).toBe('noModifier'); + } + }); + + it('rejects unmodified and shift-only printable keys', () => { + expect(isValidBinding(makeBinding({ key: 'A' })).valid).toBe(false); + expect(isValidBinding(makeBinding({ shift: true, key: 'A' })).valid).toBe(false); + }); +}); + +describe('resolveSubmitOverrideAction', () => { + const altEnter = makeBinding({ alt: true, key: 'Enter' }); + + it('submits when the event matches the configured chord', () => { + expect(resolveSubmitOverrideAction(altEnter, altEnter, false)).toBe('submit'); + }); + + it('newlines on the default Ctrl+Enter once the chord has been rebound', () => { + const ctrlEnter = makeBinding({ ctrl: true, key: 'Enter' }); + expect(resolveSubmitOverrideAction(ctrlEnter, altEnter, false)).toBe('newline'); + expect(resolveSubmitOverrideAction(ctrlEnter, altEnter, true)).toBe('newline'); + }); + + it('still submits a bare Enter when Enter-to-send is on', () => { + const plainEnter = makeBinding({ key: 'Enter' }); + expect(resolveSubmitOverrideAction(plainEnter, altEnter, true)).toBe('submit'); + expect(resolveSubmitOverrideAction(plainEnter, altEnter, false)).toBe('newline'); + }); + + it('treats an unbound submit shortcut as disabled while keeping Enter-to-send', () => { + const ctrlEnter = makeBinding({ ctrl: true, key: 'Enter' }); + const plainEnter = makeBinding({ key: 'Enter' }); + expect(resolveSubmitOverrideAction(ctrlEnter, null, false)).toBe('newline'); + expect(resolveSubmitOverrideAction(plainEnter, null, true)).toBe('submit'); + expect(resolveSubmitOverrideAction(plainEnter, null, false)).toBe('newline'); + }); + + it('leaves Shift+Enter and non-Enter keys to the default behavior', () => { + expect( + resolveSubmitOverrideAction(makeBinding({ shift: true, key: 'Enter' }), altEnter, true), + ).toBe('none'); + expect(resolveSubmitOverrideAction(makeBinding({ ctrl: true, key: 'J' }), altEnter, true)).toBe( + 'none', + ); + expect(resolveSubmitOverrideAction(null, altEnter, true)).toBe('none'); + }); +}); + +describe('isCancelKey', () => { + it('is true for a bare Escape press', () => { + expect(isCancelKey(new KeyboardEvent('keydown', { key: 'Escape' }))).toBe(true); + }); + + it('is false when Escape is combined with a modifier', () => { + expect(isCancelKey(new KeyboardEvent('keydown', { key: 'Escape', shiftKey: true }))).toBe( + false, + ); + }); +}); + +describe('display helpers', () => { + const binding = makeBinding({ meta: true, shift: true, key: 'T' }); + + it('returns an empty token list for a null binding', () => { + expect(bindingDisplayKeys(null, true)).toEqual([]); + }); + + it('lists tokens in modifier order', () => { + expect(bindingTokens(binding)).toEqual(['Meta', 'Shift', 'T']); + }); + + it('renders mac symbols joined by spaces', () => { + expect(bindingDisplayString(binding, true)).toBe('⌘ ⇧ T'); + }); + + it('renders non-mac labels joined by plus signs', () => { + const ctrlBinding = makeBinding({ ctrl: true, shift: true, key: 'T' }); + expect(bindingDisplayString(ctrlBinding, false)).toBe('Ctrl+Shift+T'); + }); + + it('labels the Meta modifier as Win on non-mac platforms', () => { + expect(bindingDisplayString(binding, false)).toBe('Win+Shift+T'); + }); +}); diff --git a/client/src/utils/shortcuts.ts b/client/src/utils/shortcuts.ts new file mode 100644 index 0000000000..0051a21718 --- /dev/null +++ b/client/src/utils/shortcuts.ts @@ -0,0 +1,266 @@ +export type ShortcutBinding = { + meta: boolean; + ctrl: boolean; + alt: boolean; + shift: boolean; + key: string; +}; + +export const isMacPlatform = + typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent); + +const MODIFIER_KEYS = new Set(['Meta', 'Control', 'Alt', 'Shift']); + +const SPECIAL_KEY_MAP: Record = { + ArrowUp: 'ArrowUp', + ArrowDown: 'ArrowDown', + ArrowLeft: 'ArrowLeft', + ArrowRight: 'ArrowRight', + Backspace: 'Backspace', + Delete: 'Delete', + Enter: 'Enter', + Escape: 'Escape', + Tab: 'Tab', + Space: 'Space', + ' ': 'Space', +}; + +const SHIFT_TO_UNSHIFT: Record = { + '?': '/', + ':': ';', + '<': ',', + '>': '.', + '"': "'", + '{': '[', + '}': ']', + '|': '\\', + _: '-', + '+': '=', + '~': '`', + '!': '1', + '@': '2', + '#': '3', + $: '4', + '%': '5', + '^': '6', + '&': '7', + '*': '8', + '(': '9', + ')': '0', +}; + +export function normalizeKey(key: string, shiftHeld?: boolean): string { + if (SPECIAL_KEY_MAP[key]) { + return SPECIAL_KEY_MAP[key]; + } + if (key.length === 1) { + if (shiftHeld && SHIFT_TO_UNSHIFT[key]) { + return SHIFT_TO_UNSHIFT[key]; + } + return key.toUpperCase(); + } + return key; +} + +export function isModifierKey(key: string): boolean { + return MODIFIER_KEYS.has(key); +} + +export function bindingFromEvent(e: KeyboardEvent): ShortcutBinding | null { + if (isModifierKey(e.key)) { + return null; + } + return { + meta: e.metaKey, + ctrl: e.ctrlKey, + alt: e.altKey, + shift: e.shiftKey, + key: normalizeKey(e.key, e.shiftKey), + }; +} + +const MODIFIER_TOKENS: Record = { + Meta: 'meta', + Cmd: 'meta', + Command: 'meta', + Control: 'ctrl', + Ctrl: 'ctrl', + Alt: 'alt', + Option: 'alt', + Shift: 'shift', +}; + +export function parseBinding(value: string | null | undefined): ShortcutBinding | null { + if (!value) { + return null; + } + const binding: ShortcutBinding = { + meta: false, + ctrl: false, + alt: false, + shift: false, + key: '', + }; + let remaining = value; + let separatorIndex = remaining.indexOf('+'); + while (separatorIndex > 0) { + const flag = MODIFIER_TOKENS[remaining.slice(0, separatorIndex)]; + if (!flag) { + break; + } + binding[flag] = true; + remaining = remaining.slice(separatorIndex + 1); + separatorIndex = remaining.indexOf('+'); + } + if (MODIFIER_TOKENS[remaining]) { + return null; + } + binding.key = normalizeKey(remaining); + if (!binding.key) { + return null; + } + return binding; +} + +export function bindingToString(binding: ShortcutBinding | null): string | null { + if (!binding) { + return null; + } + const parts: string[] = []; + if (binding.meta) parts.push('Meta'); + if (binding.ctrl) parts.push('Control'); + if (binding.alt) parts.push('Alt'); + if (binding.shift) parts.push('Shift'); + parts.push(binding.key); + return parts.join('+'); +} + +export function bindingHash(binding: ShortcutBinding): string { + const flags = [ + binding.meta ? 'M' : '', + binding.ctrl ? 'C' : '', + binding.alt ? 'A' : '', + binding.shift ? 'S' : '', + ].join(''); + return `${flags}|${binding.key}`; +} + +export function hasModifier(binding: ShortcutBinding): boolean { + return binding.meta || binding.ctrl || binding.alt; +} + +/** + * Keys allowed as a shift-only chord. Limited to keys whose native behavior is safe to + * override; combos like Shift+Tab, Shift+Arrow, or Shift+Backspace would otherwise hijack + * browser focus/navigation on non-text controls. + */ +const SHIFT_SAFE_KEYS = new Set(['Escape']); + +export function isValidBinding(binding: ShortcutBinding): { + valid: boolean; + reason?: 'noModifier'; +} { + if (hasModifier(binding)) { + return { valid: true }; + } + if (binding.shift && SHIFT_SAFE_KEYS.has(binding.key)) { + return { valid: true }; + } + return { valid: false, reason: 'noModifier' }; +} + +export type ComposerEnterAction = 'submit' | 'newline' | 'none'; + +/** + * Resolves what an Enter press should do in the composer when `submitMessage` has been rebound. + * The configured chord submits; a bare Enter still submits when "Enter to send" is on; any other + * non-shift Enter inserts a newline. Returns `none` for Shift+Enter and non-Enter keys so the + * caller leaves the browser's default behavior untouched. + */ +export function resolveSubmitOverrideAction( + eventBinding: ShortcutBinding | null, + submitOverride: ShortcutBinding | null, + enterToSend: boolean, +): ComposerEnterAction { + if (!eventBinding || eventBinding.key !== 'Enter') { + return 'none'; + } + const matchesChord = + submitOverride != null && + submitOverride.key === 'Enter' && + bindingHash(eventBinding) === bindingHash(submitOverride); + const isPlainEnter = + !eventBinding.meta && !eventBinding.ctrl && !eventBinding.alt && !eventBinding.shift; + if (matchesChord || (isPlainEnter && enterToSend)) { + return 'submit'; + } + if (!eventBinding.shift) { + return 'newline'; + } + return 'none'; +} + +export function isCancelKey(e: KeyboardEvent): boolean { + return e.key === 'Escape' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey; +} + +const MAC_SYMBOLS: Record = { + Meta: '⌘', + Control: '⌃', + Alt: '⌥', + Shift: '⇧', + Backspace: '⌫', + Delete: '⌦', + Enter: '↵', + Escape: 'Esc', + Tab: '⇥', + Space: 'Space', + ArrowUp: '↑', + ArrowDown: '↓', + ArrowLeft: '←', + ArrowRight: '→', +}; + +const OTHER_LABELS: Record = { + Meta: 'Win', + Control: 'Ctrl', + Alt: 'Alt', + Shift: 'Shift', + Backspace: 'Backspace', + Delete: 'Delete', + Enter: 'Enter', + Escape: 'Esc', + Tab: 'Tab', + Space: 'Space', + ArrowUp: '↑', + ArrowDown: '↓', + ArrowLeft: '←', + ArrowRight: '→', +}; + +function labelForToken(token: string, mac: boolean): string { + const map = mac ? MAC_SYMBOLS : OTHER_LABELS; + return map[token] ?? token; +} + +export function bindingTokens(binding: ShortcutBinding): string[] { + const tokens: string[] = []; + if (binding.meta) tokens.push('Meta'); + if (binding.ctrl) tokens.push('Control'); + if (binding.alt) tokens.push('Alt'); + if (binding.shift) tokens.push('Shift'); + tokens.push(binding.key); + return tokens; +} + +export function bindingDisplayKeys(binding: ShortcutBinding | null, mac: boolean): string[] { + if (!binding) { + return []; + } + return bindingTokens(binding).map((token) => labelForToken(token, mac)); +} + +export function bindingDisplayString(binding: ShortcutBinding | null, mac: boolean): string { + const keys = bindingDisplayKeys(binding, mac); + return mac ? keys.join(' ') : keys.join('+'); +} diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index 624998e9d8..4cdace6d3d 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -47,6 +47,11 @@ module.exports = { '0%': { transform: 'translateX(0)' }, '100%': { transform: 'translateX(100%)' }, }, + 'shortcut-shake': { + '0%, 100%': { transform: 'translateX(0)' }, + '25%': { transform: 'translateX(-3px)' }, + '75%': { transform: 'translateX(3px)' }, + }, }, animation: { 'fade-in': 'fadeIn 0.5s ease-out forwards', @@ -56,6 +61,7 @@ module.exports = { 'slide-in-left': 'slide-in-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)', 'slide-out-left': 'slide-out-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)', 'slide-out-right': 'slide-out-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)', + 'shortcut-shake': 'shortcut-shake 0.25s ease-in-out', }, colors: { gray: {