Installation
npx shadcn@latest add @pixeldosa/response-versionsRequires `cn` at `@/lib/utils`. Append a new version on regenerate; never replace one.
Usage
"use client";
import { ResponseVersions } from "@/components/ui/response-versions";
<ResponseVersions
versions={versions}
activeId={activeId}
onActiveChange={setActiveId}
onRegenerate={regenerate}
onKeep={keep}
busy={regenerating}
/>
Append a version when you regenerate. Never replace one.
Why it exists
Regenerating is the most common thing people do with an AI answer, and in most products it destroys the previous one. That makes a better answer a gamble, and it's why people paste answers into a notes app before trying again.
Features
- Nothing is lost. Regenerating appends. The count says how many you have.
- Compare with the previous one, side by side, with the current version outlined.
- Keep one and hand it back to your app as the answer.
- The old answer stays readable while a new one is being written. The busy state lives in the button, not over the text.
- Still, except a single fade when you switch versions.
Examples
Regenerating appends, so the list is the history:
const regenerate = async () => {
setBusy(true);
const next = await model.regenerate();
setVersions((previous) => [...previous, { id: next.id, label: "Another try", content: next.text }]);
setActiveId(next.id);
setBusy(false);
};
Inside a chat turn, under the answer:
<Message role="assistant" author="Assistant">
<MessageContent>
<ResponseVersions versions={turn.versions} activeId={turn.activeId} onActiveChange={choose} />
</MessageContent>
</Message>
Props
| Prop | Type | Notes |
|---|---|---|
versions | { id, content, label?, time? }[] | Required, oldest first. time is formatted by you. |
activeId | string | Required. |
onActiveChange | (id: string) => void | Required. |
onRegenerate | () => void | Adds the Regenerate button. |
onKeep | (id: string) => void | Adds "Keep this one". |
busy | boolean | True while a new version is being produced. |
label | string | Names the control group. Defaults to "Answer versions". |
Accessibility
- The controls are a labelled group; the arrows are named "Previous version" and "Next version" and disable at the ends.
- The position ("Showing version 2 of 3") is announced, not the content, so switching doesn't re-read the whole answer.
- In compare mode each side is a labelled region, and the current one says "showing now" rather than relying on the outline.
When not to use
- A document's revision history. This is for a handful of attempts at one answer.
- Comparing two arbitrary versions. Comparison is deliberately current-versus-previous; the question people actually ask is "is this better than the last try?".
why it's built this way
Engineering Notes
Versions are a caller-owned array and the component holds only the compare toggle, because the history has to outlive this component's mount — a version list kept in local state would be lost by any parent re-mount, which is exactly the loss the component exists to prevent. Comparison is deliberately limited to the current version and the one immediately before it: an arbitrary two-version picker doubles the controls for a case nobody had, and the common question is always "is this better than the last try?". The compare toggle resets itself when there is no previous version, so a shrinking list cannot leave the component in a state it cannot render. The announcement says which version is showing rather than the content, so switching versions with a screen reader gives position, not a re-read of the whole answer.
how it moves, and when it doesn't
Motion Notes
One animation: the new version fades in over `--pd-duration-base`, keyed on the version id so a re-render with the same version never re-animates. Regeneration is the only busy state, and it lives in the button's own label ("Writing another…") rather than a spinner over the text, because the old answer stays readable the entire time. Under `prefers-reduced-motion` the fade is dropped and the switch is instant.