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,
|
resolveComposerSurfacePresentation,
|
||||||
runAlternateSendAction,
|
runAlternateSendAction,
|
||||||
runDefaultSendAction,
|
runDefaultSendAction,
|
||||||
|
runMessageInputKeyboardAction,
|
||||||
stopRealtimeVoice,
|
stopRealtimeVoice,
|
||||||
} from "./state";
|
} from "./state";
|
||||||
|
|
||||||
@@ -496,65 +497,6 @@ function handleDesktopKeyPressImpl(
|
|||||||
ctx.handleDefaultSendAction();
|
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(
|
function getTextInputNativeElement(
|
||||||
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
|
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
|
||||||
): HTMLElement | null {
|
): HTMLElement | null {
|
||||||
@@ -916,22 +858,18 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
|||||||
interface StartDictationContext {
|
interface StartDictationContext {
|
||||||
dictationUnavailableMessage: string | null | undefined;
|
dictationUnavailableMessage: string | null | undefined;
|
||||||
canStartDictation: () => boolean;
|
canStartDictation: () => boolean;
|
||||||
isDictatingRef: React.MutableRefObject<boolean>;
|
|
||||||
toast: { error: (msg: string) => void };
|
toast: { error: (msg: string) => void };
|
||||||
startDictation: () => Promise<void>;
|
startDictation: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
|
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
|
||||||
if (ctx.dictationUnavailableMessage) {
|
if (ctx.dictationUnavailableMessage) {
|
||||||
ctx.isDictatingRef.current = false;
|
|
||||||
ctx.toast.error(ctx.dictationUnavailableMessage);
|
ctx.toast.error(ctx.dictationUnavailableMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!ctx.canStartDictation()) {
|
if (!ctx.canStartDictation()) {
|
||||||
ctx.isDictatingRef.current = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.isDictatingRef.current = true;
|
|
||||||
await ctx.startDictation();
|
await ctx.startDictation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1276,16 +1214,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
textInputRef.current?.blur?.();
|
textInputRef.current?.blur?.();
|
||||||
},
|
},
|
||||||
runKeyboardAction: (action) =>
|
runKeyboardAction: (action) =>
|
||||||
runKeyboardActionImpl(action, {
|
runMessageInputKeyboardAction(action, {
|
||||||
textInputRef,
|
focusInput: () => textInputRef.current?.focus(),
|
||||||
isDictatingRef,
|
isDictationRecording: isDictationActive,
|
||||||
sendAfterTranscriptRef,
|
markTranscriptForSend: () => {
|
||||||
|
sendAfterTranscriptRef.current = true;
|
||||||
|
},
|
||||||
confirmDictation,
|
confirmDictation,
|
||||||
cancelDictation,
|
cancelDictation,
|
||||||
startDictationIfAvailable,
|
startDictation: startDictationIfAvailable,
|
||||||
handleToggleRealtimeVoiceShortcut,
|
toggleRealtimeVoice: handleToggleRealtimeVoiceShortcut,
|
||||||
isRealtimeVoiceForCurrentAgent,
|
isRealtimeVoiceActive: isRealtimeVoiceForCurrentAgent,
|
||||||
voice,
|
toggleRealtimeVoiceMute: () => voice?.toggleMute(),
|
||||||
}),
|
}),
|
||||||
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
|
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
|
||||||
}));
|
}));
|
||||||
@@ -1369,6 +1309,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
isRecording: isDictating,
|
isRecording: isDictating,
|
||||||
|
isRecordingActive: isDictationActive,
|
||||||
isProcessing: isDictationProcessing,
|
isProcessing: isDictationProcessing,
|
||||||
partialTranscript: _dictationPartialTranscript,
|
partialTranscript: _dictationPartialTranscript,
|
||||||
volume: dictationVolume,
|
volume: dictationVolume,
|
||||||
@@ -1389,11 +1330,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
enableDuration: true,
|
enableDuration: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDictatingRef = useRef(isDictating);
|
|
||||||
useEffect(() => {
|
|
||||||
isDictatingRef.current = isDictating;
|
|
||||||
}, [isDictating]);
|
|
||||||
|
|
||||||
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
|
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
|
||||||
voice,
|
voice,
|
||||||
voiceServerId,
|
voiceServerId,
|
||||||
@@ -1420,7 +1356,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
startDictationIfAvailableImpl({
|
startDictationIfAvailableImpl({
|
||||||
dictationUnavailableMessage,
|
dictationUnavailableMessage,
|
||||||
canStartDictation,
|
canStartDictation,
|
||||||
isDictatingRef,
|
|
||||||
toast,
|
toast,
|
||||||
startDictation,
|
startDictation,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,12 +4,40 @@ import {
|
|||||||
resolveComposerSurfacePresentation,
|
resolveComposerSurfacePresentation,
|
||||||
runAlternateSendAction,
|
runAlternateSendAction,
|
||||||
runDefaultSendAction,
|
runDefaultSendAction,
|
||||||
|
runMessageInputKeyboardAction,
|
||||||
stopRealtimeVoice,
|
stopRealtimeVoice,
|
||||||
} from "./state";
|
} from "./state";
|
||||||
|
|
||||||
const connected = { isConnected: true } as never;
|
const connected = { isConnected: true } as never;
|
||||||
const disconnected = { isConnected: false } 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", () => {
|
describe("composer surface presentation", () => {
|
||||||
it("shows only the input when no voice overlay is active", () => {
|
it("shows only the input when no voice overlay is active", () => {
|
||||||
expect(resolveComposerSurfacePresentation(false)).toEqual({
|
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", () => {
|
describe("composer send behavior", () => {
|
||||||
function actions() {
|
function actions() {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { MessagePayload } from "@/composer/types";
|
import type { MessagePayload } from "@/composer/types";
|
||||||
|
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
|
|
||||||
export type SendBehavior = "interrupt" | "queue";
|
export type SendBehavior = "interrupt" | "queue";
|
||||||
|
|
||||||
@@ -45,6 +46,18 @@ interface SendActionContext {
|
|||||||
handleQueueMessage: () => void;
|
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: {
|
export function computeCanStartDictation(input: {
|
||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
isReadyForDictation: boolean | undefined;
|
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> {
|
export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<void> {
|
||||||
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
|
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface UseDictationOptions {
|
|||||||
|
|
||||||
export interface UseDictationResult {
|
export interface UseDictationResult {
|
||||||
isRecording: boolean;
|
isRecording: boolean;
|
||||||
|
isRecordingActive: () => boolean;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
partialTranscript: string;
|
partialTranscript: string;
|
||||||
volume: number;
|
volume: number;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isRecordingRef.current = isRecording;
|
isRecordingRef.current = isRecording;
|
||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
const isRecordingActive = useCallback(() => isRecordingRef.current, []);
|
||||||
|
|
||||||
const isProcessingRef = useRef(isProcessing);
|
const isProcessingRef = useRef(isProcessing);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -453,6 +454,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isRecording,
|
isRecording,
|
||||||
|
isRecordingActive,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
partialTranscript,
|
partialTranscript,
|
||||||
volume: audio.volume,
|
volume: audio.volume,
|
||||||
|
|||||||
Reference in New Issue
Block a user