diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 8a58c9e98..decb9e692 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -70,6 +70,7 @@ import { resolveComposerSurfacePresentation, runAlternateSendAction, runDefaultSendAction, + runMessageInputKeyboardAction, stopRealtimeVoice, } from "./state"; @@ -496,65 +497,6 @@ function handleDesktopKeyPressImpl( ctx.handleDefaultSendAction(); } -interface KeyboardActionHandlers { - textInputRef: React.MutableRefObject< - TextInput | (TextInput & { getNativeRef?: () => unknown }) | null - >; - isDictatingRef: React.MutableRefObject; - sendAfterTranscriptRef: React.MutableRefObject; - confirmDictation: () => void | Promise; - cancelDictation: () => void | Promise; - startDictationIfAvailable: () => Promise; - handleToggleRealtimeVoiceShortcut: () => void; - isRealtimeVoiceForCurrentAgent: boolean; - voice: { toggleMute: () => void } | null | undefined; -} - -function runKeyboardActionImpl( - action: MessageInputKeyboardActionKind, - h: KeyboardActionHandlers, -): boolean { - if (action === "focus") { - h.textInputRef.current?.focus(); - return true; - } - if (action === "send" || action === "dictation-confirm") { - if (h.isDictatingRef.current) { - h.sendAfterTranscriptRef.current = true; - void h.confirmDictation(); - return true; - } - return false; - } - if (action === "voice-toggle") { - h.handleToggleRealtimeVoiceShortcut(); - return true; - } - if (action === "voice-mute-toggle") { - if (h.isRealtimeVoiceForCurrentAgent) { - h.voice?.toggleMute(); - } - return true; - } - if (action === "dictation-cancel") { - if (h.isDictatingRef.current) { - void h.cancelDictation(); - return true; - } - return false; - } - if (action === "dictation-toggle") { - if (h.isDictatingRef.current) { - h.sendAfterTranscriptRef.current = true; - void h.confirmDictation(); - } else { - void h.startDictationIfAvailable(); - } - return true; - } - return false; -} - function getTextInputNativeElement( current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null, ): HTMLElement | null { @@ -916,22 +858,18 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { interface StartDictationContext { dictationUnavailableMessage: string | null | undefined; canStartDictation: () => boolean; - isDictatingRef: React.MutableRefObject; toast: { error: (msg: string) => void }; startDictation: () => Promise; } async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise { if (ctx.dictationUnavailableMessage) { - ctx.isDictatingRef.current = false; ctx.toast.error(ctx.dictationUnavailableMessage); return; } if (!ctx.canStartDictation()) { - ctx.isDictatingRef.current = false; return; } - ctx.isDictatingRef.current = true; await ctx.startDictation(); } @@ -1276,16 +1214,18 @@ export const MessageInput = forwardRef( textInputRef.current?.blur?.(); }, runKeyboardAction: (action) => - runKeyboardActionImpl(action, { - textInputRef, - isDictatingRef, - sendAfterTranscriptRef, + runMessageInputKeyboardAction(action, { + focusInput: () => textInputRef.current?.focus(), + isDictationRecording: isDictationActive, + markTranscriptForSend: () => { + sendAfterTranscriptRef.current = true; + }, confirmDictation, cancelDictation, - startDictationIfAvailable, - handleToggleRealtimeVoiceShortcut, - isRealtimeVoiceForCurrentAgent, - voice, + startDictation: startDictationIfAvailable, + toggleRealtimeVoice: handleToggleRealtimeVoiceShortcut, + isRealtimeVoiceActive: isRealtimeVoiceForCurrentAgent, + toggleRealtimeVoiceMute: () => voice?.toggleMute(), }), getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null), })); @@ -1369,6 +1309,7 @@ export const MessageInput = forwardRef( const { isRecording: isDictating, + isRecordingActive: isDictationActive, isProcessing: isDictationProcessing, partialTranscript: _dictationPartialTranscript, volume: dictationVolume, @@ -1389,11 +1330,6 @@ export const MessageInput = forwardRef( enableDuration: true, }); - const isDictatingRef = useRef(isDictating); - useEffect(() => { - isDictatingRef.current = isDictating; - }, [isDictating]); - const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent( voice, voiceServerId, @@ -1420,7 +1356,6 @@ export const MessageInput = forwardRef( startDictationIfAvailableImpl({ dictationUnavailableMessage, canStartDictation, - isDictatingRef, toast, startDictation, }), diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 5e8420e77..90232fb2e 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -4,12 +4,40 @@ import { resolveComposerSurfacePresentation, runAlternateSendAction, runDefaultSendAction, + runMessageInputKeyboardAction, stopRealtimeVoice, } from "./state"; const connected = { isConnected: true } as never; const disconnected = { isConnected: false } as never; +function createDictationKeyboard({ startsRecording }: { startsRecording: boolean }) { + let isRecording = false; + const actions: string[] = []; + + return { + actions, + pressDictationShortcut: () => + runMessageInputKeyboardAction("dictation-toggle", { + focusInput: () => undefined, + isDictationRecording: () => isRecording, + markTranscriptForSend: () => actions.push("send transcript"), + startDictation: () => { + actions.push("start"); + isRecording = startsRecording; + }, + confirmDictation: () => { + actions.push("confirm"); + isRecording = false; + }, + cancelDictation: () => undefined, + toggleRealtimeVoice: () => undefined, + isRealtimeVoiceActive: false, + toggleRealtimeVoiceMute: () => undefined, + }), + }; +} + describe("composer surface presentation", () => { it("shows only the input when no voice overlay is active", () => { expect(resolveComposerSurfacePresentation(false)).toEqual({ @@ -114,6 +142,27 @@ describe("computeCanStartDictation", () => { }); }); +describe("dictation keyboard behavior", () => { + it("starts dictation again after the previous dictation finishes", () => { + const keyboard = createDictationKeyboard({ startsRecording: true }); + + keyboard.pressDictationShortcut(); + keyboard.pressDictationShortcut(); + keyboard.pressDictationShortcut(); + + expect(keyboard.actions).toEqual(["start", "send transcript", "confirm", "start"]); + }); + + it("can retry when starting dictation does not enter the recording state", () => { + const keyboard = createDictationKeyboard({ startsRecording: false }); + + keyboard.pressDictationShortcut(); + keyboard.pressDictationShortcut(); + + expect(keyboard.actions).toEqual(["start", "start"]); + }); +}); + describe("composer send behavior", () => { function actions() { const calls: string[] = []; diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 716433f4b..6d3888679 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -1,5 +1,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { MessagePayload } from "@/composer/types"; +import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; export type SendBehavior = "interrupt" | "queue"; @@ -45,6 +46,18 @@ interface SendActionContext { handleQueueMessage: () => void; } +interface MessageInputKeyboardActions { + focusInput: () => void; + isDictationRecording: () => boolean; + markTranscriptForSend: () => void; + confirmDictation: () => void | Promise; + cancelDictation: () => void | Promise; + startDictation: () => void | Promise; + toggleRealtimeVoice: () => void; + isRealtimeVoiceActive: boolean; + toggleRealtimeVoiceMute: () => void; +} + export function computeCanStartDictation(input: { client: DaemonClient | null; isReadyForDictation: boolean | undefined; @@ -76,6 +89,51 @@ export function runAlternateSendAction(ctx: SendActionContext): void { } } +export function runMessageInputKeyboardAction( + action: MessageInputKeyboardActionKind, + actions: MessageInputKeyboardActions, +): boolean { + if (action === "focus") { + actions.focusInput(); + return true; + } + if (action === "send" || action === "dictation-confirm") { + if (actions.isDictationRecording()) { + actions.markTranscriptForSend(); + void actions.confirmDictation(); + return true; + } + return false; + } + if (action === "voice-toggle") { + actions.toggleRealtimeVoice(); + return true; + } + if (action === "voice-mute-toggle") { + if (actions.isRealtimeVoiceActive) { + actions.toggleRealtimeVoiceMute(); + } + return true; + } + if (action === "dictation-cancel") { + if (actions.isDictationRecording()) { + void actions.cancelDictation(); + return true; + } + return false; + } + if (action === "dictation-toggle") { + if (actions.isDictationRecording()) { + actions.markTranscriptForSend(); + void actions.confirmDictation(); + } else { + void actions.startDictation(); + } + return true; + } + return false; +} + export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise { if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return; diff --git a/packages/app/src/hooks/use-dictation.shared.ts b/packages/app/src/hooks/use-dictation.shared.ts index 441aa9120..275c9d93d 100644 --- a/packages/app/src/hooks/use-dictation.shared.ts +++ b/packages/app/src/hooks/use-dictation.shared.ts @@ -15,6 +15,7 @@ export interface UseDictationOptions { export interface UseDictationResult { isRecording: boolean; + isRecordingActive: () => boolean; isProcessing: boolean; partialTranscript: string; volume: number; diff --git a/packages/app/src/hooks/use-dictation.ts b/packages/app/src/hooks/use-dictation.ts index 6525ffa8a..09442b4c7 100644 --- a/packages/app/src/hooks/use-dictation.ts +++ b/packages/app/src/hooks/use-dictation.ts @@ -59,6 +59,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { useEffect(() => { isRecordingRef.current = isRecording; }, [isRecording]); + const isRecordingActive = useCallback(() => isRecordingRef.current, []); const isProcessingRef = useRef(isProcessing); useEffect(() => { @@ -453,6 +454,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { return { isRecording, + isRecordingActive, isProcessing, partialTranscript, volume: audio.volume,