# signoff-ui Review what your agent changed and approve what it may do: React components for multi-file diff review and tool approvals, for AI SDK and AG-UI agents, inside the chat UI you already have. **Works with** **Website and docs:** [signoff-ui.vercel.app](https://signoff-ui.vercel.app), with a playground where a scripted coding agent asks for a review and an approval, driven from the keyboard, with no API key. ## Why it exists Coding and editing agents propose changes across several files and ask to run commands that can break things, and someone has to sign off on both. The chat kits most teams use stop short there: assistant-ui's reviewable diff takes one file's hunks, already split, and its apply returns nothing, while AI Elements has no diff at all and a confirm/deny you wire up yourself. `DiffReview` computes the hunks from each file's old and new contents, lets a person accept or reject them (or whole files) and comment on lines from the keyboard, and gives the agent back each file with only the accepted hunks applied, the hunks it rejected and every comment with its lines. `ApprovalCard` shows exactly what will run and how risky it is, takes a reason with a denial, lets a person approve once, for the session or always (by tool and argument pattern) and edit the arguments first, and tells automatic decisions apart from a person's. ## Quickstart ```bash npm i signoff-ui ai @ai-sdk/react @ai-sdk/openai zod ``` Add the styles once: `@import 'signoff-ui/tailwind.css';` after Tailwind v4 in your CSS, or `import 'signoff-ui/styles.css';` with Tailwind v3 or none. Without Tailwind, the classes on your own elements below need [this CSS](https://signoff-ui.vercel.app/docs/getting-started#without-tailwind). Then add three files to a Next.js App Router app (under `src/app/` if your app has a `src` folder). They build as they are, and run with `OPENAI_API_KEY` set. **The route.** The agent proposes edits through `review_changes`, a tool with no `execute`, so the page answers it. `toolApproval` makes AI SDK 7 stop and ask before `run_command`: ```ts // app/api/chat/route.ts import { openai } from '@ai-sdk/openai'; import { convertToModelMessages, stepCountIs, streamText, tool, type UIMessage } from 'ai'; import { z } from 'zod'; const tools = { // No execute: the page answers it with a DiffReview, and the result holds each file as applied. review_changes: tool({ description: 'Propose edits to files. The user reviews them hunk by hunk before anything is written.', inputSchema: z.object({ files: z.array(z.object({ path: z.string(), oldContent: z.string().optional(), newContent: z.string() })), }), }), run_command: tool({ description: 'Run a shell command in the repository.', inputSchema: z.object({ command: z.string() }), execute: async ({ command }) => ({ exitCode: 0, stdout: `(simulated) ${command}` }), }), }; export async function POST(req: Request) { const { messages }: { messages: UIMessage[] } = await req.json(); const result = streamText({ model: openai('gpt-5.4-mini'), messages: await convertToModelMessages(messages, { tools }), tools, // Ask before running commands. The reason shows on the approval card. toolApproval: { run_command: { type: 'user-approval', reason: 'Runs a shell command in the repository.' } }, // Keep going after tool calls: the AI SDK stops after the first step by default. stopWhen: stepCountIs(10), }); return result.toUIMessageStreamResponse({ originalMessages: messages }); } ``` **The client component.** `AgentMessage` lays out the run with the approval card inline, and `renderTool` puts a `DiffReview` where the proposed edit is. Its `onSubmit` result goes back to the agent as the tool's output, through `reviewToolOutput`: a summary, each file as applied, the hunks rejected and the reviewer's comments. The run continues once every answer is in: ```tsx // app/agent-run.tsx 'use client'; import { useChat } from '@ai-sdk/react'; import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from 'ai'; import { useState } from 'react'; import { AgentMessage, AgentStatus, DiffReview, deriveAgentState, getToolPartName, reviewToolOutput, type FileChange, } from 'signoff-ui'; export function AgentRun() { const { messages, status, sendMessage, addToolOutput, addToolApprovalResponse } = useChat({ // Continue the run once the review is in and every approval has an answer. sendAutomaticallyWhen: (chat) => lastAssistantMessageIsCompleteWithToolCalls(chat) || lastAssistantMessageIsCompleteWithApprovalResponses(chat), }); const [input, setInput] = useState(''); const last = messages.findLast((m) => m.role === 'assistant'); // review_changes waits on a person, like an approval. const { state, detail } = deriveAgentState({ status, message: last, pendingClientTools: ['review_changes'] }); return ( // The kit's own background and text colors, so it reads well on any page. Add `dark` for the dark theme.
{last && ( getToolPartName(part) === 'review_changes' && part.state === 'input-available' ? ( addToolOutput({ tool: 'review_changes', toolCallId: part.toolCallId, output: reviewToolOutput(review), }) } /> ) : undefined } /> )}
{ event.preventDefault(); if (!input.trim()) return; void sendMessage({ text: input }); setInput(''); }} > setInput(event.target.value)} className="border-signoff-border bg-signoff-surface flex-1 rounded-lg border px-3 py-2 text-sm" />
); } ``` **Render it from a page.** New Next.js 16 apps turn on `cacheComponents`, and `useChat` creates ids with `Math.random()`, which a prerendered page may not contain, so the component that calls it goes inside `` and renders in the browser: ```tsx // app/page.tsx import { Suspense } from 'react'; import { AgentRun } from './agent-run'; export default function Page() { // With cacheComponents (on in new Next.js 16 apps), useChat needs a Suspense boundary: // it creates ids with Math.random(), which Next.js does not allow in prerendered output. return ( ); } ``` [examples/nextjs-minimal](https://github.com/dgesteves/signoff-ui/blob/main/examples/nextjs-minimal) is this app plus a cost meter, the run's timing and a `read_file` tool, against a scripted model, so it needs no API key. CI drives it in Chrome through the review, the approval and the final answer. [Getting started](https://signoff-ui.vercel.app/docs/getting-started) walks through it, including AI SDK 6, other models and the shadcn registry (`npx shadcn@latest add https://signoff-ui.vercel.app/r/diff-review.json`). ## Works inside assistant-ui, AI Elements or your own chat The components take AI SDK message parts and plain props, not a runtime, and `styles.css` styles only their own elements, never the rest of your page or the content you render inside them. - **Your own chat:** render `AgentMessage` for the assistant's message, as above, or only `DiffReview` and `ToolApprovalCard` for the tool parts you want to gate. If you already render Markdown with Streamdown or your own renderer, pass it as `renderMarkdown` and import `AgentMessage` from `signoff-ui/custom-markdown`, which leaves react-markdown out of your bundle. Your renderer then has to restrict images itself, as `allowedImageHosts` does for the built-in one. - **AI Elements:** keep its conversation, messages and prompt input, and render `ToolApprovalCard` or `DiffReview` for those tool parts in place of `Tool` and `Confirmation`. - **assistant-ui:** `signoffTools()` from `signoff-ui/assistant-ui` goes in `MessagePrimitive.Parts`' `components.tools`: a `DiffReview` for the calls that propose files, its review sent back as the call's result, and the approval card, with your approval rules, for calls at an approval gate. [examples/assistant-ui](https://github.com/dgesteves/signoff-ui/blob/main/examples/assistant-ui) runs it with no API key. - **AG-UI agents** (LangGraph, CrewAI, Mastra and others): `useAgUiAgent` from `signoff-ui/ag-ui` turns the run into the same message parts, with interrupts as approval cards. - **Agent Client Protocol:** `signoff-ui/acp` folds a prompt turn's session updates and permission requests into the same message parts, with the agent's options on the approval card and each edit's diff as `DiffReview` files. [Inside your chat UI](https://signoff-ui.vercel.app/docs/chat-ui) has the code for each. ## Components | Role | Component | What it does | | ---------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Review | `DiffReview`, `useDiffReview` | Unified or split review of edits across files, computed from their contents or a patch, with word-level highlights. Accept or reject hunks or whole files and comment on lines from the keyboard; `onSubmit` returns each file with only the accepted hunks applied, the rejected hunks and the comments, and `toPatch()` a unified diff. Large files are diffed in a worker and render only what is near the screen. `useDiffReview` is the same without the markup. | | Approve | `ApprovalCard`, `ToolApprovalCard`, `useApprovalPolicy` | What will run, its risk when you rate it, approve or deny with a reason, a second press for `critical` actions, and "Auto-approved" or "Blocked by policy" for automatic decisions. Approve once, for the session or always, by tool and argument pattern; edit the arguments first; approve all pending; an audit trail; the same rules as AI SDK 7's `toolApproval`. | | Supporting | `AgentMessage` | A whole assistant message: streaming markdown, reasoning, tool calls as one timeline with approvals inline, files and sources. | | | `ToolCallTimeline` | Every tool call's state, measured duration and a waterfall, errors inline. | | | `RunMeter`, `AgentStatus` | Tokens, estimated cost and time to first token; the run's state in one announced pill. | | | `Sources`, `Markdown`, `Reasoning` | Citations, streaming-safe markdown and collapsible reasoning. | | | `useAgUiAgent` | An AG-UI agent's run as AI SDK messages, status, usage and approvals. | Typed against AI SDK 6 and 7 (`ai@^6.0.0 || ^7.0.102`), on React 18 and 19 (`react@^18.2.0 || ^19.0.0`), with Tailwind v4, v3 or no Tailwind, each tested in CI. Keyboard-first and announced to screen readers, with axe checks in jsdom and in Chrome. Every word the components show or announce is a label you can translate, from one provider. The kit calls no model and makes no requests of its own, so any model your backend uses works. ## Docs - [Live playground](https://signoff-ui.vercel.app): a scripted coding-agent run with a review and an approval, driven from the keyboard. No API key. - [Coding agent](https://github.com/dgesteves/signoff-ui/blob/main/examples/coding-agent), the reference app: approval rules, reviews whose comments go back to the agent, and a scripted model, so no API key. Start an app from it with `npx create-next-app@latest my-agent --example https://github.com/dgesteves/signoff-ui/tree/main/examples/coding-agent`. - [Docs](https://signoff-ui.vercel.app/docs): [Getting started](https://signoff-ui.vercel.app/docs/getting-started), [Inside your chat UI](https://signoff-ui.vercel.app/docs/chat-ui), [AG-UI agents](https://signoff-ui.vercel.app/docs/ag-ui), [theming](https://signoff-ui.vercel.app/docs/getting-started#theming), [accessibility](https://signoff-ui.vercel.app/docs/accessibility), [labels and translations](https://signoff-ui.vercel.app/docs/labels), [performance and limits](https://signoff-ui.vercel.app/docs/limits), and a page per component with its props, keyboard and styling hooks. Also as [`/llms.txt`](https://signoff-ui.vercel.app/llms.txt) and [`/llms-full.txt`](https://signoff-ui.vercel.app/llms-full.txt) for coding agents. - Formerly `@dgesteves/agent-ui-kit`: [Migrating](https://signoff-ui.vercel.app/docs/migrating-from-agent-ui-kit) is a package swap and one command. - [Changelog](https://github.com/dgesteves/signoff-ui/blob/main/packages/signoff-ui/CHANGELOG.md), [contributing](https://github.com/dgesteves/signoff-ui/blob/main/CONTRIBUTING.md) and [security](https://github.com/dgesteves/signoff-ui/blob/main/SECURITY.md). ## How it compares signoff-ui does one part of an agent product in depth: the review and approval step. [assistant-ui](https://github.com/assistant-ui/assistant-ui) and [AI Elements](https://github.com/vercel/ai-elements) cover the whole chat, and they do several things better: - **assistant-ui** has a chat runtime and thread, adapters for the AI SDK, LangGraph, AG-UI and more, and an Elements collection far wider than this kit. Its task card shows nested sub-agents, which this kit renders flat. - **AI Elements** covers the most of the message surface in shadcn style (conversation, prompt input, artifacts, a `Context` usage indicator and more), maintained by Vercel. Its components are written against AI SDK 6. Where signoff-ui goes further: review from file contents across several files, with the patched files, the rejected hunks and line comments as the result; approvals once, for the session or always, by tool and argument pattern, with the arguments editable first and the same rules for AI SDK 7's `toolApproval`; approvals that tell a person's decision from a policy's; tool durations that leave out time spent waiting for a person; and a stylesheet for Tailwind v3 or no Tailwind. Use either library for the chat, and these components for the sign-off. [The full comparison](https://signoff-ui.vercel.app/docs/comparison). ## License [MIT](https://github.com/dgesteves/signoff-ui/blob/main/LICENSE) © Diogo Esteves ## Docs ## Introduction > What signoff-ui is: reviewing what an agent changed and approving what it may do, with the result going back to the agent. Source: https://signoff-ui.vercel.app/docs signoff-ui is a set of React components for the step where a person signs off on what an agent does: reviewing the edits it proposes, across files and hunk by hunk, and approving or denying the calls that need a person. The result goes back to the agent as something it can act on: each file with only the accepted hunks applied, the hunks rejected and the reviewer's comments on lines, or an approval with the reason for a denial. The components render the message parts the AI SDK's `useChat` already gives you, AG-UI agents and Agent Client Protocol (ACP) turns through adapters, and assistant-ui's tool calls through a binding of their own, inside the chat UI you already have. It works with any model and costs nothing extra. The kit calls no model, needs no API key and makes no network requests of its own. Your backend picks the model and holds the keys: OpenAI GPT, Anthropic Claude, Google Gemini, xAI Grok, Mistral or a local model through any AI SDK provider, or an agent on any AG-UI framework (LangGraph, CrewAI, Mastra and the rest). Switching providers is [one line in your route](https://signoff-ui.vercel.app/docs/getting-started#choosing-a-model); the components don't change. ### Why it exists Coding and editing agents propose changes across several files and ask to run commands that can break things, and someone has to sign off on both. The questions a person has at that point are specific: - **What did it change, and do I accept it?** Hunk by hunk or file by file, not all or nothing, with comments on lines, from the keyboard, with the result fed back to the agent. - **Should I let it do that?** What exactly will run, how risky it is, and a fast way to say yes, no, or no with a reason. And when a rule decided on its own, that it was a rule and not a person. - **What is it doing, and what did that cost?** Which calls ran, how long each took, which failed and why, and what the run cost in tokens and time. The chat kits most teams use stop short of the first two: assistant-ui's reviewable diff takes one file's hunks, already split, and its apply returns nothing, and AI Elements has no diff and a confirm/deny you wire up yourself. signoff-ui does those two in depth, and has the rest as supporting pieces. [How it compares](https://signoff-ui.vercel.app/docs/comparison) has the details, including what those kits do better. ### What's in it For review and approval: | Export | What it renders | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `DiffReview` | Unified or split review of edits across files, from their contents or a patch, with word-level highlights; returns each file with only the accepted hunks applied, the rejected hunks and comments on lines. Your own controls go in its header (`actions`) | | `useDiffReview` | The same review without its markup: its state, keyboard and result, for your own design system | | `ApprovalCard`, `ToolApprovalCard` | Human-in-the-loop approval with a preview of what will run, a risk level when given, deny with a reason, and automatic decisions shown as such | | `useApprovalPolicy` | Approval rules: once, for the session or always, by tool and argument pattern, with storage and an audit trail. `getRules` sends them with each request, and `parseApprovalRules` from `signoff-ui/core` checks them in the route | Supporting pieces: | Export | What it renders | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AgentMessage` | A whole assistant message: reasoning, streaming markdown, consecutive tool calls as one timeline, approvals inline, files and sources | | `ToolCallTimeline` | Every tool call with its state, a measured duration and a waterfall, and sub-agents nested under the call that started them; errors inline, input and output on demand | | `RunMeter` | Tokens, estimated cost, time to first token, active time and prompt-cache hit rate | | `AgentStatus` | The run's state in one pill, announced to screen readers | | `Sources` | Citations as chips or cards, linked from `[n]` markers in the text | | `useAgUiAgent` | An AG-UI agent's run as AI SDK messages, status, usage and approvals | | `signoff-ui/acp` | An ACP prompt turn as a message, its permission requests as approval cards and its edits as `DiffReview` files | | `signoffTools` | assistant-ui tool UIs, from `signoff-ui/assistant-ui`: a `DiffReview` for review tools and the approval card at an approval gate | `Markdown`, `Reasoning` and `JsonView` are exported on their own too. `AgentMessage` and `Reasoning` can render text with your own Markdown renderer (`renderMarkdown`), and `signoff-ui/custom-markdown` exports them without react-markdown. There are also hooks such as `useRunTiming` and pure helpers such as `deriveAgentState`, `addUsage`, `estimateCost` and `applyHunks`. The helpers are also in `signoff-ui/core`, which has no React, for Route Handlers and Server Components. Every word the components show or announce is a label: `SignoffLabelsProvider` takes a translation of them all, or a few, and [Labels and translations](https://signoff-ui.vercel.app/docs/labels) has a complete one in Portuguese. ### How it reads a run Each AI SDK 6 or 7 message part has a place: | AI SDK 6 / 7 | Rendered as | | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `text` (`state: 'streaming' \| 'done'`) | `Markdown`, repaired while streaming, with a caret and `[n]` citation links | | `reasoning` | `Reasoning`: open while streaming, then "Thought for 1.7s" | | `tool-*` / `dynamic-tool`, `input-streaming` | Timeline row "Preparing", partial input visible | | `input-available` | "Running", live duration ("Stopped" once the run is no longer `active`) | | `approval-requested` (`approval.id`, `approval.requestReason`) | "Needs approval" and a `ToolApprovalCard` that calls `addToolApprovalResponse({ id, approved, reason })` | | `approval-responded` | "Running" if approved, "Denied" if not | | `output-available` (`preliminary: true`) | "Running", partial output | | `output-available` | Done, output in a JSON view or your `renderOutput` | | `output-error` (`errorText`) | "Failed", the error inline, announced | | `output-denied` | "Denied", with the user's reason, or "Blocked by policy" when the approval was automatic | | client-side tool, `input-available` | Whatever `renderTool` returns, such as a `DiffReview` that calls `addToolOutput` | | `source-url` / `source-document` | `Sources` | | `file` | An image preview (`data:` and `blob:` URLs, or as `allowedImageHosts` allows) or a file link | | `data-*` | `renderData` | | `message.metadata.usage` (`LanguageModelUsage`) | `RunMeter` | | `useChat().status` | `AgentStatus` through `deriveAgentState`, timing through `useRunTiming` | The kit depends on `ai` for types only. Part detection (`isToolPart`, `getToolPartName`) mirrors the SDK's runtime helpers, and a test checks they agree. The [playground](https://signoff-ui.vercel.app/) doesn't fake any of this. Its scripted agent is a `ChatTransport` that streams real `UIMessageChunk`s through `useChat`, so the SDK assembles the parts, merges usage metadata, and drives the approval and client-tool round trips as it would against a server. ### Design decisions - **Review returns code.** `DiffReview` does not stop at a decision map: `onSubmit` includes each file with only the accepted hunks applied (`applyHunks`), unreviewed hunks left out, plus the rejected hunks and the comments with their lines. That makes it a client-side tool whose result goes straight back to the model, through `reviewToolOutput`. - **Automatic is not a person.** Decisions a `toolApproval` rule makes on its own never prompt or take focus, and read "Auto-approved" or "Blocked by policy", so whoever reads the run can tell who decided. - **Shortcuts scoped to focus.** Global single-key shortcuts are an accessibility problem and fight with text inputs; scoping them to the component avoids both, and keeps them within WCAG 2.1.4. Critical approvals need a second press. - **A run, not a message.** The unit of UI is the run: many tool calls, a pause for permission, a review, a summary. Consecutive tool parts become one timeline instead of a stack of cards, and status and telemetry live outside the message. - **Timings are measured, not invented.** Message parts carry no timestamps, so `useToolTimings` records state changes on the client. Execution time starts when a call is approved, not when it was proposed, so waiting on a person never reads as a slow tool. Calls already settled when first seen (restored history) get no duration rather than a fake zero. You can pass server-measured `timings` instead. - **Errors inline, details on demand.** A failed call shows its message under the row and stays collapsed. Auto-expanding failures (`expandErrors`) pushed everything else off screen. - **Cyan and magenta diffs, not green and red.** They stay distinguishable under the common red-green color-vision deficiencies. The + and − glyphs and screen-reader prefixes carry the meaning regardless of color. - **Cache hit rate over tokens per second.** Throughput looked precise but mixed tool time into generation speed. For agents, cached input is the bigger cost lever, so that is what the meter shows. - **Hydration-safe clocks.** Live durations and waterfall widths render after hydration, so server and client markup always match. Nothing reads the clock while rendering on the server, so pages that render the components prerender under Next.js `cacheComponents`. - **Type-only dependency on `ai`.** No SDK runtime in the build, while props stay typed to SDK parts. [Performance and limits](https://signoff-ui.vercel.app/docs/limits) has what each component costs. - **Styles that stay in their lane.** The npm build ships a precompiled stylesheet for apps without Tailwind v4: unlayered, every rule scoped to the components' own elements, and none of your theme's variables set, so your content inside a component keeps your styles. CI checks it in Chrome next to a global reset, inside a Tailwind v3 build and inside a Tailwind v4 app with its own theme. - **Two ways to install, one source.** The registry is generated from the same files by `scripts/registry.mjs`, which computes each item's file closure from its imports, so items install by URL or from GitHub without cross-item dependencies. CI fails if `registry.json` drifts. Both keep `'use client'` per module (the build emits one module per source file and checks the directives), so Server Components can render the components and call the pure helpers. ### Use these docs from your agent These docs are an MCP server at `https://signoff-ui.vercel.app/api/mcp`. Add it to Cursor, VS Code, Claude Code or Claude, and your agent can search and read them while it works. Its `search`, `fetch` and `list_pages` tools call no model, so they need no key. [![Cursor: Add server](https://img.shields.io/badge/Cursor-Add_server-181c22?style=flat-square&logo=cursor&logoColor=white)](https://cursor.com/en/install-mcp?name=signoff-ui&config=eyJ1cmwiOiJodHRwczovL3NpZ25vZmYtdWkudmVyY2VsLmFwcC9hcGkvbWNwIn0%3D) [![VS Code: Add server](https://img.shields.io/badge/VS_Code-Add_server-007ACC?style=flat-square)](https://insiders.vscode.dev/redirect/mcp/install?name=signoff-ui&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fsignoff-ui.vercel.app%2Fapi%2Fmcp%22%7D) [![Claude: Add connector](https://img.shields.io/badge/Claude-Add_connector-b4532a?style=flat-square&logo=claude&logoColor=white)](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=signoff-ui&connectorUrl=https%3A%2F%2Fsignoff-ui.vercel.app%2Fapi%2Fmcp) In Claude Code: ```bash claude mcp add --transport http signoff-ui https://signoff-ui.vercel.app/api/mcp ``` To read them instead, [`/llms.txt`](https://signoff-ui.vercel.app/llms.txt) lists every page and [`/llms-full.txt`](https://signoff-ui.vercel.app/llms-full.txt) has them all in one file. Every page is also Markdown at its own address plus `.md`. ### Next steps - [Getting started](https://signoff-ui.vercel.app/docs/getting-started): install, styles, a first run in Next.js with a review and an approval, AI SDK 6 or 7, and theming. - [Inside your chat UI](https://signoff-ui.vercel.app/docs/chat-ui): with assistant-ui, AI Elements or your own components. - [AG-UI agents](https://signoff-ui.vercel.app/docs/ag-ui): LangGraph, CrewAI, Mastra and other AG-UI agents through `useAgUiAgent`. - [Agent Client Protocol](https://signoff-ui.vercel.app/docs/acp): an ACP prompt turn, its permission requests and its edits, through `signoff-ui/acp`. - [useApprovalPolicy](https://signoff-ui.vercel.app/docs/components/use-approval-policy): approval rules on the page and in your route. - [useDiffReview](https://signoff-ui.vercel.app/docs/components/use-diff-review): the review in your own markup. - [Components](https://signoff-ui.vercel.app/components): each component in isolation, with install snippets. ## Getting started > Install from npm or the shadcn registry, add the styles, and build a Next.js run with a diff review and an approval. AI SDK 6 or 7, React 18 or 19. Source: https://signoff-ui.vercel.app/docs/getting-started ### Requirements - **React** 18.2 or later, or 19 (`react@^18.2.0 || ^19.0.0`). CI runs the test suite on both. - **AI SDK** 6 or 7 (`ai@^6.0.0 || ^7.0.102`). The kit imports its types only, so it adds no SDK code to your bundle. - **A React app.** Nothing in the kit is tied to a framework. The walkthrough below is a Next.js 16 App Router app, which CI builds and runs in Chrome. - **ES modules.** The package is ESM only, with no CommonJS build: load it with `import`, as bundlers and Next.js do. CI builds and tests it on Node 24. For an AG-UI agent (LangGraph, CrewAI, Mastra and the rest) instead of the AI SDK, read this page for the install and the styles, then go to [AG-UI agents](https://signoff-ui.vercel.app/docs/ag-ui). ### Install The same components come two ways: as an npm package, or as source files the shadcn CLI copies into your app. #### From npm ```bash npm i signoff-ui ai ``` `ai` is a peer dependency, for the message part types. `@assistant-ui/react` is an optional one, for `signoff-ui/assistant-ui` ([assistant-ui](https://signoff-ui.vercel.app/docs/chat-ui#assistant-ui)). The package ships ES modules with `'use client'` kept per file, a precompiled stylesheet, and `signoff-ui/core` for server code. #### With the shadcn CLI ```bash npx shadcn@latest add https://signoff-ui.vercel.app/r/agent-message.json ``` Each item brings the component, the helpers it imports, its npm dependencies and the theme tokens, which `shadcn add` writes into your CSS. Files land in `components/signoff-ui/`. The items are `agent-message`, `tool-call-timeline`, `approval-card`, `use-approval-policy`, `diff-review`, `use-diff-review`, `run-meter`, `agent-status`, `sources`, `markdown`, `reasoning`, `ag-ui` and `assistant-ui`, each at `https://signoff-ui.vercel.app/r/.json`. Installing a second item skips the shared files it already added. They also install straight from the repository (`npx shadcn@latest add dgesteves/signoff-ui/agent-message`). To add them by name instead (`npx shadcn@latest add @signoff-ui/agent-message`), add the namespace to your `components.json` once. The namespace needs this `registries` entry: the shadcn directory doesn't list it yet. ```json title="components.json" { "registries": { "@signoff-ui": "https://signoff-ui.vercel.app/r/{name}.json" } } ``` Component and hook files start with `'use client'`, so they work when rendered from Server Components; the helpers in `lib/` (`diff.ts`, `usage.ts`, `ai.ts`, `format.ts`) do not, so the server can call them. The files pass a new Next.js app's ESLint config with no warnings, which CI checks. **From a coding agent.** The [shadcn MCP server](https://ui.shadcn.com/docs/mcp) lets Claude Code, Cursor, VS Code or Codex browse and install registry items. Set it up with `npx shadcn@latest mcp init --client claude` (or `cursor`, `vscode`), make sure `components.json` lists the registry as above, and ask for it by name, for example "add the signoff-ui diff review to the chat page". Agents can also read these docs from [`/llms.txt`](https://signoff-ui.vercel.app/llms.txt). #### Which one | Difference | npm | shadcn | | -------------------- | --------------------------------------- | ------------------------------------------- | | Updates | `npm update` | Run `shadcn add` again and merge your edits | | Styles | Tailwind v4, Tailwind v3 or no Tailwind | Tailwind v4, like any current shadcn/ui app | | Changing a component | `className`, tokens and `data-*` hooks | Edit the source | | Imports | `signoff-ui` | `@/components/signoff-ui/...` | Start with npm if you want updates and don't plan to fork the components. Use the registry if you'd rather own the code, or your app already follows the shadcn/ui conventions. ### Add the styles > [!NOTE] > Once per app. Skip this if you installed with the shadcn CLI: it already wrote the tokens into your CSS. ```css title="app/globals.css" tab="Tailwind v4" @import 'tailwindcss'; @import 'signoff-ui/tailwind.css'; ``` ```ts title="app/layout.tsx" tab="Tailwind v3 or no Tailwind" import 'signoff-ui/styles.css'; ``` **Tailwind v4.** Import the kit's entry after Tailwind. It adds the tokens, maps them into your theme and points `@source` at the components, so your build generates only the utilities they use. **Tailwind v3, or no Tailwind.** Import the precompiled stylesheet after your global CSS. It has only the utilities the kit uses, and no global reset. #### What styles.css leaves alone - **Your app.** Every rule is scoped to the components' own elements. - **Your reset.** It has no cascade layers, so Tailwind v3 builds accept it, and a reset such as `* { padding: 0 }` can't strip the components' spacing. If your app orders its CSS with layers, use `styles.layered.css`: the same rules in `@layer theme, base, utilities`. - **Your theme's variables.** It sets no `--spacing`, `--text-*` or `--font-*`. Its utilities have the kit's values written in and read only the `--signoff-*` tokens. - **Your content inside the components.** What `renderTool`, `renderData` and `renderOutput` return, a custom approval `preview` and `DiffReview`'s `actions` sit in a `data-signoff-slot` element the kit's rules don't reach. Your CSS styles it, and it inherits the component's text color and font. #### Dark mode Light is the default. Add `class="dark"` (or `data-theme="dark"`) to `` or any ancestor for the dark palette. To follow the OS setting instead, also import `signoff-ui/theme.auto.css`; `class="light"` on `` still forces light. ### Render a run A Next.js App Router app with AI SDK 7: a client component, a page and a route. The agent reads a file, proposes an edit across two files for you to review, and asks before it runs a command. [The quickstart, running](https://github.com/dgesteves/signoff-ui/tree/main/examples/nextjs-minimal) is this section as an app against a scripted model, so it needs no API key, and CI runs it in Chrome. ```bash npm i signoff-ui ai @ai-sdk/react @ai-sdk/openai zod ``` The client renders the last assistant message, its status and its cost, with a minimal composer. `renderTool` puts a `DiffReview` where the agent proposes its edit, and the review's result goes back as the tool's output, shaped for the model by `reviewToolOutput`. The wrapper paints the kit's own background and text colors (`bg-signoff-bg text-signoff-fg`), so the run reads well whatever your page's colors are. Without Tailwind, use the CSS [after the walkthrough](https://signoff-ui.vercel.app/docs/getting-started#without-tailwind) instead, which covers the layout and the composer too. ```tsx title="app/agent-run.tsx" 'use client'; import { useChat } from '@ai-sdk/react'; import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls, type LanguageModelUsage, type UIMessage, } from 'ai'; import { useState } from 'react'; import { AgentMessage, AgentStatus, DiffReview, RunMeter, deriveAgentState, getToolPartName, reviewToolOutput, useRunTiming, type FileChange, } from 'signoff-ui'; type Message = UIMessage<{ usage?: LanguageModelUsage }>; export function AgentRun() { const { messages, status, sendMessage, addToolOutput, addToolApprovalResponse } = useChat({ // Continue the run once the review is in and every approval has an answer. sendAutomaticallyWhen: (chat) => lastAssistantMessageIsCompleteWithToolCalls(chat) || lastAssistantMessageIsCompleteWithApprovalResponses(chat), }); const [input, setInput] = useState(''); const last = messages.findLast((m) => m.role === 'assistant'); // review_changes waits on a person, like an approval. const { state, detail } = deriveAgentState({ status, message: last, pendingClientTools: ['review_changes'] }); // A run starts with each message you send and spans its review and approval round trips. const timing = useRunTiming(status, messages); return ( // The kit's own background and text colors, so it reads well on any page. Add `dark` for the dark theme.
{last && ( getToolPartName(part) === 'review_changes' && part.state === 'input-available' ? ( addToolOutput({ tool: 'review_changes', toolCallId: part.toolCallId, output: reviewToolOutput(review), }) } /> ) : undefined } /> )}
{ event.preventDefault(); if (!input.trim()) return; void sendMessage({ text: input }); setInput(''); }} > setInput(event.target.value)} className="border-signoff-border bg-signoff-surface flex-1 rounded-lg border px-3 py-2 text-sm" />
); } ``` Render it from a page. With `cacheComponents`, on by default in new Next.js 16 apps, `useChat` needs a `` boundary: it creates ids with `Math.random()`, which Next.js doesn't allow in prerendered output. So the run renders in the browser, after hydration, and the page's server HTML has only an empty placeholder for it. ```tsx title="app/page.tsx" import { Suspense } from 'react'; import { AgentRun } from './agent-run'; export default function Page() { // With cacheComponents (on in new Next.js 16 apps), useChat needs a Suspense boundary: // it creates ids with Math.random(), which Next.js does not allow in prerendered output. return ( ); } ``` On the server, give the model its tools: `review_changes` has no `execute`, so the page answers it; `run_command` needs approval first. Send usage as message metadata: ```ts title="app/api/chat/route.ts" import { openai } from '@ai-sdk/openai'; import { addUsage } from 'signoff-ui/core'; import { convertToModelMessages, stepCountIs, streamText, tool, type LanguageModelUsage, type UIMessage } from 'ai'; import { z } from 'zod'; type Message = UIMessage<{ usage?: LanguageModelUsage }>; // A pretend repository, so nothing touches your disk or shell. const FILES: Record = { 'app/api/chat/route.ts': 'export async function POST(req: Request) {\n // ...\n}\n', }; const tools = { read_file: tool({ description: 'Read a file from the repository.', inputSchema: z.object({ path: z.string() }), execute: async ({ path }) => { const content = FILES[path]; if (content === undefined) throw new Error(`ENOENT: no such file or directory, open '${path}'`); return { path, content }; }, }), // No execute: the page answers it with a DiffReview, and the result holds each file as applied. review_changes: tool({ description: 'Propose edits to files. The user reviews them hunk by hunk before anything is written.', inputSchema: z.object({ files: z.array(z.object({ path: z.string(), oldContent: z.string().optional(), newContent: z.string() })), }), }), run_command: tool({ description: 'Run a shell command in the repository.', inputSchema: z.object({ command: z.string() }), execute: async ({ command }) => ({ exitCode: 0, stdout: `(simulated) ${command}` }), }), }; export async function POST(req: Request) { const { messages }: { messages: Message[] } = await req.json(); const result = streamText({ model: openai('gpt-5.4-mini'), messages: await convertToModelMessages(messages, { tools }), tools, // Ask before running commands. The reason shows on the approval card. toolApproval: { run_command: { type: 'user-approval', reason: 'Runs a shell command in the repository.' } }, // Keep going after tool calls: the AI SDK stops after the first step by default. stopWhen: stepCountIs(10), }); // After the review and the approval, the run continues the same message in a new request whose // totalUsage starts from zero, and useChat replaces metadata.usage. Add the usage the message already has // (it comes back from the client, so it is fine for display but not for billing). const last = messages.at(-1); const previous = last?.role === 'assistant' ? last.metadata?.usage : undefined; return result.toUIMessageStreamResponse({ originalMessages: messages, sendSources: true, messageMetadata: ({ part }) => part.type === 'finish' ? { usage: addUsage(previous, part.totalUsage) } : undefined, // Failed tool calls show this text. The default hides every error as "An error occurred.". onError: (error) => (error instanceof Error ? error.message : 'An error occurred.'), }); } ``` What each part is for: - **`review_changes` without `execute`.** The AI SDK ends the step at the call and leaves it to the client. `DiffReview`'s `onSubmit` gets each file with only the accepted hunks applied (`content`) and its decision, the rejected hunks with their lines, and the comments with theirs. `reviewToolOutput` turns that into one summary sentence and those lists, and `addToolOutput` sends it to the model as the tool's result. - **`sendAutomaticallyWhen`.** Continues the run once the review is in (`lastAssistantMessageIsCompleteWithToolCalls`) and once every approval has an answer (`lastAssistantMessageIsCompleteWithApprovalResponses`). - **`pendingClientTools`.** `useChat` is `ready` while the review waits on you; naming the tool makes `deriveAgentState` read it as waiting, not done. - **`stopWhen`.** The AI SDK stops after one step by default, so without it the run ends at the first tool call and never reaches the review. - **`toolApproval`.** The `reason` shows on the approval card, as `approval.requestReason`. - **`addUsage`.** Without it, the meter would show only the last request of a run that paused for the review and the approval. - **`onError`.** A tool that throws becomes an `output-error` part, and its `errorText` goes through `onError`. By default that hides every message behind "An error occurred.", so the timeline would show that instead of `ENOENT`. In production, pass through the errors you're happy for users to read and keep the rest generic. #### Without Tailwind The classes on the wrapper, the form, the input and the button are Tailwind utilities. `styles.css` styles only the kit's own components, so without Tailwind these elements get the browser's defaults. Give the wrapper `className="agent-run"` and the form `className="composer"` instead, and add this CSS: ```css title="app/globals.css" .agent-run { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 42rem; margin: 0 auto; padding: 1.5rem; background: var(--signoff-bg); color: var(--signoff-fg); } .composer { display: flex; gap: 0.5rem; } .composer input, .composer button { border-radius: 0.5rem; font: inherit; font-size: 0.875rem; } .composer input { flex: 1; padding: 0.5rem 0.75rem; border: 1px solid var(--signoff-border); background: var(--signoff-surface); color: inherit; } .composer button { padding: 0 1rem; border: 0; background: var(--signoff-accent); color: var(--signoff-on-accent); font-weight: 500; } .composer button:disabled { opacity: 0.5; } ``` #### Imports with shadcn With the shadcn CLI, add the `agent-message`, `agent-status`, `diff-review` and `run-meter` items. The walkthrough's imports from `signoff-ui` then come from the files they add: ```tsx import { AgentMessage } from '@/components/signoff-ui/agent-message'; import { AgentStatus } from '@/components/signoff-ui/agent-status'; import { DiffReview } from '@/components/signoff-ui/diff-review'; import { RunMeter } from '@/components/signoff-ui/run-meter'; import { deriveAgentState, getToolPartName } from '@/components/signoff-ui/lib/ai'; import type { FileChange } from '@/components/signoff-ui/lib/diff'; import { useRunTiming } from '@/components/signoff-ui/lib/hooks'; import { reviewToolOutput } from '@/components/signoff-ui/lib/review'; ``` In the route, `addUsage` comes from `@/components/signoff-ui/lib/usage`, where npm has `signoff-ui/core`. ### Choosing a model The kit renders what the AI SDK streams, whichever model produced it, so the model is one line in the route. Install the provider's package, put its API key in your environment, and change `model`: ```ts title="app/api/chat/route.ts" import { anthropic } from '@ai-sdk/anthropic'; const result = streamText({ model: anthropic('claude-sonnet-5-5'), // was openai('gpt-5.4-mini') messages: await convertToModelMessages(messages, { tools }), tools, toolApproval: { run_command: { type: 'user-approval', reason: 'Runs a shell command in the repository.' } }, stopWhen: stepCountIs(10), }); ``` - **OpenAI**: `openai('gpt-5.4-mini')` from `@ai-sdk/openai`, with `OPENAI_API_KEY` - **Anthropic**: `anthropic('claude-sonnet-5-5')` from `@ai-sdk/anthropic`, with `ANTHROPIC_API_KEY` - **Google**: `google('gemini-3.5-flash')` from `@ai-sdk/google`, with `GOOGLE_GENERATIVE_AI_API_KEY` - **xAI**: `xai('grok-4.7')` from `@ai-sdk/xai`, with `XAI_API_KEY` - **Mistral**: `mistral('mistral-medium-latest')` from `@ai-sdk/mistral`, with `MISTRAL_API_KEY` - **A local model** (Ollama, LM Studio): `ollama('qwen3')` from `@ai-sdk/openai-compatible`, with no key A local model runs on your machine, with no key and nothing to pay per token. Point the OpenAI-compatible provider at the local server (LM Studio's is `http://localhost:1234/v1`) and pick a model you've pulled that can call tools: ```ts import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; const ollama = createOpenAICompatible({ name: 'ollama', baseURL: 'http://localhost:11434/v1' }); ``` The model ids are from each package's own model-id types, for the AI SDK 7 versions; any id the provider accepts works. Other providers are on the [AI SDK providers page](https://ai-sdk.dev/providers/ai-sdk-providers), including Amazon Bedrock, Azure OpenAI, Groq, DeepSeek and the Vercel AI Gateway. For tool approvals, pick a model that's good at tool calling. Agents on LangGraph, CrewAI, Mastra or another AG-UI framework pick their model in the framework, and the components render them the same way: see [AG-UI agents](https://signoff-ui.vercel.app/docs/ag-ui). ### API keys and costs **Do I need an API key, and does it work with my ChatGPT or Claude subscription?** The kit itself needs no key: it's React components, and it makes no requests of its own. The agent you build calls your provider with your API key, from your server, like any AI SDK app. Chat subscriptions such as ChatGPT Plus or Claude Pro don't include API access; API usage is billed separately by each provider, or runs free on a local model. **Does the kit cost anything?** No. It's MIT-licensed, with no service, account or usage fees. What a run costs is what your provider charges for it, which [`RunMeter`](https://signoff-ui.vercel.app/docs/components/run-meter) estimates from the rates you give it. **Does the playground use a key?** The public playground runs a scripted agent, with no model and no key. Its optional live mode runs the same tools against OpenAI, with the `OPENAI_API_KEY` (and `OPENAI_MODEL`) of whoever runs the playground; it's off on the public demo. ### AI SDK 6 or 7 The components take the same message parts from both. The differences are on the server: - Install `ai@^6 @ai-sdk/react@^3 @ai-sdk/openai@^3` for AI SDK 6, and the AI SDK 6 versions of any other providers (`@ai-sdk/anthropic@^3`, `@ai-sdk/google@^3`, `@ai-sdk/xai@^3`, `@ai-sdk/openai-compatible@^2` and so on). - `toolApproval` is an AI SDK 7 option. On 6, drop it and mark the tool with `needsApproval: true`: ```ts run_command: tool({ description: 'Run a shell command in the repository.', inputSchema: z.object({ command: z.string() }), needsApproval: true, execute: async ({ command }) => ({ exitCode: 0, stdout: `(simulated) ${command}` }), }), ``` The rest of the walkthrough is the same. CI typechecks the library and runs its tests against the latest AI SDK 6 and the oldest supported 6.0.0, as well as 7. ### Next.js **Server Components.** Components and hooks are client modules with their own `'use client'` directive, so a Server Component can render any of them. The pure helpers aren't, so Server Components and Route Handlers can call them. Import those from `signoff-ui/core`, which has no React: ```ts title="app/api/review/route.ts" import { applyHunks, parseFileChange } from 'signoff-ui/core'; ``` **`cacheComponents`.** Nothing in the kit reads the clock or a random number while rendering on the server, so pages that render it prerender with `cacheComponents` on. Live durations render after hydration, so server and client markup match. `useChat` is the exception, as above: wrap the component that calls it in ``. **Versions.** CI builds and runs the walkthrough on Next.js 16. Next.js 14 and 15 run React 18.2 and 19, which the kit supports, and the Pages Router works too, since the components are ordinary React; those aren't built in CI. ### React 18 or 19 Both work with the same code. CI installs React 18 and runs the library's test suite, typecheck included, on it. On React 18 there's nothing to change. ### Theming Every color, radius and font is a CSS variable. Override them on `:root`, on `.dark`, or on any subtree; `.light` and `.dark` switch a subtree's palette too: ```css title="app/globals.css" :root { --signoff-accent: #7c3aed; /* fills: buttons, running state, focus */ --signoff-accent-fg: #6d28d9; /* accent text */ --signoff-hot: #c2410c; /* attention: approvals, errors, deletions */ --signoff-radius: 6px; --signoff-font-sans: var(--font-inter); } ``` | Tokens | Purpose | | --------------------------------------------------------------------------------------- | -------------------------------- | | `--signoff-bg`, `--signoff-surface`, `--signoff-surface-2`, `--signoff-border(-strong)` | Surfaces and lines | | `--signoff-fg`, `--signoff-fg-muted`, `--signoff-fg-subtle` | Text, all AA on every surface | | `--signoff-accent`, `--signoff-accent-fg`, `--signoff-on-accent`, `--signoff-ring` | Activity, primary actions, focus | | `--signoff-hot`, `--signoff-hot-fg`, `--signoff-on-hot`, `--signoff-warn(-fg)` | Attention, risk, errors | | `--signoff-add-bg`, `--signoff-add-strong`, `--signoff-del-bg`, `--signoff-del-strong` | Diff lines and word highlights | | `--signoff-chart-input`, `--signoff-chart-output` | The token bar | | `--signoff-code-*` | Syntax tinting | | `--signoff-radius`, `--signoff-font-sans`, `--signoff-font-mono` | Shape and type | The fonts are your app's `--font-sans` and `--font-mono` when it defines them, as shadcn/ui and Tailwind v4 apps do, then Geist when `geist` is loaded, then the system's. Messages, timelines and sources have no background of their own: they sit on your page and take their text color from the kit's palette. If the page's background doesn't match the kit's theme (a dark page with the light palette, say), give their container `bg-signoff-bg text-signoff-fg`, as the walkthrough does. Cards, the status pill and the meter paint their own surfaces. **In a shadcn/ui app**, point the kit at your tokens so it looks native. Add this after the kit's tokens; one block covers both themes, since your tokens switch with `.dark`: ```css title="app/globals.css" :root, .dark { --signoff-bg: var(--background); --signoff-surface: var(--card); --signoff-surface-2: var(--muted); --signoff-border: var(--border); --signoff-border-strong: var(--input); --signoff-fg: var(--foreground); --signoff-fg-muted: var(--muted-foreground); --signoff-fg-subtle: var(--muted-foreground); --signoff-accent: var(--primary); --signoff-accent-fg: var(--primary); --signoff-on-accent: var(--primary-foreground); --signoff-ring: var(--ring); --signoff-hot: var(--destructive); --signoff-hot-fg: var(--destructive); --signoff-radius: var(--radius); } ``` Diff and chart colors keep the kit's cyan and magenta, which your palette may not tell apart as well. The kit's contrast test covers its own palette, so check text contrast with yours. **Styling hooks.** Components accept `className`, merged with `tailwind-merge`, and expose `data-signoff` on their root, `data-slot` (such as `data-slot="signoff-approval-card"`) and state attributes (`data-phase`, `data-state`, `data-decision`, `data-risk`) to target from your CSS: ```css [data-slot='signoff-approval-card'][data-risk='critical'] { outline: 2px solid var(--signoff-hot); } ``` ### Next steps - [AG-UI agents](https://signoff-ui.vercel.app/docs/ag-ui): render LangGraph, CrewAI or Mastra agents with the same components. - [Components](https://signoff-ui.vercel.app/components): each component in isolation, with its install command and usage. - [The playground](https://signoff-ui.vercel.app/): a full run with approvals and diff review, driven from the keyboard. ## Inside your chat UI > Use the review and approval components in assistant-ui, AI Elements or your own chat, with the code for each. Source: https://signoff-ui.vercel.app/docs/chat-ui The components take AI SDK message parts and plain props. There is no runtime or store to adopt, so they render inside whatever draws your chat: your own components, AI Elements or an assistant-ui thread. Their names (`data-signoff`, `--signoff-*`, `signoff-*` slots) don't collide with assistant-ui's or shadcn/ui's, and `styles.css` styles only the components' own elements, never your page or the content you render inside them. The examples below use a `review_changes` tool with no `execute`, which the page answers with a `DiffReview`, and a `run_command` tool that needs approval, as in [Getting started](https://signoff-ui.vercel.app/docs/getting-started#render-a-run). ### Your own chat Render `AgentMessage` for the assistant's message, with `onToolApproval` and `renderTool`, as the [quickstart](https://signoff-ui.vercel.app/docs/getting-started#render-a-run) does. Or keep your own message rendering and use the kit only for the parts a person has to sign off: ```tsx title="components/message-parts.tsx" 'use client'; import type { UIMessage } from 'ai'; import { DiffReview, ToolApprovalCard, getToolPartName, isToolPart, type DiffReviewResult, type FileChange, type ToolApprovalResponse, } from 'signoff-ui'; export function MessageParts({ message, onApproval, onReview, }: { message: UIMessage; /** `useChat().addToolApprovalResponse` */ onApproval: (response: ToolApprovalResponse) => void; /** Calls `useChat().addToolOutput` with `reviewToolOutput(review)` as the tool's output. */ onReview: (toolCallId: string, review: DiffReviewResult) => void; }) { return message.parts.map((part, index) => { if (part.type === 'text') return

{part.text}

; if (!isToolPart(part)) return null; if (getToolPartName(part) === 'review_changes' && part.state === 'input-available') { const { files } = part.input as { files: FileChange[] }; return ( onReview(part.toolCallId, review)} /> ); } // Renders nothing for calls that need no approval. return ; }); } ``` ### AI Elements AI Elements maps each message part to a component: `MessageResponse` for text, `Tool` for a tool call, `Confirmation` for its approval. Keep that, and for the calls a person signs off, render the kit's component in the same map: ```tsx if (isToolPart(part) && getToolPartName(part) === 'review_changes' && part.state === 'input-available') { return ; } if (isToolPart(part) && part.approval) { return ; } // …and AI Elements' Tool for the other calls ``` AI Elements apps run Tailwind v4, so import `signoff-ui/tailwind.css` after Tailwind, and point the kit's tokens at your shadcn/ui ones as [Theming](https://signoff-ui.vercel.app/docs/getting-started#theming) shows, so the cards match the rest of the chat. ### assistant-ui `signoff-ui/assistant-ui` renders the kit for assistant-ui's tool calls, with whatever runtime draws the thread. `signoffTools()` goes in `MessagePrimitive.Parts`' `components.tools`. A call to one of its `review` tools gets a `DiffReview`, and the review goes back as the call's result through `addResult`. Any other call at an approval gate gets the approval card, answered through `respondToApproval`. `SignoffToolsProvider`, optional, passes approval rules from `useApprovalPolicy`, risk levels by tool, and props for the reviews and the cards. ```bash npm i signoff-ui @assistant-ui/react @assistant-ui/react-ai-sdk ``` `@assistant-ui/react` is an optional peer dependency of signoff-ui, needed only for this entry, which uses its types and none of its code. `@assistant-ui/react-ai-sdk` is for the AI SDK runtime in the thread below. With the shadcn CLI, the binding is the `assistant-ui` item: `npx shadcn@latest add https://signoff-ui.vercel.app/r/assistant-ui.json`. With the AI SDK runtime, against the `review_changes` and `run_command` tools of [Getting started](https://signoff-ui.vercel.app/docs/getting-started#render-a-run): ```tsx title="components/thread.tsx" 'use client'; import { AssistantRuntimeProvider, ComposerPrimitive, MessagePrimitive, ThreadPrimitive } from '@assistant-ui/react'; import { useChatRuntime } from '@assistant-ui/react-ai-sdk'; import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from 'ai'; import { useApprovalPolicy } from 'signoff-ui'; import { SignoffToolsProvider, signoffTools } from 'signoff-ui/assistant-ui'; // review_changes gets a DiffReview; any call with an approval gate gets the approval card. const tools = signoffTools({ review: ['review_changes'] }); function AssistantMessage() { return ( ); } function UserMessage() { return ( ); } export function Thread() { const runtime = useChatRuntime({ // Continue the run once the review is in and every approval has an answer. sendAutomaticallyWhen: (chat) => lastAssistantMessageIsCompleteWithToolCalls(chat) || lastAssistantMessageIsCompleteWithApprovalResponses(chat), }); const policy = useApprovalPolicy(); return ( {({ message }) => (message.role === 'assistant' ? : )} Send ); } ``` This ran end to end against the quickstart's scripted model, the review, an approval for the session and the resumed answer, with `@assistant-ui/react` 0.15 and `@assistant-ui/react-ai-sdk` 1.4. [examples/assistant-ui](https://github.com/dgesteves/signoff-ui/tree/main/examples/assistant-ui) runs the same on assistant-ui's local runtime with a scripted model in the page, no API key and no server. CI builds it from the packed package and drives a review, an approval and a rule's automatic answer in Chrome. - **Your own components for some tools:** pass `Fallback` to `signoffTools` for calls with no approval gate, or render `ReviewToolUI` and `ApprovalToolUI` inside your own tool components. They take the tool-call props assistant-ui passes. - **Approval rules:** with a `policy`, the card offers once, this session and always, and a call a rule already decides is answered without a card once the run has paused for it. - **What the cards answer:** plain approve-or-deny gates. A request that asks a question or offers options to select (`display` other than `"decision"`) renders nothing here, so you can render your own for it. ### AG-UI agents LangGraph, CrewAI, Mastra and other AG-UI agents render through `useAgUiAgent`, which turns the run into the same message parts, interrupts as approvals. See [AG-UI agents](https://signoff-ui.vercel.app/docs/ag-ui). ## AG-UI agents > Render LangGraph, CrewAI, Mastra and other AG-UI agents with the same components, interrupts included, through useAgUiAgent. Source: https://signoff-ui.vercel.app/docs/ag-ui [AG-UI](https://docs.ag-ui.com) is the open protocol that LangGraph, CrewAI, Mastra, Pydantic AI and other agent frameworks use to stream runs to a frontend. `signoff-ui/ag-ui` turns an AG-UI agent into the same message parts the AI SDK produces, so every component works with it unchanged, approvals included. Want to see it first? The [components page](https://signoff-ui.vercel.app/components#ag-ui) runs a real `@ag-ui/client` agent replaying a LangGraph-style run, with an interrupt you can approve or deny. ### Install ```bash npm i signoff-ui ai @ag-ui/client ``` `ai` is there for the message part types only. A custom agent, an `AbstractAgent` whose `run()` returns an RxJS `Observable`, needs `rxjs` too, at the version `@ag-ui/client` uses (`rxjs@7.8.1` for 1.0). Add the styles as in [Getting started](https://signoff-ui.vercel.app/docs/getting-started#add-the-styles). With the shadcn CLI, the adapter is the `ag-ui` item: ```bash npx shadcn@latest add https://signoff-ui.vercel.app/r/ag-ui.json ``` ### Render an agent `useAgUiAgent` takes any `@ag-ui/client` agent, `HttpAgent` or a framework integration's own `AbstractAgent`, and returns what the components need: ```tsx title="app/agent-run.tsx" 'use client'; import { HttpAgent } from '@ag-ui/client'; import { AgentMessage, AgentStatus, RunMeter, deriveAgentState } from 'signoff-ui'; import { useAgUiAgent } from 'signoff-ui/ag-ui'; const agent = new HttpAgent({ url: '/api/agent' }); export function AgentRun() { const { messages, status, usage, step, respond } = useAgUiAgent(agent); const last = messages.findLast((m) => m.role === 'assistant'); const { state, detail } = deriveAgentState({ status, message: last }); return ( <> {last ? ( ) : null} ); } export async function send(text: string) { agent.addMessage({ id: crypto.randomUUID(), role: 'user', content: text }); await agent.runAgent(); } ``` Create the agent once, outside the component or in a `useMemo`: the hook subscribes to the instance it's given. | Returns | What it is | | ------------ | ------------------------------------------------------------------------------- | | `messages` | The conversation as AI SDK `UIMessage`s: pass each to `AgentMessage` | | `status` | `useChat`-style status, for `deriveAgentState` and `AgentStatus` | | `error` | The run's error, if it failed | | `usage` | Token usage summed over the runs seen, for `RunMeter` | | `step` | The step in progress, such as the LangGraph node running | | `interrupts` | Open interrupts; those bound to a tool call show as approval cards | | `respond` | Answers a tool-call interrupt; pass it as `AgentMessage`'s `onToolApproval` | | `resolve` | Answers any interrupt with your own payload | | `editInput` | Arguments a person edited, sent with the approval; pass it as `onToolInputEdit` | | `stop` | Aborts the run in progress; it ends as canceled, so it reads as `stopped` | ### How events map | AG-UI 1.0 | Becomes | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | user message | `user` message with `text` parts, images and documents as `file` parts | | the assistant, reasoning, tool and activity messages that follow it | one `assistant` message, the way the AI SDK groups a multi-step run | | `TEXT_MESSAGE_*`, `REASONING_*` | `text` and `reasoning` parts, `state: 'streaming'` until their end event | | `TOOL_CALL_START`, `TOOL_CALL_ARGS` | `dynamic-tool` part, `input-streaming`, with the arguments parsed as they arrive | | `TOOL_CALL_END` | `input-available` | | `TOOL_CALL_RESULT`, tool message | `output-available` (JSON results parsed), or `output-error` with the tool message's `error` | | `RUN_FINISHED` with an `interrupt` outcome bound to a tool call | `approval-requested`, the interrupt's `message` as `approval.requestReason` | | `respond({ id, approved, reason })` | `approval-responded` or `output-denied`; the agent resumes with `payload: { approved, reason }`, plus `input` when the arguments were edited with `editInput` | | `RUN_STARTED`, content, `RUN_FINISHED`, `RUN_ERROR` | `status`: `submitted`, `streaming`, then `ready` or `error` | | `RUN_FINISHED` with a `cancelled` outcome, or `stop()` | `status: 'ready'`, with what was streaming left cut off: `deriveAgentState` reads `stopped` | | `usage` on `RUN_FINISHED` and `RUN_ERROR` | `usage`, summed over runs, with cache and reasoning tokens | | `STEP_STARTED` | `step` | | activity message | a `data-${activityType}` part, rendered by `renderData` | | `SUBAGENT_STARTED`, and the messages and events with its `subagentRunId` | a `data-signoff-subagent` part after the call that started it (`parentToolCallId`), with `{ subagentRunId, parentToolCallId, name, status, parts }` | | `SUBAGENT_FINISHED`, `SUBAGENT_ERROR` | the part's `status`: `done`, `suspended` (paused until a later run) or `error`, with the message as `errorText` | Shared state (`STATE_SNAPSHOT`, `STATE_DELTA`) stays on `agent.state`. ### Sub-agents A sub-agent's messages don't join its parent's parts one by one. Its text and tool calls, in the same shapes as the parent's, go in one `data-signoff-subagent` part, placed after the tool call that started it, and `ToolCallTimeline` (inside `AgentMessage` too) nests them under that call, collapsed. A sub-agent another one started goes inside that one's `parts`, after its call. One started by no call it can find shows where its first message was, and the timeline shows its calls in place. Events can arrive in any order: a sub-agent's own events before its `SUBAGENT_STARTED` mark it running, and an end that came first stands. Messages restored from before the page (no events) still group by `subagentRunId`, without a name, `running` while one of their calls is. A call a sub-agent makes can wait on approval like any other: the interrupt names its `toolCallId`, the sub-agent opens to show the card, `deriveAgentState` reads `awaiting-approval`, and `respond` resumes the run. `subagents` in the run state (`reduceAgUiRun`) lists each one, with its name, parent call and status. ### Interrupts and approvals AG-UI resumes every open interrupt in one run, so the hook waits until each has an answer before it calls `runAgent({ resume })`. A tool-call interrupt renders as an approval card, and `respond` answers it with `{ approved, reason }`. Pass `editInput` as `AgentMessage`'s `onToolInputEdit` and the cards let a person edit a call's arguments first; the payload then carries them as `input`, for your agent to run with. With an `approvalPolicy`, cards offer approving for the session or always, and answer what a rule covers without asking. Interrupts that aren't tool approvals (`input_required`, or your own) are in `interrupts`. Answer them with `resolve`, matching the interrupt's `responseSchema`: ```ts await resolve({ interruptId: interrupt.id, status: 'resolved', payload: { city: 'Lisbon' } }); ``` ### LangGraph LangGraph agents speak AG-UI through [`ag-ui-langgraph`](https://github.com/ag-ui-protocol/ag-ui/tree/main/integrations/langgraph/python), which serves a compiled graph from FastAPI: ```python title="server.py" # pip install ag-ui-langgraph fastapi uvicorn from fastapi import FastAPI from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint from my_agent import graph # your compiled graph app = FastAPI() # emit_interrupt_outcome reports interrupts on RUN_FINISHED, where useAgUiAgent reads them. It's off by default. add_langgraph_fastapi_endpoint(app, LangGraphAgent(name="agent", graph=graph, emit_interrupt_outcome=True), "/agent") ``` Point `HttpAgent` at it (`new HttpAgent({ url: 'http://localhost:8000/agent' })`), with FastAPI's `CORSMiddleware` or a rewrite in your app if the origins differ. An interrupt renders as an approval card when its value names the tool call it guards, and the hook resumes it with `{ approved, reason }`: ```python from langgraph.types import interrupt answer = interrupt({"message": f"Run `{command}`?", "tool_call_id": tool_call["id"]}) if not answer["approved"]: ... # answer.get("reason") is what the user typed, if anything ``` Interrupts without a tool call are in `interrupts`, for `resolve`. ### Other frameworks CrewAI, Mastra, Pydantic AI and the protocol's other integrations each serve or wrap an AG-UI agent. Point an `HttpAgent` at the endpoint, or pass the integration's own `AbstractAgent` to `useAgUiAgent`; the components don't change. An interrupt becomes an approval card when it carries the `toolCallId` of the call it guards; how each framework sets that varies, so check its docs if you want approval cards rather than `interrupts`. ### Without the hook The pure pieces behind it, `fromAgUiMessages`, `reduceAgUiRun`, `answerAgUiInterrupt` and `getAgUiResume`, work with your own store or a recorded event log. The adapter has no dependency on `@ag-ui/*`: it reads their objects structurally, and its tests run a real `@ag-ui/client` agent through an interrupt and a resumed run. ## Agent Client Protocol > Render an ACP prompt turn with the same components: session updates as message parts, permission requests as approval cards, edits as diffs. Source: https://signoff-ui.vercel.app/docs/acp The [Agent Client Protocol](https://agentclientprotocol.com) (ACP) is the JSON-RPC protocol editors use to run coding agents. `signoff-ui/acp` turns one prompt turn into the parts the components render: the agent's message and thought chunks, its tool calls and their status, each permission request as an approval card with the options the agent offered, and an edit's diff content as `DiffReview` files. Want to see it first? The [components page](https://signoff-ui.vercel.app/components#acp) plays a scripted ACP turn, no real agent: a read, an edit that shows its diff and asks permission, then the tests, which ask too. ### Install ```bash npm i signoff-ui ai ``` `ai` is there for the message part types only. The entry is pure functions with no React and no dependency on an ACP library: its types mirror what it reads, and `@agentclientprotocol/sdk`'s objects fit them as they are (a type test checks it). Add the styles as in [Getting started](https://signoff-ui.vercel.app/docs/getting-started#add-the-styles). ### Fold a turn A client receives `session/update` notifications while `session/prompt` runs, and `session/request_permission` requests it has to answer. Fold both into an `AcpTurn`, and render `toAcpMessage(turn)`. This hook holds one turn, and is the client half of the connection: ```tsx title="components/use-acp-turn.tsx" 'use client'; import type { Agent, Client, RequestPermissionResponse } from '@agentclientprotocol/sdk'; import { useRef, useState } from 'react'; import type { ApprovalDecision } from 'signoff-ui'; import { answerAcpPermission, createAcpTurn, endAcpTurn, reduceAcpTurn, requestAcpPermission, toAcpPermissionResponse, type AcpTurn, } from 'signoff-ui/acp'; /** One prompt turn at a time: the client half of the connection, and what the components render. */ export function useAcpTurn() { const [turn, setTurn] = useState(); const waiting = useRef(new Map void>()); // Pass it to the connection: new ClientSideConnection(() => client, stream). const [client] = useState(() => ({ sessionUpdate: (params) => setTurn((t) => t && reduceAcpTurn(t, params)), requestPermission: (params) => new Promise((resolve) => { waiting.current.set(params.toolCall.toolCallId, resolve); setTurn((t) => t && requestAcpPermission(t, params)); }), })); async function prompt(agent: Agent, sessionId: string, text: string) { setTurn(createAcpTurn()); try { const response = await agent.prompt({ sessionId, prompt: [{ type: 'text', text }] }); setTurn((t) => t && endAcpTurn(t, response)); } catch (error) { setTurn((t) => t && endAcpTurn(t, { error: { message: String(error) } })); } } function decide(toolCallId: string, decision: ApprovalDecision) { const options = turn?.permissions[toolCallId]?.options ?? []; setTurn((t) => t && answerAcpPermission(t, toolCallId, decision)); waiting.current.get(toolCallId)?.(toAcpPermissionResponse(decision, options)); waiting.current.delete(toolCallId); } return { turn, client, prompt, decide }; } ``` The connection itself, `ClientSideConnection` from `@agentclientprotocol/sdk` over the agent's stdio or a bridge, is up to your app: these functions only fold what it receives. ### Render it `AgentMessage` renders the turn. For a call waiting on its permission request, render an `ApprovalCard` that offers the agent's options, with the diff of an edit as its preview: ```tsx title="components/acp-turn.tsx" 'use client'; import { AgentMessage, ApprovalCard, DiffReview, getApprovalStatus, type ApprovalDecision } from 'signoff-ui'; import { acpToolName, decisionsFromAcpOptions, fromAcpDiffs, toAcpMessage, type AcpTurn } from 'signoff-ui/acp'; export function AcpTurnView({ turn, cwd, decide, }: { turn: AcpTurn; cwd: string; decide: (id: string, d: ApprovalDecision) => void; }) { const active = turn.status === 'submitted' || turn.status === 'streaming'; return ( { const permission = turn.permissions[part.toolCallId]; if (!permission) return undefined; const call = turn.toolCalls[part.toolCallId]!; const files = fromAcpDiffs(call.content, { root: cwd }); return ( 0 ? : undefined} status={getApprovalStatus(part)} decision={permission.decision === 'cancelled' ? undefined : permission.decision} // The agent's options. It keeps what "always" means, so the card shows no argument patterns. ruleScope={false} decisions={decisionsFromAcpOptions(permission.options).filter((d) => d !== 'allow-session')} onDecide={(choice) => decide(part.toolCallId, choice.decision)} /> ); }} /> ); } ``` `toAcpPermissionResponse(decision, options)` selects the option of the same kind: `allow-once` is `allow_once`, `allow-always` is `allow_always`, `deny-once` is `reject_once` and `deny-always` is `reject_always`. With no option of that kind, the request is canceled. `decisionsFromAcpOptions` also offers `allow-session`, which answers `allow_once` and leaves the session rule to your `useApprovalPolicy`; the example above leaves it out. ### How a turn maps | ACP | Becomes | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `agent_message_chunk`, `agent_thought_chunk` (text) | `text` and `reasoning` parts, consecutive chunks joined, the last one `streaming` until the turn ends | | `tool_call`, `tool_call_update` | a `dynamic-tool` part per call, updates merged by id (`null` and missing fields keep their value) | | the tool's `name`, else `kind`, else `title` | the part's `toolName`, which approval rules match; `title` is the part's `title` | | `pending`, `in_progress` | `input-available`, shown running | | `completed` | `output-available`, the output being `rawOutput`, else the text of its content | | `failed` | `output-error`, with the text of its content | | `session/request_permission` (`requestAcpPermission`) | `approval-requested`, the tool call id as the approval id | | `answerAcpPermission(turn, id, decision)` | `approval-responded` (then the output, approval kept), or `output-denied`, also when the request was canceled | | `session/prompt` returns (`endAcpTurn`) | `status: 'ready'` and its `stopReason`, or `'error'`: a call left running then reads as stopped | | `diff` content (`fromAcpDiffs`) | `DiffReview` files, paths relative to `root` (the session's `cwd`), a diff with no `oldText` a new file | User message chunks, plans, available commands, mode, config and usage updates are left out of the message. `toAcpDiffs` goes the other way, from files to diff content. ### What it does not do yet ACP's permission request is all or nothing: the agent offers allow and reject, and nothing in between. So the card above approves or rejects a whole edit, with its diff to read. Accepting some hunks of an edit and rejecting others would mean the client applying the edit itself, through `fs/write_text_file`, which this entry does not do. ## Accessibility > Keyboard shortcuts scoped to focus, managed focus, announcements, contrast and how they are tested, with axe in jsdom and in Chrome. Source: https://signoff-ui.vercel.app/docs/accessibility Review and approval are where a person has to read carefully and decide quickly, so every component is built to work from the keyboard and with a screen reader. Each component's page lists its keys and what it announces; this page is what they share. ### Keyboard first Every action is a real button. Single-key shortcuts (Y and N on an approval card; J, K, A, R, U, C, E and V in a diff review) only fire while focus is inside the component, which keeps them compliant with WCAG 2.1.4 and out of the way of text fields. They are exposed through `aria-keyshortcuts` and described in visually hidden text. The page-wide ⌘ or Ctrl + Enter approval is opt-in, because it collides with most chat composers. ### Focus is managed, never lost Deciding an approval moves focus to the card instead of dropping it on ``. Diff hunks use a roving tabindex, so the review is one tab stop that arrow and letter keys navigate; its file list is one more. Writing a comment moves focus to its text area and back to the hunk when it closes. Tool calls follow the disclosure pattern with arrow-key movement. In a run of more than 150 calls, the timeline renders those near the screen; the arrow keys, Home and End still move between all of them, scrolling one that isn't rendered into view, and each call that renders gives its place in the whole list. ### Scrolling content is reachable Code blocks, tables, command previews, diff hunks and the compact run meter scroll sideways when they don't fit. While they do, they join the tab order as named groups ("Hunk 2 code, app/api/chat/route.ts"), so the arrow keys scroll them; once everything fits, they add no tab stop. ### Announcements Tool completions and failures (calls that settle together, such as parallel calls, in one announcement), approval decisions, review progress ("Hunk 2 of 4 accepted. 2 remaining.") and agent state changes go through live regions. State announcements are debounced, and only approvals and errors are assertive. ### Not color alone Every state has an icon and text, diff lines keep their + and − glyphs plus "Added:" and "Removed:" for screen readers, and risk levels are spelled out. ### In the reader's language Accessible names, announcements and keyboard hints are labels like the visible text, so a [translation](https://signoff-ui.vercel.app/docs/labels) covers what a screen reader says too. Set `lang` on the page, or on the element that holds the components, so it is pronounced in that language. The keys themselves stay the same. ### Motion All animation is behind `motion-safe`, and the number tweening in `RunMeter` honors `prefers-reduced-motion`. ### Contrast A unit test parses the theme and enforces 4.5:1 for text in both themes: every text token on the surfaces it sits on, syntax colors on diff lines and word highlights (composited the way the diff paints its translucent backgrounds), and every text-on-tint class in the components, such as the accepted and rejected badges. UI and chart marks get 3:1. The chart colors were run through a color-vision-deficiency check. The test covers the kit's own palette. If you map the tokens to your own, as [Theming](https://signoff-ui.vercel.app/docs/getting-started#theming) shows for shadcn/ui, check text contrast with yours. ### Document outline Titles take a `headingLevel`; source lists are labeled lists and approval cards are named groups rather than landmarks, so a long conversation does not flood landmark navigation. ### How it's tested Every component has axe checks in Vitest (jsdom). `pnpm a11y` also runs axe in Chrome against the playground at each stage of a run, the components page, the docs and a phone viewport, in the dark and light themes, which covers color contrast with real layout. CI runs both on every pull request, and both currently report no violations. ## Labels and translations > Every word the components show or announce is a label: translate them all with one provider, or change a few, with a complete Portuguese example. Source: https://signoff-ui.vercel.app/docs/labels Every word the components show or announce, the visible text and what screen readers hear alike, comes from one object of labels. Translate it once and pass it to `SignoffLabelsProvider`, or pass part of it to a component's `labels` prop. There are 207 of them, 140 fixed strings and 67 functions, in twelve sections: one per component, one for `useApprovalPolicy`, and two shared ones, `format` for numbers and `common`. ### Translate everything Labels hold functions, which cannot go from a Server Component to a Client Component, so render the provider from a module with `'use client'`, and keep the object stable: at module scope, as here, or memoized. ```tsx title="app/providers.tsx" 'use client'; import type { ReactNode } from 'react'; import { SignoffLabelsProvider } from 'signoff-ui'; import { pt } from './labels-pt'; export function Providers({ children }: { children: ReactNode }) { return {children}; } ``` Type the translation as `SignoffLabels`, as the [Portuguese below](https://signoff-ui.vercel.app/docs/labels#portuguese) is: a label it misses, or one a new release adds, is then a type error rather than English on the page. Set `lang` on the page, or on the element that holds the components when the rest of the page is in another language (`
`), so screen readers pronounce the words in the right language. ### Change a few words A partial object is enough: anything it leaves out stays English. ```tsx ``` Providers nest, the inner one winning key by key, and a component's own `labels` win over both, for it and for what it renders: `AgentMessage`'s apply to its timeline, approval cards and markdown too. Props that already took text, such as `title`, `submitLabel`, `approveLabel`, `denyLabel` and `reasonPlaceholder`, still win over the labels. For a hook or markup of your own, `useSignoffLabels()` returns the labels in effect, and `mergeLabels(defaultLabels, ...overrides)` builds the same merged object anywhere, the server included. ### Strings and functions Fixed text is a string. Text built from values (counts, file paths, line numbers, tool names) is a function, so a translation can get its plurals, its gender agreement and its word order right: ```ts files: (count) => `${count} ${count === 1 ? 'ficheiro' : 'ficheiros'}`, decided: (kind, n, total, decision, remaining) => ..., ``` A few return parts, where the components style or hide one of them: `moreAfter`, `moreBefore` and `showAll` return `{ visible, hidden }`, what the button shows and what only screen readers hear after it; `commentHeading` returns `{ hidden, visible }`; `rememberedFor` returns `{ before, after }`, the words around the tool's name. `format` holds how numbers read: durations, the spoken durations of announcements, token counts, cost and percentages. The English ones are fixed and locale-stable; a translation can use `Intl.NumberFormat`, as the Portuguese does. ### What they do not cover - **Words for the agent.** `reviewToolOutput`'s summary, `toToolApproval`'s reasons and the result objects' fields are read by the model, not by a person, and stay English. Pass `toToolApproval` a `reason` function to word its reasons yourself. A deny rule's reason is the exception, as the approval card shows it: it is `approvalPolicy.ruleDenial`, or the rule's own `reason`. - **`deriveAgentState`'s detail** while the model writes: pass `writingResponse: labels.agentStatus.writingResponse`, since the helper runs outside any provider. - **Keys.** J, K, A, R, Y, N and the rest are the same in every language, and so are their `aria-keyshortcuts`. The hints that name them are labels. - **Your content.** Tool names, titles, descriptions, tool output and what `ToolMeta` renders are yours. A tool's displayed name defaults to its humanized name (`run_command` reads "Run command"), so give `ToolMeta` a `label` in your language. - **Right-to-left layouts** have not been tested yet. ### How it's tested A test renders every component through the states it has (pending, edited, decided, compared, commented on, streamed, stopped) with each label replaced by a marker and the content in Cyrillic, and fails on any English left on the page, in text, accessible names, titles or placeholders. It also fails on a label no state shows, so the states cover every label. A second test reads the components' source and fails on English written into it: text between tags, a string where a name or a placeholder goes, or one handed to the live region. The Portuguese below is the one the tests render, and its type checks that it is complete. ### Portuguese Every label, in European Portuguese. ```ts title="labels-pt.ts" collapse import type { SignoffLabels } from 'signoff-ui'; // Every label in European Portuguese. Typed as `SignoffLabels`, so a missing one fails to compile. const plural = (n: number, one: string, many: string) => (n === 1 ? one : many); const decimal = new Intl.NumberFormat('pt-PT', { maximumFractionDigits: 1 }); const compact = new Intl.NumberFormat('pt-PT', { notation: 'compact', maximumFractionDigits: 1 }); const usd = new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'USD', maximumSignificantDigits: 3 }); const valid = (n: number | undefined | null): n is number => n != null && Number.isFinite(n) && n >= 0; const lines = (r: { side: 'new' | 'old'; startLine: number; endLine: number }) => `${r.startLine === r.endLine ? `a linha ${r.startLine}` : `as linhas ${r.startLine} a ${r.endLine}`}${r.side === 'old' ? ' do original' : ''}`; export const pt: SignoffLabels = { format: { duration: (ms) => { if (!valid(ms)) return '–'; if (Math.round(ms) < 1000) return `${Math.round(ms)} ms`; const seconds = Math.round(ms / 1000); if (ms < 60_000) return `${decimal.format(ms / 1000)} s`; return `${Math.floor(seconds / 60)} min ${String(seconds % 60).padStart(2, '0')} s`; }, durationLong: (ms) => { if (!valid(ms)) return 'duração desconhecida'; if (Math.round(ms) < 1000) return `${Math.round(ms)} milissegundos`; if (ms < 60_000) return `${decimal.format(ms / 1000)} segundos`; const seconds = Math.round(ms / 1000); const [m, s] = [Math.floor(seconds / 60), seconds % 60]; return `${m} ${plural(m, 'minuto', 'minutos')} e ${s} ${plural(s, 'segundo', 'segundos')}`; }, tokens: (n) => (n == null || !Number.isFinite(n) ? '–' : compact.format(n)), cost: (value) => (value == null || !Number.isFinite(value) ? '–' : usd.format(value)), percent: (fraction) => new Intl.NumberFormat('pt-PT', { style: 'percent' }).format(fraction), }, common: { copied: 'Copiado', copyThe: (what) => `Copiar ${what.toLowerCase()}`, showAllLines: (count) => `Mostrar as ${count} linhas`, showLess: 'Mostrar menos', opensInNewTab: '(abre num novo separador)', modKey: (mac) => (mac ? '⌘' : 'Ctrl'), }, agentStatus: { states: { idle: 'Inativo', thinking: 'A pensar', working: 'A trabalhar', 'awaiting-approval': 'À espera de aprovação', done: 'Concluído', stopped: 'Parado', error: 'Erro', }, spoken: (state, detail) => (detail ? `${state}: ${detail}` : state), writingResponse: 'A escrever a resposta', }, toolCallTimeline: { list: 'Chamadas de ferramentas', phases: { streaming: 'A preparar', running: 'A executar', 'awaiting-approval': 'Precisa de aprovação', success: 'Concluída', error: 'Falhou', denied: 'Recusada', }, stopped: 'Interrompida', finished: (name, duration) => `${name} terminou em ${duration}`, failed: (name, error) => `${name} falhou: ${error ?? 'erro desconhecido'}`, blockedByPolicy: (name) => `${name} foi bloqueada pela política`, denied: (name) => `${name} foi recusada`, input: 'Entrada', inputStreaming: 'Entrada (a chegar)', output: 'Saída', outputPartial: 'Saída (parcial)', error: 'Erro', deniedBy: (automatic) => (automatic ? 'Bloqueada pela política' : 'Recusada pelo utilizador'), waitingForInput: 'À espera da entrada…', subagent: { name: 'Subagente', calls: (count) => `${count} ${plural(count, 'chamada', 'chamadas')}`, list: (name) => `Chamadas de ferramentas de ${name}`, paused: 'Em pausa', }, }, approvalCard: { required: 'Aprovação necessária', risk: { low: 'Risco baixo', medium: 'Risco médio', high: 'Risco alto', critical: 'Crítico' }, command: 'Comando', arguments: 'Argumentos', approve: 'Aprovar', session: 'Nesta sessão', always: 'Sempre', deny: 'Recusar', alwaysDeny: 'Recusar sempre', sendDenial: 'Enviar recusa', confirm: 'Confirmar aprovação', denyWithFeedback: 'Recusar com comentário', reasonLabel: 'Diga ao agente porquê (opcional)', reasonPlaceholder: 'O que deve o agente fazer em vez disto?', approveHint: 'aprovar', keyboardHint: (offered, mod) => `Teclado: prima ${[ offered.approve && 'Y para aprovar', offered.session && 'S para aprovar nesta sessão', offered.always && 'A para aprovar sempre', offered.deny && 'N para recusar', offered.alwaysDeny && 'Shift N para recusar sempre', ] .filter(Boolean) .join(', ')}, ou ${mod} Enter para aprovar.`, resolved: { 'allow-once': 'Aprovado', 'allow-session': 'Aprovado nesta sessão', 'allow-always': 'Aprovado sempre', 'deny-once': 'Recusado', 'deny-always': 'Recusado sempre', }, approved: 'Aprovado', denied: 'Recusado', autoApproved: 'Aprovado automaticamente', blockedByPolicy: 'Bloqueado pela política', allowedByRule: 'Permitido pela sua regra', deniedByRule: 'Recusado pela sua regra', describeRule: (rule) => { const args = Object.entries(rule.args ?? {}); const tool = rule.tool === '*' ? 'qualquer ferramenta' : rule.tool.replace(/\\(.)/g, '$1'); return args.length === 0 ? `${tool}, quaisquer argumentos` : `${tool} com ${args.map(([name, pattern]) => `${name} ${pattern}`).join(', ')}`; }, critical: 'Ação crítica. Prima aprovar outra vez para confirmar.', invalidJson: 'Os argumentos não são JSON válido.', editArguments: 'Editar argumentos', doneEditing: 'Concluir edição', undoEdits: 'Desfazer edições', edited: 'Editado: ao aprovar, corre com os novos argumentos.', argumentsJson: 'Argumentos, em JSON', notValidJson: (error) => `JSON inválido: ${error}`, ruleLegend: 'Chamadas abrangidas por uma decisão duradoura', rememberedFor: (any) => ({ before: 'Fica memorizado para ', after: any ? ' com quaisquer argumentos.' : ' quando:', }), argumentMatches: (name) => `${name} corresponde a`, anyArguments: 'Quaisquer argumentos', globHelp: '* corresponde a qualquer texto exceto operadores da shell (; & | > < e acentos graves); ** a tudo.', batch: { group: 'Aprovações pendentes', waiting: (count) => `${count} aprovações à espera.`, approveAll: 'Aprovar todas', denyAll: 'Recusar todas', confirm: 'Confirmar aprovar todas', critical: 'Uma delas é crítica. Prima Aprovar todas outra vez para confirmar.', answered: (count, approved) => `${count} aprovações ${approved ? 'aprovadas' : 'recusadas'}.`, }, }, diffReview: { title: 'Rever alterações', layout: 'Disposição do diff', views: { unified: 'Unificado', split: 'Lado a lado' }, files: (count) => `${count} ${plural(count, 'ficheiro', 'ficheiros')}`, hunks: (count, comparing) => `${count} ${plural(count, 'bloco', 'blocos')}${comparing ? ' até agora' : ''}`, status: { modified: { letter: 'M', name: 'Modificado' }, added: { letter: 'A', name: 'Adicionado' }, deleted: { letter: 'E', name: 'Eliminado' }, renamed: { letter: 'R', name: 'Renomeado' }, }, navigator: 'Ficheiros nesta revisão', navigatorState: (decided, total, viewed) => `, ${decided} de ${total} decididos${viewed ? ', visto' : ''}`, comparingShort: 'a comparar…', renamedTo: 'para', toggleFile: (collapsed, path) => `${collapsed ? 'Mostrar' : 'Ocultar'} ${path}`, viewed: 'Visto', rejectFile: 'Rejeitar ficheiro', acceptFile: 'Aceitar ficheiro', comparing: 'A comparar alterações…', fallback: 'Demasiadas alterações para comparar linha a linha: as linhas alteradas aparecem como um só bloco que as substitui.', noChanges: 'Sem alterações de texto.', wholeFile: (status, binary, oldPath) => binary ? 'Ficheiro binário, não mostrado.' : status === 'renamed' ? `Renomeado de ${oldPath}, sem alterações.` : status === 'deleted' ? 'Ficheiro vazio, eliminado.' : 'Novo ficheiro vazio.', accept: 'Aceitar', reject: 'Rejeitar', itemName: (kind, n) => `${kind === 'hunk' ? 'bloco' : 'alteração'} ${n}`, acceptItem: (item) => `Aceitar ${item}`, rejectItem: (item) => `Rejeitar ${item}`, resetItem: (item) => `Repor ${item}`, commentOn: (what) => `Comentar ${what}`, badge: { accepted: 'aceite', rejected: 'rejeitado' }, hunk: (n, total, path, from, to, decision) => `Bloco ${n} de ${total}, ${path}, linhas ${from} a ${to}, ${ decision === 'pending' ? 'por rever' : decision === 'accepted' ? 'aceite' : 'rejeitado' }`, fileItem: (n, total, path, what, decision) => `Alteração ${n} de ${total}, ${path}, ${what}, ${ decision === 'pending' ? 'por rever' : decision === 'accepted' ? 'aceite' : 'rejeitada' }`, describeFile: (status, binary, oldPath) => binary ? 'ficheiro binário' : status === 'renamed' ? `renomeado de ${oldPath}` : status === 'deleted' ? 'ficheiro vazio eliminado' : 'novo ficheiro vazio', hunkCode: (n, path) => `Código do bloco ${n}, ${path}`, added: 'Adicionada: ', removed: 'Removida: ', decided: (kind, n, total, decision, remaining) => { const hunk = kind === 'hunk'; const word = decision === 'pending' ? hunk ? 'reposto' : 'reposta' : decision === 'accepted' ? 'aceite' : hunk ? 'rejeitado' : 'rejeitada'; return `${hunk ? 'Bloco' : 'Alteração'} ${n} de ${total} ${word}. ${ remaining === 0 ? 'Todos os blocos revistos.' : `${remaining} por rever.` }`; }, decidedAll: (total, decision) => `Os ${total} blocos foram ${decision === 'accepted' ? 'aceites' : 'rejeitados'}.`, decidedFile: (count, path, decision) => count === 1 ? `O bloco de ${path} foi ${decision === 'accepted' ? 'aceite' : 'rejeitado'}.` : `Os ${count} blocos de ${path} foram ${decision === 'accepted' ? 'aceites' : 'rejeitados'}.`, viewedAnnouncement: (path, viewed, total) => `${path} visto. ${viewed} de ${total} ficheiros vistos.`, notViewedAnnouncement: (path) => `${path} deixou de estar visto.`, lines, selected: (line, range) => `${line}. ${range.startLine === range.endLine ? `Linha ${range.startLine} selecionada` : `Linhas ${range.startLine} a ${range.endLine} selecionadas`}${range.side === 'old' ? ' no original' : ''}.`, where: (comment) => comment.target === 'file' ? 'o ficheiro' : comment.target === 'hunk' ? 'o bloco' : lines({ side: 'new', startLine: comment.startLine ?? 0, endLine: comment.endLine ?? 0 }), commentHeading: (where) => ({ hidden: 'Comentário ', visible: `Sobre ${where}` }), commentField: (where) => `Comentário sobre ${where}, para o agente`, commentPlaceholder: 'O que deve mudar aqui?', editComment: (where) => `Editar comentário sobre ${where}`, deleteComment: (where) => `Eliminar comentário sobre ${where}`, edit: 'Editar', delete: 'Eliminar', cancel: 'Cancelar', saveComment: 'Guardar comentário', addComment: 'Comentar', commentAdded: (where, path) => `Comentário adicionado sobre ${where} de ${path}.`, commentUpdated: 'Comentário atualizado.', commentDeleted: 'Comentário eliminado.', unchangedLines: (count) => `${count} ${plural(count, 'linha inalterada', 'linhas inalteradas')}`, moreAfter: (count, hunk) => ({ visible: `mais ${count}`, hidden: ` linhas inalteradas depois do bloco ${hunk}` }), moreBefore: (count, hunk) => ({ visible: `mais ${count}`, hidden: ` linhas inalteradas antes do bloco ${hunk}` }), showAll: (count) => ({ visible: 'Mostrar todas', hidden: ` as ${count} linhas inalteradas` }), expanded: (count) => `A mostrar mais ${count} ${plural(count, 'linha inalterada', 'linhas inalteradas')}.`, reviewed: 'revistos', filesViewed: (viewed, total) => `${viewed} de ${total} ficheiros vistos`, comments: (count) => `${count} ${plural(count, 'comentário', 'comentários')}`, comparingFiles: 'a comparar ficheiros…', skipped: 'os blocos por rever ficam de fora', apply: (accepted, total) => (accepted > 0 ? `Aplicar ${accepted} de ${total}` : 'Aplicar alterações'), acceptAll: 'Aceitar tudo', rejectAll: 'Rejeitar tudo', keyHints: { move: 'mover', accept: 'aceitar', reject: 'rejeitar', comment: 'comentar', apply: 'aplicar' }, keyboardHelp: (mod) => `Teclado: J ou K para mudar de bloco, Shift J ou Shift K para mudar de ficheiro, A para aceitar, R para rejeitar, U para repor, Alt A ou Alt R para o ficheiro inteiro, Shift A ou Shift R para todos os blocos, Shift com a seta para cima ou para baixo para selecionar linhas, C para as comentar ou comentar o bloco, E para mostrar mais linhas inalteradas, V para marcar o ficheiro como visto, ${mod} Enter para aplicar.`, }, markdown: { code: (language) => (language && language !== 'text' ? `Código, ${language}` : 'Código'), codeBadge: 'código', copyCode: 'Copiar código', table: 'Tabela', image: 'Imagem:', imageFallback: 'imagem', citation: (n) => `Fonte ${n}`, }, reasoning: { thinking: 'A pensar', thoughtFor: (duration) => `Pensou durante ${duration}`, reasoning: 'Raciocínio', }, runMeter: { title: 'Execução', metrics: (live) => `Métricas da execução${live ? ' (ao vivo)' : ''}`, summary: ({ input, output, cost, ttft, total }) => [ `${input} tokens de entrada`, `${output} tokens de saída`, cost !== undefined ? `custo estimado ${cost}` : undefined, ttft !== undefined ? `tempo até ao primeiro token ${ttft}` : undefined, total !== undefined ? `total ${total}` : undefined, ] .filter(Boolean) .join(', '), inputTokens: 'Tokens de entrada', outputTokens: 'Tokens de saída', estimatedCost: 'Custo estimado', timeToFirstToken: 'Tempo até ao primeiro token', totalTime: 'Tempo total', ttft: 'TTFT', live: 'Ao vivo', tokens: 'Tokens', estCost: 'Custo est.', breakdown: 'Detalhe dos tokens', split: (input, output) => `Entrada ${input} · Saída ${output}`, input: 'Entrada', output: 'Saída', cached: (n) => `(${n} em cache)`, reasoningTokens: (n) => `(${n} de raciocínio)`, total: 'Total', cacheHit: 'Acertos na cache', costSplit: (input, output) => `${input} entrada · ${output} saída`, rates: (input, output) => `US$ ${input}/${output} por 1M`, }, approvalPolicy: { ruleDenial: 'Recusado por uma regra' }, sources: { label: 'Fontes', document: 'Documento' }, agentMessage: { attachedImage: 'Imagem anexada' }, }; ``` ## Performance and limits > Bundle size per import, the stylesheet, known limitations with measured numbers, and what comes next. Source: https://signoff-ui.vercel.app/docs/limits What the components cost, where they slow down, and what they don't do yet. The numbers are measured from this repository; the commands that measure them are named next to each. ### Bundle size What each import adds to an app's JavaScript, minified and gzipped. React and React DOM are left out, since the app has them; the kit's own dependencies are counted (react-markdown and remark-gfm, jsdiff, two Radix primitives, clsx and tailwind-merge). Rows share most of those, so two components together cost less than the sum of their rows. | Import | Gzipped | | ------------------------------------------------------------------ | -------: | | `addUsage`, `estimateCost` from `/core` (for a route handler) | 0.4 kB | | `toToolApproval` from `/core` (approval rules in a route handler) | 0.9 kB | | ACP converters from `/acp` | 1.6 kB | | `useApprovalPolicy` (the rules without markup) | 2.2 kB | | `useAgUiAgent` from `/ag-ui` | 3.5 kB | | `applyHunks`, `parseFileChange` from `/core` (with jsdiff) | 6.6 kB | | `Sources` | 10.3 kB | | `AgentStatus` | 11.2 kB | | `RunMeter` | 12.5 kB | | `useDiffReview` (the review without markup) | 15.4 kB | | `ApprovalCard` | 18.5 kB | | `ToolCallTimeline` | 22.6 kB | | `AgentMessage` from `/custom-markdown` (with your own renderer) | 32.6 kB | | `DiffReview` | 41.3 kB | | `signoffTools` from `/assistant-ui` (with DiffReview, approvals) | 50.2 kB | | `AgentMessage` (markdown, reasoning, timeline, approvals, sources) | 86.0 kB | | Everything in the main entry | 120.8 kB | Measured with `pnpm size`, which bundles each import from the built package with Rolldown and gzips it. CI runs it on every pull request, with a budget per row about 5% over its size. Most of `AgentMessage` is the markdown parser: `Markdown` alone is 64.0 kB. An app with its own renderer can [import `AgentMessage` without it](https://signoff-ui.vercel.app/docs/components/agent-message#your-own-markdown-renderer), and CI checks that react-markdown stays out of that bundle. Each component carries the English labels of the sections it reads, not the others. `DiffReview`'s diff worker is a separate file the bundler emits, loaded only when a file is too large to diff while rendering: 18 kB minified, with jsdiff, in a Vite build. `styles.css` adds 9.9 kB gzipped, 269 kB uncompressed. Most of that is the selector each rule carries so that it styles the components' own elements and never your content inside them. With Tailwind v4, `tailwind.css` adds the tokens, and your build generates only the utilities the components use. ### Large diffs `DiffReview` on large files, in Chrome with React's production build, on an Apple M1 Max. Ready is when every file is compared and on screen; the longest task is the longest the page could not respond; the keypress is one decision from the keyboard. | Case | First render | Ready | Longest task | DOM elements | Keypress | | -------------------------------- | -----------: | -----: | -----------: | -----------: | -------: | | Local edits, 1,000 lines | 56 ms | 56 ms | 69 ms | 4,442 | 3.4 ms | | Local edits, 5,000 lines | 77 ms | 78 ms | 93 ms | 5,305 | 6.4 ms | | Full rewrite, 2,000 lines | 16 ms | 241 ms | none | 3,833 | 0.9 ms | | Full rewrite, 5,000 lines | 17 ms | 265 ms | 53 ms | 4,073 | 0.9 ms | | Full rewrite, 5,000 lines, split | 17 ms | 283 ms | 68 ms | 7,322 | 1.2 ms | Measured with `pnpm perf`, which CI runs on every pull request with a budget per case, several times these numbers so shared runners pass. Before these changes a 5,000-line rewrite blocked the page for 3.3 s, left 260,000 elements and took 77 ms per keypress. Three things keep it fast: - **A limit on the diff.** Diffing costs about the square of the lines added plus removed. Past `maxEditLength` (2,000 by default, about 0.2 s), the changed region, from its first changed line to its last, becomes one hunk that replaces it, and the review says so. Lines only added or only removed never count against it, nor do unchanged lines before and after the region. - **A worker.** A file that needs more than 300 edits is diffed in a worker, and shows "Comparing changes…" until it is done; smaller ones are diffed while rendering, on the server too. Webpack 5, Next.js (webpack or Turbopack) and Vite emit the worker without configuration. Where none is available, or `diffWorker={false}`, the file is diffed on the main thread after the first paint: the 5,000-line rewrite then blocks for about 0.17 s. - **Only what is near the screen.** A review with more than 400 rows renders, of each hunk, the rows within about 800 px of what is visible, also inside a scrolling chat panel. The others keep their height, and their lines as visually hidden text, so a screen reader and find-in-page still have every line. Hunks always render, so focus, J and K, and their names are the same. ### Long runs `ToolCallTimeline` on a run of 2,000 calls, each with its timing so the waterfall shows, in Chrome with React's production build, on an Apple M1 Max. Appending a call is the main thread's time for the update and for the frame that shows it, without the wait for that frame to start; the frame is the 95th percentile while a call runs and its clock ticks. | Case | First render | Append a call | Frame, a call running | DOM elements | Calls rendered | | ---------------- | -----------: | ------------: | --------------------: | -----------: | -------------: | | 2,000 tool calls | 41 ms | 2.2 ms | 18 ms | 1,538 | 81 | Measured with `pnpm perf`, with a budget per case; the append's is 16 ms, under a frame, and it measures 12 ms with the CPU six times slower. Before these changes the same run took 440 ms to render, 105 ms per appended call and 50 ms frames while a call ran, and left 36,000 elements. Three things keep it fast: - **Only what is near the screen.** A timeline with more than 150 calls renders, in chunks of 25, the calls within about 800 px of what is visible. A chunk away from it keeps its height as one empty item. A chunk holding an open call, a call still going or focus stays rendered. The calls that render say where they are in the whole list (`aria-posinset`, `aria-setsize`), and the arrow keys, Home and End move between all the calls, bringing one that isn't rendered into view. - **Only the calls that change render again.** A new call, or a running call's clock, renders that call, not the others. The waterfall's bars are placed from each call's times and the timeline's span, which is set on the bars that are rendered. - **No animation left running.** A call slides in when it arrives, and the animation ends there. ### Known limitations - **Large rewrites.** A file changed in more places than `maxEditLength` is reviewed as one hunk that replaces the changed region, without word-level highlights; raise the limit to compare it line by line, at the square of the cost. Long changed lines skip word-level highlights rather than stall. - **Lines are commented on, not decided.** A review accepts or rejects whole hunks, or whole files. Lines can be selected to comment on, and the agent gets the comment with its lines, but a hunk cannot be applied in part. - **Patches without binaries.** `toPatch()` lists an accepted binary file as changed, without its contents, so `git apply` cannot apply that part; the file's own `content` in the result has it, when you passed contents. - **Edited arguments and signed approvals.** With AI SDK 7's `experimental_toolApprovalSecret`, the server signs the arguments it asked about and refuses any other, so arguments edited before approving fail there by design. ACP's `request_permission` has no way to answer with edited arguments, nor a session option: `allow-session` answers `allow_once` and keeps the session rule on the client. - **Rules from the client.** Rules made in the browser are that person's choices. `toToolApproval` can apply them on the server, for that person's runs only; rules that protect other people belong on the server. `parseApprovalRules` checks the rules a request brings, and by default leaves out a pattern with more than one `*`: each `*` more can make matching a long argument take seconds. - **Markdown cost while streaming.** The whole text is parsed again on every delta: about 8 ms at 5k characters, 24 ms at 20k and 67 ms at 50k (jsdom), so very long streamed answers can drop frames. - **Citation numbering.** `[n]` markers are numbered over the message's sources after de-duplication by URL (by source id for documents). If your prompt numbers a list of sources that contains duplicates, markers after the first duplicate point one source early. Number unique sources in the prompt. - **Run state comes from you.** Parts carry no signal that a run has ended, so tool calls left behind by `stop()` or an interrupted history read "Stopped" only when you pass `active={false}`. `deriveAgentState` reports such a run as `stopped`, which the quickstart's `active` condition covers. - **Long timelines in find-in-page.** Past 150 calls, the calls far from the screen are not on the page: find-in-page and a screen reader's reading cursor reach only those near it, and `renderExtra`'s content for the others unmounts until they come back. The arrow keys, Home and End reach every call. - **Words for the agent stay English.** Every word a person sees or hears is a [label](https://signoff-ui.vercel.app/docs/labels), but what goes to the model (`reviewToolOutput`'s summary, `toToolApproval`'s reasons unless you pass its `reason`) is English. Right-to-left layouts have not been tested. - **Nesting in slots.** With `styles.css`, a component rendered in a slot of a component that is itself in a slot is styled up to two levels deep; deeper than that it renders unstyled. ### What's next The next work is on review and approval: - Adapters: an AI Elements recipe, and AG-UI sub-agents as nested timelines. - ACP: accepting some hunks of an edit and rejecting others, with the client writing the reviewed file (`fs/write_text_file`). ## How it compares > signoff-ui next to assistant-ui and AI Elements: what each does better, and where this kit differs. Source: https://signoff-ui.vercel.app/docs/comparison signoff-ui does one part of an agent product: the step where a person reviews what the agent changed and decides what it may do. [assistant-ui](https://github.com/assistant-ui/assistant-ui) and [AI Elements](https://github.com/vercel/ai-elements) cover the whole chat, and you may well want one of them for it. They compose: render the thread with either, and these components for the review and the approvals ([Inside your chat UI](https://signoff-ui.vercel.app/docs/chat-ui) shows how). ### What they do better - **assistant-ui** is a chat runtime plus composable primitives, with adapters for the AI SDK, LangGraph, AG-UI and others, on React 18 or 19. Its Elements collection (since August 2026) is far wider than this kit, with agent pieces close to it: an approval card, a reviewable diff, a trace waterfall, a tool timeline, a cost meter, agent status and more. It ships releases near daily. - **AI Elements** is Vercel's shadcn registry for AI SDK apps, with the widest coverage of the message surface: conversation, message, prompt input, reasoning, sources, a per-call `Tool` card, a `Confirmation` for tool approvals, a `Context` usage indicator and more. If you want the whole chat in shadcn style, start there. It targets React 19 and Tailwind v4, and its components are written against AI SDK 6 (`ai@^6.0.105`). ### Where signoff-ui differs | Topic | signoff-ui | assistant-ui Elements | AI Elements | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | Diff review | The old and new text of several files, or a patch, with the hunks computed and word-level highlights. Hunks or whole files accepted or rejected, comments on lines, files marked viewed. Returns each file with only the accepted hunks applied, the rejected hunks and the comments, ready to send back as a tool result, or as a unified patch. | One file's hunks, already split; keep or discard per hunk, and apply returns nothing. | None. | | Approvals | What will run, a risk level when you give one, deny with a reason, a second press for `critical`, and automatic decisions from `toolApproval` shown as such. Once, for the session or always, by tool and argument pattern, with rules that also run as AI SDK 7's `toolApproval`, sent with each request (`getRules`) and checked in the route (`parseApprovalRules`); arguments editable before approving; approve all pending; an audit trail. A key for each choice, scoped to focus. | Allow once, allow always or deny. | A `Confirmation` compound component on the tool part's approval state, with approve and deny buttons you wire to the chat. No remembered choices or rules. | | Data in | A `UIMessage` from `useChat` as it is, an AG-UI agent through `useAgUiAgent`, an ACP prompt turn through `signoff-ui/acp`, or assistant-ui's tool calls through `signoff-ui/assistant-ui`. No runtime to adopt. | Props (agent status also has a runtime-bound variant). With the assistant-ui runtime, its docs show how to map approvals, message timing and step usage into them. | AI SDK message parts, passed to each component as props. | | Tool timeline | Every state, with durations measured on the client that leave out time spent waiting for an approval. Sub-agents nest under the call that started them. | A summary of what was done, without durations or states. | A `Tool` card per call, with its state, input and output, without timing. | | Cost | Prices tokens from your rates and shows the prompt-cache hit rate. | Displays cost strings you format. | A `Context` indicator: context window usage, and a total cost looked up from the model id. | | Styling | Tailwind v4, v3 or none: the npm package ships a precompiled stylesheet scoped to its own elements. | Tailwind v4. | Tailwind v4, in a shadcn/ui app. | | React | 18 or 19. | 18 or 19. | 19. | ### Also true of this kit - **It reads AI SDK messages as they are.** Hand `AgentMessage` a `UIMessage` from `useChat` and tool parts become one timeline with inline approvals, sources become citations, and usage metadata feeds the meter, with no per-component data mapping. - **Timings are measured, not supplied.** `useRunTiming` reports time to first token and active time per turn, across review and approval round trips. - **Accessibility is audited.** Focus-scoped shortcuts, live-region announcements and contrast are covered by axe in jsdom and in Chrome; see [Accessibility](https://signoff-ui.vercel.app/docs/accessibility). - **It is smaller in scope.** There is no thread, composer or runtime here, and fewer components. What is missing is listed in [Performance and limits](https://signoff-ui.vercel.app/docs/limits). ## Migrating from @dgesteves/agent-ui-kit > The package is now signoff-ui: swap it, rename the CSS variables, classes and attributes with one command, and what changed in styles.css. Source: https://signoff-ui.vercel.app/docs/migrating-from-agent-ui-kit `@dgesteves/agent-ui-kit` is now `signoff-ui`. 0.5.0 is the first release under the new name. The components, their props and what they do are the same; the names around them changed, so they no longer collide with assistant-ui's (which also uses `data-aui` and `--aui-`). The old package stays on npm at 0.4.3 and gets no new releases. ### What changed | Where | `@dgesteves/agent-ui-kit` 0.4 | `signoff-ui` 0.5 | | ------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | | npm package | `@dgesteves/agent-ui-kit` | `signoff-ui` | | Entry points | `@dgesteves/agent-ui-kit/core`, `/ag-ui`, `/styles.css` and so on | `signoff-ui/core`, `/ag-ui`, `/styles.css` and so on, the same set | | CSS variables | `--aui-bg`, `--aui-accent`, `--aui-radius` … | `--signoff-bg`, `--signoff-accent`, `--signoff-radius` … | | Tailwind utilities | `bg-aui-bg`, `text-aui-fg-muted`, `rounded-aui`, `font-aui-mono` | `bg-signoff-bg`, `text-signoff-fg-muted`, `rounded-signoff`, `font-signoff-mono` | | Tailwind theme variables | `--color-aui-*`, `--radius-aui`, `--font-aui-*`, `--animate-aui-*` | `--color-signoff-*`, `--radius-signoff`, `--font-signoff-*`, `--animate-signoff-*` | | Keyframes | `aui-spin`, `aui-shimmer` … | `signoff-spin`, `signoff-shimmer` … | | Component roots | `data-aui` | `data-signoff` | | `data-slot` values | `approval-card`, `diff-hunk` … | `signoff-approval-card`, `signoff-diff-hunk` … | | shadcn namespace | `@agent-ui-kit` | `@signoff-ui` | | shadcn files | `components/agent-ui/` | `components/signoff-ui/` | | Repository | `github.com/dgesteves/agent-ui-kit` | `github.com/dgesteves/signoff-ui`; the old URLs redirect | | Website and registry | `agent-ui-kit-demo.vercel.app` | `signoff-ui.vercel.app`; the old address redirects | The docs, the playground and the hosted registry moved to `signoff-ui.vercel.app`. The old address redirects there for good, so registry URLs such as `https://agent-ui-kit-demo.vercel.app/r/diff-review.json` keep working. ### From npm Swap the package: ```bash npm uninstall @dgesteves/agent-ui-kit && npm i signoff-ui ``` Then rename the imports, CSS variables, classes and attributes. In a git repository, this rewrites every tracked file that uses them, outside `package.json` and lockfiles: ```bash git grep -lE 'agent-ui-kit|aui' -- ':!*package.json' ':!*lock*' | xargs perl -pi -e 's#\@dgesteves/agent-ui-kit#signoff-ui#g; s#\@agent-ui-kit\b#\@signoff-ui#g; s#--aui-#--signoff-#g; s#data-aui\b#data-signoff#g; s#(?<=[a-z0-9]-)aui(?=-[a-z0-9]|\b)#signoff#g' ``` Review the diff before you commit it. If the app also uses assistant-ui, whose own attribute is `data-aui` and whose variables start with `--aui-`, pass only the files that use this kit to `perl` instead of every match of `git grep`. The command leaves `data-slot` values alone, since other libraries use the same attribute. If your CSS or tests select the kit's, add `signoff-` in front: | Component | `data-slot` values | | ------------------ | ----------------------------------------------------------------------------------------------------------- | | `AgentMessage` | `signoff-agent-message` | | `AgentStatus` | `signoff-agent-status` | | `ApprovalCard` | `signoff-approval-card`, `signoff-approval-approve`, `signoff-approval-deny` | | `DiffReview` | `signoff-diff-review`, `signoff-diff-file`, `signoff-diff-hunk`, `signoff-diff-submit` | | `Markdown` | `signoff-markdown` | | `Reasoning` | `signoff-reasoning` | | `RunMeter` | `signoff-run-meter` | | `Sources` | `signoff-sources` | | `ToolCallTimeline` | `signoff-tool-call-timeline`, `signoff-tool-call`, `signoff-tool-call-trigger`, `signoff-tool-call-details` | | `JsonView` | `signoff-json-view` | ### From the shadcn registry The files you added are your code, and they keep working as they are. To take the new names: 1. Rename the registry in `components.json` from `@agent-ui-kit` to `@signoff-ui`, and point it at `https://signoff-ui.vercel.app/r/{name}.json`. The old URL redirects, so the CLI keeps working until you change it. 2. Add the items again, for example `npx shadcn@latest add @signoff-ui/diff-review`. The new files land in `components/signoff-ui/`, and the tokens written into your CSS are now `--signoff-*`. 3. Carry over any edits you made to the old files, point your imports at `@/components/signoff-ui/...`, then delete `components/agent-ui/` and the `--aui-*` tokens, `--color-aui-*` mappings and `aui-*` keyframes the old items wrote into your CSS. Or keep the old files and run the command above over them and your CSS, which renames their classes and tokens in place. ### `styles.css` and your content If you import `signoff-ui/styles.css` (Tailwind v3 or no Tailwind), two things changed besides the names: - **Your content in a component is yours.** What `renderTool`, `renderData`, `renderOutput`, `renderExtra` and a custom approval `preview` return now sits in an element with `data-signoff-slot`, and the stylesheet's rules stop there. Before, the kit's reset and utilities also styled that content: a `

` lost its margins and a `

    ` its bullets, and in a Tailwind v4 app with its own theme, your `p-4` there used the kit's spacing. If your content relied on that, style it in your own CSS. Components you render in a slot are styled as before. - **Your theme variables are left alone.** The stylesheet used to set Tailwind's `--spacing`, `--text-*` and other theme variables on every component, which also reached your content inside it. It now sets only the kit's `--signoff-*` tokens and Tailwind's per-element `--tw-*` properties, and its keyframes are named `signoff-*`. With Tailwind v4 and `signoff-ui/tailwind.css`, your build generates the utilities as before, so nothing changes there besides the names. ## Components ## DiffReview > Accept or reject an agent’s edits hunk by hunk, across files, and get the patched files back. Source: https://signoff-ui.vercel.app/docs/components/diff-review ### Installation From npm: ```bash npm i signoff-ui ai ``` ```tsx import { DiffReview, reviewToolOutput } from 'signoff-ui'; import 'signoff-ui/styles.css'; // once per app, or @import 'signoff-ui/tailwind.css' with Tailwind v4 ``` Or as source, with the shadcn CLI: ```bash npx shadcn@latest add https://signoff-ui.vercel.app/r/diff-review.json ``` ```tsx import { DiffReview } from '@/components/signoff-ui/diff-review'; ``` ### Usage ```tsx addToolOutput({ tool: 'review_changes', toolCallId, output: reviewToolOutput(result) }) } /> ``` ### How it works Unified or split review of agent edits across files, with word-level highlights. Give it the old and new contents of each file, or a unified `patch` when you only have the diff, and it computes the hunks. The reviewer accepts or rejects each hunk or a whole file, comments on lines or hunks, marks files viewed and applies, and the agent gets back what it needs to act on. #### What the agent gets back `onSubmit(result, { toPatch })` receives: - `files`: each file's `decision` (`accepted`, `rejected`, `partial`, `pending` or `unchanged`), whether it was marked `viewed`, and its `content` with only the accepted hunks applied, unreviewed ones left out. Each file also carries the ids of the hunks accepted, rejected or left pending. - `rejectedHunks`: each rejected hunk with its lines, so the agent knows what not to do again. - `comments`: each with its file, its line range (`side`, `startLine`, `endLine`), its text and the lines it is on (`excerpt`). - `toPatch()`: the accepted changes as a git-style unified diff, which `git apply` reads. `reviewToolOutput(result)` shapes that for the model. It gives a one-sentence summary, each file's decision with its applied contents, the rejected hunks and the comments. A file the reviewer marked viewed has `viewed: true`, and the others have no `viewed` key, so the output stays small. Pass `{ contents: false, patch: toPatch() }` to send a patch instead of contents. It is plain JSON, so it is an AI SDK tool output as it is: `addToolOutput({ tool: 'review_changes', toolCallId, output: reviewToolOutput(result) })`. For an AG-UI agent paused on an interrupt, `reviewResumeEntry(interrupt.id, result)` is the resume entry, with the same payload: pass it to `useAgUiAgent`'s `resolve`. The review is sent once per state of the review. A double click or a repeated ⌘ or Ctrl + Enter sends one review, until a decision, a comment, a viewed mark or the files change. Files compare by content, so an inline `files` array does not count as a change. #### Comments Select lines from a hunk with Shift and the up or down arrow: down starts at its first changed line, up at its last. Or click a line number and Shift-click another. C comments on the selected lines, or on the whole hunk when none are selected, and the hunk's comment button does the same. ⌘ or Ctrl + Enter saves the comment and Escape cancels it. Lines are numbered in the proposed file, or in the original when only removed lines are selected (`side: 'old'`). Control comments with `comments` and `onCommentsChange`, or start from `defaultComments`. #### Files A review of several files lists them first. The list is one tab stop: the arrow keys move between files and Enter goes to one. Any file can be accepted or rejected whole, with its buttons or with Alt + A and Alt + R. A file can be marked viewed with its checkbox, or with V, which also moves to the next file. A viewed file folds away (`collapseViewed={false}` keeps it open), and `viewed` and `onViewedChange` control the marks. A renamed file shows its old path. A binary file reads "Binary file, not shown": it is detected from a NUL character in its contents or from a binary patch, or set with `binary`. Binary files, renames without changes, and files created or deleted empty are each decided as a whole, under the id `` `${path}:file` ``. #### Context Unchanged lines between hunks can be shown, 20 at a time or all at once, from the bar between hunks or with E around the focused hunk. That needs both contents: a patch alone has no lines outside its hunks. #### Large files Large files stay responsive. A file that needs more than a few hundred edits is diffed in a worker and reads "Comparing changes…" until it is done; Apply waits for it. Webpack 5, Next.js and Vite emit the package's worker without configuration. `diffWorker` takes a function that starts your own worker, or `false` to diff on the main thread after the first paint. A file changed in more places than `maxEditLength` (2,000 lines added plus removed) is reviewed as one hunk that replaces the changed region, and the review says so. A long review renders only the rows near the screen. The rest keep their height and their text for screen readers, and every hunk stays in place for focus and for J and K. [Performance and limits](https://signoff-ui.vercel.app/docs/limits) has the numbers. #### Your own buttons `actions` puts your controls in the header, next to the layout toggle: `actions={}`. They are your content, in a `data-signoff-slot` element, so `styles.css` leaves them to your CSS. Keys pressed on them, or in a menu they open, are theirs: J, K, A, R, ⌘ or Ctrl + Enter and the review's other shortcuts don't act from there. Pass nothing, `null` or `false` and the header stays as it was. #### Your own markup [`useDiffReview`](https://signoff-ui.vercel.app/docs/components/use-diff-review) is all of this without the markup. It gives the files, decisions, comments, selection, viewed marks and shown context, and the keyboard. It also gives getters for items, buttons, the comment editor and a file navigator. #### On the server `parseFileChange`, `applyHunks`, `computeReviewResult`, `toPatch`, `reviewToolOutput` and `reviewResumeEntry` are in `signoff-ui/core`, with no React. A server render diffs the files a render may diff, and shows the rest as being compared, so hydration matches. Long changed lines skip word-level highlights rather than stall. ### API reference #### DiffReview Review agent file edits hunk by hunk, in unified or split view, with word-level highlights, and send back what was accepted, what was rejected and what the reviewer said about it. Keyboard: J/K or the arrows move between hunks and Shift+J/K between files; A accepts, R rejects, U resets, Alt+A / Alt+R decide the file and Shift+A / Shift+R everything; Shift+arrows select lines and C comments on them (or on the hunk); E shows more context; V marks the file viewed; ⌘/Ctrl+Enter applies. Large files stay responsive: a big diff is computed in a worker while the file shows "Comparing…", one past `maxEditLength` becomes a single replacing hunk, and long reviews render only the rows near the screen. For your own markup, `useDiffReview` is this component's state and keyboard without it. | Prop | Type | Default | Description | | --- | --- | --- | --- | | `files` (required) | `readonly FileChange[]` | | | | `title` | `ReactNode` | | Heading. Default "Review changes" (`labels.diffReview.title`). | | `actions` | `ReactNode` | | Your own controls in the header, next to the layout toggle, e.g. a "Copy as a patch" button. Your content, like `AgentMessage`'s `renderTool`: it sits in a `data-signoff-slot` element, and keys pressed in it are yours, not the review's shortcuts. | | `view` | `DiffViewMode` | | | | `defaultView` | `DiffViewMode` ('unified' \| 'split') | `'unified'` | | | `onViewChange` | `(view: DiffViewMode) => void` | | | | `submitLabel` | `string` | | The apply button's text. Default "Apply 2 of 5", or "Apply changes" before any is accepted (`labels.diffReview.apply`). | | `readOnly` | `boolean` | `false` | Hide review controls, e.g. once the review has been submitted. | | `context` | `number` | `3` | Lines of context around each change. | | `maxEditLength` | `number` | | Past this many lines added plus lines removed in one file, its changed region (from the first changed line to the last) is shown as one hunk that replaces it, and the review says so. Diffing costs about the square of this number. Default 2,000. | | `autoAdvance` | `boolean` | `true` | After accepting or rejecting with the keyboard, move to the next undecided hunk. | | `collapseViewed` | `boolean` | `true` | Fold a file away once it is marked viewed. | | `headingLevel` | `HeadingLevel` | `3` | Heading level for the title, to fit your document outline. | | `onSubmit` | `(result: DiffReviewResult, review: { toPatch: () => string; }) => void \| PromiseLike` | | Called with the reviewed result, and a `toPatch` that gives the accepted changes as a unified diff. Pending hunks are not applied. Fires once per review state, so a double click or a repeated ⌘/Ctrl+Enter sends one review: submitting again needs a changed decision, comment or viewed mark, changed `files`, the returned promise to settle, or the handler to throw. Files compare by content and the rest by value: an equal new array or object does not count as a change. | | `decisions` | `Readonly>` | | Controlled decisions keyed by item id: a hunk's `${path}:${index}`, or `${path}:file` for a file with no hunks (binary, renamed or empty). A repeated path gets `${path}#2`, `#3` and so on, skipping a suffix another file's path already has. | | `defaultDecisions` | `Readonly>` | | | | `onDecisionsChange` | `(decisions: Record) => void` | | | | `comments` | `readonly DiffReviewComment[]` | | Controlled comments. | | `defaultComments` | `readonly DiffReviewComment[]` | | | | `onCommentsChange` | `(comments: DiffReviewComment[]) => void` | | | | `viewed` | `Readonly>` | | Controlled "Viewed" marks, by file id (the path, or `path#2`… for a repeated path). | | `defaultViewed` | `Readonly>` | | | | `onViewedChange` | `(viewed: Record) => void` | | | | `diffWorker` | `DiffWorkerFactory \| false` | | Where files too large to diff while rendering are diffed: by default in the package's own worker, which bundlers with worker support emit (webpack 5 and Next.js, Vite, Parcel). Pass a function that starts your own `Worker` running `signoff-ui`'s diff worker, or `false` to diff them on the main thread after the first paint. Without a working worker the latter is the fallback. | | `labels` | `SignoffLabelsInput` | | Words to use instead of the English defaults, for what the hook announces and names: see `SignoffLabelsProvider`. | Other props go to the root `
    `: `className`, `id`, `aria-*`, `data-*` and event handlers. #### FileChange A file edit proposed by an agent. Provide either full contents or a unified patch. | Prop | Type | Default | Description | | --- | --- | --- | --- | | `path` (required) | `string` | | | | `oldPath` | `string` | | Previous path, for renames. | | `oldContent` | `string` | | Original file contents. Omit (or pass '') for new files. | | `newContent` | `string` | | Proposed file contents. Omit (or pass '') for deletions. | | `patch` | `string` | | A unified diff for this file, used when contents are not available. | | `language` | `string` | | Language id for highlighting, inferred from the extension when omitted. | | `binary` | `boolean` | | A binary file: shown as "Binary file, not shown" and decided as a whole. Detected when omitted: contents with a NUL character in their first 8,000, or a patch that says `Binary files … differ`. | #### DiffReviewResult | Prop | Type | Default | Description | | --- | --- | --- | --- | | `files` (required) | `DiffReviewFileResult[]` | | | | `accepted` (required) | `number` | | Decisions across the review: hunks, and files decided as a whole. | | `rejected` (required) | `number` | | | | `pending` (required) | `number` | | | | `rejectedHunks` (required) | `DiffReviewRejectedHunk[]` | | | | `comments` (required) | `DiffReviewResultComment[]` | | | #### DiffReviewFileResult | Prop | Type | Default | Description | | --- | --- | --- | --- | | `path` (required) | `string` | | | | `status` (required) | `FileStatus` ('added' \| 'deleted' \| 'modified' \| 'renamed') | | | | `decision` (required) | `FileDecision` ('accepted' \| 'rejected' \| 'partial' \| 'pending' \| 'unchanged') | | `accepted` when every change was accepted, `rejected` when every one was, `partial` for some of each or some left undecided, `pending` when none was decided, `unchanged` with nothing to decide. | | `viewed` (required) | `boolean` | | Marked "Viewed" by the reviewer. | | `content` (required) | `string` | | File contents with only accepted hunks applied. `undefined` for patch-only input. | | `accepted` (required) | `string[]` | | | | `rejected` (required) | `string[]` | | | | `pending` (required) | `string[]` | | | | `oldPath` | `string` | | The path before, for a renamed file. | | `binary` | `true` | | | #### DiffReviewRejectedHunk A hunk the reviewer turned down, with its lines, so the agent knows exactly what not to do. | Prop | Type | Default | Description | | --- | --- | --- | --- | | `id` (required) | `string` | | | | `path` (required) | `string` | | | | `header` (required) | `string` | | | | `diff` (required) | `string` | | The hunk as unified diff lines: ` ` unchanged, `-` removed, `+` added. | #### DiffReviewComment A reviewer's comment for the agent, on lines of a hunk, a whole hunk or a whole file. | Prop | Type | Default | Description | | --- | --- | --- | --- | | `id` (required) | `string` | | Unique within the review. | | `fileId` (required) | `string` | | The file's id in the review: its path, or `path#2`… for a path that repeats. | | `path` (required) | `string` | | | | `target` (required) | `'lines' \| 'hunk' \| 'file'` | | What it is on. | | `text` (required) | `string` | | | | `hunkId` | `string` | | The hunk the lines are in, for `lines` and `hunk`. | | `side` | `CommentSide` | | Which file the line numbers count in: the proposed one (`new`), or for removed lines only, the original (`old`). Absent for a comment on a whole file. | | `startLine` | `number` | | | | `endLine` | `number` | | | #### DiffReviewResultComment | Prop | Type | Default | Description | | --- | --- | --- | --- | | `id` (required) | `string` | | Unique within the review. | | `fileId` (required) | `string` | | The file's id in the review: its path, or `path#2`… for a path that repeats. | | `path` (required) | `string` | | | | `target` (required) | `'lines' \| 'hunk' \| 'file'` | | What it is on. | | `text` (required) | `string` | | | | `excerpt` | `string` | | The lines commented on, as unified diff lines, when they are in the review. | | `hunkId` | `string` | | The hunk the lines are in, for `lines` and `hunk`. | | `side` | `CommentSide` | | Which file the line numbers count in: the proposed one (`new`), or for removed lines only, the original (`old`). Absent for a comment on a whole file. | | `startLine` | `number` | | | | `endLine` | `number` | | | Extends `DiffReviewComment`. #### FileDecision How a file came out of the review. `partial`: some of its changes were accepted, not all. ```ts type FileDecision = 'accepted' | 'rejected' | 'partial' | 'pending' | 'unchanged'; ``` #### HunkDecision ```ts type HunkDecision = 'accepted' | 'rejected' | 'pending'; ``` #### DiffViewMode ```ts type DiffViewMode = 'unified' | 'split'; ``` ### Accessibility | Keys | Action | | --- | --- | | J K | Next or previous hunk (↓ and ↑ too, from a hunk) | | ⇧J ⇧K | First hunk of the next or previous file | | A | Accept the hunk, and move to the next undecided one | | R | Reject the hunk, and move on (X works too) | | U | Reset the hunk | | ⌥A ⌥R | Accept or reject every hunk in the file (Alt on Windows and Linux) | | ⇧A ⇧R | Accept or reject every hunk | | ⇧↓ ⇧↑ | Select lines in the hunk, from its first or last changed line | | ↓ ↑ | With lines selected, move the selection by a line | | Esc | Clear the selection, or cancel the comment being written | | C | Comment on the selected lines, or on the hunk | | ⌘/Ctrl ↵ | In a comment, save it; elsewhere, apply the review | | E | Show 20 more unchanged lines above and below the hunk | | V | Mark the file viewed, fold it and go to the next file | - A `
    ` named by its title. Hunks use a roving tabindex, so the whole review is one tab stop that J, K and the arrows move through. - Each hunk is a group named like "Hunk 2 of 4, app/api/chat/route.ts, lines 12 to 20, accepted"; a file decided as a whole, like "Change 5 of 6, logo.png, binary file, not reviewed". - Every shortcut is also a button: "Accept hunk 2", "Reject hunk 2", "Reset hunk 2" with `aria-pressed`, "Comment on hunk 2", "Accept file app/route.ts", the "Viewed app/route.ts" checkbox, the layout toggle and Apply. Shortcuts only work while focus is inside the review, and not on your own controls in `actions`. - Selected lines are announced as they change, line by line: "Added: const ip = … Lines 15 to 17 selected." The comment editor is a labeled text area ("Comment on lines 15 to 17, for the agent") that takes focus, and focus returns to the hunk when it closes. - The file list is a navigation landmark ("Files in this review") with one tab stop: the arrow keys, Home and End move through it, and each entry reads its path, how many changes are decided and whether it is viewed. A folded file has an expand button with `aria-expanded`. - Line numbers and the buttons that show unchanged lines are clickable but out of the tab order (Shift and the arrows select lines, E shows context); the numbers are hidden from screen readers, which read the lines themselves. - Progress, decisions, comments, viewed marks and shown context are announced: "Hunk 2 of 4 accepted. 2 remaining." - Changed lines keep their + and − glyphs and are read as "Added:" or "Removed:", so cyan and magenta never carry the meaning alone. - A long review renders only the rows near the screen. The rest keep their place as visually hidden text, read in the same order with the same "Added:" and "Removed:", and every hunk stays rendered, so its name, focus and J and K never depend on scrolling. - A file still being diffed reads "Comparing changes…" and is `aria-busy`; the review itself is not, so its announcements go out meanwhile. A file past `maxEditLength` says it is shown as one replacing hunk. ### Theming Slots (`data-slot`): `signoff-diff-hunk`, `signoff-diff-review`, `signoff-diff-submit`, `signoff-diff-files`, `signoff-diff-file`, `signoff-diff-fallback`, `signoff-diff-comment`, `signoff-diff-comment-editor`, `signoff-diff-chunk`, `signoff-diff-gap`. - `data-fallback` on `signoff-diff-file`: 'replace', for a file past maxEditLength - `data-viewed` on `signoff-diff-file`: set on a file marked viewed - `data-decision` on `signoff-diff-file`: HunkDecision, on hunks; FileDecision, on files - `data-line-index` - `data-rendered` on `signoff-diff-chunk`: set while the rows are rendered, near the screen - `data-line` - `data-selected`: set on a selected line Tokens it uses: `--signoff-accent`, `--signoff-accent-fg`, `--signoff-add-bg`, `--signoff-add-strong`, `--signoff-bg`, `--signoff-border`, `--signoff-border-strong`, `--signoff-del-bg`, `--signoff-del-strong`, `--signoff-fg`, `--signoff-fg-muted`, `--signoff-fg-subtle`, `--signoff-font-mono`, `--signoff-font-sans`, `--signoff-hot`, `--signoff-hot-fg`, `--signoff-on-accent`, `--signoff-radius`, `--signoff-ring`, `--signoff-surface`, `--signoff-surface-2`, `--signoff-warn`, `--signoff-warn-fg`. See https://signoff-ui.vercel.app/docs/getting-started#theming. ## useDiffReview > DiffReview without its markup: the review’s state, keyboard and result, for your own design system. Source: https://signoff-ui.vercel.app/docs/components/use-diff-review ### Installation From npm: ```bash npm i signoff-ui ai ``` ```tsx import { useDiffReview, reviewToolOutput } from 'signoff-ui'; import 'signoff-ui/styles.css'; // once per app, or @import 'signoff-ui/tailwind.css' with Tailwind v4 ``` Or as source, with the shadcn CLI: ```bash npx shadcn@latest add https://signoff-ui.vercel.app/r/use-diff-review.json ``` ```tsx import { useDiffReview } from '@/components/signoff-ui/use-diff-review'; ``` ### Usage ```tsx function MyReview({ files, onSubmit }: { files: FileChange[]; onSubmit: (r: DiffReviewResult) => void }) { const review = useDiffReview({ files, onSubmit }); return (
    {review.items.map((item, i) => (
    ))} {review.draft &&