fix(app): keep dictation shortcuts responsive (#2268)

The composer mirrored dictation state in a second ref that could remain stale after the recording surface changed. Route shortcut decisions through the dictation lifecycle's synchronous state so finishing or rejecting a recording cannot leave Command-D latched.
This commit is contained in:
Mohamed Boudra
2026-07-20 19:32:50 +02:00
committed by GitHub
parent b3b1283d3b
commit 2ead7e7719
5 changed files with 122 additions and 77 deletions

View File

@@ -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<boolean>;
sendAfterTranscriptRef: React.MutableRefObject<boolean>;
confirmDictation: () => void | Promise<void>;
cancelDictation: () => void | Promise<void>;
startDictationIfAvailable: () => Promise<void>;
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<boolean>;
toast: { error: (msg: string) => void };
startDictation: () => Promise<void>;
}
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
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<MessageInputRef, MessageInputProps>(
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<MessageInputRef, MessageInputProps>(
const {
isRecording: isDictating,
isRecordingActive: isDictationActive,
isProcessing: isDictationProcessing,
partialTranscript: _dictationPartialTranscript,
volume: dictationVolume,
@@ -1389,11 +1330,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
enableDuration: true,
});
const isDictatingRef = useRef(isDictating);
useEffect(() => {
isDictatingRef.current = isDictating;
}, [isDictating]);
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
voice,
voiceServerId,
@@ -1420,7 +1356,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
startDictationIfAvailableImpl({
dictationUnavailableMessage,
canStartDictation,
isDictatingRef,
toast,
startDictation,
}),

View File

@@ -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[] = [];

View File

@@ -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<void>;
cancelDictation: () => void | Promise<void>;
startDictation: () => void | Promise<void>;
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<void> {
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;

View File

@@ -15,6 +15,7 @@ export interface UseDictationOptions {
export interface UseDictationResult {
isRecording: boolean;
isRecordingActive: () => boolean;
isProcessing: boolean;
partialTranscript: string;
volume: number;

View File

@@ -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,