* fix(app): preserve native composer input semantics Native text was round-tripped through application state on every keystroke, which could overwrite an input method's in-flight edits. Keep native text ownership inside the input and reserve application-driven replacement for explicit draft mutations.\n\nRoute native clipboard images through the existing persisted attachment pipeline so keyboard and system paste actions behave like other image sources. * fix(nix): refresh dependency hash The native composer dependency changes the fixed npm dependency closure used by desktop builds. * fix(app): keep composer locked across image pastes Count concurrent native image persistence operations so submit remains disabled until every pasted image is attached. * fix(app): replace composer text through the DOM on web Web TextInput refs are DOM elements, so application-driven draft replacement must update their value and selection without native setNativeProps. * fix(app): preserve web IME composition Carry the Chinese IME fix from #2990 into the web composer input boundary. Co-authored-by: Jimmy Lee <jimersylee+github@gmail.com> --------- Co-authored-by: Jimmy Lee <jimersylee+github@gmail.com>
289 lines
8.3 KiB
TypeScript
289 lines
8.3 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
applyDictationTranscript,
|
|
computeCanStartDictation,
|
|
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({
|
|
input: { opacity: 1, pointerEvents: "auto" },
|
|
overlay: { opacity: 0, pointerEvents: "none" },
|
|
});
|
|
});
|
|
|
|
it("shows only the voice overlay while voice UI is active", () => {
|
|
expect(resolveComposerSurfacePresentation(true)).toEqual({
|
|
input: { opacity: 0, pointerEvents: "none" },
|
|
overlay: { opacity: 1, pointerEvents: "auto" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("computeCanStartDictation", () => {
|
|
it("returns false when socket is disconnected", () => {
|
|
expect(
|
|
computeCanStartDictation({
|
|
client: disconnected,
|
|
isReadyForDictation: true,
|
|
disabled: false,
|
|
dictationUnavailableMessage: null,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("returns false when isReadyForDictation is explicitly false", () => {
|
|
expect(
|
|
computeCanStartDictation({
|
|
client: connected,
|
|
isReadyForDictation: false,
|
|
disabled: false,
|
|
dictationUnavailableMessage: null,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("returns true when connected and ready", () => {
|
|
expect(
|
|
computeCanStartDictation({
|
|
client: connected,
|
|
isReadyForDictation: true,
|
|
disabled: false,
|
|
dictationUnavailableMessage: null,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("falls back to socket connected state when isReadyForDictation is undefined", () => {
|
|
expect(
|
|
computeCanStartDictation({
|
|
client: connected,
|
|
isReadyForDictation: undefined,
|
|
disabled: false,
|
|
dictationUnavailableMessage: null,
|
|
}),
|
|
).toBe(true);
|
|
|
|
expect(
|
|
computeCanStartDictation({
|
|
client: disconnected,
|
|
isReadyForDictation: undefined,
|
|
disabled: false,
|
|
dictationUnavailableMessage: null,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("returns false when the input is disabled", () => {
|
|
expect(
|
|
computeCanStartDictation({
|
|
client: connected,
|
|
isReadyForDictation: true,
|
|
disabled: true,
|
|
dictationUnavailableMessage: null,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("returns false when a dictation unavailable message is present", () => {
|
|
expect(
|
|
computeCanStartDictation({
|
|
client: connected,
|
|
isReadyForDictation: true,
|
|
disabled: false,
|
|
dictationUnavailableMessage: "Microphone unavailable",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("returns false when client is null", () => {
|
|
expect(
|
|
computeCanStartDictation({
|
|
client: null,
|
|
isReadyForDictation: true,
|
|
disabled: false,
|
|
dictationUnavailableMessage: null,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
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("dictation transcript behavior", () => {
|
|
it("publishes an auto-sent transcript to the composer before submitting it", () => {
|
|
const actions: string[] = [];
|
|
|
|
applyDictationTranscript("spoken prompt", {
|
|
value: "typed context",
|
|
defaultSendBehavior: "interrupt",
|
|
isAgentRunning: false,
|
|
onQueue: undefined,
|
|
replaceText: (text) => actions.push(`replace:${text}`),
|
|
onSubmit: (payload) => actions.push(`submit:${payload.text}`),
|
|
attachments: [],
|
|
cwd: "/repo",
|
|
autoSend: true,
|
|
});
|
|
|
|
expect(actions).toEqual([
|
|
"replace:typed context spoken prompt",
|
|
"submit:typed context spoken prompt",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("composer send behavior", () => {
|
|
function actions() {
|
|
const calls: string[] = [];
|
|
return {
|
|
calls,
|
|
handleSendMessage: () => calls.push("send"),
|
|
handleQueueMessage: () => calls.push("queue"),
|
|
onQueue: () => undefined,
|
|
};
|
|
}
|
|
|
|
it("uses Enter to interrupt and Mod+Enter to queue when interrupt is selected", () => {
|
|
const defaultAction = actions();
|
|
runDefaultSendAction({
|
|
defaultSendBehavior: "interrupt",
|
|
isAgentRunning: true,
|
|
onQueue: defaultAction.onQueue,
|
|
handleSendMessage: defaultAction.handleSendMessage,
|
|
handleQueueMessage: defaultAction.handleQueueMessage,
|
|
});
|
|
|
|
const alternateAction = actions();
|
|
runAlternateSendAction({
|
|
defaultSendBehavior: "interrupt",
|
|
isAgentRunning: true,
|
|
onQueue: alternateAction.onQueue,
|
|
handleSendMessage: alternateAction.handleSendMessage,
|
|
handleQueueMessage: alternateAction.handleQueueMessage,
|
|
});
|
|
|
|
expect(defaultAction.calls).toEqual(["send"]);
|
|
expect(alternateAction.calls).toEqual(["queue"]);
|
|
});
|
|
|
|
it("uses Enter to queue and Mod+Enter to submit when queue is selected", () => {
|
|
const defaultAction = actions();
|
|
runDefaultSendAction({
|
|
defaultSendBehavior: "queue",
|
|
isAgentRunning: true,
|
|
onQueue: defaultAction.onQueue,
|
|
handleSendMessage: defaultAction.handleSendMessage,
|
|
handleQueueMessage: defaultAction.handleQueueMessage,
|
|
});
|
|
|
|
const alternateAction = actions();
|
|
runAlternateSendAction({
|
|
defaultSendBehavior: "queue",
|
|
isAgentRunning: true,
|
|
onQueue: alternateAction.onQueue,
|
|
handleSendMessage: alternateAction.handleSendMessage,
|
|
handleQueueMessage: alternateAction.handleQueueMessage,
|
|
});
|
|
|
|
expect(defaultAction.calls).toEqual(["queue"]);
|
|
expect(alternateAction.calls).toEqual(["send"]);
|
|
});
|
|
});
|
|
|
|
describe("stopRealtimeVoice", () => {
|
|
it("keeps voice mode active when the running agent refuses cancellation", async () => {
|
|
const cancellationError = new Error("active run cancellation was not acknowledged");
|
|
const cancelAgent = vi.fn().mockRejectedValue(cancellationError);
|
|
const stopVoice = vi.fn().mockResolvedValue(undefined);
|
|
|
|
await expect(
|
|
stopRealtimeVoice({
|
|
voice: { stopVoice },
|
|
isRealtimeVoiceForCurrentAgent: true,
|
|
isAgentRunning: true,
|
|
client: { cancelAgent },
|
|
voiceAgentId: "agent-1",
|
|
}),
|
|
).rejects.toBe(cancellationError);
|
|
|
|
expect(stopVoice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stops voice mode after the running agent acknowledges cancellation", async () => {
|
|
const calls: string[] = [];
|
|
|
|
await stopRealtimeVoice({
|
|
voice: {
|
|
stopVoice: async () => {
|
|
calls.push("stop voice");
|
|
},
|
|
},
|
|
isRealtimeVoiceForCurrentAgent: true,
|
|
isAgentRunning: true,
|
|
client: {
|
|
cancelAgent: async () => {
|
|
calls.push("cancel agent");
|
|
},
|
|
},
|
|
voiceAgentId: "agent-1",
|
|
});
|
|
|
|
expect(calls).toEqual(["cancel agent", "stop voice"]);
|
|
});
|
|
});
|