shadimage
approvals-ui
componentnl-edit-panelsource: ok · refreshed

Plain-language edit panel

Edit the policy in plain English behind a human gate: a proposer (your LLM, or the bundled deterministic demo parser) returns a proposed policy plus its diff, the user reviews and applies or discards. Nothing lands without the click.

npx shadcn add "https://approvals-ui.vercel.app/r/nl-edit-panel.json"
free

Where it goes

testimonial

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

buttoninputhttps://approvals-ui.vercel.app/r/approvals-core.json

Files2 · source cached 10/8/2026, 8:49:26 AM

components/approvals-ui/nl-edit-panel.tsx
"use client";

import {
  CircleMinus,
  CirclePlus,
  Loader2,
  MessageCircleQuestion,
  Pencil,
  Sparkles,
} from "lucide-react";
import { useState } from "react";

import type { EditProposal, Proposer } from "@/lib/approvals-ui/edit-ops";
import type { ApprovalPolicy } from "@/lib/approvals-ui/policy";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { hasChanges, summarizeChanges } from "@/lib/approvals-ui/diff";
import { cn } from "@/lib/utils";

/**
 * Edit the policy in plain language, behind a human gate: the proposer only
 * ever PROPOSES. The parent renders the returned diff on the canvas, and
 * nothing lands until Apply is clicked.
 *
 * Bring your own proposer: the bundled demoProposer is deterministic (no
 * model, no key). Swap it for an LLM that emits EditOp JSON and the UI does
 * not change.
 */
export type NlEditPanelProps = {
  policy: ApprovalPolicy;
  proposer: Proposer;
  proposal: EditProposal | null;
  onProposalChange: (proposal: EditProposal | null) => void;
  onApply: (policy: ApprovalPolicy) => void;
  /** Block Apply (for example when the proposed policy has validation errors). */
  blockApply?: boolean;
  blockApplyReason?: string;
  suggestions?: string[];
  placeholder?: string;
  className?: string;
};

export const NlEditPanel = ({
  policy,
  proposer,
  proposal,
  onProposalChange,
  onApply,
  blockApply = false,
  blockApplyReason,
  suggestions = [],
  placeholder = "Above $50k also require the CFO",
  className,
}: NlEditPanelProps) => {
  const [instruction, setInstruction] = useState("");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const propose = async (text: string) => {
    const trimmed = text.trim();
    if (trimmed.length === 0 || busy) return;
    setBusy(true);
    setError(null);
    onProposalChange(null);
    try {
      onProposalChange(await proposer(trimmed, policy));
    } catch (error_) {
      setError(error_ instanceof Error ? error_.message : String(error_));
    } finally {
      setBusy(false);
    }
  };

  const apply = () => {
    if (!proposal || blockApply) return;
    onApply(proposal.proposed);
    onProposalChange(null);
    setInstruction("");
  };

  const discard = () => {
    onProposalChange(null);
  };

  const isMutating = proposal !== null && hasChanges(proposal.changes);

  return (
    <div className={cn("space-y-3", className)}>
      <form
        className="flex items-center gap-2"
        onSubmit={(event) => {
          event.preventDefault();
          void propose(instruction);
        }}
      >
        <Input
          value={instruction}
          onChange={(event) => setInstruction(event.target.value)}
          placeholder={placeholder}
          disabled={busy}
          className="h-8 text-xs"
        />
        <Button type="submit" size="sm" disabled={busy || instruction.trim().length === 0}>
          {busy ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
          Propose
        </Button>
      </form>

      {suggestions.length > 0 && proposal === null && !busy && (
        <div className="flex flex-wrap gap-1.5">
          {suggestions.map((suggestion) => (
            <button
              key={suggestion}
              type="button"
              onClick={() => {
                setInstruction(suggestion);
                void propose(suggestion);
              }}
              className="text-muted-foreground hover:bg-muted hover:text-foreground rounded-full border px-2.5 py-1 text-[11px] transition-colors"
            >
              {suggestion}
            </button>
          ))}
        </div>
      )}

      {error && <p className="text-destructive text-xs">{error}</p>}

      {proposal?.clarify && (
        <div className="space-y-2 rounded-lg border p-3">
          <p className="flex items-start gap-2 text-xs">
            <MessageCircleQuestion className="text-muted-foreground mt-0.5 size-3.5 shrink-0" />
            {proposal.clarify.question}
          </p>
          {proposal.clarify.options && proposal.clarify.options.length > 0 && (
            <div className="flex flex-wrap gap-1.5 pl-5">
              {proposal.clarify.options.map((option) => (
                <button
                  key={option}
                  type="button"
                  onClick={() => setInstruction(option)}
                  className="text-muted-foreground hover:bg-muted hover:text-foreground rounded-full border px-2.5 py-0.5 text-[11px] transition-colors"
                >
                  {option}
                </button>
              ))}
            </div>
          )}
        </div>
      )}

      {proposal && !proposal.clarify && (
        <div className="space-y-2.5 rounded-lg border p-3">
          <div className="space-y-1">
            <p className="text-xs font-medium">Proposal: {summarizeChanges(proposal.changes)}</p>
            {proposal.reason && <p className="text-muted-foreground text-xs">{proposal.reason}</p>}
          </div>

          {isMutating && (
            <ul className="space-y-1">
              {proposal.changes
                .filter((change) => change.kind !== "unchanged")
                .map((change) => (
                  <li
                    key={`${change.kind}-${change.id}`}
                    className="flex items-center gap-2 text-xs"
                  >
                    {change.kind === "added" && (
                      <CirclePlus className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
                    )}
                    {change.kind === "changed" && (
                      <Pencil className="size-3.5 shrink-0 text-amber-500" />
                    )}
                    {change.kind === "removed" && (
                      <CircleMinus className="size-3.5 shrink-0 text-red-600 dark:text-red-400" />
                    )}
                    <span className="truncate">{change.label}</span>
                    {change.kind === "changed" && (
                      <span className="text-muted-foreground truncate font-mono text-[10px]">
                        {change.fields.join(", ")}
                      </span>
                    )}
                  </li>
                ))}
            </ul>
          )}

          <div className="flex items-center gap-2">
            {isMutating && (
              <Button size="sm" onClick={apply} disabled={blockApply}>
                Apply
              </Button>
            )}
            <Button size="sm" variant="ghost" onClick={discard}>
              Discard
            </Button>
            {blockApply && blockApplyReason && (
              <p className="text-destructive text-[11px]">{blockApplyReason}</p>
            )}
          </div>
        </div>
      )}
    </div>
  );
};
lib/approvals-ui/demo-proposer.ts
import { type EditOp, type EditProposal, proposeEdits, type Proposer } from "./edit-ops";
import { type ApprovalPolicy, type ApprovalStep, isApprovalStep, isTerminalStep } from "./policy";

/**
 * A deterministic, dependency-free proposer so the edit panel works with no
 * model and no API key. It understands a handful of phrasings (thresholds,
 * approvers, quorums, renames, removals) and answers with the same
 * EditProposal contract a real LLM proposer would.
 *
 * In production you swap this for a model: have it emit `EditOp` JSON
 * (validate with `editOpSchema`), then call `proposeEdits`. The UI does not
 * change.
 */

export const demoInstructions = [
  "Above $50k also require the CFO",
  "Add Maria Chen (Controller) to Manager review",
  "Require 2 of the approvers on Finance review",
  "Rename Manager review to Budget owner review",
  "Remove the Director sign-off",
] as const;

const normalize = (text: string): string => {
  return text
    .toLowerCase()
    .replaceAll(/[^a-z0-9\s]/g, " ")
    .replaceAll(/\s+/g, " ")
    .trim();
};

const cleanRole = (text: string): string => {
  return text
    .replaceAll(/\b(approval|review|sign\s*off|gate|step)\b/gi, " ")
    .replaceAll(/[^\w\s&-]/g, " ")
    .replaceAll(/\s+/g, " ")
    .trim();
};

const titleCase = (text: string): string => {
  if (text === text.toUpperCase() && text.length <= 4) return text;
  return text
    .split(" ")
    .map((w) =>
      w === w.toUpperCase() && w.length <= 4 ? w : w.charAt(0).toUpperCase() + w.slice(1)
    )
    .join(" ");
};

const slugify = (text: string): string => {
  return normalize(text).replaceAll(/\s/g, "-") || "step";
};

const parseAmount = (raw: string, hasK: boolean): number => {
  const value = Number(raw.replaceAll(",", ""));
  return hasK ? value * 1000 : value;
};

const matchSteps = (policy: ApprovalPolicy, query: string): ApprovalStep[] => {
  const q = normalize(query);
  if (q.length === 0) return [];
  const gates = policy.steps.filter(isApprovalStep);
  const byLabel = gates.filter((s) => {
    const label = normalize(s.label);
    return label.includes(q) || q.includes(label);
  });
  if (byLabel.length > 0) return byLabel;
  return gates.filter((s) =>
    s.approvers.some((a) => {
      const title = normalize(a.title);
      return title.length > 0 && (title.includes(q) || q.includes(title));
    })
  );
};

const clarifyProposal = (
  policy: ApprovalPolicy,
  question: string,
  options?: string[]
): EditProposal => {
  return proposeEdits(policy, [{ op: "clarify", question, options }]);
};

type StepResolution =
  { step: ApprovalStep; clarify?: undefined } | { step?: undefined; clarify: EditProposal };

const resolveStep = (policy: ApprovalPolicy, query: string): StepResolution => {
  const matches = matchSteps(policy, query);
  const [only] = matches;
  if (matches.length === 1 && only) return { step: only };
  if (matches.length === 0) {
    return {
      clarify: clarifyProposal(
        policy,
        `I could not find a step matching "${query.trim()}". Which one did you mean?`,
        policy.steps.filter(isApprovalStep).map((s) => s.label)
      ),
    };
  }
  return {
    clarify: clarifyProposal(
      policy,
      `"${query.trim()}" matches several steps. Which one?`,
      matches.map((s) => s.label)
    ),
  };
};

/** The gate that feeds an approved terminal, else the last gate. */
const lastGate = (policy: ApprovalPolicy): ApprovalStep | undefined => {
  const approvedIds = new Set(
    policy.steps
      .filter(isTerminalStep)
      .filter((t) => t.outcome === "approved")
      .map((t) => t.id)
  );
  const gates = policy.steps.filter(isApprovalStep);
  return gates.find((g) => g.next.some((id) => approvedIds.has(id))) ?? gates.at(-1);
};

const uniqueId = (policy: ApprovalPolicy, base: string): string => {
  if (policy.steps.every((s) => s.id !== base)) return base;
  let i = 2;
  while (policy.steps.some((s) => s.id === `${base}-${i}`)) i += 1;
  return `${base}-${i}`;
};

/**
 * A mandatory regex capture group. When the overall match succeeded, a
 * non-optional group is always present; this asserts that so the value is a
 * `string` (not `string | undefined`) without an `as` cast.
 */
const group = (value: string | undefined): string => {
  if (value === undefined) throw new Error("Expected a matched capture group.");
  return value;
};

const proposeDemoEdit = (instruction: string, policy: ApprovalPolicy): EditProposal => {
  const text = instruction.trim();

  // "Above $50k (also) require the CFO"
  const threshold = text.match(
    /(?:above|over)\s+\$?([\d][\d,.]*)\s*(k)?\b[,:]?\s*(?:also\s+)?require\s+(?:the\s+)?(.+)$/i
  );
  if (threshold) {
    const [, rawAmount, kSuffix, rawRole] = threshold;
    const value = parseAmount(group(rawAmount), kSuffix !== undefined);
    const role = cleanRole(group(rawRole));
    if (!Number.isFinite(value) || role.length === 0) {
      return clarifyProposal(
        policy,
        "I could not read the amount or the role in that instruction."
      );
    }
    const existing = matchSteps(policy, role);
    const [onlyExisting] = existing;
    if (existing.length === 1 && onlyExisting) {
      return proposeEdits(
        policy,
        [{ op: "set-threshold", stepId: onlyExisting.id, value }],
        `Set the "${onlyExisting.label}" threshold to ${value.toLocaleString("en-US")}.`
      );
    }
    const after = lastGate(policy);
    if (!after) {
      return clarifyProposal(policy, "There is no approval gate to attach this to yet.");
    }
    const title = titleCase(role);
    const op: EditOp = {
      op: "insert-approval-after",
      afterId: after.id,
      step: {
        id: uniqueId(policy, slugify(role)),
        label: `${title} approval`,
        when: { kind: "leaf", field: "amount", op: ">", value },
        approvers: [{ name: null, title }],
      },
    };
    return proposeEdits(
      policy,
      [op],
      `Add a ${title} gate for requests above ${value.toLocaleString("en-US")}. The seat is unassigned until you name someone.`
    );
  }

  // "Require 2 of the approvers on Finance review"
  const quorum = text.match(
    /require\s+(\d+)\s+of\s+(?:the\s+)?(?:\d+\s+)?(?:approvers?\s+)?(?:on|for|in)\s+(.+)$/i
  );
  if (quorum) {
    const [, rawCount, rawTarget] = quorum;
    const { step, clarify } = resolveStep(policy, group(rawTarget));
    if (!step) return clarify;
    return proposeEdits(
      policy,
      [{ op: "set-mode", stepId: step.id, mode: "quorum", quorum: Number(rawCount) }],
      `Require ${group(rawCount)} of ${step.approvers.length} approvers on "${step.label}".`
    );
  }

  // "Add Maria Chen (Controller) to Manager review"
  const add = text.match(/add\s+(.+?)(?:\s*\(([^)]+)\))?\s+to\s+(?:the\s+)?(.+)$/i);
  if (add) {
    const [, rawName, rawTitle, rawTarget] = add;
    const { step, clarify } = resolveStep(policy, group(rawTarget));
    if (!step) return clarify;
    const name = group(rawName).trim();
    const title = rawTitle?.trim() || "Approver";
    return runOrExplain(
      policy,
      [{ op: "add-approver", stepId: step.id, approver: { name, title } }],
      `Add ${name} to "${step.label}".`
    );
  }

  // "Remove Maria Chen from Finance review"
  const removalMatch = text.match(/remove\s+(.+?)\s+from\s+(?:the\s+)?(.+)$/i);
  if (removalMatch) {
    const [, rawWho, rawTarget] = removalMatch;
    const { step, clarify } = resolveStep(policy, group(rawTarget));
    if (!step) return clarify;
    const who = group(rawWho).trim();
    const match = step.approvers.find(
      (a) => a.name !== null && normalize(a.name).includes(normalize(who))
    );
    if (!match || match.name === null) {
      return clarifyProposal(
        policy,
        `Nobody called "${who}" approves on "${step.label}".`,
        step.approvers.flatMap((a) => (a.name === null ? [] : [a.name]))
      );
    }
    return runOrExplain(
      policy,
      [{ op: "remove-approver", stepId: step.id, name: match.name }],
      `Remove ${match.name} from "${step.label}".`
    );
  }

  // "Rename Manager review to Budget owner review"
  const rename = text.match(/rename\s+(?:the\s+)?(.+?)\s+to\s+(.+)$/i);
  if (rename) {
    const [, rawFrom, rawTo] = rename;
    const { step, clarify } = resolveStep(policy, group(rawFrom));
    if (!step) return clarify;
    return proposeEdits(
      policy,
      [{ op: "rename-step", stepId: step.id, label: group(rawTo).trim() }],
      `Rename "${step.label}".`
    );
  }

  // "Remove the Director sign-off"
  const remove = text.match(/(?:remove|delete)\s+(?:the\s+)?(.+)$/i);
  if (remove) {
    const [, rawTarget] = remove;
    const { step, clarify } = resolveStep(policy, group(rawTarget));
    if (!step) return clarify;
    return proposeEdits(
      policy,
      [{ op: "remove-step", stepId: step.id }],
      `Remove "${step.label}" and rewire around it.`
    );
  }

  return clarifyProposal(
    policy,
    "The demo parser understands thresholds, approvers, quorums, renames, and removals.",
    [...demoInstructions]
  );
};

export const demoProposer: Proposer = async (instruction, policy) => {
  return await Promise.resolve(proposeDemoEdit(instruction, policy));
};

const runOrExplain = (policy: ApprovalPolicy, ops: EditOp[], reason: string): EditProposal => {
  try {
    return proposeEdits(policy, ops, reason);
  } catch (error) {
    return clarifyProposal(policy, error instanceof Error ? error.message : String(error));
  }
};

More from approvals-ui

Browse all components in this library →