Chat App New
A complete chat experience with a conversation sidebar, streaming replies and a rich composer.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-chat-app.json02
Or copy the source below (6 files). Dependencies: lucide-react.
Source
components/niceui/ai/ChatApp.tsx
import { useEffect, useRef, useState } from 'react';
import { MessageSquare, PanelLeft, Search, Sparkles, SquarePen } from 'lucide-react';
import { cn } from '@/lib/cn';
import { MessageBubble } from './MessageBubble';
import { PromptInput } from './PromptInput';
import { TypingIndicator } from './AgentLoading';
import { Markdown, useStream } from './stream';
export type Msg = { id: string; role: 'user' | 'assistant'; text: string; time: string; live?: boolean };
export type Convo = { id: string; title: string; group: 'Today' | 'Previous 7 days'; messages: Msg[] };
const now = () => new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
const SEED: Convo[] = [
{
id: 'c1',
title: 'Trip plan for Lisbon',
group: 'Today',
messages: [
{ id: 'm1', role: 'user', text: 'Plan 3 days in Lisbon for a first-timer who loves food.', time: '9:12 AM' },
{ id: 'm2', role: 'assistant', text: '## Day 1 — Alfama & Baixa\n- Breakfast **pastéis de nata** at Manteigaria\n- Ride tram 28 up to Miradouro de Santa Luzia\n- Dinner with fado at Tasca do Chico\n## Day 2 — Belém\n- Jerónimos Monastery, then the original **Pastéis de Belém**', time: '9:12 AM' },
],
},
{ id: 'c2', title: 'Refactor auth middleware', group: 'Today', messages: [{ id: 'm3', role: 'user', text: 'Why does my Next.js middleware run twice?', time: '8:03 AM' }, { id: 'm4', role: 'assistant', text: 'It usually runs once per request **and** once per prefetch. Add a `matcher` to exclude `/_next/static` and image routes.', time: '8:03 AM' }] },
{ id: 'c3', title: 'Quarterly OKR draft', group: 'Previous 7 days', messages: [{ id: 'm5', role: 'user', text: 'Draft 3 OKRs for a growth team.', time: 'Mon' }, { id: 'm6', role: 'assistant', text: '1. Grow weekly active teams from 1,200 to 1,800\n2. Lift trial-to-paid conversion from 9% to 13%\n3. Cut onboarding time to first value below 5 minutes', time: 'Mon' }] },
{ id: 'c4', title: 'SQL window functions', group: 'Previous 7 days', messages: [{ id: 'm7', role: 'user', text: 'Explain ROW_NUMBER vs RANK.', time: 'Sun' }, { id: 'm8', role: 'assistant', text: '`ROW_NUMBER` always gives unique numbers. `RANK` gives ties the same number and **skips** the next ones.', time: 'Sun' }] },
];
const REPLIES = [
'Great question. Here is a quick breakdown:\n- **Start small** — ship the smallest version that proves the idea\n- **Measure** one metric that actually reflects value\n- **Iterate weekly** and cut anything nobody uses\nWant me to turn this into a checklist?',
'## Short answer\nYes, and it is simpler than it looks.\n- Cache the response for 60 seconds with `stale-while-revalidate`\n- Invalidate on write using a tag per resource\nThis typically cuts p95 latency by **40–60%**.',
];
function LiveMessage({ text, active, onFinish, onTick }: { text: string; active: boolean; onFinish: (t: string) => void; onTick: () => void }) {
const [thinking, setThinking] = useState(true);
const { output, done } = useStream(text, { speed: 26, enabled: active && !thinking });
useEffect(() => {
const t = window.setTimeout(() => setThinking(false), 900);
return () => window.clearTimeout(t);
}, []);
useEffect(onTick, [output, onTick]);
useEffect(() => {
if (done || !active) onFinish(done ? text : output || 'Stopped.');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [done, active]);
if (thinking) return <TypingIndicator className="!rounded-tl-md !bg-transparent !p-0 !shadow-none" />;
return <Markdown text={output} caret={!done} className="text-ink-200" />;
}
export type ChatAppProps = { conversations?: Convo[]; replies?: string[]; defaultSidebarOpen?: boolean; className?: string };
export function ChatApp({ conversations = SEED, replies = REPLIES, defaultSidebarOpen = true, className }: ChatAppProps) {
const [convos, setConvos] = useState(conversations);
const [activeId, setActiveId] = useState(conversations[0]?.id ?? '');
const [generating, setGenerating] = useState<string | null>(null);
const [sidebar, setSidebar] = useState(defaultSidebarOpen);
const scroller = useRef<HTMLDivElement>(null);
const replyIdx = useRef(0);
const convo = convos.find((c) => c.id === activeId) ?? convos[0];
const scrollDown = useRef(() => {
const el = scroller.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}).current;
useEffect(scrollDown, [activeId, convo.messages.length, scrollDown]);
const update = (id: string, fn: (m: Msg[]) => Msg[]) => setConvos((cs) => cs.map((c) => (c.id === id ? { ...c, messages: fn(c.messages) } : c)));
const finalizeAll = () => setConvos((cs) => cs.map((c) => ({ ...c, messages: c.messages.map((m) => ({ ...m, live: false })) })));
const send = (text: string) => {
const reply = replies[replyIdx.current++ % replies.length];
const aid = `a${Date.now()}`;
update(convo.id, (m) => [...m, { id: `u${Date.now()}`, role: 'user', text: text || 'Sent an attachment', time: now() }, { id: aid, role: 'assistant', text: reply, time: now(), live: true }]);
setGenerating(aid);
};
const newChat = () => {
finalizeAll();
setGenerating(null);
const id = `c${Date.now()}`;
setConvos((cs) => [{ id, title: 'New chat', group: 'Today', messages: [] }, ...cs]);
setActiveId(id);
};
return (
<div className={cn('flex h-[560px] w-full overflow-hidden rounded-2xl bg-ink-950 shadow-card', className)}>
<aside className={cn('hidden shrink-0 flex-col overflow-hidden border-r border-white/5 bg-ink-900/60 transition-[width] duration-500 ease-smooth md:flex', sidebar ? 'w-60' : 'w-0')}>
<div className="flex w-60 items-center justify-between px-3 py-3">
<span className="flex items-center gap-2 text-sm font-medium text-ink-50">
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-gradient-to-br from-brand-400 to-brand-700"><Sparkles className="h-3.5 w-3.5 text-white" /></span>
NiceUI
</span>
<button onClick={newChat} className="focus-ring flex h-7 w-7 items-center justify-center rounded-md text-ink-400 transition-colors hover:bg-white/5 hover:text-ink-50" aria-label="New chat">
<SquarePen className="h-4 w-4" />
</button>
</div>
<div className="mx-3 mb-2 flex w-[216px] shrink-0 items-center gap-2 rounded-lg bg-white/[0.04] px-2.5 py-1.5 text-xs text-ink-500">
<Search className="h-3.5 w-3.5" /> Search chats
</div>
<nav className="w-60 flex-1 overflow-y-auto px-2 pb-3">
{(['Today', 'Previous 7 days'] as const).map((g) => (
<div key={g} className="mt-3">
<p className="px-2 pb-1 text-[11px] font-medium text-ink-500">{g}</p>
{convos.filter((c) => c.group === g).map((c) => (
<button
key={c.id}
onClick={() => {
finalizeAll();
setGenerating(null);
setActiveId(c.id);
}}
className={cn('focus-ring flex w-full animate-fade-in items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors', c.id === convo.id ? 'bg-white/[0.07] text-ink-50' : 'text-ink-400 hover:bg-white/[0.04] hover:text-ink-200')}
>
<MessageSquare className="h-3.5 w-3.5 shrink-0 opacity-60" />
<span className="truncate">{c.title}</span>
</button>
))}
</div>
))}
</nav>
<div className="flex w-60 items-center gap-2 border-t border-white/5 px-3 py-3">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-amber-500/20 text-xs font-medium text-amber-200">MR</span>
<span className="text-xs"><span className="block text-ink-100">Maya Reyes</span><span className="block text-ink-500">Pro plan</span></span>
</div>
</aside>
<section className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-2 border-b border-white/5 px-3 py-2.5">
<button onClick={() => setSidebar((s) => !s)} className="focus-ring hidden h-7 w-7 items-center justify-center rounded-md text-ink-400 transition-colors hover:bg-white/5 hover:text-ink-50 md:flex" aria-label="Toggle sidebar">
<PanelLeft className="h-4 w-4" />
</button>
<h3 key={convo.id} className="animate-fade-in truncate text-sm font-medium text-ink-100">{convo.title}</h3>
</header>
<div ref={scroller} className="flex-1 overflow-y-auto px-4 py-5">
<div key={convo.id} className="mx-auto flex max-w-2xl flex-col gap-2">
{convo.messages.length === 0 && (
<div className="flex animate-fade-up flex-col items-center pt-24 text-center">
<Sparkles className="h-6 w-6 text-brand-300" />
<p className="mt-3 text-lg font-medium text-ink-50">How can I help today?</p>
<p className="mt-1 text-sm text-ink-500">Ask a question, paste code or attach a file.</p>
</div>
)}
{convo.messages.map((m) => (
<div key={m.id} className="animate-fade-up">
<MessageBubble role={m.role} time={m.time} status={m.role === 'user' ? 'read' : undefined} copyText={m.text} actions={!m.live}>
{m.live ? (
<LiveMessage
text={m.text}
active={generating === m.id}
onTick={scrollDown}
onFinish={(t) => {
update(convo.id, (ms) => ms.map((x) => (x.id === m.id ? { ...x, text: t, live: false } : x)));
setGenerating((g) => (g === m.id ? null : g));
}}
/>
) : m.role === 'assistant' ? (
<Markdown text={m.text} className="text-ink-200" />
) : (
m.text
)}
</MessageBubble>
</div>
))}
</div>
</div>
<div className="mx-auto w-full max-w-2xl px-4 pb-4">
<PromptInput onSubmit={send} generating={!!generating} onStop={() => setGenerating(null)} placeholder="Message NiceUI…" maxRows={5} />
</div>
</section>
</div>
);
}
export function ChatAppDemo() {
return <ChatApp />;
}components/niceui/ai/MessageBubble.tsx
import { useEffect, useState, type ReactNode } from 'react';
import { Bot, Check, Copy, RefreshCw, ThumbsDown, ThumbsUp } from 'lucide-react';
import { cn } from '@/lib/cn';
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'read';
export type MessageBubbleProps = {
role: 'user' | 'assistant';
children: ReactNode;
time?: string;
status?: MessageStatus;
copyText?: string;
onRegenerate?: () => void;
actions?: boolean;
className?: string;
};
function Receipt({ status }: { status: MessageStatus }) {
const read = status === 'read';
return (
<span className={cn('relative inline-flex h-3 w-4 transition-colors duration-500', read ? 'text-brand-300' : 'text-ink-500')} aria-label={status}>
<Check className={cn('absolute left-0 h-3 w-3 transition-all duration-300', status === 'sending' && 'opacity-30')} strokeWidth={2.5} />
<Check
className={cn(
'absolute left-[5px] h-3 w-3 transition-all duration-300 ease-spring',
status === 'delivered' || read ? 'scale-100 opacity-100' : 'scale-50 opacity-0',
)}
strokeWidth={2.5}
/>
</span>
);
}
const actionBtn =
'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';
export function MessageBubble({ role, children, time, status, copyText, onRegenerate, actions = true, className }: MessageBubbleProps) {
const [copied, setCopied] = useState(false);
const [vote, setVote] = useState<'up' | 'down' | null>(null);
const isUser = role === 'user';
const copy = async () => {
try {
await navigator.clipboard.writeText(copyText ?? '');
} catch {
/* clipboard unavailable */
}
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
return (
<div className={cn('group flex w-full gap-3', isUser ? 'justify-end' : 'justify-start', className)}>
{!isUser && (
<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={cn('flex min-w-0 max-w-[85%] flex-col', isUser ? 'items-end' : 'items-start')}>
<div
className={cn(
'text-sm leading-relaxed',
isUser
? 'rounded-2xl rounded-br-md bg-brand-600 px-3.5 py-2 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.15)]'
: 'rounded-2xl rounded-tl-md bg-ink-850 px-3.5 py-2.5 text-ink-200 shadow-hairline',
)}
>
{children}
</div>
<div className={cn('mt-1 flex h-7 items-center gap-1.5', isUser ? 'flex-row-reverse' : 'flex-row')}>
{(time || status) && (
<span className="flex items-center gap-1 px-1 text-[11px] text-ink-500">
{time}
{isUser && status && <Receipt status={status} />}
</span>
)}
{actions && (
<div className="flex translate-y-1 items-center opacity-0 transition-all duration-200 ease-smooth group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100">
<button onClick={copy} className={actionBtn} aria-label="Copy message">
<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>
{!isUser && (
<>
<button onClick={() => setVote(vote === 'up' ? null : 'up')} className={cn(actionBtn, vote === 'up' && 'text-emerald-400')} aria-label="Good response">
<ThumbsUp className={cn('h-3.5 w-3.5 transition-transform duration-300 ease-spring', vote === 'up' && '-rotate-12 scale-110')} />
</button>
<button onClick={() => setVote(vote === 'down' ? null : 'down')} className={cn(actionBtn, vote === 'down' && 'text-rose-400')} aria-label="Bad response">
<ThumbsDown className={cn('h-3.5 w-3.5 transition-transform duration-300 ease-spring', vote === 'down' && 'rotate-12 scale-110')} />
</button>
{onRegenerate && (
<button onClick={onRegenerate} className={cn(actionBtn, 'group/re')} aria-label="Regenerate">
<RefreshCw className="h-3.5 w-3.5 transition-transform duration-500 group-hover/re:rotate-180" />
</button>
)}
</>
)}
</div>
)}
</div>
</div>
</div>
);
}
const REPLIES = [
'Yes — move the standup to 10:30 and I’ll shift the design review to 2pm so nobody double-books.',
'Done. Standup is now 10:30 and the design review moved to 2:00pm. Invites were updated for all 6 attendees.',
'I’d keep standup at 10:30 but shorten the review to 30 min — Maya has a hard stop at 2:45.',
];
export function MessageBubbleDemo() {
const [status, setStatus] = useState<MessageStatus>('sending');
const [reply, setReply] = useState(0);
useEffect(() => {
const a = window.setTimeout(() => setStatus('sent'), 500);
const b = window.setTimeout(() => setStatus('delivered'), 1200);
const c = window.setTimeout(() => setStatus('read'), 2200);
return () => [a, b, c].forEach((t) => window.clearTimeout(t));
}, []);
return (
<div className="mx-auto flex w-full max-w-lg flex-col gap-1">
<div className="animate-fade-up">
<MessageBubble role="user" time="9:41" status="read" copyText="Can you move tomorrow’s standup later?">
Can you move tomorrow’s standup later? I have a dentist appointment at 9.
</MessageBubble>
</div>
<div className="animate-fade-up" style={{ animationDelay: '120ms' }}>
<MessageBubble role="assistant" time="9:41" copyText={REPLIES[reply]} onRegenerate={() => setReply((r) => (r + 1) % REPLIES.length)}>
<span key={reply} className="block animate-fade-in">{REPLIES[reply]}</span>
</MessageBubble>
</div>
<div className="animate-fade-up" style={{ animationDelay: '240ms' }}>
<MessageBubble role="user" time="9:42" status={status} copyText="Perfect, thanks!">
Perfect, thanks! 🙏
</MessageBubble>
</div>
<p className="mt-2 text-center text-xs text-ink-500">Hover a message to copy, rate or regenerate.</p>
</div>
);
}components/niceui/ai/PromptInput.tsx
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowUp, Check, ChevronDown, FileText, Image, Paperclip, Sparkles, Square, X, Zap, Brain } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useClickOutside, useEscape, usePresence } from '@/components/niceui/ui/motion/hooks';
export type Attachment = { id: string; name: string; size: string; kind: 'image' | 'file' };
export type Model = { id: string; name: string; hint: string; icon: typeof Zap };
export const MODELS: Model[] = [
{ id: 'nice-pro', name: 'Nice Pro', hint: 'Best for complex tasks', icon: Sparkles },
{ id: 'nice-fast', name: 'Nice Fast', hint: 'Quick everyday answers', icon: Zap },
{ id: 'nice-reason', name: 'Nice Reason', hint: 'Thinks before answering', icon: Brain },
];
const SAMPLE_FILES: Omit<Attachment, 'id'>[] = [
{ name: 'q3-revenue.csv', size: '48 KB', kind: 'file' },
{ name: 'dashboard.png', size: '1.2 MB', kind: 'image' },
{ name: 'product-spec.pdf', size: '312 KB', kind: 'file' },
];
export type PromptInputProps = {
onSubmit: (text: string, attachments: Attachment[]) => void;
generating?: boolean;
onStop?: () => void;
placeholder?: string;
maxRows?: number;
className?: string;
};
function ModelSelect({ value, onChange }: { value: Model; onChange: (m: Model) => void }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const close = useCallback(() => setOpen(false), []);
const { mounted, visible } = usePresence(open, 160);
useClickOutside([ref], open, close);
useEscape(open, close);
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="focus-ring flex h-8 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-ink-300 transition-colors hover:bg-white/5 hover:text-ink-50"
>
<value.icon className="h-3.5 w-3.5 text-brand-300" />
{value.name}
<ChevronDown className={cn('h-3.5 w-3.5 text-ink-500 transition-transform duration-300 ease-spring', open && 'rotate-180')} />
</button>
{mounted && (
<div
className={cn(
'absolute bottom-full left-0 z-20 mb-2 w-60 origin-bottom-left rounded-xl bg-ink-850 p-1 shadow-pop transition-all',
visible ? 'scale-100 opacity-100 duration-300 ease-spring' : 'scale-95 translate-y-1 opacity-0 duration-150',
)}
>
{MODELS.map((m) => (
<button
key={m.id}
type="button"
onClick={() => {
onChange(m);
close();
}}
className="focus-ring flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-white/5"
>
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-white/5 text-brand-300">
<m.icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm text-ink-100">{m.name}</span>
<span className="block text-xs text-ink-500">{m.hint}</span>
</span>
{m.id === value.id && <Check className="h-4 w-4 animate-scale-in text-brand-300" />}
</button>
))}
</div>
)}
</div>
);
}
export function PromptInput({ onSubmit, generating = false, onStop, placeholder = 'Ask anything…', maxRows = 8, className }: PromptInputProps) {
const [text, setText] = useState('');
const [files, setFiles] = useState<Attachment[]>([]);
const [model, setModel] = useState(MODELS[0]);
const [focused, setFocused] = useState(false);
const area = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = area.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, maxRows * 22 + 16)}px`;
}, [text, maxRows]);
const canSend = text.trim().length > 0 || files.length > 0;
const submit = () => {
if (generating) return onStop?.();
if (!canSend) return;
onSubmit(text.trim(), files);
setText('');
setFiles([]);
};
const attach = () => {
const next = SAMPLE_FILES[files.length % SAMPLE_FILES.length];
setFiles((f) => [...f, { ...next, id: `${Date.now()}` }]);
};
return (
<div
className={cn(
'w-full rounded-2xl bg-ink-900 shadow-card transition-shadow duration-300',
focused && 'shadow-[0_0_0_1px_rgba(56,189,248,0.35),0_8px_32px_-8px_rgba(56,189,248,0.25)]',
className,
)}
>
<div className={cn('grid transition-[grid-template-rows] duration-300 ease-smooth', files.length ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
<div className="overflow-hidden">
<div className="flex flex-wrap gap-2 px-3 pt-3">
{files.map((f) => (
<span key={f.id} className="group flex animate-scale-in items-center gap-2 rounded-lg bg-ink-850 py-1.5 pl-2 pr-1.5 shadow-hairline">
<span className={cn('flex h-6 w-6 items-center justify-center rounded-md', f.kind === 'image' ? 'bg-sky-500/15 text-sky-300' : 'bg-brand-500/15 text-brand-300')}>
{f.kind === 'image' ? <Image className="h-3.5 w-3.5" /> : <FileText className="h-3.5 w-3.5" />}
</span>
<span className="text-xs">
<span className="block max-w-[120px] truncate text-ink-100">{f.name}</span>
<span className="block text-[10px] text-ink-500">{f.size}</span>
</span>
<button
type="button"
onClick={() => setFiles((all) => all.filter((x) => x.id !== f.id))}
className="focus-ring ml-1 flex h-5 w-5 items-center justify-center rounded text-ink-500 transition-colors hover:bg-white/10 hover:text-ink-100"
aria-label={`Remove ${f.name}`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
</div>
</div>
<textarea
ref={area}
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submit();
}
}}
placeholder={placeholder}
className="block w-full resize-none bg-transparent px-4 pt-3.5 text-sm leading-[22px] text-ink-50 outline-none placeholder:text-ink-500"
/>
<div className="flex items-center justify-between gap-2 p-2">
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={attach}
className="focus-ring flex h-8 w-8 items-center justify-center rounded-lg text-ink-400 transition-all hover:bg-white/5 hover:text-ink-50 active:scale-90"
aria-label="Attach file"
>
<Paperclip className="h-4 w-4" />
</button>
<ModelSelect value={model} onChange={setModel} />
</div>
<div className="flex items-center gap-2">
<kbd className="hidden rounded-md bg-white/5 px-1.5 py-0.5 font-mono text-[10px] text-ink-500 sm:block">⌘ ↵</kbd>
<button
type="button"
onClick={submit}
disabled={!generating && !canSend}
aria-label={generating ? 'Stop generating' : 'Send message'}
className={cn(
'focus-ring relative flex h-8 w-8 items-center justify-center rounded-full transition-all duration-300 ease-spring active:scale-90',
generating ? 'bg-ink-50 text-ink-950' : canSend ? 'bg-brand-500 text-white shadow-glow hover:bg-brand-400' : 'bg-ink-800 text-ink-500',
)}
>
<ArrowUp className={cn('absolute h-4 w-4 transition-all duration-300 ease-spring', generating ? 'rotate-90 scale-0 opacity-0' : 'scale-100')} />
<Square className={cn('absolute h-3 w-3 fill-current transition-all duration-300 ease-spring', generating ? 'scale-100 opacity-100' : 'scale-0 opacity-0')} />
{generating && <span className="absolute inset-0 animate-pulse-ring rounded-full border border-ink-50/40" />}
</button>
</div>
</div>
</div>
);
}
export function PromptInputDemo() {
const [generating, setGenerating] = useState(false);
const [last, setLast] = useState<{ text: string; files: number } | null>(null);
const timer = useRef<number>();
const send = (text: string, files: Attachment[]) => {
setLast({ text: text || 'Sent attachments', files: files.length });
setGenerating(true);
timer.current = window.setTimeout(() => setGenerating(false), 3000);
};
const stop = () => {
window.clearTimeout(timer.current);
setGenerating(false);
};
useEffect(() => () => window.clearTimeout(timer.current), []);
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-4">
<div className="h-10">
{last && (
<div key={last.text} className="flex animate-fade-up items-center gap-2 text-xs text-ink-400">
<span className={cn('h-1.5 w-1.5 rounded-full', generating ? 'animate-pulse bg-brand-400' : 'bg-emerald-400')} />
<span className="truncate">
{generating ? 'Generating response to' : 'Answered'} “{last.text}”{last.files ? ` · ${last.files} file${last.files > 1 ? 's' : ''}` : ''}
</span>
</div>
)}
</div>
<PromptInput onSubmit={send} generating={generating} onStop={stop} placeholder="Summarize the Q3 revenue report…" />
</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/AgentLoading.tsx
import type { ReactNode } from 'react';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/cn';
const KEYFRAMES =
'@keyframes ai-dot{0%,80%,100%{transform:scale(.6);opacity:.35}40%{transform:scale(1);opacity:1}}' +
'@keyframes ai-type{0%,60%,100%{transform:translateY(0);opacity:.4}30%{transform:translateY(-4px);opacity:1}}';
export function ThinkingShimmer({ label = 'Thinking…', className }: { label?: string; className?: string }) {
return (
<span className={cn('flex items-center gap-2 text-sm', className)} role="status">
<Sparkles className="h-4 w-4 animate-pulse text-brand-300" />
<span className="shimmer-text font-medium">{label}</span>
</span>
);
}
export function DotPulse({ className }: { className?: string }) {
return (
<span className={cn('flex items-center gap-1.5', className)} role="status" aria-label="Loading">
<style>{KEYFRAMES}</style>
{[0, 1, 2].map((i) => (
<span key={i} className="h-2.5 w-2.5 rounded-full bg-brand-400" style={{ animation: `ai-dot 1.2s ease-in-out ${i * 160}ms infinite` }} />
))}
</span>
);
}
export function EqualizerBars({ bars = 7, className }: { bars?: number; className?: string }) {
return (
<span className={cn('flex h-8 items-center gap-[3px]', className)} role="status" aria-label="Loading">
{Array.from({ length: bars }, (_, i) => (
<span
key={i}
className="h-full w-1 animate-bar-bounce rounded-full bg-gradient-to-t from-brand-600 to-sky-300"
style={{ animationDelay: `${(i * 137) % 600}ms`, animationDuration: `${800 + ((i * 211) % 500)}ms` }}
/>
))}
</span>
);
}
export function OrbitSpinner({ className }: { className?: string }) {
return (
<span className={cn('relative block h-12 w-12', className)} role="status" aria-label="Loading">
<span className="absolute inset-0 rounded-full border border-white/10" />
<span className="absolute inset-0 animate-spin [animation-duration:1.6s]">
<span className="absolute -top-1 left-1/2 h-2 w-2 -translate-x-1/2 rounded-full bg-brand-400 shadow-[0_0_12px_2px_rgba(56,189,248,0.7)]" />
</span>
<span className="absolute inset-3 animate-spin rounded-full border border-white/10 [animation-direction:reverse] [animation-duration:1.1s]">
<span className="absolute -top-[3px] left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-teal-300" />
</span>
<span className="absolute inset-[22px] rounded-full bg-brand-300/80" />
</span>
);
}
export function SkeletonLines({ lines = 4, className }: { lines?: number; className?: string }) {
const widths = ['92%', '100%', '78%', '56%', '85%'];
return (
<span className={cn('flex w-full flex-col gap-2', className)} role="status" aria-label="Loading">
{Array.from({ length: lines }, (_, i) => (
<span
key={i}
className="h-2.5 animate-shimmer rounded-full bg-[linear-gradient(90deg,#1a1a1e_0%,#1a1a1e_35%,#36363e_50%,#1a1a1e_65%,#1a1a1e_100%)] bg-[length:200%_100%]"
style={{ width: widths[i % widths.length], animationDelay: `${i * 120}ms` }}
/>
))}
</span>
);
}
export function TypingIndicator({ className }: { className?: string }) {
return (
<span className={cn('inline-flex items-center gap-1 rounded-2xl rounded-bl-md bg-ink-800 px-3.5 py-3 shadow-hairline', className)} role="status" aria-label="Assistant is typing">
<style>{KEYFRAMES}</style>
{[0, 1, 2].map((i) => (
<span key={i} className="h-1.5 w-1.5 rounded-full bg-ink-300" style={{ animation: `ai-type 1.3s ease-in-out ${i * 150}ms infinite` }} />
))}
</span>
);
}
function Tile({ label, children, i }: { label: string; children: ReactNode; i: number }) {
return (
<div
className="group flex h-36 animate-fade-up flex-col rounded-2xl bg-ink-900 p-4 shadow-card transition-transform duration-300 ease-spring hover:-translate-y-0.5"
style={{ animationDelay: `${i * 70}ms` }}
>
<div className="flex flex-1 items-center justify-center px-2">{children}</div>
<p className="text-center text-xs text-ink-500 transition-colors group-hover:text-ink-300">{label}</p>
</div>
);
}
export function AgentLoadingDemo() {
const items: Array<[string, ReactNode]> = [
['Shimmer text', <ThinkingShimmer />],
['Dot pulse', <DotPulse />],
['Equalizer', <EqualizerBars />],
['Orbit', <OrbitSpinner />],
['Skeleton lines', <SkeletonLines />],
['Typing indicator', <TypingIndicator />],
];
return (
<div className="mx-auto grid w-full max-w-2xl grid-cols-2 gap-3 sm:grid-cols-3">
{items.map(([label, node], i) => (
<Tile key={label} label={label} i={i}>
{node}
</Tile>
))}
</div>
);
}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>
);
}