Skip to main content

DiffReview

When the assistant proposes a change to text (a document, a config, a query) the human reviews it as a diff and takes it hunk by hunk. Give it the two texts and it computes the diff itself (diffHunks from @zuilib/ai/diff-engine), or give it precomputed hunks or a unified patch; decisions come back through callbacks and you decide what accepting means.

import DiffReview from '@zuilib/ai/diff-review'

Unified

Loading example

decisions is a map from hunk index to 'accepted' | 'rejected'. Leave it out and the component keeps the decisions itself (uncontrolled): every button updates them, and onDecisionsChange reports the whole map after each change together with the text it produces, so the simplest wiring is one callback. The per-hunk callbacks still fire alongside. A decided hunk shows a badge instead of its buttons, a rejected one dims, and the header buttons disable once nothing is left to decide.

const [accepted, setAccepted] = useState(document)
<DiffReview original={document} modified={proposal} onDecisionsChange={(_, text) => setAccepted(text)} />

That text is applyDecisions(original, hunks, decisions) from @zuilib/ai/diff-engine: original with every accepted hunk applied, keeping its newline style and whether it ended with a newline (an empty original yields the accepted lines with no trailing newline); a rejected or undecided hunk leaves its lines as they were.

Hunks and patches

hunks takes what diffHunks (or your own diff) produced; patch takes a unified diff (diff -u, git diff) and parses it with parsePatch. Either replaces the texts, which is what a server-side diff needs. The accepted text reported by onDecisionsChange is then built from the hunks' own lines (applyHunkDecisions) rather than from original.

Loading example

Long diffs and the render path

Up to maxLines lines per side (5,000 by default) the diff is an exact LCS table. Beyond it the linear-space Myers walk (myersDiff) runs instead: the same minimal edit script in O(N + M) memory, so a 100k-line file never allocates a table. Past hardMaxLines (200,000) no line diff is attempted: the result is the common prefix and suffix plus one replace hunk, and the card shows truncatedNotice as a status row (data-slot="diff-review-truncated"). The root carries data-algorithm="lcs" | "myers" | "replace". The same limits are DiffOptions on diffLines / diffHunks; diffLinesWithInfo / diffHunksWithInfo return {…, truncated, algorithm}.

The diff never blocks an urgent update: without computeDiff it runs on deferred inputs (useDeferredValue), so typing into the texts renders with the previous hunks first and the recomputation follows; the root is data-pending meanwhile. computeDiff moves it off the main thread entirely: a function receiving {original, modified, context, options} and resolving to diffHunksWithInfo's result (run it in a Web Worker); the card is data-pending and aria-busy until it resolves.

// diff.worker.ts
import {diffHunksWithInfo} from '@zuilib/ai/diff-engine'
self.onmessage = ({data}) => postMessage(diffHunksWithInfo(data.original, data.modified, data.context, data.options))

// the host
<DiffReview original={a} modified={b} computeDiff={(request) => call(worker, request)} />

Split

Two columns need room: when the container is narrower than 384px the component renders the unified layout instead, and data-mode on the root reports the layout actually shown.

Loading example

Props

PropTypeDefaultDescription
original / modifiedstringThe two texts
hunksDiffHunk[]Precomputed hunks; wins over patch and the texts
patchstringA unified diff, parsed with parsePatch
computeDiff(request: DiffComputeRequest) => Promise<DiffHunksResult>Computes the diff off-thread; data-pending until it resolves
mode'unified' | 'split''unified'
contextnumber3Equal lines around each change
originalLabel / modifiedLabelstring'Original' / 'Proposed'
decisionsRecord<number, 'accepted' | 'rejected'>Controlled; omit and the component keeps them
onDecisionsChange(decisions, accepted: string) => voidEvery change, with the text the decisions produce
onAcceptHunk / onRejectHunk(index: number, hunk: DiffHunk) => voidPer-hunk buttons appear when either, or onDecisionsChange, is given
onAcceptAll / onRejectAll() => voidHeader buttons appear when either, or onDecisionsChange, is given
lineNumbersbooleantrue
maxLinesnumber5000Lines per side handled by the LCS table; beyond it the Myers walk runs
hardMaxLinesnumber200000Lines per side beyond which the diff becomes one replace hunk
truncatedNoticeReactNode'This diff is too large…'Shown when truncated
classNamestring

diffLines, diffHunks, diffLinesWithInfo, diffHunksWithInfo, myersDiff, parsePatch, toHunks, toSplitRows, applyDecisions, applyHunkDecisions, DEFAULT_MAX_DIFF_LINES and DEFAULT_HARD_MAX_DIFF_LINES are re-exported from this entry and from @zuilib/ai/diff-engine, so you can also apply a hunk yourself: each DiffHunk carries its lines with old and new line numbers.