Diff Review

Revisa las ediciones de un agente en todos los archivos antes de que se apliquen. Un árbol de archivos con contadores, aceptar o rechazar cada cambio, cada archivo o todo, comentarios en cualquier línea o rango que vuelven al agente, vistas unificada y dividida, resaltado a nivel de palabra, deshacer, ediciones en streaming y un resumen «Editados 4 archivos» para el chat.

Los agentes de programación cambian varios archivos a la vez, y la gente necesita conservar una parte y descartar el resto. Diff Review pone cada edición en una sola lista desplazable con un árbol de archivos al lado, para que puedas aceptar o rechazar un solo cambio, un archivo entero o todo, con el ratón o el teclado. DiffSummary es la tarjeta «Edited 4 files +120 −34» para el chat, con las mismas acciones en cada archivo.

Cada decisión afecta exactamente a un cambio. Un cambio decidido se pliega a un resumen de una línea con su resultado, Show y Undo, y un archivo se pliega cuando todos sus cambios están decididos. Las acciones masivas indican a cuántos cambios afectan, y cada decisión, incluidas Accept all y Reject all, se puede deshacer con U o ⌘Z. «Next» siempre significa el siguiente cambio que aún no has decidido. Los contadores y la barra de progreso salen de lo que sigue pendiente, así que la revisión termina con un claro «All reviewed».

Pasa el cursor sobre una línea y pulsa + en el margen, o enfoca una línea y pulsa Enter o C, para comentarla. Arrastra o haz clic con Shift en los números de línea, o usa Shift+↑↓, para comentar un rango. El compositor se abre como un pequeño popover bajo la línea, y un comentario enviado permanece en la línea como un hilo que puedes editar, eliminar con deshacer o colapsar, con un contador en el margen. Las líneas eliminadas se reportan en el lado antiguo y las añadidas o sin cambios en el lado nuevo, así que los números de línea siempre coinciden con el archivo.

Pasa un diff unificado, o el contenido antes y después y deja que el bloque calcule el diff. Con el contenido completo puedes mostrar las líneas sin cambios entre cambios, de 20 en 20 hacia arriba o hacia abajo, o todas a la vez cuando hay menos de 20 ocultas. Las líneas eliminadas llevan una fina barra rayada en el margen y las añadidas una sólida, así que la diferencia nunca depende del color. Las líneas se resaltan con Shiki como en Code Block, y las palabras modificadas dentro de una línea se marcan cuando las dos líneas se parecen lo bastante como para que ayude. El diseño pasa a la vista dividida cuando hay espacio, que por defecto son al menos 900px. Los archivos que aún están en streaming se muestran en vivo y todavía no se pueden decidir. Los archivos que cambiaron en el disco se pueden rechazar pero no aceptar. Los diffs de más de 400 líneas modificadas esperan tras Load diff, y sus acciones a nivel de archivo siguen funcionando.

  1. Añade el registro Pro a components.json

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

    Crea un token en tu página de cuenta y colócalo en .env.local como HEXTAUI_PRO_TOKEN.

  3. Añade el bloque

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

Con AI SDK

Convierte las llamadas a herramientas de edición en archivos, incluida la entrada que aún está en streaming: un diff unificado, el contenido de un archivo nuevo, o old_string y new_string de Claude Code. El resumen va en el chat y la revisión a su lado, compartiendo un mismo 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>
  )
}

Envía comentarios de línea al agente

onComment recibe el archivo, el lado, los números de línea y un extracto del diff, para que puedas enviarlo como mensaje de seguimiento. Devuelve la promesa de sendMessage y cada comentario muestra Sending… y luego 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>
  )
}

Escribe lo que se aceptó

applyDecisions reconstruye cada archivo a partir de los hunks que conservaste, usando el contenido original o el editado. También devuelve un parche solo con los hunks aceptados, e indica cuándo un archivo debe eliminarse o no crearse nunca.

"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 de solo lectura

DiffView muestra los cambios de un archivo con el mismo resaltado, barras de margen y expansión de líneas sin cambios, sin controles de revisión. Crece con su contenido, así que colócalo dentro de un padre desplazable.

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

Desde un git diff

parsePatch divide la salida de git diff o de un diff unificado simple en archivos, con renombrados, archivos nuevos, eliminados y binarios.

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

Anatomía

Las partes que compones, de fuera hacia dentro.

ParteDescripción
DiffReviewProviderContiene los archivos, las decisiones y el historial de deshacer, para que un resumen en el chat y un panel de revisión se mantengan sincronizados.
DiffReviewLa superficie de revisión: barra de herramientas, árbol de archivos, los cambios y la barra de revisión con progreso, navegación y acciones masivas.
DiffSummaryUna tarjeta para el chat: «Edited 4 files» con contadores, cada archivo con aceptar y rechazar, y acciones masivas.
DiffViewUn diff de solo lectura de un archivo, para mostrar los cambios entre versiones.
Comment threadsComentarios bajo una línea o rango, con autor, hora, estado de entrega, edición, eliminación y colapso.
parsePatch, applyDecisionsLee la salida de git diff en archivos, y convierte las decisiones de nuevo en contenido de archivos y un parche solo con lo aceptado.
useDiffReviewEl estado compartido, para construir tus propios controles dentro del provider.

DiffReviewProvider

DiffReview y DiffSummary reciben las mismas props cuando se usan por separado.

PropTipoPredeterminado
filesLos archivos modificados, en el orden en que el agente los editó.
DiffFile[]–
decisionsDecisiones controladas por id de cambio. Los cambios sin entrada están pendientes.
Record<string, "accepted" | "rejected">–
defaultDecisionsDecisiones iniciales cuando no es controlado.
Record<string, "accepted" | "rejected">–
onDecisionsChangeSe llama con las siguientes decisiones tras cada acción, incluido deshacer.
(decisions) => void–
onDecideSe llama una vez por acción con la decisión, su origen (change, file, all o undo) y los cambios que afectó. Úsalo para escribir o revertir archivos.
(event: DiffDecideEvent) => void–
commentsComentarios controlados.
DiffComment[]–
defaultCommentsComentarios iniciales cuando no es controlado.
DiffComment[]–
onCommentsChangeSe llama después de añadir, editar, eliminar o restaurar un comentario.
(comments: DiffComment[]) => void–
onCommentSe llama cuando se envía un comentario, y de nuevo con el mismo id cuando se edita. Devuelve una promesa para mostrar Sending… y Sent to the agent, o Couldn’t send con Retry si se rechaza.
(comment: DiffComment) => unknown–
authorSe muestra en los comentarios nuevos.
{ name: string; image?: string }{ name: "You" }
PropTipoPredeterminado
pathLa ruta del archivo. Cuando un agente edita la misma ruta dos veces, se enumeran ambas ediciones.
string–
patchUn diff unificado de este archivo. Los encabezados de hunk sin números de línea, CRLF y «No newline at end of file» son válidos.
string–
beforeEl contenido original. Con after, el diff se calcula por ti y se pueden mostrar las líneas sin cambios.
string–
afterEl contenido editado. Por sí solo, describe un archivo nuevo.
string–
oldPathLa ruta anterior de un archivo renombrado.
string–
statusSe calcula a partir del contenido cuando se omite.
"added" | "deleted" | "modified" | "renamed"–
languageId de lenguaje de Shiki. Por defecto, la extensión del archivo.
string–
binaryMuestra «Binary file not shown» y decide el archivo como un todo.
boolean–
streamingEl agente aún está escribiendo este archivo. Se actualiza en vivo y todavía no se puede decidir.
boolean–
staleEl archivo cambió en el disco después de la edición. Aceptar está desactivado hasta que pases un diff nuevo.
boolean–
PropTipoPredeterminado
viewDiseño controlado. Auto es dividido cuando el área del diff mide al menos 900px de ancho. Split pasa a unificado por debajo de 600px.
"auto" | "unified" | "split"–
defaultViewDiseño inicial cuando no es controlado.
"auto" | "unified" | "split""auto"
onViewChangeSe llama cuando alguien elige un diseño.
(view: "unified" | "split") => void–
advanceTras decidir con el teclado, pasa al siguiente cambio que sigue pendiente.
booleantrue
largeDiffLinesLos archivos con más líneas modificadas que esto esperan tras Load diff.
number400
classNameDale una altura, o colócalo en una columna flex. La lista se desplaza en su interior.
string–
PropTipoPredeterminado
onReviewMuestra Review y hace que cada archivo abra la revisión en ese archivo.
(path?: string) => void–
foldAfterPliega las listas más largas tras «Show 3 more files».
number6
PropTipoPredeterminado
pathEl archivo al que se refiere el comentario.
string–
sideEl lado de la última línea: old para una línea eliminada, new para una añadida o sin cambios.
"old" | "new"–
startSideEl lado de la primera línea. Difiere de side cuando un rango va de líneas eliminadas a añadidas.
"old" | "new"–
linesNúmeros de línea del archivo en startSide y side.
{ start: number; end: number }–
excerptLas líneas seleccionadas en forma de diff, como «-old» y «+new», para que el agente vea el código incluso después de que cambien los números de línea.
string–
textLo que escribió el lector.
string–
id, createdAt, authorSe rellena cuando se crea el comentario.
string, number, { name; image? }–

DiffView

Se exporta desde diff-view.tsx. No tiene contenedor de scroll propio: crece con su contenido y los encabezados de sus cambios se quedan fijos en el padre desplazable más cercano, así que colócalo en uno, como un elemento min-h-0 flex-1 overflow-y-auto.

PropTipoPredeterminado
beforeEl contenido anterior.
string–
afterEl contenido posterior.
string–
patchUn diff unificado, en lugar de antes y después.
string–
languageId de lenguaje de Shiki. Por defecto, la extensión de path.
string–
pathSe usa para el lenguaje y el encabezado opcional.
string"file"
viewSplit pasa a unificado por debajo de 600px.
"unified" | "split""unified"
wrapAjusta las líneas largas. Split siempre las ajusta.
booleanfalse
headerMuestra la ruta y los contadores +N / −N sobre los cambios.
booleanfalse
classNameClases para la raíz.
string–
PropTipoPredeterminado
decisionPendiente cuando se deshizo una decisión.
"accepted" | "rejected" | "pending"–
sourceLo que hizo la persona.
"change" | "file" | "all" | "undo"–
changesSolo los cambios cuya decisión realmente cambió.
{ file: string; path: string; id: string }[]–

applyDecisions(file, decisions, options?)

Devuelve { path, decision, content?, deleted, patch }. Los cambios pendientes cuentan como rechazados salvo que options.pending sea "accepted".

PropTipoPredeterminado
contentEl archivo tras la revisión, construido a partir de before o after. Undefined para archivos binarios o cuando no se conoce ninguno.
string | undefined–
deletedTrue cuando el archivo no debería existir: una eliminación aceptada o un archivo nuevo rechazado.
boolean–
patchSolo los hunks aceptados, renumerados para que git apply funcione sobre el archivo original.
string–
KeyAcción
JPasa al siguiente cambio que sigue pendiente. K retrocede.
NPasa al siguiente archivo. P pasa al anterior.
YAcepta el cambio actual. ⌘↵ hace lo mismo.
XRechaza el cambio actual. ⌘⌫ hace lo mismo.
⇧YAcepta todos los cambios pendientes del archivo actual. ⇧X los rechaza.
⌘⇧↵Acepta todos los cambios que están listos. ⌘⇧⌫ los rechaza.
UDeshace la última decisión, incluidas las masivas. ⌘Z hace lo mismo.
↑↓Recorre el árbol de archivos. Enter salta al archivo, ← y → pliegan carpetas, y escribir salta a un archivo por nombre.
TabEntra en las líneas de un cambio. Cada cambio es una parada, y ↑ ↓ Home End se mueven entre sus líneas; en la vista dividida, ← → cambian de lado.
⇧↓Selecciona un rango de líneas. ⇧↑ lo extiende hacia arriba y Esc lo borra.
EnterAbre el compositor de comentarios para la línea enfocada o el rango seleccionado. C hace lo mismo.
⌘↵Envía el comentario desde el compositor. Esc lo cierra y conserva lo que escribiste para esa línea.
  • La revisión es una región llamada «Review changes». Cada cambio es un grupo con un nombre completo, como «Change 2 of 9, app/page.tsx, lines 40–52, 3 lines added, 1 line removed, pending».
  • Las decisiones, acciones masivas y deshacer se anuncian mediante una región live polite con lo que queda, por ejemplo «Accepted change 2 of 9 in app/page.tsx. 7 changes left.»
  • Los atajos solo funcionan mientras el foco está dentro de la revisión. Se ignoran en los campos de texto, y las letras en el árbol de archivos saltan a archivos por nombre. Tras decidir con el teclado, el foco pasa al siguiente cambio pendiente. Tras hacer clic, pasa al botón Undo en el mismo lugar.
  • Las líneas añadidas y eliminadas se leen como «Added:» y «Removed:», así que no dependen del color. Las palabras modificadas se subrayan en el modo de alto contraste, y el progreso también se expone como progressbar.
  • Los cambios plegados son inertes, así que Tab y los lectores de pantalla los omiten hasta que los muestras. Con movimiento reducido, el plegado, los contadores y el progreso se actualizan sin animación.
  • Las líneas de cada cambio son una sola parada de tabulación con foco itinerante, como una cuadrícula. La línea enfocada muestra un tinte y un + en el margen, y el modo de colores forzados dibuja un contorno en su lugar.
  • Al abrir el compositor, el foco entra en él y la línea permanece resaltada. Esc devuelve el foco a la línea de forma visible. Tras enviar o hacer clic fuera, el foco vuelve a la línea sin indicador de foco, de modo que la siguiente tecla de flecha continúa desde ahí.
  • Las líneas eliminadas y añadidas llevan una barra de margen rayada o sólida además de los signos − y +, y las barras siguen visibles en el modo de colores forzados.
  • Añadir, editar, eliminar y restaurar comentarios se anuncia, y los comentarios eliminados se pueden restaurar durante unos segundos.

Construido con

Los componentes gratuitos de HextaUI con los que está hecho Diff Review. Cada uno se instala por separado.

Código

13 archivos, añadidos a components/blocks/diff-review.