AI

Prompt Diff

A word-level diff of two prompt versions — split or unified columns, version chips with author, date and run count, side swap, per-version copy and a one-shot restore.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowLeftRight,
  CaseSensitive,
  Check,
  ChevronDown,
  ChevronUp,
  Columns2,
  Copy,
  Ellipsis,
  Info,
  ListFilter,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/prompt-diff.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "PromptDiff" component: a word-level diff
of two versions of the SAME prompt, with per-version metadata, a side swap, a
copy per version and a restore hook. Dependencies: lucide-react, a shadcn Button
and Badge, a cn() class merger. The diff is implemented in the file — no diff
library, no Markdown renderer.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
  children; the rest of the props spread onto the root.
- a: string — the earlier version (the baseline).
- b: string — the later version (read as the final text).
- meta?: { a?: VersionMeta; b?: VersionMeta } where
  VersionMeta = { label?: string; author?: string; date?: string; runs?: number }.
- view?: "split" | "unified", defaultView = "split", onViewChange?.
- swapped?: boolean, defaultSwapped = false, onSwapChange? — true reads b as the
  baseline and a as the newer text.
- defaultOnlyChanges?: boolean (false), defaultIgnoreCase?: boolean (true).
- contextWords?: number (6) — unchanged words kept on each side of a fold.
- splitMinWidth?: number (560), maxHeight?: number | string.
- showToolbar?: boolean (true), baseLabel = "Base", compareLabel = "Compare",
  emptyLabel = "Empty version", restoreLabel = "Restore".
- onRestore?: (version: { side: "a" | "b"; text: string; meta?: VersionMeta }) => void
  — renders a restore button per version when present. `side` is the PROP the
  version came from, so it stays stable when the sides are swapped.
- actions?: ReactNode — extra toolbar controls; the consumer owns their onClick.
- Also export the pure model builder, buildPromptDiff(base, compare, ignoreCase),
  so an app can show "+42 / -17" in a list row without mounting the panel.

Behavior — the diff unit
- Tokenize each version into units of { word, trailing whitespace }. The
  whitespace rides along so the prompt's blank lines survive verbatim, and it is
  rendered OUTSIDE the ins/del box: a tint that runs through the gap between two
  words reads as one long smear.
- Match words on a NORMALISED key: strip edge punctuation ("refund." vs
  "refund," is not a change anyone wants marked) and lower-case unless the reader
  presses the case toggle. A pure-punctuation token keeps its raw form so `---`
  still diffs. Keep the leading whitespace of each text separately — the
  tokenizer would otherwise drop an opening blank line.
- Do NOT diff by line. Two prompt versions differ by a handful of words inside
  paragraphs a person wrote by hand; a line diff answers "the whole prompt
  changed", which is useless for a reviewer.
- Trim the equal head and tail before building the table (a revision keeps its
  opening role sentence and its closing rule), then run a suffix-LCS table over
  what is left and walk it FORWARD so runs come out in reading order. Deletions
  win ties, so a replacement reads "old words struck through, then the new ones".
- Cap the table (~160k cells). Past it, emit the changed middle as one delete
  plus one insert and show a "too long for exact word matching" notice — an
  unbounded matrix lets a pasted document freeze the tab.
- Keep BOTH sides of an equal pair. The base column then renders its own
  spelling/spacing and the compare column its own, which is what makes a
  case-only or reflow-only edit visible on the side it belongs to.

Behavior — changes as units of review
- Number the changes: a delete immediately followed by an insert is ONE change,
  because that is how a human counts an edit. Everything else (the counter, the
  prev/next arrows, the active highlight) reads from that numbering.
- Prev/next cycle with wrap-around; before the first press the counter reads
  "–/N". `n` and `p` do the same while focus is anywhere inside the component —
  ignore the shortcut when a modifier is held or the event came from an input,
  textarea, select or contenteditable.
- Scrolling to a change moves the PANEL, not the page: measure the target
  against the scroll container and call container.scrollTo, falling back to
  scrollIntoView only when the container is not scrollable. Under
  prefers-reduced-motion the behaviour is "auto" instead of "smooth".
- When a change has both sides, centre the INSERT: it holds the words that are
  staying. Highlight every element of the change (both columns in split view).
- Reset the active change when the model identity changes (new text, swap, case
  toggle) by keying the state on the model object and adjusting it during render
  — not from an effect.

Behavior — the three verdicts the panel must state out loud
- identical: a === b character for character. Say so; the counter is 0 and the
  navigation and fold toggles are disabled.
- cosmetic-only: they differ but added === removed === 0, i.e. only spacing,
  punctuation or (while case is folded) letter case moved. Say that too,
  otherwise a reviewer stares at an untinted diff wondering what broke.
- degraded: the cell cap was hit — the middle is one wholesale replace.
- Derive all three from the model, never from a separate scan of the strings.

Behavior — folding, swapping, copying, restoring
- "Only changes" folds every long equal run into an inline pill showing the
  hidden word count; keep `contextWords` words on each side, drop the context on
  the document's outer edge, never fold a run shorter than ~4 hidden words, and
  never fold at all when there are no changes (it would hide everything). The
  pill is a real <button> that expands that run; expansions reset with the model.
- Swap flips which version is the baseline. Every derived value follows: stats,
  tints, column order, chips, copy targets, the restore payload's `side`.
- Copy writes the RAW prop text (never the rendered, folded text) and reports
  honestly: a success tick, a warning glyph plus a live-region message when the
  clipboard is missing (insecure context) or rejects. Clear the flash with one
  timeout that is cancelled on unmount.
- Restore is ONE SHOT per version: store the exact text that was restored, and
  render the button as a disabled "Restored". A new a/b pair unlocks it by itself
  — the stored text no longer matches — so there is no reset effect and no stale
  flag. Restoring is the consumer's job; the component only fires the callback.

Behavior — layout
- split renders two prose columns (base keeps deletes, compare keeps inserts);
  unified renders one flow with both. Below splitMinWidth the container MEASURES
  itself with a ResizeObserver and renders unified regardless of the requested
  view, showing a "too narrow for split" hint; the toggle keeps reporting what
  was asked for. Width 0 means "not measured yet": render what was asked so
  nothing flashes on the first paint. Disconnect the observer on unmount.
- Headers sit above the body: side by side and aligned with the columns in
  split, stacked in unified. Each shows the role word, the version chip and a
  fact line (author · date · run count · word count).
- maxHeight caps the body and makes it a labelled, focusable scroll region.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the shell, bg-muted/40
  for the toolbar and notices, border, text-muted-foreground, bg-primary/10 +
  decoration-primary for insertions, bg-destructive/10 + decoration-destructive
  for deletions, ring-primary / ring-destructive for the active change. No hex,
  no rgb(), no oklch().
- Colour is never the only signal: insertions are real <ins> elements with an
  underline, deletions real <del> elements with a line-through, so the diff still
  reads in monochrome and for a colour-blind reviewer.
- The prose columns are whitespace-pre-wrap + wrap-anywhere: a prompt's line
  breaks are content, and an unbroken 200-character URL must not widen the card.
- Format dates and counts deterministically (UTC month table, manual thousands
  separator) — an Intl call with the machine's time zone re-renders differently
  on the server and the client and trips hydration.
- Accessibility: the root and each column are named groups; toolbar toggles carry
  aria-pressed; the fold pill is labelled "Show N unchanged words"; a polite
  sr-only status region reports the change you stepped onto ("Change 3 of 6:
  removed X, added Y"), the swap, the copy result and the restore, and is set
  from event handlers so it never re-announces on its own.
- The only animation is a colour transition on the marks, disabled with
  motion-reduce; nothing about the diff depends on it.

Customization levers
- Match strictness: keep edge punctuation, or diff on raw words, by changing the
  match key — punctuation-sensitive prompts (JSON templates, few-shot blocks)
  usually want the raw form. Case folding is already a prop plus a toggle.
- Granularity: swap the tokenizer for a sentence splitter to review long prose
  prompts at sentence level, or for a character walker for short slogans; the
  rest of the pipeline is agnostic.
- Density: the column padding (px-3 py-3), text-sm / leading-relaxed and the
  toolbar's py-1.5 are the only spacing knobs; switch the columns to font-mono
  when your prompts are structured templates rather than prose.
- Chips: `meta` is four optional fields — drop the ones you do not have, or
  replace the fact line with your own (model, temperature, eval score) by editing
  one array of strings.
- Toolbar: pass showToolbar={false} for a read-only diff in a list row, or keep
  it and append your own controls through `actions` (open in editor, view runs).
- Fold: contextWords tunes how much unchanged text survives a fold; raise the
  minimum hidden-word threshold if pills start peppering the paragraph.
- Restore: drop onRestore entirely for an audit view, or rename it ("Promote",
  "Roll back") through restoreLabel — the one-shot lock stays either way.
- Tints: bg-primary/10 and bg-destructive/10 can move to any token pair; keep the
  underline/line-through so the marks survive without colour.

Concepts

  • Word units, not lines — the diff's atom is a word plus the whitespace that followed it; the whitespace rides along so blank lines survive and is painted outside the mark, which is what keeps a tint from smearing across the gap between two words.
  • Normalised match key — words are compared with edge punctuation stripped and (by default) case folded, so refund.refund, is not reported as an edit; flipping the case toggle rebuilds the model and turns the same text into real changes.
  • One numbered change — a deletion immediately followed by an insertion is a single replacement, so the counter, the arrows and the n / p shortcuts step through edits the way a reviewer counts them rather than through raw runs.
  • Swapped baseline — swapping re-assigns which prop plays base and which plays compare; every derived value (stats, tints, chips, copy target, the restore payload's side) follows from that one flag instead of from duplicated state.
  • Cosmetic-only verdict — when two versions differ but nothing was added or removed, the panel says so out loud; an untinted diff with no explanation reads as a broken component.
  • One-shot restore — the button stores the exact text it handed back, so it locks after firing and unlocks by itself when a new version pair arrives; the component never mutates a prompt, it only reports the intent.

On This Page