import * as React from "react";
import type { ProjectSettings } from "../api/settings";
import { settingsApi } from "../api/settings";
const { useState, useEffect } = React;
interface SettingsPageProps {
onBack: () => void;
}
const fieldStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: "4px",
};
const labelStyle: React.CSSProperties = {
fontSize: "0.8em",
color: "#aaa",
fontWeight: 500,
};
const descStyle: React.CSSProperties = {
fontSize: "0.75em",
color: "#666",
marginTop: "2px",
};
const inputStyle: React.CSSProperties = {
padding: "8px 10px",
borderRadius: "6px",
border: "1px solid #333",
background: "#1e1e1e",
color: "#ececec",
fontSize: "0.9em",
fontFamily: "monospace",
outline: "none",
};
const sectionStyle: React.CSSProperties = {
background: "#1e1e1e",
border: "1px solid #333",
borderRadius: "8px",
padding: "20px",
display: "flex",
flexDirection: "column",
gap: "16px",
};
const sectionTitleStyle: React.CSSProperties = {
fontSize: "0.85em",
fontWeight: 600,
color: "#aaa",
textTransform: "uppercase",
letterSpacing: "0.06em",
marginBottom: "2px",
};
interface TextFieldProps {
label: string;
description?: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
}
function TextField({
label,
description,
value,
onChange,
placeholder,
}: TextFieldProps) {
return (
{description && {description}}
onChange(e.target.value)}
placeholder={placeholder ?? ""}
style={inputStyle}
autoComplete="off"
/>
);
}
interface NumberFieldProps {
label: string;
description?: string;
value: number | null;
onChange: (v: number | null) => void;
min?: number;
placeholder?: string;
}
function NumberField({
label,
description,
value,
onChange,
min,
placeholder,
}: NumberFieldProps) {
return (
{description && {description}}
{
const raw = e.target.value.trim();
if (raw === "") {
onChange(null);
} else {
const n = Number(raw);
if (!Number.isNaN(n)) onChange(n);
}
}}
placeholder={placeholder ?? ""}
style={inputStyle}
/>
);
}
interface CheckboxFieldProps {
label: string;
description?: string;
checked: boolean;
onChange: (v: boolean) => void;
}
function CheckboxField({
label,
description,
checked,
onChange,
}: CheckboxFieldProps) {
return (
{description && {description}}
);
}
const QA_MODES = ["server", "agent", "human"] as const;
/** Settings page — form-based editor for project.toml scalar settings. */
export function SettingsPage({ onBack }: SettingsPageProps) {
const [settings, setSettings] = useState(null);
const [status, setStatus] = useState<
"idle" | "loading" | "saving" | "saved" | "error"
>("loading");
const [errorMsg, setErrorMsg] = useState(null);
const [validationErrors, setValidationErrors] = useState<
Record
>({});
useEffect(() => {
settingsApi
.getProjectSettings()
.then((s) => {
setSettings(s);
setStatus("idle");
})
.catch((e: unknown) => {
setStatus("error");
setErrorMsg(e instanceof Error ? e.message : "Failed to load settings");
});
}, []);
function patch(partial: Partial) {
setSettings((prev) => (prev ? { ...prev, ...partial } : prev));
setValidationErrors({});
}
function validate(s: ProjectSettings): Record {
const errors: Record = {};
if (!QA_MODES.includes(s.default_qa as (typeof QA_MODES)[number])) {
errors.default_qa = `Must be one of: ${QA_MODES.join(", ")}`;
}
if (s.max_retries < 0) {
errors.max_retries = "Must be 0 or greater";
}
if (s.watcher_sweep_interval_secs < 1) {
errors.watcher_sweep_interval_secs = "Must be at least 1 second";
}
if (s.watcher_done_retention_secs < 1) {
errors.watcher_done_retention_secs = "Must be at least 1 second";
}
return errors;
}
async function handleSave() {
if (!settings) return;
const errors = validate(settings);
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
return;
}
setStatus("saving");
setErrorMsg(null);
try {
const saved = await settingsApi.putProjectSettings(settings);
setSettings(saved);
setStatus("saved");
setTimeout(() => setStatus("idle"), 2000);
} catch (e) {
setStatus("error");
setErrorMsg(e instanceof Error ? e.message : "Save failed");
}
}
const s = settings;
return (
{/* Header */}
Project Settings
{/* Body */}
{status === "loading" && (
Loading settings…
)}
{status === "error" && !s && (
Error: {errorMsg}
)}
{s && (
<>
{/* Pipeline */}
Pipeline
How stories are QA-reviewed after the coder stage. Default:
server.
{validationErrors.default_qa && (
{validationErrors.default_qa}
)}
patch({ max_retries: v ?? 0 })}
/>
{validationErrors.max_retries && (
{validationErrors.max_retries}
)}
patch({ max_coders: v })}
/>
patch({ default_coder_model: v.trim() || null })
}
placeholder="e.g. sonnet"
/>
{/* Git */}
Git
patch({ base_branch: v.trim() || null })}
placeholder="e.g. master"
/>
{/* Notifications */}
Notifications
patch({ rate_limit_notifications: v })}
/>
{/* Advanced */}
Advanced
patch({ timezone: v.trim() || null })}
placeholder="e.g. Europe/London"
/>
patch({ rendezvous: v.trim() || null })}
placeholder="e.g. ws://host:3001/crdt-sync"
/>
{/* Watcher */}
Archiver
patch({ watcher_sweep_interval_secs: v ?? 60 })
}
/>
{validationErrors.watcher_sweep_interval_secs && (
{validationErrors.watcher_sweep_interval_secs}
)}
patch({ watcher_done_retention_secs: v ?? 14400 })
}
/>
{validationErrors.watcher_done_retention_secs && (
{validationErrors.watcher_done_retention_secs}
)}
{/* Save */}
{status === "error" && errorMsg && (
{errorMsg}
)}
>
)}
);
}