Elements

Elements · 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
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-reviewable-diff"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

A reviewable diff breaks a patch into hunks and asks a decision of each one: keep it or discard it, with a running count and an apply button that stays disabled until every hunk has an answer. With a runtime this is a human tool: the model proposes the hunks and pauses for a decision. Standalone you supply the hunks and own the decisions yourself.

Getting started

A patch review has no automatic outcome, so it registers as a human tool: there is no execute, and the call only resolves once the renderer calls addResult.

Render the tool call

components/assistant-ui/elements/review-patch-tool-ui.tsx
"use client";

import { useState } from "react";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import {
  ReviewableDiff,
  type DiffHunk,
  type HunkDecision,
} from "@/components/assistant-ui/elements/reviewable-diff";

type ReviewPatchArgs = {
  filename: string;
  hunks: readonly Omit<DiffHunk, "decision">[];
};
type ReviewPatchResult = { kept: readonly string[] };

export const ReviewPatchToolUI: ToolCallMessagePartComponent<
  ReviewPatchArgs,
  ReviewPatchResult
> = ({ args, result, addResult }) => {
  const [decisions, setDecisions] = useState<Record<string, HunkDecision>>({});

  if (result) {
    return (
      <p>
        Applied {result.kept.length} of {args.hunks.length} hunks to {args.filename}.
      </p>
    );
  }

  const hunks: DiffHunk[] = args.hunks.map((hunk) => ({
    ...hunk,
    decision: decisions[hunk.id] ?? "pending",
  }));

  return (
    <ReviewableDiff
      filename={args.filename}
      hunks={hunks}
      onKeep={(id) => setDecisions((d) => ({ ...d, [id]: "kept" }))}
      onDiscard={(id) => setDecisions((d) => ({ ...d, [id]: "discarded" }))}
      onApply={() =>
        addResult({
          kept: hunks.filter((h) => h.decision === "kept").map((h) => h.id),
        })
      }
    />
  );
};

Register the tool

app/toolkit.ts
import { defineToolkit } from "@assistant-ui/react";
import { z } from "zod";
import { ReviewPatchToolUI } from "@/components/assistant-ui/elements/review-patch-tool-ui";

export const toolkit = defineToolkit({
  review_patch: {
    type: "human",
    description: "Ask the user which hunks of a proposed patch to keep.",
    parameters: z.object({
      filename: z.string(),
      hunks: z.array(
        z.object({
          id: z.string(),
          range: z.string(),
          lines: z.array(
            z.object({
              kind: z.enum(["context", "added", "removed"]),
              text: z.string(),
            }),
          ),
        }),
      ),
    }),
    render: ReviewPatchToolUI,
  },
});
app/MyRuntimeProvider.tsx
import { AssistantRuntimeProvider, AuiConfig, Tools } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/ai-sdk";
import { toolkit } from "./toolkit";

export function MyRuntimeProvider({ children }: { children: React.ReactNode }) {
  const runtime = useChatRuntime();
  const config = AuiConfig({ tools: Tools({ toolkit }) });
  return (
    <AssistantRuntimeProvider runtime={runtime} config={config}>
      {children}
    </AssistantRuntimeProvider>
  );
}

See User Input Collection for more on human tools and addResult.

Anatomy

<div data-slot="reviewable-diff">
  <div>{/* filename, "N of M kept" */}</div>
  <div>
    {/* one block per hunk: range, then Discard/Keep while pending, else the decision label; the diff lines dim once discarded but stay visible */}
  </div>
  <div>{/* "N left to review" or "All reviewed"; an Apply button disabled while any hunk is pending */}</div>
</div>

kept and pending are always derived by counting hunks, never stored separately, so the header and footer stay in sync with whatever decision values you pass in. onKeep, onDiscard, and onApply are all optional: pass only the ones you need, and the matching control still renders but calls nothing when pressed.

Examples

Restyle the review

Both lanes take className on the root. The card surface comes from paper, the Apply button from inkButton, both in surfaces.tsx.

<ReviewableDiff className="max-w-none" /* ... */ />

Nothing to review

An empty hunks array is valid: the header reads "0 of 0 kept", the footer reads "All reviewed" since there is nothing pending, and Apply reads "Apply 0" and stays enabled.

<ReviewableDiff filename="composer.tsx" hunks={[]} />

Discard everything at once

Both lanes hold decisions as local state, so a reject-all control is the same one line wherever that state lives:

function discardAll() {
  setDecisions(Object.fromEntries(hunks.map((h) => [h.id, "discarded"])));
}

API reference

Tool-call render props

PropTypeDescription
args{ filename: string; hunks: readonly Omit<DiffHunk, "decision">[] }The filename and the unreviewed hunks the model is proposing.
result{ kept: readonly string[] } | undefinedSet once addResult is called; the ids of the hunks the user kept.
addResult(result: ReviewPatchResult) => voidCall once, after the user has decided every hunk, to resolve the call.

See Tool UI for the full render-prop surface and human-tool registration.