Suggestion diff 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. npx shadcn@latest add button Tokens this needs: --card, --foreground, --muted-foreground, --border, --status-critical, --status-nominal, --chart-1 The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // SuggestionDiff.tsx ──────────────────────────────────────────────────────────────────────── 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>({}); 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 (

{hunks.length} changes{summary && ` · ${summary}`}

{hunks.length - decided} undecided {decided > 0 && ( )}
); } ──────────────────────────────────────────────────────────────────────── // diff.ts ──────────────────────────────────────────────────────────────────────── /** 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(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(); 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) { 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.tsx ──────────────────────────────────────────────────────────────────────── 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 (
{doc}
); }