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

303 lines
10 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import type { components } from "../../api/schema";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { DEFAULT_PROJECT_AGENT } from "../lib/agent-options";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { RequiredAgentField } from "./CreateProjectAgentSheet";
import { DashboardSubhead } from "./DashboardSubhead";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Label } from "./ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
type Project = components["schemas"]["Project"];
type ProjectConfig = components["schemas"]["ProjectConfig"];
const PERMISSION_MODE_OPTIONS = [
{ value: "default", label: "Default" },
{ value: "accept-edits", label: "Accept edits" },
{ value: "auto", label: "Auto" },
{ value: "bypass-permissions", label: "Bypass permissions" },
] as const;
const REVIEWER_OPTIONS = ["claude-code"] as const;
const projectQueryKey = (id: string) => ["project", id] as const;
export function ProjectSettingsForm({ projectId }: { projectId: string }) {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: projectQueryKey(projectId),
queryFn: async () => {
const { data, error } = await apiClient.GET("/api/v1/projects/{id}", {
params: { path: { id: projectId } },
});
if (error) throw new Error(apiErrorMessage(error));
if (data?.status !== "ok") throw new Error("Project config is unavailable (degraded).");
return data.project as Project;
},
});
if (query.isLoading) {
return <CenteredNote>Loading project settings</CenteredNote>;
}
if (query.isError || !query.data) {
return (
<CenteredNote>{query.error instanceof Error ? query.error.message : "Could not load project."}</CenteredNote>
);
}
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<DashboardSubhead title="Settings" subtitle={query.data.path} />
<div className="min-h-0 flex-1 overflow-y-auto p-[18px]">
<SettingsBody
key={projectId}
project={query.data}
onSaved={() => queryClient.invalidateQueries({ queryKey: workspaceQueryKey })}
projectId={projectId}
/>
</div>
</div>
);
}
function SettingsBody({ project, projectId, onSaved }: { project: Project; projectId: string; onSaved: () => void }) {
const queryClient = useQueryClient();
const config = project.config ?? {};
const [form, setForm] = useState({
defaultBranch: config.defaultBranch ?? project.defaultBranch ?? "",
sessionPrefix: config.sessionPrefix ?? "",
workerAgent: config.worker?.agent || DEFAULT_PROJECT_AGENT,
orchestratorAgent: config.orchestrator?.agent || DEFAULT_PROJECT_AGENT,
model: config.agentConfig?.model ?? "",
permissions: config.agentConfig?.permissions ?? "",
reviewerHarness: config.reviewers?.[0]?.harness ?? "",
});
const [savedAt, setSavedAt] = useState<number | null>(null);
const mutation = useMutation({
mutationFn: async () => {
// PUT replaces the whole config; merge the edited fields over what loaded
// so we don't drop env/symlinks/postCreate the form doesn't expose.
const next: ProjectConfig = {
...config,
defaultBranch: form.defaultBranch || undefined,
sessionPrefix: form.sessionPrefix || undefined,
worker: { ...config.worker, agent: form.workerAgent },
orchestrator: { ...config.orchestrator, agent: form.orchestratorAgent },
agentConfig: blankToUndefined({
...config.agentConfig,
model: form.model || undefined,
permissions: form.permissions || undefined,
}),
reviewers: form.reviewerHarness ? [{ harness: form.reviewerHarness }] : undefined,
};
const { error } = await apiClient.PUT("/api/v1/projects/{id}/config", {
params: { path: { id: projectId } },
body: { config: next },
});
if (error) throw new Error(apiErrorMessage(error));
},
onSuccess: () => {
setSavedAt(Date.now());
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
onSaved();
},
});
return (
<form
className="mx-auto flex max-w-2xl flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
setSavedAt(null);
mutation.mutate();
}}
>
<Card>
<CardHeader>
<CardTitle className="text-[13px]">Identity</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2 font-mono text-[12px] text-muted-foreground">
<ReadonlyRow label="id" value={project.id} />
<ReadonlyRow label="path" value={project.path} />
<ReadonlyRow label="repo" value={project.repo || "—"} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-[13px]">Worktrees</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field label="Default branch" htmlFor="defaultBranch">
<input
id="defaultBranch"
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-[13px] text-foreground placeholder:text-passive focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-weak"
value={form.defaultBranch}
onChange={(e) => setForm((f) => ({ ...f, defaultBranch: e.target.value }))}
placeholder="main"
/>
</Field>
<Field label="Session prefix" htmlFor="sessionPrefix">
<input
id="sessionPrefix"
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-[13px] text-foreground placeholder:text-passive focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-weak"
value={form.sessionPrefix}
onChange={(e) => setForm((f) => ({ ...f, sessionPrefix: e.target.value }))}
placeholder="ao"
/>
</Field>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-[13px]">Agents</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<RequiredAgentField
id="workerAgent"
value={form.workerAgent}
placeholder="Select worker agent"
label="Default worker agent"
onChange={(v) => setForm((f) => ({ ...f, workerAgent: v }))}
/>
<RequiredAgentField
id="orchestratorAgent"
value={form.orchestratorAgent}
placeholder="Select orchestrator agent"
label="Default orchestrator agent"
onChange={(v) => setForm((f) => ({ ...f, orchestratorAgent: v }))}
/>
<Field label="Model override" htmlFor="model">
<input
id="model"
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-[13px] text-foreground placeholder:text-passive focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-weak"
value={form.model}
onChange={(e) => setForm((f) => ({ ...f, model: e.target.value }))}
placeholder="(agent default)"
/>
</Field>
<Field label="Permission mode" htmlFor="permissionMode">
<PermissionModeSelect
id="permissionMode"
value={form.permissions}
onChange={(v) => setForm((f) => ({ ...f, permissions: v }))}
/>
</Field>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-[13px]">Reviewers</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field label="Default reviewer agent" htmlFor="reviewerHarness">
<ReviewerSelect
id="reviewerHarness"
value={form.reviewerHarness}
onChange={(v) => setForm((f) => ({ ...f, reviewerHarness: v }))}
/>
</Field>
</CardContent>
</Card>
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={mutation.isPending}>
{mutation.isPending ? "Saving…" : "Save changes"}
</Button>
{mutation.isError && (
<span className="text-[12px] text-error">
{mutation.error instanceof Error ? mutation.error.message : "Save failed"}
</span>
)}
{savedAt && !mutation.isPending && !mutation.isError && (
<span className="text-[12px] text-success">Saved.</span>
)}
</div>
</form>
);
}
function PermissionModeSelect({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
<SelectTrigger id={id} className="h-8 w-full text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Project default</SelectItem>
{PERMISSION_MODE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function ReviewerSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) {
return (
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
<SelectTrigger id={id} className="h-8 w-full text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">claude-code (default)</SelectItem>
{REVIEWER_OPTIONS.map((reviewer) => (
<SelectItem key={reviewer} value={reviewer}>
{reviewer}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function Field({ label, htmlFor, children }: { label: string; htmlFor?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={htmlFor} className="text-[12px] text-muted-foreground">
{label}
</Label>
{children}
</div>
);
}
function ReadonlyRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-3">
<span className="w-12 shrink-0 text-passive">{label}</span>
<span className="min-w-0 flex-1 truncate text-foreground">{value}</span>
</div>
);
}
function CenteredNote({ children }: { children: React.ReactNode }) {
return (
<div className="grid h-full place-items-center bg-background p-6 text-center text-[12px] text-passive">
{children}
</div>
);
}
// Drop an object whose every value is undefined so we send `undefined` (omit)
// rather than an empty {} the daemon would persist.
function blankToUndefined<T extends object>(obj: T): T | undefined {
return Object.values(obj).some((v) => v !== undefined) ? obj : undefined;
}