Components
DiffReview
Accept or reject an agent’s edits hunk by hunk, across files, and get the patched files back.
Example
Review changes
4 files · 4 hunks · +21 −1
Comment On line 7
Read the window from an env var, so staging can use a longer one.
2/6 reviewed · 0 of 4 files viewed · 1 comment · unreviewed hunks are skipped
Keyboard: J or K to move between hunks, Shift J or Shift K between files, A to accept, R to reject, U to reset, Alt A or Alt R for the whole file, Shift A or Shift R for every hunk, Shift with the up or down arrow to select lines, C to comment on them or on the hunk, E to show more unchanged lines, V to mark the file viewed, Ctrl Enter to apply.
Interactive, and the same on the components page. Try the keyboard below on it.
Installation
npm i signoff-ui aiimport { DiffReview, reviewToolOutput } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';npx shadcn@latest add https://signoff-ui.vercel.app/r/diff-review.jsonimport { DiffReview } from '@/components/signoff-ui/diff-review';The styles are once per app; Getting started has the Tailwind v4 and plain CSS options.
Usage
<DiffReview
files={[{ path: 'app/api/chat/route.ts', oldContent, newContent }]}
// The agent gets each file as applied, the rejected hunks and every comment.
onSubmit={(result) =>
addToolOutput({ tool: 'review_changes', toolCallId, output: reviewToolOutput(result) })
}
/>How it works
Unified or split review of agent edits across files, with word-level highlights. Give it the old and new contents of each file, or a unified patch when you only have the diff, and it computes the hunks. The reviewer accepts or rejects each hunk or a whole file, comments on lines or hunks, marks files viewed and applies, and the agent gets back what it needs to act on.
What the agent gets back. onSubmit(result, { toPatch }) receives:
files: each file'sdecision(accepted,rejected,partial,pendingorunchanged), whether it was markedviewed, and itscontentwith only the accepted hunks applied, unreviewed ones left out. Each file also carries the ids of the hunks accepted, rejected or left pending.rejectedHunks: each rejected hunk with its lines, so the agent knows what not to do again.comments: each with its file, its line range (side,startLine,endLine), its text and the lines it is on (excerpt).toPatch(): the accepted changes as a git-style unified diff, whichgit applyreads.
reviewToolOutput(result) shapes that for the model. It gives a one-sentence summary, each file's decision with its applied contents, the rejected hunks and the comments; pass { contents: false, patch: toPatch() } to send a patch instead of contents. It is plain JSON, so it is an AI SDK tool output as it is: addToolOutput({ tool: 'review_changes', toolCallId, output: reviewToolOutput(result) }). For an AG-UI agent paused on an interrupt, reviewResumeEntry(interrupt.id, result) is the resume entry, with the same payload: pass it to useAgUiAgent's resolve.
The review is sent once per state of the review. A double click or a repeated ⌘ or Ctrl + Enter sends one review, until a decision, a comment, a viewed mark or the files change. Files compare by content, so an inline files array does not count as a change.
Comments. Select lines from a hunk with Shift and the up or down arrow: down starts at its first changed line, up at its last. Or click a line number and Shift-click another. C comments on the selected lines, or on the whole hunk when none are selected, and the hunk's comment button does the same. ⌘ or Ctrl + Enter saves the comment and Escape cancels it. Lines are numbered in the proposed file, or in the original when only removed lines are selected (side: 'old'). Control comments with comments and onCommentsChange, or start from defaultComments.
Files. A review of several files lists them first. The list is one tab stop: the arrow keys move between files and Enter goes to one. Any file can be accepted or rejected whole, with its buttons or with Alt + A and Alt + R. A file can be marked viewed with its checkbox, or with V, which also moves to the next file. A viewed file folds away (collapseViewed={false} keeps it open), and viewed and onViewedChange control the marks. A renamed file shows its old path. A binary file reads "Binary file, not shown": it is detected from a NUL character in its contents or from a binary patch, or set with binary. Binary files, renames without changes, and files created or deleted empty are each decided as a whole, under the id `${path}:file`.
Context. Unchanged lines between hunks can be shown, 20 at a time or all at once, from the bar between hunks or with E around the focused hunk. That needs both contents: a patch alone has no lines outside its hunks.
Large files. Large files stay responsive. A file that needs more than a few hundred edits is diffed in a worker and reads "Comparing changes…" until it is done; Apply waits for it. Webpack 5, Next.js and Vite emit the package's worker without configuration. diffWorker takes a function that starts your own worker, or false to diff on the main thread after the first paint. A file changed in more places than maxEditLength (2,000 lines added plus removed) is reviewed as one hunk that replaces the changed region, and the review says so. A long review renders only the rows near the screen. The rest keep their height and their text for screen readers, and every hunk stays in place for focus and for J and K. Performance and limits has the numbers.
Your own markup. useDiffReview is all of this without the markup. It gives the files, decisions, comments, selection, viewed marks and shown context, and the keyboard. It also gives getters for items, buttons, the comment editor and a file navigator.
On the server. parseFileChange, applyHunks, computeReviewResult, toPatch, reviewToolOutput and reviewResumeEntry are in signoff-ui/, with no React. A server render diffs the files a render may diff, and shows the rest as being compared, so hydration matches. Long changed lines skip word-level highlights rather than stall.
API reference
Generated from the types the package ships, so it matches the version you install.
DiffReview
Review agent file edits hunk by hunk, in unified or split view, with word-level highlights, and send back what was accepted, what was rejected and what the reviewer said about it.
Keyboard: J/K or the arrows move between hunks and Shift+J/K between files; A accepts, R rejects, U resets, Alt+A / Alt+R decide the file and Shift+A / Shift+R everything; Shift+arrows select lines and C comments on them (or on the hunk); E shows more context; V marks the file viewed; ⌘/Ctrl+Enter applies.
Large files stay responsive: a big diff is computed in a worker while the file shows
"Comparing…", one past maxEditLength becomes a single replacing hunk, and long reviews
render only the rows near the screen. For your own markup, useDiffReview is this component's
state and keyboard without it.
Props
filesrequiredreadonly FileChange[]titleReactNodeHeading. Default "Review changes" (
labels.diffReview.title).viewDiffViewModedefaultViewDiffViewModeDefault'unified''unified' | 'split'onViewChange(view: DiffViewMode) => voidsubmitLabelstringThe apply button's text. Default "Apply 2 of 5", or "Apply changes" before any is accepted (
labels.diffReview.apply).readOnlybooleanDefaultfalseHide review controls, e.g. once the review has been submitted.
contextnumberDefault3Lines of context around each change.
maxEditLengthnumberPast this many lines added plus lines removed in one file, its changed region (from the first changed line to the last) is shown as one hunk that replaces it, and the review says so. Diffing costs about the square of this number. Default 2,000.
autoAdvancebooleanDefaulttrueAfter accepting or rejecting with the keyboard, move to the next undecided hunk.
collapseViewedbooleanDefaulttrueFold a file away once it is marked viewed.
headingLevelHeadingLevelDefault3Heading level for the title, to fit your document outline.
onSubmit(result: DiffReviewResult, review: { toPatch: () => string; }) => void | PromiseLike<void>Called with the reviewed result, and a
toPatchthat gives the accepted changes as a unified diff. Pending hunks are not applied. Fires once per review state, so a double click or a repeated ⌘/Ctrl+Enter sends one review: submitting again needs a changed decision, comment or viewed mark, changedfiles, the returned promise to settle, or the handler to throw. Files compare by content and the rest by value: an equal new array or object does not count as a change.decisionsReadonly<Record<string, HunkDecision>>Controlled decisions keyed by item id: a hunk's
${path}:${index}, or${path}:filefor a file with no hunks (binary, renamed or empty). A repeated path gets${path}#2,#3and so on, skipping a suffix another file's path already has.defaultDecisionsReadonly<Record<string, HunkDecision>>onDecisionsChange(decisions: Record<string, HunkDecision>) => voidcommentsreadonly DiffReviewComment[]Controlled comments.
defaultCommentsreadonly DiffReviewComment[]onCommentsChange(comments: DiffReviewComment[]) => voidviewedReadonly<Record<string, boolean>>Controlled "Viewed" marks, by file id (the path, or
path#2… for a repeated path).defaultViewedReadonly<Record<string, boolean>>onViewedChange(viewed: Record<string, boolean>) => voiddiffWorkerDiffWorkerFactory | falseWhere files too large to diff while rendering are diffed: by default in the package's own worker, which bundlers with worker support emit (webpack 5 and Next.js, Vite, Parcel). Pass a function that starts your own
Workerrunningsignoff-ui's diff worker, orfalseto diff them on the main thread after the first paint. Without a working worker the latter is the fallback.labelsSignoffLabelsInputWords to use instead of the English defaults, for what the hook announces and names: see
SignoffLabelsProvider.
Other props go to the root <section>: className (merged with tailwind-merge), id, aria-*, data-* and event handlers.
FileChange
A file edit proposed by an agent. Provide either full contents or a unified patch.
pathrequiredstringoldPathstringPrevious path, for renames.
oldContentstringOriginal file contents. Omit (or pass '') for new files.
newContentstringProposed file contents. Omit (or pass '') for deletions.
patchstringA unified diff for this file, used when contents are not available.
languagestringLanguage id for highlighting, inferred from the extension when omitted.
binarybooleanA binary file: shown as "Binary file, not shown" and decided as a whole. Detected when omitted: contents with a NUL character in their first 8,000, or a patch that says
Binary files … differ.
DiffReviewResult
filesrequiredDiffReviewFileResult[]acceptedrequirednumberDecisions across the review: hunks, and files decided as a whole.
rejectedrequirednumberpendingrequirednumberrejectedHunksrequiredDiffReviewRejectedHunk[]commentsrequiredDiffReviewResultComment[]
DiffReviewFileResult
pathrequiredstringstatusrequiredFileStatus'added' | 'deleted' | 'modified' | 'renamed'decisionrequiredFileDecision'pending' | 'accepted' | 'rejected' | 'partial' | 'unchanged'acceptedwhen every change was accepted,rejectedwhen every one was,partialfor some of each or some left undecided,pendingwhen none was decided,unchangedwith nothing to decide.viewedrequiredbooleanMarked "Viewed" by the reviewer.
contentrequiredstringFile contents with only accepted hunks applied.
undefinedfor patch-only input.acceptedrequiredstring[]rejectedrequiredstring[]pendingrequiredstring[]oldPathstringThe path before, for a renamed file.
binarytrue
DiffReviewRejectedHunk
A hunk the reviewer turned down, with its lines, so the agent knows exactly what not to do.
idrequiredstringpathrequiredstringheaderrequiredstringdiffrequiredstringThe hunk as unified diff lines:
unchanged,-removed,+added.
DiffReviewComment
A reviewer's comment for the agent, on lines of a hunk, a whole hunk or a whole file.
idrequiredstringUnique within the review.
fileIdrequiredstringThe file's id in the review: its path, or
path#2… for a path that repeats.pathrequiredstringtargetrequired'lines' | 'hunk' | 'file'What it is on.
textrequiredstringhunkIdstringThe hunk the lines are in, for
linesandhunk.sideCommentSideWhich file the line numbers count in: the proposed one (
new), or for removed lines only, the original (old). Absent for a comment on a whole file.startLinenumberendLinenumber
DiffReviewResultComment
idrequiredstringUnique within the review.
fileIdrequiredstringThe file's id in the review: its path, or
path#2… for a path that repeats.pathrequiredstringtargetrequired'lines' | 'hunk' | 'file'What it is on.
textrequiredstringexcerptstringThe lines commented on, as unified diff lines, when they are in the review.
hunkIdstringThe hunk the lines are in, for
linesandhunk.sideCommentSideWhich file the line numbers count in: the proposed one (
new), or for removed lines only, the original (old). Absent for a comment on a whole file.startLinenumberendLinenumber
Extends DiffReviewComment.
FileDecision
How a file came out of the review. partial: some of its changes were accepted, not all.
type FileDecision = 'pending' | 'accepted' | 'rejected' | 'partial' | 'unchanged';HunkDecision
type HunkDecision = 'pending' | 'accepted' | 'rejected';DiffViewMode
type DiffViewMode = 'unified' | 'split';Accessibility
| Keys | Action |
|---|---|
| JK | Next or previous hunk (↓ and ↑ too, from a hunk) |
| ⇧J⇧K | First hunk of the next or previous file |
| A | Accept the hunk, and move to the next undecided one |
| R | Reject the hunk, and move on (X works too) |
| U | Reset the hunk |
| ⌥A⌥R | Accept or reject every hunk in the file (Alt on Windows and Linux) |
| ⇧A⇧R | Accept or reject every hunk |
| ⇧↓⇧↑ | Select lines in the hunk, from its first or last changed line |
| ↓↑ | With lines selected, move the selection by a line |
| Esc | Clear the selection, or cancel the comment being written |
| C | Comment on the selected lines, or on the hunk |
| ⌘/Ctrl↵ | In a comment, save it; elsewhere, apply the review |
| E | Show 20 more unchanged lines above and below the hunk |
| V | Mark the file viewed, fold it and go to the next file |
- A
<section>named by its title. Hunks use a roving tabindex, so the whole review is one tab stop that J, K and the arrows move through. - Each hunk is a group named like "Hunk 2 of 4, app/api/chat/route.ts, lines 12 to 20, accepted"; a file decided as a whole, like "Change 5 of 6, logo.png, binary file, not reviewed".
- Every shortcut is also a button: "Accept hunk 2", "Reject hunk 2", "Reset hunk 2" with
aria-pressed, "Comment on hunk 2", "Accept file app/route.ts", the "Viewed app/route.ts" checkbox, the layout toggle and Apply. Shortcuts only work while focus is inside the review. - Selected lines are announced as they change, line by line: "Added: const ip = … Lines 15 to 17 selected." The comment editor is a labelled text area ("Comment on lines 15 to 17, for the agent") that takes focus, and focus returns to the hunk when it closes.
- The file list is a navigation landmark ("Files in this review") with one tab stop: the arrow keys, Home and End move through it, and each entry reads its path, how many changes are decided and whether it is viewed. A folded file has an expand button with
aria-expanded. - Line numbers and the buttons that show unchanged lines are clickable but out of the tab order (Shift and the arrows select lines, E shows context); the numbers are hidden from screen readers, which read the lines themselves.
- Progress, decisions, comments, viewed marks and shown context are announced: "Hunk 2 of 4 accepted. 2 remaining."
- Changed lines keep their + and − glyphs and are read as "Added:" or "Removed:", so cyan and magenta never carry the meaning alone.
- A long review renders only the rows near the screen. The rest keep their place as visually hidden text, read in the same order with the same "Added:" and "Removed:", and every hunk stays rendered, so its name, focus and J and K never depend on scrolling.
- A file still being diffed reads "Comparing changes…" and is
aria-busy; the review itself is not, so its announcements go out meanwhile. A file pastmaxEditLengthsays it is shown as one replacing hunk.
Theming
Every color, radius and font is a CSS variable, so the overrides in Theming apply, on :root or scoped to any element. These hooks are read from the component's markup.
- Slots (data-slot)
- signoff-diff-hunksignoff-diff-reviewsignoff-diff-submitsignoff-diff-filessignoff-diff-filesignoff-diff-fallbacksignoff-diff-commentsignoff-diff-comment-editorsignoff-diff-chunksignoff-diff-gap
- State attributes
- data-fallbackon
signoff-diff-file'replace', for a file past maxEditLengthdata-viewedonsignoff-diff-fileset on a file marked vieweddata-decisiononsignoff-diff-fileHunkDecision, on hunks; FileDecision, on filesdata-line-indexdata-renderedonsignoff-diff-chunkset while the rows are rendered, near the screendata-linedata-selectedset on a selected line - Tokens it uses
- --signoff-accent--signoff-accent-fg--signoff-add-bg--signoff-add-strong--signoff-bg--signoff-border--signoff-border-strong--signoff-del-bg--signoff-del-strong--signoff-fg--signoff-fg-muted--signoff-fg-subtle--signoff-font-mono--signoff-font-sans--signoff-hot--signoff-hot-fg--signoff-on-accent--signoff-radius--signoff-ring--signoff-surface--signoff-surface-2--signoff-warn--signoff-warn-fg
/* Only this component, and only inside .settings-panel */
.settings-panel [data-slot='signoff-diff-hunk'] {
--signoff-radius: 4px;
}
[data-slot='signoff-diff-file'][data-fallback='replace'] {
outline: 1px solid var(--signoff-accent);
}