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>
243 lines
7.5 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|