feat: add persisted shortcuts denylist with UI for masking shortcuts

Add a persisted shortcuts preferences store that allows users to disable
specific keyboard shortcuts via the Shortcuts modal (Ctrl+Shift+?).
Each shortcut now has a toggle button - disabled shortcuts show a block
icon, strikethrough description, and are skipped by both the global
handler and the Composer's hardcoded shortcuts (Ctrl+Enter, Alt+Enter).
Denied shortcuts are also hidden from the StatusBar.

Closes #895

Co-authored-by: Enrico Ros <enricoros@users.noreply.github.com>
This commit is contained in:
claude[bot]
2026-04-24 04:24:56 +00:00
parent 9bb178413b
commit dace3cacf3
5 changed files with 111 additions and 17 deletions
+6 -2
View File
@@ -10,6 +10,7 @@ import { ShortcutKey, ShortcutObject } from '~/common/components/shortcuts/useGl
import { ConfirmationModal } from '~/common/components/modals/ConfirmationModal';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { useGlobalShortcutsStore } from '~/common/components/shortcuts/store-global-shortcuts';
import { useShortcutsPreferencesStore, shortcutFingerprint } from '~/common/components/shortcuts/store-shortcuts-preferences';
import { useOverlayComponents } from '~/common/layout/overlays/useOverlayComponents';
import { useUXLabsStore } from '~/common/stores/store-ux-labs';
@@ -176,9 +177,12 @@ export function StatusBar(props: { toggleMinimized?: () => void, isMinimized?: b
// external state
const labsShowShortcutBar = useUXLabsStore(state => state.labsShowShortcutBar);
const disabledShortcuts = useShortcutsPreferencesStore(state => state.disabledShortcuts);
const shortcuts = useGlobalShortcutsStore(useShallow(state => {
// get visible shortcuts
let visibleShortcuts = !labsShowShortcutBar ? [] : state.getAllShortcuts().filter(shortcut => !!shortcut.description);
// get visible shortcuts, excluding user-denied ones
let visibleShortcuts = !labsShowShortcutBar ? [] : state.getAllShortcuts().filter(shortcut =>
!!shortcut.description && !disabledShortcuts.includes(shortcutFingerprint(shortcut)),
);
// filter by highest level if levels are present
const maxLevel = Math.max(...visibleShortcuts.map(s => s.level ?? 0));
+11 -6
View File
@@ -26,6 +26,7 @@ import { ConversationsManager } from '~/common/chat-overlay/ConversationsManager
import { DMessageId, DMessageMetadata, DMetaReferenceItem, messageFragmentsReduceText } from '~/common/stores/chat/chat.message';
import { PhPaintBrush } from '~/common/components/icons/phosphor/PhPaintBrush';
import { ShortcutKey, ShortcutObject, useGlobalShortcuts } from '~/common/components/shortcuts/useGlobalShortcuts';
import { isShortcutDenied } from '~/common/components/shortcuts/store-shortcuts-preferences';
import { addSnackbar } from '~/common/components/snackbar/useSnackbarsStore';
import { animationEnterBelow } from '~/common/util/animUtils';
import { browserSpeechRecognitionCapability, PLACEHOLDER_INTERIM_TRANSCRIPT, SpeechResult, useSpeechRecognition } from '~/common/components/speechrecognition/useSpeechRecognition';
@@ -545,16 +546,20 @@ export function Composer(props: {
// Alt (Windows) or Option (Mac) + Enter: append the message instead of sending it
if (e.altKey && !e.metaKey && !e.ctrlKey) {
if (await handleSendAction('append-user', composeText)) // 'alt+enter' -> write
e.stopPropagation();
return e.preventDefault();
if (!isShortcutDenied({ key: 'Enter', alt: true })) {
if (await handleSendAction('append-user', composeText)) // 'alt+enter' -> write
e.stopPropagation();
return e.preventDefault();
}
}
// Ctrl (Windows) or Command (Mac) + Enter: send for beaming
if (e.ctrlKey && !e.metaKey && !e.altKey) {
if (await handleSendAction('beam-content', composeText)) // 'ctrl+enter' -> beam
e.stopPropagation();
return e.preventDefault();
if (!isShortcutDenied({ key: 'Enter', ctrl: true })) {
if (await handleSendAction('beam-content', composeText)) // 'ctrl+enter' -> beam
e.stopPropagation();
return e.preventDefault();
}
}
// Shift: toggles the 'enter is newline'
+46 -9
View File
@@ -1,10 +1,13 @@
import * as React from 'react';
import { Box, Chip, Divider, Typography } from '@mui/joy';
import { Box, Chip, Divider, IconButton, Tooltip, Typography } from '@mui/joy';
import BlockIcon from '@mui/icons-material/Block';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import { GoodModal } from '~/common/components/modals/GoodModal';
import type { ShortcutDefinition } from '~/common/components/shortcuts/useGlobalShortcuts';
import { shortcutsCatalog } from '~/common/components/shortcuts/shortcutsCatalog';
import { shortcutFingerprint, useShortcutsPreferencesStore } from '~/common/components/shortcuts/store-shortcuts-preferences';
import { useGlobalShortcutsStore } from '~/common/components/shortcuts/store-global-shortcuts';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { Is } from '~/common/util/pwaUtils';
@@ -36,6 +39,11 @@ const _styles = {
justifyContent: 'space-between',
gap: 1,
},
rowLeft: {
display: 'flex',
alignItems: 'center',
gap: 1,
},
keys: {
display: 'flex',
gap: 0.5,
@@ -111,6 +119,42 @@ function ShortcutKeyCombo(props: { def: ShortcutDefinition }) {
}
function ShortcutRow(props: { item: typeof shortcutsCatalog[number]['items'][number], active: boolean }) {
const { item, active } = props;
const fp = shortcutFingerprint(item);
const isDenied = useShortcutsPreferencesStore((state) => state.disabledShortcuts.includes(fp));
const handleToggle = React.useCallback(() => {
useShortcutsPreferencesStore.getState().toggleShortcutDisabled(fp);
}, [fp]);
const effectivelyActive = active && !isDenied;
return (
<Box sx={_styles.row}>
<Box sx={_styles.rowLeft}>
<Tooltip title={isDenied ? 'Enable shortcut' : 'Disable shortcut'} variant='soft'>
<IconButton
size='sm'
variant={isDenied ? 'soft' : 'plain'}
color={isDenied ? 'danger' : 'neutral'}
onClick={handleToggle}
sx={{ minWidth: 28, minHeight: 28 }}
>
{isDenied ? <BlockIcon sx={{ fontSize: 16 }} /> : <CheckCircleOutlineIcon sx={{ fontSize: 16, opacity: 0.5 }} />}
</IconButton>
</Tooltip>
<ShortcutKeyCombo def={item} />
</Box>
<Typography level='body-xs' sx={!effectivelyActive ? { opacity: 0.5, textDecoration: isDenied ? 'line-through' : undefined } : undefined}>
{item.description}
</Typography>
</Box>
);
}
export function ShortcutsModal(props: { onClose: () => void }) {
// external state
@@ -130,14 +174,7 @@ export function ShortcutsModal(props: { onClose: () => void }) {
</Typography>
{category.items.map((item, i) => {
const active = _isActive(item, activeFingerprints);
return (
<Box key={i} sx={_styles.row}>
<ShortcutKeyCombo def={item} />
<Typography level='body-xs' sx={!active ? { opacity: 0.5 } : undefined}>
{item.description}
</Typography>
</Box>
);
return <ShortcutRow key={i} item={item} active={active} />;
})}
</React.Fragment>
))}
@@ -1,4 +1,5 @@
import { useGlobalShortcutsStore } from './store-global-shortcuts';
import { isShortcutDenied } from './store-shortcuts-preferences';
export function ensureGlobalShortcutHandler() {
@@ -41,6 +42,10 @@ function _handleGlobalShortcutKeyDown(event: KeyboardEvent) {
if (shortcut.skipIfInput && _isTextInputFocused())
continue;
// Skip if the shortcut is on the user's denylist
if (isShortcutDenied(shortcut))
continue;
// Execute the action (and prevent the default browser action)
event.preventDefault();
event.stopPropagation();
@@ -0,0 +1,43 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { ShortcutDefinition } from './useGlobalShortcuts';
export function shortcutFingerprint(def: Pick<ShortcutDefinition, 'key' | 'ctrl' | 'shift' | 'alt'>): string {
return `${def.key.toLowerCase()}:${!!def.ctrl}:${!!def.shift}:${!!def.alt}`;
}
interface ShortcutsPreferencesStore {
disabledShortcuts: string[];
toggleShortcutDisabled: (fingerprint: string) => void;
}
export const useShortcutsPreferencesStore = create<ShortcutsPreferencesStore>()(
persist(
(set) => ({
disabledShortcuts: [],
toggleShortcutDisabled: (fingerprint: string) =>
set((state) => {
const idx = state.disabledShortcuts.indexOf(fingerprint);
if (idx >= 0)
return { disabledShortcuts: state.disabledShortcuts.filter((f) => f !== fingerprint) };
return { disabledShortcuts: [...state.disabledShortcuts, fingerprint] };
}),
}),
{
name: 'app-shortcuts-preferences',
version: 1,
},
),
);
export function isShortcutDenied(def: Pick<ShortcutDefinition, 'key' | 'ctrl' | 'shift' | 'alt'>): boolean {
const fp = shortcutFingerprint(def);
return useShortcutsPreferencesStore.getState().disabledShortcuts.includes(fp);
}