Skip to content
CodeDiff viewerMenu

Diff viewer

Review an agent's code changes: unified or split, word-level highlights, collapsed unchanged lines, per-hunk accept and reject, streaming patches and a guard for very large files. Red and green by default, with a gray tone.

Six files from a pagination change, with changed files on the left and per-hunk accept and reject on the right.

Installation

$ pnpm dlx shadcn@latest add https://chatcn.dev/r/diff-viewer.json

Usage

tsx
import { DiffViewer } from "@/components/ui/chatcn/diff-viewer"

<DiffViewer path="src/lib/runs.ts" oldText={before} newText={after} />

Pass the two versions as oldText and newText and the diff is computed in the browser by chatcn-diff, a dependency-free Myers diff with word-level refinement. Because both versions are known, collapsed unchanged lines can expand. Most agents return unified diff text instead: pass it as patch.

tsx
<DiffViewer patch={toolOutput.diff} streaming={part.state === "input-streaming"} />

For a multi-file change, parse once with parsePatch or diffText and pass each file. useChatcnDiffReview keeps accept and reject decisions across files, with bulk actions and undo.

tsx
import { useChatcnDiffReview } from "@/hooks/use-chatcn-diff-review"
import { applyHunks, parsePatch } from "@/lib/chatcn-diff"

const files = parsePatch(patch)
const review = useChatcnDiffReview(files)

<DiffViewer
  file={file}
  decisions={review.decisions[file.path] ?? {}}
  onDecisionChange={(hunkId, decision) => review.decide(file.path, hunkId, decision)}
  onDecideAll={(decision) => review.decideFile(file.path, decision)}
/>

// The file after review: accepted hunks take the new lines, rejected ones keep the old.
const result = applyHunks(file, review.decisions[file.path] ?? {}, { oldText })

Examples

Per-hunk review

Each hunk gets Accept and Reject. A decided hunk folds down to one line with Show and Undo, so what is left to review is always what you see. With a hunk focused, a and r decide and move to the next pending hunk.

Accept or reject each hunk with the mouse or with j, k, a and r.

Split view

Removed lines on the left, added lines on the right, paired row by row. Split lines always wrap, so the two sides stay aligned.

The same change side by side, with word-level highlights.

Streaming patch

With streaming, a patch that stops mid-hunk renders as far as it has arrived. Hunks only ever append, so nothing above the one being written moves. Word highlights and review actions arrive when a hunk is complete.

Hunks appear as the agent writes them, and finished hunks never move.

Gray tone

Additions and deletions use the familiar green and red from the --chatcn-diff-* tokens, on top of the +/- glyphs and rules, so the diff still reads for people who cannot tell the two apart. tone="mono" drops the hues for a fully gray interface.

Grays and weight only, for a fully monochrome interface. The glyphs carry the meaning.

States

Large diff guard, deleted file, waiting, no changes, binary and wrapped long lines.

Reading a diff without color

LineTreatment
Added+ glyph, a green tint and a 1 px rule at the start
Removed- glyph, a red tint and a 1 px rule. Never struck through
Changed wordsA stronger tint behind just the words that differ
UnchangedMuted gray, no glyph. Long runs fold into Show 24 unchanged lines

Props

PropTypeNotes
fileDiffFileFrom diffText or parsePatch. Wins over the other inputs
patchstringUnified diff text. The first file is shown
oldText, newTextstringTwo versions, diffed in the browser
path, oldPathstringShown in the header. Different paths mean a rename
view, defaultView, onViewChange"unified" | "split"Controlled or uncontrolled
viewTogglebooleanShow the layout toggle. Defaults to true
tone"color" | "mono"Defaults to color: red and green from --chatcn-diff-*
contextnumberUnchanged lines around each change. Defaults to 3
wordsbooleanWord-level highlights. Defaults to true
streamingbooleanThe patch is still arriving
reviewbooleanShow Accept and Reject. On when any decision prop is set
decisions, defaultDecisionsRecord<string, HunkDecision>By hunk id
onDecisionChange(hunkId, decision?) => voidundefined puts a hunk back to pending
onDecideAll(decision?) => voidHeader Accept all and Reject all
bulkActionsbooleanShow the header bulk actions. Defaults to true
maxLinesnumberAbove this, the diff waits behind Show anyway. Defaults to 1000
wrapbooleanWrap long lines in the unified view
headerbooleanShow the file header
actionsReactNodeExtra controls at the end of the header
variant"card" | "plain"plain sits inside a surface you already have

chatcn-diff

FunctionDoes
diffText(old, new, options)Two texts to a DiffFile with hunks, word segments and the full source
parsePatch(text, { streaming })Unified diff text to DiffFile[]: git headers, renames, new, deleted and binary files
applyHunks(file, decisions)The file after review
myersDiff(a, b)Shortest edit script for any two arrays, in linear space
formatPatch(file), createPatch(old, new)Back to unified diff text
diffWords, splitRows, diffGaps, diffStatsThe pieces the viewer is built from

Keyboard

KeyAction
TabMove into the diff. The current hunk is the one focus stop
J, ArrowDownNext hunk
K, ArrowUpPrevious hunk
Home, EndFirst or last hunk
AAccept the focused hunk and move to the next pending one
RReject the focused hunk and move to the next pending one
UPut the focused hunk back to pending

Accessibility

  • Meaning never rides on color: every changed line has a glyph, and screen readers hear Added line 42 or Removed line 40 before its text.
  • Each hunk is a labelled group, for example Change 2 of 4, 3 added, 1 removed, accepted.
  • Decisions and the end of a stream are announced once through a polite live region.
  • The view toggle buttons use aria-pressed. Code is always laid out left to right, while the header follows the page direction.
  • Review buttons grow to 36 px on touch screens.

Motion

  • Almost none, on purpose: a review tool is used hundreds of times a day. Moving between hunks, expanding lines and switching views are instant.
  • The decided check or cross swaps in with the kit's 150 ms blur crossfade. Under reduced motion it simply appears.
  • While a hunk streams, its header shows the Writing shimmer and the caret sits at the last line.