Diff Review

Prüfe die Änderungen eines Agenten über mehrere Dateien, bevor sie übernommen werden. Ein Dateibaum mit Zählern, jede Änderung, jede Datei oder alles annehmen oder ablehnen, Kommentare zu jeder Zeile oder jedem Bereich, die an den Agenten zurückgehen, Unified- und Split-Ansicht, Hervorhebung auf Wortebene, Rückgängig, streamende Änderungen und eine Zusammenfassung „4 Dateien bearbeitet“ für den Chat.

Coding-Agenten ändern mehrere Dateien auf einmal, und Leute müssen einen Teil behalten und den Rest verwerfen. Diff Review legt jede Änderung in eine scrollende Liste mit einem Dateibaum daneben, sodass du eine einzelne Änderung, eine ganze Datei oder alles per Maus oder Tastatur annehmen oder ablehnen kannst. DiffSummary ist die Card „4 Dateien bearbeitet +120 −34“ für den Chat, mit denselben Aktionen für jede Datei.

Jede Entscheidung betrifft genau eine Änderung. Eine entschiedene Änderung klappt zu einem einzeiligen Stub mit ihrem Ergebnis, Show und Undo zusammen, und eine Datei klappt weg, sobald jede Änderung darin entschieden ist. Sammelaktionen sagen, wie viele Änderungen sie abdecken, und jede Entscheidung, auch Accept all und Reject all, lässt sich mit U oder ⌘Z rückgängig machen. „Next“ meint immer die nächste Änderung, die du noch nicht entschieden hast. Die Zähler und der Fortschrittsbalken stammen aus dem, was noch aussteht, sodass das Review mit einem klaren „All reviewed“ endet.

Fahre über eine Zeile und drücke + in der Rinne, oder fokussiere eine Zeile und drücke Enter oder C, um sie zu kommentieren. Ziehe oder klicke mit Shift auf Zeilennummern, oder nutze Shift+↑↓, um einen Bereich zu kommentieren. Der Composer öffnet sich als kleines Popover unter der Zeile, und ein gesendeter Kommentar bleibt als Thread an der Zeile, den du bearbeiten, mit Undo löschen oder einklappen kannst, mit einer Zahl in der Rinne. Entfernte Zeilen werden auf der alten Seite gemeldet, hinzugefügte oder unveränderte Zeilen auf der neuen, sodass die Zeilennummern immer zur Datei passen.

Übergib einen Unified Diff oder den Inhalt davor und danach und lass den Block den Diff berechnen. Mit dem vollständigen Inhalt kannst du die unveränderten Zeilen zwischen Änderungen anzeigen, je 20 nach oben oder unten, oder alle auf einmal, wenn weniger als 20 verborgen sind. Entfernte Zeilen tragen in der Rinne einen feinen gestreiften Balken, hinzugefügte einen durchgehenden, sodass der Unterschied nie von Farbe abhängt. Zeilen werden wie bei Code Block mit Shiki hervorgehoben, und geänderte Wörter innerhalb einer Zeile werden markiert, wenn sich die beiden Zeilen ähnlich genug sind, dass es hilft. Das Layout wechselt zur Split-Ansicht, wenn Platz ist, standardmäßig ab mindestens 900 px. Dateien, die noch streamen, werden live angezeigt und können noch nicht entschieden werden. Dateien, die sich auf der Platte geändert haben, können abgelehnt, aber nicht angenommen werden. Diffs mit über 400 geänderten Zeilen warten hinter Load diff, und ihre Aktionen auf Dateiebene funktionieren weiter.

  1. Die Pro-Registry zu components.json hinzufügen

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Token hinzufügen

    Erstelle auf deiner Kontoseite einen Token und trage ihn in .env.local als HEXTAUI_PRO_TOKEN ein.

  3. Den Block hinzufügen

    pnpm dlx shadcn@latest add @hextaui-pro/diff-review

Mit dem AI SDK

Mache aus Edit-Tool-Aufrufen Dateien, auch bei Eingaben, die noch streamen: ein Unified Diff, der Inhalt einer neuen Datei oder old_string und new_string von Claude Code. Die Zusammenfassung kommt in den Chat und das Review daneben, beide teilen sich einen Provider.

"use client"

import { useChat } from "@ai-sdk/react"
import { getToolName, isToolUIPart, type UIMessage } from "ai"

import type { DiffFile } from "@/components/blocks/diff-review/diff"
import { DiffReview } from "@/components/blocks/diff-review/diff-review"
import { DiffSummary } from "@/components/blocks/diff-review/diff-summary"
import { DiffReviewProvider } from "@/components/blocks/diff-review/use-diff-review"

type Input = Record<string, string | undefined>

function editsOf(messages: UIMessage[]): DiffFile[] {
  return messages.flatMap((message) =>
    message.parts.flatMap((part): DiffFile[] => {
      if (!isToolUIPart(part)) return []
      const input = (part.input ?? {}) as Input
      const streaming = part.state === "input-streaming"
      switch (getToolName(part)) {
        case "editFile":
          return input.path ? [{ path: input.path, patch: input.diff ?? "", streaming }] : []
        case "writeFile":
          return input.path ? [{ path: input.path, after: input.content ?? "", streaming }] : []
        case "Edit":
          return input.file_path
            ? [{ path: input.file_path, before: input.old_string ?? "", after: input.new_string ?? "", streaming }]
            : []
        default:
          return []
      }
    })
  )
}

export function AgentWorkspace({ onApply }: { onApply: (files: DiffFile[]) => void }) {
  const { messages } = useChat()
  const files = editsOf(messages)

  return (
    <DiffReviewProvider
      files={files}
      onDecide={({ decision, changes }) => {
        if (decision === "accepted") onApply(files.filter((file) => changes.some((change) => change.path === file.path)))
      }}
    >
      <div className="grid h-svh lg:grid-cols-[28rem_1fr]">
        <aside className="overflow-y-auto p-4">
          <DiffSummary />
        </aside>
        <DiffReview />
      </div>
    </DiffReviewProvider>
  )
}

Zeilenkommentare an den Agenten senden

onComment erhält die Datei, die Seite, die Zeilennummern und einen Diff-Ausschnitt, sodass du ihn als Folgenachricht senden kannst. Gib das Promise von sendMessage zurück, und jeder Kommentar zeigt Sending… und danach Sent to the agent.

"use client"

import { useChat } from "@ai-sdk/react"

import type { DiffComment } from "@/components/blocks/diff-review/comments"
import type { DiffFile } from "@/components/blocks/diff-review/diff"
import { DiffReview } from "@/components/blocks/diff-review/diff-review"

function lines({ lines, side, startSide }: DiffComment) {
  const mark = (value: DiffComment["side"]) => (value === "old" ? "-" : "+")
  if (side !== startSide) return `${mark(startSide)}${lines.start} to ${mark(side)}${lines.end}`
  return lines.start === lines.end ? `${lines.start}` : `${lines.start}-${lines.end}`
}

export function ReviewWithFeedback({ files }: { files: DiffFile[] }) {
  const { sendMessage } = useChat()

  return (
    <div className="h-[36rem]">
      <DiffReview
        files={files}
        onComment={(comment) =>
          sendMessage({
            text: [
              `Feedback on ${comment.path}, ${comment.side === "old" ? "removed line" : "line"} ${lines(comment)}:`,
              "```diff",
              comment.excerpt,
              "```",
              comment.text,
            ].join("\n"),
          })
        }
      />
    </div>
  )
}

Das Angenommene schreiben

applyDecisions baut jede Datei aus den behaltenen Hunks neu auf, anhand des ursprünglichen oder bearbeiteten Inhalts. Es gibt außerdem einen Patch nur mit den angenommenen Hunks zurück und sagt, wenn eine Datei gelöscht oder nie erstellt werden soll.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"

import { applyDecisions, type DiffDecisions, type DiffFile } from "@/components/blocks/diff-review/diff"
import { DiffReview } from "@/components/blocks/diff-review/diff-review"

export function ReviewThenWrite({
  files,
  writeFile,
  deleteFile,
}: {
  files: DiffFile[]
  writeFile: (path: string, content: string) => Promise<void>
  deleteFile: (path: string) => Promise<void>
}) {
  const [decisions, setDecisions] = React.useState<DiffDecisions>({})

  const finish = async () => {
    for (const file of files) {
      const result = applyDecisions(file, decisions)
      if (result.deleted) await deleteFile(file.path)
      else if (result.content !== undefined) await writeFile(result.path, result.content)
    }
  }

  return (
    <div className="flex h-[36rem] flex-col gap-3">
      <DiffReview files={files} decisions={decisions} onDecisionsChange={setDecisions} />
      <Button onClick={finish}>Write accepted changes</Button>
    </div>
  )
}

Ein schreibgeschützter Diff

DiffView zeigt die Änderungen einer Datei mit derselben Hervorhebung, denselben Rinnenbalken und derselben Erweiterung unveränderter Zeilen, ohne Review-Steuerelemente. Es wächst mit seinem Inhalt, setze es also in ein scrollendes Parent.

import { DiffView } from "@/components/blocks/diff-review/diff-view"

export function VersionChanges({ previous, current }: { previous: string; current: string }) {
  return (
    <div className="flex h-96 flex-col overflow-hidden rounded-xl border">
      <div className="min-h-0 flex-1 overflow-y-auto">
        <DiffView before={previous} after={current} language="tsx" header path="app/page.tsx" />
      </div>
    </div>
  )
}

Aus einem Git-Diff

parsePatch zerlegt die Ausgabe von git diff oder einen einfachen Unified Diff in Dateien, mit umbenannten, neuen, gelöschten und Binärdateien.

import { parsePatch } from "@/components/blocks/diff-review/diff"
import { DiffReview } from "@/components/blocks/diff-review/diff-review"

export function PullRequestReview({ gitDiff }: { gitDiff: string }) {
  return (
    <div className="h-[36rem] overflow-hidden rounded-xl border">
      <DiffReview files={parsePatch(gitDiff)} defaultView="split" />
    </div>
  )
}

Aufbau

Die Teile, die du zusammensetzt, von außen nach innen.

PartBeschreibung
DiffReviewProviderHält die Dateien, die Entscheidungen und den Undo-Verlauf, sodass eine Zusammenfassung im Chat und ein Review-Panel synchron bleiben.
DiffReviewDie Review-Oberfläche: Toolbar, Dateibaum, die Änderungen und die Review-Leiste mit Fortschritt, Navigation und Sammelaktionen.
DiffSummaryEine Card für den Chat: „4 Dateien bearbeitet“ mit Zählern, jede Datei mit Annehmen und Ablehnen, und Sammelaktionen.
DiffViewEin schreibgeschützter Diff einer Datei, um Änderungen zwischen Versionen zu zeigen.
Comment threadsKommentare unter einer Zeile oder einem Bereich, mit Autor, Zeit, Zustellstatus, Bearbeiten, Löschen und Einklappen.
parsePatch, applyDecisionsLiest die Ausgabe von git diff in Dateien ein und macht aus Entscheidungen wieder Dateiinhalte und einen Patch nur mit den angenommenen Änderungen.
useDiffReviewDer gemeinsame State, um innerhalb des Providers eigene Steuerelemente zu bauen.

DiffReviewProvider

DiffReview und DiffSummary nehmen dieselben Props, wenn sie allein verwendet werden.

PropTypStandard
filesDie geänderten Dateien, in der Reihenfolge, in der der Agent sie bearbeitet hat.
DiffFile[]–
decisionsKontrollierte Entscheidungen nach Änderungs-ID. Änderungen ohne Eintrag stehen aus.
Record<string, "accepted" | "rejected">–
defaultDecisionsAnfangsentscheidungen im unkontrollierten Modus.
Record<string, "accepted" | "rejected">–
onDecisionsChangeWird nach jeder Aktion, auch Undo, mit den nächsten Entscheidungen aufgerufen.
(decisions) => void–
onDecideWird einmal pro Aktion mit der Entscheidung, ihrer Quelle (change, file, all oder undo) und den betroffenen Änderungen aufgerufen. Nutze es, um Dateien zu schreiben oder zurückzusetzen.
(event: DiffDecideEvent) => void–
commentsKontrollierte Kommentare.
DiffComment[]–
defaultCommentsAnfangskommentare im unkontrollierten Modus.
DiffComment[]–
onCommentsChangeWird aufgerufen, nachdem ein Kommentar hinzugefügt, bearbeitet, gelöscht oder wiederhergestellt wurde.
(comments: DiffComment[]) => void–
onCommentWird aufgerufen, wenn ein Kommentar gesendet wird, und erneut mit derselben ID, wenn er bearbeitet wird. Gib ein Promise zurück, um Sending… und Sent to the agent zu zeigen, oder Couldn’t send mit Retry, falls es abgelehnt wird.
(comment: DiffComment) => unknown–
authorWird bei neuen Kommentaren angezeigt.
{ name: string; image?: string }{ name: "You" }
PropTypStandard
pathDer Pfad der Datei. Bearbeitet ein Agent denselben Pfad zweimal, werden beide Änderungen aufgelistet.
string–
patchEin Unified Diff dieser Datei. Hunk-Header ohne Zeilennummern, CRLF und „No newline at end of file“ sind in Ordnung.
string–
beforeDer ursprüngliche Inhalt. Mit after wird der Diff für dich berechnet, und unveränderte Zeilen können angezeigt werden.
string–
afterDer bearbeitete Inhalt. Allein beschreibt er eine neue Datei.
string–
oldPathDer frühere Pfad einer umbenannten Datei.
string–
statusWird aus dem Inhalt ermittelt, wenn weggelassen.
"added" | "deleted" | "modified" | "renamed"–
languageShiki-Sprach-ID. Standardmäßig die Dateiendung.
string–
binaryZeigt „Binary file not shown“ und entscheidet die Datei als Ganzes.
boolean–
streamingDer Agent schreibt diese Datei noch. Sie aktualisiert sich live und kann noch nicht entschieden werden.
boolean–
staleDie Datei hat sich nach der Änderung auf der Platte geändert. Annehmen ist deaktiviert, bis du einen aktuellen Diff übergibst.
boolean–
PropTypStandard
viewKontrolliertes Layout. Auto ist Split, wenn der Diff-Bereich mindestens 900 px breit ist. Split fällt unter 600 px auf Unified zurück.
"auto" | "unified" | "split"–
defaultViewAnfangslayout im unkontrollierten Modus.
"auto" | "unified" | "split""auto"
onViewChangeWird aufgerufen, wenn jemand ein Layout wählt.
(view: "unified" | "split") => void–
advanceSpringt nach einer Entscheidung per Tastatur zur nächsten Änderung, die noch aussteht.
booleantrue
largeDiffLinesDateien mit mehr geänderten Zeilen als diesem Wert warten hinter Load diff.
number400
classNameGib ihm eine Höhe oder setze es in eine Flex-Spalte. Die Liste scrollt darin.
string–
PropTypStandard
onReviewZeigt Review und lässt jede Datei das Review an dieser Datei öffnen.
(path?: string) => void–
foldAfterFaltet längere Listen hinter „Show 3 more files“.
number6
PropTypStandard
pathDie Datei, auf die sich der Kommentar bezieht.
string–
sideDie Seite der letzten Zeile: old für eine entfernte Zeile, new für eine hinzugefügte oder unveränderte.
"old" | "new"–
startSideDie Seite der ersten Zeile. Sie unterscheidet sich von side, wenn ein Bereich von entfernten in hinzugefügte Zeilen reicht.
"old" | "new"–
linesZeilennummern in der Datei auf startSide und side.
{ start: number; end: number }–
excerptDie gewählten Zeilen in Diff-Form, etwa „-old“ und „+new“, damit der Agent den Code auch dann sieht, wenn sich Zeilennummern verschieben.
string–
textWas der Leser geschrieben hat.
string–
id, createdAt, authorWird beim Erstellen des Kommentars ausgefüllt.
string, number, { name; image? }–

DiffView

Aus diff-view.tsx exportiert. Es hat keinen eigenen Scroll-Container: Es wächst mit seinem Inhalt, und seine Änderungs-Header haften am nächsten scrollenden Parent, setze es also in einen, etwa ein Element mit min-h-0 flex-1 overflow-y-auto.

PropTypStandard
beforeDer frühere Inhalt.
string–
afterDer spätere Inhalt.
string–
patchEin Unified Diff, statt before und after.
string–
languageShiki-Sprach-ID. Standardmäßig die Endung von path.
string–
pathWird für die Sprache und den optionalen Header verwendet.
string"file"
viewSplit fällt unter 600 px auf Unified zurück.
"unified" | "split""unified"
wrapBricht lange Zeilen um. Split bricht immer um.
booleanfalse
headerZeigt den Pfad und die +N-/−N-Zähler über den Änderungen.
booleanfalse
classNameKlassen für das Root.
string–
PropTypStandard
decisionPending, wenn eine Entscheidung rückgängig gemacht wurde.
"accepted" | "rejected" | "pending"–
sourceWas die Person getan hat.
"change" | "file" | "all" | "undo"–
changesNur die Änderungen, deren Entscheidung sich tatsächlich geändert hat.
{ file: string; path: string; id: string }[]–

applyDecisions(file, decisions, options?)

Gibt { path, decision, content?, deleted, patch } zurück. Ausstehende Änderungen zählen als abgelehnt, außer options.pending ist "accepted".

PropTypStandard
contentDie Datei nach dem Review, aus before oder after aufgebaut. Undefined bei Binärdateien oder wenn keines von beidem bekannt ist.
string | undefined–
deletedTrue, wenn die Datei nicht existieren soll: eine angenommene Löschung oder eine abgelehnte neue Datei.
boolean–
patchNur die angenommenen Hunks, neu nummeriert, sodass git apply auf der Originaldatei funktioniert.
string–
TasteAktion
JSpringt zur nächsten Änderung, die noch aussteht. K geht zurück.
NSpringt zur nächsten Datei. P springt zur vorherigen.
YNimmt die aktuelle Änderung an. ⌘↵ tut dasselbe.
XLehnt die aktuelle Änderung ab. ⌘⌫ tut dasselbe.
⇧YNimmt jede ausstehende Änderung in der aktuellen Datei an. ⇧X lehnt sie ab.
⌘⇧↵Nimmt jede bereite Änderung an. ⌘⇧⌫ lehnt sie ab.
UMacht die letzte Entscheidung rückgängig, auch Sammelentscheidungen. ⌘Z tut dasselbe.
↑↓Wechselt durch den Dateibaum. Enter springt zur Datei, ← und → falten Ordner, und Tippen springt nach Name zu einer Datei.
TabSpringt in die Zeilen einer Änderung. Jede Änderung ist ein Stopp, und ↑ ↓ Pos1 Ende wechseln zwischen ihren Zeilen; in der Split-Ansicht wechseln ← → die Seiten.
⇧↓Wählt einen Zeilenbereich. ⇧↑ erweitert ihn nach oben, und Esc hebt ihn auf.
EnterÖffnet den Kommentar-Composer für die fokussierte Zeile oder den gewählten Bereich. C tut dasselbe.
⌘↵Sendet den Kommentar aus dem Composer. Esc schließt ihn und behält, was du für diese Zeile geschrieben hast.
  • Das Review ist eine Region namens „Review changes“. Jede Änderung ist eine Gruppe mit vollständigem Namen, etwa „Änderung 2 von 9, app/page.tsx, Zeilen 40–52, 3 Zeilen hinzugefügt, 1 Zeile entfernt, ausstehend“.
  • Entscheidungen, Sammelaktionen und Undo werden über eine höfliche Live-Region mit dem Rest angesagt, zum Beispiel „Änderung 2 von 9 in app/page.tsx angenommen. 7 Änderungen übrig.“
  • Tastenkürzel funktionieren nur, solange der Fokus im Review liegt. In Textfeldern werden sie ignoriert, und Buchstaben im Dateibaum springen nach Name zu Dateien. Nach einer Entscheidung per Tastatur springt der Fokus zur nächsten ausstehenden Änderung. Nach einem Klick springt er an derselben Stelle zum Undo-Button.
  • Hinzugefügte und entfernte Zeilen werden als „Hinzugefügt:“ und „Entfernt:“ vorgelesen, sodass sie sich nicht auf Farbe verlassen. Geänderte Wörter sind im Hochkontrastmodus unterstrichen, und der Fortschritt wird zusätzlich als Progressbar bereitgestellt.
  • Gefaltete Änderungen sind inert, werden also von Tab und Screenreadern übersprungen, bis du sie zeigst. Bei reduzierter Bewegung aktualisieren sich Falten, Zähler und Fortschritt ohne Animation.
  • Die Zeilen jeder Änderung bilden einen Tab-Stopp mit Roving Focus, wie ein Grid. Die fokussierte Zeile zeigt eine Tönung und ein + in der Rinne, und der Forced-Colors-Modus zeichnet stattdessen eine Kontur.
  • Beim Öffnen des Composers springt der Fokus hinein, und die Zeile bleibt hervorgehoben. Esc gibt den Fokus sichtbar an die Zeile zurück. Nach dem Senden oder Wegklicken geht der Fokus ohne Fokusanzeige zur Zeile zurück, sodass die nächste Pfeiltaste von dort weitermacht.
  • Entfernte und hinzugefügte Zeilen tragen neben den Zeichen − und + einen gestreiften oder durchgehenden Rinnenbalken, und die Balken bleiben im Forced-Colors-Modus sichtbar.
  • Hinzufügen, Bearbeiten, Löschen und Wiederherstellen von Kommentaren werden angesagt, und gelöschte Kommentare lassen sich einige Sekunden lang wiederherstellen.

Gebaut mit

Die kostenlosen HextaUI-Komponenten, aus denen Diff Review besteht. Jede lässt sich einzeln installieren.

Code

13 Dateien, hinzugefügt zu components/blocks/diff-review.