Skip to content
Star on GitHub
Review and approveChange review
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Change review

Accept or reject each change the agent made, file by file. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add change-review
3 files changed
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add change-review

React

$npx designme add change-review --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Change review lays out the edits an agent made as a file list beside a diff, so a person can go through them one change at a time. Each hunk gets its own Keep and Undo buttons, and any line can carry a question back to the agent.

  • When an agent has edited several files and a person should approve the work before it lands.
  • When some changes are right and others are not, and people need to keep part of the work.
  • When a reviewer wants to ask the agent why it changed a specific line.
  • When you need a running record of which hunks were kept and which were undone.
03

Usage

Import it and drive it from your own state.

Import

import { ChangeReview } from '@/components/designme/change-review/ChangeReview';

Example

The demo above as a React component, 21 lines you can copy.

'use client';
// The preview from designme.dev, rebuilt from the React component: three files to review, hunk by hunk.
import { ChangeReview, type ChangedFile } from './ChangeReview';

const FILES: ChangedFile[] = [
  { name: 'PricingCard.tsx', path: 'src/PricingCard.tsx', hunks: [
    { title: 'Use the shared button', lines: [{ kind: 'ctx', line: 14, text: '  return (' }, { kind: 'del', line: 15, text: '    <button className="cta-old">' }, { kind: 'add', line: 15, text: '    <Button variant="primary">' }, { kind: 'ctx', line: 16, text: '      {plan.cta}' }] },
    { title: 'Drop the second CTA', lines: [{ kind: 'del', line: 22, text: '    <a href="/contact">Talk to sales</a>' }, { kind: 'ctx', line: 22, text: '  </Card>' }] }
  ] },
  { name: 'tokens.css', path: 'src/tokens.css', hunks: [
    { title: 'Raise muted text contrast', lines: [{ kind: 'ctx', line: 8, text: ':root {' }, { kind: 'del', line: 9, text: '  --text-muted: #c9cfd8;' }, { kind: 'add', line: 9, text: '  --text-muted: #5b6472;' }, { kind: 'ctx', line: 10, text: '  --radius-card: 16px;' }] }
  ] },
  { name: 'pricing.mdx', path: 'src/pricing.mdx', hunks: [
    { title: 'Move the FAQ below the table', lines: [{ kind: 'del', line: 31, text: '<FAQ />' }, { kind: 'ctx', line: 31, text: '<CompareTable />' }, { kind: 'add', line: 32, text: '<FAQ />' }] }
  ] }
];

export default function ChangeReviewDemo() {
  return <div style={{ width: '100%' }}><ChangeReview files={FILES} /></div>;
}
04

Props

Everything ChangeReview takes.

PropTypeWhat it does
filesrequiredChangedFile[]
filenumberThe file shown on the right, by index. Leave it out to let the list pick it
defaultFilenumber
onFileChange(index: number) => void
decisionsRecord<string, HunkDecision>Kept or undone, by hunk key. Leave it out to let the buttons keep track
defaultDecisionsRecord<string, HunkDecision>
onDecisionsChange(decisions: Record<string, HunkDecision>) => void
onComment(comment: LineComment) => voidCalled when the person asks the agent about a line. Without a question it sends "Why this change?"
titlestringHeader over the file list. Defaults to "N files changed"
classNamestring
05

Code

The full source, as the CLI installs it.

ChangeReview.tsxReact · 174 lines
'use client';
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './change-review.css';

export interface DiffLine {
  /** add and del lines are coloured; ctx lines are unchanged context */
  kind: 'add' | 'del' | 'ctx';
  /** The line number shown in the gutter */
  line: number;
  text: string;
}

export interface Hunk {
  /** Identifies the hunk in `decisions`. Defaults to "<file name>#<index>" */
  key?: string;
  /** What the change does, like "Use the shared button" */
  title: string;
  lines: DiffLine[];
}

export interface ChangedFile {
  /** Like "PricingCard.tsx". Its extension becomes the type label */
  name: string;
  /** Shown above the diff, like "src/PricingCard.tsx". Defaults to the name */
  path?: string;
  hunks: Hunk[];
}

export type HunkDecision = 'kept' | 'undone';

export interface LineComment {
  file: ChangedFile;
  hunk: Hunk;
  line: DiffLine;
  text: string;
}

export interface ChangeReviewProps {
  files: ChangedFile[];
  /** The file shown on the right, by index. Leave it out to let the list pick it */
  file?: number;
  defaultFile?: number;
  onFileChange?: (index: number) => void;
  /** Kept or undone, by hunk key. Leave it out to let the buttons keep track */
  decisions?: Record<string, HunkDecision>;
  defaultDecisions?: Record<string, HunkDecision>;
  onDecisionsChange?: (decisions: Record<string, HunkDecision>) => void;
  /** Called when the person asks the agent about a line. Without a question it sends "Why this change?" */
  onComment?: (comment: LineComment) => void;
  /** Header over the file list. Defaults to "N files changed" */
  title?: string;
  className?: string;
}

const keyOf = (f: ChangedFile, h: Hunk, k: number) => h.key ?? `${f.name}#${k}`;
const ext = (name: string) => name.split('.').pop() ?? '';

type Box = { draft: string; sent?: string };

/** A diff to review hunk by hunk: keep or undo each change, or ask the agent about a line. */
export function ChangeReview({ files, file, defaultFile = 0, onFileChange, decisions, defaultDecisions = {}, onDecisionsChange, onComment, title, className }: ChangeReviewProps) {
  const [cur, setCur] = useControllable(file, defaultFile, onFileChange);
  const [dec, setDec] = useControllable(decisions, defaultDecisions, onDecisionsChange);
  // the hunks play their entrance again when the file is shown afresh, as after Keep all
  const [shown, setShown] = useState(0);
  const [boxes, setBoxes] = useState<Record<string, Box>>({});
  const next = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => clearTimeout(next.current), []);

  const at = Math.max(0, Math.min(files.length - 1, cur));
  const f = files[at] ?? { name: '', hunks: [] };
  let added = 0, removed = 0;
  files.forEach(x => x.hunks.forEach(h => h.lines.forEach(l => { if (l.kind === 'add') added++; if (l.kind === 'del') removed++; })));

  const show = (i: number) => { setCur(i); setShown(s => s + 1); };
  const decide = (key: string, s: HunkDecision) => { const d = { ...dec }; if (d[key] === s) delete d[key]; else d[key] = s; setDec(d); };
  const bulk = (s: HunkDecision) => {
    const d = { ...dec }; f.hunks.forEach((h, k) => { d[keyOf(f, h, k)] = s; }); setDec(d); setShown(n => n + 1);
    clearTimeout(next.current);
    if (s === 'kept' && at < files.length - 1) next.current = setTimeout(() => show(at + 1), 500);
  };

  const state = (x: ChangedFile) => {
    const all = (s: HunkDecision) => x.hunks.every((h, k) => dec[keyOf(x, h, k)] === s);
    if (all('kept')) return 'kept'; if (all('undone')) return 'undone';
    return x.hunks.some((h, k) => dec[keyOf(x, h, k)]) ? 'some' : '';
  };

  const box = (id: string, b: Box | null) => setBoxes(all => { const n = { ...all }; if (b) n[id] = b; else delete n[id]; return n; });
  const send = (id: string, h: Hunk, l: DiffLine) => {
    const text = boxes[id]?.draft.trim() || 'Why this change?';
    box(id, { draft: '', sent: text });
    onComment?.({ file: f, hunk: h, line: l, text });
  };

  return (
    <div data-dm="" className={cx('dm-crx dm-panel', className)}>
      <div className="dm-crx-side">
        <div className="dm-crx-sh">
          <b>{title ?? `${files.length} ${files.length === 1 ? 'file' : 'files'} changed`}</b>
          <span className="dm-muted dm-crx-tot dm-num"><span style={{ color: 'var(--dm-ok)' }}>+{added}</span> <span style={{ color: 'var(--dm-bad)' }}>−{removed}</span></span>
        </div>
        <div className="dm-crx-files">
          {files.map((x, i) => {
            const s = state(x), e = ext(x.name);
            return (
              <button key={x.name + i} type="button" className={cx('dm-crx-f', i === at && 'dm-on', s === 'kept' && 'dm-kept', s === 'undone' && 'dm-undone')} onClick={() => show(i)}>
                <span className={cx('dm-ft', e === 'tsx' && 'dm-re')}>{e.toUpperCase()}</span>
                <span className="dm-nm">{x.name}</span>
                <span className="dm-st">{s === 'kept' ? <Icon name="check" size="xs" /> : s === 'undone' ? <Icon name="undo" size="xs" /> : <span className="dm-dot" style={s === 'some' ? { color: 'var(--dm-accent)' } : undefined} />}</span>
              </button>
            );
          })}
        </div>
      </div>
      <div className="dm-crx-main">
        <div className="dm-crx-mh">
          <Swap className="dm-crx-fn dm-num">{f.path ?? f.name}</Swap>
          <span className="dm-crx-sp" />
          <button type="button" className="dm-pill dm-ghost dm-sm dm-crx-rej" onClick={() => bulk('undone')}>Undo all</button>
          <button type="button" className="dm-pill dm-primary dm-sm dm-crx-ok" onClick={() => bulk('kept')}>Keep all</button>
        </div>
        <div className="dm-crx-diff" key={at + ':' + shown}>
          {f.hunks.map((h, k) => {
            const key = keyOf(f, h, k), s = dec[key] ?? '', e = enter(k * 70);
            return (
              <div key={key} className={cx('dm-crx-hk', s && 'dm-' + s, e.className)} style={e.style}>
                <div className="dm-crx-hh">
                  <Icon name="spark" size="xs" />
                  <span>{h.title}</span>
                  <span className="dm-sp" />
                  <button type="button" className="dm-pill dm-ghost dm-hk-u" onClick={() => decide(key, 'undone')}>Undo</button>
                  <button type="button" className="dm-pill dm-soft dm-hk-k" onClick={() => decide(key, 'kept')}>Keep</button>
                </div>
                {h.lines.map((l, j) => {
                  const id = `${at}:${k}:${j}`, b = boxes[id];
                  return [
                    <div key={id} className={cx('dm-crx-l', 'dm-' + l.kind)}>
                      <span className="dm-ln">{l.line}</span>
                      <span className="dm-c">{l.text}</span>
                      <Tip label="Comment">
                        <button type="button" className="dm-cm" aria-label="Comment on line" onClick={() => { if (!b) box(id, { draft: '' }); }}><Icon name="plus" size="xs" /></button>
                      </Tip>
                    </div>,
                    b && <Comment key={id + 'c'} box={b} onDraft={draft => box(id, { draft })} onSend={() => send(id, h, l)} onCancel={() => box(id, null)} />
                  ];
                })}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

function Comment({ box, onDraft, onSend, onCancel }: { box: Box; onDraft: (v: string) => void; onSend: () => void; onCancel: () => void }) {
  const input = useRef<HTMLInputElement>(null);
  useEffect(() => { input.current?.focus(); }, []);
  const key = (e: KeyboardEvent) => { if (e.key === 'Enter') onSend(); if (e.key === 'Escape') onCancel(); };
  if (box.sent !== undefined) return <div key="sent" className="dm-crx-cmt dm-sent dm-enter"><Icon name="at" size="xs" /><span>{box.sent}</span></div>;
  return (
    <div key="draft" className="dm-crx-cmt dm-enter">
      <input ref={input} placeholder="Ask the agent about this line" aria-label="Ask the agent about this line" value={box.draft} onChange={e => onDraft(e.target.value)} onKeyDown={key} />
      <button type="button" className="dm-pill dm-accent dm-sm" onClick={onSend}>Send</button>
    </div>
  );
}
change-review.cssReact · 43 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Change review ---------- */
.dm-crx{display:grid;grid-template-columns:210px 1fr;overflow:hidden;min-height:330px}
@media (max-width:720px){.dm-crx{grid-template-columns:1fr}.dm-crx-side{border-right:0!important;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}}
.dm-crx-side{border-right:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);padding:10px;display:flex;flex-direction:column;gap:6px;min-width:0}
.dm-crx-sh{display:flex;justify-content:space-between;align-items:baseline;padding:var(--dm-s-1) 6px;font-size:12.5px}
.dm-crx-sh b{font-weight:500}
.dm-crx-files{display:flex;flex-direction:column;gap:2px}
.dm-crx-f{display:flex;align-items:center;gap:var(--dm-s-2);padding:7px var(--dm-s-2);border-radius:10px;font-size:12.5px;text-align:left;transition:background .25s;min-width:0}
@media (hover:hover) and (pointer:fine){.dm-crx-f:hover{background:var(--dm-row)}}
.dm-crx-f.dm-on{background:var(--dm-sel)}
.dm-crx-f .dm-nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dm-mono);font-size:11.5px}
.dm-crx-f .dm-st{width:16px;height:16px;display:grid;place-items:center;color:var(--dm-faint)}
.dm-crx-f.dm-kept .dm-st{color:var(--dm-ok)}.dm-crx-f.dm-undone .dm-st{color:var(--dm-muted)}
.dm-crx-main{display:flex;flex-direction:column;min-width:0}
.dm-crx-mh{display:flex;align-items:center;gap:6px;padding:10px var(--dm-s-3);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-crx-fn{font-size:12px;font-family:var(--dm-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.dm-crx-sp{flex:1}
.dm-crx-diff{padding:var(--dm-s-2) 0;font-family:var(--dm-mono);font-size:12px;line-height:1.75;overflow-x:auto}
.dm-crx-hk{margin:0 10px 10px;border-radius:10px;overflow:auto hidden;transition:opacity .35s}
.dm-crx-hh{display:flex;align-items:center;gap:6px;padding:var(--dm-s-1) 6px var(--dm-s-1) 10px;font-family:var(--dm-sans);font-size:12px;color:var(--dm-fg2);background:var(--dm-chip)}
.dm-crx-hh .dm-sp{flex:1}
.dm-crx-hh .dm-pill{height:24px;padding:0 9px;font-size:12px}
.dm-crx-l{display:flex;position:relative;white-space:pre;padding-right:10px}
.dm-crx-l .dm-ln{width:36px;text-align:right;padding-right:10px;color:var(--dm-muted);flex:none;user-select:none}
.dm-crx-l.dm-add{background:var(--dm-ok-bg)}.dm-crx-l.dm-add .dm-c::before{content:"+ ";color:var(--dm-ok)}
.dm-crx-l.dm-del{background:var(--dm-bad-bg)}.dm-crx-l.dm-del .dm-c::before{content:"− ";color:var(--dm-bad)}
.dm-crx-l:is(.dm-add,.dm-del) .dm-ln{color:var(--dm-fg2)}
.dm-crx-l.dm-ctx .dm-c::before{content:"  "}
.dm-crx-l .dm-cm{position:absolute;left:4px;top:3px;width:18px;height:18px;border-radius:6px;background:var(--dm-accent);color:var(--dm-on-accent);display:grid;place-items:center;opacity:0;transform:scale(.7);transition:opacity .2s,transform .25s var(--dm-ease)}
/* the hunk scrolls sideways and cuts anything outside its last line: the ring is drawn inside, with a gap of the surface
   between it and the fill */
.dm-crx-l .dm-cm:focus-visible{outline-offset:-2px;box-shadow:inset 0 0 0 3.5px var(--dm-surface);opacity:1;transform:none}
@media (hover:hover) and (pointer:fine){.dm-crx-l:hover .dm-cm{opacity:1;transform:none}}
.dm-crx-hk.dm-kept .dm-crx-l.dm-del{display:none}.dm-crx-hk.dm-kept .dm-crx-l.dm-add{background:transparent}.dm-crx-hk.dm-kept .dm-crx-l.dm-add .dm-c::before{content:"  "}
.dm-crx-hk.dm-undone .dm-crx-l.dm-add{display:none}.dm-crx-hk.dm-undone .dm-crx-l.dm-del{background:transparent}.dm-crx-hk.dm-undone .dm-crx-l.dm-del .dm-c::before{content:"  "}
.dm-crx-hk.dm-kept .dm-crx-hh,.dm-crx-hk.dm-undone .dm-crx-hh{color:var(--dm-muted)}
.dm-crx-cmt{font-family:var(--dm-sans);margin:var(--dm-s-1) 10px 6px 36px;display:flex;gap:6px;align-items:center;padding:6px;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent)}
@supports (corner-shape:squircle){.dm-crx-cmt:not(.dm-sent){corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-crx-cmt input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:12.5px;color:var(--dm-fg);padding:0 6px}
.dm-crx-cmt:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-crx-cmt.dm-sent{background:var(--dm-sel);box-shadow:none;font-size:12.5px;padding:6px 10px}
change-review.htmlHTML · 15 lines
<div class="card wide" data-c="change-review">
  <div class="stage-in">
    <div class="crx panel">
      <div class="crx-side">
        <div class="crx-sh"><b>3 files changed</b><span class="muted crx-tot num"></span></div>
        <div class="crx-files"></div>
      </div>
      <div class="crx-main">
        <div class="crx-mh"><span class="crx-fn num"></span><span class="crx-sp"></span><button class="pill ghost sm crx-rej">Undo all</button><button class="pill primary sm crx-ok">Keep all</button></div>
        <div class="crx-diff"></div>
      </div>
    </div>
  </div>
</div>
change-review.cssHTML · 42 lines
/* ---------- Change review ---------- */
.crx{display:grid;grid-template-columns:210px 1fr;overflow:hidden;min-height:330px}
@media (max-width:720px){.crx{grid-template-columns:1fr}.crx-side{border-right:0!important;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}}
.crx-side{border-right:1px solid color-mix(in srgb,var(--line) 50%,transparent);padding:10px;display:flex;flex-direction:column;gap:6px;min-width:0}
.crx-sh{display:flex;justify-content:space-between;align-items:baseline;padding:var(--s-1) 6px;font-size:12.5px}
.crx-sh b{font-weight:500}
.crx-files{display:flex;flex-direction:column;gap:2px}
.crx-f{display:flex;align-items:center;gap:var(--s-2);padding:7px var(--s-2);border-radius:10px;font-size:12.5px;text-align:left;transition:background .25s;min-width:0}
@media (hover:hover) and (pointer:fine){.crx-f:hover{background:var(--row)}}
.crx-f.on{background:var(--sel)}
.crx-f .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--mono);font-size:11.5px}
.crx-f .st{width:16px;height:16px;display:grid;place-items:center;color:var(--faint)}
.crx-f.kept .st{color:var(--ok)}.crx-f.undone .st{color:var(--muted)}
.crx-main{display:flex;flex-direction:column;min-width:0}
.crx-mh{display:flex;align-items:center;gap:6px;padding:10px var(--s-3);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.crx-fn{font-size:12px;font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.crx-sp{flex:1}
.crx-diff{padding:var(--s-2) 0;font-family:var(--mono);font-size:12px;line-height:1.75;overflow-x:auto}
.crx-hk{margin:0 10px 10px;border-radius:10px;overflow:auto hidden;transition:opacity .35s}
.crx-hh{display:flex;align-items:center;gap:6px;padding:var(--s-1) 6px var(--s-1) 10px;font-family:var(--sans);font-size:12px;color:var(--fg2);background:var(--chip)}
.crx-hh .sp{flex:1}
.crx-hh .pill{height:24px;padding:0 9px;font-size:12px}
.crx-l{display:flex;position:relative;white-space:pre;padding-right:10px}
.crx-l .ln{width:36px;text-align:right;padding-right:10px;color:var(--muted);flex:none;user-select:none}
.crx-l.add{background:var(--ok-bg)}.crx-l.add .c::before{content:"+ ";color:var(--ok)}
.crx-l.del{background:var(--bad-bg)}.crx-l.del .c::before{content:"− ";color:var(--bad)}
.crx-l:is(.add,.del) .ln{color:var(--fg2)}
.crx-l.ctx .c::before{content:"  "}
.crx-l .cm{position:absolute;left:4px;top:3px;width:18px;height:18px;border-radius:6px;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;opacity:0;transform:scale(.7);transition:opacity .2s,transform .25s var(--ease)}
/* the hunk scrolls sideways and cuts anything outside its last line: the ring is drawn inside, with a gap of the surface
   between it and the fill */
.crx-l .cm:focus-visible{outline-offset:-2px;box-shadow:inset 0 0 0 3.5px var(--surface);opacity:1;transform:none}
@media (hover:hover) and (pointer:fine){.crx-l:hover .cm{opacity:1;transform:none}}
.crx-hk.kept .crx-l.del{display:none}.crx-hk.kept .crx-l.add{background:transparent}.crx-hk.kept .crx-l.add .c::before{content:"  "}
.crx-hk.undone .crx-l.add{display:none}.crx-hk.undone .crx-l.del{background:transparent}.crx-hk.undone .crx-l.del .c::before{content:"  "}
.crx-hk.kept .crx-hh,.crx-hk.undone .crx-hh{color:var(--muted)}
.crx-cmt{font-family:var(--sans);margin:var(--s-1) 10px 6px 36px;display:flex;gap:6px;align-items:center;padding:6px;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent)}
@supports (corner-shape:squircle){.crx-cmt:not(.sent){corner-shape:squircle;border-radius:var(--r-field-sq)}}
.crx-cmt input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:12.5px;color:var(--fg);padding:0 6px}
.crx-cmt:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.crx-cmt.sent{background:var(--sel);box-shadow:none;font-size:12.5px;padding:6px 10px}
change-review.jsHTML · 32 lines
DM.def('change-review', (r, c) => {
  const F = [
    { n:'PricingCard.tsx', h:[ { t:'Use the shared button', l:[['ctx',14,'  return ('],['del',15,'    <button className="cta-old">'],['add',15,'    <Button variant="primary">'],['ctx',16,'      {plan.cta}']] }, { t:'Drop the second CTA', l:[['del',22,'    <a href="/contact">Talk to sales</a>'],['ctx',22,'  </Card>']] } ] },
    { n:'tokens.css', h:[ { t:'Raise muted text contrast', l:[['ctx',8,':root {'],['del',9,'  --text-muted: #c9cfd8;'],['add',9,'  --text-muted: #5b6472;'],['ctx',10,'  --radius-card: 16px;']] } ] },
    { n:'pricing.mdx', h:[ { t:'Move the FAQ below the table', l:[['del',31,'<FAQ />'],['ctx',31,'<CompareTable />'],['add',32,'<FAQ />']] } ] }
  ];
  const files = $('.crx-files', r), diff = $('.crx-diff', r), fn = $('.crx-fn', r);
  let add = 0, del = 0; F.forEach(f => f.h.forEach(h => h.l.forEach(l => { if (l[0] === 'add') add++; if (l[0] === 'del') del++; h.s = h.s || ''; })));
  $('.crx-tot', r).innerHTML = `<span style="color:var(--ok)">+${add}</span> <span style="color:var(--bad)">−${del}</span>`;
  files.innerHTML = F.map((f, i) => `<button class="crx-f" data-i="${i}"><span class="ft ${f.n.endsWith('tsx') ? 're' : ''}">${f.n.split('.').pop().toUpperCase()}</span><span class="nm">${f.n}</span><span class="st"><span class="dot"></span></span></button>`).join('');
  let cur = 0;
  const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;');
  function fileState(i){ const hs = F[i].h, b = $$('.crx-f', files)[i]; const all = s => hs.every(h => h.s === s); b.classList.toggle('kept', all('kept')); b.classList.toggle('undone', all('undone')); $('.st', b).innerHTML = all('kept') ? I('check','xs') : all('undone') ? I('undo','xs') : hs.some(h => h.s) ? '<span class="dot" style="color:var(--accent-mark,var(--accent))"></span>' : '<span class="dot"></span>'; }
  function show(i, quiet){
    cur = i; $$('.crx-f', files).forEach((b, j) => b.classList.toggle('on', j === i)); swap(fn, 'src/' + F[i].n);
    diff.innerHTML = F[i].h.map((h, k) => `<div class="crx-hk ${h.s}" data-k="${k}"><div class="crx-hh">${I('spark','xs')}<span>${h.t}</span><span class="sp"></span><button class="pill ghost hk-u">Undo</button><button class="pill soft hk-k">Keep</button></div>${h.l.map(l => `<div class="crx-l ${l[0]}"><span class="ln">${l[1]}</span><span class="c">${esc(l[2])}</span><button class="cm" aria-label="Comment on line" data-tip="Comment">${I('plus','xs')}</button></div>`).join('')}</div>`).join('');
    if (!quiet) stagger($$('.crx-hk', diff), 70);
    $$('.crx-hk', diff).forEach(hk => { const h = F[i].h[+hk.dataset.k];
      const setS = s => { h.s = h.s === s ? '' : s; hk.className = 'crx-hk ' + h.s; fileState(i); };
      $('.hk-k', hk).onclick = () => setS('kept'); $('.hk-u', hk).onclick = () => setS('undone');
      $$('.cm', hk).forEach(b => b.onclick = () => { const ln = b.closest('.crx-l'); if (ln.nextElementSibling?.classList.contains('crx-cmt')) return;
        const box = html(`<div class="crx-cmt"><input placeholder="Ask the agent about this line"><button class="pill accent sm">Send</button></div>`); ln.after(box); enter(box); const inp = $('input', box); inp.focus({ preventScroll:true });
        const send = () => { const v = inp.value.trim() || 'Why this change?'; box.className = 'crx-cmt sent'; box.innerHTML = `${I('at','xs')}<span>${esc(v)}</span>`; enter(box); };
        $('button', box).onclick = send; inp.onkeydown = e => { if (e.key === 'Enter') send(); if (e.key === 'Escape') box.remove(); }; });
    });
  }
  $$('.crx-f', files).forEach(b => b.onclick = () => show(+b.dataset.i));
  const bulk = s => { F[cur].h.forEach(h => h.s = s); fileState(cur); show(cur); if (s === 'kept' && cur < F.length - 1) c.later(500, () => show(cur + 1)); };
  $('.crx-ok', r).onclick = () => bulk('kept'); $('.crx-rej', r).onclick = () => bulk('undone');
  F.forEach((_, i) => fileState(i)); show(0, true);
});
06

Questions

What people ask about it.

How do I know which changes the person kept?
Read the decisions object passed to onDecisionsChange. It maps each hunk key to kept or undone, and hunks with no decision are left out.
What is a hunk key if I don't set one?
It defaults to the file name plus the hunk's index, like PricingCard.tsx#0. Set key on a hunk if you need a stable id of your own.
How do line questions reach my agent?
Handle onComment. It receives the file, hunk, line and the question text, and the sent question stays under the line in the diff.
Does Undo actually revert the code?
No. The component records the decision and greys out the hunk. Applying or reverting the change in your project is up to your own code.
07

More in Review and approve

Moments where a person checks, steers or signs off on the agent's work.

Plan reviewCheck the agent's plan, edit steps, then let it run.
Permission gateThe agent asks before it touches something new.
Hand-off to a personThe agent hands a conversation to a person, with what they need to pick it up.
Decision promptThe agent asks a few quick questions before it starts.
Proposal cardA suggested change with confidence, alternatives and undo.
Design tunerTweak a component by hand or let the agent tune it.
Rewrite toolbarSelect a sentence and let the agent rewrite it in place.
Launch checklistThe agent checks a design before it ships, and fixes what fails.
All 14 in Review and approve →