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.
On this page
Installation
Usage
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.
<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.
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.
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.
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.
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.
States
Reading a diff without color
| Line | Treatment |
|---|---|
| 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 words | A stronger tint behind just the words that differ |
| Unchanged | Muted gray, no glyph. Long runs fold into Show 24 unchanged lines |
Props
| Prop | Type | Notes |
|---|---|---|
file | DiffFile | From diffText or parsePatch. Wins over the other inputs |
patch | string | Unified diff text. The first file is shown |
oldText, newText | string | Two versions, diffed in the browser |
path, oldPath | string | Shown in the header. Different paths mean a rename |
view, defaultView, onViewChange | "unified" | "split" | Controlled or uncontrolled |
viewToggle | boolean | Show the layout toggle. Defaults to true |
tone | "color" | "mono" | Defaults to color: red and green from --chatcn-diff-* |
context | number | Unchanged lines around each change. Defaults to 3 |
words | boolean | Word-level highlights. Defaults to true |
streaming | boolean | The patch is still arriving |
review | boolean | Show Accept and Reject. On when any decision prop is set |
decisions, defaultDecisions | Record<string, HunkDecision> | By hunk id |
onDecisionChange | (hunkId, decision?) => void | undefined puts a hunk back to pending |
onDecideAll | (decision?) => void | Header Accept all and Reject all |
bulkActions | boolean | Show the header bulk actions. Defaults to true |
maxLines | number | Above this, the diff waits behind Show anyway. Defaults to 1000 |
wrap | boolean | Wrap long lines in the unified view |
header | boolean | Show the file header |
actions | ReactNode | Extra controls at the end of the header |
variant | "card" | "plain" | plain sits inside a surface you already have |
chatcn-diff
| Function | Does |
|---|---|
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, diffStats | The pieces the viewer is built from |
Keyboard
| Key | Action |
|---|---|
Tab | Move into the diff. The current hunk is the one focus stop |
J, ArrowDown | Next hunk |
K, ArrowUp | Previous hunk |
Home, End | First or last hunk |
A | Accept the focused hunk and move to the next pending one |
R | Reject the focused hunk and move to the next pending one |
U | Put 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.