fix(new-task): use project default agent and expose all agents in spawn dialog (#2291)
This commit is contained in:
parent
7c4a77d7cc
commit
78cbe22928
|
|
@ -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", () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function renderDialog() {
|
||||||
|
const onCreated = vi.fn();
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<NewTaskDialog open projectId="proj-1" onCreated={onCreated} onOpenChange={onOpenChange} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return { onCreated, onOpenChange };
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnBody() {
|
||||||
|
return (postMock.mock.calls[0][1] as { body: Record<string, unknown> }).body;
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
postMock.mockReset();
|
getMock.mockReset().mockResolvedValue({
|
||||||
postMock.mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined });
|
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", () => {
|
afterEach(() => vi.restoreAllMocks());
|
||||||
it("starts a worker task with the entered title and brief", async () => {
|
|
||||||
const onCreated = vi.fn();
|
|
||||||
const onOpenChange = vi.fn();
|
|
||||||
render(<NewTaskDialog open projectId="proj-1" onCreated={onCreated} onOpenChange={onOpenChange} />);
|
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText("Title"), "Fix fallback renderer");
|
describe("NewTaskDialog", () => {
|
||||||
await userEvent.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails.");
|
it("preselects the project's default agent and omits harness so the daemon applies it", async () => {
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Start task" }));
|
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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue