Response Branches New
Regenerated replies you can page through, with copy, rate, feedback, share and read-aloud actions.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-response-branches.json02
Or copy the source below (3 files). Dependencies: lucide-react.
Source
components/niceui/ai/ResponseBranches.tsx
import { useEffect, useRef, useState } from 'react';
import { Bot, Check, ChevronLeft, ChevronRight, Copy, RefreshCw, Share2, ThumbsDown, ThumbsUp, Volume2 } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useClickOutside, useEscape, usePresence } from '@/components/niceui/ui/motion/hooks';
import { Markdown, useStream } from './stream';
const KEYFRAMES =
'@keyframes rb-in-r{from{opacity:0;transform:translateX(16px);filter:blur(3px)}to{opacity:1;transform:none;filter:none}}' +
'@keyframes rb-in-l{from{opacity:0;transform:translateX(-16px);filter:blur(3px)}to{opacity:1;transform:none;filter:none}}' +
'@keyframes rb-eq{0%,100%{transform:scaleY(.35)}50%{transform:scaleY(1)}}';
const btn = 'focus-ring flex h-7 w-7 items-center justify-center rounded-md text-ink-400 transition-all hover:bg-white/5 hover:text-ink-100 active:scale-90 disabled:pointer-events-none disabled:opacity-40';
export type BranchPickerProps = { index: number; count: number; onChange: (index: number) => void; className?: string };
/** “‹ 2 / 3 ›” navigation between regenerated versions of a reply. */
export function BranchPicker({ index, count, onChange, className }: BranchPickerProps) {
return (
<div className={cn('flex items-center', className)}>
<button type="button" onClick={() => onChange(index - 1)} disabled={index === 0} className={btn} aria-label="Previous version">
<ChevronLeft className="h-4 w-4" />
</button>
<span className="min-w-[38px] text-center font-mono text-[11px] tabular-nums text-ink-400" aria-live="polite">
{index + 1} / {count}
</span>
<button type="button" onClick={() => onChange(index + 1)} disabled={index >= count - 1} className={btn} aria-label="Next version">
<ChevronRight className="h-4 w-4" />
</button>
</div>
);
}
export type Feedback = { vote: 'up' | 'down'; reasons?: string[]; comment?: string };
export type ResponseActionsProps = {
/** Text copied by the copy action. */
text: string;
onRegenerate?: () => void;
regenerating?: boolean;
onFeedback?: (feedback: Feedback | null) => void;
/** Link copied by the share action. */
shareUrl?: string;
/** Small trailing label, e.g. model and latency. */
meta?: string;
className?: string;
};
const REASONS = ['Inaccurate', 'Too long', 'Off-topic', 'Ignored instructions', 'Outdated', 'Unsafe'];
/** Copy, regenerate, rate, share and read-aloud actions for an assistant reply. */
export function ResponseActions({ text, onRegenerate, regenerating, onFeedback, shareUrl = 'https://chat.example/s/7f3a9c', meta, className }: ResponseActionsProps) {
const [copied, setCopied] = useState(false);
const [shared, setShared] = useState(false);
const [vote, setVote] = useState<'up' | 'down' | null>(null);
const [panel, setPanel] = useState<'closed' | 'open' | 'sent'>('closed');
const [reasons, setReasons] = useState<string[]>([]);
const [comment, setComment] = useState('');
const [reading, setReading] = useState(false);
const pop = useRef<HTMLDivElement>(null);
const timers = useRef<number[]>([]);
const { mounted, visible } = usePresence(panel !== 'closed', 160);
const later = (ms: number, f: () => void) => void timers.current.push(window.setTimeout(f, ms));
const closePanel = () => setPanel('closed');
useClickOutside([pop], panel === 'open', closePanel);
useEscape(panel === 'open', closePanel);
useEffect(() => () => timers.current.forEach(window.clearTimeout), []);
useEffect(() => {
if (!reading) return;
const t = window.setTimeout(() => setReading(false), 6000);
return () => window.clearTimeout(t);
}, [reading]);
const write = async (s: string) => {
try {
await navigator.clipboard.writeText(s);
} catch {
/* clipboard unavailable */
}
};
const copy = () => {
write(text);
setCopied(true);
later(1500, () => setCopied(false));
};
const share = () => {
write(shareUrl);
setShared(true);
later(1600, () => setShared(false));
};
const rate = (v: 'up' | 'down') => {
if (vote === v) {
setVote(null);
setPanel('closed');
onFeedback?.(null);
return;
}
setVote(v);
if (v === 'up') {
setPanel('closed');
onFeedback?.({ vote: 'up' });
} else setPanel('open');
};
const submit = () => {
onFeedback?.({ vote: 'down', reasons, comment: comment.trim() || undefined });
setPanel('sent');
later(1300, () => setPanel('closed'));
};
return (
<div className={cn('relative flex flex-wrap items-center gap-0.5', className)}>
<style>{KEYFRAMES}</style>
<button type="button" onClick={copy} className={btn} aria-label="Copy reply">
<span className="relative h-3.5 w-3.5">
<Copy className={cn('absolute inset-0 h-3.5 w-3.5 transition-all duration-200', copied ? 'scale-50 opacity-0' : 'opacity-100')} />
<Check className={cn('absolute inset-0 h-3.5 w-3.5 text-emerald-400 transition-all duration-300 ease-spring', copied ? 'scale-100 opacity-100' : 'scale-50 opacity-0')} />
</span>
</button>
{onRegenerate && (
<button type="button" onClick={onRegenerate} disabled={regenerating} className={cn(btn, 'group/re')} aria-label="Regenerate reply">
<RefreshCw className={cn('h-3.5 w-3.5 transition-transform duration-500 group-hover/re:rotate-180', regenerating && 'animate-spin')} />
</button>
)}
<button type="button" onClick={() => rate('up')} aria-pressed={vote === 'up'} className={cn(btn, vote === 'up' && 'text-emerald-400 hover:text-emerald-300')} aria-label="Good reply">
<ThumbsUp className={cn('h-3.5 w-3.5 transition-transform duration-300 ease-spring', vote === 'up' && '-rotate-12 scale-110 fill-emerald-400/20')} />
</button>
<div ref={pop} className="relative">
<button type="button" onClick={() => rate('down')} aria-pressed={vote === 'down'} aria-expanded={panel === 'open'} className={cn(btn, vote === 'down' && 'text-rose-400 hover:text-rose-300')} aria-label="Bad reply">
<ThumbsDown className={cn('h-3.5 w-3.5 transition-transform duration-300 ease-spring', vote === 'down' && 'rotate-12 scale-110 fill-rose-400/20')} />
</button>
{mounted && (
<div
role="dialog"
aria-label="Feedback"
className={cn(
'absolute left-0 top-full z-20 mt-2 w-[min(18rem,calc(100vw-4rem))] origin-top-left rounded-xl bg-ink-850 p-3 shadow-pop transition-all',
visible ? 'scale-100 opacity-100 duration-300 ease-spring' : 'scale-95 opacity-0 duration-150',
)}
>
{panel === 'sent' ? (
<p className="flex animate-fade-in items-center gap-2 py-2 text-sm text-ink-100">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500/15">
<Check className="h-3 w-3 text-emerald-400" />
</span>
Thanks, this helps us improve.
</p>
) : (
<>
<p className="text-[13px] font-medium text-ink-100">What went wrong?</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{REASONS.map((r) => {
const on = reasons.includes(r);
return (
<button
key={r}
type="button"
aria-pressed={on}
onClick={() => setReasons((all) => (on ? all.filter((x) => x !== r) : [...all, r]))}
className={cn('focus-ring rounded-full px-2.5 py-1 text-[11px] transition-all active:scale-95', on ? 'bg-rose-500/15 text-rose-200 ring-1 ring-inset ring-rose-400/30' : 'bg-white/5 text-ink-300 hover:bg-white/10')}
>
{r}
</button>
);
})}
</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={2}
placeholder="Tell us more (optional)"
className="mt-2.5 block w-full resize-none rounded-lg bg-ink-900 px-2.5 py-2 text-xs text-ink-100 shadow-hairline outline-none placeholder:text-ink-500 focus:shadow-[0_0_0_1px_rgba(56,189,248,0.35)]"
/>
<div className="mt-2.5 flex justify-end gap-1.5">
<button type="button" onClick={closePanel} className="focus-ring h-7 rounded-lg px-2.5 text-xs text-ink-400 hover:bg-white/5 hover:text-ink-100">
Cancel
</button>
<button type="button" onClick={submit} className="focus-ring h-7 rounded-lg bg-ink-50 px-3 text-xs font-medium text-ink-950 transition-all hover:bg-white active:scale-95">
Send feedback
</button>
</div>
</>
)}
</div>
)}
</div>
<button type="button" onClick={share} className={btn} aria-label="Copy share link">
{shared ? <Check className="h-3.5 w-3.5 animate-scale-in text-emerald-400" /> : <Share2 className="h-3.5 w-3.5" />}
</button>
<button type="button" onClick={() => setReading((r) => !r)} aria-pressed={reading} className={cn(btn, reading && 'text-brand-300')} aria-label={reading ? 'Stop reading aloud' : 'Read aloud'}>
{reading ? (
<span className="flex h-3.5 items-center gap-[2px]">
{[0, 1, 2, 3].map((i) => (
<span key={i} className="h-full w-[2px] rounded-full bg-current" style={{ animation: `rb-eq 0.9s ease-in-out ${i * 120}ms infinite` }} />
))}
</span>
) : (
<Volume2 className="h-3.5 w-3.5" />
)}
</button>
{(shared || meta) && (
<span className="ml-1.5 truncate text-[11px] text-ink-500">
{shared ? <span className="animate-fade-in text-emerald-300">Link copied</span> : meta}
</span>
)}
</div>
);
}
const REPLIES = [
'**“Write freely. Nobody’s reading.”**\nIt sounds human and makes privacy a feeling rather than a feature. Back it with one line underneath: “End-to-end encrypted. No ads. No training on your notes.”',
'**“Your notes. Your keys. Your business.”**\nThree beats that build from ownership to attitude. It works on a billboard and fits an app store subtitle.',
'**“The notebook that forgets you exist.”**\nA little cheeky, and it turns “we track nothing” into a promise. Best for a younger, design-led audience.',
'**“Private by default. Beautiful by design.”**\nSafe and broad. Pick it if the app competes on craft as much as on privacy.',
'**“Think out loud, quietly.”**\nThe shortest option, with a nice tension in it. Let the visuals carry the privacy story.',
];
export function ResponseBranchesDemo() {
const [branches, setBranches] = useState([0, 1]);
const [index, setIndex] = useState(1);
const [dir, setDir] = useState<1 | -1>(1);
const [streaming, setStreaming] = useState(false);
const live = REPLIES[branches[index]];
const stream = useStream(live, { speed: 26, enabled: streaming });
useEffect(() => {
if (streaming && stream.done) setStreaming(false);
}, [streaming, stream.done]);
const go = (i: number) => {
setDir(i > index ? 1 : -1);
setIndex(i);
setStreaming(false);
};
const regenerate = () => {
const next = branches.length % REPLIES.length;
setBranches((b) => [...b, next]);
setDir(1);
setIndex(branches.length);
setStreaming(!window.matchMedia('(prefers-reduced-motion: reduce)').matches);
stream.restart();
};
return (
<div className="mx-auto w-full max-w-xl space-y-5">
<div className="flex justify-end">
<p className="max-w-[85%] rounded-2xl rounded-br-md bg-brand-600 px-3.5 py-2 text-sm text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.15)]">
Give me a tagline for a privacy-first notes app.
</p>
</div>
<div className="flex gap-3">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-brand-400 to-brand-700 text-white shadow-glow">
<Bot className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="min-h-[104px] overflow-hidden">
<div key={index} style={{ animation: `${dir === 1 ? 'rb-in-r' : 'rb-in-l'} 380ms cubic-bezier(0.16,1,0.3,1) both` }}>
<Markdown text={streaming ? stream.output : live} caret={streaming} className="[&_strong]:text-[15px]" />
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-white/5 pt-2">
<BranchPicker index={index} count={branches.length} onChange={go} />
<span className="mx-1 hidden h-4 w-px bg-white/10 sm:block" />
<ResponseActions text={live.replace(/\*\*/g, '')} onRegenerate={regenerate} regenerating={streaming} meta="Nova Pro · 1.4s" />
</div>
</div>
</div>
</div>
);
}components/niceui/ui/motion/hooks.ts
import { useEffect, useState, type ReactNode, type RefObject } from 'react';
import { createPortal } from 'react-dom';
/**
* Keeps an element mounted while it animates out.
* `mounted` controls rendering, `visible` drives the enter/exit classes.
*/
export function usePresence(open: boolean, exitMs = 220) {
const [mounted, setMounted] = useState(open);
const [visible, setVisible] = useState(open);
useEffect(() => {
if (open) {
setMounted(true);
let inner = 0;
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => setVisible(true));
});
return () => {
cancelAnimationFrame(outer);
cancelAnimationFrame(inner);
};
}
setVisible(false);
const t = window.setTimeout(() => setMounted(false), exitMs);
return () => window.clearTimeout(t);
}, [open, exitMs]);
return { mounted, visible };
}
/** Calls `handler` when Escape is pressed while `active`. */
export function useEscape(active: boolean, handler: () => void) {
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handler();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, handler]);
}
/** Calls `handler` on pointerdown outside every provided ref while `active`. */
export function useClickOutside(
refs: Array<RefObject<HTMLElement>>,
active: boolean,
handler: () => void,
) {
useEffect(() => {
if (!active) return;
const onDown = (e: PointerEvent) => {
const target = e.target as Node;
if (refs.some((r) => r.current?.contains(target))) return;
handler();
};
document.addEventListener('pointerdown', onDown);
return () => document.removeEventListener('pointerdown', onDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, handler]);
}
/**
* Renders overlays into document.body so `position: fixed` is relative to the
* viewport even when an ancestor has a transform (e.g. entrance animations).
*/
export function Portal({ children }: { children: ReactNode }) {
if (typeof document === 'undefined') return null;
return createPortal(children, document.body);
}components/niceui/ai/stream.tsx
import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
/** Streams `text` token-by-token (words + whitespace). */
export function useStream(text: string, { speed = 32, enabled = true }: { speed?: number; enabled?: boolean } = {}) {
const [count, setCount] = useState(0);
const [run, setRun] = useState(0);
const tokens = useRef<string[]>([]);
tokens.current = text.split(/(\s+)/).filter(Boolean);
useEffect(() => {
setCount(0);
}, [text, run]);
useEffect(() => {
if (!enabled) return;
if (count >= tokens.current.length) return;
const jitter = speed * (0.5 + Math.random());
const t = window.setTimeout(() => setCount((c) => c + 2), jitter);
return () => window.clearTimeout(t);
}, [count, enabled, speed, text, run]);
const restart = useCallback(() => setRun((r) => r + 1), []);
const total = tokens.current.length;
return { output: tokens.current.slice(0, count).join(''), done: count >= total, restart, progress: total ? Math.min(1, count / total) : 1 };
}
export function Caret({ className }: { className?: string }) {
return <span aria-hidden className={cn('ml-0.5 inline-block h-[1.05em] w-[7px] translate-y-[3px] animate-blink rounded-[2px] bg-brand-400', className)} />;
}
function inline(text: string) {
return text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).map((part, i) => {
if (part.startsWith('**') && part.endsWith('**') && part.length > 4)
return <strong key={i} className="font-medium text-ink-50">{part.slice(2, -2)}</strong>;
if (part.startsWith('`') && part.endsWith('`') && part.length > 2)
return <code key={i} className="rounded bg-white/[0.06] px-1 py-px font-mono text-[0.85em] text-brand-200">{part.slice(1, -1)}</code>;
return <Fragment key={i}>{part}</Fragment>;
});
}
/** Minimal markdown: `## heading`, `- bullet`, `1. step`, **bold**, `code`. */
export function Markdown({ text, caret, className }: { text: string; caret?: boolean; className?: string }) {
const blocks = text.split('\n').filter((l, i, arr) => l.trim() !== '' || i === arr.length - 1);
const last = blocks.length - 1;
const tail = (i: number): ReactNode => (caret && i === last ? <Caret /> : null);
return (
<div className={cn('space-y-2 text-sm leading-relaxed text-ink-300', className)}>
{blocks.map((line, i) => {
if (line.startsWith('## '))
return (
<h4 key={i} className="pt-1 text-[15px] font-medium text-ink-50">
{inline(line.slice(3))}
{tail(i)}
</h4>
);
const bullet = line.match(/^(- |\d+\. )(.*)$/);
if (bullet)
return (
<div key={i} className="flex gap-2.5 pl-1">
<span className={cn('shrink-0 select-none', bullet[1] === '- ' ? 'mt-[9px] h-1 w-1 rounded-full bg-brand-400' : 'font-mono text-xs leading-6 text-ink-500')}>
{bullet[1] === '- ' ? '' : bullet[1].trim()}
</span>
<p>
{inline(bullet[2])}
{tail(i)}
</p>
</div>
);
return (
<p key={i}>
{inline(line)}
{tail(i)}
</p>
);
})}
</div>
);
}