Files
kandev/apps/web/components/task-create-dialog-selectors.test.tsx
T
João Salavisa f23d11dc64 feat(web): add voice mode to task create dialog (#1230)
* 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>
2026-06-01 18:38:51 +01:00

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