Skip to content

Docs

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

Added:lib/ratelimit.ts+10 −0
@@ -1,0 +1,10 @@accepted
Added: import { Ratelimit } from '@upstash/ratelimit';
Added: import { redis } from '@/lib/redis';
Added:
Added: /** 10 requests per 10 seconds per key, sliding window. */
Added: export const ratelimit = new Ratelimit({
Added: redis,
Added: limiter: Ratelimit.slidingWindow(10, '10 s'),

Comment On line 7

Read the window from an env var, so staging can use a longer one.

Added: prefix: 'ratelimit:chat',
Added: analytics: true,
Added: });
Modified:app/api/chat/route.ts+11 −1
@@ -1,6 +1,7 @@
import { convertToModelMessages, streamText, type UIMessage } from 'ai';
import { openai } from '@ai-sdk/openai';
import { tools } from '@/lib/tools';
Added: import { ratelimit } from '@/lib/ratelimit';
export const maxDuration = 30;
4 unchanged lines
@@ -11,6 +12,15 @@
].join(' ');
export async function POST(req: Request) {
Added: const ip = req.headers.get('x-forwarded-for') ?? 'anonymous';
Added: const { success, reset } = await ratelimit.limit(ip);
Added: if (!success) {
Added: return new Response('Too many requests', {
Added: status: 429,
Added: headers: { 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)) },
Added: });
Added: }
Added:
const { messages }: { messages: UIMessage[] } = await req.json();
if (messages.length === 0) return new Response('Empty conversation', { status: 400 });
const modelMessages = convertToModelMessages(messages);
1 unchanged line
@@ -18,7 +28,7 @@rejected
console.info('chat', { turns: messages.length, last: lastUserMessage?.id });
const result = streamText({
Removed: model: openai('gpt-4o'),
Added: model: openai('gpt-4.1-mini'),
system: SYSTEM,
messages: modelMessages,
tools,
4 unchanged lines
Renamed:docs/rate-limiting.md to docs/guides/rate-limiting.md
Renamed from docs/rate-limiting.md, without changes.
Added:public/errors/429.png
Binary file, not shown.

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 ai
import { DiffReview, reviewToolOutput } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

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's decision (accepted, rejected, partial, pending or unchanged), whether it was marked viewed, and its content with 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, which git apply reads.

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/core, 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

filesrequired
readonly FileChange[]
title
ReactNode

Heading. Default "Review changes" (labels.diffReview.title).

view
DiffViewMode
defaultView
DiffViewModeDefault 'unified'
'unified' | 'split'
onViewChange
(view: DiffViewMode) => void
submitLabel
string

The apply button's text. Default "Apply 2 of 5", or "Apply changes" before any is accepted (labels.diffReview.apply).

readOnly
booleanDefault false

Hide review controls, e.g. once the review has been submitted.

context
numberDefault 3

Lines of context around each change.

maxEditLength
number

Past 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.

autoAdvance
booleanDefault true

After accepting or rejecting with the keyboard, move to the next undecided hunk.

collapseViewed
booleanDefault true

Fold a file away once it is marked viewed.

headingLevel
HeadingLevelDefault 3

Heading 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 toPatch that 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, changed files, 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.

decisions
Readonly<Record<string, HunkDecision>>

Controlled decisions keyed by item id: a hunk's ${path}:${index}, or ${path}:file for a file with no hunks (binary, renamed or empty). A repeated path gets ${path}#2, #3 and so on, skipping a suffix another file's path already has.

defaultDecisions
Readonly<Record<string, HunkDecision>>
onDecisionsChange
(decisions: Record<string, HunkDecision>) => void
comments
readonly DiffReviewComment[]

Controlled comments.

defaultComments
readonly DiffReviewComment[]
onCommentsChange
(comments: DiffReviewComment[]) => void
viewed
Readonly<Record<string, boolean>>

Controlled "Viewed" marks, by file id (the path, or path#2… for a repeated path).

defaultViewed
Readonly<Record<string, boolean>>
onViewedChange
(viewed: Record<string, boolean>) => void
diffWorker
DiffWorkerFactory | false

Where 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 Worker running signoff-ui's diff worker, or false to diff them on the main thread after the first paint. Without a working worker the latter is the fallback.

labels
SignoffLabelsInput

Words 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.

pathrequired
string
oldPath
string

Previous path, for renames.

oldContent
string

Original file contents. Omit (or pass '') for new files.

newContent
string

Proposed file contents. Omit (or pass '') for deletions.

patch
string

A unified diff for this file, used when contents are not available.

language
string

Language id for highlighting, inferred from the extension when omitted.

binary
boolean

A 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

filesrequired
DiffReviewFileResult[]
acceptedrequired
number

Decisions across the review: hunks, and files decided as a whole.

rejectedrequired
number
pendingrequired
number
rejectedHunksrequired
DiffReviewRejectedHunk[]
commentsrequired
DiffReviewResultComment[]

DiffReviewFileResult

pathrequired
string
statusrequired
FileStatus
'added' | 'deleted' | 'modified' | 'renamed'
decisionrequired
FileDecision
'pending' | 'accepted' | 'rejected' | 'partial' | 'unchanged'

accepted when every change was accepted, rejected when every one was, partial for some of each or some left undecided, pending when none was decided, unchanged with nothing to decide.

viewedrequired
boolean

Marked "Viewed" by the reviewer.

contentrequired
string

File contents with only accepted hunks applied. undefined for patch-only input.

acceptedrequired
string[]
rejectedrequired
string[]
pendingrequired
string[]
oldPath
string

The path before, for a renamed file.

binary
true

DiffReviewRejectedHunk

A hunk the reviewer turned down, with its lines, so the agent knows exactly what not to do.

idrequired
string
pathrequired
string
headerrequired
string
diffrequired
string

The 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.

idrequired
string

Unique within the review.

fileIdrequired
string

The file's id in the review: its path, or path#2… for a path that repeats.

pathrequired
string
targetrequired
'lines' | 'hunk' | 'file'

What it is on.

textrequired
string
hunkId
string

The hunk the lines are in, for lines and hunk.

side
CommentSide

Which 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.

startLine
number
endLine
number

DiffReviewResultComment

idrequired
string

Unique within the review.

fileIdrequired
string

The file's id in the review: its path, or path#2… for a path that repeats.

pathrequired
string
targetrequired
'lines' | 'hunk' | 'file'

What it is on.

textrequired
string
excerpt
string

The lines commented on, as unified diff lines, when they are in the review.

hunkId
string

The hunk the lines are in, for lines and hunk.

side
CommentSide

Which 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.

startLine
number
endLine
number

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

Keyboard
KeysAction
JKNext or previous hunk (↓ and ↑ too, from a hunk)
⇧J⇧KFirst hunk of the next or previous file
AAccept the hunk, and move to the next undecided one
RReject the hunk, and move on (X works too)
UReset the hunk
⌥A⌥RAccept or reject every hunk in the file (Alt on Windows and Linux)
⇧A⇧RAccept 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
EscClear the selection, or cancel the comment being written
CComment on the selected lines, or on the hunk
⌘/Ctrl↵In a comment, save it; elsewhere, apply the review
EShow 20 more unchanged lines above and below the hunk
VMark 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 past maxEditLength says 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-viewedon signoff-diff-fileset on a file marked vieweddata-decisionon signoff-diff-fileHunkDecision, on hunks; FileDecision, on filesdata-line-indexdata-renderedon signoff-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
app/globals.css
/* 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);
}