Review and approveChange review
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callChange 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add change-reviewReact
npx designme add change-review --reactThe 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.
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.
- The side list shows every file with its type label, a status mark (kept, undone or partly decided) and a total of added and removed lines.
- Each hunk has a title, coloured add and delete lines with line numbers, and its own Keep and Undo buttons. Pressing the same choice again clears it.
- Undo all and Keep all decide every hunk in the open file. After Keep all, the next file opens by itself.
- The plus button on a line opens a field to ask the agent about it. Sending it empty asks "Why this change?" and calls
onCommentwith the file, hunk, line and text. - Decisions are stored by hunk
keyand can be left internal or controlled withdecisionsandonDecisionsChange. - The open file can be controlled with
fileandonFileChange, andtitlereplaces the "N files changed" header.
- Files, hunk choices and bulk actions are all real buttons.
- Each line's comment button has
aria-label="Comment on line"and a Comment tooltip, and it shows while it has the keyboard focus, as it does on hover. - The comment field is focused when it opens and has an
aria-label. Enter sends it and Escape closes it.
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>;
}
Props
Everything ChangeReview takes.
| Prop | Type | What it does |
|---|---|---|
filesrequired | ChangedFile[] | |
file | number | The file shown on the right, by index. Leave it out to let the list pick it |
defaultFile | number | |
onFileChange | (index: number) => void | |
decisions | Record<string, HunkDecision> | Kept or undone, by hunk key. Leave it out to let the buttons keep track |
defaultDecisions | Record<string, HunkDecision> | |
onDecisionsChange | (decisions: Record<string, HunkDecision>) => void | |
onComment | (comment: LineComment) => void | Called when the person asks the agent about a line. Without a question it sends "Why this change?" |
title | string | Header over the file list. Defaults to "N files changed" |
className | string |
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,'&').replace(/</g,'<');
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);
});
Questions
What people ask about it.
- How do I know which changes the person kept?
- Read the
decisionsobject passed toonDecisionsChange. It maps each hunk key tokeptorundone, 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. Setkeyon 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.
More in Review and approve
Moments where a person checks, steers or signs off on the agent's work.