ns-ui
uiapproval-inline-diffsource: ok · refreshed
Approval Inline Diff
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.
npx shadcn add "https://design.helpmarq.com/r/approval-inline-diff.json"free
Where it goes
data-table
Interaction
accordion
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files1 · source cached 10/8/2026, 2:52:21 AM
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;
/** the call's arguments, one editable field each */
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;
/** called once the human decides, with the final field values */
onDecision?: (decision: AssayDecision, fields: AssayField[]) => void;
/** pre-seed as already decided (e.g. rendering decision history) */
initialDecision?: AssayDecision;
/** extra classes merged onto the rendered root element */
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-ns-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-ns-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-ns-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-ns-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-ns-muted decoration-1">
{originals[f.key]}
</s>
<span className="sr-only">to</span>
<span className="text-ns-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-ns-accent"
>
<DenyIcon />
Deny
</button>
<button
type="button"
data-assay-approve
onClick={() => decide("approved")}
className="inline-flex items-center gap-1.5 rounded-sm bg-ns-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-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-ns-muted")
}
aria-hidden
>
{approved ? <CheckIcon /> : <DenyIcon />}
</span>
<span className={"shrink-0 " + (approved ? "font-medium" : "")}>
{receiptLabel}
</span>
<span className="shrink-0 text-ns-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-ns-muted">{toolName}</span>
<span className="shrink-0 text-ns-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-ns-muted"
title={decision.actor}
>
{decision.actor}
</span>
<span className="shrink-0 text-ns-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-ns-muted">
<TimeStamp ts={decision.timestamp} />
</span>
<span className="ml-auto flex shrink-0 items-center gap-1 pl-3 text-ns-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-ns-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-ns-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-ns-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>
);
}