Add provider-backed dictation mode (#1327)

This commit is contained in:
SSU-WEI HUANG
2026-08-03 06:05:58 +08:00
committed by GitHub
parent abf9cb02a5
commit 9d07857570
22 changed files with 934 additions and 43 deletions
@@ -1,8 +1,8 @@
import type { ReactElement } from 'react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/lib/i18n-context'
import { UnifiedButton } from './ComposerButtons'
import { DictationButton, UnifiedButton } from './ComposerButtons'
function renderInProviders(ui: ReactElement) {
return render(<I18nProvider>{ui}</I18nProvider>)
@@ -82,3 +82,24 @@ describe('UnifiedButton — routesToScratchlist visual state', () => {
expect(btn.className).not.toContain('bg-amber-500')
})
})
describe('DictationButton', () => {
afterEach(cleanup)
it('keeps dictation available when an existing draft makes the main button a send button', () => {
const onVoiceToggle = vi.fn()
renderInProviders(
<DictationButton
enabled
canSend
voiceEnabled
voiceStatus="disconnected"
controlsDisabled={false}
onVoiceToggle={onVoiceToggle}
/>,
)
fireEvent.click(getButton('Dictate'))
expect(onVoiceToggle).toHaveBeenCalledOnce()
})
})
@@ -389,6 +389,7 @@ export function UnifiedButton(props: {
controlsDisabled: boolean
onSend: () => void
onVoiceToggle: () => void
voiceLabel?: string
/**
* When true, the send button repaints amber and the aria-label
* announces "Send to scratchlist" instead of "Send message". The
@@ -446,7 +447,7 @@ export function UnifiedButton(props: {
} else if (props.voiceEnabled) {
icon = <VoiceAssistantIcon />
className = 'bg-black text-white'
ariaLabel = t('composer.voice')
ariaLabel = props.voiceLabel ?? t('composer.voice')
} else {
icon = <SendIcon />
className = 'bg-[#C0C0C0] text-white'
@@ -478,6 +479,37 @@ export function UnifiedButton(props: {
)
}
export function DictationButton(props: {
enabled: boolean
canSend: boolean
voiceEnabled: boolean
voiceStatus: ConversationStatus
controlsDisabled: boolean
onVoiceToggle: () => void
}) {
const { t } = useTranslation()
if (
!props.enabled
|| !props.canSend
|| !props.voiceEnabled
|| props.voiceStatus === 'connecting'
|| props.voiceStatus === 'connected'
) return null
return (
<button
type="button"
onClick={props.onVoiceToggle}
disabled={props.controlsDisabled}
aria-label={t('composer.dictate')}
title={t('composer.dictate')}
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-50"
>
<VoiceAssistantIcon />
</button>
)
}
export function ComposerButtons(props: {
canSend: boolean
controlsDisabled: boolean
@@ -496,6 +528,7 @@ export function ComposerButtons(props: {
isSwitching: boolean
onSwitch: () => void
voiceEnabled: boolean
dictationEnabled?: boolean
voiceStatus: ConversationStatus
voiceMicMuted?: boolean
onVoiceToggle: () => void
@@ -739,6 +772,15 @@ export function ComposerButtons(props: {
</OrderedToolbarItems>
</div>
<DictationButton
enabled={props.dictationEnabled ?? false}
canSend={props.canSend}
voiceEnabled={props.voiceEnabled}
voiceStatus={props.voiceStatus}
controlsDisabled={props.controlsDisabled}
onVoiceToggle={props.onVoiceToggle}
/>
<UnifiedButton
canSend={props.canSend}
voiceStatus={props.voiceStatus}
@@ -746,6 +788,7 @@ export function ComposerButtons(props: {
controlsDisabled={props.controlsDisabled}
onSend={props.onSend}
onVoiceToggle={props.onVoiceToggle}
voiceLabel={props.dictationEnabled ? t('composer.dictate') : undefined}
/*
* Derived, NOT raw scratchlistMode. Mirror SessionChat's
* shouldRouteToScratchlist: amber + "Send to scratchlist"
@@ -47,6 +47,9 @@ import { getPiThinkingLevelOptions, getHighestThinkingLevel, isThinkingLevelSupp
import { groupModelsByProvider } from './piModelGroups'
import { PiModelPanel } from './PiModelPanel'
import { PiThinkingLevelPanel } from './PiThinkingLevelPanel'
import type { ApiClient } from '@/api/client'
import { useVoiceInputPreferences } from '@/hooks/useVoiceInputPreferences'
import { useDictation } from '@/hooks/useDictation'
export interface TextInputState {
text: string
@@ -229,6 +232,7 @@ export function HappyComposer(props: {
voiceMicMuted?: boolean
onVoiceToggle?: () => void
onVoiceMicToggle?: () => void
voiceTranscriptionApi?: ApiClient
// Schedule props (lifted from internal state when provided)
pendingSchedule?: PendingSchedule | null
onSchedule?: (pending: PendingSchedule) => void
@@ -315,6 +319,40 @@ export function HappyComposer(props: {
const attachments = useAuiState((s) => s.composer.attachments)
const threadIsRunning = useAuiState((s) => s.thread.isRunning)
const threadIsDisabled = useAuiState((s) => s.thread.isDisabled)
const composerTextRef = useRef(composerText)
composerTextRef.current = composerText
const getCurrentComposerText = useCallback(() => composerTextRef.current, [])
const setComposerText = useCallback((text: string) => api.composer().setText(text), [api])
const voiceInput = useVoiceInputPreferences(props.voiceTranscriptionApi ?? null)
const dictationConfig = useMemo(() => ({
api: props.voiceTranscriptionApi ?? null,
provider: voiceInput.provider,
mode: voiceInput.transcriptionMode,
getCurrentText: getCurrentComposerText,
onTextChange: setComposerText
}), [
props.voiceTranscriptionApi,
voiceInput.provider,
voiceInput.transcriptionMode,
getCurrentComposerText,
setComposerText
])
const dictation = useDictation(dictationConfig)
const dictationActive = voiceInput.voiceMode === 'dictation'
const effectiveVoiceStatus = dictationActive ? dictation.status : voiceStatus
const effectiveVoiceToggle = dictationActive
? (dictation.supported ? dictation.toggle : undefined)
: onVoiceToggle
const previousVoiceModeRef = useRef(voiceInput.voiceMode)
useEffect(() => {
if (previousVoiceModeRef.current === voiceInput.voiceMode) return
previousVoiceModeRef.current = voiceInput.voiceMode
if (dictationActive && (voiceStatus === 'connected' || voiceStatus === 'connecting')) {
onVoiceToggle?.()
} else if (!dictationActive && (dictation.status === 'connected' || dictation.status === 'connecting')) {
void dictation.toggle()
}
}, [dictationActive, voiceInput.voiceMode, voiceStatus, onVoiceToggle, dictation.status, dictation.toggle])
const controlsDisabled = disabled || (!active && !allowSendWhenInactive) || threadIsDisabled
const trimmed = composerText.trim()
@@ -889,7 +927,7 @@ export function HappyComposer(props: {
|| showFastModeSettings
)
const showAbortButton = true
const voiceEnabled = Boolean(onVoiceToggle)
const voiceEnabled = Boolean(effectiveVoiceToggle)
const handleSend = useCallback(() => {
flushAndSend()
@@ -1380,9 +1418,15 @@ export function HappyComposer(props: {
permissionMode={permissionMode}
collaborationMode={collaborationMode}
agentFlavor={agentFlavor}
voiceStatus={voiceStatus}
voiceStatus={effectiveVoiceStatus}
/>
{dictationActive && dictation.error ? (
<div role="alert" className="mb-2 rounded-md bg-[var(--app-subtle-bg)] px-3 py-2 text-sm text-red-600">
{dictation.error}
</div>
) : null}
{sendError ? (
<div
role="alert"
@@ -1467,10 +1511,11 @@ export function HappyComposer(props: {
isSwitching={isSwitching}
onSwitch={handleSwitch}
voiceEnabled={voiceEnabled}
voiceStatus={voiceStatus}
voiceMicMuted={voiceMicMuted}
onVoiceToggle={onVoiceToggle ?? (() => {})}
onVoiceMicToggle={onVoiceMicToggle}
dictationEnabled={dictationActive}
voiceStatus={effectiveVoiceStatus}
voiceMicMuted={dictationActive ? false : voiceMicMuted}
onVoiceToggle={effectiveVoiceToggle ?? (() => {})}
onVoiceMicToggle={dictationActive ? undefined : onVoiceMicToggle}
onSend={handleSend}
pendingSchedule={pendingSchedule}
onSchedule={setPendingSchedule}
+1
View File
@@ -1573,6 +1573,7 @@ function SessionChatInner(props: SessionChatProps) {
voiceMicMuted={voice?.micMuted}
onVoiceToggle={voice && voiceBackendReady ? handleVoiceToggle : undefined}
onVoiceMicToggle={voice && voiceBackendReady ? handleVoiceMicToggle : undefined}
voiceTranscriptionApi={props.api}
scratchlistMode={scratchlistMode}
scratchlistCount={scratchlist.entries.length}
onScratchlistToggle={handleScratchlistToggle}