moadim 3.2.7

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
import { useState } from "react";
import {
  useHealth,
  useMaxConcurrentRuns,
  useRetryCrontabSync,
  useSetMaxConcurrentRuns,
  useSetUserPrompt,
  useUserPrompt
} from "../../api/hooks";
import { loadRefreshToken, RefreshControl, saveRefreshToken, type RefreshToken } from "../../components/RefreshControl";
import { loadThemeLight, saveThemeLight } from "../../lib/theme";
import { useToasts } from "../../shell/toasts";
import { CrontabSyncStatus } from "./CrontabSyncStatus";

export function SettingsPage() {
  const { addToast } = useToasts();
  const userPrompt = useUserPrompt();
  const setUserPrompt = useSetUserPrompt();
  const health = useHealth(30_000);
  const retryCrontabSync = useRetryCrontabSync();
  const maxConcurrentRuns = useMaxConcurrentRuns();
  const setMaxConcurrentRuns = useSetMaxConcurrentRuns();

  const [content, setContent] = useState("");
  const [loadedContent, setLoadedContent] = useState("");
  const [refreshToken, setRefreshToken] = useState<RefreshToken>(loadRefreshToken);
  const [lightTheme, setLightTheme] = useState(loadThemeLight);
  const [concurrencyInput, setConcurrencyInput] = useState("");

  // Seed the editable draft once the initial fetch resolves. Adjusting state during
  // render (rather than in an effect) on a tracked "previous prop" per
  // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
  // avoids the extra render pass `react-hooks/set-state-in-effect` warns about.
  const [seededFrom, setSeededFrom] = useState<string | undefined>(undefined);
  if (userPrompt.data !== undefined && userPrompt.data !== seededFrom) {
    setSeededFrom(userPrompt.data);
    setContent(userPrompt.data);
    setLoadedContent(userPrompt.data);
  }

  const dirty = content !== loadedContent;

  const [seededConcurrencyFrom, setSeededConcurrencyFrom] = useState<number | null | undefined>(undefined);
  if (maxConcurrentRuns.data !== undefined && maxConcurrentRuns.data.override_value !== seededConcurrencyFrom) {
    setSeededConcurrencyFrom(maxConcurrentRuns.data.override_value);
    setConcurrencyInput(
      maxConcurrentRuns.data.override_value === null ? "" : String(maxConcurrentRuns.data.override_value),
    );
  }

  const concurrencyDirty = concurrencyInput !== (seededConcurrencyFrom === null ? "" : String(seededConcurrencyFrom ?? ""));
  const concurrencyValid = concurrencyInput.trim() === "" || /^\d+$/.test(concurrencyInput.trim());

  const saveConcurrency = () => {
    const trimmed = concurrencyInput.trim();
    const value = trimmed === "" ? null : Number.parseInt(trimmed, 10);
    setMaxConcurrentRuns.mutate(value, {
      onSuccess: () => addToast("Concurrency cap saved", "ok"),
      onError: (err) => addToast(`Save failed: ${err.message}`, "err"),
    });
  };

  const save = () => {
    setUserPrompt.mutate(content, {
      onSuccess: () => {
        setLoadedContent(content);
        addToast("Prompt saved", "ok");
      },
      onError: (err) => addToast(`Save failed: ${err.message}`, "err"),
    });
  };

  const onSetRefreshToken = (next: RefreshToken) => {
    saveRefreshToken(next);
    setRefreshToken(next);
    addToast("Refresh cadence saved", "ok");
  };

  const onSetTheme = (nextLight: boolean) => {
    saveThemeLight(nextLight);
    setLightTheme(nextLight);
    addToast("Theme saved", "ok");
  };

  return (
    <div>
      <h1 className="page-title">Settings</h1>
      <div className="card settings-card">
        <div className="settings-card-title">Appearance</div>
        <p className="settings-card-copy">
          Choose the app theme. This replaces the header theme toggle so appearance lives with the rest of Settings.
        </p>
        <div className="settings-segmented" role="group" aria-label="Theme">
          <button
            type="button"
            className={`btn ${lightTheme ? "btn-primary" : "btn-secondary"}`}
            aria-pressed={lightTheme}
            onClick={() => onSetTheme(true)}
          >
            Light
          </button>
          <button
            type="button"
            className={`btn ${!lightTheme ? "btn-primary" : "btn-secondary"}`}
            aria-pressed={!lightTheme}
            onClick={() => onSetTheme(false)}
          >
            Dark
          </button>
        </div>
      </div>
      <div className="card settings-card">
        <div className="settings-card-title">Data refresh</div>
        <p className="settings-card-copy">
          Choose how often the app pulls fresh daemon data. The same cadence is used by overview,
          routines, logs, heatmap, machines, and reliability views.
        </p>
        <RefreshControl token={refreshToken} updatedAtMs={0} onChange={onSetRefreshToken} />
      </div>
      <div className="card settings-card">
        <div className="settings-card-title">Concurrency</div>
        <p className="settings-card-copy">
          The most routines this daemon will run at once fleet-wide. Extra due routines wait for a
          slot instead of starting immediately. Takes effect on the next trigger check — no restart
          required. Leave blank to fall back to the <code>MOADIM_MAX_CONCURRENT_RUNS</code> env var,
          or the built-in default if that isn&apos;t set either.
        </p>
        {maxConcurrentRuns.isLoading ? (
          <div>Loading…</div>
        ) : (
          <>
            <div className="settings-card-copy">
              Effective cap right now:{" "}
              <strong>
                {maxConcurrentRuns.data?.value === 0 ? "unbounded" : maxConcurrentRuns.data?.value}
              </strong>
            </div>
            <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 8 }}>
              <input
                type="number"
                min={0}
                step={1}
                className="form-input"
                style={{ width: 120 }}
                placeholder="unset"
                aria-label="Max concurrent runs override"
                value={concurrencyInput}
                onChange={(e) => setConcurrencyInput(e.target.value)}
              />
              <button
                className="btn btn-primary"
                disabled={!concurrencyDirty || !concurrencyValid || setMaxConcurrentRuns.isPending}
                onClick={saveConcurrency}
              >
                {setMaxConcurrentRuns.isPending ? "Saving…" : "Save"}
              </button>
              {concurrencyInput.trim() !== "" && (
                <button
                  type="button"
                  className="btn btn-ghost"
                  disabled={setMaxConcurrentRuns.isPending}
                  onClick={() => setConcurrencyInput("")}
                >
                  Clear
                </button>
              )}
              {concurrencyDirty && concurrencyValid && (
                <span style={{ color: "var(--text-faint)", fontSize: 12 }}>unsaved changes</span>
              )}
              {!concurrencyValid && (
                <span style={{ color: "var(--err)", fontSize: 12 }}>must be a whole number ≥ 0</span>
              )}
            </div>
          </>
        )}
      </div>
      <div className="card settings-card settings-health-card">
        <div className="settings-card-title">System health</div>
        <CrontabSyncStatus
          health={health.data}
          loading={health.isLoading}
          retrying={retryCrontabSync.isPending}
          onRefresh={() => void health.refetch()}
          onRetry={() => {
            retryCrontabSync.mutate(undefined, {
              onSuccess: (data) => {
                if (data.crontab_sync.ok) {
                  addToast("Crontab sync recovered", "ok");
                } else {
                  addToast("Crontab sync still needs attention", "err");
                }
              },
              onError: (err) => addToast(`Retry failed: ${err.message}`, "err"),
            });
          }}
        />
      </div>
      <div className="card" style={{ padding: 16 }}>
        <div style={{ fontWeight: 700, marginBottom: 4 }}>Persistent prompt</div>
        <p style={{ color: "var(--text-dim)", fontSize: 13, marginTop: 0 }}>
          Appended to every routine&apos;s agent instructions file (CLAUDE.md/AGENTS.md), alongside
          the moadim-managed preamble, on every run.
        </p>
        {userPrompt.isLoading ? (
          <div>Loading…</div>
        ) : (
          <>
            <textarea
              className="form-textarea"
              rows={12}
              placeholder="e.g. always run `cargo fmt` before finishing a task"
              value={content}
              onChange={(e) => setContent(e.target.value)}
            />
            <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 10 }}>
              <button
                className="btn btn-primary"
                disabled={!dirty || setUserPrompt.isPending}
                onClick={save}
              >
                {setUserPrompt.isPending ? "Saving…" : "Save"}
              </button>
              {dirty && <span style={{ color: "var(--text-faint)", fontSize: 12 }}>unsaved changes</span>}
            </div>
          </>
        )}
      </div>
    </div>
  );
}