The most dangerous sentence in an AI-powered codebase is: “I made a small change.”
Small compared to what?
An agent may say it “cleaned up the auth middleware,” “optimized the config,” or “updated dependencies.” That is not enough. If an AI agent can modify code, configuration, infrastructure definitions, database records, or application content, the human reviewer needs something stronger than a summary.
They need a diff.
Not a vague explanation. Not a list of intentions. Not “here are the files I touched.” A precise, reviewable, approve-or-rejectable view of the exact proposed change.
A diff viewer for AI agents is not just a code review widget. It is a trust boundary. It answers:
- What is the current state?
- What does the agent want the new state to be?
- Which lines, fields, records, or files change?
- What gets deleted?
- What gets renamed?
- What looks risky?
- Is the proposal still valid against the latest version of the target?
- Can a human approve, reject, or modify it safely?
This article walks through how to build that system properly.
TL;DR
- Do not let the agent describe changes in prose. Require a structured change proposal.
- Compute diffs from the trusted current state, not from the agent’s memory.
- Use unified diffs for text/code, JSON Patch for structured data, and field diffs for forms.
- Show a change manifest: files, statuses, line counts, risk flags.
- Render diffs with syntax highlighting, collapsible context, and side-by-side mode.
- Normalize noise: whitespace, formatting, lockfiles, generated files.
- Highlight risky edits: deletions, secrets, migrations, permission changes.
- Make approval stateful: base hash, accept/reject, comments, re-propose.
- Do not build hunk-level partial approval on day one. Start with file-level approval.
📋 Table of Contents
- 1. Stop reviewing the agent’s explanation; review the change set
- 2. Make the proposal a concrete artifact
- 3. Compute diffs from trusted current state
- 4. Pick the right diff format for the artifact
- 5. Build a change manifest reviewers can scan
- 6. Render diffs with enough context to say yes or no
- 7. Tame noisy changes before showing them
- 8. Flag risky changes like a security reviewer would
- 9. Turn the viewer into an approval gate
- 10. Test against the proposals that will actually hurt you
- What I would build
1. Stop reviewing the agent’s explanation; review the change set
Scenario: An agent says, “I updated the payment service to improve error handling.” The human approves. Later, someone notices the agent also removed a timeout, changed a retry policy, and added a debug log that prints card metadata.
The problem is not that the agent made multiple changes. The problem is that the review surface was the agent’s explanation instead of the actual change set.
A diff viewer for AI agents needs to invert the workflow.
Bad flow:
Agent writes explanation
Human reads explanation
Human approves
Agent applies changes
Better flow:
Agent produces proposed state
System computes diff against current state
Human reviews exact diff
Human approves or rejects
System applies approved change
The agent’s explanation still matters. It belongs in metadata: “Why are you making this change?” But the explanation must never be the thing being approved.
The thing being approved is the diff.
This distinction becomes even more important when agents work autonomously across many files. A human can skim a summary and miss a destructive change. A well-designed diff viewer makes deletions, additions, and risky edits visible.
2. Make the proposal a concrete artifact
Before you build UI, define the data model.
A useful AI change proposal is not a message. It is a structured artifact that includes:
- The target being changed.
- The type of change.
- The current base version, if known.
- The proposed new state.
- The reason for the change.
- The agent session or tool call that created it.
- A risk classification.
- A status: pending, approved, rejected, applied, failed.
For file-based changes, the proposal might look like this:
type FileChangeAction =
| "create"
| "update"
| "delete"
| "rename";
type FileChangeProposal = {
proposalId: string;
targetUri: string;
action: FileChangeAction;
// For rename actions
proposedTargetUri?: string;
// The content the agent wants to write
proposedContent?: string;
// Hash of the current version the agent based its proposal on
baseContentHash?: string;
intent: string;
explanation?: string;
source: {
agentSessionId: string;
toolName: string;
modelId?: string;
requestId?: string;
};
createdAt: string;
};
This gives you something durable to review.
If the target is JSON, such as application settings, feature flags, or a Kubernetes manifest, do not force the agent to output a giant text diff. Ask for the proposed JSON document or a set of structured operations.
type JsonChangeProposal = {
proposalId: string;
targetUri: string;
action: "update";
proposedValue: unknown;
baseContentHash?: string;
intent: string;
explanation?: string;
source: {
agentSessionId: string;
toolName: string;
};
createdAt: string;
};
If the target is a database row, the proposal should include the record identifier and the field-level changes.
type RecordChangeProposal = {
proposalId: string;
targetUri: string; // e.g. "postgres://billing/customers/8421"
action: "update";
proposedFields: Record<string, unknown>;
baseRevision?: string;
intent: string;
explanation?: string;
source: {
agentSessionId: string;
toolName: string;
};
createdAt: string;
};
🔍 Why this matters: Once the proposal is a concrete artifact, you can version it, validate it, diff it, approve it, reject it, audit it, and replay it. If it only exists as chat text, you have no machine-verifiable truth.
3. Compute diffs from trusted current state
A common mistake is to trust the agent’s version of the current state.
The agent may say:
Original file:
export const timeout = 3000;
Proposed file:
export const timeout = 10000;
But the actual file in the repository may already be:
export const timeout = 5000;
Now the diff based on the agent’s memory is misleading. It hides the real change from the human reviewer.
The diff must be computed against the trusted source of truth:
- The repository.
- The database.
- The configuration store.
- The object storage bucket.
- The CMS.
- The live application state.
The agent can provide a baseContentHash, but your system should verify it.
import { createHash } from "node:crypto";
import { createTwoFilesPatch } from "diff";
export function sha256Hex(input: string): string {
return createHash("sha256").update(input, "utf8").digest("hex");
}
export class StaleProposalError extends Error {
constructor(message: string) {
super(message);
this.name = "StaleProposalError";
}
}
export async function buildFileDiff(
proposal: FileChangeProposal,
readCurrentFile: (uri: string) => Promise<string>
) {
const currentContent =
proposal.action === "create"
? ""
: await readCurrentFile(proposal.targetUri);
const currentHash = sha256Hex(currentContent);
if (proposal.baseContentHash && proposal.baseContentHash !== currentHash) {
throw new StaleProposalError(
`Proposal ${proposal.proposalId} is based on an outdated version of ${proposal.targetUri}`
);
}
const proposedContent =
proposal.action === "delete"
? ""
: proposal.proposedContent ?? "";
const patch = createTwoFilesPatch(
`a/${proposal.targetUri}`,
`b/${proposal.proposedTargetUri ?? proposal.targetUri}`,
currentContent,
proposedContent,
"",
"",
{
context: 3,
}
);
return {
proposalId: proposal.proposalId,
targetUri: proposal.targetUri,
currentHash,
patch,
};
}
The important part is that the server, not the agent, owns the comparison.
The agent may propose content. It may even propose a patch. But the reviewer should see the diff generated from the real current state.
This also protects against a more subtle problem: the agent may hallucinate the original file. It can produce a plausible-looking diff that does not correspond to reality. Server-side diffing catches that.
⚠️ Gotcha: If the target can change frequently, store the
currentHashwith the proposal at review time. If the target changes before approval, mark the proposal stale and require the agent to re-propose.
4. Pick the right diff format for the artifact
Not everything should be reviewed as a text diff.
A diff viewer for AI agents needs to handle multiple kinds of changes. The wrong diff format makes the change harder to understand, not easier.
Text and source code
Use a unified diff.
Unified diffs are familiar to developers. They show hunks, context lines, additions, and deletions. They work well for:
- TypeScript
- Python
- SQL
- Markdown
- YAML
- Dockerfiles
- Shell scripts
- Configuration files
Example:
import { createTwoFilesPatch } from "diff";
const patch = createTwoFilesPatch(
"a/src/auth/middleware.ts",
"b/src/auth/middleware.ts",
currentContent,
proposedContent,
"",
"",
{ context: 5 }
);
JSON documents
Use JSON Patch, defined by RFC 6902.
A JSON Patch describes operations like:
[
{ "op": "replace", "path": "/timeoutMs", "value": 10000 },
{ "op": "add", "path": "/features/betaSearch", "value": true },
{ "op": "remove", "path": "/legacyMode" }
]
This is much better than a text diff for structured data because it avoids noise from key ordering and formatting.
In JavaScript, fast-json-patch can generate these operations:
import { compare } from "fast-json-patch";
export function buildJsonPatch(current: unknown, proposed: unknown) {
return compare(current as any, proposed as any);
}
Then render each operation in a human-friendly way:
function describeJsonPatchOperation(op: {
op: string;
path: string;
value?: unknown;
}) {
switch (op.op) {
case "add":
return `Add ${op.path} = ${JSON.stringify(op.value)}`;
case "replace":
return `Replace ${op.path} with ${JSON.stringify(op.value)}`;
case "remove":
return `Remove ${op.path}`;
case "move":
return `Move ${op.from} to ${op.path}`;
case "copy":
return `Copy ${op.from} to ${op.path}`;
default:
return `${op.op} ${op.path}`;
}
}
Database records
Use field-level diffs.
For a customer record, do not show a serialized JSON blob if you can avoid it. Show:
email:
old: jane@example.com
new: jane.smith@example.com
plan:
old: starter
new: pro
billingLimit:
old: 100
new: 500
Field diffs are easier for support, operations, and compliance teams to review.
Infrastructure and declarative resources
Use a plan-style diff.
For Terraform, Kubernetes, CloudFormation, or similar systems, the best diff may not be raw text. It may be a resource plan:
~ aws_s3_bucket.public_assets
acl: "private" -> "public-read"
+ kubernetes_deployment.search_worker
replicas: 2
- redis_cache.legacy
If your AI agent can modify infrastructure, the diff viewer should lean on the native plan output whenever possible.
| Artifact | Best diff format | Good for | Risk |
|---|---|---|---|
| Source code | Unified diff | Developers | Medium/high |
| Config files | Unified diff or semantic diff | Operators | High |
| JSON settings | JSON Patch | Structured review | Medium |
| Database rows | Field diff | Support/compliance | High |
| Infrastructure | Plan output | Platform teams | Critical |
| Binary files | Metadata-only diff | Safety | High |
🧠 The important part: The goal is not “show a diff.” The goal is “make the change understandable.” Sometimes that means a line diff. Sometimes it means a table of field changes. Sometimes it means a resource plan.
5. Build a change manifest reviewers can scan
A raw diff is not enough when the agent changes many files.
If the reviewer sees 2,000 lines of diff immediately, they will either skim carelessly or give up. You need an intermediate layer: a change manifest.
The manifest summarizes the proposal before the human dives into details.
type FileDiffSummary = {
targetUri: string;
proposedTargetUri?: string;
action: "create" | "update" | "delete" | "rename";
additions: number;
deletions: number;
language?: string;
riskFlags: string[];
isGenerated: boolean;
isBinary: boolean;
};
type ChangeManifest = {
proposalId: string;
intent: string;
explanation?: string;
createdAt: string;
source: {
agentSessionId: string;
toolName: string;
modelId?: string;
};
files: FileDiffSummary[];
totals: {
filesChanged: number;
additions: number;
deletions: number;
highRiskChanges: number;
};
};
The UI should show this first:
Agent intent:
Improve payment retry handling
Files:
M src/payments/retry.ts +42 -18
M src/payments/client.ts +8 -3
A test/payments/retry.test.ts +115 -0
M config/payments.yaml +3 -1 ⚠ high risk
Totals:
4 files changed
168 additions
22 deletions
1 high-risk change
This gives the reviewer a mental model.
They can ask:
- Does the file list match the intent?
- Are tests included?
- Did the agent touch config unexpectedly?
- Are there deletions?
- Are generated files included?
- Is there anything unrelated?
The manifest also helps you group changes. If an agent says it is fixing a bug but also modifies .github/workflows, database migrations, and a billing config, the manifest makes that obvious.
You can compute the manifest from structured patch output. The diff package has structuredPatch, which returns hunks instead of only a string:
import { structuredPatch } from "diff";
export function summarizeFileDiff(
targetUri: string,
currentContent: string,
proposedContent: string
) {
const patch = structuredPatch(
`a/${targetUri}`,
`b/${targetUri}`,
currentContent,
proposedContent,
"",
"",
{ context: 3 }
);
let additions = 0;
let deletions = 0;
for (const hunk of patch.hunks) {
for (const line of hunk.lines) {
if (line.startsWith("+")) additions += 1;
if (line.startsWith("-")) deletions += 1;
}
}
return {
targetUri,
additions,
deletions,
hunks: patch.hunks.length,
};
}
This is not perfect for every edge case, but it is enough for a practical review UI.
6. Render diffs with enough context to say yes or no
Now the UI.
You have three main choices:
- Use a full editor diff component, such as Monaco’s diff editor.
- Build a custom unified diff renderer.
- Use a specialized structured diff view for JSON, records, or infrastructure plans.
For source code, Monaco’s diff editor is a strong default if you are already in a web app. It gives you syntax highlighting, side-by-side view, inline view, minimap, and familiar editor behavior.
A framework-agnostic mount function might look like this:
import * as monaco from "monaco-editor";
export function mountDiffViewer(
container: HTMLElement,
original: string,
modified: string,
language: string
) {
const originalModel = monaco.editor.createModel(original, language);
const modifiedModel = monaco.editor.createModel(modified, language);
const diffEditor = monaco.editor.createDiffEditor(container, {
readOnly: true,
renderSideBySide: true,
automaticLayout: true,
ignoreTrimWhitespace: false,
renderOverviewRuler: true,
});
diffEditor.setModel({
original: originalModel,
modified: modifiedModel,
});
return () => {
diffEditor.dispose();
originalModel.dispose();
modifiedModel.dispose();
};
}
In React, you would use this inside useEffect:
import { useEffect, useRef } from "react";
import * as monaco from "monaco-editor";
type Props = {
original: string;
modified: string;
language: string;
};
export function AgentDiffViewer({ original, modified, language }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const originalModel = monaco.editor.createModel(original, language);
const modifiedModel = monaco.editor.createModel(modified, language);
const diffEditor = monaco.editor.createDiffEditor(containerRef.current, {
readOnly: true,
renderSideBySide: true,
automaticLayout: true,
ignoreTrimWhitespace: false,
});
diffEditor.setModel({
original: originalModel,
modified: modifiedModel,
});
return () => {
diffEditor.dispose();
originalModel.dispose();
modifiedModel.dispose();
};
}, [original, modified, language]);
return <div ref={containerRef} style={{ height: 500 }} />;
}
Monaco is powerful, but it is not free. It increases bundle size and initialization complexity. If your diff viewer is a lightweight admin panel, a custom renderer may be better.
A custom unified diff renderer can be surprisingly effective if you parse hunks and render them as HTML:
type DiffHunk = {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: string[];
};
function lineClass(line: string) {
if (line.startsWith("+")) return "bg-green-100 text-green-900";
if (line.startsWith("-")) return "bg-red-100 text-red-900";
return "text-gray-700";
}
export function UnifiedDiffView({ hunks }: { hunks: DiffHunk[] }) {
return (
<div className="font-mono text-sm border rounded overflow-auto">
{hunks.map((hunk, hunkIndex) => (
<div key={hunkIndex}>
<div className="bg-blue-50 text-blue-800 px-3 py-1">
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
</div>
{hunk.lines.map((line, lineIndex) => (
<div
key={lineIndex}
className={`px-3 whitespace-pre ${lineClass(line)}`}
>
{line}
</div>
))}
</div>
))}
</div>
);
}
This is simplified, but it shows the idea. You can extend it with:
- Line numbers.
- Collapsed unchanged regions.
- File-level folding.
- Comments.
- Risk annotations.
- Copy buttons.
- Per-file approve/reject controls.
For JSON Patch, a table is often better than Monaco:
type JsonPatchRow = {
op: "add" | "remove" | "replace" | "move" | "copy";
path: string;
value?: unknown;
oldValue?: unknown;
};
export function JsonPatchTable({ rows }: { rows: JsonPatchRow[] }) {
return (
<table className="w-full text-sm border">
<thead>
<tr className="text-left border-b">
<th className="p-2">Operation</th>
<th className="p-2">Path</th>
<th className="p-2">Old</th>
<th className="p-2">New</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index} className="border-b align-top">
<td className="p-2 font-mono">{row.op}</td>
<td className="p-2 font-mono">{row.path}</td>
<td className="p-2 font-mono">
{row.op === "remove" || row.op === "replace"
? JSON.stringify(row.oldValue)
: ""}
</td>
<td className="p-2 font-mono">
{row.op === "add" || row.op === "replace"
? JSON.stringify(row.value)
: ""}
</td>
</tr>
))}
</tbody>
</table>
);
}
The UI should make the most dangerous parts obvious. Deletions should not look the same as additions. A removed permission should not be visually equivalent to a renamed variable.
💡 Practical note: For large files, do not render every unchanged line. Collapse unchanged regions and show context around hunks. A diff viewer that renders 50,000 lines of unchanged code will become unusable fast.
7. Tame noisy changes before showing them
AI agents often create noisy diffs.
They may reformat a whole file, normalize quotes, change line endings, sort imports, or regenerate lockfiles. If you show all of that directly, reviewers will suffer diff fatigue.
Diff fatigue is dangerous because it teaches humans to approve without reading.
You need a noise-control layer.
Whitespace and formatting
For text diffs, you can offer a toggle: “Ignore whitespace.” The diff package supports options for line diffing, and many diff renderers can hide whitespace-only changes.
But be careful. Whitespace can matter in YAML, Makefiles, Python, Markdown, and some configuration formats. A global “ignore whitespace” toggle should be a view option, not a permanent truth.
Generated files
Generated files should usually be summarized, not fully diffed.
Examples:
package-lock.jsonpnpm-lock.yamlyarn.lockdist/build/- compiled assets
- generated API clients
- Prisma migration snapshots
- OpenAPI generated types
For lockfiles, show a dependency summary instead of 8,000 lines:
package-lock.json
added:
zod@3.23.8
removed:
joi@17.12.0
updated:
axios: 1.6.8 -> 1.7.9
If you cannot generate a good summary, at least collapse the file and show stats.
Formatting-only changes
If the agent reformats code, separate that from semantic changes if possible.
A good review UI can group changes:
Semantic changes:
src/auth/middleware.ts
Formatting-only changes:
src/utils/format.ts
src/api/routes.ts
This is hard to do perfectly, but even a rough classification helps.
One approach:
- Compute the proposed diff.
- Run the project’s formatter on both current and proposed versions in a sandbox.
- Compare formatted current to formatted proposed.
- If the semantic diff disappears, classify the change as mostly formatting.
This is not universal, and it depends on your toolchain, but it is useful in codebases with Prettier, Black, gofmt, or similar formatters.
Line endings
If your repository has mixed line endings, the agent may create huge diffs by converting CRLF to LF or vice versa. Normalize line endings before diffing if that matches your repository policy.
export function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, "\n");
}
But again, store the original proposed content. Do not silently destroy what the agent proposed unless your system has a well-defined normalization policy.
8. Flag risky changes like a security reviewer would
A diff viewer for AI agents should not wait for a human to notice danger. It should highlight danger early.
You do not need a perfect static analysis engine to start. You need a few high-signal rules.
High-risk paths
Some paths deserve extra scrutiny:
const HIGH_RISK_PATH_PATTERNS = [
/\.env(\..+)?$/,
/(^|\/)secrets\//,
/(^|\/)migrations\//,
/(^|\/)infrastructure\//,
/(^|\/)\.github\/workflows\//,
/(^|\/)docker-compose.*\.ya?ml$/,
/(^|\/)Dockerfile$/,
/(^|\/)iam/,
/(^|\/)auth/,
/(^|\/)billing/,
];
Suspicious content
You can scan proposed additions for secret-like strings:
const SECRET_PATTERNS = [
/-----BEGIN (RSA|EC|OPENSSH|PRIVATE) KEY-----/,
/aws_access_key_id/i,
/aws_secret_access_key/i,
/api[_-]?key\s*[:=]/i,
/secret[_-]?key\s*[:=]/i,
/password\s*[:=]/i,
/Bearer\s+[A-Za-z0-9\-._~+/]+=*/,
];
export function detectSecretRisk(proposedContent: string): string[] {
const risks: string[] = [];
for (const pattern of SECRET_PATTERNS) {
if (pattern.test(proposedContent)) {
risks.push(`Possible secret material: ${pattern.source}`);
}
}
return risks;
}
This will produce false positives. That is acceptable if the UI presents them as warnings, not hard failures.
Deletions
Deletions deserve special treatment.
A reviewer should see:
Deleted files:
src/auth/rateLimit.ts
docs/api-keys.md
Removed configuration keys:
/security/mfaRequired
/rateLimit/enabled
If an agent removes a security control, that should be visually loud.
Permission and access changes
If your system can modify IAM policies, roles, feature flags, or access control lists, create dedicated diff views for those. Do not bury them in generic JSON.
For example:
IAM policy change:
effect: Allow
action: s3:DeleteObject
resource: arn:aws:s3:::customer-backups/*
principal: role/batch-processor
status: ADDED
That is much easier to review than a raw JSON policy diff.
A simple risk model might look like this:
type RiskLevel = "low" | "medium" | "high" | "critical";
export function scoreFileRisk(args: {
targetUri: string;
action: "create" | "update" | "delete" | "rename";
proposedContent?: string;
isGenerated: boolean;
isBinary: boolean;
}): { level: RiskLevel; flags: string[] } {
const flags: string[] = [];
let score = 0;
if (args.action === "delete") {
flags.push("Deletes content");
score += 2;
}
if (HIGH_RISK_PATH_PATTERNS.some((pattern) => pattern.test(args.targetUri))) {
flags.push("High-risk path");
score += 2;
}
if (args.proposedContent && detectSecretRisk(args.proposedContent).length > 0) {
flags.push("Possible secret");
score += 3;
}
if (args.isBinary) {
flags.push("Binary content");
score += 2;
}
if (args.isGenerated) {
flags.push("Generated file");
score += 0;
}
const level: RiskLevel =
score >= 5 ? "critical" : score >= 3 ? "high" : score >= 1 ? "medium" : "low";
return { level, flags };
}
Do not rely only on the AI model to label its own changes as risky. The model may be honest, but it is not a security boundary.
🚨 Production warning: If the agent can modify the rules that classify risk, you have a serious design problem. Risk rules should be owned by humans, versioned separately, and protected from agent writes.
9. Turn the viewer into an approval gate
A diff viewer becomes truly useful when it is connected to an approval flow.
The approval flow should be explicit and stateful.
At minimum, support:
- Approve the whole proposal.
- Reject the whole proposal.
- Request changes with a comment.
- Mark the proposal stale if the target changed.
- Apply only after approval.
- Record who approved and when.
The approval payload might look like this:
type ApprovalDecision = {
proposalId: string;
decision: "approved" | "rejected";
reviewerId: string;
comment?: string;
approvedContentHash?: string;
decidedAt: string;
};
When applying, verify that the proposal has not changed and that the target has not moved underneath it.
export class ConflictError extends Error {}
export async function applyApprovedProposal(
decision: ApprovalDecision,
deps: {
getProposal: (id: string) => Promise<FileChangeProposal>;
getCurrentContent: (uri: string) => Promise<string>;
writeContent: (uri: string, content: string) => Promise<void>;
deleteContent: (uri: string) => Promise<void>;
appendAuditEvent: (event: unknown) => Promise<void>;
}
) {
const proposal = await deps.getProposal(decision.proposalId);
if (decision.decision !== "approved") {
throw new Error("Proposal was not approved");
}
const currentContent =
proposal.action === "create"
? ""
: await deps.getCurrentContent(proposal.targetUri);
const currentHash = sha256Hex(currentContent);
if (proposal.baseContentHash && proposal.baseContentHash !== currentHash) {
throw new ConflictError(
"Target changed after approval. The proposal must be regenerated."
);
}
switch (proposal.action) {
case "create":
case "update":
case "rename": {
if (typeof proposal.proposedContent !== "string") {
throw new Error("Proposed content is missing");
}
await deps.writeContent(
proposal.proposedTargetUri ?? proposal.targetUri,
proposal.proposedContent
);
break;
}
case "delete": {
await deps.deleteContent(proposal.targetUri);
break;
}
}
await deps.appendAuditEvent({
type: "agent_change_applied",
proposalId: proposal.proposalId,
reviewerId: decision.reviewerId,
targetUri: proposal.targetUri,
action: proposal.action,
currentHash,
appliedAt: new Date().toISOString(),
});
}
This is intentionally conservative.
It does not try to apply half a proposal. It does not try to merge hunks automatically. It says: if the world changed, stop and regenerate.
Partial approval
Partial approval is tempting. You may want to let a human approve some hunks but not others.
I would avoid this in the first version.
Hunk-level approval is hard because the proposed file is no longer a single coherent artifact. You have to reconstruct a new file from selected hunks, validate syntax, handle conflicts, and recompute the final state. For code, a partial application may produce broken imports, invalid syntax, or inconsistent behavior.
Start with:
Approve file
Reject file
Later, if you really need it, add:
Approve selected hunks
But only after you can safely reconstruct and validate the resulting file.
For structured data, partial approval is easier. You can approve individual JSON Patch operations or individual database field changes, as long as the operations are independent.
Keep an audit trail
Every approval decision should be logged.
At minimum:
- Proposal ID.
- Reviewer ID.
- Decision.
- Comment.
- Content hash approved.
- Timestamp.
- Application result.
If you are dealing with regulated environments, this audit trail may matter more than the diff UI itself.
10. Test against the proposals that will actually hurt you
A diff viewer for AI agents will be tested by weird and hostile proposals. Do not wait for production to discover them.
Build a test suite.
Rename plus edit
An agent renames a file and modifies it. Your UI should show:
renamed: src/auth/rateLimit.ts -> src/auth/rateLimiter.ts
and still display the content diff.
If you only detect delete + create, reviewers will miss continuity.
Empty file creation
The agent creates an empty file. The diff should show creation, not a confusing empty pane.
Deletion
The agent deletes a file. The UI should make this impossible to miss.
DELETED: src/auth/rateLimit.ts
Whitespace-only change
The agent reformats a file. The UI should classify it as low semantic risk or at least offer a whitespace-ignored view.
Generated file explosion
The agent updates package-lock.json with 12,000 lines. The UI should collapse it and show dependency summaries.
Binary file replacement
The agent replaces an image or compiled asset. Do not try to render binary as text. Show metadata:
binary file changed
old size: 42 KB
new size: 51 KB
old hash: sha256:...
new hash: sha256:...
Secret injection
The agent adds an API key into source code. The viewer should flag it.
Config key removal
The agent removes a security setting from JSON config. The JSON Patch view should show:
remove /security/requireMfa
This should be visually marked as high risk.
Stale proposal
The target changes after the agent proposes a diff. The viewer should not show a misleading diff. It should say the proposal is stale.
Huge file
The agent proposes a 5 MB file change. The viewer should not freeze the browser. It should paginate, virtualize, or summarize.
Invalid syntax
The agent proposes TypeScript that does not compile. The diff viewer may not be responsible for full validation, but the approval flow should run checks before applying.
A good approval pipeline looks like this:
Proposal received
→ validate schema
→ compute diff
→ scan risk
→ human review
→ approval recorded
→ apply in sandbox or controlled writer
→ run tests/linters/build
→ commit or reject with diagnostics
If the post-apply validation fails, the system should not pretend success. It should roll back, mark the proposal failed, and show the error.
What I would build
If I were building this for a real product, I would not start with the fanciest semantic diff engine. I would start with a boring, robust pipeline.
Backend
- A
change_proposalstable storing structured proposals. - A trusted diff service that reads current state from the source of truth.
- SHA-256 hashes for current and proposed content.
- Unified diff generation for text files.
- JSON Patch generation for structured documents.
- Field diff generation for database records.
- Risk scoring rules maintained separately from the agent.
- Approval and audit tables.
Frontend
- A proposal list with intent, source, and risk summary.
- A change manifest showing files, additions, deletions, and flags.
- A Monaco diff editor for code.
- A table view for JSON Patch and record changes.
- Collapsible generated files.
- A stale-proposal warning.
- Approve/reject/comment controls.
- A clear “apply only after approval” state machine.
Safety rules
- Never apply without an approved proposal.
- Never trust the agent’s description as the approved artifact.
- Never trust the agent’s view of current state.
- Never let the agent approve its own proposal.
- Never show raw secrets in the diff if they can be masked.
- Never treat generated noise as equal to semantic change.
The deeper point is this: an AI agent’s proposed change is not a conversation. It is a mutation. And mutations need review surfaces.
A good diff viewer does more than display lines. It makes the agent’s intent inspectable, contestable, and safe to approve. It turns “the agent wants to change something” into “here is exactly what changes, why they matter, and whether we accept them.”
That is the difference between an AI assistant and a system you can actually operate.