Fix voice dictation tip keyboard behavior

This commit is contained in:
Brennan Benson
2026-07-31 17:22:04 -07:00
parent 87b657da4e
commit 69e70b94da
7 changed files with 108 additions and 59 deletions
@@ -1,4 +1,4 @@
import type { JSX } from 'react'
import type { JSX, Ref } from 'react'
import { Loader2 } from 'lucide-react'
import type { FeatureTip, FeatureTipAction } from '../../../../shared/feature-tips'
import { Button } from '@/components/ui/button'
@@ -17,7 +17,8 @@ export function FeatureTipActions({
onPrimaryAction,
onSkip,
showSkip = true,
fullWidth = false
fullWidth = false,
primaryButtonRef
}: {
currentTip: FeatureTip
primaryBusy: boolean
@@ -25,6 +26,7 @@ export function FeatureTipActions({
onSkip: () => void
showSkip?: boolean
fullWidth?: boolean
primaryButtonRef?: Ref<HTMLButtonElement>
}): JSX.Element {
return (
<>
@@ -34,6 +36,7 @@ export function FeatureTipActions({
</Button>
) : null}
<Button
ref={primaryButtonRef}
className={fullWidth ? 'w-full' : undefined}
onClick={onPrimaryAction}
disabled={primaryBusy}
@@ -365,6 +365,11 @@ export default function FeatureTipsModal(): JSX.Element | null {
)
}
if (currentTip.action !== 'enable-voice') {
currentTip.action satisfies never
return null
}
return (
<VoiceDictationTipDialog
open={isOpen}
@@ -8,15 +8,13 @@ import { VoiceDictationFeatureTipVisual } from './VoiceDictationFeatureTipVisual
const prefersReducedMotionMock = vi.hoisted(() => vi.fn(() => false))
const shortcutMock = vi.hoisted(() => vi.fn(() => ({ keys: ['⌘', 'E'], doubleTap: false })))
const formatShortcutMock = vi.hoisted(() => vi.fn(() => [{ keys: ['⌘', 'E'], doubleTap: false }]))
vi.mock('@/components/feature-wall/feature-wall-modal-helpers', () => ({
usePrefersReducedMotion: prefersReducedMotionMock
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
useShortcutKeyDetails: shortcutMock,
formatShortcutKeyComboDetails: formatShortcutMock
useShortcutKeyDetails: shortcutMock
}))
async function renderVisual(): Promise<{ container: HTMLDivElement; root: Root }> {
@@ -31,7 +29,6 @@ describe('VoiceDictationFeatureTipVisual', () => {
beforeEach(() => {
prefersReducedMotionMock.mockReturnValue(false)
shortcutMock.mockReturnValue({ keys: ['⌘', 'E'], doubleTap: false })
formatShortcutMock.mockReturnValue([{ keys: ['⌘', 'E'], doubleTap: false }])
})
afterEach(() => {
@@ -80,14 +77,12 @@ describe('VoiceDictationFeatureTipVisual', () => {
await act(async () => root.unmount())
})
it('falls back to the default shortcut when the live binding is unassigned', () => {
it('does not show a shortcut that the user has unassigned', () => {
shortcutMock.mockReturnValue({ keys: [], doubleTap: false })
formatShortcutMock.mockReturnValue([{ keys: ['Ctrl', 'E'], doubleTap: false }])
const html = renderToStaticMarkup(<VoiceDictationFeatureTipVisual />)
expect(formatShortcutMock).toHaveBeenCalledWith('voice.dictation')
expect(html).toContain('Ctrl')
expect(html).toContain('E')
expect(html).not.toContain('Start dictation')
expect(html).not.toContain('Ctrl')
})
})
@@ -2,7 +2,7 @@ import { useEffect, useState, type JSX } from 'react'
import { Mic, Square } from 'lucide-react'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { usePrefersReducedMotion } from '@/components/feature-wall/feature-wall-modal-helpers'
import { formatShortcutKeyComboDetails, useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
const TYPE_START_DELAY_MS = 650
@@ -12,8 +12,6 @@ const WAVEFORM_BAR_HEIGHTS = [38, 70, 100, 58, 82]
export function VoiceDictationFeatureTipVisual(): JSX.Element {
const reducedMotion = usePrefersReducedMotion()
const shortcut = useShortcutKeyDetails('voice.dictation')
const displayShortcut =
shortcut.keys.length > 0 ? shortcut : formatShortcutKeyComboDetails('voice.dictation')[0]
const dictatedPrompt = translate(
'featureTips.voice.demoPrompt',
'Refactor the voice setup flow and add coverage for the permission states.'
@@ -55,11 +53,11 @@ export function VoiceDictationFeatureTipVisual(): JSX.Element {
className="relative flex h-full min-h-[23rem] flex-col items-center justify-center overflow-hidden px-6 py-7"
aria-hidden="true"
>
{displayShortcut && displayShortcut.keys.length > 0 ? (
{shortcut.keys.length > 0 ? (
<div className="flex items-center gap-2.5">
<ShortcutKeyCombo
keys={displayShortcut.keys}
doubleTap={displayShortcut.doubleTap}
keys={shortcut.keys}
doubleTap={shortcut.doubleTap}
keyCapClassName="h-7 min-w-7 bg-foreground/[0.08] px-2 font-semibold shadow-xs"
/>
<span className="text-[10px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
@@ -1,17 +1,18 @@
// @vitest-environment happy-dom
import type { ReactNode } from 'react'
import { act, type ReactNode, type RefObject } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FEATURE_TIPS, type FeatureTip } from '../../../../shared/feature-tips'
import { VoiceDictationTipDialog } from './VoiceDictationTipDialog'
const shortcutMock = vi.hoisted(() => vi.fn(() => ({ keys: ['⌘', 'E'], doubleTap: false })))
const formatShortcutMock = vi.hoisted(() => vi.fn(() => [{ keys: ['⌘', 'E'], doubleTap: false }]))
const dialogContentPropsMock = vi.hoisted(() => vi.fn())
const featureTipActionsPropsMock = vi.hoisted(() => vi.fn())
vi.mock('@/hooks/useShortcutLabel', () => ({
useShortcutKeyDetails: shortcutMock,
formatShortcutKeyComboDetails: formatShortcutMock
useShortcutKeyDetails: shortcutMock
}))
vi.mock('./VoiceDictationFeatureTipVisual', () => ({
@@ -20,7 +21,16 @@ vi.mock('./VoiceDictationFeatureTipVisual', () => ({
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogContent: ({
children,
...props
}: {
children: ReactNode
onOpenAutoFocus?: (event: Event) => void
}) => {
dialogContentPropsMock(props)
return <div>{children}</div>
},
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <footer>{children}</footer>,
DialogHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
@@ -28,7 +38,10 @@ vi.mock('@/components/ui/dialog', () => ({
}))
vi.mock('./FeatureTipActions', () => ({
FeatureTipActions: () => <div data-testid="feature-tip-actions" />
FeatureTipActions: (props: { primaryButtonRef?: RefObject<HTMLButtonElement | null> }) => {
featureTipActionsPropsMock(props)
return <button ref={props.primaryButtonRef}>Primary action</button>
}
}))
function getVoiceTip(): FeatureTip {
@@ -56,7 +69,8 @@ function renderDialog(): string {
describe('VoiceDictationTipDialog', () => {
beforeEach(() => {
shortcutMock.mockReturnValue({ keys: ['⌘', 'E'], doubleTap: false })
formatShortcutMock.mockReturnValue([{ keys: ['⌘', 'E'], doubleTap: false }])
dialogContentPropsMock.mockClear()
featureTipActionsPropsMock.mockClear()
})
it('uses the established feature-tip layout and durable copy', () => {
@@ -78,13 +92,43 @@ describe('VoiceDictationTipDialog', () => {
expect(html).toContain('Settings → Voice')
})
it('falls back to the platform default shortcut when unassigned', () => {
it('uses generic instructions instead of a shortcut the user has unassigned', () => {
shortcutMock.mockReturnValue({ keys: [], doubleTap: false })
formatShortcutMock.mockReturnValue([{ keys: ['Ctrl', 'E'], doubleTap: false }])
const html = renderDialog()
expect(formatShortcutMock).toHaveBeenCalledWith('voice.dictation')
expect(html).toContain('Ctrl')
expect(html).toContain(
'Assign a dictation shortcut before starting voice dictation in a focused pane.'
)
expect(html).not.toContain('to start voice dictation. Press')
expect(html).not.toContain('Ctrl')
})
it('moves initial focus to the primary action', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => {
root.render(
<VoiceDictationTipDialog
open
tip={getVoiceTip()}
primaryBusy={false}
onOpenChange={vi.fn()}
onPrimaryAction={vi.fn()}
onSkip={vi.fn()}
onVoiceSettingsClick={vi.fn()}
/>
)
})
const event = { preventDefault: vi.fn() } as unknown as Event
dialogContentPropsMock.mock.lastCall?.[0].onOpenAutoFocus(event)
expect(event.preventDefault).toHaveBeenCalledOnce()
expect(document.activeElement?.textContent).toBe('Primary action')
await act(async () => root.unmount())
container.remove()
})
})
@@ -1,4 +1,4 @@
import type { JSX } from 'react'
import { useRef, type JSX } from 'react'
import type { FeatureTip } from '../../../../shared/feature-tips'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { Badge } from '@/components/ui/badge'
@@ -10,7 +10,7 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { formatShortcutKeyComboDetails, useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import { FeatureTipActions } from './FeatureTipActions'
import { VoiceDictationFeatureTipVisual } from './VoiceDictationFeatureTipVisual'
@@ -33,15 +33,17 @@ export function VoiceDictationTipDialog({
onVoiceSettingsClick: () => void
}): JSX.Element {
const shortcut = useShortcutKeyDetails('voice.dictation')
const displayShortcut =
shortcut.keys.length > 0 ? shortcut : formatShortcutKeyComboDetails('voice.dictation')[0]
const primaryButtonRef = useRef<HTMLButtonElement>(null)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="!flex max-h-[calc(100vh-2rem)] flex-col gap-0 overflow-hidden bg-[color-mix(in_srgb,var(--foreground)_8%,var(--background))] p-0 dark:bg-[color-mix(in_srgb,var(--foreground)_16%,var(--background))] sm:max-w-4xl md:!h-[min(27rem,calc(100vh-2rem))] md:!flex-row"
showCloseButton
onOpenAutoFocus={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => {
event.preventDefault()
primaryButtonRef.current?.focus()
}}
>
<div className="scrollbar-sleek flex min-h-0 min-w-0 flex-1 flex-col justify-between overflow-y-auto px-8 py-9 md:shrink-0 md:basis-1/2">
<DialogHeader className="gap-4 text-left">
@@ -56,37 +58,38 @@ export function VoiceDictationTipDialog({
{tip.title}
</DialogTitle>
<DialogDescription className="mt-3 max-w-2xl space-y-3 text-sm leading-relaxed">
<span className="block">
{translate(
'featureTips.voice.focusPaneInstruction',
'Focus a terminal, editor, or agent prompt, then press'
)}{' '}
{displayShortcut && displayShortcut.keys.length > 0 ? (
{shortcut.keys.length > 0 ? (
<span className="block">
{translate(
'featureTips.voice.focusPaneInstruction',
'Focus a terminal, editor, or agent prompt, then press'
)}{' '}
<ShortcutKeyCombo
keys={displayShortcut.keys}
doubleTap={displayShortcut.doubleTap}
keys={shortcut.keys}
doubleTap={shortcut.doubleTap}
className="mx-1 align-middle"
keyCapClassName="min-w-0 bg-card px-1.5 py-0 text-[11px] text-foreground shadow-none"
/>
) : (
translate('featureTips.voice.theShortcut', 'the dictation shortcut')
)}{' '}
{translate(
'featureTips.voice.startInstruction',
'to start voice dictation. Press'
)}{' '}
{displayShortcut && displayShortcut.keys.length > 0 ? (
/>{' '}
{translate(
'featureTips.voice.startInstruction',
'to start voice dictation. Press'
)}{' '}
<ShortcutKeyCombo
keys={displayShortcut.keys}
doubleTap={displayShortcut.doubleTap}
keys={shortcut.keys}
doubleTap={shortcut.doubleTap}
className="mx-1 align-middle"
keyCapClassName="min-w-0 bg-card px-1.5 py-0 text-[11px] text-foreground shadow-none"
/>
) : (
translate('featureTips.voice.theShortcut', 'the dictation shortcut')
)}{' '}
{translate('featureTips.voice.stopInstruction', 'again to stop.')}
</span>
/>{' '}
{translate('featureTips.voice.stopInstruction', 'again to stop.')}
</span>
) : (
<span className="block">
{translate(
'featureTips.voice.unassignedInstruction',
'Assign a dictation shortcut before starting voice dictation in a focused pane.'
)}
</span>
)}
<span className="block text-muted-foreground">
{translate(
'featureTips.voice.settingsInstruction',
@@ -113,6 +116,7 @@ export function VoiceDictationTipDialog({
onSkip={onSkip}
showSkip={false}
fullWidth
primaryButtonRef={primaryButtonRef}
/>
</DialogFooter>
</div>
+1 -1
View File
@@ -14694,9 +14694,9 @@
"agentPromptTitle": "Agent prompt",
"listening": "Listening...",
"focusPaneInstruction": "Focus a terminal, editor, or agent prompt, then press",
"theShortcut": "the dictation shortcut",
"startInstruction": "to start voice dictation. Press",
"stopInstruction": "again to stop.",
"unassignedInstruction": "Assign a dictation shortcut before starting voice dictation in a focused pane.",
"settingsInstruction": "Change the model, dictation mode, or shortcut anytime in",
"settingsLink": "Settings → Voice"
}