Files
kandev/apps/web/components/task-create-dialog-form-body.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

243 lines
7.5 KiB
TypeScript

import { createRef } from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
CreateEditSelectors,
DialogPromptSection,
WorkflowSection,
} from "./task-create-dialog-form-body";
import type { DialogFormState, TaskFormInputsHandle } from "./task-create-dialog-types";
vi.mock("@/components/workflow-selector-row", () => ({
WorkflowSelectorRow: ({ selectedWorkflowId }: { selectedWorkflowId: string | null }) => (
<button type="button">Workflow selector {selectedWorkflowId ?? "none"}</button>
),
}));
// Capture every render of TaskFormInputs so passthrough-related assertions can
// inspect the props that DialogPromptSection forwards to the textarea.
const taskFormInputsCalls: Array<Record<string, unknown>> = [];
vi.mock("@/components/task-create-dialog-selectors", () => ({
TaskFormInputs: (props: Record<string, unknown>) => {
taskFormInputsCalls.push(props);
return (
<textarea
data-testid="task-description-textarea"
placeholder={(props.placeholder as string | undefined) ?? "default placeholder"}
disabled={Boolean(props.disabled)}
/>
);
},
}));
const workflow = { id: "wf-1", name: "Development" };
function renderWorkflowSection(effectiveWorkflowId: string | null) {
return render(
<WorkflowSection
isCreateMode={true}
isTaskStarted={false}
workflows={[workflow]}
snapshots={{}}
effectiveWorkflowId={effectiveWorkflowId}
onWorkflowChange={() => {}}
agentProfiles={[]}
/>,
);
}
describe("WorkflowSection", () => {
it("keeps the selector reachable when no effective workflow is selected", () => {
renderWorkflowSection(null);
expect(screen.getByRole("button", { name: /workflow selector none/i })).toBeTruthy();
});
it("does not show redundant selector for a selected single workflow without overrides", () => {
const { container } = renderWorkflowSection("wf-1");
expect(container.textContent).toBe("");
});
});
function makeFs(): DialogFormState {
return {
taskName: "",
setTaskName: () => {},
hasTitle: false,
setHasTitle: () => {},
hasDescription: false,
setHasDescription: () => {},
draftDescription: "",
openCycle: 0,
currentDefaults: { name: "", description: "" },
descriptionInputRef: createRef<TaskFormInputsHandle>(),
repositories: [],
setRepositories: () => {},
addRepository: () => {},
removeRepository: () => {},
updateRepository: () => {},
agentProfileId: "",
setAgentProfileId: () => {},
executorId: "",
setExecutorId: () => {},
executorProfileId: "",
setExecutorProfileId: () => {},
discoveredRepositories: [],
setDiscoveredRepositories: () => {},
discoverReposLoading: false,
setDiscoverReposLoading: () => {},
discoverReposLoaded: false,
setDiscoverReposLoaded: () => {},
selectedWorkflowId: null,
setSelectedWorkflowId: () => {},
fetchedSteps: null,
setFetchedSteps: () => {},
isCreatingSession: false,
setIsCreatingSession: () => {},
isCreatingTask: false,
setIsCreatingTask: () => {},
useRemote: false,
setUseRemote: () => {},
remoteRepos: [],
setRemoteRepos: () => {},
addRemoteRepo: () => {},
removeRemoteRepo: () => {},
updateRemoteRepo: () => {},
branchesByUrl: {
branches: () => [],
loading: () => false,
ensure: () => undefined,
clear: () => undefined,
},
prInfoByUrl: {
info: () => undefined,
loading: () => false,
ensure: () => undefined,
clear: () => undefined,
},
githubUrlError: null,
setGitHubUrlError: () => {},
workflowAgentProfileId: "",
setWorkflowAgentProfileId: () => {},
clearDraft: () => {},
freshBranchEnabled: false,
setFreshBranchEnabled: () => {},
currentLocalBranch: "",
setCurrentLocalBranch: () => {},
currentLocalBranchLoading: false,
setCurrentLocalBranchLoading: () => {},
noRepository: false,
setNoRepository: () => {},
workspacePath: "",
setWorkspacePath: () => {},
};
}
describe("DialogPromptSection (CLI-mode parity)", () => {
it("keeps the prompt textarea enabled when the selected profile is passthrough", () => {
taskFormInputsCalls.length = 0;
render(
<DialogPromptSection
isSessionMode={false}
isTaskStarted={false}
initialDescription=""
fs={makeFs()}
handleKeyDown={(() => {}) as never}
descriptionPlaceholder="Write a prompt for the agent..."
/>,
);
const textarea = screen.getByTestId("task-description-textarea") as HTMLTextAreaElement;
expect(textarea.disabled).toBe(false);
expect(textarea.placeholder).toBe("Write a prompt for the agent...");
const last = taskFormInputsCalls.at(-1)!;
expect(last.disabled).toBe(false);
expect(last.placeholder).toBe("Write a prompt for the agent...");
});
it("does not render the legacy 'Prompt ignored — passthrough mode active' warning", () => {
const { container } = render(
<DialogPromptSection
isSessionMode={false}
isTaskStarted={false}
initialDescription="hello"
fs={makeFs()}
handleKeyDown={(() => {}) as never}
/>,
);
expect(container.textContent).not.toMatch(/prompt ignored/i);
expect(container.textContent).not.toMatch(/passthrough mode/i);
});
it("allows Jira/Linear import in passthrough (CLI) mode", () => {
taskFormInputsCalls.length = 0;
render(
<DialogPromptSection
isSessionMode={false}
isTaskStarted={false}
initialDescription=""
fs={makeFs()}
handleKeyDown={(() => {}) as never}
workspaceId="ws-1"
onJiraImport={() => {}}
onLinearImport={() => {}}
/>,
);
const last = taskFormInputsCalls.at(-1)!;
expect((last.jiraImport as { disabled: boolean } | undefined)?.disabled).toBe(false);
expect((last.linearImport as { disabled: boolean } | undefined)?.disabled).toBe(false);
});
it("forwards onVoiceAutoSend to TaskFormInputs", () => {
taskFormInputsCalls.length = 0;
const onVoiceAutoSend = () => {};
render(
<DialogPromptSection
isSessionMode={false}
isTaskStarted={false}
initialDescription=""
fs={makeFs()}
handleKeyDown={(() => {}) as never}
onVoiceAutoSend={onVoiceAutoSend}
/>,
);
const last = taskFormInputsCalls.at(-1)!;
expect(last.onVoiceAutoSend).toBe(onVoiceAutoSend);
});
});
describe("CreateEditSelectors", () => {
it("links credential setup to the selected executor profile", () => {
const EmptySelector = () => <button type="button">selector</button>;
render(
<CreateEditSelectors
isTaskStarted={false}
agentProfiles={[{ id: "agent-1", label: "Codex", agent_name: "codex" } as never]}
agentProfilesLoading={false}
agentProfileOptions={[]}
agentProfileId=""
onAgentProfileChange={() => {}}
isCreatingSession={false}
executorProfileOptions={[]}
executorProfileId="exec-profile-1"
onExecutorProfileChange={() => {}}
executorsLoading={false}
AgentSelectorComponent={EmptySelector}
ExecutorProfileSelectorComponent={EmptySelector}
workflowAgentLocked={false}
noCompatibleAgent={true}
executorProfileName="Docker"
/>,
);
expect(screen.getByRole("link", { name: /configure credentials/i }).getAttribute("href")).toBe(
"/settings/executors/exec-profile-1",
);
});
});