agent-orchestrator/frontend/src/renderer/components/SpawnWorkerModal.tsx

305 lines
10 KiB
TypeScript

import * as Dialog from "@radix-ui/react-dialog";
import { ChevronDown, CornerDownLeft, X } from "lucide-react";
import { FormEvent, useEffect, useState } from "react";
import type { AgentProvider, WorkspaceSummary } from "../types/workspace";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
const agentOptions: { value: AgentProvider; label: string }[] = [
{ value: "claude-code", label: "claude-code" },
{ value: "codex", label: "codex" },
{ value: "opencode", label: "opencode" },
{ value: "amp", label: "amp" },
{ value: "goose", label: "goose" },
{ value: "kiro", label: "kiro" },
{ value: "kimi", label: "kimi" },
{ value: "crush", label: "crush" },
{ value: "vibe", label: "vibe" },
];
const basedOnTabs = ["Branch", "Issue", "Pull Request"] as const;
// The project's default branch — selecting it in "Based on" means "new session
// branch off the default", not "check out the default branch itself".
const BASE_BRANCH = "main";
type BasedOn = (typeof basedOnTabs)[number];
const NAME_RULE = /^[a-z0-9-]+$/;
type SpawnWorkerModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaces: WorkspaceSummary[];
defaultProjectId?: string;
onCreateTask: (input: {
projectId: string;
prompt: string;
branch?: string;
harness?: AgentProvider;
}) => Promise<void>;
};
export function SpawnWorkerModal({
open,
onOpenChange,
workspaces,
defaultProjectId,
onCreateTask,
}: SpawnWorkerModalProps) {
const fallbackProjectId = defaultProjectId ?? workspaces[0]?.id ?? "";
const [name, setName] = useState("");
const [projectId, setProjectId] = useState(fallbackProjectId);
const [agent, setAgent] = useState<AgentProvider>("claude-code");
const [basedOn, setBasedOn] = useState<BasedOn>("Branch");
const [branch, setBranch] = useState(BASE_BRANCH);
const [tab, setTab] = useState<"Prompt" | "Workspace">("Prompt");
const [prompt, setPrompt] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset to the launching project each time the dialog opens.
useEffect(() => {
if (open) {
setProjectId(fallbackProjectId);
setError(null);
}
}, [open, fallbackProjectId]);
const selectedWorkspace = workspaces.find((workspace) => workspace.id === projectId) ?? workspaces[0];
const branchOptions = Array.from(
new Set([BASE_BRANCH, ...(selectedWorkspace?.sessions.map((session) => session.branch).filter(Boolean) ?? [])]),
);
const nameValid = name === "" || NAME_RULE.test(name);
const canSubmit = prompt.trim().length > 0 && projectId !== "" && nameValid && !isSubmitting;
const submit = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!canSubmit) return;
setError(null);
setIsSubmitting(true);
try {
// The API's `branch` field means "check out this exact branch in the
// session worktree" — valid for resuming an existing session branch, but
// never for the base branch itself (git refuses a second worktree on a
// checked-out branch; daemon manager.go). "Based on main" therefore
// OMITS branch so the daemon mints a fresh ao/<sessionId> off the
// project's default branch.
const trimmedBranch = branch.trim();
await onCreateTask({
projectId,
prompt: prompt.trim(),
branch:
basedOn === "Branch" && trimmedBranch !== "" && trimmedBranch !== BASE_BRANCH ? trimmedBranch : undefined,
harness: agent,
});
setName("");
setPrompt("");
setBranch(BASE_BRANCH);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not spawn worker");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/50 data-[state=open]:animate-overlay-in" />
<Dialog.Content
aria-describedby={undefined}
className="fixed left-1/2 top-1/2 z-50 w-[512px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-xl bg-background text-foreground shadow-[0_24px_70px_rgb(0_0_0_/_0.55)] ring-1 ring-foreground/10 focus-visible:outline-none data-[state=open]:animate-modal-in"
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
void submit();
}
}}
>
<div className="flex items-center gap-2 border-b border-border px-[18px] py-[15px]">
<Dialog.Title className="font-mono text-[11px] uppercase tracking-[0.14em] text-passive">
New worker
</Dialog.Title>
<Dialog.Close
aria-label="Close"
className="ml-auto inline-flex text-passive transition-colors hover:text-foreground"
>
<X className="h-[15px] w-[15px]" aria-hidden="true" />
</Dialog.Close>
</div>
<form className="flex flex-col gap-[15px] p-[18px]" onSubmit={submit}>
<div className="flex flex-col gap-1.5">
<input
aria-label="Worker name"
autoFocus
className="w-full border-none bg-transparent p-0 text-lg font-medium text-foreground outline-none placeholder:text-passive"
onChange={(event) => setName(event.target.value)}
placeholder="worker-name"
value={name}
/>
<span className={cn("text-[11.5px]", nameValid ? "text-passive" : "text-error")}>
Worker names allow letters, numbers, and hyphens.
</span>
</div>
<SelectRow
label="Project"
aria-label="Project"
onChange={setProjectId}
value={projectId}
options={workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name }))}
/>
<SelectRow
label="Agent"
aria-label="Agent"
onChange={(value) => setAgent(value as AgentProvider)}
value={agent}
options={agentOptions}
/>
<div className="overflow-hidden rounded-lg border border-border">
<div className="flex items-center gap-2 border-b border-border px-2.5 py-1.5">
<span className="text-[13px] text-muted-foreground">Based on</span>
<div className="ml-auto inline-flex overflow-hidden rounded-md border border-border">
{basedOnTabs.map((option) => (
<button
className={cn(
"px-2.5 py-0.5 text-[11.5px] transition-colors",
basedOn === option
? "bg-raised text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
key={option}
onClick={() => setBasedOn(option)}
type="button"
>
{option}
</button>
))}
</div>
</div>
<div className="p-2.5">
{basedOn === "Branch" ? (
<SelectControl
aria-label="Based on branch"
className="flex w-full"
onChange={setBranch}
value={branch}
options={branchOptions.map((option) => ({ value: option, label: option }))}
/>
) : (
<p className="px-1 py-1.5 text-[12.5px] text-passive">
{basedOn === "Issue" ? "Pick an issue to start from." : "Pick a pull request to start from."}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-1">
{(["Prompt", "Workspace"] as const).map((option) => (
<button
className={cn(
"rounded-md px-2.5 py-1 text-[12.5px] transition-colors",
tab === option ? "bg-raised text-foreground" : "text-muted-foreground hover:text-foreground",
)}
key={option}
onClick={() => setTab(option)}
type="button"
>
{option}
</button>
))}
</div>
{tab === "Prompt" ? (
<textarea
aria-label="Prompt"
className="min-h-[74px] w-full resize-none rounded-md border border-border bg-transparent px-2.5 py-2 text-[13px] text-foreground outline-none placeholder:text-passive focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-weak"
onChange={(event) => setPrompt(event.target.value)}
placeholder="What should this worker do?"
value={prompt}
/>
) : (
<p className="rounded-md border border-border px-2.5 py-2 font-mono text-[12px] text-passive">
~/.rc/wt/{selectedWorkspace?.name ?? "project"}/{name || "worker-name"}
</p>
)}
</div>
{error && (
<p className="text-[12px] text-error" role="alert">
{error}
</p>
)}
<div className="-mx-[18px] -mb-[18px] flex justify-end border-t border-border bg-surface px-3.5 py-3">
<Button disabled={!canSubmit} type="submit" variant="primary">
Spawn worker
<span className="ml-0.5 inline-flex items-center gap-0.5 rounded border border-accent-foreground/35 px-1 font-mono text-[10px] opacity-70">
<CornerDownLeft className="h-2.5 w-2.5" aria-hidden="true" />
</span>
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function SelectRow({
label,
"aria-label": ariaLabel,
value,
options,
onChange,
}: {
label: string;
"aria-label": string;
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
}) {
return (
<div className="flex items-center gap-2.5">
<span className="text-[13px] text-muted-foreground">{label}</span>
<div className="ml-auto">
<SelectControl aria-label={ariaLabel} onChange={onChange} options={options} value={value} />
</div>
</div>
);
}
function SelectControl({
"aria-label": ariaLabel,
value,
options,
onChange,
className,
}: {
"aria-label": string;
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
className?: string;
}) {
return (
<div className={cn("relative inline-flex h-7 items-center rounded-md border border-border", className)}>
<select
aria-label={ariaLabel}
className="h-full w-full cursor-pointer appearance-none bg-transparent pl-2.5 pr-7 text-[13px] text-foreground outline-none"
onChange={(event) => onChange(event.target.value)}
value={value}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 h-[13px] w-[13px] text-passive" aria-hidden="true" />
</div>
);
}