Fazekit

Code

"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;