mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
Fix voice dictation tip keyboard behavior
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user