Files
paseo/packages/app/src/composer/input/state.test.ts
T
30195d3bd1 fix(app): make composer input IME-safe and paste-aware (#3343)
* 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>
2026-08-14 11:52:24 +02:00

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"]);
});
});