feat(hotkey-recorder): 快捷键录入交互重构——主行按钮移除、箭头右对齐、动画优化

- 未展开主行只留键帽 + 下拉箭头,录入入口统一在展开菜单里(录制快捷键/重置/停用)
- 箭头恒靠最右;菜单展开/收起高度改为固定值动画,多次切换不再卡顿
- ShortcutRecorder 支持 value=null(问答/切风格/唤起 App 未启用时直接录入即启用)
- 菜单支持 Esc / 点击外部收起;滑动方向统一向右
- 新增 defaultDictationHotkey()(RightControl,与 mock/Rust 默认一致)
This commit is contained in:
sim authored and TRIP committed 2026-08-03 11:15:30 +08:00
1 parent e484cd11cf
commit e12c8dcc20
4 files changed
+302 -217

No files matched your search

@@ -1,41 +1,76 @@
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { formatComboParts, getHotkeyTriggerLabel, modifiersFromPressedCodes, shortcutFromLegacyTrigger } from '../lib/hotkey';
import { formatComboParts, modifiersFromPressedCodes } from '../lib/hotkey';
import { KbdGroup } from './Kbd';
import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc';
import type { HotkeyTrigger, ShortcutBinding } from '../lib/types';
import type { ShortcutBinding } from '../lib/types';
/** 主行与「正在录入」面板切换时的水平滑动距离(px)。 */
const SLIDE_DISTANCE = 48;
/** 下拉菜单展开后的固定高度(px):菜单按钮行高恒定,用固定值动画避免每次测量。 */
const MENU_HEIGHT = 34;
/** 滑动切换用 spring(与 Style.tsx 编辑抽屉同款)。只动 transform/opacity,不驱动布局,避免抽搐。 */
const slideSpring = { type: 'spring' as const, damping: 26, stiffness: 280 };
/** 下拉菜单展开/收起缓动,与 --ol-motion-soft 一致。 */
const menuEase = [0.22, 0.8, 0.22, 1] as const;
export function ShortcutRecorder({
value,
onSave,
alignRecordButton = false,
disabled = false,
onDisable,
disableLabel,
disableDisabled = false,
disableHint,
onReset,
resetLabel,
comboOnly = false,
modifierPresets = [],
sideSpecificModifiers = false,
}: {
value: ShortcutBinding;
value: ShortcutBinding | null;
onSave: (binding: ShortcutBinding) => Promise<void>;
alignRecordButton?: boolean;
disabled?: boolean;
/** 提供则在「录制」按钮左侧并排渲染一个「停用」旋钮。 */
/** 提供则下拉菜单里的「停用」可点(问答/切风格等可停用快捷键)。 */
onDisable?: () => void | Promise<void>;
disableLabel?: string;
/** 置灰「停用」(核心快捷键不可停用),配合 disableHint 展示原因。 */
disableDisabled?: boolean;
disableHint?: string;
/** 提供则下拉菜单里渲染「重置」——恢复该快捷键的默认绑定。 */
onReset?: () => void | Promise<void>;
resetLabel?: string;
/** 仅允许组合键(修饰键+主键 / 功能键);拒绝单修饰键,因为全局热键无法注册它。 */
comboOnly?: boolean;
/** 平台可用的单修饰键快捷选项(如 Fn,WebView 无法录制)。 */
modifierPresets?: HotkeyTrigger[];
/** 听写 start/stop 专用:录制 cmd-left / ctrl-right 等侧向修饰键。 */
sideSpecificModifiers?: boolean;
}) {
const { t } = useTranslation();
const [recording, setRecording] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const pendingModifier = useRef<ShortcutBinding | null>(null);
const pendingTimer = useRef<number | null>(null);
const pressedCodes = useRef<Set<string>>(new Set());
const rootRef = useRef<HTMLDivElement | null>(null);
// 菜单打开时:Esc 或点击菜单外部即收起,避免菜单"赖着不关"。
useEffect(() => {
if (!menuOpen) return;
const onKeyDown = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') setMenuOpen(false);
};
const onPointerDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setMenuOpen(false);
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('mousedown', onPointerDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('mousedown', onPointerDown);
};
}, [menuOpen]);
const clearPressedCodes = () => {
pressedCodes.current.clear();
@@ -84,6 +119,15 @@ export function ShortcutRecorder({
}
};
/** 开始录入:同时收起菜单——「录制快捷键」按下后,重置/停用两个按钮随之消失。 */
const startRecording = () => {
if (disabled || recording) return;
setMenuOpen(false);
setError(null);
resetRecordingState();
setRecording(true);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!recording || disabled) return;
e.preventDefault();
@@ -132,123 +176,204 @@ export function ShortcutRecorder({
}
};
const doReset = () => {
setMenuOpen(false);
setError(null);
if (onReset) void onReset();
};
const doDisable = () => {
setMenuOpen(false);
setError(null);
if (onDisable) void onDisable();
};
// 「停用」可点:有 onDisable 且未被置灰(录音快捷键置灰,核心热键不可停用)。
const canDisable = Boolean(onDisable) && !disableDisabled;
const rootStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
width: alignRecordButton ? '100%' : undefined,
width: '100%',
};
const recorderRowStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
width: alignRecordButton ? '100%' : undefined,
width: '100%',
};
const recordButtonStyle: CSSProperties = {
fontSize: 12,
padding: '5px 14px',
background: recording ? 'rgba(37,99,235,0.12)' : 'var(--ol-blue)',
color: recording ? 'var(--ol-blue)' : '#fff',
const chevronButtonStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 26,
height: 26,
padding: 0,
border: 0,
borderRadius: 6,
fontFamily: 'inherit',
fontWeight: 500,
cursor: recording || disabled ? 'default' : 'pointer',
opacity: disabled ? 0.68 : 1,
};
const disableKnobStyle: CSSProperties = {
fontSize: 12,
padding: '5px 12px',
background: 'transparent',
color: 'var(--ol-ink-4)',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 6,
fontFamily: 'inherit',
fontWeight: 500,
cursor: recording ? 'default' : 'pointer',
cursor: 'pointer',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
};
const controlsGroupStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 8,
marginLeft: alignRecordButton ? 'auto' : undefined,
gap: 4,
marginLeft: 'auto',
};
const presetChipStyle: CSSProperties = {
fontSize: 11,
padding: '4px 10px',
borderRadius: 999,
const menuButtonStyle: CSSProperties = {
fontSize: 12,
padding: '5px 12px',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 6,
background: 'transparent',
color: 'var(--ol-ink-3)',
color: 'var(--ol-ink-2)',
fontFamily: 'inherit',
cursor: disabled || recording ? 'default' : 'pointer',
fontWeight: 500,
cursor: 'pointer',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
};
const menuPrimaryStyle: CSSProperties = {
...menuButtonStyle,
background: 'rgba(37,99,235,0.08)',
borderColor: 'rgba(37,99,235,0.25)',
color: 'var(--ol-blue)',
};
const disabledMenuButtonStyle: CSSProperties = {
...menuButtonStyle,
opacity: 0.45,
cursor: 'default',
};
const menuRowStyle: CSSProperties = {
display: 'flex',
gap: 8,
paddingTop: 2,
};
const presetTriggers = modifierPresets.filter(t => t !== 'custom' && t !== 'mediaPlayPause');
return (
<div style={rootStyle}>
<div style={recorderRowStyle}>
{/* 键帽逐键展示(Kbd 组件,用户拍板的快捷键展示标准),替代整块灰底文本。 */}
<KbdGroup keys={formatComboParts(value)} />
<div style={controlsGroupStyle}>
{onDisable && (
<button
onClick={() => {
if (recording) return;
void onDisable();
}}
disabled={recording}
style={disableKnobStyle}
>
{disableLabel ?? t('settings.shortcuts.disable', 'Disable')}
</button>
)}
<button
onClick={() => {
if (disabled) return;
setRecording(true);
setError(null);
resetRecordingState();
<div style={rootStyle} ref={rootRef}>
{/* mode="wait":主行与「正在录入」面板不重叠渲染;切换只做 transform/opacity 动画,
不驱动布局,面板运动过程不抽搐。所有滑入/滑出统一向右。 */}
<AnimatePresence mode="wait" initial={false}>
{recording ? (
<motion.div
key="recording"
initial={{ x: SLIDE_DISTANCE, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: SLIDE_DISTANCE, opacity: 0 }}
transition={slideSpring}
tabIndex={-1}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
ref={el => el?.focus()}
style={{
minHeight: 36,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '8px 12px',
borderRadius: 8,
background: 'rgba(37,99,235,0.06)',
border: '1px solid rgba(37,99,235,0.2)',
fontSize: 12,
color: 'var(--ol-blue)',
outline: 'none',
}}
disabled={recording || disabled}
style={recordButtonStyle}
>
{recording ? t('settings.recording.comboRecordHint') : t('settings.recording.comboRecordBtn')}
</button>
</div>
</div>
{!comboOnly && presetTriggers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', alignSelf: 'center' }}>
{t('settings.recording.modifierPresetsLabel', '常用单键:')}
</span>
{presetTriggers.map(trigger => (
<button
key={trigger}
type="button"
disabled={disabled || recording}
style={presetChipStyle}
onClick={() => void finish(shortcutFromLegacyTrigger(trigger))}
>
{getHotkeyTriggerLabel(trigger)}
</button>
))}
</div>
)}
{recording && (
<div
tabIndex={-1}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
style={{ padding: '8px 12px', borderRadius: 8, background: 'rgba(37,99,235,0.06)', border: '1px solid rgba(37,99,235,0.2)', fontSize: 12, color: 'var(--ol-blue)', outline: 'none' }}
ref={el => el?.focus()}
>
{t('settings.recording.comboRecordHint')}
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>Esc 取消</div>
</div>
)}
{t('settings.recording.comboRecordHint')}
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>Esc 取消</div>
</motion.div>
) : (
<motion.div
key="idle"
initial={{ x: SLIDE_DISTANCE, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: SLIDE_DISTANCE, opacity: 0 }}
transition={slideSpring}
>
<div style={recorderRowStyle}>
{/* 键帽逐键展示(Kbd 组件,用户拍板的快捷键展示标准),替代整块灰底文本。
录入入口在展开菜单里(录制快捷键);主行只留箭头,统一靠最右。 */}
{value && <KbdGroup keys={formatComboParts(value)} />}
<div style={controlsGroupStyle}>
<motion.button
whileTap={{ scale: 0.9 }}
onClick={() => setMenuOpen(open => !open)}
aria-label={t('settings.recording.comboMenuToggle', 'More options')}
aria-expanded={menuOpen}
title={t('settings.recording.comboMenuToggle', 'More options')}
style={chevronButtonStyle}
>
<motion.span
animate={{ rotate: menuOpen ? 180 : 0 }}
transition={{ duration: 0.16, ease: menuEase }}
style={{ display: 'inline-flex' }}
>
<ChevronDown size={14} strokeWidth={2.2} />
</motion.span>
</motion.button>
</div>
</div>
{/* 下拉菜单:整个板块向下展开,出现 录制快捷键 / 重置 / 停用 三个按钮。
高度用固定值动画(菜单内容高度恒定),避免 'auto' 每次测量带来的卡顿。 */}
<AnimatePresence initial={false}>
{menuOpen && (
<motion.div
key="menu"
initial={{ height: 0, opacity: 0 }}
animate={{ height: MENU_HEIGHT, opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.16, ease: menuEase }}
style={{ overflow: 'hidden' }}
>
<div style={menuRowStyle}>
<motion.button
initial={{ y: 4, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.16, ease: menuEase }}
whileHover={{ y: -1 }}
whileTap={{ scale: 0.96 }}
onClick={startRecording}
style={menuPrimaryStyle}
>
{t('settings.recording.comboRecordBtn')}
</motion.button>
{onReset && (
<motion.button
initial={{ y: 4, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.16, ease: menuEase, delay: 0.03 }}
whileHover={{ y: -1 }}
whileTap={{ scale: 0.96 }}
onClick={doReset}
style={menuButtonStyle}
>
{resetLabel ?? t('settings.recording.comboResetBtn', 'Reset')}
</motion.button>
)}
<motion.button
initial={{ y: 4, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.16, ease: menuEase, delay: 0.06 }}
whileHover={canDisable ? { y: -1 } : undefined}
whileTap={canDisable ? { scale: 0.96 } : undefined}
onClick={canDisable ? doDisable : undefined}
title={canDisable ? undefined : disableHint}
style={canDisable ? menuButtonStyle : disabledMenuButtonStyle}
>
{disableLabel ?? t('settings.shortcuts.disable', 'Disable')}
</motion.button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
{error && <div style={{ fontSize: 11, color: 'var(--ol-red, #ef4444)' }}>{error}</div>}
</div>
);
+6
View File
@@ -26,6 +26,12 @@ export function defaultLessComputerShortcut(): ShortcutBinding {
return { primary: 'LeftControl', modifiers: [] };
}
// 默认录音快捷键:右侧 Control。与 mock-data 默认值 / Rust legacy trigger
// (shortcut_binding.rs 的 rightControl 映射)保持一致。
export function defaultDictationHotkey(): ShortcutBinding {
return { primary: 'RightControl', modifiers: [] };
}
export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined): string {
if (!trigger) return i18n.t('hotkey.fallback');
if (trigger === 'custom') return i18n.t('hotkey.triggers.custom');
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ShortcutRecorder } from '../../components/ShortcutRecorder';
import { playRecordStartCue } from '../../lib/audioCue';
import { defaultDictationHotkey } from '../../lib/hotkey';
import { emitSaved } from '../../lib/savedEvent';
import { isHotkeyModeMigrationNoticeActive } from '../../lib/hotkeyMigration';
import {
@@ -224,12 +225,19 @@ export function RecordingInputSection() {
<SettingRow label={t('settings.recording.hotkeyLabel')}>
<ShortcutRecorder
value={prefs.dictationHotkey}
modifierPresets={capability?.availableTriggers ?? []}
sideSpecificModifiers
// 录音快捷键是核心热键,Rust 端不接受 null,不可停用——置灰并提示。
disableDisabled
disableHint={t('settings.recording.comboDisableHint')}
onSave={async binding => {
await setDictationHotkey(binding);
await savePrefs({ ...prefs, dictationHotkey: binding });
}}
onReset={async () => {
const binding = defaultDictationHotkey();
await setDictationHotkey(binding);
await savePrefs({ ...prefs, dictationHotkey: binding });
}}
/>
</SettingRow>
)}
@@ -1,7 +1,6 @@
// 快捷键设置:开始/停止、翻译、问答、切风格、唤起 App、以及只读取消/确认提示。
import { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { ShortcutRecorder } from '../../components/ShortcutRecorder';
import {
@@ -25,30 +24,17 @@ import { Card } from '../_atoms';
import { SettingRow } from './shared';
import { detectOS } from '../../components/WindowChrome';
const enableBtnStyle: CSSProperties = {
alignSelf: 'flex-start',
fontSize: 12,
padding: '5px 14px',
background: 'var(--ol-blue)',
color: '#fff',
border: 0,
borderRadius: 6,
fontFamily: 'inherit',
fontWeight: 500,
cursor: 'pointer',
};
export function ShortcutsSection() {
const { t } = useTranslation();
const os = detectOS();
const { prefs, hotkey, capability, updatePrefs: savePrefs } = useHotkeySettings();
const { prefs, hotkey, updatePrefs: savePrefs } = useHotkeySettings();
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
useEffect(() => {
void getPlatformCapabilities().then(setPlatformCaps);
}, []);
if (!prefs || !hotkey || !capability) {
if (!prefs || !hotkey) {
return (
<Card>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
@@ -73,9 +59,10 @@ export function ShortcutsSection() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
<ShortcutRecorder
value={prefs.dictationHotkey}
alignRecordButton
sideSpecificModifiers
modifierPresets={capability?.availableTriggers ?? []}
// 与「录音与输入」页一致:核心热键不可停用,置灰并提示。
disableDisabled
disableHint={t('settings.recording.comboDisableHint')}
onSave={async binding => {
await setDictationHotkey(binding);
await savePrefs({ ...prefs, dictationHotkey: binding });
@@ -89,7 +76,6 @@ export function ShortcutsSection() {
<SettingRow label={t('translation.hotkey.title', 'Translation shortcut')}>
<ShortcutRecorder
value={prefs.translationHotkey}
alignRecordButton
onSave={async binding => {
await setTranslationHotkey(binding);
await savePrefs({ ...prefs, translationHotkey: binding });
@@ -97,113 +83,73 @@ export function ShortcutsSection() {
/>
</SettingRow>
<SettingRow label={t('selectionAsk.hotkey.title')}>
{prefs.qaHotkey ? (
<ShortcutRecorder
value={prefs.qaHotkey}
alignRecordButton
onSave={async binding => {
await setQaHotkey(binding);
await savePrefs({ ...prefs, qaHotkey: binding });
}}
onDisable={async () => {
await setQaHotkey(null);
await savePrefs({ ...prefs, qaHotkey: null });
}}
/>
) : (
<button
onClick={async () => {
const binding = defaultQaShortcut();
await setQaHotkey(binding);
await savePrefs({ ...prefs, qaHotkey: binding });
}}
style={{ fontSize: 12, padding: '5px 14px', background: 'var(--ol-blue)', color: '#fff', border: 0, borderRadius: 6, fontFamily: 'inherit', fontWeight: 500, cursor: 'default' }}
>
{t('selectionAsk.hotkey.enable', 'Enable')}
</button>
)}
<ShortcutRecorder
value={prefs.qaHotkey}
onSave={async binding => {
await setQaHotkey(binding);
await savePrefs({ ...prefs, qaHotkey: binding });
}}
onDisable={async () => {
await setQaHotkey(null);
await savePrefs({ ...prefs, qaHotkey: null });
}}
onReset={async () => {
const binding = defaultQaShortcut();
await setQaHotkey(binding);
await savePrefs({ ...prefs, qaHotkey: binding });
}}
/>
</SettingRow>
<SettingRow label={t('settings.shortcuts.switchStyle')}>
{prefs.switchStyleHotkey ? (
<ShortcutRecorder
value={prefs.switchStyleHotkey}
alignRecordButton
onSave={async binding => {
await setSwitchStyleHotkey(binding);
await savePrefs({ ...prefs, switchStyleHotkey: binding });
}}
onDisable={async () => {
await setSwitchStyleHotkey(null);
await savePrefs({ ...prefs, switchStyleHotkey: null });
}}
/>
) : (
<button
onClick={async () => {
const binding = defaultSwitchStyleShortcut();
await setSwitchStyleHotkey(binding);
await savePrefs({ ...prefs, switchStyleHotkey: binding });
}}
style={enableBtnStyle}
>
{t('settings.shortcuts.enable', 'Enable')}
</button>
)}
<ShortcutRecorder
value={prefs.switchStyleHotkey}
onSave={async binding => {
await setSwitchStyleHotkey(binding);
await savePrefs({ ...prefs, switchStyleHotkey: binding });
}}
onDisable={async () => {
await setSwitchStyleHotkey(null);
await savePrefs({ ...prefs, switchStyleHotkey: null });
}}
onReset={async () => {
const binding = defaultSwitchStyleShortcut();
await setSwitchStyleHotkey(binding);
await savePrefs({ ...prefs, switchStyleHotkey: binding });
}}
/>
</SettingRow>
<SettingRow label={t('settings.shortcuts.openApp')}>
{prefs.openAppHotkey ? (
<ShortcutRecorder
value={prefs.openAppHotkey}
alignRecordButton
onSave={async binding => {
await setOpenAppHotkey(binding);
await savePrefs({ ...prefs, openAppHotkey: binding });
}}
onDisable={async () => {
await setOpenAppHotkey(null);
await savePrefs({ ...prefs, openAppHotkey: null });
}}
/>
) : (
<button
onClick={async () => {
const binding = defaultOpenAppShortcut();
await setOpenAppHotkey(binding);
await savePrefs({ ...prefs, openAppHotkey: binding });
}}
style={enableBtnStyle}
>
{t('settings.shortcuts.enable', 'Enable')}
</button>
)}
<ShortcutRecorder
value={prefs.openAppHotkey}
onSave={async binding => {
await setOpenAppHotkey(binding);
await savePrefs({ ...prefs, openAppHotkey: binding });
}}
onDisable={async () => {
await setOpenAppHotkey(null);
await savePrefs({ ...prefs, openAppHotkey: null });
}}
onReset={async () => {
const binding = defaultOpenAppShortcut();
await setOpenAppHotkey(binding);
await savePrefs({ ...prefs, openAppHotkey: binding });
}}
/>
</SettingRow>
{os === 'mac' && (
<SettingRow label={t('settings.codingAgent.title')} desc={t('settings.codingAgent.voiceHotkeyDesc')}>
{prefs.codingAgentEnabled && prefs.codingAgentVoiceHotkey ? (
<ShortcutRecorder
value={prefs.codingAgentVoiceHotkey}
alignRecordButton
onSave={async binding => {
await savePrefs({ ...prefs, codingAgentVoiceHotkey: binding });
}}
onDisable={async () => {
await savePrefs({ ...prefs, codingAgentVoiceHotkey: null });
}}
/>
) : (
<button
onClick={() =>
void savePrefs({
...prefs,
codingAgentEnabled: true,
codingAgentVoiceHotkey: prefs.codingAgentVoiceHotkey ?? defaultLessComputerShortcut(),
})
}
style={enableBtnStyle}
>
{t('settings.shortcuts.enable', 'Enable')}
</button>
)}
<ShortcutRecorder
value={prefs.codingAgentVoiceHotkey}
onSave={async binding => {
await savePrefs({ ...prefs, codingAgentEnabled: true, codingAgentVoiceHotkey: binding });
}}
onDisable={async () => {
await savePrefs({ ...prefs, codingAgentVoiceHotkey: null });
}}
onReset={async () => {
await savePrefs({ ...prefs, codingAgentEnabled: true, codingAgentVoiceHotkey: defaultLessComputerShortcut() });
}}
/>
</SettingRow>
)}
{readonlyRows.map(([k, v]) => (