fix(new-task): use project default agent and expose all agents in spawn dialog (#2291)

This commit is contained in:
Apoorv Singh 2026-06-30 16:36:57 +05:30 committed by GitHub
parent 7c4a77d7cc
commit 78cbe22928
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 100 additions and 43 deletions

View File

@ -1,15 +1,18 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; 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"; import { NewTaskDialog } from "./NewTaskDialog";
const { postMock } = vi.hoisted(() => ({ const { getMock, postMock } = vi.hoisted(() => ({
getMock: vi.fn(),
postMock: vi.fn(), postMock: vi.fn(),
})); }));
vi.mock("../lib/api-client", () => ({ vi.mock("../lib/api-client", () => ({
apiClient: { apiClient: {
POST: postMock, GET: (...args: unknown[]) => getMock(...args),
POST: (...args: unknown[]) => postMock(...args),
}, },
apiErrorMessage: (error: unknown, fallback = "Request failed") => { apiErrorMessage: (error: unknown, fallback = "Request failed") => {
if (typeof error === "object" && error !== null && "message" in error) { if (typeof error === "object" && error !== null && "message" in error) {
@ -19,27 +22,48 @@ vi.mock("../lib/api-client", () => ({
}, },
})); }));
beforeEach(() => { function renderDialog() {
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 onCreated = vi.fn();
const onOpenChange = vi.fn(); const onOpenChange = vi.fn();
render(<NewTaskDialog open projectId="proj-1" onCreated={onCreated} onOpenChange={onOpenChange} />); render(
<QueryClientProvider client={new QueryClient()}>
<NewTaskDialog open projectId="proj-1" onCreated={onCreated} onOpenChange={onOpenChange} />
</QueryClientProvider>,
);
return { onCreated, onOpenChange };
}
await userEvent.type(screen.getByLabelText("Title"), "Fix fallback renderer"); function spawnBody() {
await userEvent.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails."); return (postMock.mock.calls[0][1] as { body: Record<string, unknown> }).body;
await userEvent.click(screen.getByRole("button", { name: "Start task" })); }
beforeEach(() => {
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 });
});
afterEach(() => vi.restoreAllMocks());
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)); await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", { expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", {
body: { body: {
projectId: "proj-1", projectId: "proj-1",
kind: "worker", kind: "worker",
harness: "codex", harness: undefined,
issueId: "Fix fallback renderer", issueId: "Fix fallback renderer",
prompt: "Restore the fallback renderer after WebGL init fails.", prompt: "Restore the fallback renderer after WebGL init fails.",
branch: undefined, branch: undefined,
@ -49,10 +73,28 @@ describe("NewTaskDialog", () => {
expect(onOpenChange).toHaveBeenCalledWith(false); expect(onOpenChange).toHaveBeenCalledWith(false);
}); });
it("requires both title and brief", async () => { it("sends the chosen harness when the user overrides the default, including agents beyond the legacy four", async () => {
render(<NewTaskDialog open projectId="proj-1" onCreated={vi.fn()} onOpenChange={vi.fn()} />); 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(await screen.findByText("Title and brief are required.")).toBeInTheDocument();
expect(postMock).not.toHaveBeenCalled(); expect(postMock).not.toHaveBeenCalled();

View File

@ -1,12 +1,16 @@
import * as Dialog from "@radix-ui/react-dialog"; import * as Dialog from "@radix-ui/react-dialog";
import { useQuery } from "@tanstack/react-query";
import { Loader2, X } from "lucide-react"; import { Loader2, X } from "lucide-react";
import { type FormEvent, useEffect, useId, useState } from "react"; import { type FormEvent, useEffect, useId, useState } from "react";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Input } from "./ui/input"; 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 { apiClient, apiErrorMessage } from "../lib/api-client";
import type { AgentProvider } from "../types/workspace"; import type { AgentProvider } from "../types/workspace";
type Project = components["schemas"]["Project"];
type NewTaskDialogProps = { type NewTaskDialogProps = {
open: boolean; open: boolean;
projectId?: string; projectId?: string;
@ -14,35 +18,51 @@ type NewTaskDialogProps = {
onOpenChange: (open: boolean) => 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) { export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewTaskDialogProps) {
const titleId = useId(); const titleId = useId();
const promptId = useId(); const promptId = useId();
const branchId = useId(); const branchId = useId();
const agentId = useId();
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [branch, setBranch] = useState(""); const [branch, setBranch] = useState("");
const [agent, setAgent] = useState<AgentProvider>("codex"); const [agent, setAgent] = useState("");
const [agentTouched, setAgentTouched] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | undefined>(); const [error, setError] = useState<string | undefined>();
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(() => { useEffect(() => {
if (!open) { if (!open) {
setTitle(""); setTitle("");
setPrompt(""); setPrompt("");
setBranch(""); setBranch("");
setAgent("codex"); setAgent("");
setAgentTouched(false);
setError(undefined); setError(undefined);
setIsSubmitting(false); setIsSubmitting(false);
} }
}, [open]); }, [open]);
useEffect(() => {
if (open && !agentTouched) {
setAgent(defaultWorkerAgent);
}
}, [open, agentTouched, defaultWorkerAgent]);
const submit = async (event: FormEvent<HTMLFormElement>) => { const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (!projectId || isSubmitting) return; if (!projectId || isSubmitting) return;
@ -62,7 +82,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
body: { body: {
projectId, projectId,
kind: "worker", kind: "worker",
harness: agent, harness: agentTouched && agent ? (agent as AgentProvider) : undefined,
issueId: cleanTitle, issueId: cleanTitle,
prompt: cleanPrompt, prompt: cleanPrompt,
branch: cleanBranch || undefined, branch: cleanBranch || undefined,
@ -130,21 +150,16 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
</div> </div>
<div className="grid gap-3 sm:grid-cols-[1fr_1fr]"> <div className="grid gap-3 sm:grid-cols-[1fr_1fr]">
<div className="space-y-1.5"> <RequiredAgentField
<label className="text-[12px] font-medium text-muted-foreground">Agent</label> id={agentId}
<Select value={agent} onValueChange={(value) => setAgent(value as AgentProvider)}> label="Agent"
<SelectTrigger className="h-8 w-full text-[13px]"> placeholder="Project default"
<SelectValue /> value={agent}
</SelectTrigger> onChange={(value) => {
<SelectContent> setAgent(value);
{AGENTS.map((entry) => ( setAgentTouched(true);
<SelectItem key={entry.value} value={entry.value}> }}
{entry.label} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[12px] font-medium text-muted-foreground" htmlFor={branchId}> <label className="text-[12px] font-medium text-muted-foreground" htmlFor={branchId}>
Branch Branch