mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface UseDictationOptions {
|
||||
|
||||
export interface UseDictationResult {
|
||||
isRecording: boolean;
|
||||
isRecordingActive: () => boolean;
|
||||
isProcessing: boolean;
|
||||
partialTranscript: string;
|
||||
volume: number;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user