mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
* fix(app): keep fullscreen composer in the keyboard dock * fix(app): preserve composer caret and scope keyboard actions
365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
applyDictationTranscript,
|
|
computeCanStartDictation,
|
|
resolveActiveSendBehavior,
|
|
resolveComposerSurfacePresentation,
|
|
resolveComposerSurfaceText,
|
|
resolveGlyphRailOffset,
|
|
resolveRetainedSelection,
|
|
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", () => {
|
|
it("sends immediately when queue mode cannot advance past a permission", () => {
|
|
expect(resolveActiveSendBehavior("queue", true)).toBe("interrupt");
|
|
expect(resolveActiveSendBehavior("queue", false)).toBe("queue");
|
|
expect(resolveActiveSendBehavior("steer", true)).toBe("steer");
|
|
});
|
|
|
|
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 steer and Mod+Enter to queue when steer is selected", () => {
|
|
const defaultAction = actions();
|
|
runDefaultSendAction({
|
|
defaultSendBehavior: "steer",
|
|
isAgentRunning: true,
|
|
onQueue: defaultAction.onQueue,
|
|
handleSendMessage: defaultAction.handleSendMessage,
|
|
handleQueueMessage: defaultAction.handleQueueMessage,
|
|
});
|
|
|
|
const alternateAction = actions();
|
|
runAlternateSendAction({
|
|
defaultSendBehavior: "steer",
|
|
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"]);
|
|
});
|
|
});
|
|
|
|
describe("composer surface text", () => {
|
|
it("measures a new nonempty replacement before it reaches the editing handle", () => {
|
|
const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" };
|
|
expect(resolveComposerSurfaceText("Short draft", replacement, "draft:1")).toBe(
|
|
replacement.text,
|
|
);
|
|
});
|
|
|
|
it("keeps live typing once the replacement is applied", () => {
|
|
const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" };
|
|
expect(resolveComposerSurfaceText("Edited live draft", replacement, "draft:2")).toBe(
|
|
"Edited live draft",
|
|
);
|
|
});
|
|
|
|
it("keeps the live buffer when the initial publication is already applied", () => {
|
|
const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" };
|
|
expect(resolveComposerSurfaceText("Live edited draft", replacement, "draft:0")).toBe(
|
|
"Live edited draft",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("retained selection", () => {
|
|
it("keeps a caret that fits and clamps one that no longer does", () => {
|
|
expect(resolveRetainedSelection({ start: 4, end: 17 }, 40)).toEqual({ start: 4, end: 17 });
|
|
expect(resolveRetainedSelection({ start: 4, end: 17 }, 10)).toEqual({ start: 4, end: 10 });
|
|
expect(resolveRetainedSelection({ start: 12, end: 17 }, 10)).toEqual({ start: 10, end: 10 });
|
|
expect(resolveRetainedSelection({ start: -1, end: 3 }, 10)).toEqual({ start: 0, end: 3 });
|
|
});
|
|
});
|
|
|
|
describe("fullscreen glyph rail", () => {
|
|
it("nudges 16px fullscreen glyphs onto the Send arrow's ink edge", () => {
|
|
expect(
|
|
resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 16, glyphIconSize: 16 }),
|
|
).toBeCloseTo(4 / 3);
|
|
expect(
|
|
resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 20, glyphIconSize: 16 }),
|
|
).toBeCloseTo(1 / 6);
|
|
});
|
|
});
|