moadim 1.6.0

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
import { useEffect } from "react";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { useAgents } from "../../api/hooks";
import type { components } from "../../api/schema.gen";
import { describeCronLive } from "../../lib/cronUtils";
import { MachinesPicker } from "./MachinesPicker";
import { parseTtl, reposToText, tagsToText, textToRepos, textToTags } from "./routineDraft";
import { TTL_PRESETS } from "./ttl";

type Repository = components["schemas"]["Repository"];

export interface RoutineDraft {
  schedule: string;
  title: string;
  agent: string;
  model: string | null;
  prompt: string;
  goal: string | null;
  repositories: Repository[];
  machines: string[];
  enabled: boolean;
  ttl_secs: number | null;
  tags: string[];
}

interface FormValues {
  title: string;
  schedule: string;
  agent: string;
  model: string;
  prompt: string;
  goal: string;
  reposRaw: string;
  machines: string[];
  tagsRaw: string;
  ttlRaw: string;
  enabled: boolean;
}

const nonBlank = z.string().trim().min(1);

const FALLBACK_AGENTS = ["claude", "codex"];

const CRON_PRESETS: [string, string][] = [
  ["@daily", "@daily"],
  ["@hourly", "@hourly"],
  ["@weekly", "@weekly"],
  ["@monthly", "@monthly"],
  ["0 0 9 * * 1-5 *", "weekdays 9am"],
  ["0 0 * * * * *", "every hour"],
];

function draftToValues(draft?: Partial<RoutineDraft>): FormValues {
  return {
    title: draft?.title ?? "",
    schedule: draft?.schedule ?? "",
    agent: draft?.agent ?? "claude",
    model: draft?.model ?? "",
    prompt: draft?.prompt ?? "",
    goal: draft?.goal ?? "",
    reposRaw: reposToText(draft?.repositories ?? []),
    machines: draft?.machines ?? [],
    tagsRaw: tagsToText(draft?.tags ?? []),
    ttlRaw: draft?.ttl_secs != null ? String(draft.ttl_secs) : "",
    enabled: draft?.enabled ?? true,
  };
}

export interface RoutineFormProps {
  /** `undefined` = blank create form. */
  initial?: Partial<RoutineDraft>;
  mode: "create" | "edit" | "clone";
  saving: boolean;
  onCancel: () => void;
  onSave: (draft: RoutineDraft) => void;
}

export function RoutineForm({ initial, mode, saving, onCancel, onSave }: RoutineFormProps) {
  const agentsQuery = useAgents();
  const agents = agentsQuery.data && agentsQuery.data.length > 0 ? agentsQuery.data : FALLBACK_AGENTS;

  const { register, control, setValue, handleSubmit, reset } = useForm<FormValues>({
    defaultValues: draftToValues(initial),
  });

  // Re-seed the form whenever the routine being edited/cloned changes identity.
  useEffect(() => {
    reset(draftToValues(initial));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [initial]);

  const title = useWatch({ control, name: "title" });
  const schedule = useWatch({ control, name: "schedule" });
  const agent = useWatch({ control, name: "agent" });
  const prompt = useWatch({ control, name: "prompt" });
  const machines = useWatch({ control, name: "machines" });
  const canSave =
    nonBlank.safeParse(title).success &&
    nonBlank.safeParse(schedule).success &&
    nonBlank.safeParse(agent).success &&
    nonBlank.safeParse(prompt).success;

  const [cronOk, cronText] = describeCronLive(schedule);
  const previewClass =
    schedule.trim() === "" ? "cron-preview" : cronOk ? "cron-preview ok" : "cron-preview bad";

  const submit = handleSubmit((v) => {
    onSave({
      schedule: v.schedule,
      title: v.title,
      agent: v.agent,
      model: v.model.trim() === "" ? null : v.model,
      prompt: v.prompt,
      goal: v.goal.trim() === "" ? null : v.goal,
      repositories: textToRepos(v.reposRaw),
      machines: v.machines,
      enabled: v.enabled,
      ttl_secs: parseTtl(v.ttlRaw),
      tags: textToTags(v.tagsRaw),
    });
  });

  const isEdit = mode === "edit";
  const submitLabel = saving ? "…" : isEdit ? "SAVE CHANGES" : "CREATE ROUTINE";

  const body = (
    <>
      <div className="form-group">
        <label className="form-label">TITLE*</label>
        <input
          className="form-input"
          placeholder="ops/nightly triage"
          autoComplete="off"
          spellCheck={false}
          {...register("title")}
        />
        <div className="form-hint">Use / to nest folders and subfolders.</div>
      </div>

      <div className="form-group">
        <label className="form-label">SCHEDULE*</label>
        <input
          className="form-input"
          placeholder="sec min hour dom month dow year"
          {...register("schedule")}
        />
        <div className="cron-presets">
          {CRON_PRESETS.map(([value, label]) => (
            <button
              type="button"
              key={value}
              className="preset-btn"
              onClick={() => setValue("schedule", value)}
            >
              {label}
            </button>
          ))}
        </div>
        <div className={previewClass}>{cronText}</div>
      </div>

      <div className="form-group">
        <label className="form-label">AGENT*</label>
        <select className="form-select" {...register("agent")}>
          {agents.map((a) => (
            <option key={a} value={a}>
              {a}
            </option>
          ))}
        </select>
      </div>

      <div className="form-group">
        <label className="form-label">
          MODEL <span style={{ color: "var(--text-faint)" }}>(optional; blank = agent default)</span>
        </label>
        <input className="form-input" placeholder="claude-sonnet-4-6" {...register("model")} />
      </div>

      <div className="form-group">
        <label className="form-label">PROMPT*</label>
        <textarea
          className="form-textarea"
          rows={6}
          placeholder="Review open PRs and summarize…"
          {...register("prompt")}
        />
      </div>

      <div className="form-group">
        <label className="form-label">
          GOAL <span style={{ color: "var(--text-faint)" }}>(optional; ≤5 lines — the why)</span>
        </label>
        <textarea
          className="form-textarea"
          rows={3}
          placeholder="Keep the PR backlog under control…"
          {...register("goal")}
        />
      </div>

      <div className="form-group">
        <label className="form-label">
          REPOSITORIES <span style={{ color: "var(--text-faint)" }}>(one url [branch] per line)</span>
        </label>
        <textarea
          className="form-textarea"
          rows={3}
          placeholder="https://github.com/org/repo main"
          {...register("reposRaw")}
        />
      </div>

      <MachinesPicker value={machines} onChange={(m) => setValue("machines", m)} />

      <div className="form-group">
        <label className="form-label">
          TAGS <span style={{ color: "var(--text-faint)" }}>(comma-separated)</span>
        </label>
        <input className="form-input" placeholder="triage, nightly" {...register("tagsRaw")} />
      </div>

      <div className="form-group">
        <label className="form-label">
          WORKBENCH TTL <span style={{ color: "var(--text-faint)" }}>(seconds; blank = server default)</span>
        </label>
        <input className="form-input" type="number" min={0} placeholder="604800" {...register("ttlRaw")} />
        <div className="ttl-presets">
          {TTL_PRESETS.map(([secs, label]) => (
            <button type="button" key={secs} className="preset-btn" onClick={() => setValue("ttlRaw", secs)}>
              {label}
            </button>
          ))}
        </div>
      </div>

      <div className="toggle-row">
        <span>ENABLED</span>
        <label className="toggle">
          <input type="checkbox" {...register("enabled")} />
          <div className="toggle-track" />
        </label>
      </div>
    </>
  );

  const footer = (
    <div className="modal-ft">
      <button type="button" className="btn btn-ghost btn-sm" onClick={onCancel}>
        CANCEL
      </button>
      <button type="button" className="btn btn-primary btn-sm" disabled={saving || !canSave} onClick={submit}>
        {submitLabel}
      </button>
    </div>
  );

  if (isEdit) {
    return (
      <div className="overlay open">
        <div className="dialog modal">
          <div className="modal-hd">
            <div className="modal-title">EDIT ROUTINE</div>
            <button type="button" className="modal-x" title="Close" aria-label="Close" onClick={onCancel}>
              ✕
            </button>
          </div>
          <div className="modal-body">{body}</div>
          {footer}
        </div>
      </div>
    );
  }

  return (
    <main className="create-page">
      <div className="page-hd">
        <button type="button" className="btn btn-ghost btn-sm" onClick={onCancel}>
          ← BACK
        </button>
        <div className="page-title">{mode === "clone" ? "NEW ROUTINE (FROM CLONE)" : "NEW ROUTINE"}</div>
      </div>
      <div className="page-card card">
        <div className="modal-body">{body}</div>
        {footer}
      </div>
    </main>
  );
}