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 Loading project settings… ;
}
if (query.isError || !query.data) {
return (
{query.error instanceof Error ? query.error.message : "Could not load project."}
);
}
return (
queryClient.invalidateQueries({ queryKey: workspaceQueryKey })}
projectId={projectId}
/>
);
}
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(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 (
);
}
function PermissionModeSelect({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) {
return (
onChange(v === "__default__" ? "" : v)}>
Project default
{PERMISSION_MODE_OPTIONS.map((opt) => (
{opt.label}
))}
);
}
function ReviewerSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) {
return (
onChange(v === "__default__" ? "" : v)}>
claude-code (default)
{REVIEWER_OPTIONS.map((reviewer) => (
{reviewer}
))}
);
}
function Field({ label, htmlFor, children }: { label: string; htmlFor?: string; children: React.ReactNode }) {
return (
{label}
{children}
);
}
function ReadonlyRow({ label, value }: { label: string; value: string }) {
return (
{label}
{value}
);
}
function CenteredNote({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
// Drop an object whose every value is undefined so we send `undefined` (omit)
// rather than an empty {} the daemon would persist.
function blankToUndefined(obj: T): T | undefined {
return Object.values(obj).some((v) => v !== undefined) ? obj : undefined;
}