feat(desktop): add composer dictation keybind

(cherry picked from commit bb6e188bb6fe6b59ac8f829b3461b2b50ef571e2)
This commit is contained in:
KoNit-K
2026-09-17 16:49:35 +08:00
committed by brooklyn!
parent 068db016fb
commit 3cc24b0130
12 changed files with 68 additions and 3 deletions

View File

@@ -7,9 +7,11 @@ import {
focusComposerInput,
getActiveComposer,
markActiveComposer,
onComposerDictationRequest,
onComposerFocusRequest,
onComposerModelMenuRequest,
releaseActiveComposer,
requestComposerDictation,
requestComposerFocus,
requestModelMenuToggle
} from './focus'
@@ -317,3 +319,19 @@ describe('requestModelMenuToggle', () => {
expect(await collectModelMenuTargets()).toEqual([])
})
})
describe('requestComposerDictation', () => {
it('delivers the request only to the active visible composer', async () => {
mountSurface('main', true)
mountSurface('tile:front')
markActiveComposer('tile:front')
const targets: string[] = []
const off = onComposerDictationRequest(target => targets.push(target))
requestComposerDictation('active')
await new Promise(resolve => window.setTimeout(resolve, 0))
off()
expect(targets).toEqual(['tile:front'])
})
})

View File

@@ -50,6 +50,7 @@ const ATTACH_IMAGES_EVENT = 'hermes:composer-attach-images'
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
const SUBMIT_EVENT = 'hermes:composer-submit'
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
const DICTATION_EVENT = 'hermes:composer-dictation'
const MODEL_MENU_EVENT = 'hermes:composer-model-menu'
/** Inline edit composer root — mounted only while a user bubble is being edited. */
@@ -366,6 +367,14 @@ export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active')
export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target))
/** Start or stop dictation on one composer. Like voice conversation, the
* rebindable action targets only the active visible composer. */
export const requestComposerDictation = (target: ComposerTarget | 'active' = 'active') =>
dispatch<{ target: ComposerTarget }>(DICTATION_EVENT, { target: resolve(target) })
export const onComposerDictationRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<{ target: ComposerTarget }>(DICTATION_EVENT, ({ target }) => handler(target))
/** The chat surface inside the zone the pointer is over, if any. Mirrors the
* tab verbs' hover-first targeting (`tabTargetGroupId`, #74447): the model
* hotkey lands in the pane you're pointing at without clicking into it first.

View File

@@ -19,7 +19,7 @@ import { resumeWakeAfterVoice } from '@/store/wake-word'
import { pinFloatingComposerCapture } from '../floating-target'
import type { ComposerTarget } from '../focus'
import { onComposerVoiceToggleRequest } from '../focus'
import { onComposerDictationRequest, onComposerVoiceToggleRequest } from '../focus'
import { useComposerScope, useComposerSurfaceId } from '../scope'
import type { ChatBarProps } from '../types'
@@ -313,6 +313,14 @@ export function useComposerVoice({
[target, toggleVoiceConversation]
)
// The bindable `composer.dictate` action shares the mic button's callback,
// including its recording/transcribing state machine. Ignore disabled
// composers so an unavailable draft cannot acquire the microphone.
useEffect(
() => onComposerDictationRequest(requested => requested === target && !disabled && dictate()),
[dictate, disabled, target]
)
useEffect(() => {
if (target === 'main' && !disabled && takeVoiceConversationStart(voiceStartRequest) && !voiceConversationActive) {
activateConversation()

View File

@@ -69,7 +69,7 @@ import { toggleStatusbarVisible } from '@/store/statusbar-prefs'
import { openNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus'
import { requestComposerDictation, requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus'
import { handleComposerFocusChord } from '../chat/composer/focus-chord'
import { handleWindowPaste } from '../chat/composer/paste-to-focus'
import { openSession } from '../open-session'
@@ -197,6 +197,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
}
},
'composer.voice': requestVoiceToggle,
'composer.dictate': requestComposerDictation,
// On the Settings overlay, ⌘K scopes to settings search; the second press
// (or Esc) still closes as usual via toggle.

View File

@@ -311,6 +311,7 @@ export const ar = defineLocale({
'composer.focus': 'التركيز على المحرّر',
'composer.modelPicker': 'فتح منتقي النموذج',
'composer.voice': 'بدء / إيقاف المحادثة الصوتية',
'composer.dictate': 'بدء / إيقاف الإملاء',
'view.toggleSidebar': 'تبديل الشريط الجانبي للجلسات',
'view.toggleRightSidebar': 'تبديل متصفح الملفات',
'view.toggleReview': 'تبديل لوحة المراجعة',

View File

@@ -661,6 +661,7 @@ export const en: Translations = {
'composer.focus': 'Focus composer',
'composer.modelPicker': 'Open model picker',
'composer.voice': 'Start / stop voice conversation',
'composer.dictate': 'Start / stop dictation',
'view.toggleSidebar': 'Toggle sessions sidebar',
'view.cycleSidebarGrouping': 'Cycle session grouping',
'view.toggleRightSidebar': 'Toggle file browser',

View File

@@ -333,6 +333,7 @@ export const ru = defineLocale({
'composer.focus': 'Сфокусировать композер',
'composer.modelPicker': 'Открыть выбор модели',
'composer.voice': 'Начать / остановить голосовой диалог',
'composer.dictate': 'Начать / остановить диктовку',
'view.toggleSidebar': 'Показать / скрыть панель сеансов',
'view.toggleRightSidebar': 'Показать / скрыть браузер файлов',
'view.toggleReview': 'Показать / скрыть панель ревью',

View File

@@ -379,6 +379,7 @@ export const zh = defineLocale({
'composer.focus': '聚焦输入框',
'composer.modelPicker': '打开模型选择器',
'composer.voice': '开始 / 停止语音对话',
'composer.dictate': '开始 / 停止听写',
'view.toggleSidebar': '切换会话侧边栏',
'view.toggleRightSidebar': '切换文件浏览器',
'view.toggleReview': '切换审查面板',

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { en } from '@/i18n/en'
import { KEYBIND_ACTIONS } from './actions'
import { defaultBindings, KEYBIND_ACTIONS, keybindAction } from './actions'
// Relationship checks between the action table and its consumers, not the
// specific chord or wording any one action ships with.
@@ -19,4 +19,22 @@ describe('KEYBIND_ACTIONS', () => {
expect(missing).toEqual([])
})
it('keeps session archive registered and unbound by default', () => {
const action = keybindAction('session.archive')
expect(action).toMatchObject({ category: 'session', defaults: [] })
expect(defaultBindings()['session.archive']).toEqual([])
expect(en.keybinds.actions['session.archive']).toBe('Archive current session')
expect(KEYBIND_ACTIONS.filter(candidate => candidate.id === 'session.archive')).toHaveLength(1)
})
it('registers dictation with an English label and no default chord', () => {
const action = keybindAction('composer.dictate')
expect(action).toMatchObject({ category: 'composer', defaults: [] })
expect(defaultBindings()['composer.dictate']).toEqual([])
expect(en.keybinds.actions['composer.dictate']).toBe('Start / stop dictation')
expect(KEYBIND_ACTIONS.filter(candidate => candidate.id === 'composer.dictate')).toHaveLength(1)
})
})

View File

@@ -67,6 +67,9 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// chord, so ship it unbound there (rebindable in the panel) rather than
// stealing the long-standing sidebar binding.
{ id: 'composer.voice', category: 'composer', defaults: IS_MAC ? ['ctrl+b'] : [] },
// Dictation is intentionally unbound: it is available for users who prefer
// a keyboard trigger without claiming a chord from text entry by default.
{ id: 'composer.dictate', category: 'composer', defaults: [] },
// ── Profiles ─────────────────────────────────────────────────────────────
{ id: 'profile.default', category: 'profiles', defaults: ['mod+d'] },

View File

@@ -92,6 +92,9 @@ describe('actionAllowedInInput', () => {
// (or the pre-#76185 'shift+n') must not fire while the user types N.
expect(actionAllowedInInput('session.new', 'n')).toBe(false)
expect(actionAllowedInInput('session.new', 'shift+n')).toBe(false)
// Dictation is intentionally bindable without a shipped chord. A user who
// assigns a bare/Shift chord expects it to remain reachable from the draft.
expect(actionAllowedInInput('composer.dictate', 'shift+d')).toBe(true)
})
it('leaves text navigation chords with the focused input even when rebound to an allowed action', () => {

View File

@@ -258,6 +258,7 @@ export function isEditableTarget(target: EventTarget | null): boolean {
const INPUT_SAFE_ACTIONS = new Set([
'composer.modelPicker',
'composer.voice',
'composer.dictate',
'keybinds.openPanel',
'nav.commandPalette',
'session.next',