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} )}
)}
); }