{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"agent-review","type":"registry:component","title":"Agent Review Surface","description":"Proposed change, evidence, and a human decision — the step between.","dependencies":[],"categories":["agentic","review","diff","human-in-the-loop","decisions"],"files":[{"path":"components/lab/agent-review/agent-review.tsx","target":"components/lab/agent-review/agent-review.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent,\n} from \"react\";\nimport \"./agent-review.css\";\n\nexport type DiffLineKind = \"add\" | \"remove\" | \"context\";\n\nexport type DiffLine = {\n  kind: DiffLineKind;\n  text: string;\n  line?: number;\n};\n\nexport type ReviewFile = {\n  path: string;\n  hunks: DiffLine[];\n};\n\nexport type ReviewChange = {\n  id: string;\n  title: string;\n  rationale: string;\n  files: ReviewFile[];\n};\n\nexport type ReviewDecision = \"pending\" | \"accepted\" | \"rejected\" | \"revise\";\n\nexport type ReviewEvidence = { label: string; value: string };\n\nexport type AgentReviewSurfaceProps = {\n  /** The proposal: title, rationale, and one or more files of diff rows. */\n  change: ReviewChange;\n  /** Short evidence rows shown with the proposal. */\n  evidence?: ReviewEvidence[];\n  /** Controlled decision; omit to let the surface own the state. */\n  decision?: ReviewDecision;\n  /** Called with the reviewer's choice. */\n  onDecision?: (decision: ReviewDecision, note?: string) => void;\n  /** A / R / E key bindings, shown as hints. */\n  shortcuts?: boolean;\n  className?: string;\n};\n\nconst SIGNS: Record<DiffLineKind, string> = {\n  add: \"+\",\n  remove: \"−\",\n  context: \" \",\n};\n\nconst RESOLVED_TITLE: Record<Exclude<ReviewDecision, \"pending\">, string> = {\n  accepted: \"Change accepted\",\n  rejected: \"Change rejected\",\n  revise: \"Revision requested\",\n};\n\nconst RESOLVED_NOTE: Record<Exclude<ReviewDecision, \"pending\">, string> = {\n  accepted: \"The diff stays in view, locked with this decision.\",\n  rejected: \"No changes are applied to the files above.\",\n  revise: \"The proposal goes back to its author with the note.\",\n};\n\n/**\n * AGENT REVIEW SURFACE — the step between a proposed change and a person.\n *\n * A titled proposal, a structured diff, the evidence the process used,\n * and three honest actions: accept, reject, revise. The diff is data,\n * so it works for code, documents and configuration alike, and the\n * decision is announced politely instead of moved into a modal.\n */\nexport function AgentReviewSurface({\n  change,\n  evidence,\n  decision,\n  onDecision,\n  shortcuts = true,\n  className,\n}: AgentReviewSurfaceProps) {\n  const [internal, setInternal] = useState<ReviewDecision>(decision ?? \"pending\");\n  const [lastDecision, setLastDecision] = useState(decision);\n  const [revising, setRevising] = useState(false);\n  const [note, setNote] = useState(\"\");\n  const [noteFor, setNoteFor] = useState<{\n    decision: ReviewDecision;\n    note: string;\n  } | null>(null);\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const reviseRef = useRef<HTMLButtonElement>(null);\n  const noteId = useId();\n\n  // Keep the uncontrolled state following a controlled prop, per contract.\n  if (decision !== lastDecision) {\n    setLastDecision(decision);\n    if (decision !== undefined) {\n      setInternal(decision);\n      // A host that resolves the decision externally also closes the note.\n      if (decision !== \"pending\") {\n        setRevising(false);\n        setNote(\"\");\n      }\n    }\n  }\n\n  const current = decision ?? internal;\n  const resolved = current !== \"pending\";\n\n  // Revise opens a note field: move focus in, and back on cancel.\n  useEffect(() => {\n    if (revising) textareaRef.current?.focus();\n  }, [revising]);\n\n  const decide = (next: ReviewDecision, withNote?: string) => {\n    const trimmed = withNote?.trim();\n    if (decision === undefined) setInternal(next);\n    setRevising(false);\n    setNote(\"\");\n    setNoteFor(trimmed ? { decision: next, note: trimmed } : null);\n    onDecision?.(next, trimmed || undefined);\n  };\n\n  const cancelRevise = () => {\n    setRevising(false);\n    setNote(\"\");\n    reviseRef.current?.focus();\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    // Shortcuts stay out of the way while the note is being written.\n    if (!shortcuts || resolved || revising) return;\n    if (\n      event.defaultPrevented ||\n      event.metaKey ||\n      event.ctrlKey ||\n      event.altKey ||\n      event.shiftKey\n    ) {\n      return;\n    }\n    const target = event.target instanceof HTMLElement ? event.target : null;\n    if (\n      target &&\n      target.closest(\"textarea, input, select, [contenteditable='true']\")\n    ) {\n      return;\n    }\n    const key = event.key.toLowerCase();\n    if (key === \"a\") {\n      event.preventDefault();\n      decide(\"accepted\");\n    } else if (key === \"r\") {\n      event.preventDefault();\n      decide(\"rejected\");\n    } else if (key === \"e\") {\n      event.preventDefault();\n      setRevising(true);\n    }\n  };\n\n  const statusTitle = current === \"pending\" ? null : RESOLVED_TITLE[current];\n  const bannerNote =\n    noteFor && noteFor.decision === current ? noteFor.note : null;\n  const resolvedSub =\n    current === \"pending\" ? null : bannerNote ?? RESOLVED_NOTE[current];\n\n  return (\n    <div\n      className={`ar-root${className ? ` ${className}` : \"\"}`}\n      data-decision={current}\n      onKeyDown={handleKeyDown}\n    >\n      <span className=\"ar-sr-only sr-only\" role=\"status\" aria-live=\"polite\">\n        {statusTitle ?? \"\"}\n      </span>\n\n      <header className=\"ar-head\">\n        <p className=\"ar-eyebrow\">Proposed change</p>\n        <h3 className=\"ar-title\">{change.title}</h3>\n        <p className=\"ar-rationale\">{change.rationale}</p>\n      </header>\n\n      <div className=\"ar-files\" data-locked={resolved ? \"true\" : \"false\"}>\n        {change.files.map((file, fileIndex) => (\n          <div\n            key={`${file.path}:${fileIndex}`}\n            className=\"ar-file\"\n            role=\"group\"\n            aria-label={file.path}\n          >\n            <div className=\"ar-file-head\">\n              <span className=\"ar-file-path\">{file.path}</span>\n              <span className=\"ar-file-count\">\n                {file.hunks.length} {file.hunks.length === 1 ? \"line\" : \"lines\"}\n              </span>\n            </div>\n            <ul className=\"ar-diff\">\n              {file.hunks.map((line, index) => (\n                <li\n                  key={`${file.path}:${index}`}\n                  className=\"ar-diff-row\"\n                  data-kind={line.kind}\n                >\n                  <span className=\"ar-sign\" aria-hidden=\"true\">\n                    {SIGNS[line.kind]}\n                  </span>\n                  <span className=\"ar-lineno\" aria-hidden=\"true\">\n                    {line.line ?? \"\"}\n                  </span>\n                  <span className=\"ar-text\">\n                    {line.kind === \"add\" ? (\n                      <span className=\"ar-sr-only sr-only\">added: </span>\n                    ) : null}\n                    {line.kind === \"remove\" ? (\n                      <span className=\"ar-sr-only sr-only\">removed: </span>\n                    ) : null}\n                    {line.text}\n                  </span>\n                </li>\n              ))}\n            </ul>\n          </div>\n        ))}\n      </div>\n\n      {evidence && evidence.length > 0 ? (\n        <ul className=\"ar-evidence\" aria-label=\"Evidence\">\n          {evidence.map((item) => (\n            <li key={`${item.label}:${item.value}`} className=\"ar-chip\">\n              <span className=\"ar-chip-label\">{item.label}</span>\n              <span className=\"ar-chip-value\">{item.value}</span>\n            </li>\n          ))}\n        </ul>\n      ) : null}\n\n      {resolved ? (\n        <div className=\"ar-resolved\">\n          <div className=\"ar-banner\" data-state={current}>\n            <span className=\"ar-banner-dot\" aria-hidden=\"true\" />\n            <div className=\"ar-banner-copy\">\n              <p className=\"ar-banner-text\">{statusTitle}</p>\n              <p className=\"ar-banner-note\">{resolvedSub}</p>\n            </div>\n          </div>\n        </div>\n      ) : (\n        <div className=\"ar-actions\">\n          <div className=\"ar-action-row\">\n            <button\n              type=\"button\"\n              className=\"ar-btn ar-btn--primary\"\n              onClick={() => decide(\"accepted\")}\n            >\n              Accept change\n              {shortcuts ? (\n                <kbd className=\"ar-key\" aria-hidden=\"true\">\n                  A\n                </kbd>\n              ) : null}\n            </button>\n            <button\n              type=\"button\"\n              className=\"ar-btn\"\n              onClick={() => decide(\"rejected\")}\n            >\n              Reject\n              {shortcuts ? (\n                <kbd className=\"ar-key\" aria-hidden=\"true\">\n                  R\n                </kbd>\n              ) : null}\n            </button>\n            <button\n              type=\"button\"\n              className=\"ar-btn\"\n              ref={reviseRef}\n              aria-expanded={revising}\n              aria-controls={revising ? noteId : undefined}\n              onClick={() => (revising ? cancelRevise() : setRevising(true))}\n            >\n              Revise\n              {shortcuts ? (\n                <kbd className=\"ar-key\" aria-hidden=\"true\">\n                  E\n                </kbd>\n              ) : null}\n            </button>\n          </div>\n\n          {revising ? (\n            <div className=\"ar-revise\">\n              <label className=\"ar-note-label\" htmlFor={noteId}>\n                What should change?\n              </label>\n              <textarea\n                id={noteId}\n                ref={textareaRef}\n                className=\"ar-note\"\n                rows={3}\n                value={note}\n                placeholder=\"Name the adjustment — the author receives this note.\"\n                onChange={(event) => setNote(event.target.value)}\n              />\n              <div className=\"ar-revise-actions\">\n                <button\n                  type=\"button\"\n                  className=\"ar-btn ar-btn--primary\"\n                  onClick={() => decide(\"revise\", note)}\n                >\n                  Send revision\n                </button>\n                <button type=\"button\" className=\"ar-btn\" onClick={cancelRevise}>\n                  Cancel\n                </button>\n              </div>\n            </div>\n          ) : null}\n        </div>\n      )}\n    </div>\n  );\n}\n"},{"path":"components/lab/agent-review/agent-review.css","target":"components/lab/agent-review/agent-review.css","type":"registry:file","content":"/* ============================================================\n   AGENT REVIEW SURFACE — self-contained styles.\n   Every token is scoped to the root with a fallback, so the\n   surface works in any host codebase. Neutral surfaces, thin\n   rules, mono only where the diff is actually code.\n   ============================================================ */\n\n.ar-root {\n  --ar-ink: #141418;\n  --ar-ink-soft: #303038;\n  --ar-muted: #5f5f68;\n  --ar-faint: #8c8c94;\n  --ar-surface: #ffffff;\n  --ar-surface-soft: #f8f8f6;\n  --ar-code: #fbfbfa;\n  --ar-line: rgba(20, 20, 24, 0.12);\n  --ar-line-soft: rgba(20, 20, 24, 0.07);\n  --ar-accent: #4f27e0;\n  --ar-accent-deep: #32119c;\n  --ar-add-ink: #0f5c3c;\n  --ar-add-wash: rgba(19, 122, 78, 0.09);\n  --ar-remove-ink: #92201a;\n  --ar-remove-wash: rgba(179, 38, 30, 0.07);\n  --ar-warn: #8a5300;\n  --ar-mono: ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, monospace;\n\n  min-width: 0;\n  overflow: hidden;\n  border: 1px solid var(--ar-line);\n  border-radius: 14px;\n  background: var(--ar-surface);\n  color: var(--ar-ink);\n  font-size: 0.875rem;\n  line-height: 1.55;\n}\n\n.ar-root :focus-visible {\n  outline: 2px solid var(--ar-accent);\n  outline-offset: 2px;\n}\n\n/* Visually hidden, in case the host has no utility class of its own. */\n.ar-sr-only {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0 0 0 0);\n  white-space: nowrap;\n  border: 0;\n}\n\n/* ---- header ---- */\n\n.ar-head {\n  padding: 16px 16px 12px;\n}\n\n.ar-eyebrow {\n  margin: 0;\n  font-family: var(--ar-mono);\n  font-size: 0.625rem;\n  letter-spacing: 0.12em;\n  text-transform: uppercase;\n  color: var(--ar-faint);\n}\n\n.ar-title {\n  margin: 6px 0 0;\n  font-size: 1.0625rem;\n  font-weight: 600;\n  letter-spacing: -0.015em;\n  line-height: 1.35;\n  overflow-wrap: anywhere;\n}\n\n.ar-rationale {\n  margin: 8px 0 0;\n  max-width: 62ch;\n  font-size: 0.8125rem;\n  line-height: 1.6;\n  color: var(--ar-muted);\n}\n\n/* ---- files and diff ---- */\n\n.ar-files {\n  display: grid;\n  gap: 10px;\n  padding: 0 16px 14px;\n}\n\n.ar-file {\n  min-width: 0;\n  overflow: hidden;\n  border: 1px solid var(--ar-line-soft);\n  border-radius: 10px;\n  background: var(--ar-surface);\n}\n\n.ar-file-head {\n  display: flex;\n  align-items: baseline;\n  justify-content: space-between;\n  gap: 10px;\n  padding: 8px 10px;\n  border-bottom: 1px solid var(--ar-line-soft);\n  background: var(--ar-surface-soft);\n}\n\n.ar-file-path {\n  font-family: var(--ar-mono);\n  font-size: 0.6875rem;\n  color: var(--ar-ink-soft);\n  overflow-wrap: anywhere;\n}\n\n.ar-file-count {\n  flex: none;\n  font-family: var(--ar-mono);\n  font-size: 0.5625rem;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  color: var(--ar-faint);\n  white-space: nowrap;\n}\n\n.ar-diff {\n  margin: 0;\n  padding: 6px 0;\n  list-style: none;\n  overflow-x: auto;\n  background: var(--ar-code);\n}\n\n.ar-diff-row {\n  display: grid;\n  grid-template-columns: 1.25rem 2.25rem auto;\n  align-items: baseline;\n  width: max-content;\n  min-width: 100%;\n  min-height: 1.35rem;\n  padding: 1px 10px 1px 4px;\n  font-family: var(--ar-mono);\n  font-size: 0.75rem;\n  line-height: 1.5;\n  white-space: pre;\n}\n\n.ar-diff-row[data-kind=\"add\"] {\n  background: var(--ar-add-wash);\n}\n\n.ar-diff-row[data-kind=\"remove\"] {\n  background: var(--ar-remove-wash);\n}\n\n.ar-sign {\n  text-align: center;\n  color: var(--ar-faint);\n}\n\n.ar-diff-row[data-kind=\"add\"] .ar-sign {\n  color: var(--ar-add-ink);\n}\n\n.ar-diff-row[data-kind=\"remove\"] .ar-sign {\n  color: var(--ar-remove-ink);\n}\n\n.ar-lineno {\n  padding-right: 10px;\n  text-align: right;\n  font-size: 0.6875rem;\n  color: var(--ar-faint);\n  user-select: none;\n}\n\n.ar-text {\n  color: var(--ar-ink-soft);\n}\n\n.ar-diff-row[data-kind=\"add\"] .ar-text {\n  color: var(--ar-add-ink);\n}\n\n.ar-diff-row[data-kind=\"remove\"] .ar-text {\n  color: var(--ar-remove-ink);\n}\n\n/* Once decided, the diff is reference material — quieter headers. */\n.ar-files[data-locked=\"true\"] .ar-file-head {\n  background: var(--ar-surface);\n}\n\n/* ---- evidence strip ---- */\n\n.ar-evidence {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px;\n  margin: 0;\n  padding: 0 16px 14px;\n  list-style: none;\n}\n\n.ar-chip {\n  display: inline-flex;\n  align-items: baseline;\n  gap: 6px;\n  border: 1px solid var(--ar-line-soft);\n  border-radius: 999px;\n  background: var(--ar-surface-soft);\n  padding: 4px 10px;\n  font-size: 0.6875rem;\n}\n\n.ar-chip-label {\n  color: var(--ar-faint);\n}\n\n.ar-chip-value {\n  color: var(--ar-ink-soft);\n  font-weight: 500;\n}\n\n/* ---- actions ---- */\n\n.ar-actions {\n  display: grid;\n  gap: 10px;\n  padding: 12px 16px 16px;\n  border-top: 1px solid var(--ar-line-soft);\n}\n\n.ar-action-row {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n.ar-btn {\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n  border: 1px solid var(--ar-line);\n  border-radius: 999px;\n  background: var(--ar-surface);\n  padding: 8px 14px;\n  font: inherit;\n  font-size: 0.8125rem;\n  font-weight: 500;\n  color: var(--ar-ink);\n  cursor: pointer;\n}\n\n.ar-btn:hover {\n  background: var(--ar-surface-soft);\n}\n\n.ar-btn--primary {\n  border-color: var(--ar-accent);\n  background: var(--ar-accent);\n  color: #ffffff;\n}\n\n.ar-btn--primary:hover {\n  background: #3f1bc0;\n}\n\n.ar-key {\n  font-family: var(--ar-mono);\n  font-size: 0.625rem;\n  line-height: 1;\n  padding: 3px 5px;\n  border: 1px solid currentColor;\n  border-radius: 4px;\n  opacity: 0.72;\n}\n\n/* ---- revise form ---- */\n\n.ar-revise {\n  display: grid;\n  gap: 8px;\n  padding: 12px;\n  border: 1px solid var(--ar-line-soft);\n  border-radius: 10px;\n  background: var(--ar-surface-soft);\n}\n\n.ar-note-label {\n  font-size: 0.75rem;\n  font-weight: 600;\n  color: var(--ar-ink-soft);\n}\n\n.ar-note {\n  width: 100%;\n  min-height: 72px;\n  resize: vertical;\n  border: 1px solid var(--ar-line);\n  border-radius: 8px;\n  background: var(--ar-surface);\n  padding: 8px 10px;\n  font: inherit;\n  font-size: 0.8125rem;\n  line-height: 1.5;\n  color: var(--ar-ink);\n}\n\n.ar-note::placeholder {\n  color: var(--ar-faint);\n}\n\n.ar-revise-actions {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n/* ---- resolved banner ---- */\n\n.ar-resolved {\n  padding: 12px 16px 16px;\n  border-top: 1px solid var(--ar-line-soft);\n}\n\n.ar-banner {\n  display: flex;\n  align-items: flex-start;\n  gap: 10px;\n  padding: 10px 12px;\n  border: 1px solid var(--ar-line-soft);\n  border-radius: 10px;\n  background: var(--ar-surface-soft);\n}\n\n.ar-banner-dot {\n  flex: none;\n  width: 8px;\n  height: 8px;\n  margin-top: 6px;\n  border-radius: 50%;\n  background: var(--ar-faint);\n}\n\n.ar-banner[data-state=\"accepted\"] {\n  border-color: rgba(19, 122, 78, 0.24);\n  background: rgba(19, 122, 78, 0.06);\n}\n\n.ar-banner[data-state=\"accepted\"] .ar-banner-dot {\n  background: #147a4e;\n}\n\n.ar-banner[data-state=\"rejected\"] {\n  border-color: rgba(179, 38, 30, 0.22);\n  background: rgba(179, 38, 30, 0.05);\n}\n\n.ar-banner[data-state=\"rejected\"] .ar-banner-dot {\n  background: var(--ar-remove-ink);\n}\n\n.ar-banner[data-state=\"revise\"] {\n  border-color: rgba(138, 83, 0, 0.22);\n  background: rgba(138, 83, 0, 0.05);\n}\n\n.ar-banner[data-state=\"revise\"] .ar-banner-dot {\n  background: var(--ar-warn);\n}\n\n.ar-banner-copy {\n  min-width: 0;\n}\n\n.ar-banner-text {\n  margin: 0;\n  font-size: 0.8125rem;\n  font-weight: 600;\n}\n\n.ar-banner-note {\n  margin: 4px 0 0;\n  font-size: 0.75rem;\n  line-height: 1.55;\n  color: var(--ar-muted);\n  overflow-wrap: anywhere;\n}\n\n/* ---- narrow screens ---- */\n\n@media (max-width: 420px) {\n  .ar-head {\n    padding: 14px 12px 10px;\n  }\n\n  .ar-files {\n    padding: 0 12px 12px;\n  }\n\n  .ar-evidence {\n    padding: 0 12px 12px;\n  }\n\n  .ar-actions,\n  .ar-resolved {\n    padding: 12px;\n  }\n\n  .ar-btn {\n    padding: 8px 12px;\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ar-root * {\n    transition: none;\n  }\n}\n"}]}