AI & Chat

Diff Accept / Reject

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.

Install

npx shadcn@latest add @paragon/diff-accept-reject

diff-accept-reject.tsx

"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>
  );
}