mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
* feat(app): add fullscreen editing to the compact composer * fix(app): preserve read-only terminal command previews Static command previews must render their published source; the live editor buffer is updated after render and has no mounted input to repaint read-only content. * fix(app): import React for composer editing scope rendering Workspace pane unit tests use the classic JSX transform, so the editing-scope provider needs React in scope when mounted. * fix(app): preserve fullscreen composer controls and editing Keep the fullscreen frame inside the visual viewport, gate hidden autocomplete keys, apply replacement commands to local line measurement, and return to inline before starting voice so its controls stay accessible. * test(app): cover draft replacement through the production composer Replace the measurement-hook fixture with profile switching and typing through the real composer. Keep measurement state inside its production surface; Android manual red/green evidence covers the native replacement flow.
343 lines
10 KiB
TypeScript
343 lines
10 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
applyDictationTranscript,
|
|
computeCanStartDictation,
|
|
resolveActiveSendBehavior,
|
|
resolveComposerSurfacePresentation,
|
|
resolveComposerSurfaceText,
|
|
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("uses the live handoff when its replacement has already been applied", () => {
|
|
const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" };
|
|
expect(resolveComposerSurfaceText("Live remount handoff", replacement, "draft:0")).toBe(
|
|
"Live remount handoff",
|
|
);
|
|
});
|
|
});
|