feat(desktop): add composer dictation keybind
(cherry picked from commit bb6e188bb6fe6b59ac8f829b3461b2b50ef571e2)
This commit is contained in:
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -311,6 +311,7 @@ export const ar = defineLocale({
|
||||
'composer.focus': 'التركيز على المحرّر',
|
||||
'composer.modelPicker': 'فتح منتقي النموذج',
|
||||
'composer.voice': 'بدء / إيقاف المحادثة الصوتية',
|
||||
'composer.dictate': 'بدء / إيقاف الإملاء',
|
||||
'view.toggleSidebar': 'تبديل الشريط الجانبي للجلسات',
|
||||
'view.toggleRightSidebar': 'تبديل متصفح الملفات',
|
||||
'view.toggleReview': 'تبديل لوحة المراجعة',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -333,6 +333,7 @@ export const ru = defineLocale({
|
||||
'composer.focus': 'Сфокусировать композер',
|
||||
'composer.modelPicker': 'Открыть выбор модели',
|
||||
'composer.voice': 'Начать / остановить голосовой диалог',
|
||||
'composer.dictate': 'Начать / остановить диктовку',
|
||||
'view.toggleSidebar': 'Показать / скрыть панель сеансов',
|
||||
'view.toggleRightSidebar': 'Показать / скрыть браузер файлов',
|
||||
'view.toggleReview': 'Показать / скрыть панель ревью',
|
||||
|
||||
@@ -379,6 +379,7 @@ export const zh = defineLocale({
|
||||
'composer.focus': '聚焦输入框',
|
||||
'composer.modelPicker': '打开模型选择器',
|
||||
'composer.voice': '开始 / 停止语音对话',
|
||||
'composer.dictate': '开始 / 停止听写',
|
||||
'view.toggleSidebar': '切换会话侧边栏',
|
||||
'view.toggleRightSidebar': '切换文件浏览器',
|
||||
'view.toggleReview': '切换审查面板',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user