diff --git a/frontend/src/renderer/components/NewTaskDialog.test.tsx b/frontend/src/renderer/components/NewTaskDialog.test.tsx index 456a2919e..d7e480f5a 100644 --- a/frontend/src/renderer/components/NewTaskDialog.test.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.test.tsx @@ -1,15 +1,18 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { NewTaskDialog } from "./NewTaskDialog"; -const { postMock } = vi.hoisted(() => ({ +const { getMock, postMock } = vi.hoisted(() => ({ + getMock: vi.fn(), postMock: vi.fn(), })); vi.mock("../lib/api-client", () => ({ apiClient: { - POST: postMock, + GET: (...args: unknown[]) => getMock(...args), + POST: (...args: unknown[]) => postMock(...args), }, apiErrorMessage: (error: unknown, fallback = "Request failed") => { if (typeof error === "object" && error !== null && "message" in error) { @@ -19,27 +22,48 @@ vi.mock("../lib/api-client", () => ({ }, })); +function renderDialog() { + const onCreated = vi.fn(); + const onOpenChange = vi.fn(); + render( + + + , + ); + return { onCreated, onOpenChange }; +} + +function spawnBody() { + return (postMock.mock.calls[0][1] as { body: Record }).body; +} + beforeEach(() => { - postMock.mockReset(); - postMock.mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined }); + getMock.mockReset().mockResolvedValue({ + data: { status: "ok", project: { id: "proj-1", config: { worker: { agent: "claude-code" } } } }, + error: undefined, + }); + postMock.mockReset().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(); +afterEach(() => vi.restoreAllMocks()); - 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" })); +describe("NewTaskDialog", () => { + it("preselects the project's default agent and omits harness so the daemon applies it", async () => { + const { onCreated, onOpenChange } = renderDialog(); + const user = userEvent.setup(); + + await screen.findByText("claude-code"); + + await user.type(screen.getByLabelText("Title"), "Fix fallback renderer"); + await user.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails."); + await user.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", + harness: undefined, issueId: "Fix fallback renderer", prompt: "Restore the fallback renderer after WebGL init fails.", branch: undefined, @@ -49,10 +73,28 @@ describe("NewTaskDialog", () => { expect(onOpenChange).toHaveBeenCalledWith(false); }); - it("requires both title and brief", async () => { - render(); + it("sends the chosen harness when the user overrides the default, including agents beyond the legacy four", async () => { + renderDialog(); + const user = userEvent.setup(); + await screen.findByText("claude-code"); - await userEvent.click(screen.getByRole("button", { name: "Start task" })); + await user.type(screen.getByLabelText("Title"), "T"); + await user.type(screen.getByLabelText("Brief"), "B"); + + await user.click(screen.getByRole("combobox", { name: "Agent" })); + await user.click(await screen.findByRole("option", { name: "cursor" })); + + await user.click(screen.getByRole("button", { name: "Start task" })); + + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(spawnBody().harness).toBe("cursor"); + }); + + it("requires both title and brief", async () => { + renderDialog(); + const user = userEvent.setup(); + + await user.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 index 385eb4076..cef37d5b8 100644 --- a/frontend/src/renderer/components/NewTaskDialog.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.tsx @@ -1,12 +1,16 @@ import * as Dialog from "@radix-ui/react-dialog"; +import { useQuery } from "@tanstack/react-query"; 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 { RequiredAgentField } from "./CreateProjectAgentSheet"; +import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import type { AgentProvider } from "../types/workspace"; +type Project = components["schemas"]["Project"]; + type NewTaskDialogProps = { open: boolean; projectId?: string; @@ -14,35 +18,51 @@ type NewTaskDialogProps = { 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 agentId = useId(); const [title, setTitle] = useState(""); const [prompt, setPrompt] = useState(""); const [branch, setBranch] = useState(""); - const [agent, setAgent] = useState("codex"); + const [agent, setAgent] = useState(""); + const [agentTouched, setAgentTouched] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(); + const projectQuery = useQuery({ + queryKey: ["project", projectId], + enabled: open && Boolean(projectId), + queryFn: async () => { + const { data, error: apiError } = await apiClient.GET("/api/v1/projects/{id}", { + params: { path: { id: projectId as string } }, + }); + if (apiError) throw new Error(apiErrorMessage(apiError)); + if (data?.status !== "ok") throw new Error("Project config is unavailable."); + return data.project as Project; + }, + }); + const defaultWorkerAgent = projectQuery.data?.config?.worker?.agent ?? ""; + useEffect(() => { if (!open) { setTitle(""); setPrompt(""); setBranch(""); - setAgent("codex"); + setAgent(""); + setAgentTouched(false); setError(undefined); setIsSubmitting(false); } }, [open]); + useEffect(() => { + if (open && !agentTouched) { + setAgent(defaultWorkerAgent); + } + }, [open, agentTouched, defaultWorkerAgent]); + const submit = async (event: FormEvent) => { event.preventDefault(); if (!projectId || isSubmitting) return; @@ -62,7 +82,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT body: { projectId, kind: "worker", - harness: agent, + harness: agentTouched && agent ? (agent as AgentProvider) : undefined, issueId: cleanTitle, prompt: cleanPrompt, branch: cleanBranch || undefined, @@ -130,21 +150,16 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT - - Agent - setAgent(value as AgentProvider)}> - - - - - {AGENTS.map((entry) => ( - - {entry.label} - - ))} - - - + { + setAgent(value); + setAgentTouched(true); + }} + /> Branch