mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
Touch users had no way to reach what Cmd/Ctrl+Enter does: queue when Default send is Steer or Interrupt, send now when it is Queue. Holding the Send button now runs that same action on every platform, with a haptic on native and an accessibility hint naming it. Tapping is unchanged. Both paths go through runAlternateSendAction, which now falls back to the default send when no turn is running, so holding Send while idle still sends and the keyboard path no longer carries its own gate. Co-authored-by: bjspi <43368639+bjspi@users.noreply.github.com>
407 lines
13 KiB
TypeScript
407 lines
13 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
applyDictationTranscript,
|
|
computeCanStartDictation,
|
|
resolveActiveSendBehavior,
|
|
resolveComposerSurfacePresentation,
|
|
resolveComposerSurfaceText,
|
|
resolveGlyphRailOffset,
|
|
resolveRetainedSelection,
|
|
resolveAlternateSendAction,
|
|
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"]);
|
|
});
|
|
|
|
it("sends with the alternate action while no turn is running", () => {
|
|
for (const defaultSendBehavior of ["steer", "interrupt", "queue"] as const) {
|
|
const alternateAction = actions();
|
|
runAlternateSendAction({
|
|
defaultSendBehavior,
|
|
isAgentRunning: false,
|
|
onQueue: alternateAction.onQueue,
|
|
handleSendMessage: alternateAction.handleSendMessage,
|
|
handleQueueMessage: alternateAction.handleQueueMessage,
|
|
});
|
|
expect(alternateAction.calls).toEqual(["send"]);
|
|
}
|
|
});
|
|
|
|
it("offers an alternate action only while a turn runs and the composer can queue", () => {
|
|
const onQueue = () => undefined;
|
|
expect(
|
|
resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: true, onQueue }),
|
|
).toBe("queue");
|
|
expect(
|
|
resolveAlternateSendAction({
|
|
defaultSendBehavior: "interrupt",
|
|
isAgentRunning: true,
|
|
onQueue,
|
|
}),
|
|
).toBe("queue");
|
|
expect(
|
|
resolveAlternateSendAction({ defaultSendBehavior: "queue", isAgentRunning: true, onQueue }),
|
|
).toBe("send");
|
|
expect(
|
|
resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: false, onQueue }),
|
|
).toBeNull();
|
|
expect(
|
|
resolveAlternateSendAction({
|
|
defaultSendBehavior: "queue",
|
|
isAgentRunning: true,
|
|
onQueue: undefined,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|