462 lines
12 KiB
TypeScript
462 lines
12 KiB
TypeScript
|
|
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 (
|
||
|
|
<div style={fieldStyle}>
|
||
|
|
<label style={labelStyle}>{label}</label>
|
||
|
|
{description && <span style={descStyle}>{description}</span>}
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={value}
|
||
|
|
onChange={(e) => onChange(e.target.value)}
|
||
|
|
placeholder={placeholder ?? ""}
|
||
|
|
style={inputStyle}
|
||
|
|
autoComplete="off"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
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 (
|
||
|
|
<div style={fieldStyle}>
|
||
|
|
<label style={labelStyle}>{label}</label>
|
||
|
|
{description && <span style={descStyle}>{description}</span>}
|
||
|
|
<input
|
||
|
|
type="number"
|
||
|
|
value={value === null ? "" : value}
|
||
|
|
min={min}
|
||
|
|
onChange={(e) => {
|
||
|
|
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}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
interface CheckboxFieldProps {
|
||
|
|
label: string;
|
||
|
|
description?: string;
|
||
|
|
checked: boolean;
|
||
|
|
onChange: (v: boolean) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function CheckboxField({ label, description, checked, onChange }: CheckboxFieldProps) {
|
||
|
|
return (
|
||
|
|
<div style={fieldStyle}>
|
||
|
|
{description && <span style={descStyle}>{description}</span>}
|
||
|
|
<label
|
||
|
|
style={{
|
||
|
|
display: "flex",
|
||
|
|
alignItems: "center",
|
||
|
|
gap: "8px",
|
||
|
|
cursor: "pointer",
|
||
|
|
fontSize: "0.9em",
|
||
|
|
color: "#ccc",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={checked}
|
||
|
|
onChange={(e) => onChange(e.target.checked)}
|
||
|
|
/>
|
||
|
|
{label}
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
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<ProjectSettings | null>(null);
|
||
|
|
const [status, setStatus] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
|
||
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||
|
|
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||
|
|
|
||
|
|
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<ProjectSettings>) {
|
||
|
|
setSettings((prev) => (prev ? { ...prev, ...partial } : prev));
|
||
|
|
setValidationErrors({});
|
||
|
|
}
|
||
|
|
|
||
|
|
function validate(s: ProjectSettings): Record<string, string> {
|
||
|
|
const errors: Record<string, string> = {};
|
||
|
|
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 (
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
display: "flex",
|
||
|
|
flexDirection: "column",
|
||
|
|
height: "100%",
|
||
|
|
backgroundColor: "#171717",
|
||
|
|
color: "#ececec",
|
||
|
|
overflow: "auto",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{/* Header */}
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
padding: "12px 24px",
|
||
|
|
borderBottom: "1px solid #333",
|
||
|
|
display: "flex",
|
||
|
|
alignItems: "center",
|
||
|
|
gap: "16px",
|
||
|
|
background: "#171717",
|
||
|
|
flexShrink: 0,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={onBack}
|
||
|
|
style={{
|
||
|
|
background: "transparent",
|
||
|
|
border: "none",
|
||
|
|
cursor: "pointer",
|
||
|
|
color: "#888",
|
||
|
|
fontSize: "0.9em",
|
||
|
|
padding: "4px 8px",
|
||
|
|
borderRadius: "4px",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
← Back
|
||
|
|
</button>
|
||
|
|
<span style={{ fontWeight: 700, fontSize: "1em" }}>Project Settings</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Body */}
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
flex: 1,
|
||
|
|
padding: "24px",
|
||
|
|
display: "flex",
|
||
|
|
flexDirection: "column",
|
||
|
|
gap: "20px",
|
||
|
|
maxWidth: "640px",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{status === "loading" && (
|
||
|
|
<p style={{ color: "#888", fontSize: "0.9em" }}>Loading settings…</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{status === "error" && !s && (
|
||
|
|
<p style={{ color: "#f08080", fontSize: "0.9em" }}>
|
||
|
|
Error: {errorMsg}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{s && (
|
||
|
|
<>
|
||
|
|
{/* Pipeline */}
|
||
|
|
<div style={sectionStyle}>
|
||
|
|
<div style={sectionTitleStyle}>Pipeline</div>
|
||
|
|
|
||
|
|
<div style={fieldStyle}>
|
||
|
|
<label style={labelStyle}>Default QA Mode</label>
|
||
|
|
<span style={descStyle}>
|
||
|
|
How stories are QA-reviewed after the coder stage.
|
||
|
|
Default: server.
|
||
|
|
</span>
|
||
|
|
<select
|
||
|
|
value={s.default_qa}
|
||
|
|
onChange={(e) => patch({ default_qa: e.target.value })}
|
||
|
|
style={{ ...inputStyle, cursor: "pointer" }}
|
||
|
|
>
|
||
|
|
{QA_MODES.map((m) => (
|
||
|
|
<option key={m} value={m}>
|
||
|
|
{m}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
{validationErrors.default_qa && (
|
||
|
|
<span style={{ color: "#f08080", fontSize: "0.8em" }}>
|
||
|
|
{validationErrors.default_qa}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<NumberField
|
||
|
|
label="Max Retries"
|
||
|
|
description="Maximum retries per story per pipeline stage before blocking. Default: 2. Set 0 to disable."
|
||
|
|
value={s.max_retries}
|
||
|
|
min={0}
|
||
|
|
onChange={(v) => patch({ max_retries: v ?? 0 })}
|
||
|
|
/>
|
||
|
|
{validationErrors.max_retries && (
|
||
|
|
<span style={{ color: "#f08080", fontSize: "0.8em" }}>
|
||
|
|
{validationErrors.max_retries}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<NumberField
|
||
|
|
label="Max Concurrent Coders"
|
||
|
|
description="Maximum number of coder-stage agents running at once. Leave blank for unlimited."
|
||
|
|
value={s.max_coders}
|
||
|
|
min={1}
|
||
|
|
placeholder="unlimited"
|
||
|
|
onChange={(v) => patch({ max_coders: v })}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<TextField
|
||
|
|
label="Default Coder Model"
|
||
|
|
description="When set, only coder agents matching this model are auto-assigned (e.g. sonnet, opus)."
|
||
|
|
value={s.default_coder_model ?? ""}
|
||
|
|
onChange={(v) =>
|
||
|
|
patch({ default_coder_model: v.trim() || null })
|
||
|
|
}
|
||
|
|
placeholder="e.g. sonnet"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Git */}
|
||
|
|
<div style={sectionStyle}>
|
||
|
|
<div style={sectionTitleStyle}>Git</div>
|
||
|
|
|
||
|
|
<TextField
|
||
|
|
label="Base Branch"
|
||
|
|
description="Overrides auto-detection of the merge target branch (e.g. main, master, develop)."
|
||
|
|
value={s.base_branch ?? ""}
|
||
|
|
onChange={(v) =>
|
||
|
|
patch({ base_branch: v.trim() || null })
|
||
|
|
}
|
||
|
|
placeholder="e.g. master"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Notifications */}
|
||
|
|
<div style={sectionStyle}>
|
||
|
|
<div style={sectionTitleStyle}>Notifications</div>
|
||
|
|
|
||
|
|
<CheckboxField
|
||
|
|
label="Rate Limit Notifications"
|
||
|
|
description="Send chat notifications on soft API rate-limit warnings. Disable to reduce noise."
|
||
|
|
checked={s.rate_limit_notifications}
|
||
|
|
onChange={(v) => patch({ rate_limit_notifications: v })}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Advanced */}
|
||
|
|
<div style={sectionStyle}>
|
||
|
|
<div style={sectionTitleStyle}>Advanced</div>
|
||
|
|
|
||
|
|
<TextField
|
||
|
|
label="Timezone"
|
||
|
|
description="IANA timezone for timer inputs (e.g. Europe/London, America/New_York). Leave blank for system default."
|
||
|
|
value={s.timezone ?? ""}
|
||
|
|
onChange={(v) => patch({ timezone: v.trim() || null })}
|
||
|
|
placeholder="e.g. Europe/London"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<TextField
|
||
|
|
label="Rendezvous URL"
|
||
|
|
description="WebSocket URL of a remote huskies node for CRDT state sync (e.g. ws://host:3001/crdt-sync)."
|
||
|
|
value={s.rendezvous ?? ""}
|
||
|
|
onChange={(v) => patch({ rendezvous: v.trim() || null })}
|
||
|
|
placeholder="e.g. ws://host:3001/crdt-sync"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Watcher */}
|
||
|
|
<div style={sectionStyle}>
|
||
|
|
<div style={sectionTitleStyle}>Archiver</div>
|
||
|
|
|
||
|
|
<NumberField
|
||
|
|
label="Sweep Interval (seconds)"
|
||
|
|
description="How often to check the done stage for items ready to archive. Default: 60."
|
||
|
|
value={s.watcher_sweep_interval_secs}
|
||
|
|
min={1}
|
||
|
|
onChange={(v) =>
|
||
|
|
patch({ watcher_sweep_interval_secs: v ?? 60 })
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
{validationErrors.watcher_sweep_interval_secs && (
|
||
|
|
<span style={{ color: "#f08080", fontSize: "0.8em" }}>
|
||
|
|
{validationErrors.watcher_sweep_interval_secs}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<NumberField
|
||
|
|
label="Done Retention (seconds)"
|
||
|
|
description="How long an item must stay in the done stage before archiving. Default: 14400 (4 hours)."
|
||
|
|
value={s.watcher_done_retention_secs}
|
||
|
|
min={1}
|
||
|
|
onChange={(v) =>
|
||
|
|
patch({ watcher_done_retention_secs: v ?? 14400 })
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
{validationErrors.watcher_done_retention_secs && (
|
||
|
|
<span style={{ color: "#f08080", fontSize: "0.8em" }}>
|
||
|
|
{validationErrors.watcher_done_retention_secs}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Save */}
|
||
|
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={handleSave}
|
||
|
|
disabled={status === "saving"}
|
||
|
|
style={{
|
||
|
|
padding: "8px 24px",
|
||
|
|
borderRadius: "6px",
|
||
|
|
border: "none",
|
||
|
|
background:
|
||
|
|
status === "saved" ? "#1a5c2a" : "#2563eb",
|
||
|
|
color: "#fff",
|
||
|
|
cursor:
|
||
|
|
status === "saving" ? "not-allowed" : "pointer",
|
||
|
|
fontSize: "0.9em",
|
||
|
|
fontWeight: 600,
|
||
|
|
opacity: status === "saving" ? 0.7 : 1,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{status === "saving"
|
||
|
|
? "Saving…"
|
||
|
|
: status === "saved"
|
||
|
|
? "Saved!"
|
||
|
|
: "Save"}
|
||
|
|
</button>
|
||
|
|
{status === "error" && errorMsg && (
|
||
|
|
<span style={{ color: "#f08080", fontSize: "0.85em" }}>
|
||
|
|
{errorMsg}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|