f23d11dc64
* feat(web): add voice mode to task create dialog prompt Wires the existing VoiceInputButton (introduced in #1159 for the chat composer) into the task-create dialog's prompt toolbar so users can dictate the initial task description. - TaskFormInputs gains an internal insertAtCursor that splices the transcript at the textarea caret with a single leading space when it follows a non-whitespace character, matching the chat composer's behaviour. A pending-cursor ref + useLayoutEffect restores the caret position after React commits the new value. - An optional onVoiceAutoSend prop is threaded from TaskCreateDialog down through DialogPromptSection -> TaskFormInputs -> VoiceInputButton. The dialog points it at formRef.current?.requestSubmit() so dictation can create the task hands-free; every existing submit gate still applies via the disabled submit button. - Voice mode is gated globally on userSettings.voiceMode.enabled, so the button self-hides for users who turned it off in settings and self-greys on browsers without a usable engine. * fix(web): invoke submit handler directly for voice auto-send `HTMLFormElement.requestSubmit()` is missing on Safari < 16, so the previous wiring silently failed with a TypeError on browsers where voice mode itself works (Safari 14.5-15 supports Web Speech / Whisper Web). Drop the form ref and call `setup.guardedHandleSubmit` with a synthetic event instead. `guardedHandleSubmit` only reads `preventDefault` off the event, and every validation gate (`validateForCreate`, session-mode description/agent checks, edit-mode title check, `submitBlockedReason`) lives inside the submit handler — so dictation with incomplete fields still no-ops, identical to the prior requestSubmit-based behaviour. This also matches the chat composer's pattern of calling the same submit function the send button uses. independent-review: voice auto-send Safari < 16 compat * test(web): expand voice splice cases for task-create dialog Adds adversarial coverage for the at-cursor splice helper that backs voice-mode dictation in the task-create dialog: - multi-line description with caret on a line boundary - transcript with internal newlines preserved as-is - existing tabs / newlines before the caret treated as whitespace - session-mode renders the voice button Splits the test file into a rendering-and-wiring `describe` and a splice-behaviour `describe` so each arrow function stays under the 100-line `max-lines-per-function` lint cap. qa: adversarial voice splice cases + session-mode parity --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
215 lines
6.6 KiB
TypeScript
215 lines
6.6 KiB
TypeScript
import { createRef, type ReactNode } from "react";
|
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { TooltipProvider } from "@kandev/ui/tooltip";
|
|
import { TaskFormInputs } from "./task-create-dialog-selectors";
|
|
import type { TaskFormInputsHandle } from "./task-create-dialog-types";
|
|
|
|
// Capture the props (notably `onTranscript` / `onAutoSend`) that
|
|
// TaskFormInputs hands the voice button so we can drive transcripts
|
|
// without instantiating the real VoiceInputButton (which subscribes to
|
|
// the user-settings store and instantiates voice engines).
|
|
type VoiceProps = {
|
|
onTranscript: (text: string) => void;
|
|
onAutoSend?: () => void;
|
|
disabled?: boolean;
|
|
};
|
|
const voiceCalls: VoiceProps[] = [];
|
|
vi.mock("@/components/task/chat/voice-input-button", () => ({
|
|
VoiceInputButton: (props: VoiceProps) => {
|
|
voiceCalls.push(props);
|
|
return <button type="button" data-testid="voice-input-button" />;
|
|
},
|
|
}));
|
|
|
|
// Inert mention popover — the real hook installs a `keydown` listener that
|
|
// drains React's event queue across re-renders and adds noise to assertions.
|
|
vi.mock("@/hooks/use-task-create-prompt-mention", () => ({
|
|
useTaskCreatePromptMention: () => ({
|
|
isOpen: false,
|
|
isLoading: false,
|
|
position: null,
|
|
items: [],
|
|
query: "",
|
|
selectedIndex: 0,
|
|
handleChange: (_: string) => {},
|
|
handleKeyDown: (_: React.KeyboardEvent) => {},
|
|
handleSelect: () => {},
|
|
closeMenu: () => {},
|
|
setSelectedIndex: () => {},
|
|
}),
|
|
}));
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
voiceCalls.length = 0;
|
|
});
|
|
|
|
function lastVoiceProps(): VoiceProps {
|
|
const last = voiceCalls.at(-1);
|
|
if (!last) throw new Error("VoiceInputButton was not rendered");
|
|
return last;
|
|
}
|
|
|
|
function Wrapper({ children }: { children: ReactNode }) {
|
|
return <TooltipProvider>{children}</TooltipProvider>;
|
|
}
|
|
|
|
function renderTaskFormInputs(initial: string) {
|
|
const ref = createRef<TaskFormInputsHandle>();
|
|
const utils = render(
|
|
<TaskFormInputs
|
|
isSessionMode={false}
|
|
autoFocus={false}
|
|
initialDescription={initial}
|
|
onDescriptionChange={() => {}}
|
|
onKeyDown={() => {}}
|
|
descriptionValueRef={ref}
|
|
/>,
|
|
{ wrapper: Wrapper },
|
|
);
|
|
const textarea = screen.getByTestId("task-description-input") as HTMLTextAreaElement;
|
|
return { ...utils, textarea, ref };
|
|
}
|
|
|
|
describe("TaskFormInputs voice-input wiring — rendering", () => {
|
|
it("renders the voice button inside the prompt toolbar", () => {
|
|
renderTaskFormInputs("");
|
|
expect(screen.getByTestId("voice-input-button")).toBeTruthy();
|
|
});
|
|
|
|
it("renders the voice button in session mode too", () => {
|
|
const ref = createRef<TaskFormInputsHandle>();
|
|
render(
|
|
<TaskFormInputs
|
|
isSessionMode
|
|
autoFocus={false}
|
|
initialDescription=""
|
|
onDescriptionChange={() => {}}
|
|
onKeyDown={() => {}}
|
|
descriptionValueRef={ref}
|
|
/>,
|
|
{ wrapper: Wrapper },
|
|
);
|
|
expect(screen.getByTestId("voice-input-button")).toBeTruthy();
|
|
expect(lastVoiceProps()).toBeTruthy();
|
|
});
|
|
|
|
it("forwards onVoiceAutoSend to the voice button", () => {
|
|
const onVoiceAutoSend = vi.fn();
|
|
const ref = createRef<TaskFormInputsHandle>();
|
|
render(
|
|
<TaskFormInputs
|
|
isSessionMode={false}
|
|
autoFocus={false}
|
|
initialDescription=""
|
|
onDescriptionChange={() => {}}
|
|
onKeyDown={() => {}}
|
|
descriptionValueRef={ref}
|
|
onVoiceAutoSend={onVoiceAutoSend}
|
|
/>,
|
|
{ wrapper: Wrapper },
|
|
);
|
|
|
|
const { onAutoSend } = lastVoiceProps();
|
|
onAutoSend?.();
|
|
expect(onVoiceAutoSend).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("disables the voice button when the form is disabled", () => {
|
|
const ref = createRef<TaskFormInputsHandle>();
|
|
render(
|
|
<TaskFormInputs
|
|
isSessionMode={false}
|
|
autoFocus={false}
|
|
initialDescription=""
|
|
onDescriptionChange={() => {}}
|
|
onKeyDown={() => {}}
|
|
descriptionValueRef={ref}
|
|
disabled
|
|
/>,
|
|
{ wrapper: Wrapper },
|
|
);
|
|
|
|
expect(lastVoiceProps().disabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("TaskFormInputs voice-input wiring — at-cursor splice", () => {
|
|
it("splices the transcript at the caret with a leading space after a word", () => {
|
|
const { textarea } = renderTaskFormInputs("hello world");
|
|
textarea.focus();
|
|
textarea.setSelectionRange(5, 5);
|
|
|
|
act(() => lastVoiceProps().onTranscript("there"));
|
|
|
|
expect(textarea.value).toBe("hello there world");
|
|
expect(textarea.selectionStart).toBe(11);
|
|
expect(textarea.selectionEnd).toBe(11);
|
|
});
|
|
|
|
it("inserts the transcript without a leading space when the caret follows whitespace", () => {
|
|
const { textarea } = renderTaskFormInputs("hello ");
|
|
textarea.focus();
|
|
textarea.setSelectionRange(6, 6);
|
|
|
|
act(() => lastVoiceProps().onTranscript("world"));
|
|
|
|
expect(textarea.value).toBe("hello world");
|
|
expect(textarea.selectionStart).toBe(11);
|
|
});
|
|
|
|
it("replaces selected text with the transcript", () => {
|
|
const { textarea } = renderTaskFormInputs("hello world");
|
|
textarea.focus();
|
|
textarea.setSelectionRange(6, 11);
|
|
|
|
act(() => lastVoiceProps().onTranscript("there"));
|
|
|
|
expect(textarea.value).toBe("hello there");
|
|
});
|
|
|
|
it("ignores empty / whitespace-only transcripts", () => {
|
|
const { textarea } = renderTaskFormInputs("hello");
|
|
textarea.focus();
|
|
textarea.setSelectionRange(5, 5);
|
|
|
|
act(() => lastVoiceProps().onTranscript(" "));
|
|
|
|
expect(textarea.value).toBe("hello");
|
|
});
|
|
|
|
it("inserts the transcript into a multi-line description at the line caret", () => {
|
|
const { textarea } = renderTaskFormInputs("line one\nline two");
|
|
textarea.focus();
|
|
// Caret right after "line one" on the first line — char-before is "e",
|
|
// non-whitespace, so a leading space is prepended.
|
|
textarea.setSelectionRange(8, 8);
|
|
|
|
act(() => lastVoiceProps().onTranscript("added"));
|
|
|
|
expect(textarea.value).toBe("line one added\nline two");
|
|
expect(textarea.selectionStart).toBe(14);
|
|
});
|
|
|
|
it("preserves internal newlines from the transcript", () => {
|
|
const { textarea } = renderTaskFormInputs("");
|
|
textarea.focus();
|
|
textarea.setSelectionRange(0, 0);
|
|
|
|
act(() => lastVoiceProps().onTranscript("first\nsecond"));
|
|
|
|
expect(textarea.value).toBe("first\nsecond");
|
|
});
|
|
|
|
it("treats existing tabs / newlines before the caret as whitespace (no extra space)", () => {
|
|
const { textarea } = renderTaskFormInputs("line\n");
|
|
textarea.focus();
|
|
textarea.setSelectionRange(5, 5);
|
|
|
|
act(() => lastVoiceProps().onTranscript("two"));
|
|
|
|
expect(textarea.value).toBe("line\ntwo");
|
|
});
|
|
});
|