Diff Review

エージェントによる編集を、反映される前にファイルをまたいで確認します。件数つきのファイルツリー、変更ごと、ファイルごと、またはすべてに対する承認と却下、任意の行や範囲に付けてエージェントへ返せるコメント、ユニファイドビューとスプリットビュー、単語単位のハイライト、取り消し、ストリーミングされる編集、チャット向けの「Edited 4 files」サマリーを備えています。

コーディングエージェントは複数のファイルを一度に変更するため、一部は残して残りは捨てたいことがあります。Diff Reviewはすべての編集を、ファイルツリーを横に置いた1つのスクロールリストにまとめ、1つの変更、ファイル全体、またはすべてを、マウスでもキーボードでも承認または却下できます。DiffSummaryはチャット向けの「Edited 4 files +120 −34」のカードで、各ファイルに同じアクションがあります。

各判断は、ちょうど1つの変更に作用します。判断された変更は、結果、Show、Undoを備えた1行のスタブに折りたたまれ、ファイル内のすべての変更が判断されると、そのファイルは折りたたまれます。一括アクションは対象となる変更の数を示し、Accept allとReject allを含むすべての判断は、Uまたは⌘Zで取り消せます。「Next」は常に、まだ判断していない次の変更を意味します。件数とプログレスバーはまだ保留中のものから算出されるため、レビューは明確な「All reviewed」で終わります。

行にホバーしてガターの+を押すか、行にフォーカスしてEnterまたはCを押すと、その行にコメントできます。行番号をドラッグするかShiftクリックするか、Shift+↑↓を使うと、範囲にコメントできます。コンポーザーは行の下の小さなポップオーバーとして開き、送信したコメントは行にスレッドとして残り、編集、取り消しつきの削除、折りたたみができ、ガターに件数が表示されます。削除された行は旧側、追加された行と変更のない行は新側として報告されるため、行番号は常にファイルと一致します。

統合差分、または変更前と変更後の内容を渡すと、ブロックが差分を計算します。完全な内容があれば、変更の間にある変更のない行を、上方向または下方向に20行ずつ、非表示の行が20行未満なら一度にすべて表示できます。削除された行にはガターに細い縞模様のバーが、追加された行には塗りつぶしのバーが付くため、違いが色に依存することはありません。行はCode BlockのようにShikiでハイライトされ、行内で変更された単語は、2つの行が十分似ていて役立つ場合にマークされます。余裕があるとき(デフォルトでは900px以上)は、レイアウトがスプリットビューに切り替わります。まだストリーミング中のファイルはリアルタイムで表示され、まだ判断できません。ディスク上で変更されたファイルは却下はできますが承認はできません。変更が400行を超える差分はLoad diffの背後で待機し、ファイル単位のアクションは引き続き動作します。

  1. Proレジストリをcomponents.jsonに追加する

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. トークンを追加する

    アカウントページでトークンを作成し、.env.local に HEXTAUI_PRO_TOKEN として設定してください。

  3. ブロックを追加する

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

AI SDKと使う

編集ツールの呼び出しを、まだストリーミング中の入力も含めてファイルに変換します。統合差分、新しいファイルの内容、またはClaude Codeのold_stringとnew_stringです。サマリーはチャットに、レビューはその横に置き、1つのプロバイダーを共有します。

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

行コメントをエージェントに送る

onCommentは、ファイル、側、行番号、差分の抜粋を受け取るので、フォローアップのメッセージとして送信できます。sendMessageのPromiseを返すと、各コメントにSending…、続いて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>
  )
}

承認された内容を書き込む

applyDecisionsは、元の内容または編集後の内容を使って、残したハンクから各ファイルを再構築します。承認されたハンクだけのパッチも返し、ファイルを削除すべき場合や作成しないべき場合も伝えます。

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

読み取り専用の差分

DiffViewは、レビュー用のコントロールなしで、同じハイライト、ガターバー、変更のない行の展開を使って、1つのファイルの変更を表示します。内容に合わせて伸びるので、スクロールする親要素の中に置いてください。

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

git diffから

parsePatchは、git diffまたはプレーンな統合差分の出力を、名前変更、新規、削除、バイナリのファイルを含めて、ファイルに分割します。

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

構造

外側から内側へ組み合わせるパーツ。

パーツ説明
DiffReviewProviderファイル、判断、取り消し履歴を保持するため、チャット内のサマリーとレビューパネルの同期が保たれます。
DiffReviewレビューのサーフェス。ツールバー、ファイルツリー、変更、そして進捗、ナビゲーション、一括アクションを備えたレビューバー。
DiffSummaryチャット向けのカード: 件数つきの「Edited 4 files」、承認と却下を備えた各ファイル、一括アクション。
DiffViewバージョン間の変更を示す、1つのファイルの読み取り専用の差分。
Comment threads行や範囲の下に付くコメント。作成者、時刻、配信ステータス、編集、削除、折りたたみに対応します。
parsePatch, applyDecisionsgit diffの出力をファイルに読み込み、判断をファイルの内容と承認分のみのパッチに戻します。
useDiffReview共有される状態。プロバイダー内に独自のコントロールを構築するためのものです。

DiffReviewProvider

DiffReviewとDiffSummaryは、単独で使う場合は同じプロップを受け取ります。

プロパティ型デフォルト
files変更されたファイル。エージェントが編集した順です。
DiffFile[]–
decisions変更idごとの、制御された判断。エントリのない変更は保留中です。
Record<string, "accepted" | "rejected">–
defaultDecisions非制御の場合の、最初の判断。
Record<string, "accepted" | "rejected">–
onDecisionsChange取り消しを含むすべてのアクションの後に、次の判断とともに呼ばれます。
(decisions) => void–
onDecideアクションごとに一度、判断、その発生元(change、file、all、undo)、影響した変更とともに呼ばれます。ファイルの書き込みや元に戻す処理に使ってください。
(event: DiffDecideEvent) => void–
comments制御されたコメント。
DiffComment[]–
defaultComments非制御の場合の、最初のコメント。
DiffComment[]–
onCommentsChangeコメントが追加、編集、削除、復元された後に呼ばれます。
(comments: DiffComment[]) => void–
onCommentコメントが送信されたときに呼ばれ、編集された場合は同じidで再び呼ばれます。Promiseを返すと、SendingとSent to the agentが表示され、拒否された場合はCouldn’t sendとRetryが表示されます。
(comment: DiffComment) => unknown–
author新しいコメントに表示されます。
{ name: string; image?: string }{ name: "You" }
プロパティ型デフォルト
pathファイルのパス。エージェントが同じパスを2回編集した場合は、両方の編集が一覧されます。
string–
patchこのファイルの統合差分。行番号のないハンクヘッダー、CRLF、「No newline at end of file」にも対応します。
string–
before元の内容。afterとともに渡すと、差分が自動で計算され、変更のない行を表示できます。
string–
after編集後の内容。単独で渡した場合は、新しいファイルを表します。
string–
oldPath名前が変更されたファイルの以前のパス。
string–
status省略した場合は、内容から導き出されます。
"added" | "deleted" | "modified" | "renamed"–
languageShikiの言語id。デフォルトはファイルの拡張子です。
string–
binary「Binary file not shown」を表示し、ファイル全体として判断します。
boolean–
streamingエージェントがまだこのファイルを書き込み中です。リアルタイムで更新され、まだ判断できません。
boolean–
stale編集後にディスク上でファイルが変更されました。新しい差分を渡すまで、承認は無効になります。
boolean–
プロパティ型デフォルト
view制御されたレイアウト。Autoは、差分領域の幅が900px以上のときsplitになります。Splitは600px未満ではunifiedにフォールバックします。
"auto" | "unified" | "split"–
defaultView非制御の場合の、最初のレイアウト。
"auto" | "unified" | "split""auto"
onViewChangeレイアウトが選ばれたときに呼ばれます。
(view: "unified" | "split") => void–
advanceキーボードで判断した後、まだ保留中の次の変更に移動します。
booleantrue
largeDiffLines変更された行がこれより多いファイルは、Load diffの背後で待機します。
number400
className高さを与えるか、flexカラムの中に置いてください。リストは内部でスクロールします。
string–
プロパティ型デフォルト
onReviewReviewを表示し、各ファイルをクリックするとそのファイルでレビューを開きます。
(path?: string) => void–
foldAfterそれより長いリストを「Show 3 more files」の背後に折りたたみます。
number6
プロパティ型デフォルト
pathコメントの対象となるファイル。
string–
side最後の行の側。削除された行ならold、追加された行や変更のない行ならnewです。
"old" | "new"–
startSide最初の行の側。範囲が削除された行から追加された行にまたがる場合、sideとは異なります。
"old" | "new"–
linesstartSideとsideにおける、ファイル内の行番号。
{ start: number; end: number }–
excerpt選択した行を「-old」と「+new」のような差分形式で。行番号が動いても、エージェントはコードを確認できます。
string–
text読み手が書いた内容。
string–
id, createdAt, authorコメントの作成時に設定されます。
string, number, { name; image? }–

DiffView

diff-view.tsxからエクスポートされています。独自のスクロールコンテナを持ちません。内容に合わせて伸び、変更ヘッダーは最も近いスクロールする親要素に固定されるので、min-h-0 flex-1 overflow-y-auto の要素などの中に置いてください。

プロパティ型デフォルト
before以前の内容。
string–
after後の内容。
string–
patch変更前と変更後の代わりに渡す統合差分。
string–
languageShikiの言語id。デフォルトはpathの拡張子です。
string–
path言語と任意のヘッダーに使われます。
string"file"
viewSplitは600px未満ではunifiedにフォールバックします。
"unified" | "split""unified"
wrap長い行を折り返します。Splitは常に折り返します。
booleanfalse
header変更の上に、パスと +N / −N の件数を表示します。
booleanfalse
classNameルートのクラス。
string–
プロパティ型デフォルト
decision判断が取り消されたときはpendingになります。
"accepted" | "rejected" | "pending"–
sourceその人が行ったこと。
"change" | "file" | "all" | "undo"–
changes判断が実際に変わった変更のみ。
{ file: string; path: string; id: string }[]–

applyDecisions(file, decisions, options?)

{ path, decision, content?, deleted, patch } を返します。options.pendingが "accepted" でない限り、保留中の変更は却下されたものとして扱われます。

プロパティ型デフォルト
contentレビュー後のファイル。beforeまたはafterから構築されます。バイナリファイルや、どちらも不明な場合はundefinedです。
string | undefined–
deletedファイルが存在すべきでない場合にtrue。承認された削除、または却下された新規ファイルです。
boolean–
patch承認されたハンクのみ。元のファイルにgit applyが使えるよう、番号が振り直されます。
string–
キーアクション
Jまだ保留中の次の変更に移動します。Kで戻ります。
N次のファイルに移動します。Pで前のファイルに移動します。
Y現在の変更を承認します。⌘↵も同じ動作です。
X現在の変更を却下します。⌘⌫も同じ動作です。
⇧Y現在のファイルで保留中のすべての変更を承認します。⇧Xで却下します。
⌘⇧↵準備ができているすべての変更を承認します。⌘⇧⌫で却下します。
U一括のものを含め、直前の判断を取り消します。⌘Zも同じ動作です。
↑↓ファイルツリー内を移動します。Enterでファイルへジャンプし、← →でフォルダーを折りたたみ、文字を入力するとファイル名でジャンプします。
Tab変更の行の内部に移動します。各変更は1つのタブストップで、↑ ↓ Home Endでその行の間を移動し、スプリットビューでは← →で側を切り替えます。
⇧↓行の範囲を選択します。⇧↑で上方向に拡張し、Escで解除します。
Enterフォーカスのある行、または選択した範囲のコメントコンポーザーを開きます。Cも同じ動作です。
⌘↵コンポーザーからコメントを送信します。Escで閉じ、その行について書いた内容は保持されます。
  • レビューは「Review changes」という名前のリージョンです。各変更は、「Change 2 of 9, app/page.tsx, lines 40–52, 3 lines added, 1 line removed, pending」のようにフルネームを持つグループです。
  • 判断、一括アクション、取り消しは、「Accepted change 2 of 9 in app/page.tsx. 7 changes left.」のように残りの件数とともに、politeなライブリージョンで読み上げられます。
  • ショートカットは、フォーカスがレビュー内にあるときだけ動作します。テキストフィールドでは無視され、ファイルツリーでは文字を入力するとファイル名でジャンプします。キーボードで判断した後は、フォーカスが次の保留中の変更に移ります。クリックした後は、同じ場所にあるUndoボタンに移ります。
  • 追加された行と削除された行は「Added:」と「Removed:」として読み上げられるため、色に頼りません。変更された単語は、ハイコントラストモードでは下線が引かれ、進捗はprogressbarとしても公開されます。
  • 折りたたまれた変更はinertになるため、表示するまでTabやスクリーンリーダーでスキップされます。モーション軽減時は、折りたたみ、件数、進捗がアニメーションなしで更新されます。
  • 各変更の行は、グリッドのようにローヴィングフォーカスを持つ1つのタブストップです。フォーカスされた行には色合いとガターの+が表示され、強制カラーモードでは代わりにアウトラインが描画されます。
  • コンポーザーを開くとフォーカスがその中に移り、行はハイライトされたままです。Escで、フォーカスが見える形で行に戻ります。送信後や外をクリックした後は、フォーカスインジケーターなしで行にフォーカスが戻るため、次の矢印キーはそこから続きます。
  • 削除された行と追加された行には、−と+の記号に加えて、縞模様または塗りつぶしのガターバーが付き、バーは強制カラーモードでも表示されたままです。
  • コメントの追加、編集、削除、復元は読み上げられ、削除したコメントは数秒間復元できます。

使用技術

Diff Review を構成する無料のHextaUIコンポーネントです。それぞれ単独でインストールできます。

コード

13 個のファイルを components/blocks/diff-review に追加しました。