Skip to content
Star on GitHub
Review and approveInline text diff
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

Inline text diff

The agent's edits to a piece of copy, accepted or rejected one by one. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add inline-text-diff
Pricing intro4 edits to review

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add inline-text-diff

React

$npx designme add inline-text-diff --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.

Inline text diff shows the agent's edits to a piece of copy where they sit in the text: the old words struck in red, the new ones beside them in green. Clicking an edit opens a small toolbar to accept or reject it, an edit the agent isn't sure of is marked and says why, and the header can accept or reject everything still waiting.

  • When the agent rewrites copy and people should approve it word by word, not all or nothing.
  • When the edits are short and read best in place, inside the sentence they change.
  • When the agent can say it isn't sure of an edit, like a claim that isn't in the brief, so people check that one first.
  • When most edits are fine and one click should take the rest.
03

Usage

Import it and drive it from your own state.

Import

import { InlineTextDiff } from '@/components/designme/inline-text-diff/InlineTextDiff';

Example

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

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { InlineTextDiff, type EditDecision, type TextPart } from './InlineTextDiff';
import { useStory } from '../lib/useScript';

/* ---------- Inline text diff: accept two edits, read why the agent isn't sure of one and reject it, then accept the rest ---------- */
const PARTS: TextPart[] = [
  { key: 'pick', from: 'Pick', to: 'Choose' }, ' a plan that ', { key: 'team', from: 'fits your team', to: 'grows with your studio' }, '. Every plan ',
  { key: 'includes', from: 'includes', to: 'comes with' }, ' unlimited projects, and ',
  { key: 'cancel', from: 'you can cancel at any time', to: 'your first month is free', note: 'Not in the pricing brief: there is no free month' }, '.'
];

export default function InlineTextDiffDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const [decisions, setDecisions] = useState<Record<string, EditDecision>>({});

  // the preview's story, on the same beats as the site's
  useStory(ref, async ({ wait, press }) => {
    await wait(2500); press('[data-edit="pick"]'); await wait(1500); press('.dm-itd-acc');
    await wait(1000); press('[data-edit="team"]'); await wait(1500); press('.dm-itd-acc');
    await wait(1000); press('[data-edit="cancel"]'); await wait(3000); press('.dm-itd-rej');
    await wait(2000); press('.dm-itd-ok');
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <InlineTextDiff title="Pricing intro" parts={PARTS} decisions={decisions} onDecisionsChange={setDecisions} doneLabel="Saved to Pricing v4" />
    </div>
  );
}
04

Props

Everything InlineTextDiff takes.

PropTypeWhat it does
titlerequiredstringWhat the copy is, like "Pricing intro"
partsrequiredTextPart[]The copy in order: plain strings, with the edits between them
decisionsRecord<string, EditDecision>What was decided for each edit, by key. Leave an edit out while it waits
defaultDecisionsRecord<string, EditDecision>
onDecisionsChange(decisions: Record<string, EditDecision>) => void
doneLabelstringThe line that replaces the buttons once every edit is decided, like "Saved to Pricing v4"
labels{ accept?: string; reject?: string; acceptAll?: string; rejectAll?: string }
classNamestring
05

Code

The full source, as the CLI installs it.

InlineTextDiff.tsxReact · 135 lines
'use client';
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Swap } from '../lib/motion';
import '../designme.css';
import './inline-text-diff.css';

export interface TextEdit {
  key: string;
  /** The words the copy has now, shown struck */
  from: string;
  /** The words the agent suggests instead */
  to: string;
  /** Why the agent isn't sure of this one. The edit is marked and the note shows above its buttons */
  note?: string;
}

/** A run of the copy as it stands, or one of the agent's edits to it */
export type TextPart = string | TextEdit;
export type EditDecision = 'accepted' | 'rejected';

export interface InlineTextDiffProps {
  /** What the copy is, like "Pricing intro" */
  title: string;
  /** The copy in order: plain strings, with the edits between them */
  parts: TextPart[];
  /** What was decided for each edit, by key. Leave an edit out while it waits */
  decisions?: Record<string, EditDecision>;
  defaultDecisions?: Record<string, EditDecision>;
  onDecisionsChange?: (decisions: Record<string, EditDecision>) => void;
  /** The line that replaces the buttons once every edit is decided, like "Saved to Pricing v4" */
  doneLabel?: string;
  labels?: { accept?: string; reject?: string; acceptAll?: string; rejectAll?: string };
  className?: string;
}

const isEdit = (p: TextPart): p is TextEdit => typeof p !== 'string';

/** The agent's edits to a piece of copy, struck and added inline. Each one opens a small toolbar to accept or reject it,
    and the header takes the rest all at once. */
export function InlineTextDiff({ title, parts, decisions, defaultDecisions = {}, onDecisionsChange, doneLabel = 'Every edit reviewed', labels = {}, className }: InlineTextDiffProps) {
  const [dec, setDec] = useControllable(decisions, defaultDecisions, onDecisionsChange);
  const [cur, setCur] = useState<string | null>(null);
  const [at, setAt] = useState({ left: 0, top: 0 });
  const root = useRef<HTMLDivElement>(null), bar = useRef<HTMLDivElement>(null);
  const chunks = useRef(new Map<string, HTMLSpanElement>());
  const L = { accept: 'Accept', reject: 'Reject', acceptAll: 'Accept all', rejectAll: 'Reject all', ...labels };
  const edits = parts.filter(isEdit);
  const left = edits.filter(e => !dec[e.key]).length, yes = edits.filter(e => dec[e.key] === 'accepted').length;
  const status = left === edits.length ? `${left} edits to review` : left ? `${left} of ${edits.length} left` : `${yes} accepted, ${edits.length - yes} rejected`;
  const open = edits.find(e => e.key === cur);
  // the toolbar keeps the last edit's note while it fades out
  const last = useRef<TextEdit | undefined>(undefined); if (open) last.current = open;
  // each decision folds an edit into plain text, and the last swaps the actions for a short note: from the first one the
  // card keeps the height it had with every edit open, and centres what is left in it
  const fresh = left === edits.length;
  const openHeight = useRef<number | undefined>(undefined);
  const [held, setHeld] = useState<number>();
  useLayoutEffect(() => { if (fresh && root.current) openHeight.current = root.current.offsetHeight; });
  useLayoutEffect(() => { setHeld(fresh ? undefined : openHeight.current); }, [fresh]);

  // the toolbar opens under the edit's last line; inside a scaled preview the offsets are divided by its scale
  useLayoutEffect(() => {
    const r = root.current, b = bar.current, el = cur ? chunks.current.get(cur) : null; if (!r || !b || !el) return;
    const rb = r.getBoundingClientRect(), k = rb.width / r.offsetWidth || 1, rs = el.getClientRects(), cr = rs[rs.length - 1] ?? el.getBoundingClientRect();
    setAt({ left: Math.max(12, Math.min((cr.left - rb.left) / k, r.offsetWidth - b.offsetWidth - 12)), top: (cr.bottom - rb.top) / k + 6 });
  }, [cur]);
  // Enter or Space opens an edit's toolbar with the focus on Accept, as a dialog takes the focus; Escape brings it back
  const keyOpen = useRef(false);
  useLayoutEffect(() => { if (cur && keyOpen.current) { keyOpen.current = false; bar.current?.querySelector<HTMLElement>('.dm-itd-acc')?.focus({ preventScroll: true }); } }, [cur]);
  // a click anywhere else closes it
  useEffect(() => {
    if (!cur) return;
    const down = (e: PointerEvent) => { const t = e.target as Element; if (!bar.current?.contains(t) && !t.closest?.('.dm-itd-c')) setCur(null); };
    document.addEventListener('pointerdown', down);
    return () => document.removeEventListener('pointerdown', down);
  }, [cur]);

  const decide = (s: EditDecision) => {
    if (!cur) return;
    const back = bar.current?.contains(document.activeElement), el = chunks.current.get(cur);
    setDec({ ...dec, [cur]: s }); setCur(null);
    if (back) el?.focus({ preventScroll: true });
  };
  const all = (s: EditDecision) => { setCur(null); setDec({ ...Object.fromEntries(edits.filter(e => !dec[e.key]).map(e => [e.key, s])), ...dec }); };

  return (
    <div data-dm="" className={className} style={{ width: '100%' }}
      onKeyDown={e => { if (e.key === 'Escape' && cur) { const el = chunks.current.get(cur); setCur(null); el?.focus({ preventScroll: true }); } }}>
      <div ref={root} className={cx('dm-itd dm-panel', held && 'dm-held')} style={held ? { minHeight: held } : undefined}>
        <div className="dm-itd-h">
          <div className="dm-itd-ht"><b>{title}</b><Swap className="dm-muted dm-itd-st">{status}</Swap></div>
          {left > 0 && (
            <div className="dm-itd-a">
              <button type="button" className="dm-pill dm-ghost dm-sm dm-itd-no" onClick={() => all('rejected')}>{L.rejectAll}</button>
              <button type="button" className="dm-pill dm-primary dm-sm dm-itd-ok" onClick={() => all('accepted')}>{L.acceptAll}</button>
            </div>
          )}
        </div>
        <p className="dm-itd-t">
          {parts.map((p, i) => {
            if (!isEdit(p)) return <Fragment key={i}>{p}</Fragment>;
            const n = edits.indexOf(p), d = dec[p.key];
            return (
              <span key={p.key} ref={el => { if (el) chunks.current.set(p.key, el); else chunks.current.delete(p.key); }} data-edit={p.key}
                className={cx('dm-itd-c', p.note && 'dm-unsure', cur === p.key && 'dm-on', d === 'accepted' && 'dm-acc', d === 'rejected' && 'dm-rej')}
                role="button" tabIndex={0} aria-haspopup="dialog" aria-expanded={cur === p.key} aria-label={`Edit ${n + 1} of ${edits.length}: ${p.from}, suggested ${p.to}`}
                onClick={() => setCur(cur === p.key ? null : p.key)}
                onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); keyOpen.current = cur !== p.key; setCur(cur === p.key ? null : p.key); } }}>
                <del>{p.from}</del><ins>{p.to}</ins>
              </span>
            );
          })}
        </p>
        <p className="dm-itd-f dm-muted" aria-live="polite">
          {left === 0 && <span className="dm-enter" style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Icon name="ok" size="xs" /><span>{doneLabel}</span></span>}
        </p>
        <div ref={bar} className={cx('dm-itd-bar', open && 'dm-open')} role="dialog" aria-label="Edit" inert={!open} style={at}
          // the left and right arrows move between Accept and Reject
          onKeyDown={e => { const bs = Array.from(bar.current?.querySelectorAll<HTMLElement>('button') ?? []), j = bs.indexOf(document.activeElement as HTMLElement); if (j < 0) return;
            const d = ({ ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } as Record<string, number>)[e.key], to = d ? bs[(j + d + bs.length) % bs.length] : e.key === 'Home' ? bs[0] : e.key === 'End' ? bs[bs.length - 1] : null;
            if (to) { e.preventDefault(); to.focus({ preventScroll: true }); } }}>
          {last.current?.note && <p className="dm-itd-why"><Icon name="alert" size="xs" /><span>{last.current.note}</span></p>}
          <div className="dm-itd-bb">
            <button type="button" className="dm-pill dm-primary dm-sm dm-itd-acc" onClick={() => decide('accepted')}><Icon name="check" size="xs" />{L.accept}</button>
            <button type="button" className="dm-pill dm-ghost dm-sm dm-itd-rej" onClick={() => decide('rejected')}><Icon name="x" size="xs" />{L.reject}</button>
          </div>
        </div>
      </div>
    </div>
  );
}
inline-text-diff.cssReact · 28 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Inline text diff ---------- */
/* a piece of copy with the agent's edits marked inline, struck and added; each edit opens a small toolbar to accept or
   reject it, and an edit the agent isn't sure of says why */
.dm-itd{position:relative;width:100%;max-width:440px;margin:0 auto;padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-itd-h{display:flex;align-items:center;gap:var(--dm-s-3)}
.dm-itd-ht{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-itd-ht b{font-weight:500;font-size:14px}
.dm-itd-st{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-itd-a{display:flex;gap:6px;flex:none}
.dm-itd-t{margin:0;font-size:14px;line-height:1.8;color:var(--dm-fg)}
.dm-itd-c{padding:2px 1px;border-radius:5px;cursor:pointer;outline:0;transition:background .16s;-webkit-box-decoration-break:clone;box-decoration-break:clone}
.dm-itd-c.dm-on{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-itd-c:hover{background:var(--dm-row)}}
.dm-itd-c:focus-visible{box-shadow:0 0 0 2px var(--dm-focus)}
.dm-itd-c del{color:var(--dm-del);text-decoration:line-through;text-decoration-thickness:1px;background:color-mix(in srgb,var(--dm-del) 9%,transparent);border-radius:3px;padding:0 2px}
.dm-itd-c ins{color:var(--dm-add);text-decoration:none;background:color-mix(in srgb,var(--dm-add) 11%,transparent);border-radius:3px;padding:0 2px;margin-left:3px}
.dm-itd-c.dm-unsure ins{color:var(--dm-warn);background:var(--dm-warn-bg)}
.dm-itd-c.dm-acc del,.dm-itd-c.dm-rej ins{display:none}
.dm-itd-c.dm-acc ins,.dm-itd-c.dm-rej del{color:inherit;background:none;text-decoration:none;margin:0;padding:0}
.dm-itd-bar{position:absolute;left:0;top:0;z-index:5;display:flex;flex-direction:column;gap:var(--dm-s-2);padding:var(--dm-s-2);width:max-content;max-width:260px;border-radius:var(--dm-r-in);background:var(--dm-raised);box-shadow:var(--dm-shadow-pop);opacity:0;transform:translateY(-4px);visibility:hidden;pointer-events:none;transition:opacity .16s,transform .32s var(--dm-ease),visibility 0s .32s}
.dm-itd-bar.dm-open{opacity:1;transform:none;visibility:visible;pointer-events:auto;transition-delay:0s}
.dm-itd-why{margin:0;display:flex;gap:6px;font-size:13px;line-height:1.45;color:var(--dm-warn)}
.dm-itd-why svg{flex:none;margin-top:3px}
.dm-itd-bb{display:flex;gap:6px}
.dm-itd-f{margin:0;display:flex;align-items:center;gap:6px;font-size:13px;min-height:20px}
.dm-itd-f svg{color:var(--dm-ok)}
inline-text-diff.htmlHTML · 14 lines
<div class="card" data-c="inline-text-diff">
  <div class="stage-in">
    <div class="itd panel">
      <div class="itd-h"><div class="itd-ht"><b>Pricing intro</b><span class="muted itd-st">4 edits to review</span></div><div class="itd-a"><button class="pill ghost sm itd-no">Reject all</button><button class="pill primary sm itd-ok">Accept all</button></div></div>
      <p class="itd-t"></p>
      <p class="itd-f muted" aria-live="polite"></p>
      <div class="itd-bar" role="dialog" aria-label="Edit">
        <p class="itd-why"><svg class="i xs"><use href="#i-alert"/></svg><span></span></p>
        <div class="itd-bb"><button class="pill primary sm itd-acc"><svg class="i xs"><use href="#i-check"/></svg>Accept</button><button class="pill ghost sm itd-rej"><svg class="i xs"><use href="#i-x"/></svg>Reject</button></div>
      </div>
    </div>
  </div>
</div>
inline-text-diff.cssHTML · 27 lines
/* ---------- Inline text diff ---------- */
/* a piece of copy with the agent's edits marked inline, struck and added; each edit opens a small toolbar to accept or
   reject it, and an edit the agent isn't sure of says why */
.itd{position:relative;width:100%;max-width:440px;margin:0 auto;padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-3)}
.itd-h{display:flex;align-items:center;gap:var(--s-3)}
.itd-ht{flex:1;min-width:0;display:flex;flex-direction:column}
.itd-ht b{font-weight:500;font-size:14px}
.itd-st{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.itd-a{display:flex;gap:6px;flex:none}
.itd-t{margin:0;font-size:14px;line-height:1.8;color:var(--fg)}
.itd-c{padding:2px 1px;border-radius:5px;cursor:pointer;outline:0;transition:background .16s;-webkit-box-decoration-break:clone;box-decoration-break:clone}
.itd-c.on{background:var(--row)}
@media (hover:hover) and (pointer:fine){.itd-c:hover{background:var(--row)}}
.itd-c:focus-visible{box-shadow:0 0 0 2px var(--focus)}
.itd-c del{color:var(--del);text-decoration:line-through;text-decoration-thickness:1px;background:color-mix(in srgb,var(--del) 9%,transparent);border-radius:3px;padding:0 2px}
.itd-c ins{color:var(--add);text-decoration:none;background:color-mix(in srgb,var(--add) 11%,transparent);border-radius:3px;padding:0 2px;margin-left:3px}
.itd-c.unsure ins{color:var(--warn);background:var(--warn-bg)}
.itd-c.acc del,.itd-c.rej ins{display:none}
.itd-c.acc ins,.itd-c.rej del{color:inherit;background:none;text-decoration:none;margin:0;padding:0}
.itd-bar{position:absolute;left:0;top:0;z-index:5;display:flex;flex-direction:column;gap:var(--s-2);padding:var(--s-2);width:max-content;max-width:260px;border-radius:var(--r-in);background:var(--raised);box-shadow:var(--shadow-pop);opacity:0;transform:translateY(-4px);visibility:hidden;pointer-events:none;transition:opacity .16s,transform .32s var(--ease),visibility 0s .32s}
.itd-bar.open{opacity:1;transform:none;visibility:visible;pointer-events:auto;transition-delay:0s}
.itd-why{margin:0;display:flex;gap:6px;font-size:13px;line-height:1.45;color:var(--warn)}
.itd-why svg{flex:none;margin-top:3px}
.itd-bb{display:flex;gap:6px}
.itd-f{margin:0;display:flex;align-items:center;gap:6px;font-size:13px;min-height:20px}
.itd-f svg{color:var(--ok)}
inline-text-diff.jsHTML · 56 lines
DM.def('inline-text-diff', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{chk}', I('check','xs')).replace('{x}', I('x','xs')).replace('{warn}', I('alert','xs'));
  // the copy, and the agent's edits to it: what it said, what the agent suggests, and why, when the agent isn't sure
  const T = [{ o:'Pick', n:'Choose' }, ' a plan that ', { o:'fits your team', n:'grows with your studio' }, '. Every plan ', { o:'includes', n:'comes with' }, ' unlimited projects, and ', { o:'you can cancel at any time', n:'your first month is free', why:'Not in the pricing brief: there is no free month' }, '.'];
  const E = T.filter(x => typeof x === 'object');
  const root = $('.itd', r), tx = $('.itd-t', r), st = $('.itd-st', r), bar = $('.itd-bar', r), why = $('.itd-why', r), f = $('.itd-f', r), acts = $('.itd-a', r);
  tx.innerHTML = T.map(x => typeof x === 'string' ? x : `<span class="itd-c${x.why ? ' unsure' : ''}" role="button" tabindex="0" aria-haspopup="dialog" aria-expanded="false"><del>${x.o}</del><ins>${x.n}</ins></span>`).join('');
  const chunks = $$('.itd-c', tx);
  E.forEach((e, i) => { e.s = ''; chunks[i].setAttribute('aria-label', `Edit ${i + 1} of ${E.length}: ${e.o}, suggested ${e.n}`); });
  let cur = -1;
  function tally(){
    const n = E.filter(e => !e.s).length, a = E.filter(e => e.s === 'acc').length;
    swap(st, n === E.length ? `${n} edits to review` : n ? `${n} of ${E.length} left` : `${a} accepted, ${E.length - a} rejected`);
    if (!n && !f.textContent){ acts.hidden = true; f.innerHTML = `${I('ok','xs')}<span>Saved to Pricing v4</span>`; enter(f); }
  }
  const close = () => { bar.classList.remove('open'); bar.inert = true; chunks.forEach(x => { x.classList.remove('on'); x.setAttribute('aria-expanded', false); }); cur = -1; };
  // the toolbar opens under the edit's last line; inside a scaled preview the offsets are divided by its scale
  function open(i){
    cur = i; const el = chunks[i], e = E[i];
    chunks.forEach((x, k) => { x.classList.toggle('on', k === i); x.setAttribute('aria-expanded', k === i); });
    why.hidden = !e.why; $('span', why).textContent = e.why || '';
    const rb = root.getBoundingClientRect(), k = rb.width / root.offsetWidth || 1, rs = el.getClientRects(), cr = rs[rs.length - 1] || el.getBoundingClientRect();
    bar.inert = false; bar.classList.add('open');
    bar.style.left = Math.max(12, Math.min((cr.left - rb.left) / k, root.offsetWidth - bar.offsetWidth - 12)) + 'px';
    bar.style.top = ((cr.bottom - rb.top) / k + 6) + 'px';
  }
  // each decision folds an edit into plain text, and the last swaps the actions for a short note: from the first one the
  // card keeps the height it had with every edit open, and centres what is left in it (hold)
  function decide(i, s){ if (!root.classList.contains('held')) hold(root); E[i].s = s; chunks[i].classList.remove('acc', 'rej'); chunks[i].classList.add(s); tally(); }
  // a click anywhere else on the page can close the toolbar between the story's taps, and then there is no edit to decide
  const pick = s => { const i = cur; if (i < 0) return; const back = bar.contains(document.activeElement); close(); decide(i, s); if (back) chunks[i].focus({ preventScroll:true }); };
  // Enter or Space opens an edit's toolbar with the focus on Accept, as a dialog takes the focus; Escape brings it back
  chunks.forEach((el, i) => { el.onclick = () => cur === i ? close() : open(i); el.onkeydown = ev => { if (ev.key === 'Enter' || ev.key === ' '){ ev.preventDefault(); el.click(); if (cur === i) $('.itd-acc', r).focus({ preventScroll:true }); } }; });
  // the left and right arrows move between Accept and Reject
  c.on(bar, 'keydown', ev => { const bs = $$('button', bar), j = bs.indexOf(document.activeElement), d = { ArrowRight:1, ArrowDown:1, ArrowLeft:-1, ArrowUp:-1 }[ev.key];
    if (j < 0) return; const to = d ? bs[(j + d + bs.length) % bs.length] : ev.key === 'Home' ? bs[0] : ev.key === 'End' ? bs[bs.length - 1] : null;
    if (to){ ev.preventDefault(); to.focus({ preventScroll:true }); } });
  $('.itd-acc', r).onclick = () => pick('acc');
  $('.itd-rej', r).onclick = () => pick('rej');
  $('.itd-ok', r).onclick = () => { close(); E.forEach((e, i) => { if (!e.s) decide(i, 'acc'); }); };
  $('.itd-no', r).onclick = () => { close(); E.forEach((e, i) => { if (!e.s) decide(i, 'rej'); }); };
  // Escape or a click elsewhere closes the toolbar
  c.on(r, 'keydown', ev => { if (ev.key === 'Escape' && cur >= 0){ const el = chunks[cur]; close(); el.focus({ preventScroll:true }); } });
  c.on(document, 'pointerdown', ev => { if (cur >= 0 && !bar.contains(ev.target) && !ev.target.closest('.itd-c')) close(); });
  bar.inert = true; tally();
  // the preview's story: accept two edits, read why the agent isn't sure of one and reject it, then accept the rest
  c.story(async s => {
    await s.beat(4);
    await s.tap(chunks[0]); await s.beat(2); await s.tap($('.itd-acc', r)); await s.beat(1);
    await s.tap(chunks[1]); await s.beat(2); await s.tap($('.itd-acc', r)); await s.beat(1);
    await s.tap(chunks[3]); await s.beat(5); await s.tap($('.itd-rej', r)); await s.beat(3);
    await s.tap($('.itd-ok', r)); s.away();
    await s.beat(3);
  });
});
06

Questions

What people ask about it.

Does it work out the diff between two texts?
No. You pass the edits as parts, each with the words it replaces and the words it suggests. Your agent decides what changed; the component shows it and records the decisions.
How is it different from Change review?
Change review is for code: whole files and hunks, kept or undone. Inline text diff is for prose, where each edit is a few words inside a sentence.
How do I get the final text?
Walk parts with the decisions: plain strings as they are, to for an accepted edit and from for a rejected one.
07

More in Review and approve

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

Change reviewAccept or reject each change the agent made, file by file.
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.
All 14 in Review and approve →