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.
This commit is contained in:
Marco Beretta 2026-05-11 23:05:36 +02:00
parent c127d8e26f
commit 2884fed30d
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
11 changed files with 417 additions and 209 deletions

View file

@ -7,7 +7,6 @@ import type { TPreset, TInterfaceConfig } from 'librechat-data-provider';
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
import { useSetIndexOptions, useLocalize } from '~/hooks';
import { useGetEndpointsQuery } from '~/data-provider';
import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts';
import OptionsPopover from './OptionsPopover';
import PopoverButtons from './PopoverButtons';
import { useChatContext } from '~/Providers';
@ -25,11 +24,6 @@ export default function HeaderOptions({
const { showPopover, conversation, setShowPopover } = useChatContext();
const { setOption } = useSetIndexOptions();
const { endpoint } = conversation ?? {};
const toggleRightSidebarHint = useShortcutHint(
'toggleRightSidebar',
localize('com_ui_model_parameters'),
);
const toggleRightSidebarAriaKey = useShortcutAriaKey('toggleRightSidebar');
const saveAsPreset = () => {
setSaveAsDialogShow(true);
@ -57,12 +51,11 @@ export default function HeaderOptions({
<TooltipAnchor
id="parameters-button"
aria-label={localize('com_ui_model_parameters')}
description={toggleRightSidebarHint}
description={localize('com_ui_model_parameters')}
tabIndex={0}
role="button"
onClick={triggerAdvancedMode}
data-testid="parameters-button"
aria-keyshortcuts={toggleRightSidebarAriaKey}
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Settings2 size={16} aria-hidden="true" />

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

@ -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',
@ -95,6 +97,7 @@ const HoverButton = memo(
return (
<button
id={id}
data-testid={dataTestId}
className={buttonStyle}
onClick={onClick}
type="button"
@ -169,6 +172,7 @@ const HoverButtons = ({
title={localize('com_ui_regenerate')}
icon={<RegenerateIcon size="19" />}
isLast={isLast}
dataTestId={isLast ? 'regenerate-generation-button' : undefined}
/>
)}
</div>
@ -200,6 +204,7 @@ const HoverButtons = ({
icon={props.icon}
isActive={props.isActive}
isLast={isLast}
dataTestId={isLast ? 'read-aloud-button' : undefined}
/>
)}
/>
@ -255,6 +260,7 @@ const HoverButtons = ({
title={localize('com_ui_regenerate')}
icon={<RegenerateIcon size="19" />}
isLast={isLast}
dataTestId={isLast ? 'regenerate-generation-button' : undefined}
className="active"
/>
)}
@ -266,6 +272,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

@ -15,7 +15,6 @@ import { GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client';
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
import { useAuthContext } from '~/hooks/AuthContext';
import { useShortcutDisplay } from '~/hooks/useKeyboardShortcuts';
import { useLocalize } from '~/hooks';
import Settings from './Settings';
import store from '~/store';
@ -25,13 +24,11 @@ function HelpSubmenu({
termsOfServiceURL,
privacyPolicyURL,
onShowShortcuts,
showShortcutsDisplay,
}: {
helpAndFaqURL?: string;
termsOfServiceURL?: string;
privacyPolicyURL?: string;
onShowShortcuts: () => void;
showShortcutsDisplay: string;
}) {
const localize = useLocalize();
const hasHelpFaq = !!helpAndFaqURL && helpAndFaqURL !== '/';
@ -53,7 +50,7 @@ function HelpSubmenu({
</Menu.MenuItem>
<Menu.Menu
portal
gutter={4}
gutter={12}
className="account-settings-popover popover-ui popover-from-left z-[126] w-[244px] rounded-lg"
>
{hasHelpFaq && (
@ -67,8 +64,7 @@ function HelpSubmenu({
)}
<Menu.MenuItem onClick={onShowShortcuts} className="select-item text-sm">
<Keyboard className="icon-md" aria-hidden="true" />
<span className="flex-1">{localize('com_shortcut_keyboard_shortcuts')}</span>
<span className="text-xs text-text-secondary">{showShortcutsDisplay}</span>
{localize('com_shortcut_keyboard_shortcuts')}
</Menu.MenuItem>
{showLegalDivider && (hasTos || hasPrivacy) && <DropdownMenuSeparator />}
{hasTos && (
@ -105,8 +101,6 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
const [showFiles, setShowFiles] = useState(false);
const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog);
const accountSettingsButtonRef = useRef<HTMLButtonElement>(null);
const showShortcutsDisplay = useShortcutDisplay('showShortcuts');
const openSettingsDisplay = useShortcutDisplay('openSettings');
return (
<Menu.MenuProvider>
@ -158,6 +152,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')}
@ -168,16 +168,8 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
data-testid="nav-settings"
>
<GearIcon className="icon-md" aria-hidden="true" />
<span className="flex-1">{localize('com_nav_settings')}</span>
<span className="text-xs text-text-secondary">{openSettingsDisplay}</span>
{localize('com_nav_settings')}
</Menu.MenuItem>
<HelpSubmenu
helpAndFaqURL={startupConfig?.helpAndFaqURL}
termsOfServiceURL={startupConfig?.interface?.termsOfService?.externalUrl}
privacyPolicyURL={startupConfig?.interface?.privacyPolicy?.externalUrl}
onShowShortcuts={() => setShowShortcutsDialog(true)}
showShortcutsDisplay={showShortcutsDisplay}
/>
<DropdownMenuSeparator />
<Menu.MenuItem onClick={() => logout()} className="select-item text-sm">
<LogOut className="icon-md" aria-hidden="true" />

View file

@ -1,11 +1,11 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { useRecoilState } from 'recoil';
import { Pencil, RotateCcw, X } from 'lucide-react';
import { Plus, X } from 'lucide-react';
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 { isMac, useShortcutBindings, useShortcutDisplay } from '~/hooks/useKeyboardShortcuts';
import { isMac, useShortcutBindings } from '~/hooks/useKeyboardShortcuts';
import { RecorderInfo, RecorderPill, useShortcutRecorder } from './ShortcutRecorder';
import { bindingDisplayKeys } from '~/utils/shortcuts';
import ShortcutKeyCombo from './ShortcutKeyCombo';
@ -15,6 +15,8 @@ import store from '~/store';
type GroupedBindings = Record<string, ShortcutBindingInfo[]>;
const PANELS_GROUP = 'com_shortcut_group_panels';
function EditingRow({
info,
label,
@ -59,9 +61,9 @@ function EditingRow({
});
return (
<div className="flex flex-col gap-2.5">
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-[13.5px] font-medium text-text-primary">{label}</span>
<span className="truncate text-[13px] text-text-primary">{label}</span>
<RecorderPill
state={recorder}
ariaLabel={localize('com_shortcut_edit_aria', { 0: label })}
@ -100,17 +102,12 @@ function ShortcutRow({
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;
return (
<div
className={cn(
'group relative rounded-xl px-3 py-2.5 transition-all duration-200',
isEditing
? 'bg-surface-tertiary/60 dark:bg-surface-secondary-alt/60 ring-1 ring-blue-500/30'
: 'hover:bg-surface-tertiary/50 dark:hover:bg-surface-secondary-alt/40',
)}
>
{isEditing ? (
if (isEditing) {
return (
<div className="px-2 py-2">
<EditingRow
info={info}
label={label}
@ -119,50 +116,53 @@ function ShortcutRow({
setBinding={setBinding}
onStopEdit={onStopEdit}
/>
) : (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-[13.5px] font-medium text-text-primary">{label}</span>
{info.isCustom && (
<span
className="inline-flex h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500 ring-2 ring-blue-500/20"
aria-hidden="true"
title={localize('com_shortcut_edit_aria', { 0: label })}
/>
)}
</div>
<div className="flex items-center gap-1.5">
{displayKeys.length > 0 ? (
<ShortcutKeyCombo keys={displayKeys} />
) : (
<span className="text-[12px] font-medium italic text-text-secondary">
{localize('com_shortcut_not_set')}
</span>
)}
<div className="ml-1 flex items-center gap-0.5 opacity-0 transition-all duration-200 focus-within:opacity-100 group-hover:opacity-100">
{info.isCustom && (
<button
type="button"
onClick={() => resetBinding(info.id)}
aria-label={localize('com_shortcut_reset_aria', { 0: label })}
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-text-secondary transition-colors hover:bg-surface-active-alt hover:text-text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
onClick={() => onStartEdit(info.id)}
aria-label={localize('com_shortcut_edit_aria', { 0: label })}
data-testid={`edit-shortcut-${info.id}`}
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-text-secondary hover:bg-surface-active-alt hover:text-text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
)}
</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>
);
}
@ -190,11 +190,60 @@ function ShortcutGroup({
}) {
const localize = useLocalize();
return (
<section className="mb-5 last:mb-0">
<h3 className="mb-1.5 px-3 text-[10.5px] font-semibold uppercase tracking-[0.12em] text-text-secondary">
<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 gap-0.5">
<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}
@ -216,7 +265,6 @@ function ShortcutGroup({
function KeyboardShortcutsDialog() {
const localize = useLocalize();
const { bindings, bindingMap, setBinding, resetBinding, resetAll } = useShortcutBindings();
const showShortcutsDisplay = useShortcutDisplay('showShortcuts');
const [open, setOpen] = useRecoilState(store.showShortcutsDialog);
const [editingId, setEditingId] = useState<ShortcutActionId | null>(null);
@ -235,13 +283,14 @@ function KeyboardShortcutsDialog() {
const groupEntries = useMemo(() => Object.entries(grouped), [grouped]);
const leftColumn = useMemo(
() => groupEntries.filter(([key]) => key !== 'com_shortcut_group_chat'),
() => 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>();
@ -274,80 +323,78 @@ function KeyboardShortcutsDialog() {
>
<OGDialogContent
showCloseButton={false}
className="flex max-h-[85vh] w-11/12 max-w-4xl flex-col overflow-hidden p-0"
className="flex max-h-[85vh] w-11/12 max-w-3xl flex-col overflow-hidden p-0"
>
<header className="flex shrink-0 items-start justify-between gap-4 px-7 pb-4 pt-6">
<div className="flex flex-col gap-1">
<OGDialogTitle className="text-[18px] font-semibold tracking-tight text-text-primary">
{localize('com_shortcut_keyboard_shortcuts')}
</OGDialogTitle>
<p className="text-[13px] text-text-secondary">
{localize('com_shortcut_dialog_subtitle')}
</p>
</div>
<OGDialogClose className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-text-secondary transition-colors hover:bg-surface-active-alt hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<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="grid flex-1 grid-cols-1 gap-x-8 overflow-y-auto px-4 pb-2 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 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>
<footer className="bg-surface-primary-alt/50 flex shrink-0 items-center justify-between border-t border-border-light px-7 py-3.5">
<button
type="button"
disabled={!hasAnyCustom}
onClick={resetAll}
className={cn(
'inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[12.5px] font-medium transition-all',
hasAnyCustom
? 'text-text-secondary hover:bg-surface-active-alt hover:text-text-primary'
: 'cursor-not-allowed text-text-secondary opacity-40',
)}
>
<RotateCcw className="h-3.5 w-3.5" />
{localize('com_shortcut_reset_all')}
</button>
<div className="flex items-center gap-2.5">
<span className="text-[12.5px] font-medium text-text-secondary">
{localize('com_shortcut_show_shortcuts')}
</span>
<ShortcutKeyCombo display={showShortcutsDisplay} />
</div>
</footer>
{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>
);

View file

@ -46,7 +46,7 @@ export default function NavToggle({
}
const ariaDescription = localize(actionKey, { 0: sidebarLabel });
const shortcutId = side === 'left' ? 'toggleSidebar' : 'toggleRightSidebar';
const shortcutId = side === 'left' ? 'toggleSidebar' : undefined;
const tooltipDescription = useShortcutHint(shortcutId, ariaDescription);
const ariaKey = useShortcutAriaKey(shortcutId);

View file

@ -9,7 +9,7 @@ function ShortcutKbd({ children, className = '' }: { children: ReactNode; classN
return (
<kbd
className={cn(
'inline-flex h-[26px] min-w-[26px] items-center justify-center rounded-[7px] border border-border-medium bg-surface-primary-alt px-1.5 font-sans text-[11.5px] font-semibold leading-none text-text-primary shadow-[0_1px_0_0_rgba(0,0,0,0.06),inset_0_-1px_0_0_rgba(0,0,0,0.04)] dark:shadow-[0_1px_0_0_rgba(255,255,255,0.04),inset_0_-1px_0_0_rgba(0,0,0,0.4)]',
'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,
)}
>

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ArrowRight, X } from 'lucide-react';
import { X } from 'lucide-react';
import type { RefObject } from 'react';
import type { ShortcutBinding } from '~/utils/shortcuts';
import {
@ -171,6 +171,12 @@ export function RecorderPill({
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}
@ -182,24 +188,13 @@ export function RecorderPill({
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
className={cn(
'group relative flex h-[30px] items-center gap-1.5 rounded-lg px-2.5 outline-none transition-all duration-200',
'ring-2 ring-offset-2 ring-offset-surface-primary',
hasConflict
? 'bg-amber-500/10 ring-amber-500/50'
: showInvalid
? 'animate-shortcut-shake bg-red-500/10 ring-red-500/50'
: 'bg-blue-500/10 ring-blue-500/50',
'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,
)}
>
<span
aria-hidden="true"
className={cn(
'inline-block h-1.5 w-1.5 shrink-0 animate-pulse rounded-full',
hasConflict ? 'bg-amber-500' : showInvalid ? 'bg-red-500' : 'bg-blue-500',
)}
/>
{showHint ? (
<span className="text-[11.5px] font-medium text-text-secondary">
<span className="text-[11.5px] text-text-secondary">
{localize('com_shortcut_recorder_placeholder')}
</span>
) : (
@ -227,29 +222,26 @@ export function RecorderInfo({
return (
<div
id={`${ownerId}-recorder-hint`}
className="flex w-full items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2"
className="flex flex-wrap items-center justify-end gap-x-2 gap-y-1 pl-1 text-[11.5px]"
>
<span className="text-[12.5px] text-text-primary">
<span className="font-medium text-text-secondary">
{localize('com_shortcut_recorder_conflict_prefix')}
</span>{' '}
<span className="font-semibold">{conflict.conflictLabel}</span>
<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 items-center gap-1.5">
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onTryAgain}
className="inline-flex items-center rounded-md px-2.5 py-1 text-[12px] font-medium text-text-secondary transition-colors hover:bg-surface-active-alt hover:text-text-primary"
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="inline-flex items-center gap-1 rounded-md bg-amber-500 px-3 py-1 text-[12px] font-semibold text-white shadow-sm transition-colors hover:bg-amber-600"
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')}
<ArrowRight className="h-3 w-3" />
</button>
</div>
</div>
@ -257,21 +249,22 @@ export function RecorderInfo({
}
return (
<div id={`${ownerId}-recorder-hint`} className="flex items-center justify-end gap-2">
{showInvalid ? (
<span className="text-[11.5px] font-medium text-red-600 dark:text-red-400">
{localize('com_shortcut_recorder_needs_modifier')}
</span>
) : (
<span className="text-[11.5px] font-medium text-text-secondary">
{localize('com_shortcut_recorder_hint')}
</span>
)}
<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="ml-1 inline-flex h-5 w-5 items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-surface-active-alt hover:text-text-primary"
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>

View file

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

View file

@ -81,14 +81,6 @@ export const shortcutDefinitions = {
ariaMac: 'Meta+Shift+S',
ariaOther: 'Control+Shift+S',
},
toggleRightSidebar: {
labelKey: 'com_shortcut_toggle_right_sidebar',
groupKey: 'com_shortcut_group_navigation',
displayMac: '⌘ ⇧ R',
displayOther: 'Ctrl+Shift+R',
ariaMac: 'Meta+Shift+R',
ariaOther: 'Control+Shift+R',
},
openModelSelector: {
labelKey: 'com_shortcut_open_model_selector',
groupKey: 'com_shortcut_group_navigation',
@ -185,6 +177,102 @@ export const shortcutDefinitions = {
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<string, ShortcutDefinition>;
export type ShortcutActionId = keyof typeof shortcutDefinitions;
@ -196,6 +284,15 @@ export type ShortcutAction = ShortcutDefinition & {
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"]');
}
@ -304,11 +401,6 @@ export function useShortcutActions(): ShortcutAction[] {
setSidebarExpanded((prev) => !prev);
}, [setSidebarExpanded]);
const handleToggleRightSidebar = useCallback(() => {
const btn = document.querySelector<HTMLButtonElement>('[data-testid="parameters-button"]');
btn?.click();
}, []);
const handleOpenModelSelector = useCallback(() => {
const btn = document.querySelector<HTMLButtonElement>('[data-testid="model-selector-button"]');
btn?.click();
@ -469,6 +561,46 @@ export function useShortcutActions(): ShortcutAction[] {
navigate,
]);
const handleSubmitMessage = useCallback(() => {
const btn = document.querySelector<HTMLButtonElement>('[data-testid="send-button"]');
if (btn && !btn.disabled) {
btn.click();
}
}, []);
const handleContinueResponse = useCallback(() => {
const btn = document.querySelector<HTMLButtonElement>(
'[data-testid="continue-generation-button"]',
);
btn?.click();
}, []);
const handleReadAloudLastResponse = useCallback(() => {
const btn = document.querySelector<HTMLButtonElement>('[data-testid="read-aloud-button"]');
btn?.click();
}, []);
const handleBookmarkConversation = useCallback(() => {
const btn =
document.querySelector<HTMLButtonElement>('[data-testid="bookmark-menu"]') ??
document.getElementById('bookmark-menu-button');
(btn as HTMLButtonElement | null)?.click();
}, []);
const handleOpenPanel = useCallback((panelId: string) => {
const btn = document.querySelector<HTMLButtonElement>(`[data-testid="nav-panel-${panelId}"]`);
btn?.click();
}, []);
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<Record<ShortcutActionId, () => void>>(
() => ({
showShortcuts: handleShowShortcuts,
@ -477,7 +609,6 @@ export function useShortcutActions(): ShortcutAction[] {
copyLastResponse: handleCopyLastResponse,
uploadFile: handleUploadFile,
toggleSidebar: handleToggleSidebar,
toggleRightSidebar: handleToggleRightSidebar,
openModelSelector: handleOpenModelSelector,
focusSearch: handleFocusSearch,
openSettings: handleOpenSettings,
@ -490,6 +621,18 @@ export function useShortcutActions(): ShortcutAction[] {
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,
@ -498,7 +641,6 @@ export function useShortcutActions(): ShortcutAction[] {
handleCopyLastResponse,
handleUploadFile,
handleToggleSidebar,
handleToggleRightSidebar,
handleOpenModelSelector,
handleFocusSearch,
handleOpenSettings,
@ -511,6 +653,18 @@ export function useShortcutActions(): ShortcutAction[] {
handleToggleTemporaryChat,
handleArchiveConversation,
handleDeleteConversation,
handleSubmitMessage,
handleBookmarkConversation,
handleContinueResponse,
handleReadAloudLastResponse,
handleOpenAssistants,
handleOpenAgents,
handleOpenPrompts,
handleOpenMemories,
handleOpenParameters,
handleOpenFiles,
handleOpenBookmarks,
handleOpenMCP,
],
);
@ -674,7 +828,12 @@ export default function useKeyboardShortcuts() {
const isEditing =
tagName === 'INPUT' || tagName === 'TEXTAREA' || target?.isContentEditable === true;
const allowedWhileEditing: ShortcutActionId[] = ['focusChat', 'focusSearch', 'showShortcuts'];
const allowedWhileEditing: ShortcutActionId[] = [
'focusChat',
'focusSearch',
'showShortcuts',
'submitMessage',
];
if (isEditing && !allowedWhileEditing.includes(matchedId)) {
return;
}

View file

@ -611,6 +611,8 @@
"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",
@ -622,9 +624,20 @@
"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_not_set": "Not set",
"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",
@ -633,12 +646,14 @@
"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_reset_aria": "Reset shortcut for {{0}} to default",
"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_toggle_right_sidebar": "Toggle right sidebar",
"com_shortcut_submit_message": "Submit message",
"com_shortcut_toggle_sidebar": "Toggle sidebar",
"com_shortcut_upload_file": "Upload file",
"com_show_examples": "Show Examples",