"use client"; import type { UIMessage } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { Loader2, MessageCircleIcon, RefreshCw, Send, X } from "lucide-react"; import type { ComponentProps, ReactNode, SyntheticEvent } from "react"; import { createContext, use, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; import { Markdown } from "./markdown"; import { MessageFeedback } from "./message-feedback"; import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from "./ui/drawer"; // ─── Context ───────────────────────────────────────────────────────────────── const AIChatContext = createContext<{ open: boolean; setOpen: (open: boolean) => void; chat: ReturnType; } | null>(null); export function useAIChat() { const ctx = use(AIChatContext); if (!ctx) throw new Error("Missing "); return ctx; } function useChatContext() { return useAIChat().chat; } // ─── Root ──────────────────────────────────────────────────────────────────── const DEFAULT_PANEL_WIDTH = 400; const MIN_PANEL_WIDTH = 320; const MAX_PANEL_WIDTH = 640; const chatTransport = new DefaultChatTransport({ api: "/api/docs/chat", }); /** Dispatched to open the panel from outside the provider tree (e.g. mobile top nav). */ export const OPEN_AI_CHAT_EVENT = "better-auth:open-ai-chat"; export function AIChat({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); const chat = useChat({ id: "ai-chat", transport: chatTransport, }); useEffect(() => { const onOpen = () => setOpen(true); window.addEventListener(OPEN_AI_CHAT_EVENT, onOpen); return () => window.removeEventListener(OPEN_AI_CHAT_EVENT, onOpen); }, []); // Support ?askai= URL param const handleAskAiParam = useEffectEvent(() => { const params = new URLSearchParams(window.location.search); const aiQuery = params.get("askai"); if (aiQuery) { setOpen(true); void chat.sendMessage({ text: aiQuery }); const newParams = new URLSearchParams(window.location.search); newParams.delete("askai"); const newUrl = newParams.toString() ? `${window.location.pathname}?${newParams.toString()}` : window.location.pathname; window.history.replaceState({}, "", newUrl); } }); useEffect(() => { handleAskAiParam(); }, []); return ( ({ chat, open, setOpen }), [chat, open])} > {children} ); } // ─── Trigger ───────────────────────────────────────────────────────────────── export function AIChatTrigger({ className, ...props }: ComponentProps<"button">) { const { open, setOpen } = useAIChat(); return ( ); } // ─── Panel Messages ────────────────────────────────────────────────────────── function PanelMessages({ className, ...props }: ComponentProps<"div">) { const { messages } = useChatContext(); const containerRef = useRef(null); const filtered = messages.filter((msg) => msg.role !== "system"); useEffect(() => { if (!containerRef.current) return; const container = containerRef.current; const scrollToBottom = () => { container.scrollTo({ top: container.scrollHeight, behavior: "instant", }); }; // Observe size changes on all current and future children const resizeObserver = new ResizeObserver(scrollToBottom); const observeChildren = () => { resizeObserver.disconnect(); for (const child of container.children) { resizeObserver.observe(child); } }; observeChildren(); // Re-attach when children are added/removed const mutationObserver = new MutationObserver(observeChildren); mutationObserver.observe(container, { childList: true }); // Prevent scroll chaining to body on desktop const onWheel = (e: WheelEvent) => { const { scrollTop, scrollHeight, clientHeight } = container; const atTop = scrollTop <= 0 && e.deltaY < 0; const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0; if (atTop || atBottom) { e.preventDefault(); } }; container.addEventListener("wheel", onWheel, { passive: false }); return () => { resizeObserver.disconnect(); mutationObserver.disconnect(); container.removeEventListener("wheel", onWheel); }; }, []); return (
{filtered.length === 0 ? (

Start a new chat below.

) : (
{filtered.map((item, index) => ( ))}
)}
); } // ─── Panel Input ───────────────────────────────────────────────────────────── function PanelInput({ autoFocus = false }: { autoFocus?: boolean }) { const { status, sendMessage, stop, setMessages, messages, regenerate } = useChatContext(); const [input, setInput] = useState(""); const textareaRef = useRef(null); const isLoading = status === "streaming" || status === "submitted"; const inputDragRef = useRef<{ startY: number; startHeight: number } | null>( null, ); const [inputMinHeight, setInputMinHeight] = useState(38); const adjustHeight = useCallback(() => { const el = textareaRef.current; if (!el) return; const maxH = window.innerHeight * 0.35; el.style.height = "auto"; el.style.height = `${Math.min(maxH, Math.max(el.scrollHeight, inputMinHeight))}px`; }, [inputMinHeight]); useEffect(() => { adjustHeight(); }, [adjustHeight]); const onSubmit = (e?: SyntheticEvent) => { e?.preventDefault(); if (!input.trim() || isLoading) return; void sendMessage({ text: input }); setInput(""); requestAnimationFrame(adjustHeight); }; const handleDragStart = useCallback( (e: React.MouseEvent) => { e.preventDefault(); inputDragRef.current = { startY: e.clientY, startHeight: inputMinHeight }; const onMove = (ev: MouseEvent) => { if (!inputDragRef.current) return; const delta = inputDragRef.current.startY - ev.clientY; setInputMinHeight( Math.max(38, Math.min(400, inputDragRef.current.startHeight + delta)), ); }; const onUp = () => { inputDragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); document.body.style.userSelect = ""; document.body.style.cursor = ""; }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); document.body.style.userSelect = "none"; document.body.style.cursor = "row-resize"; }, [inputMinHeight], ); return (
{/* Drag handle to resize input */}