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 (
{ event.preventDefault(); setSavedAt(null); mutation.mutate(); }} > Identity Worktrees setForm((f) => ({ ...f, defaultBranch: e.target.value }))} placeholder="main" /> setForm((f) => ({ ...f, sessionPrefix: e.target.value }))} placeholder="ao" /> Agents setForm((f) => ({ ...f, workerAgent: v }))} /> setForm((f) => ({ ...f, orchestratorAgent: v }))} /> setForm((f) => ({ ...f, model: e.target.value }))} placeholder="(agent default)" /> setForm((f) => ({ ...f, permissions: v }))} /> Reviewers setForm((f) => ({ ...f, reviewerHarness: v }))} />
{mutation.isError && ( {mutation.error instanceof Error ? mutation.error.message : "Save failed"} )} {savedAt && !mutation.isPending && !mutation.isError && ( Saved. )}
); } function PermissionModeSelect({ id, value, onChange, }: { id: string; value: string; onChange: (value: string) => void; }) { return ( ); } function ReviewerSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) { return ( ); } function Field({ label, htmlFor, children }: { label: string; htmlFor?: string; children: React.ReactNode }) { return (
{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; }