⌨️ feat: Keyboard Shortcuts (#12425)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `@librechat/client` to NPM / pack (push) Has been cancelled
Publish `librechat-data-provider` to NPM / pack (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / pack (push) Has been cancelled
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled

* feat: add useKeyboardShortcuts hook and showShortcutsDialog atom

Implements the core keyboard shortcuts hook with 11 shortcuts:
- General: new chat, focus input, copy last response
- Navigation: toggle sidebar, model selector, search, settings
- Chat: stop generating, scroll to bottom, temporary chat, copy code

Also adds the showShortcutsDialog atom to control dialog visibility.

Closes #3664

* feat: add KeyboardShortcutsDialog component

Renders a modal dialog listing all available keyboard shortcuts
grouped by category (General, Navigation, Chat). Features:
- Platform-aware key labels (⌘ on Mac, Ctrl on others)
- Clean kbd-style key badges with subtle shadows
- Grouped sections with separators
- Sticky footer with shortcut to open the dialog itself
- Single close button, Escape to dismiss

* feat: integrate keyboard shortcuts into Root layout and account menu

- Mount useKeyboardShortcuts and KeyboardShortcutsDialog in Root.tsx
  via a KeyboardShortcutsProvider wrapper (only renders post-auth)
- Add 'Keyboard Shortcuts' menu item with Keyboard icon to the
  account settings popover for discoverability

* chore: add data-testid to model selector button

Adds data-testid="model-selector-button" to the model selector
trigger for reliable DOM targeting by keyboard shortcuts and tests.

* i18n: add keyboard shortcuts localization keys

Adds 12 new com_shortcut_* translation keys for the keyboard
shortcuts feature: group labels, action labels, and dialog title.

* style: fix keyboard shortcuts dialog dark mode

Replace token-based dark mode styling with explicit white-alpha
values for kbd badges, borders, and separators:
- Kbd: dark:bg-white/[0.06] dark:border-white/[0.08] dark:shadow-none
- Separators: dark:border-white/[0.06]
- Dialog border: dark:border-white/[0.06] dark:shadow-2xl

Ensures the key badges blend naturally into the dark surface
instead of appearing as harsh bright rectangles.

* feat(shortcuts): add definitions for 8 new keyboard shortcuts

Add shortcut definitions and localization keys for:
- Upload file (Cmd/Ctrl+Shift+U)
- Toggle right sidebar (Cmd/Ctrl+Shift+R)
- Regenerate response (Cmd/Ctrl+Shift+E)
- Edit last message (Cmd/Ctrl+Shift+I)
- Scroll to top (Cmd/Ctrl+Shift+↑)
- Archive conversation (Cmd/Ctrl+Shift+A)
- Delete conversation (Cmd/Ctrl+Shift+Backspace)

Addresses #3664

* feat(shortcuts): implement handlers for all new shortcuts

New handlers:
- Upload file: triggers attach-file button click
- Toggle right sidebar: clicks parameters-button
- Regenerate response: clicks regenerate-generation-button
- Edit last message: finds last user-turn and clicks edit button
- Scroll to top: scrolls main[role=main] to top
- Archive conversation: calls archive mutation + navigates to new chat
- Delete conversation: calls delete mutation + navigates to new chat

Improvements:
- Use getMainScrollContainer() helper targeting main[role=main]
  instead of fragile class-based selectors
- Use data-testid selectors instead of aria-label substring matching
  for stop-generation and model-selector buttons
- Use id-based selectors (button[id^=edit-]) for edit buttons
- Add isEditing guard to skip shortcuts when user is typing in
  inputs, textareas, or contentEditable elements
- Refactor handler from if/return chain to switch statement for
  cleaner flow control

* fix(shortcuts): increase dialog scroll height for expanded shortcut list

With 20 shortcuts across 3 groups, the previous 480px max was tight.
Increase to 560px / 70vh so all shortcuts are visible without
excessive scrolling.

* refactor(shortcuts): use data-testid selectors for reliable targeting

Add data-testid="nav-settings" to the Settings menu item in
AccountSettings so the open-settings shortcut no longer relies on
fragile text-content matching ('Settings' but not 'Keyboard').

* refactor(shortcuts): two-column layout for shortcuts dialog

Split the shortcuts dialog into a two-column grid layout:
- Left column: General + Navigation groups
- Right column: Chat group (which has the most shortcuts)

Reduces vertical height so the full list is visible without scrolling.
Widen dialog to max-w-4xl (w-11/12) to accommodate both columns.
Simplify Kbd/group styling for cleaner visual density.

* refactor(shortcuts): adjust padding in KeyboardShortcutsDialog content

* feat(shortcuts): customizable keyboard shortcuts with recorder UI

Add per-shortcut overrides stored in localStorage, a recorder component
for capturing new key combos with conflict detection, and a per-row
edit/reset affordance in the shortcuts dialog.

* test(shortcuts): fix specs broken by keyboard shortcut hooks

- ExpandedPanel: add customShortcuts atom to the store mock so
  useShortcutDisplay/useShortcutAriaKey can read state
- AttachFileMenu: update queries to the new 'Attach Files' aria-label
- Button (Generations): wrap renders in RecoilRoot now that the
  component reads shortcut state

* feat(shortcuts): add panel/submit/bookmark/continue/read-aloud shortcuts

- Wire stop, regenerate, continue, and read-aloud handlers to existing
  buttons via data-testid, fixing handlers that previously queried
  selectors with no matching DOM nodes.
- Add data-testid='nav-panel-${id}' to expanded sidebar nav buttons so
  the panel-opener shortcuts can target them.
- Add new shortcut definitions and handlers: submitMessage,
  bookmarkConversation, continueResponse, readAloudLastResponse, and
  the open* panel openers (assistants, agents, prompts, memories,
  parameters, files, bookmarks, MCP).
- Drop the toggleRightSidebar shortcut — there is no right sidebar to
  toggle in this codebase.
- Refresh the KeyboardShortcutsDialog layout and ShortcutRecorder for
  the new groups, tighten ShortcutKeyCombo styling, and surface the
  shortcuts hint chips in the account menu.

* chore(shortcuts): remove unused translation keys

Drop com_shortcut_dialog_subtitle, com_shortcut_not_set, and
com_shortcut_reset_aria — no remaining references in the codebase.

* fix(shortcuts): resolve keyboard shortcut and footer regressions

- Guard the temporary-chat toggle so the shortcut mirrors the UI, only
  toggling when the conversation has no messages and is not submitting.
- Stop Ctrl/Cmd+Enter from double-submitting: the main chat textarea
  already submits via its own handler, and submit is blocked from
  unrelated inputs while still working in the chat box.
- Ignore repeated keydown events (e.repeat) so held keys no longer
  re-run toggles or destructive actions.
- Scope archive/delete shortcuts to the conversation in the active
  route using useMatch, preventing mutations of a stale background
  conversation on non-chat routes.
- Keep the recorder conflict controls clickable by including the whole
  editing row in the outside-click containment check.
- Restore privacy policy and terms of service links on public share
  pages via an opt-in Footer prop.
- Expand the sidebar before activating panel shortcuts so they are
  visible on mobile, and avoid toggling an already-active panel.

* fix(shortcuts): reject bare non-printable shortcut bindings

A recorded non-printable key (Tab, Enter, Backspace, Delete, arrows,
Space) with no Cmd/Ctrl/Alt was treated as valid, so it could be saved
and then hijack navigation or fire destructive actions since the global
handler preventDefaults it outside text inputs. Require Shift at minimum
for these keys, which keeps Shift+Escape (focusChat) valid while
rejecting bare single-key bindings.

* style: fix import order drift across keyboard shortcut files

* fix(shortcuts): guard actions behind dialog and resolve reset conflicts

- Ignore global shortcut actions while the shortcuts dialog is open
  (except the toggle that closes it), so a combo like delete/archive
  can no longer fire on the conversation behind the modal.
- When resetting a shortcut to its default, unbind any other action
  whose custom binding collides with that restored default, so Reset
  after a Replace can't leave two rows sharing one binding with one
  action unreachable.

* fix(shortcuts): keep attach menu button accessible name stable

The shortcut pass changed the attach menu button's aria-label from the hardcoded "Attach File Options" to localize('com_sidepanel_attach_files') ("Attach Files"), which changed its accessible name and broke the provider-file e2e specs that locate it by name. Restore the original label and keep only the added aria-keyshortcuts.

* fix(shortcuts): gate temporary chat toggle to chat routes

The Root-level listener runs on non-chat routes (search, settings, panels) where the last loaded conversation may be empty, so Ctrl/Cmd+Shift+T could flip the hidden isTemporary state without the TemporaryChat control being visible. Require an active chat route (routeConvoId) before toggling.

* test(shortcuts): align attach menu spec with button accessible name

The attach menu button's aria-label was restored to "Attach File Options" (matching dev and the provider-file e2e specs), so update the unit test's button queries from /attach files/i to /attach file options/i. All 26 cases pass.

* fix(shortcuts): target conversation bookmark and reveal search panel

- Bookmark: query the unique #bookmark-menu-button so the shortcut
  bookmarks the current conversation. The previous
  querySelector('[data-testid="bookmark-menu"]') matched the sidebar
  tag-filter button first (same testid, earlier in the DOM), toggling
  the filter instead of bookmarking.
- Focus search: activate the conversations panel before focusing, since
  the search input only mounts there and the sidebar renders just the
  active panel. Route through the nav-panel-conversations button (the
  listener is outside ActivePanelProvider) and settle before focusing,
  so Ctrl/Cmd+/ works from any panel.

* fix(shortcuts): preserve footer links, cross-platform bindings, modal guard

- restore unconditional legal footer links (drop showLegalLinks gate)
- keep untouched platform's default when customizing a binding
- round-trip bindings whose key is the plus character
- suppress global shortcuts while any modal dialog is open
- tag read-aloud test id only on assistant turns

* fix(shortcuts): include non-Radix dialogs in the modal guard

The guard only matched Radix dialogs via data-state="open", missing
Headless UI dialogs (e.g. the redesigned Settings modal) that render
role="dialog" without data-state. Iterate all dialog/alertdialog nodes
and treat one as open unless it is inert or data-state="closed", which
also avoids false positives from always-mounted inert panels.

* fix(shortcuts): gate temporary chat toggle behind TEMPORARY_CHAT permission

* fix(shortcuts): only prevent native key event when shortcut action runs

* fix(shortcuts): rebind temporary chat, open settings without toggling menu, release no-op keys

* fix(shortcuts): confirm conversation delete, use clipboard fallback, add tests

* fix(shortcuts): navigate to new chat after keyboard-confirmed delete

* fix(shortcuts): copy last response via message button, guard unavailable controls

* fix(shortcuts): keep custom Enter-based submit bindings working in the composer

* fix(shortcuts): restrict shift-only bindings to safe keys

* fix(shortcuts): submit custom Enter chords in the composer without inserting a newline

* fix(shortcuts): block global shortcuts while a menu overlay is focused

* fix(shortcuts): rebind archive off the browser-reserved Ctrl+Shift+A

* fix(shortcuts): honor submitMessage overrides in the composer
This commit is contained in:
Marco Beretta 2026-06-22 23:02:46 +02:00 committed by GitHub
parent 77983dbd42
commit ebb4f15dbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2970 additions and 44 deletions

1
.gitignore vendored
View file

@ -179,3 +179,4 @@ claude-flow
hive-mind-prompt-*.txt
CLAUDE.md
.gsd
codedb.snapshot

View file

@ -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<HTMLInputElement>(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 (
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>
<TooltipAnchor
description={localize('com_sidepanel_attach_files')}
description={tooltipDescription}
id="attach-file"
disabled={isUploadDisabled}
render={
<button
type="button"
aria-label={localize('com_sidepanel_attach_files')}
aria-keyshortcuts={ariaKey}
disabled={isUploadDisabled}
className={cn(
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',

View file

@ -34,6 +34,7 @@ import {
useLocalize,
} from '~/hooks';
import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling';
import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts';
import { SharePointPickerDialog } from '~/components/SharePoint';
import { useGetStartupConfig } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
@ -78,6 +79,8 @@ const AttachFileMenu = ({
const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(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 = ({
</Ariakit.MenuButton>
}
id="attach-file-menu-button"
description={localize('com_sidepanel_attach_files')}
description={uploadFileTooltip}
disabled={isUploadDisabled}
/>
);

View file

@ -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';

View file

@ -18,6 +18,7 @@ export default memo(function StopButton({
render={
<button
type="button"
data-testid="stop-generation-button"
className={cn(
'rounded-full bg-text-primary p-1.5 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
)}

View file

@ -9,6 +9,7 @@ import {
renderCustomGroups,
} from './components';
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts';
import { ModelSelectorChatProvider } from './ModelSelectorChatContext';
import { getSelectedIcon, getDisplayValue } from './utils';
import { CustomMenu as Menu } from './CustomMenu';
@ -19,6 +20,8 @@ const defaultInterface = getConfigDefaults().interface;
function ModelSelectorContent() {
const localize = useLocalize();
const modelSelectorHint = useShortcutHint('openModelSelector', localize('com_ui_select_model'));
const modelSelectorAriaKey = useShortcutAriaKey('openModelSelector');
const {
// LibreChat
@ -64,9 +67,11 @@ function ModelSelectorContent() {
const trigger = (
<TooltipAnchor
aria-label={localize('com_ui_select_model')}
description={localize('com_ui_select_model')}
description={modelSelectorHint}
render={
<button
data-testid="model-selector-button"
aria-keyshortcuts={modelSelectorAriaKey}
className="my-1 flex h-9 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-presentation px-3 py-2 text-sm text-text-primary hover:bg-surface-active-alt"
aria-label={localize('com_ui_select_model')}
>

View file

@ -1,6 +1,7 @@
import { startTransition } from 'react';
import { useSetRecoilState } from 'recoil';
import { TooltipAnchor, Button, Sidebar } from '@librechat/client';
import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
@ -11,6 +12,8 @@ export const OPEN_SIDEBAR_ID = 'open-sidebar-button';
export default function OpenSidebar({ className }: { className?: string }) {
const localize = useLocalize();
const setSidebarExpanded = useSetRecoilState(store.sidebarExpanded);
const tooltipDescription = useShortcutHint('toggleSidebar', localize('com_nav_open_sidebar'));
const ariaKey = useShortcutAriaKey('toggleSidebar');
const handleClick = () => {
startTransition(() => {
@ -23,7 +26,7 @@ export default function OpenSidebar({ className }: { className?: string }) {
return (
<TooltipAnchor
description={localize('com_nav_open_sidebar')}
description={tooltipDescription}
render={
<Button
id={OPEN_SIDEBAR_ID}
@ -33,6 +36,7 @@ export default function OpenSidebar({ className }: { className?: string }) {
aria-label={localize('com_nav_open_sidebar')}
aria-expanded={false}
aria-controls="chat-history-nav"
aria-keyshortcuts={ariaKey}
className={cn(
'rounded-xl bg-presentation duration-0 hover:bg-surface-active-alt',
className,

View file

@ -35,6 +35,7 @@ type HoverButtonProps = {
isLast?: boolean;
className?: string;
buttonStyle?: string;
dataTestId?: string;
};
const extractMessageContent = (message: TMessage): string => {
@ -80,6 +81,7 @@ const HoverButton = memo(
isDisabled = false,
isLast = false,
className = '',
dataTestId,
}: HoverButtonProps) => {
const buttonStyle = cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
@ -96,6 +98,7 @@ const HoverButton = memo(
return (
<button
id={id}
data-testid={dataTestId}
className={buttonStyle}
onClick={onClick}
type="button"
@ -170,6 +173,7 @@ const HoverButtons = ({
title={localize('com_ui_regenerate')}
icon={<RegenerateIcon size="19" />}
isLast={isLast}
dataTestId={isLast ? 'regenerate-generation-button' : undefined}
/>
)}
</div>
@ -201,6 +205,7 @@ const HoverButtons = ({
icon={props.icon}
isActive={props.isActive}
isLast={isLast}
dataTestId={isLast && !isCreatedByUser ? 'read-aloud-button' : undefined}
/>
)}
/>
@ -220,6 +225,7 @@ const HoverButtons = ({
? 'group-hover:opacity-100 [@media(hover:hover)]:opacity-0'
: '',
)}
dataTestId={!isCreatedByUser ? 'copy-response-button' : undefined}
/>
{/* Edit Button */}
@ -258,6 +264,7 @@ const HoverButtons = ({
title={localize('com_ui_regenerate')}
icon={<RegenerateIcon size="19" />}
isLast={isLast}
dataTestId={isLast ? 'regenerate-generation-button' : undefined}
className="active"
/>
)}
@ -269,6 +276,7 @@ const HoverButtons = ({
title={localize('com_ui_continue')}
icon={<ContinueIcon className="w-19 h-19 -rotate-180" />}
isLast={isLast}
dataTestId={isLast ? 'continue-generation-button' : undefined}
className="active"
/>
)}

View file

@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil';
import { TooltipAnchor } from '@librechat/client';
import { MessageCircleDashed } from 'lucide-react';
import { useRecoilState, useRecoilCallback } from 'recoil';
import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
@ -12,6 +13,8 @@ export function TemporaryChat() {
const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary);
const conversation = useRecoilValue(store.conversationByIndex(0));
const isSubmitting = useRecoilValue(store.isSubmittingFamily(0));
const tooltipDescription = useShortcutHint('toggleTemporaryChat', localize('com_ui_temporary'));
const ariaKey = useShortcutAriaKey('toggleTemporaryChat');
const handleBadgeToggle = useRecoilCallback(
() => () => {
@ -30,12 +33,13 @@ export function TemporaryChat() {
return (
<div className="relative flex flex-wrap items-center gap-2">
<TooltipAnchor
description={localize('com_ui_temporary')}
description={tooltipDescription}
render={
<button
onClick={handleBadgeToggle}
aria-label={localize('com_ui_temporary')}
aria-pressed={isTemporary}
aria-keyshortcuts={ariaKey}
className={cn(
'inline-flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out',
isTemporary

View file

@ -26,11 +26,13 @@ type DeleteButtonProps = {
setShowDeleteDialog?: (value: boolean) => void;
triggerRef?: React.RefObject<HTMLButtonElement>;
setMenuOpen?: (open: boolean) => void;
currentConversationId?: string;
};
export function DeleteConversationDialog({
setShowDeleteDialog,
conversationId,
currentConversationId,
setMenuOpen,
retainView,
title,
@ -38,6 +40,7 @@ export function DeleteConversationDialog({
setMenuOpen?: (open: boolean) => void;
setShowDeleteDialog: (value: boolean) => void;
conversationId: string;
currentConversationId?: string;
retainView: () => void;
title: string;
}) {
@ -46,7 +49,8 @@ export function DeleteConversationDialog({
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const { newConversation } = useNewConvo();
const { conversationId: currentConvoId } = useParams();
const { conversationId: routeConversationId } = useParams();
const currentConvoId = currentConversationId ?? routeConversationId;
const deleteMutation = useDeleteConversationMutation({
onSuccess: () => {
@ -112,6 +116,7 @@ export function DeleteConversationDialog({
export default function DeleteButton({
conversationId,
currentConversationId,
retainView,
title,
setMenuOpen,
@ -132,6 +137,7 @@ export default function DeleteButton({
<DeleteConversationDialog
setShowDeleteDialog={setShowDeleteDialog}
conversationId={conversationId}
currentConversationId={currentConversationId}
setMenuOpen={setMenuOpen}
retainView={retainView}
title={title}

View file

@ -1,3 +1,5 @@
import type { ShortcutActionId } from '~/hooks/useKeyboardShortcuts';
import { useShortcutAriaKey, useShortcutDisplay } from '~/hooks/useKeyboardShortcuts';
import { cn, removeFocusOutlines } from '~/utils/';
export default function Button({
@ -5,15 +7,21 @@ export default function Button({
children,
onClick,
className = '',
shortcutId,
}: {
type?: 'regenerate' | 'continue' | 'stop';
children: React.ReactNode;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
className?: string;
shortcutId?: ShortcutActionId;
}) {
const shortcutDisplay = useShortcutDisplay(shortcutId);
const ariaKey = useShortcutAriaKey(shortcutId);
return (
<button
data-testid={`${type}-generation-button`}
aria-keyshortcuts={ariaKey}
className={cn(
'custom-btn btn-neutral relative -z-0 whitespace-nowrap border-0 md:border',
removeFocusOutlines,
@ -21,7 +29,14 @@ export default function Button({
)}
onClick={onClick}
>
<div className="flex w-full items-center justify-center gap-2">{children}</div>
<div className="flex w-full items-center justify-center gap-2">
{children}
{shortcutDisplay && (
<span className="hidden rounded-md border border-border-light px-1.5 py-0.5 text-[10px] leading-none text-text-secondary md:inline-flex">
{shortcutDisplay}
</span>
)}
</div>
</button>
);
}

View file

@ -7,7 +7,7 @@ export default function Regenerate({ onClick }: TGenButtonProps) {
const localize = useLocalize();
return (
<Button onClick={onClick}>
<Button onClick={onClick} shortcutId="regenerateResponse">
<RegenerateIcon className="h-3 w-3 flex-shrink-0 text-gray-600/90 dark:text-gray-400" />
{localize('com_ui_regenerate')}
</Button>

View file

@ -7,7 +7,7 @@ export default function Stop({ onClick }: TGenButtonProps) {
const localize = useLocalize();
return (
<Button type="stop" onClick={onClick}>
<Button type="stop" onClick={onClick} shortcutId="stopGenerating">
<StopGeneratingIcon className="text-gray-600/90 dark:text-gray-400" />
{localize('com_ui_stop')}
</Button>

View file

@ -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(<RecoilRoot>{ui}</RecoilRoot>);
describe('Button', () => {
it('renders with the correct type and children', () => {
const { getByTestId, getByText } = render(
const { getByTestId, getByText } = renderWithRecoil(
<Button
type="regenerate"
onClick={() => {
@ -19,7 +23,7 @@ describe('Button', () => {
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
const { getByText } = render(
const { getByText } = renderWithRecoil(
<Button type="continue" onClick={handleClick}>
Continue
</Button>,

View file

@ -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 (
<Menu.MenuProvider placement="right-start">
<Menu.MenuItem
hideOnClick={false}
render={
<Menu.MenuButton className="select-item flex w-full cursor-pointer items-center gap-2 text-sm" />
}
>
<CircleHelp className="icon-md" aria-hidden="true" />
<span className="flex-1 text-left">{localize('com_nav_help')}</span>
<ChevronRight className="h-4 w-4 text-text-secondary" aria-hidden="true" />
</Menu.MenuItem>
<Menu.Menu
portal
gutter={12}
className="account-settings-popover popover-ui popover-from-left z-[126] w-[244px] rounded-lg"
>
{hasHelpFaq && (
<Menu.MenuItem
onClick={() => window.open(helpAndFaqURL, '_blank', 'noopener,noreferrer')}
className="select-item text-sm"
>
<LifeBuoy className="icon-md" aria-hidden="true" />
{localize('com_nav_help_faq')}
</Menu.MenuItem>
)}
<Menu.MenuItem onClick={onShowShortcuts} className="select-item text-sm">
<Keyboard className="icon-md" aria-hidden="true" />
{localize('com_shortcut_keyboard_shortcuts')}
</Menu.MenuItem>
{showLegalDivider && (hasTos || hasPrivacy) && <DropdownMenuSeparator />}
{hasTos && (
<Menu.MenuItem
onClick={() => window.open(termsOfServiceURL, '_blank', 'noopener,noreferrer')}
className="select-item text-sm"
>
<Scale className="icon-md" aria-hidden="true" />
{localize('com_ui_terms_of_service')}
</Menu.MenuItem>
)}
{hasPrivacy && (
<Menu.MenuItem
onClick={() => window.open(privacyPolicyURL, '_blank', 'noopener,noreferrer')}
className="select-item text-sm"
>
<ShieldCheck className="icon-md" aria-hidden="true" />
{localize('com_ui_privacy_policy')}
</Menu.MenuItem>
)}
</Menu.Menu>
</Menu.MenuProvider>
);
}
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<HTMLButtonElement>(null);
@ -70,6 +154,12 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
<DropdownMenuSeparator />
</>
)}
<HelpSubmenu
helpAndFaqURL={startupConfig?.helpAndFaqURL}
termsOfServiceURL={startupConfig?.interface?.termsOfService?.externalUrl}
privacyPolicyURL={startupConfig?.interface?.privacyPolicy?.externalUrl}
onShowShortcuts={() => setShowShortcutsDialog(true)}
/>
<Menu.MenuItem onClick={() => setShowFiles(true)} className="select-item text-sm">
<FileText className="icon-md" aria-hidden="true" />
{localize('com_nav_my_files')}
@ -78,16 +168,11 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
<Archive className="icon-md" aria-hidden="true" />
{localize('com_nav_archived_chats')}
</Menu.MenuItem>
{startupConfig?.helpAndFaqURL !== '/' && (
<Menu.MenuItem
onClick={() => window.open(startupConfig?.helpAndFaqURL, '_blank')}
className="select-item text-sm"
>
<LinkIcon aria-hidden="true" />
{localize('com_nav_help_faq')}
</Menu.MenuItem>
)}
<Menu.MenuItem onClick={() => setShowSettings(true)} className="select-item text-sm">
<Menu.MenuItem
onClick={() => setShowSettings(true)}
className="select-item text-sm"
data-testid="nav-settings"
>
<GearIcon className="icon-md" aria-hidden="true" />
{localize('com_nav_settings')}
</Menu.MenuItem>

View file

@ -0,0 +1,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 (
<DeleteButton
title={target.title}
conversationId={target.conversationId}
currentConversationId={target.conversationId}
retainView={retainView}
showDeleteDialog={true}
setShowDeleteDialog={setShowDeleteDialog}
/>
);
}

View file

@ -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<string, ShortcutBindingInfo[]>;
const PANELS_GROUP = 'com_shortcut_group_panels';
function EditingRow({
info,
label,
bindingMap,
getActionLabel,
setBinding,
onStopEdit,
}: {
info: ShortcutBindingInfo;
label: string;
bindingMap: Map<string, ShortcutActionId>;
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<string, string>,
ownerId: info.id,
getActionLabel,
onSave: handleSave,
onCancel: onStopEdit,
});
return (
<div ref={recorder.boundaryRef} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-[13px] text-text-primary">{label}</span>
<RecorderPill
state={recorder}
ariaLabel={localize('com_shortcut_edit_aria', { 0: label })}
ownerId={info.id}
/>
</div>
<RecorderInfo
state={recorder}
ownerId={info.id}
onCancel={onStopEdit}
onSaveReplacing={handleSaveReplacing}
/>
</div>
);
}
function ShortcutRow({
info,
isEditing,
onStartEdit,
onStopEdit,
bindingMap,
getActionLabel,
setBinding,
resetBinding,
}: {
info: ShortcutBindingInfo;
isEditing: boolean;
onStartEdit: (id: ShortcutActionId) => void;
onStopEdit: () => void;
bindingMap: Map<string, ShortcutActionId>;
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 (
<div className="px-2 py-2">
<EditingRow
info={info}
label={label}
bindingMap={bindingMap}
getActionLabel={getActionLabel}
setBinding={setBinding}
onStopEdit={onStopEdit}
/>
</div>
);
}
return (
<div className="group flex items-center justify-between gap-3 px-2 py-2">
<span
className={cn(
'truncate text-[13px]',
isUnset ? 'text-text-secondary' : 'text-text-primary',
)}
>
{label}
</span>
<div className="flex items-center gap-2">
{info.isCustom && (
<button
type="button"
onClick={() => resetBinding(info.id)}
className="text-[11.5px] text-text-secondary opacity-0 transition-opacity hover:text-text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100"
>
{localize('com_shortcut_reset')}
</button>
)}
{isUnset ? (
<button
type="button"
onClick={() => onStartEdit(info.id)}
aria-label={editAriaLabel}
data-testid={`edit-shortcut-${info.id}`}
className="inline-flex h-[22px] items-center gap-1 rounded-md border border-dashed border-border-medium bg-transparent px-2 text-[11px] font-medium text-text-secondary transition-colors hover:border-border-heavy hover:bg-surface-tertiary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-surface-secondary-alt"
>
<Plus className="h-3 w-3" aria-hidden="true" />
{localize('com_shortcut_set')}
</button>
) : (
<button
type="button"
onClick={() => onStartEdit(info.id)}
aria-label={editAriaLabel}
data-testid={`edit-shortcut-${info.id}`}
className="rounded-md px-1 py-0.5 transition-colors hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-surface-secondary-alt"
>
<ShortcutKeyCombo keys={displayKeys} />
</button>
)}
</div>
</div>
);
}
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<string, ShortcutActionId>;
getActionLabel: (id: string) => string;
setBinding: (id: ShortcutActionId, binding: ShortcutBinding | null) => void;
resetBinding: (id: ShortcutActionId) => void;
}) {
const localize = useLocalize();
return (
<section className="mb-6 last:mb-0">
<h3 className="mb-2 px-2 text-[12px] font-medium text-text-secondary">
{localize(groupKey as TranslationKeys)}
</h3>
<div className="flex flex-col">
{bindings.map((info) => (
<ShortcutRow
key={info.id}
info={info}
isEditing={editingId === info.id}
onStartEdit={onStartEdit}
onStopEdit={onStopEdit}
bindingMap={bindingMap}
getActionLabel={getActionLabel}
setBinding={setBinding}
resetBinding={resetBinding}
/>
))}
</div>
</section>
);
}
function PanelsSection({
bindings,
editingId,
onStartEdit,
onStopEdit,
bindingMap,
getActionLabel,
setBinding,
resetBinding,
}: {
bindings: ShortcutBindingInfo[];
editingId: ShortcutActionId | null;
onStartEdit: (id: ShortcutActionId) => void;
onStopEdit: () => void;
bindingMap: Map<string, ShortcutActionId>;
getActionLabel: (id: string) => string;
setBinding: (id: ShortcutActionId, binding: ShortcutBinding | null) => void;
resetBinding: (id: ShortcutActionId) => void;
}) {
const localize = useLocalize();
return (
<section className="border-t border-border-light px-5 pb-2 pt-4">
<div className="mb-2 flex items-baseline justify-between gap-3 px-2">
<h3 className="text-[12px] font-medium text-text-secondary">
{localize('com_shortcut_group_panels')}
</h3>
<p className="text-text-secondary/80 text-[11.5px]">
{localize('com_shortcut_group_panels_hint')}
</p>
</div>
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
{bindings.map((info) => (
<ShortcutRow
key={info.id}
info={info}
isEditing={editingId === info.id}
onStartEdit={onStartEdit}
onStopEdit={onStopEdit}
bindingMap={bindingMap}
getActionLabel={getActionLabel}
setBinding={setBinding}
resetBinding={resetBinding}
/>
))}
</div>
</section>
);
}
function KeyboardShortcutsDialog() {
const localize = useLocalize();
const { bindings, bindingMap, setBinding, resetBinding, resetAll } = useShortcutBindings();
const [open, setOpen] = useRecoilState(store.showShortcutsDialog);
const [editingId, setEditingId] = useState<ShortcutActionId | null>(null);
const grouped = useMemo<GroupedBindings>(() => {
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<Map<string, string>>(() => {
const map = new Map<string, string>();
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 (
<OGDialog
open={open}
onOpenChange={(next) => {
if (!next) {
setEditingId(null);
}
setOpen(next);
}}
>
<OGDialogContent
showCloseButton={false}
className="flex max-h-[85vh] w-11/12 max-w-3xl flex-col overflow-hidden p-0"
>
<header className="flex shrink-0 items-center justify-between gap-4 px-7 pt-6">
<OGDialogTitle className="text-[16px] font-semibold text-text-primary">
{localize('com_shortcut_keyboard_shortcuts')}
</OGDialogTitle>
<OGDialogClose className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-surface-tertiary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-surface-secondary-alt">
<X className="h-4 w-4" />
<span className="sr-only">{localize('com_ui_close')}</span>
</OGDialogClose>
</header>
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 gap-x-10 px-5 pb-2 pt-5 md:grid-cols-2">
<div>
{leftColumn.map(([groupKey, items]) => (
<ShortcutGroup
key={groupKey}
groupKey={groupKey}
bindings={items}
editingId={editingId}
onStartEdit={handleStartEdit}
onStopEdit={handleStopEdit}
bindingMap={bindingMap}
getActionLabel={getActionLabel}
setBinding={setBinding}
resetBinding={resetBinding}
/>
))}
</div>
<div>
{rightColumn.map(([groupKey, items]) => (
<ShortcutGroup
key={groupKey}
groupKey={groupKey}
bindings={items}
editingId={editingId}
onStartEdit={handleStartEdit}
onStopEdit={handleStopEdit}
bindingMap={bindingMap}
getActionLabel={getActionLabel}
setBinding={setBinding}
resetBinding={resetBinding}
/>
))}
</div>
</div>
{panelEntries.length > 0 && (
<PanelsSection
bindings={panelEntries}
editingId={editingId}
onStartEdit={handleStartEdit}
onStopEdit={handleStopEdit}
bindingMap={bindingMap}
getActionLabel={getActionLabel}
setBinding={setBinding}
resetBinding={resetBinding}
/>
)}
</div>
{hasAnyCustom && (
<footer className="flex shrink-0 justify-end border-t border-border-light px-7 py-3">
<button
type="button"
onClick={resetAll}
className="text-[12px] text-text-secondary transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{localize('com_shortcut_reset_all')}
</button>
</footer>
)}
</OGDialogContent>
</OGDialog>
);
}
export default memo(KeyboardShortcutsDialog);

View file

@ -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 (
<div
@ -65,7 +69,8 @@ export default function NavToggle({
id={`toggle-${side}-nav`}
onClick={onToggle}
role="button"
description={ariaDescription}
description={tooltipDescription}
aria-keyshortcuts={ariaKey}
className="flex items-center justify-center"
tabIndex={0}
>

View file

@ -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<HTMLDivEleme
const [text, setText] = useState('');
const inputRef = useRef<HTMLInputElement>(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<HTMLDivEleme
/>
<input
type="text"
data-testid="nav-search-input"
ref={inputRef}
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
value={text}
@ -125,6 +128,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
e.code === 'Space' ? e.stopPropagation() : null;
}}
aria-label={localize('com_nav_search_placeholder')}
aria-keyshortcuts={focusSearchAriaKey}
placeholder={localize('com_nav_search_placeholder')}
onKeyUp={handleKeyUp}
onFocus={() => setSearchState((prev) => ({ ...prev, isSearching: true }))}

View file

@ -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 (
<kbd
className={cn(
'inline-flex h-[22px] min-w-[22px] items-center justify-center rounded-md border border-border-light bg-surface-primary-alt px-1.5 font-sans text-[11px] font-medium leading-none text-text-primary',
className,
)}
>
{children}
</kbd>
);
}
export default function ShortcutKeyCombo({
display,
keys,
className = '',
keyClassName = '',
}: {
display?: string;
keys?: string[];
className?: string;
keyClassName?: string;
}) {
const shortcutKeys = keys ?? parseShortcutKeys(display ?? '');
return (
<div className={cn('flex items-center gap-1', className)}>
{shortcutKeys.map((key, idx) => (
<ShortcutKbd key={`${key}-${idx}`} className={keyClassName}>
{key}
</ShortcutKbd>
))}
</div>
);
}

View file

@ -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<HTMLDivElement>;
boundaryRef: RefObject<HTMLDivElement>;
previewKeys: string[];
hasConflict: boolean;
conflict: ConflictInfo | null;
showInvalid: boolean;
showHint: boolean;
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
onKeyUp: () => void;
onTryAgain: () => void;
};
type Options = {
initial: ShortcutBinding | null;
bindingMap: Map<string, string>;
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<HTMLDivElement>(null);
const boundaryRef = useRef<HTMLDivElement>(null);
const [pending, setPending] = useState<ShortcutBinding | null>(null);
const [draft, setDraft] = useState<ShortcutBinding | null>(initial);
const [error, setError] = useState<'noModifier' | null>(null);
const [conflict, setConflict] = useState<ConflictInfo | null>(null);
const previewKeys = useMemo(() => {
const source = pending ?? draft;
if (!source) return [];
return bindingDisplayKeys(source, isMac);
}, [pending, draft]);
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
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 (
<div
ref={containerRef}
role="textbox"
tabIndex={0}
aria-label={ariaLabel}
aria-describedby={`${ownerId}-recorder-hint`}
data-testid={`shortcut-recorder-${ownerId}`}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
className={cn(
'flex h-[30px] items-center gap-1.5 rounded-md border bg-surface-primary px-2 outline-none transition-colors',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-surface-primary-alt',
stateBorder,
)}
>
{showHint ? (
<span className="text-[11.5px] text-text-secondary">
{localize('com_shortcut_recorder_placeholder')}
</span>
) : (
<ShortcutKeyCombo keys={previewKeys} />
)}
</div>
);
}
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 (
<div
id={`${ownerId}-recorder-hint`}
className="flex flex-wrap items-center justify-end gap-x-2 gap-y-1 pl-1 text-[11.5px]"
>
<span className="text-text-secondary">
{localize('com_shortcut_recorder_conflict_prefix')}{' '}
<span className="font-medium text-text-primary">{conflict.conflictLabel}</span>
</span>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onTryAgain}
className="whitespace-nowrap rounded-md px-1.5 py-0.5 text-text-secondary transition-colors hover:text-text-primary"
>
{localize('com_shortcut_recorder_try_again')}
</button>
<button
type="button"
onClick={() => onSaveReplacing(conflict.binding, conflict.conflictId)}
className="whitespace-nowrap rounded-md bg-surface-tertiary px-2 py-0.5 font-medium text-text-primary transition-colors hover:bg-surface-active-alt"
>
{localize('com_shortcut_recorder_replace')}
</button>
</div>
</div>
);
}
return (
<div id={`${ownerId}-recorder-hint`} className="flex items-center justify-end gap-2 pl-1">
<span
className={cn(
'text-[11.5px]',
showInvalid ? 'text-red-600 dark:text-red-400' : 'text-text-secondary',
)}
>
{showInvalid
? localize('com_shortcut_recorder_needs_modifier')
: localize('com_shortcut_recorder_hint')}
</span>
<button
type="button"
onClick={onCancel}
aria-label={localize('com_ui_cancel')}
className="inline-flex h-5 w-5 items-center justify-center rounded text-text-secondary transition-colors hover:bg-surface-active-alt hover:text-text-primary"
>
<X className="h-3 w-3" />
</button>
</div>
);
}

View file

@ -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<HTMLAnchorElement>) => {
@ -42,12 +45,13 @@ const NewChatButton = memo(function NewChatButton({
return (
<TooltipAnchor
side="right"
description={localize('com_ui_new_chat')}
description={tooltipDescription}
render={
<a
href="/c/new"
data-testid="new-chat-button"
aria-label={localize('com_ui_new_chat')}
aria-keyshortcuts={ariaKey}
className="flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-surface-hover"
onClick={handleClick}
>
@ -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 (
<div className="flex h-full flex-shrink-0 flex-col gap-2 border-r border-border-light bg-surface-primary-alt px-2 py-2">
<TooltipAnchor
side="right"
description={localize(toggleLabel)}
description={toggleSidebarHint}
render={
<Button
id={expanded ? CLOSE_SIDEBAR_ID : undefined}
@ -149,6 +156,7 @@ function ExpandedPanel({
variant="ghost"
aria-label={localize(toggleLabel)}
aria-expanded={expanded}
aria-keyshortcuts={toggleSidebarAriaKey}
className="h-9 w-9 rounded-lg"
onClick={toggleClick}
>

View file

@ -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,
},
};
});

View file

@ -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,

View file

@ -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<typeof copy>;
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 (
<>
<span data-testid="delete-target">{deleteTarget?.conversationId ?? 'none'}</span>
<span data-testid="sidebar">{String(sidebarExpanded)}</span>
</>
);
}
function renderHarness(conversation?: TConversation, route = '/c/test-convo') {
const initializeState = (snapshot: MutableSnapshot) => {
if (conversation) {
snapshot.set(store.conversationByIndex(0), conversation);
}
};
return render(<Harness />, {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient()}>
<RecoilRoot initializeState={initializeState}>
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
</RecoilRoot>
</QueryClientProvider>
),
});
}
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');
});
});

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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 (
<>
<KeyboardShortcutsDialog />
<KeyboardDeleteDialog />
</>
);
}
export default function Root() {
const [showTerms, setShowTerms] = useState(false);
@ -98,6 +112,7 @@ export default function Root() {
modalContent={config.interface.termsOfService.modalContent}
/>
)}
<KeyboardShortcutsProvider />
</AssistantsMapContext.Provider>
</FileMapContext.Provider>
</SetConvoProvider>

View file

@ -57,6 +57,31 @@ const isEditingBadges = atom<boolean>({
default: false,
});
const showShortcutsDialog = atom<boolean>({
key: 'showShortcutsDialog',
default: false,
});
export type KeyboardDeleteTarget = {
conversationId: string;
title: string;
};
const keyboardDeleteTarget = atom<KeyboardDeleteTarget | null>({
key: 'keyboardDeleteTarget',
default: null,
});
export type ShortcutOverride = {
mac: string | null;
other: string | null;
};
const customShortcuts = atomWithLocalStorage<Record<string, ShortcutOverride>>(
'customKeyboardShortcuts',
{},
);
const chatBadges = atomWithLocalStorage<Pick<BadgeItem, 'id'>[]>('chatBadges', [
// When adding new badges, make sure to add them to useChatBadges.ts as well and add them as last item
// DO NOT CHANGE THE ORDER OF THE BADGES ALREADY IN THE ARRAY
@ -70,5 +95,8 @@ export default {
conversationAttachmentsSelector,
queriesEnabled,
isEditingBadges,
showShortcutsDialog,
keyboardDeleteTarget,
customShortcuts,
chatBadges,
};

View file

@ -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;

View file

@ -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> = {}): 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');
});
});

View file

@ -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<string, string> = {
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<string, string> = {
'?': '/',
':': ';',
'<': ',',
'>': '.',
'"': "'",
'{': '[',
'}': ']',
'|': '\\',
_: '-',
'+': '=',
'~': '`',
'!': '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<string, 'meta' | 'ctrl' | 'alt' | 'shift'> = {
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<string, string> = {
Meta: '⌘',
Control: '⌃',
Alt: '⌥',
Shift: '⇧',
Backspace: '⌫',
Delete: '⌦',
Enter: '↵',
Escape: 'Esc',
Tab: '⇥',
Space: 'Space',
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
};
const OTHER_LABELS: Record<string, string> = {
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('+');
}

View file

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