diff --git a/frontend/src/renderer/components/NewTaskDialog.test.tsx b/frontend/src/renderer/components/NewTaskDialog.test.tsx new file mode 100644 index 000000000..456a2919e --- /dev/null +++ b/frontend/src/renderer/components/NewTaskDialog.test.tsx @@ -0,0 +1,60 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NewTaskDialog } from "./NewTaskDialog"; + +const { postMock } = vi.hoisted(() => ({ + postMock: vi.fn(), +})); + +vi.mock("../lib/api-client", () => ({ + apiClient: { + POST: postMock, + }, + apiErrorMessage: (error: unknown, fallback = "Request failed") => { + if (typeof error === "object" && error !== null && "message" in error) { + return String((error as { message: unknown }).message); + } + return fallback; + }, +})); + +beforeEach(() => { + postMock.mockReset(); + postMock.mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined }); +}); + +describe("NewTaskDialog", () => { + it("starts a worker task with the entered title and brief", async () => { + const onCreated = vi.fn(); + const onOpenChange = vi.fn(); + render(); + + await userEvent.type(screen.getByLabelText("Title"), "Fix fallback renderer"); + await userEvent.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails."); + await userEvent.click(screen.getByRole("button", { name: "Start task" })); + + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", { + body: { + projectId: "proj-1", + kind: "worker", + harness: "codex", + issueId: "Fix fallback renderer", + prompt: "Restore the fallback renderer after WebGL init fails.", + branch: undefined, + }, + }); + expect(onCreated).toHaveBeenCalledWith("task-1"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("requires both title and brief", async () => { + render(); + + await userEvent.click(screen.getByRole("button", { name: "Start task" })); + + expect(await screen.findByText("Title and brief are required.")).toBeInTheDocument(); + expect(postMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/renderer/components/NewTaskDialog.tsx b/frontend/src/renderer/components/NewTaskDialog.tsx new file mode 100644 index 000000000..385eb4076 --- /dev/null +++ b/frontend/src/renderer/components/NewTaskDialog.tsx @@ -0,0 +1,183 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import { Loader2, X } from "lucide-react"; +import { type FormEvent, useEffect, useId, useState } from "react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; +import type { AgentProvider } from "../types/workspace"; + +type NewTaskDialogProps = { + open: boolean; + projectId?: string; + onCreated: (sessionId: string) => void; + onOpenChange: (open: boolean) => void; +}; + +const AGENTS: Array<{ value: AgentProvider; label: string }> = [ + { value: "codex", label: "Codex" }, + { value: "claude-code", label: "Claude Code" }, + { value: "opencode", label: "OpenCode" }, + { value: "aider", label: "Aider" }, +]; + +export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewTaskDialogProps) { + const titleId = useId(); + const promptId = useId(); + const branchId = useId(); + const [title, setTitle] = useState(""); + const [prompt, setPrompt] = useState(""); + const [branch, setBranch] = useState(""); + const [agent, setAgent] = useState("codex"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(); + + useEffect(() => { + if (!open) { + setTitle(""); + setPrompt(""); + setBranch(""); + setAgent("codex"); + setError(undefined); + setIsSubmitting(false); + } + }, [open]); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!projectId || isSubmitting) return; + + const cleanTitle = title.trim(); + const cleanPrompt = prompt.trim(); + const cleanBranch = branch.trim(); + if (!cleanTitle || !cleanPrompt) { + setError("Title and brief are required."); + return; + } + + setIsSubmitting(true); + setError(undefined); + try { + const { data, error: apiError } = await apiClient.POST("/api/v1/sessions", { + body: { + projectId, + kind: "worker", + harness: agent, + issueId: cleanTitle, + prompt: cleanPrompt, + branch: cleanBranch || undefined, + }, + }); + if (apiError) throw new Error(apiErrorMessage(apiError, "Unable to start task")); + if (!data?.session?.id) throw new Error("Task creation returned no session"); + onCreated(data.session.id); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to start task"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + +
+
+ New task + + Start a worker directly from this project. + +
+ + + +
+ +
+
+ + setTitle(event.target.value)} + /> +
+ +
+ +