"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import { ArrowDown, Code2, Lightbulb, Mail, Map as MapIcon, Menu, SquarePen, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { AssistantMark, ChatMessageView } from "./chat-message";
import { ChatSidebar } from "./chat-sidebar";
import { Composer } from "./composer";
import { MODELS, SEED_CONVERSATIONS, SEED_NOW, SUGGESTIONS } from "./data";
import { localRespond } from "./engine";
import { ModelPicker } from "./model-picker";
import type { ChatMessage, ChatModel, Conversation, RespondFn, Suggestion } from "./types";
export type { ChatMessage, ChatModel, Conversation, RespondFn, Suggestion };
export interface AiChatAppProps {
/** Chat history. Defaults to a few seeded demo conversations. */
initialConversations?: Conversation[];
/** Conversation to open first. `null` opens a new, empty chat. Defaults to the most recent one. */
initialConversationId?: string | null;
/** Models for the picker. Only the id is passed to `respond`. */
models?: ChatModel[];
/** Stream a reply. Defaults to a local canned-response engine (no network). */
respond?: RespondFn;
suggestions?: Suggestion[];
user?: { name: string; plan?: string };
assistantName?: string;
/** Reference "now" for the history groups. Defaults to the seed timestamp (demo) or the client clock (your data). */
now?: Date | string;
/** Called with all conversations after every change (debounce before persisting while streaming). */
onChange?: (conversations: Conversation[]) => void;
className?: string;
}
let seq = 0;
const uid = (p: string) => `${p}-${Date.now().toString(36)}-${(++seq).toString(36)}`;
function titleFrom(text: string) {
const t = text.replace(/\s+/g, " ").trim().replace(/[?.!]+$/, "");
const short = t.length > 40 ? `${t.slice(0, 40).replace(/\s+\S*$/, "")}…` : t;
return short.charAt(0).toUpperCase() + short.slice(1);
}
const SUGGESTION_ICONS = [Code2, MapIcon, Mail, Lightbulb];
export function AiChatApp({
initialConversations,
initialConversationId,
models = MODELS,
respond = localRespond,
suggestions = SUGGESTIONS,
user = { name: "Alex Rivera", plan: "Pro plan" },
assistantName = "Nova",
now: nowProp,
onChange,
className,
}: AiChatAppProps) {
const [conversations, setConversations] = React.useState<Conversation[]>(initialConversations ?? SEED_CONVERSATIONS);
const [activeId, setActiveId] = React.useState<string | null>(() => {
if (initialConversationId !== undefined) return initialConversationId;
const list = initialConversations ?? SEED_CONVERSATIONS;
return [...list].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]?.id ?? null;
});
const [model, setModel] = React.useState(models[0]?.id ?? "default");
const [input, setInput] = React.useState("");
const [generating, setGenerating] = React.useState<{ convId: string; msgId: string } | null>(null);
const [drawer, setDrawer] = React.useState(false);
const [atBottom, setAtBottom] = React.useState(true);
const [now, setNow] = React.useState(() => new Date(nowProp ?? SEED_NOW));
const abortRef = React.useRef<AbortController | null>(null);
const attempts = React.useRef(new Map<string, number>());
const scrollerRef = React.useRef<HTMLDivElement>(null);
const stick = React.useRef(true);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const mountedAt = React.useRef(0);
const respondRef = React.useRef(respond);
const onChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
respondRef.current = respond;
onChangeRef.current = onChange;
});
React.useEffect(() => {
mountedAt.current = Date.now();
if (!nowProp && initialConversations) setNow(new Date());
}, [nowProp, initialConversations]);
const stamp = React.useCallback(() => new Date(now.getTime() + (Date.now() - (mountedAt.current || Date.now()))).toISOString(), [now]);
const first = React.useRef(true);
React.useEffect(() => {
if (first.current) {
first.current = false;
return;
}
onChangeRef.current?.(conversations);
}, [conversations]);
React.useEffect(() => () => abortRef.current?.abort(), []);
const active = conversations.find((c) => c.id === activeId) ?? null;
const busy = generating !== null;
/* ------------------------------ scrolling ------------------------------ */
const onScroll = () => {
const el = scrollerRef.current;
if (!el) return;
const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
stick.current = bottom;
setAtBottom(bottom);
};
const scrollToBottom = (smooth = true) => {
const el = scrollerRef.current;
if (!el) return;
stick.current = true;
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? "smooth" : "auto" });
};
// Follow the stream while the user is at the bottom.
const lastContent = active?.messages[active.messages.length - 1]?.content.length ?? 0;
React.useLayoutEffect(() => {
if (stick.current && scrollerRef.current) scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight;
}, [lastContent, active?.messages.length]);
// Jump to the end when switching conversations.
React.useLayoutEffect(() => {
stick.current = true;
if (scrollerRef.current) scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight;
}, [activeId]);
/* ------------------------------ mutations ------------------------------ */
const updateMessage = React.useCallback((convId: string, msgId: string, fn: (m: ChatMessage) => ChatMessage) => {
setConversations((cs) => cs.map((c) => (c.id === convId ? { ...c, messages: c.messages.map((m) => (m.id === msgId ? fn(m) : m)) } : c)));
}, []);
const run = React.useCallback(
(convId: string, msgId: string, history: ChatMessage[], attempt: number, modelId: string) => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setGenerating({ convId, msgId });
(async () => {
try {
for await (const chunk of respondRef.current(history, { model: modelId, signal: ctrl.signal, attempt })) {
if (ctrl.signal.aborted) break;
updateMessage(convId, msgId, (m) => ({ ...m, content: m.content + chunk }));
}
updateMessage(convId, msgId, (m) => ({ ...m, status: ctrl.signal.aborted ? "stopped" : "done" }));
} catch {
updateMessage(convId, msgId, (m) => ({ ...m, status: ctrl.signal.aborted ? "stopped" : "error" }));
} finally {
if (abortRef.current === ctrl) {
abortRef.current = null;
setGenerating(null);
}
}
})();
},
[updateMessage],
);
const send = (text: string) => {
const content = text.trim();
if (!content || busy) return;
const ts = stamp();
const userMsg: ChatMessage = { id: uid("u"), role: "user", content, createdAt: ts };
const reply: ChatMessage = { id: uid("a"), role: "assistant", content: "", createdAt: ts, model, status: "streaming" };
let convId = activeId;
let history: ChatMessage[];
if (!active || !convId) {
convId = uid("c");
history = [userMsg];
const conv: Conversation = { id: convId, title: titleFrom(content), updatedAt: ts, messages: [userMsg, reply] };
setConversations((cs) => [conv, ...cs]);
setActiveId(convId);
} else {
history = [...active.messages, userMsg];
const id = convId;
setConversations((cs) => cs.map((c) => (c.id === id ? { ...c, updatedAt: ts, messages: [...c.messages, userMsg, reply] } : c)));
}
setInput("");
stick.current = true;
run(convId, reply.id, history, 0, model);
};
const regenerate = () => {
if (!active || busy) return;
const msgs = active.messages;
const last = msgs[msgs.length - 1];
if (!last || last.role !== "assistant") return;
const history = msgs.slice(0, -1);
const key = history[history.length - 1]?.id ?? active.id;
const attempt = (attempts.current.get(key) ?? 0) + 1;
attempts.current.set(key, attempt);
const reply: ChatMessage = { id: uid("a"), role: "assistant", content: "", createdAt: stamp(), model, status: "streaming" };
const id = active.id;
setConversations((cs) => cs.map((c) => (c.id === id ? { ...c, updatedAt: reply.createdAt, messages: [...history, reply] } : c)));
stick.current = true;
run(id, reply.id, history, attempt, model);
};
const stop = () => abortRef.current?.abort();
const newChat = () => {
setActiveId(null);
setDrawer(false);
requestAnimationFrame(() => textareaRef.current?.focus());
};
const selectChat = (id: string) => {
setActiveId(id);
setDrawer(false);
};
const deleteChat = (id: string) => {
if (generating?.convId === id) abortRef.current?.abort();
setConversations((cs) => cs.filter((c) => c.id !== id));
if (activeId === id) setActiveId(null);
};
// Escape stops generation; also closes the drawer.
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (drawer) setDrawer(false);
else if (abortRef.current) abortRef.current.abort();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [drawer]);
/* -------------------------------- render ------------------------------- */
const sidebar = (
<ChatSidebar
conversations={conversations}
activeId={activeId}
now={now}
generatingId={generating?.convId ?? null}
user={user}
onSelect={selectChat}
onNew={newChat}
onRename={(id, title) => setConversations((cs) => cs.map((c) => (c.id === id ? { ...c, title } : c)))}
onDelete={deleteChat}
/>
);
const composer = (
<Composer
value={input}
onChange={setInput}
onSubmit={() => send(input)}
onStop={stop}
generating={busy}
placeholder={`Message ${assistantName}…`}
textareaRef={textareaRef}
footer={
<p className="mt-2 text-center text-[11px] text-muted-foreground">
<span className="hidden sm:inline">
<kbd className="font-sans font-medium">Enter</kbd> to send · <kbd className="font-sans font-medium">Shift + Enter</kbd> for a new line ·{" "}
</span>
{assistantName} can make mistakes.
</p>
}
/>
);
return (
<MotionConfig reducedMotion="user">
<div className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", className)}>
<aside aria-label="Chats" className="hidden w-64 shrink-0 border-r bg-muted/30 md:block dark:bg-muted/20">
{sidebar}
</aside>
<main inert={drawer ? true : undefined} className="relative flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center gap-1 border-b px-2 sm:px-3">
<button
type="button"
aria-label="Open chat history"
onClick={() => setDrawer(true)}
className="grid size-8 place-items-center rounded-lg text-muted-foreground outline-none transition hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring md:hidden"
>
<Menu className="size-4" />
</button>
<ModelPicker models={models} value={model} onChange={setModel} />
<AnimatePresence mode="popLayout" initial={false}>
{active && (
<motion.p
key={active.id + active.title}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="hidden min-w-0 truncate border-l pl-3 text-[13px] text-muted-foreground sm:block"
>
{active.title}
</motion.p>
)}
</AnimatePresence>
<button
type="button"
aria-label="New chat"
onClick={newChat}
className="ml-auto grid size-8 place-items-center rounded-lg text-muted-foreground outline-none transition hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring md:hidden"
>
<SquarePen className="size-4" />
</button>
</header>
{active ? (
<>
<div ref={scrollerRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
<div className="mx-auto w-full max-w-3xl space-y-6 px-4 py-6 sm:px-6" aria-live="polite" aria-busy={busy}>
{active.messages.map((m, i) => (
<ChatMessageView
key={m.id}
message={m}
models={models}
isLast={i === active.messages.length - 1}
busy={busy}
onRegenerate={regenerate}
onFeedback={(v) => updateMessage(active.id, m.id, (x) => ({ ...x, feedback: v }))}
/>
))}
</div>
</div>
<div className="relative shrink-0 px-3 pb-3 pt-1 sm:px-6">
<AnimatePresence>
{!atBottom && (
<motion.button
type="button"
aria-label="Scroll to latest message"
onClick={() => scrollToBottom()}
initial={{ opacity: 0, y: 8, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.9 }}
className="absolute -top-11 left-1/2 grid size-8 -translate-x-1/2 place-items-center rounded-full border bg-background text-muted-foreground shadow-md outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowDown className="size-4" />
</motion.button>
)}
</AnimatePresence>
<div className="mx-auto max-w-3xl">{composer}</div>
</div>
</>
) : (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 sm:px-6">
<div className="m-auto w-full max-w-2xl py-8">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.2, 0.8, 0.2, 1] }}
className="text-center"
>
<div className="relative mx-auto grid size-14 place-items-center">
<motion.span
aria-hidden
className="absolute inset-0 rounded-full bg-gradient-to-br from-violet-500 via-fuchsia-500 to-amber-400 opacity-30 blur-xl"
animate={{ scale: [1, 1.15, 1], opacity: [0.25, 0.45, 0.25] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
/>
<AssistantMark className="size-12 [&_svg]:size-5" />
</div>
<h1 className="mt-5 text-2xl font-semibold tracking-tight">How can I help today?</h1>
<p className="mt-1.5 text-sm text-muted-foreground">Ask anything, or start from one of these.</p>
</motion.div>
<div className="mt-7">{composer}</div>
<ul className="mt-5 grid grid-cols-1 gap-2 sm:grid-cols-2">
{suggestions.map((s, i) => {
const Icon = SUGGESTION_ICONS[i % SUGGESTION_ICONS.length];
return (
<motion.li
key={s.prompt}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + i * 0.05, duration: 0.3 }}
>
<button
type="button"
onClick={() => send(s.prompt)}
className="group flex w-full items-start gap-3 rounded-xl border bg-card p-3 text-left outline-none transition hover:-translate-y-px hover:border-foreground/15 hover:shadow-sm focus-visible:ring-2 focus-visible:ring-ring dark:bg-muted/30"
>
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground transition group-hover:bg-primary/10 group-hover:text-primary">
<Icon className="size-4" aria-hidden />
</span>
<span className="min-w-0">
<span className="block text-[13px] font-medium">{s.title}</span>
<span className="block truncate text-xs text-muted-foreground">{s.prompt}</span>
</span>
</button>
</motion.li>
);
})}
</ul>
</div>
</div>
)}
</main>
{/* Mobile history drawer */}
<AnimatePresence>
{drawer && (
<>
<motion.div
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setDrawer(false)}
className="absolute inset-0 z-30 bg-foreground/15 md:hidden dark:bg-black/50"
/>
<motion.aside
aria-label="Chats"
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", stiffness: 420, damping: 40 }}
className="absolute inset-y-0 left-0 z-40 w-72 max-w-[85%] border-r bg-background shadow-2xl md:hidden"
>
<button
type="button"
aria-label="Close chat history"
onClick={() => setDrawer(false)}
className="absolute right-2 top-3.5 z-10 grid size-7 place-items-center rounded-md text-muted-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</button>
{sidebar}
</motion.aside>
</>
)}
</AnimatePresence>
</div>
</MotionConfig>
);
}
export default AiChatApp;