303 lines
10 KiB
TypeScript
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;
|
|
} |