Review changes
Continuation from the AI agent chatbot guide
This guide continues the AI agent chatbot guide. Read it first.
The AI Toolkit lets you show AI-generated changes in a beautiful diff UI, so your users can review and accept or reject them.
See the source code on GitHub.
First, configure the reviewOptions parameter. Set the mode to 'preview' to show a preview of the changes before they are applied.
// inside the useChat hook
const result = toolkit.executeTool({
toolName,
input,
reviewOptions: { mode: 'preview' },
})After executing the tool, several suggestions will be displayed inside the editor. Each suggestion represents a change that the AI model wants to make to the document.
Before continuing the conversation, the AI agent has to wait for the user to review the changes. So if the tool call modifies the document, then addtoolOutput should not be called immediately, and instead a review UI should be displayed.
Edit the code so that the chatbot stops and waits for the user to review the changes:
const [reviewState, setReviewState] = useState({
// Whether to display a review UI
isReviewing: false,
// Data for the tool call result
tool: '',
toolCallId: '',
output: '',
// Feedback events collected from user actions
userFeedback: [] as SuggestionFeedbackEvent[],
})
const { messages, sendMessage, addtoolOutput } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
async onToolCall({ toolCall }) {
if (!editor) return
const { toolName, input, toolCallId } = toolCall
// Use the AI Toolkit to execute the tool
const toolkit = getAiToolkit(editor)
const result = toolkit.executeTool({
toolName,
input,
reviewOptions: {
mode: 'preview',
},
})
// If the tool call modifies the document, stop and display a review UI
if (result.docChanged) {
// Show the review UI
setReviewState({
isReviewing: true,
tool: toolName,
toolCallId,
output: result.output,
userFeedback: [],
})
} else {
// Continue the conversation
addtoolOutput({ tool: toolName, toolCallId, output: result.output })
}
},
})Style suggestions
Create a CSS file (app/suggestions.css) to style suggestions in red/green.
/* Highlight deleted text in red */
.tiptap-ai-suggestion,
.tiptap-ai-suggestion > * {
background-color: oklch(80.8% 0.114 19.571);
}
.tiptap-ai-suggestion--selected,
.tiptap-ai-suggestion--selected > * {
background-color: oklch(70.4% 0.191 22.216);
}
/* Highlight inserted text in green */
.tiptap-ai-suggestion-diff,
.tiptap-ai-suggestion-diff > * {
background-color: oklch(87.1% 0.15 154.449);
}
.tiptap-ai-suggestion-diff--selected,
.tiptap-ai-suggestion-diff--selected > * {
background-color: oklch(79.2% 0.209 151.711);
}Import the stylesheet in your app:
// app/page.tsx
import './suggestions.css'Accept/reject all suggestions
Then, in the React component, display a button to reject/accept the changes:
{
reviewState.isReviewing && (
<div>
<h2>Reviewing Changes</h2>
<button
onClick={() => {
const toolkit = getAiToolkit(editor)
const result = toolkit.acceptAllSuggestions()
// Combine all feedback events (previous + new)
const userFeedback = [...reviewState.userFeedback, ...result.aiFeedback.events]
let output = reviewState.output
// Add feedback to tool output if there are any changes that were not accepted
if (userFeedback.length > 0 && userFeedback.some((event) => !event.accepted)) {
output += `\n\n<user_feedback>\n${JSON.stringify(userFeedback)}\n</user_feedback>`
}
addtoolOutput({
tool: reviewState.tool,
toolCallId: reviewState.toolCallId,
output,
})
// Reset feedback events and close review UI
setReviewState({
...reviewState,
isReviewing: false,
userFeedback: [],
})
}}
>
Accept all
</button>
<button
onClick={() => {
const toolkit = getAiToolkit(editor)
const result = toolkit.rejectAllSuggestions()
// Combine all feedback events (previous + new)
const userFeedback = [...reviewState.userFeedback, ...result.aiFeedback.events]
// Combine rejection message with feedback in XML tags
const rejectionMessage =
'Some changes you made were rejected by the user. Ask the user why, and what you can do to improve them.'
const outputWithFeedback = `${rejectionMessage}\n\n<user_feedback>\n${JSON.stringify(userFeedback)}\n</user_feedback>`
addtoolOutput({
tool: reviewState.tool,
toolCallId: reviewState.toolCallId,
output: outputWithFeedback,
})
// Reset feedback events and close review UI
setReviewState({
...reviewState,
isReviewing: false,
userFeedback: [],
})
}}
>
Reject all
</button>
</div>
)
}Collecting AI feedback
When users accept or reject suggestions, the AI Toolkit provides feedback that you can send back to the AI to help it learn from user preferences. The acceptSuggestion, acceptAllSuggestions, rejectSuggestion, and rejectAllSuggestions methods all return an aiFeedback property containing events with information about what was changed.
const result = toolkit.acceptSuggestion('suggestion-1')
console.log(result.aiFeedback.events)In the example above, feedback events are collected in the reviewState.userFeedback array and then sent to the AI wrapped in XML tags when the user accepts or rejects all changes. This allows the AI to understand which changes were accepted or rejected and improve future suggestions.
Accept/reject individual suggestions
You can also display buttons or a popover over a suggestion, with actions to accept or reject it.
To render custom elements in the UI, set the reviewOptions.displayOptions parameter. There, you can set the renderDecorations option. It's a function that returns a list of ProseMirror decorations.
const result = toolkit.executeTool({
toolName,
input,
reviewOptions: {
mode: 'preview',
displayOptions: {
renderDecorations(options) {
return [
...options.defaultRenderDecorations(),
// Accept button
Decoration.widget(options.range.to, () => {
const element = document.createElement('button')
element.textContent = 'Accept'
element.addEventListener('click', () => {
const result = toolkit.acceptSuggestion(options.suggestion.id)
// Collect feedback events using functional update
setReviewState((prev) => ({
...prev,
userFeedback: [...prev.userFeedback, ...result.aiFeedback.events],
}))
})
return element
}),
// Reject button
Decoration.widget(options.range.to, () => {
const element = document.createElement('button')
element.textContent = 'Reject'
element.addEventListener('click', () => {
const result = toolkit.rejectSuggestion(options.suggestion.id)
// Collect feedback events using functional update
setReviewState((prev) => ({
...prev,
userFeedback: [...prev.userFeedback, ...result.aiFeedback.events],
}))
})
return element
}),
]
},
},
},
})Full demo code
// app/page.tsx
'use client'
import { useChat } from '@ai-sdk/react'
import { Decoration } from '@tiptap/pm/view'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { AiToolkit, getAiToolkit, type SuggestionFeedbackEvent } from '@tiptap-pro/ai-toolkit'
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai'
import { useRef, useState } from 'react'
import './suggestions.css'
export default function Page() {
const editor = useEditor({
immediatelyRender: false,
extensions: [StarterKit, AiToolkit],
content: `<h1>AI agent demo</h1><p>Ask the AI to improve this.</p>`,
})
const [reviewState, setReviewState] = useState({
// Whether to display the review UI
isReviewing: false,
// Data for the tool call result
tool: '',
toolCallId: '',
output: '',
// Feedback events collected from user actions
userFeedback: [] as SuggestionFeedbackEvent[],
})
const acceptButtonRef = useRef<HTMLButtonElement>(null)
const rejectButtonRef = useRef<HTMLButtonElement>(null)
const { messages, sendMessage, addToolOutput } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
async onToolCall({ toolCall }) {
if (!editor) return
const { toolName, input, toolCallId } = toolCall
// Use the AI Toolkit to execute the tool
const toolkit = getAiToolkit(editor)
const result = toolkit.executeTool({
toolName,
input,
reviewOptions: {
mode: 'preview',
displayOptions: {
renderDecorations(options) {
return [
...options.defaultRenderDecorations(),
// Accept button
Decoration.widget(options.range.to, () => {
const element = document.createElement('button')
element.textContent = 'Accept'
element.addEventListener('click', () => {
const result = toolkit.acceptSuggestion(options.suggestion.id)
// Collect feedback events using functional update
setReviewState((prev) => ({
...prev,
userFeedback: [...prev.userFeedback, ...result.aiFeedback.events],
}))
if (toolkit.getSuggestions().length === 0) {
acceptButtonRef.current?.click()
}
})
return element
}),
// Reject button
Decoration.widget(options.range.to, () => {
const element = document.createElement('button')
element.textContent = 'Reject'
element.addEventListener('click', () => {
const result = toolkit.rejectSuggestion(options.suggestion.id)
// Collect feedback events using functional update
setReviewState((prev) => ({
...prev,
userFeedback: [...prev.userFeedback, ...result.aiFeedback.events],
}))
if (toolkit.getSuggestions().length === 0) {
rejectButtonRef.current?.click()
}
})
return element
}),
]
},
},
},
})
// If the tool call modifies the document, halt the conversation and display the review UI
if (result.docChanged) {
// Show the review UI
setReviewState({
isReviewing: true,
tool: toolName,
toolCallId,
output: result.output,
userFeedback: [],
})
} else {
// Continue the conversation
addToolOutput({ tool: toolName, toolCallId, output: result.output })
}
},
})
const [input, setInput] = useState('Replace the last paragraph with a short story about Tiptap')
if (!editor) return null
return (
<div>
<EditorContent editor={editor} />
{messages?.map((message) => (
<div key={message.id} style={{ whiteSpace: 'pre-wrap' }}>
<strong>{message.role}</strong>
<br />
{message.parts
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join('\n')}
</div>
))}
{!reviewState.isReviewing && (
<form
onSubmit={(e) => {
e.preventDefault()
if (input.trim()) {
sendMessage({ text: input })
setInput('')
}
}}
>
<input value={input} onChange={(e) => setInput(e.target.value)} />
</form>
)}
{reviewState.isReviewing && (
<div>
<h2>Reviewing Changes</h2>
<button
ref={acceptButtonRef}
onClick={() => {
const toolkit = getAiToolkit(editor)
const result = toolkit.acceptAllSuggestions()
// Combine all feedback events (previous + new)
const userFeedback = [...reviewState.userFeedback, ...result.aiFeedback.events]
let output = reviewState.output
// Add feedback to tool output if there are any changes that were not accepted
if (userFeedback.length > 0 && userFeedback.some((event) => !event.accepted)) {
output += `\n\n<user_feedback>\n${JSON.stringify(userFeedback)}\n</user_feedback>`
}
addToolOutput({
tool: reviewState.tool,
toolCallId: reviewState.toolCallId,
output,
})
// Reset feedback events and close review UI
setReviewState({
...reviewState,
isReviewing: false,
userFeedback: [],
})
}}
>
Accept all
</button>
<button
ref={rejectButtonRef}
onClick={() => {
const toolkit = getAiToolkit(editor)
const result = toolkit.rejectAllSuggestions()
// Combine all feedback events (previous + new)
const userFeedback = [...reviewState.userFeedback, ...result.aiFeedback.events]
// Combine rejection message with feedback in XML tags
const rejectionMessage =
'Some changes you made were rejected by the user. Ask the user why, and what you can do to improve them.'
const outputWithFeedback = `${rejectionMessage}\n\n<user_feedback>\n${JSON.stringify(userFeedback)}\n</user_feedback>`
addToolOutput({
tool: reviewState.tool,
toolCallId: reviewState.toolCallId,
output: outputWithFeedback,
})
// Reset feedback events and close review UI
setReviewState({
...reviewState,
isReviewing: false,
userFeedback: [],
})
}}
>
Reject all
</button>
</div>
)}
</div>
)
}End result
With additional CSS styles, the result is a simple but polished AI chatbot application where users can review AI-generated changes:
See the source code on GitHub.
Next steps
Advanced review options
The reviewOptions parameter gives you full control over the review workflow and the UI.
With the reviewOptions.mode parameter you can control when the changes are applied to the document.
preview: Show a preview of the changes before they are applied. Changes are not applied to the document until the user has accepted them.review: Apply changes immediately, and review them after they are applied to the document.
See a complete reference of the reviewOptions parameter in the API reference for the executeTool method.
Popular AI agent chatbots like Cursor call multiple tools in a row and show a summary of all changes after the AI agent has finished all its work. To implement this type of workflow, follow the Review changes as a summary guide.
Customize how suggestions are displayed
When reviewing changes, the AI Toolkit displays the preview of the changes as suggestions.
You can customize how these suggestions are displayed, by setting the reviewOptions.displayOptions parameter. You can even render React components inside them.