Skip to content
KONIGI

AI Assistants / In-product assistance / Suggestion diff

5 of 5

Suggestion diff

The model rewrote a paragraph and the viewer has to see exactly what changed before agreeing to it.

Updated September 12, 2026

Problem

A rewrite comes back and reads well. Whether it kept the one clause that mattered, changed a number, or dropped a qualifier is invisible, because a fluent replacement paragraph looks exactly like a fluent original paragraph.

Solution

Show the change rather than the result. Mark what was removed and what was added, keep both visible until a decision is made, and make accepting an explicit act. Version control has run on that contract for decades, and it earns its keep here for a sharper reason: a person reviewing their own prose can’t reliably spot a single altered word by reading, and a model’s rewrite changes many words at once.

Granularity is the decision that separates a useful implementation from a frustrating one. A single accept-or-reject over a whole rewritten section forces an all-or-nothing judgement on a change that’s usually eighty per cent good. Splitting the proposal into hunks, each independently acceptable, is what lets the viewer keep the tightened opening and reject the sentence that lost the caveat. Per-hunk controls cost more to build and are the difference between a feature people use and one they try twice.

Presentation depends on the material. Inline marking, with deletions struck and insertions underlined, keeps the change in its surrounding context and reads well for small edits in prose. Side-by-side is better for a heavy rewrite, where inline marking produces an unreadable tangle of overlapping changes. Structural edits want a summary above the diff, because a reader can’t reconstruct “these three paragraphs were reordered” from a set of insertions and deletions.

The word-level detail matters for prose more than for code. A line-based diff on a reflowed paragraph reports that the whole paragraph changed, which is technically true and useless. Word-level or sentence-level granularity is what makes a prose diff readable at all.

Reversal is the part that gets skipped. Accepting should be undoable as a single step, and a viewer who accepted six hunks and changed their mind shouldn’t be reversing six operations. HAX guideline 9 asks for efficient correction, and in this pattern the correction is frequently of the acceptance rather than of the model.

Use when

The model proposes changes to material the viewer owns and will be held responsible 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

Diffs are the honest presentation and they’re harder to read than clean text, so viewers under time pressure accept without reading and get the worst of both. Per-hunk controls give real granularity and clutter the document with decision points. Side-by-side preserves clarity at the cost of half the width, which hurts most at the widths where prose is already tight. A diff also makes the model’s changes look larger than they are. An edit touching many small things reads as extensive even when it’s trivial.

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.

Inline assist is what usually produces the proposal. Ghost text is the lighter form for changes small enough not to need a diff. Artifact panel is where a large proposed document goes. Code block actions covers applying a snippet rather than reviewing a change. Response actions is the equivalent control row in a conversation.

Suggestion diff anatomy A proposed rewrite shown as word-level changes across three hunks, each accepted or rejected independently, with a summary above naming a structural change that the marks alone cannot convey. Show the change, not the result 3 changes · two paragraphs reordered keep reject keep reject keep reject word level, because a reflowed paragraph reports as entirely changed one undo reverses all three, not three undos 1 2 3 4 1 WORD LEVEL A line diff on reflowed prose says the paragraph changed, which is useless. 2 PER-HUNK CONTROLS All or nothing forces a verdict on a change that is usually eighty per cent good. 3 SUMMARISE STRUCTURE Nobody reconstructs "these were reordered" from a set of insertions and deletions. 4 REVERSING THE ACCEPT The correction is often of the acceptance rather than of the model. Diffs are honest and harder to read, so people under time pressure accept without reading.
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.

shadcn
npx shadcn@latest add button
Tokens
--card--foreground--muted-foreground--border--status-critical--status-nominal--chart-1

3 changes · two paragraphs reordered

  • Our pricing is designed to grow with you. Every

    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.

Examples

No captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.