Cursor-style per-hunk AI edit review with added/removed line tinting; accepting settles the hunk into a kept bar and rejecting collapses it away, plus accept-all and reject-all.
npx shadcn@latest add @paragon/diff-accept-reject"use client";
import * as React from "react";
import { Check, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type DiffLineKind = "add" | "remove" | "context";
export interface DiffAcceptRejectLine {
/** Defaults to "context". */
kind?: DiffLineKind;
content: string;
}
export interface DiffHunk {
id: string;
/** Optional location label, e.g. "src/auth.ts · L42". */
location?: string;
lines: DiffAcceptRejectLine[];
}
export type HunkDecision = "pending" | "accepted" | "rejected";
export interface DiffAcceptRejectProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
hunks: DiffHunk[];
fileName?: string;
/** Fires whenever a hunk is decided. */
onDecide?: (id: string, decision: "accepted" | "rejected") => void;
static?: boolean;
}
const lineTint: Record<DiffLineKind, string> = {
add: "bg-success/10 text-foreground",
remove: "bg-destructive/10 text-foreground",
context: "text-muted-foreground",
};
const lineMarker: Record<DiffLineKind, string> = {
add: "text-success",
remove: "text-destructive",
context: "text-transparent",
};
const glyph: Record<DiffLineKind, string> = {
add: "+",
remove: "−",
context: " ",
};
/**
* Cursor-style per-hunk AI edit review. Each hunk shows added/removed line
* tinting and Accept / Reject controls. Accepting slides the hunk up and fades
* a "kept" resolved bar in; rejecting collapses the hunk away via a grid-rows
* transition (0fr↔1fr — the one height exception). Accept-all / reject-all
* decide every pending hunk. Reduced motion swaps state without the slide or
* collapse. Uncontrolled by default.
*/
export function DiffAcceptReject({
hunks,
fileName,
onDecide,
static: isStatic = false,
className,
...props
}: DiffAcceptRejectProps) {
const [decisions, setDecisions] = React.useState<
Record<string, HunkDecision>
>(() => Object.fromEntries(hunks.map((h) => [h.id, "pending"])));
const decide = (id: string, decision: "accepted" | "rejected") => {
setDecisions((prev) => {
if (prev[id] && prev[id] !== "pending") return prev;
return { ...prev, [id]: decision };
});
onDecide?.(id, decision);
};
const decideAll = (decision: "accepted" | "rejected") => {
setDecisions((prev) => {
const next = { ...prev };
for (const h of hunks) {
if (next[h.id] === "pending" || next[h.id] === undefined) {
next[h.id] = decision;
onDecide?.(h.id, decision);
}
}
return next;
});
};
const pendingCount = hunks.filter(
(h) => (decisions[h.id] ?? "pending") === "pending",
).length;
const collapseCls = isStatic
? ""
: "transition-[grid-template-rows] duration-(--duration-base) ease-(--ease-in-out) motion-reduce:transition-none";
return (
<div
data-slot="diff-accept-reject"
className={cn(
"flex w-full flex-col overflow-hidden rounded-xl bg-card shadow-border",
className,
)}
{...props}
>
<div className="flex items-center justify-between gap-3 border-b px-4 py-2.5">
<span className="truncate font-mono text-xs font-medium text-foreground">
{fileName ?? "Proposed changes"}
</span>
<div className="flex shrink-0 items-center gap-1">
<span className="mr-1 text-[11px] text-muted-foreground tabular-nums">
{pendingCount} pending
</span>
<button
type="button"
disabled={pendingCount === 0}
onClick={() => decideAll("rejected")}
className="pressable rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors duration-(--duration-fast) hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
Reject all
</button>
<button
type="button"
disabled={pendingCount === 0}
onClick={() => decideAll("accepted")}
className="pressable rounded-md bg-secondary px-2 py-1 text-[11px] font-medium text-foreground transition-colors duration-(--duration-fast) hover:bg-secondary/80 disabled:pointer-events-none disabled:opacity-40"
>
Accept all
</button>
</div>
</div>
<div className="flex flex-col">
{hunks.map((hunk) => {
const decision = decisions[hunk.id] ?? "pending";
const resolved = decision !== "pending";
return (
<div key={hunk.id} className="border-b last:border-b-0">
{/* Resolved summary bar */}
<div
className={cn("grid", collapseCls)}
style={{
gridTemplateRows: resolved ? "1fr" : "0fr",
}}
>
<div className="min-h-0 overflow-hidden">
<div
className={cn(
"flex items-center gap-2 px-4 py-2 text-xs font-medium",
decision === "accepted"
? "text-success"
: "text-muted-foreground",
)}
>
{decision === "accepted" ? (
<Check aria-hidden className="size-3.5" />
) : (
<X aria-hidden className="size-3.5" />
)}
{decision === "accepted" ? "Kept" : "Discarded"}
{hunk.location && (
<span className="font-mono font-normal text-muted-foreground/70">
{hunk.location}
</span>
)}
</div>
</div>
</div>
{/* Pending hunk body */}
<div
className={cn("grid", collapseCls)}
style={{
gridTemplateRows: resolved ? "0fr" : "1fr",
}}
>
<div inert={resolved} className="min-h-0 overflow-hidden">
{hunk.location && (
<div className="bg-secondary/40 px-4 py-1 font-mono text-[11px] text-muted-foreground">
{hunk.location}
</div>
)}
<div className="overflow-x-auto py-1 font-mono text-xs">
{hunk.lines.map((line, li) => {
const kind = line.kind ?? "context";
return (
<div
key={li}
className={cn(
"flex w-max min-w-full items-stretch",
lineTint[kind],
)}
>
<span
aria-hidden
className={cn(
"w-6 shrink-0 py-0.5 text-center select-none",
lineMarker[kind],
)}
>
{glyph[kind]}
</span>
<span className="flex-1 py-0.5 pr-4 whitespace-pre">
{line.content}
</span>
</div>
);
})}
</div>
<div className="flex items-center justify-end gap-1.5 px-3 py-2">
<button
type="button"
onClick={() => decide(hunk.id, "rejected")}
className="pressable inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-muted-foreground transition-colors duration-(--duration-fast) hover:bg-accent hover:text-foreground"
>
<X aria-hidden className="size-3.5" />
Reject
</button>
<button
type="button"
onClick={() => decide(hunk.id, "accepted")}
className="pressable inline-flex h-7 items-center gap-1.5 rounded-md bg-success px-2.5 text-xs font-medium text-success-foreground transition-[background-color] duration-(--duration-fast) hover:bg-success/90"
>
<Check aria-hidden className="size-3.5" />
Accept
</button>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}