Browse all components
Response Versions UI
The 1/3 pager after a regenerate — previous and next versions, branch markers, continue-from-here, and honest generating, failed and stopped states.
Preview
The only answer so far. Nothing to page through yet.
Presets
Props
Also takes onVersionChange, onRegenerate, onContinueFrom, onCompare.
Agent promptClaude Code · Cursor · any agent
Add the Response Versions UI component from the Scrim UI registry to this project and use it in the configuration described below.
Response Versions UI — The 1/3 pager after a regenerate — previous and next versions, branch markers, continue-from-here, and honest generating, failed and stopped states.
## 1. Install
```bash
npx shadcn@latest add @scrimui/response-versions
```
This writes a single file to `components/ui/response-versions.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/response-versions to the same path by hand; nothing in the file depends on shadcn.
## 2. Use it
```tsx
import { ResponseVersions } from "@/components/ui/response-versions";
const VERSIONS = [
{ id: "v1", status: "ready", content: <p>{"The only answer so far. Nothing to page through yet."}</p> },
];
<ResponseVersions
versions={VERSIONS}
onVersionChange={() => {}}
onRegenerate={() => {}}
onContinueFrom={() => {}}
onCompare={() => {}}
/>
```
## 3. The configuration that matters
This is the component's "Single" state — One version, no pager — only the regenerate entry shows.
These props were set deliberately and should be preserved as written:
- `versions` — Versions (## status [from vN], then the answer): pass the `VERSIONS` value declared above the element. Keep its shape; the entries themselves are placeholder content to replace with real data.
Every other prop is at its default; leave those off the call site rather than writing the default value out.
## 4. Wire the handlers
`onVersionChange`, `onRegenerate`, `onContinueFrom`, `onCompare` are stubs in the snippet above (`() => {}`). Connect them to this project's own state and data layer instead of shipping the empty functions.
Reference: https://scrimui.dev/components/response-versionsFollows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.
Install
npx shadcn@latest add @scrimui/response-versionsComponent source
React + Tailwind component using shadcn/ui (badge, button). The install command adds these dependencies automatically.
When copying source manually, install the required primitives first:
npx shadcn@latest add badge button"use client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import * as React from "react";
/**
* The version navigator that appears after a regenerate.
*
* Message Actions can fire "regenerate", but what happens next is usually
* left broken: the old answer is overwritten, or the new one shoves the
* reader somewhere they did not ask to go. This component owns the state
* model for what comes after:
*
* **Versions are identified by id, never by array position.** The caller
* passes a stable `id` per version; the pager's "2 / 3" is display only.
* Retries and branches append — nothing is ever overwritten in place.
*
* **A new version must not steal the reader's place.** The navigator
* auto-advances to an arriving version only while the reader is already
* sitting on the newest one. Someone paging back through v1 while v4
* streams in stays on v1; the pager badge tells them something new landed.
*
* **A failed version keeps its partial content.** The half-written answer
* is often still useful, and it is always evidence. It stays on screen
* under the failure notice instead of vanishing.
*
* **Branching is marked, not diagrammed.** When a version was created off
* an older one (an edited prompt, a continue-from-here), a chip names the
* parent. A full branch tree is a pattern-level concern.
*/
export type ResponseVersionStatus = "ready" | "generating" | "failed" | "stopped";
export type ResponseVersion = {
id: string;
content: React.ReactNode;
status?: ResponseVersionStatus;
/** Id of the version this one was branched off, if any. */
branchedFrom?: string;
};
export type ResponseVersionsProps = {
versions: ResponseVersion[];
/** Seeds the navigator. After that, position is internal state. */
defaultCurrentId?: string;
onVersionChange?: (id: string) => void;
/** Ask the caller for another version. Appending it is the caller's job. */
onRegenerate?: () => void;
/** Continue the conversation from a version that is not the latest. */
onContinueFrom?: (id: string) => void;
/** Open a comparison of the current version against another. */
onCompare?: (currentId: string) => void;
className?: string;
};
/* ------------------------------------------------------------------ */
/* Icons */
/* ------------------------------------------------------------------ */
function ChevronLeftIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="13" height="13" {...props}>
<path d="m15 18-6-6 6-6" />
</svg>
);
}
function ChevronRightIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="13" height="13" {...props}>
<path d="m9 18 6-6-6-6" />
</svg>
);
}
function BranchIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="11" height="11" {...props}>
<path d="M6 3v12" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
);
}
function RegenerateIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="13" height="13" {...props}>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
<path d="M3 21v-5h5" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* ResponseVersions */
/* ------------------------------------------------------------------ */
export function ResponseVersions({
versions,
defaultCurrentId,
onVersionChange,
onRegenerate,
onContinueFrom,
onCompare,
className = "",
}: ResponseVersionsProps) {
const lastId = versions.length > 0 ? versions[versions.length - 1].id : undefined;
const [currentId, setCurrentId] = React.useState(defaultCurrentId ?? lastId);
/* Auto-follow the newest version, but only while the reader is already
there. Render-phase adjustment: when the versions array gains a new
tail, advance iff the previous tail was the one on screen. A reader
paging through older versions is never yanked forward. */
const [followed, setFollowed] = React.useState(lastId);
if (lastId !== followed) {
setFollowed(lastId);
if (currentId === followed && lastId) setCurrentId(lastId);
}
if (versions.length === 0) return null;
const current = versions.find((v) => v.id === currentId) ?? versions[versions.length - 1];
const index = versions.indexOf(current);
const status = current.status ?? "ready";
const branchIndex = current.branchedFrom
? versions.findIndex((v) => v.id === current.branchedFrom)
: -1;
function go(id: string) {
setCurrentId(id);
onVersionChange?.(id);
}
const navBtn =
"rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30 disabled:hover:bg-transparent";
return (
<div className={className}>
{status === "generating" && (
<p className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary" />
Generating a new version…
</p>
)}
<div>{current.content}</div>
{status === "failed" && (
<div className="mt-2 flex items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 dark:border-red-900/50 dark:bg-red-950/40">
<span className="text-xs text-red-700 dark:text-red-300">
This version failed to finish — the partial answer is kept above.
</span>
{onRegenerate && (
<Button variant="destructive" size="sm"
type="button"
onClick={onRegenerate}
className="shrink-0 rounded-md bg-red-600 px-2.5 py-1 text-xs font-medium text-white transition-opacity hover:opacity-90"
>
Retry
</Button>
)}
</div>
)}
{status === "stopped" && (
<p className="mt-2 text-xs text-muted-foreground">
Stopped — this version is incomplete.
</p>
)}
{(versions.length > 1 || onRegenerate) && (
<div role="group" aria-label="Response versions" className="mt-2 flex items-center gap-1">
{versions.length > 1 && (
<>
<Button variant="ghost" size="icon-sm"
type="button"
aria-label={`Previous version (${index} of ${versions.length})`}
disabled={index === 0}
onClick={() => go(versions[index - 1].id)}
className={navBtn}
>
<ChevronLeftIcon />
</Button>
<span
aria-live="polite"
className="min-w-9 text-center text-xs tabular-nums text-muted-foreground"
>
{index + 1} / {versions.length}
</span>
<Button variant="ghost" size="icon-sm"
type="button"
aria-label={`Next version (${index + 2} of ${versions.length})`}
disabled={index === versions.length - 1}
onClick={() => go(versions[index + 1].id)}
className={navBtn}
>
<ChevronRightIcon />
</Button>
{branchIndex >= 0 && (
<Badge variant="secondary" className="ml-1 flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
<BranchIcon />
from v{branchIndex + 1}
</Badge>
)}
</>
)}
<span className="flex-1" />
{onCompare && versions.filter((v) => (v.status ?? "ready") === "ready").length > 1 && (
<Button variant="ghost" size="sm"
type="button"
onClick={() => onCompare(current.id)}
className="rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Compare
</Button>
)}
{index < versions.length - 1 && onContinueFrom && status === "ready" && (
<Button variant="ghost" size="sm"
type="button"
onClick={() => onContinueFrom(current.id)}
className="rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Continue from here
</Button>
)}
{onRegenerate && status !== "failed" && (
<Button variant="ghost" size="icon-sm"
type="button"
aria-label="Regenerate response"
onClick={onRegenerate}
className={navBtn}
>
<RegenerateIcon />
</Button>
)}
</div>
)}
</div>
);
}
When to use it
- Give every version a stable id — array position breaks the moment a retry lands mid-list.
- Auto-advance to a new version only while the reader is already on the newest one.
- Keep a failed version's partial text on screen; it is often still useful, and always evidence.
- Mark which version a branch came from — one chip, not a tree diagram.
- Hide the pager at one version. A 1 / 1 control teaches the reader to ignore it.
What breaks in production
- Overwriting the old answer in place, so an accidental regenerate destroys a good response.
- Jumping the reader to the newest version while they were re-reading an older one.
- Treating a stopped stream as a finished answer — partial output must say it is partial.
- Letting compare open two identical versions because failed and generating ones were not filtered.
Related Components
Render an AI reply token by token like ChatGPT's typing effect — blinking cursor, stop button, and no layout jump as the text grows.
The user's chat bubble — right-aligned, with hover actions to copy, edit the prompt, or regenerate the answer.
The action row under an AI reply — copy, regenerate, share and thumbs up/down feedback, revealed on hover.
What to show when a generation fails — a plain-English reason, a retry button, and a countdown for rate limits.