Skip to content

Components

Each component in isolation, rendered from AI SDK 6 and 7 message parts (or an AG-UI agent and an ACP turn, at the end), with how to install it from npm or as a shadcn registry item. Everything here is interactive and keyboard accessible; each component's docs page has its props, keyboard and theming hooks. The frames follow the site's theme: switch it in the header to see either palette.

<DiffReview />

Accept or reject agent edits hunk by hunk or file by file, comment on lines, and send the agent what you applied, rejected and said. Large files are diffed in a worker and render only what is near the screen.

Docs and props for DiffReview

Review changes

4 files · 4 hunks · +21 −1

Added:lib/ratelimit.ts+10 −0
@@ -1,0 +1,10 @@accepted
Added: import { Ratelimit } from '@upstash/ratelimit';
Added: import { redis } from '@/lib/redis';
Added:
Added: /** 10 requests per 10 seconds per key, sliding window. */
Added: export const ratelimit = new Ratelimit({
Added: redis,
Added: limiter: Ratelimit.slidingWindow(10, '10 s'),

Comment On line 7

Read the window from an env var, so staging can use a longer one.

Added: prefix: 'ratelimit:chat',
Added: analytics: true,
Added: });
Modified:app/api/chat/route.ts+11 −1
@@ -1,6 +1,7 @@
import { convertToModelMessages, streamText, type UIMessage } from 'ai';
import { openai } from '@ai-sdk/openai';
import { tools } from '@/lib/tools';
Added: import { ratelimit } from '@/lib/ratelimit';
export const maxDuration = 30;
4 unchanged lines
@@ -11,6 +12,15 @@
].join(' ');
export async function POST(req: Request) {
Added: const ip = req.headers.get('x-forwarded-for') ?? 'anonymous';
Added: const { success, reset } = await ratelimit.limit(ip);
Added: if (!success) {
Added: return new Response('Too many requests', {
Added: status: 429,
Added: headers: { 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)) },
Added: });
Added: }
Added:
const { messages }: { messages: UIMessage[] } = await req.json();
if (messages.length === 0) return new Response('Empty conversation', { status: 400 });
const modelMessages = convertToModelMessages(messages);
1 unchanged line
@@ -18,7 +28,7 @@rejected
console.info('chat', { turns: messages.length, last: lastUserMessage?.id });
const result = streamText({
Removed: model: openai('gpt-4o'),
Added: model: openai('gpt-4.1-mini'),
system: SYSTEM,
messages: modelMessages,
tools,
4 unchanged lines
Renamed:docs/rate-limiting.md to docs/guides/rate-limiting.md
Renamed from docs/rate-limiting.md, without changes.
Added:public/errors/429.png
Binary file, not shown.

2/6 reviewed · 0 of 4 files viewed · 1 comment · unreviewed hunks are skipped

Keyboard: J or K to move between hunks, Shift J or Shift K between files, A to accept, R to reject, U to reset, Alt A or Alt R for the whole file, Shift A or Shift R for every hunk, Shift with the up or down arrow to select lines, C to comment on them or on the hunk, E to show more unchanged lines, V to mark the file viewed, Ctrl Enter to apply.

npm i signoff-ui ai
import { DiffReview, reviewToolOutput } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

useDiffReview(options)

The same review in markup of its own: this list uses the site’s classes and none of the kit’s, and the hook brings the keyboard, decisions, comments and the result.

Docs and props for useDiffReview
  1. @@ -1,6 +1,7 @@not decided
    + import { ratelimit } from '@/lib/ratelimit';
  2. @@ -11,6 +12,15 @@not decided
    +   const ip = req.headers.get('x-forwarded-for') ?? 'anonymous';
    +   const { success, reset } = await ratelimit.limit(ip);
    +   if (!success) {
    +     return new Response('Too many requests', {
    +       status: 429,
    +       headers: { 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)) },
    +     });
    +   }
    + 
  3. @@ -18,7 +28,7 @@not decided
    -     model: openai('gpt-4o'),
    +     model: openai('gpt-4.1-mini'),

J and K move, A and R decide, C writes a note.

npm i signoff-ui ai
import { useDiffReview, reviewToolOutput } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

<ApprovalCard />

Approve or deny a tool call once, for the session or always, with what will run, its risk, the arguments editable first, a reason and a key for each choice.

Docs and props for ApprovalCard

Approval required

High risk

Run command

Installs a package from the npm registry and updates package.json and pnpm-lock.yaml.

~/acme/chat-app
pnpm add @upstash/ratelimit
Calls a lasting decision covers

Remembered for run_command calls where:

* matches any text but shell operators (; & | > < and backticks); ** matches anything.

npm i signoff-ui ai
import { ApprovalCard, ToolApprovalCard, ToolApprovalBatch } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

useApprovalPolicy()

Approval rules: answer once, for this session or always, by tool and argument pattern. The next call a rule covers is answered without asking, and every decision goes to the audit trail.

Docs and props for useApprovalPolicy
The agent asks to run

Pick a command above to see its approval.

npm i signoff-ui ai
import { useApprovalPolicy, webStorageRules, setToolInput } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

<ToolCallTimeline />

Every tool state with durations, a waterfall, and expandable input and output.

Docs and props for ToolCallTimeline
  1. ENOENT: no such file or directory, open 'middleware.ts'

npm i signoff-ui ai
import { ToolCallTimeline } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

<AgentStatus />

Run state in one pill, announced through a live region.

Docs and props for AgentStatus
Thinking
Workingread_file3.42s
Waiting for approvalrun_command
Done21.8s
Stopped6.24s
Rate limited by provider
npm i signoff-ui ai
import { AgentStatus, deriveAgentState } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

<RunMeter />

Tokens, estimated cost and latency; compact for headers, expanded for panels.

Docs and props for RunMeter
variant="expanded"

Run

mock-agent-1
Tokens
41.1k
Est. cost
$0.056
  • Input 38.7k(28.8k cached)
  • Output 2.41k(96 reasoning)
TTFT
684ms
Total
21.8s
Cache hit
74%

$0.032 input · $0.024 output · $2.5/$10 per 1M

variant="compact"
38.7k input tokens, 2.41k output tokens, estimated cost $0.056, time to first token 684ms, total 21.8s

Pass totalUsage from streamText through message metadata, and timing from useRunTiming(status, messages). Cost is an estimate from the pricing you supply.

npm i signoff-ui ai
import { RunMeter, useRunTiming } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

<Sources />

Citations as compact chips or cards; inline [n] markers link to them.

Docs and props for Sources
npm i signoff-ui ai
import { Sources, getSourceParts } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

Theming

Every color, radius and font is a CSS variable. Light is the default, .dark switches, and any subtree can override tokens.

Theming docs for theming
Dark
Waiting for approvalrun_command
  1. ENOENT: no such file or directory, open 'middleware.ts'

Light
Waiting for approvalrun_command
  1. ENOENT: no such file or directory, open 'middleware.ts'

Custom tokens
Waiting for approvalrun_command
  1. ENOENT: no such file or directory, open 'middleware.ts'

Labels

Every word the components show or announce is a label. Here the approval and the review are in Portuguese, from one SignoffLabelsProvider.

Labels docs for labels

Aprovação necessária

Risco alto

Executar comando

Instala um pacote do registo npm e atualiza o package.json.

~/acme/chat-app
pnpm add @upstash/ratelimit
Chamadas abrangidas por uma decisão duradoura

Fica memorizado para run_command quando:

* corresponde a qualquer texto exceto operadores da shell (; & | > < e acentos graves); ** a tudo.

Rever alterações

1 ficheiro · 3 blocos · +11 −1

Modificado:app/api/chat/route.ts+11 −1
@@ -1,6 +1,7 @@
import { convertToModelMessages, streamText, type UIMessage } from 'ai';
import { openai } from '@ai-sdk/openai';
import { tools } from '@/lib/tools';
Adicionada: import { ratelimit } from '@/lib/ratelimit';
export const maxDuration = 30;
4 linhas inalteradas
@@ -11,6 +12,15 @@
].join(' ');
export async function POST(req: Request) {
Adicionada: const ip = req.headers.get('x-forwarded-for') ?? 'anonymous';
Adicionada: const { success, reset } = await ratelimit.limit(ip);
Adicionada: if (!success) {
Adicionada: return new Response('Too many requests', {
Adicionada: status: 429,
Adicionada: headers: { 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)) },
Adicionada: });
Adicionada: }
Adicionada:
const { messages }: { messages: UIMessage[] } = await req.json();
if (messages.length === 0) return new Response('Empty conversation', { status: 400 });
const modelMessages = convertToModelMessages(messages);
1 linha inalterada
@@ -18,7 +28,7 @@
console.info('chat', { turns: messages.length, last: lastUserMessage?.id });
const result = streamText({
Removida: model: openai('gpt-4o'),
Adicionada: model: openai('gpt-4.1-mini'),
system: SYSTEM,
messages: modelMessages,
tools,
4 linhas inalteradas

0/3 revistos

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, Ctrl Enter para aplicar.

<AgentMessage />

A whole assistant UIMessage: reasoning, streaming markdown, grouped tool calls and sources.

Docs and props for AgentMessage
npm i signoff-ui ai
import { AgentMessage } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

<Markdown />

Streaming-safe markdown: syntax that hasn’t closed yet never flashes raw, and no raw HTML.

Docs and props for Markdown

The limiter belongs at the top of the handler, before streamText opens the stream:

  1. Read the client's IP from x-forwarded-for.
  2. Call ratelimit.limit(ip) and return a 429 when it fails.
WindowRequestsBurst
Sliding, 10 s10smoothed
Fixed, 10 s10up to 20 at the edge
ts
const { success } = await limit(ip);if (!success) return tooMany();
npm i signoff-ui ai
import { Markdown } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

<Reasoning />

Open while the model thinks, then one line: “Thought for 1.7s”.

Docs and props for Reasoning
npm i signoff-ui ai
import { Reasoning } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

useAgUiAgent(agent)

AG-UI agents (LangGraph, CrewAI, Mastra, Pydantic AI) through the same components. This one is a real @ag-ui/client agent replaying a LangGraph-style run: steps, streamed tool arguments, and an interrupt that resumes the run when you answer it.

Docs and props for useAgUiAgent
npm i signoff-ui ai @ag-ui/client
import { useAgUiAgent } from 'signoff-ui/ag-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';

Agent Client Protocol

An ACP prompt turn through the same components, with signoff-ui/acp: a scripted agent sends its session updates and asks permission to edit a file, whose diff the card shows, and to run the tests. The card offers the options the agent sent.

ACP docs for the Agent Client Protocol
Idle
Idle