Elements

28 / 122 · Tool use

Reviewable diff

The same diff, but each hunk is a decision: keep it, discard it, apply what survived.

composer.tsx0 of 2 kept
@@ -12,6 +12,7
const composer = useComposer();
const [draft, setDraft] = useState("");
+ const draft = useDraft(threadId);
@@ -31,4 +32,5
useEffect(() => {
+ if (!threadId) return;
hydrate(draft);
2 left to review

Installation

npx shadcn@latest add "@assistant-ui/elements-reviewable-diff"

Usage

import { ReviewableDiff } from "@/components/elements/reviewable-diff";

<ReviewableDiff
  filename="composer.tsx"
  hunks={hunks}
  onKeep={(id) => decide(id, "kept")}
  onDiscard={(id) => decide(id, "discarded")}
  onApply={commit}
/>

Props

ReviewableDiff

filename*stringFile the hunks belong to.
hunks*DiffHunk[]Each hunk carries its own decision. Apply stays disabled until none are pending.
onKeep(id: string) => voidCalled when a hunk is accepted.
onDiscard(id: string) => voidCalled when a hunk is rejected.
onApply() => voidCalled when the reviewed set is committed. Only kept hunks should be written.
classNamestringExtra classes merged onto the root.

DiffHunk

id*stringStable identity, reported back by onKeep and onDiscard.
range*stringUnified-diff range header, shown in mono.
decision*"pending" | "kept" | "discarded"Discarded hunks stay visible at reduced opacity so the review reads as a whole.
lines*DiffLine[]Reuses the DiffLine shape from the code diff element.

Source

reviewable-diff.tsx
"use client";

import { CheckIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { inkButton, mono, paper } from "./surfaces";
import type { DiffLine } from "./code-diff";

export type HunkDecision = "pending" | "kept" | "discarded";

export interface DiffHunk {
  id: string;
  range: string;
  decision: HunkDecision;
  lines: readonly DiffLine[];
}

const GUTTER = { context: "", added: "+", removed: "−" } as const;

export function ReviewableDiff({
  filename,
  hunks,
  onKeep,
  onDiscard,
  onApply,
  className,
}: {
  filename: string;
  hunks: readonly DiffHunk[];
  onKeep?: (id: string) => void;
  onDiscard?: (id: string) => void;
  onApply?: () => void;
  className?: string;
}) {
  const kept = hunks.filter((hunk) => hunk.decision === "kept").length;
  const pending = hunks.filter((hunk) => hunk.decision === "pending").length;

  return (
    <div
      className={cn(
        paper,
        "flex w-full max-w-md flex-col overflow-hidden rounded-2xl",
        className,
      )}
    >
      <div className="flex items-center justify-between px-4 pt-3 pb-2">
        <span className="font-mono text-xs">{filename}</span>
        <span className={cn(mono, "text-foreground/35 tabular-nums")}>
          {kept} of {hunks.length} kept
        </span>
      </div>

      <div className="flex flex-col">
        {hunks.map((hunk) => (
          <div
            key={hunk.id}
            className={cn(
              "border-foreground/[0.06] border-t transition-opacity duration-300",
              hunk.decision === "discarded" && "opacity-40",
            )}
          >
            <div className="flex items-center gap-2 px-4 py-1.5">
              <span className={cn(mono, "text-foreground/30")}>
                {hunk.range}
              </span>
              <span className="ms-auto flex items-center gap-1">
                {hunk.decision === "pending" ? (
                  <>
                    <button
                      type="button"
                      aria-label={`Discard hunk ${hunk.range}`}
                      onClick={() => onDiscard?.(hunk.id)}
                      className="text-foreground/45 hover:bg-foreground/[0.06] hover:text-foreground/90 flex h-6 items-center gap-1 rounded-full px-2 text-[11px] font-medium transition-[background-color,color,scale] duration-150 active:scale-[0.96]"
                    >
                      <XIcon className="size-3" />
                      Discard
                    </button>
                    <button
                      type="button"
                      aria-label={`Keep hunk ${hunk.range}`}
                      onClick={() => onKeep?.(hunk.id)}
                      className="flex h-6 items-center gap-1 rounded-full bg-emerald-500/12 px-2 text-[11px] font-medium text-emerald-700 transition-[background-color,scale] duration-150 hover:bg-emerald-500/20 active:scale-[0.96] dark:text-emerald-300"
                    >
                      <CheckIcon className="size-3" />
                      Keep
                    </button>
                  </>
                ) : (
                  <span
                    className={cn(
                      mono,
                      "fade-in animate-in duration-300",
                      hunk.decision === "kept"
                        ? "text-emerald-600 dark:text-emerald-400"
                        : "text-foreground/35",
                    )}
                  >
                    {hunk.decision}
                  </span>
                )}
              </span>
            </div>
            <div className="pb-1.5 font-mono text-xs">
              {hunk.lines.map((line, i) => (
                <div
                  key={`${hunk.id}-${i}`}
                  className={cn(
                    "flex px-4 py-0.5 leading-relaxed whitespace-pre",
                    line.kind === "context" && "text-foreground/40",
                    line.kind === "added" &&
                      "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
                    line.kind === "removed" &&
                      "bg-red-500/10 text-red-700 dark:text-red-300",
                  )}
                >
                  <span className="w-4 shrink-0 select-none">
                    {GUTTER[line.kind]}
                  </span>
                  <span>{line.text}</span>
                </div>
              ))}
            </div>
          </div>
        ))}
      </div>

      <div className="border-foreground/[0.06] flex items-center justify-between border-t px-4 py-2.5">
        <span className={cn(mono, "text-foreground/35")}>
          {pending > 0 ? `${pending} left to review` : "All reviewed"}
        </span>
        <button
          type="button"
          disabled={pending > 0}
          onClick={onApply}
          className={cn(
            inkButton,
            "flex h-7 items-center rounded-full px-3 text-xs font-medium disabled:pointer-events-none disabled:opacity-30",
          )}
        >
          Apply {kept}
        </button>
      </div>
    </div>
  );
}