Review and approveInline text diff
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callInline 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add inline-text-diffReact
npx designme add inline-text-diff --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.
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.
partshold the copy in order: plain strings, with edits between them. Each edit has akey, the words it replaces (from) and the words it suggests (to).- An edit with a
noteis marked in amber, and the note shows above its Accept and Reject buttons. - Clicking an edit opens its toolbar under the edit's last line. Accepting keeps the new words and rejecting keeps the old ones, both as plain text.
decisions/defaultDecisions/onDecisionsChangekeep what was decided,acceptedorrejectedper edit key.- The status under the title counts down the edits left, then says how many were accepted and rejected.
- Accept all and Reject all take every edit still waiting. Once none are left, the buttons give way to
doneLabel.
- Each edit is a button you can reach with Tab and open with Enter or Space, which puts the focus on Accept; ← and → move between Accept and Reject. Its label says which edit it is, the words it replaces and the words it suggests, and
aria-expandedsays whether its toolbar is open. - The toolbar is a dialog labelled "Edit". Escape closes it and puts focus back on the edit, and so does deciding from the keyboard.
- The line under the copy is an
aria-live="polite"region, so the final result is read out.
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>
);
}
Props
Everything InlineTextDiff takes.
| Prop | Type | What it does |
|---|---|---|
titlerequired | string | What the copy is, like "Pricing intro" |
partsrequired | TextPart[] | The copy in order: plain strings, with the edits between them |
decisions | Record<string, EditDecision> | What was decided for each edit, by key. Leave an edit out while it waits |
defaultDecisions | Record<string, EditDecision> | |
onDecisionsChange | (decisions: Record<string, EditDecision>) => void | |
doneLabel | string | The line that replaces the buttons once every edit is decided, like "Saved to Pricing v4" |
labels | { accept?: string; reject?: string; acceptAll?: string; rejectAll?: string } | |
className | string |
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);
});
});
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
partswith thedecisions: plain strings as they are,tofor an accepted edit andfromfor a rejected one.
More in Review and approve
Moments where a person checks, steers or signs off on the agent's work.