Diff Review

Relisez les modifications d’un agent dans plusieurs fichiers avant qu’elles ne soient appliquées. Une arborescence de fichiers avec compteurs, acceptation ou rejet de chaque changement, de chaque fichier ou de tout, des commentaires sur n’importe quelle ligne ou plage renvoyés à l’agent, des vues unifiée et côte à côte, des surlignages au niveau du mot, l’annulation, des modifications en streaming et un résumé « 4 fichiers modifiés » pour le chat.

Les agents de code modifient plusieurs fichiers à la fois, et il faut pouvoir en garder une partie et jeter le reste. Diff Review place chaque modification dans une seule liste défilante avec une arborescence de fichiers à côté, pour accepter ou rejeter un seul changement, un fichier entier ou tout, à la souris ou au clavier. DiffSummary est la carte « Edited 4 files +120 −34 » pour le chat, avec les mêmes actions sur chaque fichier.

Chaque décision touche exactement un changement. Un changement décidé se replie en une ligne avec son résultat, Show et Undo, et un fichier se replie dès que tous ses changements sont décidés. Les actions groupées indiquent combien de changements elles couvrent, et chaque décision, y compris Accept all et Reject all, peut être annulée avec U ou ⌘Z. « Next » désigne toujours le prochain changement que vous n’avez pas encore décidé. Les compteurs et la barre de progression viennent de ce qui est encore en attente, donc la revue se termine par un clair « All reviewed ».

Survolez une ligne et appuyez sur + dans la gouttière, ou donnez le focus à une ligne et appuyez sur Enter ou C, pour la commenter. Faites glisser ou cliquez avec Shift sur les numéros de ligne, ou utilisez Shift+↑↓, pour commenter une plage. Le champ de saisie s’ouvre en petit popover sous la ligne, et un commentaire envoyé reste sur la ligne sous forme de fil que vous pouvez modifier, supprimer avec annulation ou replier, avec un compteur dans la gouttière. Les lignes supprimées sont rapportées côté ancien et les lignes ajoutées ou inchangées côté nouveau, donc les numéros de ligne correspondent toujours au fichier.

Passez un diff unifié, ou le contenu avant et après et laissez le block calculer le diff. Avec le contenu complet, vous pouvez afficher les lignes inchangées entre les changements, 20 à la fois vers le haut ou le bas, ou toutes d’un coup quand moins de 20 sont masquées. Les lignes supprimées portent une fine barre rayée dans la gouttière et les lignes ajoutées une barre pleine, donc la différence ne dépend jamais de la couleur. Les lignes sont colorées avec Shiki comme Code Block, et les mots modifiés dans une ligne sont marqués quand les deux lignes sont assez proches pour que cela aide. La disposition passe en vue split quand il y a la place, soit au moins 900px par défaut. Les fichiers encore en streaming s’affichent en direct et ne peuvent pas encore être décidés. Les fichiers modifiés sur le disque peuvent être rejetés mais pas acceptés. Les diffs de plus de 400 lignes modifiées attendent derrière Load diff, et leurs actions au niveau du fichier continuent de fonctionner.

  1. Ajouter le registre Pro à components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Ajouter votre token

    Créez un token sur votre page de compte et placez-le dans .env.local sous le nom HEXTAUI_PRO_TOKEN.

  3. Ajouter le block

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

Avec AI SDK

Transforme les appels d’outils de modification en fichiers, y compris une entrée encore en streaming : un diff unifié, le contenu d’un nouveau fichier, ou old_string et new_string de Claude Code. Le résumé va dans le chat et la revue à côté, avec un seul provider partagé.

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

Envoyer les commentaires de ligne à l’agent

onComment reçoit le fichier, le côté, les numéros de ligne et un extrait de diff, pour que vous puissiez l’envoyer comme message de suivi. Retournez la promesse de sendMessage et chaque commentaire affiche Sending… puis 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>
  )
}

Écrire ce qui a été accepté

applyDecisions reconstruit chaque fichier à partir des hunks conservés, en utilisant le contenu d’origine ou modifié. Il renvoie aussi un patch des seuls hunks acceptés, et indique quand un fichier doit être supprimé ou ne jamais être créé.

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

Un diff en lecture seule

DiffView affiche les changements d’un fichier avec la même coloration, les mêmes barres de gouttière et le même dépliage des lignes inchangées, sans contrôles de revue. Il grandit avec son contenu, donc placez-le dans un parent défilant.

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

À partir d’un git diff

parsePatch découpe la sortie de git diff ou d’un diff unifié simple en fichiers, avec renommages, fichiers nouveaux, supprimés et binaires.

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

Anatomie

Les parties à composer, de l’extérieur vers l’intérieur.

PartieDescription
DiffReviewProviderContient les fichiers, les décisions et l’historique d’annulation, afin qu’un résumé dans le chat et un panneau de revue restent synchronisés.
DiffReviewLa surface de revue : barre d’outils, arborescence de fichiers, les changements et la barre de revue avec progression, navigation et actions groupées.
DiffSummaryUne carte pour le chat : « Edited 4 files » avec compteurs, chaque fichier avec accepter et rejeter, et des actions groupées.
DiffViewUn diff en lecture seule d’un fichier, pour montrer les changements entre versions.
Comment threadsDes commentaires sous une ligne ou une plage, avec auteur, heure, statut de livraison, modification, suppression et repli.
parsePatch, applyDecisionsLit la sortie de git diff en fichiers, et retransforme les décisions en contenus de fichiers et en patch ne contenant que ce qui est accepté.
useDiffReviewL’état partagé, pour construire vos propres contrôles dans le provider.

DiffReviewProvider

DiffReview et DiffSummary acceptent les mêmes props lorsqu’ils sont utilisés seuls.

PropTypePar défaut
filesLes fichiers modifiés, dans l’ordre où l’agent les a édités.
DiffFile[]–
decisionsDécisions contrôlées par id de changement. Les changements sans entrée sont en attente.
Record<string, "accepted" | "rejected">–
defaultDecisionsDécisions initiales en mode non contrôlé.
Record<string, "accepted" | "rejected">–
onDecisionsChangeAppelé avec les décisions suivantes après chaque action, annulation comprise.
(decisions) => void–
onDecideAppelé une fois par action avec la décision, sa source (change, file, all ou undo) et les changements touchés. Utilisez-le pour écrire ou annuler des fichiers.
(event: DiffDecideEvent) => void–
commentsCommentaires contrôlés.
DiffComment[]–
defaultCommentsCommentaires initiaux en mode non contrôlé.
DiffComment[]–
onCommentsChangeAppelé après l’ajout, la modification, la suppression ou la restauration d’un commentaire.
(comments: DiffComment[]) => void–
onCommentAppelé à l’envoi d’un commentaire, et de nouveau avec le même id lorsqu’il est modifié. Retournez une promesse pour afficher Sending… puis Sent to the agent, ou Couldn’t send avec Retry si elle est rejetée.
(comment: DiffComment) => unknown–
authorAffiché sur les nouveaux commentaires.
{ name: string; image?: string }{ name: "You" }
PropTypePar défaut
pathLe chemin du fichier. Quand un agent modifie deux fois le même chemin, les deux modifications sont listées.
string–
patchUn diff unifié de ce fichier. Les en-têtes de hunk sans numéros de ligne, le CRLF et « No newline at end of file » sont acceptés.
string–
beforeLe contenu d’origine. Avec after, le diff est calculé pour vous et les lignes inchangées peuvent être affichées.
string–
afterLe contenu modifié. Seul, il décrit un nouveau fichier.
string–
oldPathL’ancien chemin d’un fichier renommé.
string–
statusDéduit du contenu si omis.
"added" | "deleted" | "modified" | "renamed"–
languageIdentifiant de langage Shiki. Par défaut, l’extension du fichier.
string–
binaryAffiche « Binary file not shown » et décide du fichier dans son ensemble.
boolean–
streamingL’agent est encore en train d’écrire ce fichier. Il se met à jour en direct et ne peut pas encore être décidé.
boolean–
staleLe fichier a changé sur le disque après la modification. L’acceptation est désactivée jusqu’à ce que vous passiez un nouveau diff.
boolean–
PropTypePar défaut
viewDisposition contrôlée. Auto est split lorsque la zone de diff fait au moins 900px de large. Split repasse en unified sous 600px.
"auto" | "unified" | "split"–
defaultViewDisposition initiale en mode non contrôlé.
"auto" | "unified" | "split""auto"
onViewChangeAppelé quand quelqu’un choisit une disposition.
(view: "unified" | "split") => void–
advanceAprès une décision au clavier, passe au prochain changement encore en attente.
booleantrue
largeDiffLinesLes fichiers comptant plus de lignes modifiées que cette valeur attendent derrière Load diff.
number400
classNameDonnez-lui une hauteur, ou placez-le dans une colonne flex. La liste défile à l’intérieur.
string–
PropTypePar défaut
onReviewAffiche Review et fait ouvrir la revue sur chaque fichier.
(path?: string) => void–
foldAfterReplie les listes plus longues derrière « Show 3 more files ».
number6
PropTypePar défaut
pathLe fichier concerné par le commentaire.
string–
sideLe côté de la dernière ligne : old pour une ligne supprimée, new pour une ligne ajoutée ou inchangée.
"old" | "new"–
startSideLe côté de la première ligne. Il diffère de side quand une plage va de lignes supprimées à des lignes ajoutées.
"old" | "new"–
linesNuméros de ligne dans le fichier pour startSide et side.
{ start: number; end: number }–
excerptLes lignes sélectionnées sous forme de diff, comme « -old » et « +new », pour que l’agent voie le code même après le déplacement des numéros de ligne.
string–
textCe que le lecteur a écrit.
string–
id, createdAt, authorRenseigné à la création du commentaire.
string, number, { name; image? }–

DiffView

Exporté depuis diff-view.tsx. Il n’a pas de conteneur de défilement propre : il grandit avec son contenu et ses en-têtes de changement se fixent au parent défilant le plus proche, donc placez-le dans un tel parent, comme un élément min-h-0 flex-1 overflow-y-auto.

PropTypePar défaut
beforeLe contenu précédent.
string–
afterLe contenu ultérieur.
string–
patchUn diff unifié, au lieu de avant et après.
string–
languageIdentifiant de langage Shiki. Par défaut, l’extension de path.
string–
pathUtilisé pour le langage et l’en-tête facultatif.
string"file"
viewSplit repasse en unified sous 600px.
"unified" | "split""unified"
wrapRevient à la ligne pour les longues lignes. Split revient toujours à la ligne.
booleanfalse
headerAffiche le chemin et les compteurs +N / −N au-dessus des changements.
booleanfalse
classNameClasses pour la racine.
string–
PropTypePar défaut
decisionPending quand une décision a été annulée.
"accepted" | "rejected" | "pending"–
sourceCe que la personne a fait.
"change" | "file" | "all" | "undo"–
changesUniquement les changements dont la décision a réellement changé.
{ file: string; path: string; id: string }[]–

applyDecisions(file, decisions, options?)

Renvoie { path, decision, content?, deleted, patch }. Les changements en attente comptent comme rejetés sauf si options.pending vaut "accepted".

PropTypePar défaut
contentLe fichier après revue, construit à partir de before ou after. Undefined pour les fichiers binaires ou quand aucun des deux n’est connu.
string | undefined–
deletedtrue quand le fichier ne devrait pas exister : une suppression acceptée ou un nouveau fichier rejeté.
boolean–
patchUniquement les hunks acceptés, renumérotés pour que git apply fonctionne sur le fichier d’origine.
string–
ToucheAction
JPasse au prochain changement encore en attente. K revient en arrière.
NPasse au fichier suivant. P passe au précédent.
YAccepte le changement courant. ⌘↵ fait de même.
XRejette le changement courant. ⌘⌫ fait de même.
⇧YAccepte tous les changements en attente du fichier courant. ⇧X les rejette.
⌘⇧↵Accepte tous les changements prêts. ⌘⇧⌫ les rejette.
UAnnule la dernière décision, y compris les décisions groupées. ⌘Z fait de même.
↑↓Parcourt l’arborescence de fichiers. Enter saute au fichier, ← et → replient les dossiers, et la saisie saute à un fichier par son nom.
TabEntre dans les lignes d’un changement. Chaque changement est un seul arrêt, et ↑ ↓ Home End passent d’une ligne à l’autre ; en vue split, ← → changent de côté.
⇧↓Sélectionne une plage de lignes. ⇧↑ l’étend vers le haut, et Esc l’efface.
EnterOuvre le champ de commentaire pour la ligne focalisée ou la plage sélectionnée. C fait de même.
⌘↵Envoie le commentaire depuis le champ de saisie. Esc le ferme et conserve ce que vous avez écrit pour cette ligne.
  • La revue est une région nommée « Review changes ». Chaque changement est un groupe avec un nom complet, par exemple « Change 2 of 9, app/page.tsx, lines 40–52, 3 lines added, 1 line removed, pending ».
  • Les décisions, actions groupées et annulations sont annoncées via une région live polie avec ce qu’il reste, par exemple « Accepted change 2 of 9 in app/page.tsx. 7 changes left. »
  • Les raccourcis ne fonctionnent que lorsque le focus est dans la revue. Ils sont ignorés dans les champs de texte, et les lettres dans l’arborescence sautent aux fichiers par leur nom. Après une décision au clavier, le focus passe au prochain changement en attente. Après un clic, il passe au bouton Undo au même endroit.
  • Les lignes ajoutées et supprimées sont lues « Added: » et « Removed: », donc elles ne reposent pas sur la couleur. Les mots modifiés sont soulignés en mode contraste élevé, et la progression est aussi exposée comme progressbar.
  • Les changements repliés sont inertes, donc Tab et les lecteurs d’écran les ignorent jusqu’à ce que vous les affichiez. Avec réduction des animations, le repli, les compteurs et la progression se mettent à jour sans animation.
  • Les lignes de chaque changement forment un seul arrêt de tabulation avec focus itinérant, comme une grille. La ligne focalisée affiche une teinte et un + dans la gouttière, et le mode couleurs forcées dessine un contour à la place.
  • L’ouverture du champ de saisie y déplace le focus et la ligne reste surlignée. Esc rend le focus à la ligne de façon visible. Après l’envoi ou un clic ailleurs, le focus retourne à la ligne sans indicateur de focus, donc la prochaine flèche continue à partir de là.
  • Les lignes supprimées et ajoutées portent une barre de gouttière rayée ou pleine en plus des signes − et +, et les barres restent visibles en mode couleurs forcées.
  • L’ajout, la modification, la suppression et la restauration de commentaires sont annoncés, et les commentaires supprimés peuvent être restaurés pendant quelques secondes.

Construit avec

Les composants HextaUI gratuits dont Diff Review est constitué. Chacun s’installe séparément.

Code

13 fichiers, ajoutés à components/blocks/diff-review.