Approval Inline Diff

Approval

Human-in-the-loop tool-call approval row: every argument is editable inline with an old→new diff, and deciding collapses the row irreversibly into a one-line receipt.

Install
npx shadcn add https://design.helpmarq.com/r/approval-inline-diff.json
Source
registry/core/approval-inline-diff/component.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// AssayGate — a human-in-the-loop approval row for an agent's proposed tool
// call. Every argument renders as a real, labelled, inline-editable field;
// touching one keeps the original value on the record and shows a Geist Mono
// old→new diff (strikethrough) beneath it rather than silently swapping the
// value. Approve and Deny are told apart by fill and weight, not colour —
// Approve is the solid accent action, Deny a plain outlined one, both with
// distinct glyphs. Deciding collapses the row into a one-line receipt
// (outcome · actor · time) that cannot be reopened for editing — the
// collapse is driven by grid-template-rows so it plays as a real motion, and
// once `decision` is set nothing in this component ever clears it. The full
// payload survives as-decided behind a native <details> disclosure on the
// receipt line.
// ---------------------------------------------------------------------------

export type AssayField = {
  key: string;
  /** short label shown above the field, e.g. "command" */
  label: string;
  value: string;
};

export type AssayDecision = {
  outcome: "approved" | "denied";
  /** who decided — the human at the console, not the requesting agent */
  actor: string;
  timestamp: number;
};

export interface AssayGateProps {
  /** the tool being called, e.g. "execute_shell" */
  toolName: string;
  /** the agent/identity that proposed the call */
  requestedBy?: string;
  fields: AssayField[];
  /** seed one or more fields as already edited, keyed by field.key — the
   * diff renders immediately instead of waiting for the human to type */
  initialValues?: Record<string, string>;
  /** recorded on the receipt as the decider — default "you" */
  approverName?: string;
  onDecision?: (decision: AssayDecision, fields: AssayField[]) => void;
  /** pre-seed as already decided (e.g. rendering decision history) */
  initialDecision?: AssayDecision;
  className?: string;
}

function timeLabel(ts: number) {
  return new Date(ts).toLocaleTimeString(undefined, {
    hour: "numeric",
    minute: "2-digit",
    second: "2-digit",
  });
}

// A wall-clock stamp is the one thing in this component that can legitimately
// differ between the server render and the client one: `toLocaleTimeString`
// resolves against the *renderer's* locale and time zone, and a server in UTC
// formatting the same epoch as a browser in Europe/Athens produces different
// text. Left alone that is a hydration text mismatch (React error #418) on
// every receipt. The client's formatting is the correct one to show, so the
// mismatch is expected rather than a bug — which is exactly what
// `suppressHydrationWarning` is for.
function TimeStamp({ ts }: { ts: number }) {
  return <span suppressHydrationWarning>{timeLabel(ts)}</span>;
}

function CheckIcon() {
  return (
    <svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" aria-hidden>
      <path
        d="M3.5 8.5l3 3 6-7"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function DenyIcon() {
  return (
    <svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" aria-hidden>
      <path
        d="M4 4l8 8M12 4l-8 8"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </svg>
  );
}

function ArrowIcon() {
  return (
    <svg viewBox="0 0 16 16" className="h-3 w-3 shrink-0" fill="none" aria-hidden>
      <path
        d="M2.5 8h10M8.5 4.5L12 8l-3.5 3.5"
        stroke="currentColor"
        strokeWidth="1.4"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function ChevronIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      className="h-3.5 w-3.5 shrink-0 transition-transform duration-200 group-open:rotate-180"
      fill="none"
      aria-hidden
    >
      <path
        d="M4 6l4 4 4-4"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export function AssayGate({
  toolName,
  requestedBy = "agent",
  fields,
  initialValues,
  approverName = "you",
  onDecision,
  initialDecision,
  className = "",
}: AssayGateProps) {
  const uid = useId();
  const originals = useRef(
    Object.fromEntries(fields.map((f) => [f.key, f.value]))
  ).current;
  const [values, setValues] = useState<Record<string, string>>(() => ({
    ...originals,
    ...initialValues,
  }));
  const [decision, setDecision] = useState<AssayDecision | null>(
    initialDecision ?? null
  );
  const decidedRef = useRef(!!initialDecision);
  const summaryRef = useRef<HTMLElement>(null);
  const wasPending = useRef(!initialDecision);

  // move focus onto the receipt once a decision lands, so the outcome is
  // where focus is and gets announced rather than stranded on a collapsing
  // button; only for decisions made *in* this session, not the seeded ones.
  useEffect(() => {
    if (decision && wasPending.current) {
      wasPending.current = false;
      summaryRef.current?.focus();
    }
  }, [decision]);

  function decide(outcome: "approved" | "denied") {
    if (decidedRef.current) return; // irreversible: first decision wins, no path back
    decidedRef.current = true;
    const next: AssayDecision = {
      outcome,
      actor: approverName,
      timestamp: Date.now(),
    };
    const finalFields = fields.map((f) => ({
      ...f,
      value: values[f.key] ?? f.value,
    }));
    setDecision(next);
    onDecision?.(next, finalFields);
  }

  const approved = decision?.outcome === "approved";
  const receiptLabel = decision
    ? decision.outcome === "approved"
      ? "Approved"
      : "Denied"
    : "";

  return (
    <div className={className}>
      <style>{`
@keyframes ns-assay-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
.ns-assay-in{animation:ns-assay-in 320ms cubic-bezier(0.16,1,0.3,1) both}
.ns-assay-collapse{display:grid;transition:grid-template-rows 420ms cubic-bezier(0.16,1,0.3,1)}
@media (prefers-reduced-motion: reduce){
  .ns-assay-in{animation:none}
  .ns-assay-collapse{transition:none}
}
`}</style>

      {/* pending body — collapses to zero height, irreversibly, on decision */}
      <div
        className="ns-assay-collapse"
        style={{ gridTemplateRows: decision ? "0fr" : "1fr" }}
        // removed from hit-testing and the a11y tree the moment a decision
        // lands, so a stray Tab can never land on a control now sitting at
        // zero height inside the collapsing row
        inert={decision ? true : undefined}
      >
        <div className="overflow-hidden">
          <div className="rounded-md border border-border bg-surface p-4">
            <div className="mb-3 flex items-center justify-between gap-3">
              <div className="min-w-0">
                <p className="truncate font-mono text-sm font-medium text-foreground">
                  {toolName}
                </p>
                <p className="truncate font-mono text-[11px] text-muted">
                  requested by {requestedBy}
                </p>
              </div>
              <span className="shrink-0 rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide text-muted">
                pending
              </span>
            </div>

            <div className="space-y-3">
              {fields.map((f) => {
                const id = `${uid}-${f.key}`;
                const current = values[f.key] ?? f.value;
                const dirty = current !== originals[f.key];
                return (
                  <div key={f.key}>
                    <label
                      htmlFor={id}
                      className="block font-mono text-[11px] uppercase tracking-wide text-muted"
                    >
                      {f.label}
                    </label>
                    <input
                      id={id}
                      type="text"
                      value={current}
                      onChange={(e) =>
                        setValues((v) => ({ ...v, [f.key]: e.target.value }))
                      }
                      className="mt-1 w-full rounded-sm border border-border bg-background px-2.5 py-1.5 font-mono text-sm text-foreground outline-none transition-colors focus-visible:border-accent"
                    />
                    {dirty ? (
                      <div className="mt-1.5 flex flex-wrap items-center gap-1.5 font-mono text-xs">
                        <span className="sr-only">changed from</span>
                        <s className="break-all text-muted decoration-1">
                          {originals[f.key]}
                        </s>
                        <span className="sr-only">to</span>
                        <span className="text-muted">
                          <ArrowIcon />
                        </span>
                        <span className="break-all text-foreground">
                          {current}
                        </span>
                      </div>
                    ) : null}
                  </div>
                );
              })}
            </div>

            <div className="mt-4 flex items-center justify-end gap-2">
              <button
                type="button"
                data-assay-deny
                onClick={() => decide("denied")}
                className="inline-flex items-center gap-1.5 rounded-sm border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-background focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
              >
                <DenyIcon />
                Deny
              </button>
              <button
                type="button"
                data-assay-approve
                onClick={() => decide("approved")}
                className="inline-flex items-center gap-1.5 rounded-sm bg-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
              >
                <CheckIcon />
                Approve
              </button>
            </div>
          </div>
        </div>
      </div>

      {/* receipt — mounts once, never unmounts, never edited again */}
      {decision ? (
        <div className="ns-assay-in">
          <details className="group rounded-md border border-border">
            <summary
              ref={summaryRef}
              tabIndex={0}
              data-assay-receipt
              className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 font-mono text-xs text-foreground [&::-webkit-details-marker]:hidden"
            >
              <span
                className={
                  "flex h-4 w-4 shrink-0 items-center justify-center rounded-full border " +
                  (approved
                    ? "border-foreground text-foreground"
                    : "border-border text-muted")
                }
                aria-hidden
              >
                {approved ? <CheckIcon /> : <DenyIcon />}
              </span>
              <span className={"shrink-0 " + (approved ? "font-medium" : "")}>
                {receiptLabel}
              </span>
              <span className="shrink-0 text-muted">·</span>
              {/* the one variable-length field on this row — it truncates so a
                  long tool name can't shove the payload affordance into the
                  timestamp (ml-auto yields nothing once the row is full) */}
              <span className="min-w-0 truncate text-muted">{toolName}</span>
              <span className="shrink-0 text-muted">·</span>
              {/* the actor is variable-length too (a console handle can be an
                  email or a long service identity): like the tool name it
                  gives rather than shoving the timestamp/payload off the row,
                  and the full string stays available on hover and in the
                  expanded payload's own header row below */}
              <span
                className="min-w-0 truncate text-muted"
                title={decision.actor}
              >
                {decision.actor}
              </span>
              <span className="shrink-0 text-muted">·</span>
              {/* shrink-0 + nowrap: without it flex squeezed the stamp until
                  "4:33:04 PM" wrapped onto a second line and got clipped by the
                  row. The tool name above is the only field allowed to give. */}
              <span className="shrink-0 whitespace-nowrap text-muted">
                <TimeStamp ts={decision.timestamp} />
              </span>
              <span className="ml-auto flex shrink-0 items-center gap-1 pl-3 text-muted">
                payload
                <ChevronIcon />
              </span>
            </summary>
            <div
              data-assay-payload
              className="space-y-1.5 border-t border-border px-3 py-2.5"
            >
              {/* the two identifiers that truncate on the collapsed receipt
                  line (the tool and the decider) are repeated here in full, so
                  ellipsizing them above never loses information */}
              <div className="flex gap-2 font-mono text-xs">
                <span className="shrink-0 text-muted">tool</span>
                <span className="break-all text-foreground">{toolName}</span>
              </div>
              <div className="flex gap-2 font-mono text-xs">
                <span className="shrink-0 text-muted">decided by</span>
                <span className="break-all text-foreground">{decision.actor}</span>
              </div>
              {fields.map((f) => (
                <div key={f.key} className="flex gap-2 font-mono text-xs">
                  <span className="shrink-0 text-muted">{f.label}</span>
                  <span className="break-all text-foreground">
                    {values[f.key] ?? f.value}
                  </span>
                </div>
              ))}
            </div>
          </details>
          <p role="status" aria-live="polite" className="sr-only">
            {receiptLabel} by {decision.actor} at <TimeStamp ts={decision.timestamp} />
          </p>
        </div>
      ) : null}
    </div>
  );
}
Use when

a human reviews and can edit an agent's proposed tool-call arguments before a one-shot, irreversible approve/deny — pick confirm-hold-ink instead for a bare press-and-hold confirm with no payload to inspect or change.

Build spec

Renders one proposed tool call — toolName, the requesting agent's name, and a list of {key, label, value} arguments — as a review row the human must approve or deny before anything runs. Each argument is a real labelled <input>, not a JSON textarea: it starts showing the proposed value and is editable in place. The very first value each field held is captured once at mount and kept for the life of the row; any edit is compared against that original, never against the previous edit, so the row always answers 'what did the human actually change from what the agent asked for'. An optional initialValues map lets a consumer seed a field as already edited before the human ever touches it — useful for showing a queued, pre-adjusted call at rest rather than only after live typing. The moment a field's live value differs from its original, a Geist Mono diff line appears beneath it — the original struck through, an arrow glyph, then the current value — and stays until the row is decided; sr-only 'changed from'/'to' glue text carries the same meaning to assistive tech. Approve and Deny are told apart by fill and weight, not colour: Approve is a solid accent button with a check glyph (the component's one and only use of --accent, reserved for this primary action), Deny is a plain outlined button with an X glyph — no red, no destructive styling, just a lighter, unfilled visual claim so the two outcomes read as equally final but differently weighted. Clicking either calls onDecision once with {outcome, actor: approverName, timestamp} plus the fields as finally edited, then the row collapses: the entire editable body animates its grid-template-rows from 1fr to 0fr (420ms, ease-out-expo-shaped cubic-bezier) and is marked inert so no stray Tab or synthetic replay can reach a control sitting at zero height, while a one-line receipt — outcome glyph, outcome word, tool name, actor, wall-clock time — fades and slides in below it. This state is genuinely terminal: the component holds a decided-once guard that no button, prop, or internal code path ever clears, so there is no way back into edit mode for that call. The receipt line is a native <details>/<summary> disclosure; opening it reveals the full payload exactly as approved or denied, one row per argument, in Geist Mono. A component consumer can pre-seed a row as already decided via initialDecision, for rendering a history/audit feed of past calls next to a live pending one — those seeded rows mount straight into the collapsed receipt (the collapsing editable body is never shown) but still play the same brief fade/slide-in as the receipt itself settles. Fully keyboard operable: Tab reaches every field, then Deny, then Approve, in document order; the decided receipt's summary receives focus automatically after an in-session decision so the outcome — not a vanished button — is what focus and the screen reader land on, backed by an aria-live status announcement of the same text. Under prefers-reduced-motion the collapse and receipt entrance are instant with no transition or keyframe, fully legible and usable either way.

Tags
approvalreviewagentformdiffconfirmationaccessibility