People need to see what the assistant changed before accepting a revision.
Updated September 12, 2026
Problem
A rewritten passage may read well while changing a number, removing a qualification, or altering the meaning. People need to see what changed before accepting it.
Solution
Show additions and removals alongside the original text. Keep the original until the person accepts the proposal.
For small prose edits, highlight changed words in place. For a substantial rewrite, use a side-by-side view if there is enough room. Summarize structural changes, such as reordered paragraphs, when markings alone would be hard to follow.
Let people accept or reject separate changes when the proposal contains independent edits. Make acceptance reversible with a clear undo action.
Use when
The assistant proposes changes to content that people need to review and take responsibility for.
Don’t use when
The output is new content with no prior version. There’s nothing to diff against a blank space, and framing a first draft as a proposed change adds ceremony to an operation that’s just insertion.
Trade-offs
Change markings are harder to read than clean text. Separate decisions give more control but can clutter the document. Side-by-side views need width, and many small edits can make a minor revision look extensive.
Checklist
Is the change shown at word level, or only line level?
Can individual hunks be accepted separately?
Is one undo enough to reverse an acceptance?
Does a structural change get a summary as well as a diff?
What does the diff look like on a fully rewritten paragraph?
Is the original recoverable after accepting?
Are additions and deletions distinguished by something other than colour?
Does it work at a narrow width without forcing a horizontal scroll?
Can the viewer edit the proposal before accepting it?
Is there a clear state for a proposal nobody has decided on yet?
Compare
GitHub Copilot delivers its review as pull request comments with suggested changes, so the proposal enters an existing review workflow and inherits its per-hunk accept controls rather than inventing new ones. Notion presents its rewrite as a proposal with accept, discard and retry, keeping the original in place until the viewer decides. Google Docs routes the equivalent through suggestion mode, where a model’s edit becomes the same object as a colleague’s and carries the same accept and reject affordances. Cursor shows code edits as an inline diff in the editor with per-hunk keys. It’s the finest-grained version of the pattern, and the one assuming the most expert reader.
Related
Inline assist often produces the proposed edit. Ghost text handles short completions. Artifact panel provides space for larger documents.
Code block actions applies code to a destination. Response actions offers similar controls for chat answers.
Wireframe — the pattern's anatomy, not any one product's version of it
Implementation
Show the change rather than the result. Word-level hunks accepted or rejected independently, because a reflowed paragraph reports as entirely changed, with a summary above for structural moves the marks cannot convey.
Our pricing is per seat, so it grows with you. Every
unlimited viewers and unlimited projects, and you can move
on a contract, with invoicing and a dedicated support line, billed annually.
3 undecided
Starter gives three seats for free, Team is £12 a seat a month, and Enterprise is priced per seat on a contract, with invoicing and a dedicated support line, billed annually.
Our pricing is designed to grow with you. Every plan includes unlimited viewers and you can move between plans at any time without losing work.
SuggestionDiff.tsxEvery hunk with its own keep and reject, the document updated on each decision, a summary line for what the marks cannot show, and one undo for all of it.
import { useMemo, useState } from "react";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";import { apply, diffProse } from "./diff";const WORDS = ["", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"];type Props = { original: string; proposal: string; /** What the marks cannot say. Computed from the paragraph alignment when * the app has nothing better; a model that explains its own move can * pass it here. */ structure?: string; /** Fires with the whole document after every decision. Keeping a hunk * changes the document at once; there is no separate apply step. */ onChange: (text: string) => void;};/** Every hunk accepted or rejected on its own, and one undo that reverses * all of them. */export function SuggestionDiff({ original, proposal, structure, onChange }: Props) { const { pairs, hunks, moved } = useMemo(() => diffProse(original, proposal), [original, proposal]); const [verdict, setVerdict] = useState<Record<number, "keep" | "reject">>({}); const decided = Object.keys(verdict).length; const decide = (id: number, v: "keep" | "reject") => { const next = { ...verdict, [id]: v }; setVerdict(next); onChange(apply(pairs, hunks, new Set(hunks.filter((h) => next[h.id] === "keep").map((h) => h.id)))); }; const undo = () => { setVerdict({}); onChange(original); }; const summary = structure ?? (moved ? `${WORDS[moved] ?? moved} paragraphs reordered` : ""); return ( <div> <p className="text-xs tabular-nums text-muted-foreground"> {hunks.length} changes{summary && ` · ${summary}`} </p> <ul className="mt-3 flex flex-col gap-2.5"> {hunks.map((h) => ( <li key={h.id} className={cn("flex items-start gap-3", verdict[h.id] && "opacity-60")}> <div className="min-w-0 flex-1 text-sm leading-relaxed"> {/* Struck and underlined as well as tinted, so the two survive without colour. */} {h.removed.length > 0 && ( <p className="rounded-[2px] bg-status-critical/10 px-2 py-1"> <span className="text-muted-foreground">{h.before} </span> <del className="decoration-status-critical">{h.removed.join(" ")}</del> <span className="text-muted-foreground"> {h.after}</span> </p> )} {h.added.length > 0 && ( <p className="mt-0.5 rounded-[2px] bg-status-nominal/10 px-2 py-1"> <span className="text-muted-foreground">{h.before} </span> <ins className="decoration-chart-1 underline-offset-2">{h.added.join(" ")}</ins> <span className="text-muted-foreground"> {h.after}</span> </p> )} </div> <div className="flex shrink-0 gap-1"> {(["keep", "reject"] as const).map((v) => ( <Button key={v} variant="ghost" size="sm" className={cn("h-6 px-1.5 text-xs", verdict[h.id] === v ? "font-medium text-foreground underline" : "text-muted-foreground")} onClick={() => decide(h.id, v)} > {v} </Button> ))} </div> </li> ))} </ul> <div className="mt-4 flex items-center gap-3 text-xs text-muted-foreground"> <span>{hunks.length - decided} undecided</span> {decided > 0 && ( <button type="button" className="underline hover:text-foreground" onClick={undo}>undo</button> )} </div> </div> );}
diff.tsWord-level diff over aligned paragraphs, so a paragraph that moved is reported as a move rather than as a deletion and an insertion.
/** One change, at word level. A line diff on reflowed prose reports the * whole paragraph as changed, which is true and useless. */export type Hunk = { id: number; /** Which paragraph of the proposal it sits in, and where in that * paragraph's original tokens. */ para: number; at: number; removed: string[]; added: string[]; /** A few words either side, so a row reads without the document. */ before: string; after: string;};/** A proposal paragraph and the original paragraph it was matched to. */export type Pair = { from: number; tokens: string[] };const tokens = (s: string) => s.split(/\s+/).filter(Boolean);/** Longest common subsequence over tokens; the gaps between matches are the hunks. */function diffTokens(a: string[], b: string[]) { const n = a.length, m = b.length; const L = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0)); for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i][j] = a[i] === b[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]); const out: { at: number; removed: string[]; added: string[] }[] = []; let i = 0, j = 0, cur: (typeof out)[number] | null = null; while (i < n || j < m) { if (i < n && j < m && a[i] === b[j]) { cur = null; i++; j++; continue; } if (!cur) { cur = { at: i, removed: [], added: [] }; out.push(cur); } if (j < m && (i >= n || L[i][j + 1] >= L[i + 1][j])) cur.added.push(b[j++]); else cur.removed.push(a[i++]); } return out;}/** * Paragraphs first, words second. Each proposal paragraph is paired with the * original paragraph it shares the most words with, so a paragraph that moved * is reported as a move rather than as a deletion here and an insertion there. * An original paragraph nothing matches is dropped with the proposal. */export function diffProse(original: string, proposal: string) { const A = original.split(/\n\s*\n/).map(tokens); const B = proposal.split(/\n\s*\n/).map(tokens); const taken = new Set<number>(); const pairs: Pair[] = B.map((b) => { let from = -1, score = 0; A.forEach((a, k) => { if (taken.has(k)) return; const s = a.filter((w) => b.includes(w)).length; if (s > score) { score = s; from = k; } }); if (from >= 0) taken.add(from); return { from, tokens: from >= 0 ? A[from] : [] }; }); const hunks: Hunk[] = []; pairs.forEach(({ tokens: a }, para) => { for (const h of diffTokens(a, B[para])) { const end = h.at + h.removed.length; hunks.push({ id: hunks.length + 1, para, ...h, before: a.slice(Math.max(0, h.at - 3), h.at).join(" "), after: a.slice(end, end + 3).join(" ") }); } }); const moved = pairs.filter((p, k) => p.from >= 0 && p.from !== k).length; return { pairs, hunks, moved };}/** The document with only the kept hunks applied, in the proposal's order. */export function apply(pairs: Pair[], hunks: Hunk[], kept: Set<number>) { return pairs .map((p, para) => { const out: string[] = []; let i = 0; for (const h of hunks.filter((h) => h.para === para)) { out.push(...p.tokens.slice(i, h.at), ...(kept.has(h.id) ? h.added : h.removed)); i = h.at + h.removed.length; } return [...out, ...p.tokens.slice(i)].join(" "); }) .join("\n\n");}
demo.tsxHow it is called: the pricing draft against its rewrite. Three hunks, two paragraphs reordered, and the document below updating as you decide.
import { useState } from "react";import { SuggestionDiff } from "./SuggestionDiff";/** Two paragraphs of the pricing page. The rewrite moves the intro above the * plans and makes three word-level edits: one replacement, one insertion, * one deletion. */const ORIGINAL = `Starter gives three seats for free, Team is £12 a seat a month, and Enterprise is priced per seat on a contract, with invoicing and a dedicated support line, billed annually.Our pricing is designed to grow with you. Every plan includes unlimited viewers and you can move between plans at any time without losing work.`;const PROPOSAL = `Our pricing is per seat, so it grows with you. Every plan includes unlimited viewers and unlimited projects, and you can move between plans at any time without losing work.Starter gives three seats for free, Team is £12 a seat a month, and Enterprise is priced per seat on a contract, billed annually.`;/** The document is whatever the reader has kept so far, and it starts as * the original. Keep changes it at once; undo puts all of it back. */export default function Demo() { const [doc, setDoc] = useState(ORIGINAL); return ( <div className="rounded-lg border bg-card p-4"> <SuggestionDiff original={ORIGINAL} proposal={PROPOSAL} onChange={setDoc} /> <div className="mt-4 whitespace-pre-line border-t pt-3 text-xs leading-relaxed text-muted-foreground">{doc}</div> </div> );}
What it renders. Identical markup in both panes, with only the token values changing.