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: {