"use client"; import type { UIMessage } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { AnimatePresence, motion } from "framer-motion"; import { useDocsSearch } from "fumadocs-core/search/client"; import { ArrowUp, FileText, Hash, Loader2, Search, Sparkles, Text, Trash2, } from "lucide-react"; import { useRouter } from "next/navigation"; import type { ComponentProps, SyntheticEvent } from "react"; import { createContext, use, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; import { Markdown } from "./markdown"; import { MessageFeedback } from "./message-feedback"; // ─── Context ───────────────────────────────────────────────────────────────── type CommandMenuContextValue = { open: boolean; setOpen: (open: boolean) => void; openAI: () => void; }; const CommandMenuContext = createContext(null); export function useCommandMenu() { const ctx = use(CommandMenuContext); if (!ctx) throw new Error("useCommandMenu must be used within CommandMenuProvider"); return ctx; } // ─── AI Suggestions ────────────────────────────────────────────────────────── const suggestions = [ "How to configure Sqlite database?", "How to require email verification?", "How to change session expiry?", "How to share cookies across subdomains?", ]; // ─── Provider ──────────────────────────────────────────────────────────────── const initialModeRef = { current: "search" as Mode }; const initialAiQueryRef = { current: null as string | null }; export function CommandMenuProvider({ children, }: { children: React.ReactNode; }) { const [open, setOpen] = useState(false); const openAI = useCallback(() => { initialModeRef.current = "ai"; setOpen(true); }, []); useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen((prev) => !prev); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); // Support ?askai= URL param useEffect(() => { const params = new URLSearchParams(window.location.search); const aiQuery = params.get("askai"); if (aiQuery) { // Store the query before cleaning URL so dialog can access it // Note: URLSearchParams.get() already returns a decoded string initialAiQueryRef.current = aiQuery; initialModeRef.current = "ai"; setOpen(true); // Clean up URL 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); } }, []); const value = useMemo(() => ({ open, setOpen, openAI }), [open, openAI]); return ( {children} ); } // ─── Dialog ────────────────────────────────────────────────────────────────── type Mode = "search" | "ai"; function CommandMenuDialog() { const { open, setOpen } = useCommandMenu(); const [mode, setMode] = useState("search"); const [searchQuery, setSearchQuery] = useState(""); // Pick up initial mode when opening useEffect(() => { if (open && initialModeRef.current !== "search") { setMode(initialModeRef.current); initialModeRef.current = "search"; } }, [open]); const chat = useChat({ id: "command-menu-ai", transport: new DefaultChatTransport({ api: "/api/docs/chat" }), }); // Handle initial ?askai= param (read from ref since URL is cleaned by provider) const initialAiQueryHandled = useRef(false); useEffect(() => { if (open && !initialAiQueryHandled.current && initialAiQueryRef.current) { setMode("ai"); void chat.sendMessage({ text: initialAiQueryRef.current }); initialAiQueryHandled.current = true; initialAiQueryRef.current = null; } }, [open]); const handleOpenChange = useCallback( (next: boolean) => { setOpen(next); if (!next) { // Reset state on close setTimeout(() => { setMode("search"); setSearchQuery(""); chat.setMessages([]); }, 200); } }, [setOpen, chat], ); const handleTabToggle = useCallback(() => { setMode((prev) => (prev === "search" ? "ai" : "search")); }, []); return ( {open && ( <> {/* Overlay */} handleOpenChange(false)} onKeyDown={(e) => { if (e.key === "Escape") handleOpenChange(false); }} /> {/* Dialog */} { if (e.key === "Escape") { e.stopPropagation(); handleOpenChange(false); } if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); handleTabToggle(); } }} >
{mode === "search" ? ( handleOpenChange(false)} onTabToggle={handleTabToggle} /> ) : ( handleOpenChange(false)} onTabToggle={handleTabToggle} /> )}
)}
); } // ─── Search Mode ───────────────────────────────────────────────────────────── function SearchMode({ query, setQuery, onClose, onTabToggle, }: { query: string; setQuery: (q: string) => void; onClose: () => void; onTabToggle: () => void; }) { const { search: _search, setSearch, query: results, } = useDocsSearch({ type: "fetch", api: "/api/docs/search", }); const router = useRouter(); const inputRef = useRef(null); const [selectedIndex, setSelectedIndex] = useState(0); // Sync external query with search useEffect(() => { setSearch(query); }, [query, setSearch]); // Auto-focus useEffect(() => { inputRef.current?.focus(); }, []); const items = results.data !== "empty" ? (results.data ?? []) : []; // Reset selection when results change useEffect(() => { setSelectedIndex(0); }, [items.length]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((prev) => Math.max(prev - 1, 0)); } else if (e.key === "Enter" && items[selectedIndex]) { e.preventDefault(); router.push(items[selectedIndex].url); onClose(); } else if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); onTabToggle(); } }; return ( <> {/* Input */}
setQuery(e.target.value)} onKeyDown={handleKeyDown} placeholder="Search documentation..." className="flex h-11 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground font-mono" />
{/* Results */}
{results.isLoading && (
Searching...
)} {!results.isLoading && query && items.length === 0 && (
No results found.
)} {!results.isLoading && !query && (
Type to search documentation...
)} {items.map((item, index) => { const isNested = item.type === "heading" || item.type === "text"; const pageName = (item as any).pageName as string | undefined; return ( ); })}
{/* Footer */}
↑↓ {" "} navigate {" "} open esc {" "} close
); } // ─── AI Mode ───────────────────────────────────────────────────────────────── function AIMode({ chat, onClose, onTabToggle, }: { chat: ReturnType; onClose: () => void; onTabToggle: () => void; }) { const { messages, status, sendMessage, stop, setMessages } = chat; const [input, setInput] = useState(""); const isLoading = status === "streaming" || status === "submitted"; const showSuggestions = messages.length === 0 && !isLoading; const listRef = useRef(null); const isUserScrollingRef = useRef(false); const prevMessageCountRef = useRef(messages.length); const onStart = (e?: SyntheticEvent) => { e?.preventDefault(); if (!input.trim() || isLoading) return; void sendMessage({ text: input }); setInput(""); }; const handleSuggestionClick = (suggestion: string) => { void sendMessage({ text: suggestion }); }; const handleClear = () => { setMessages([]); setInput(""); }; // Scroll to bottom on new messages useEffect(() => { if (messages.length > prevMessageCountRef.current) { isUserScrollingRef.current = false; listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth", }); } prevMessageCountRef.current = messages.length; }, [messages.length]); // Auto-scroll on content changes useEffect(() => { if (!listRef.current) return; const container = listRef.current; function callback() { if (!container || isUserScrollingRef.current) return; container.scrollTo({ top: container.scrollHeight, behavior: "instant", }); } const observer = new ResizeObserver(callback); const element = container.firstElementChild; if (element) observer.observe(element); return () => observer.disconnect(); }, []); // Track user scroll useEffect(() => { const container = listRef.current; if (!container) return; const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = container; isUserScrollingRef.current = scrollHeight - scrollTop - clientHeight >= 50; }; container.addEventListener("scroll", handleScroll, { passive: true }); return () => container.removeEventListener("scroll", handleScroll); }, []); return ( <> {/* Input */}
{isLoading ? ( ) : ( )}
{/* Messages / Suggestions */}
{ if (e.key === "Escape") onClose(); }} > {showSuggestions && (

Try asking:

We also offer{" "} Skills {" "} and{" "} MCP servers {" "} for local development integrations.

{suggestions.map((s) => ( ))}
)} {!showSuggestions && (
{messages .filter((msg) => msg.role !== "system") .map((item, index, filtered) => { const isLastMessage = index === filtered.length - 1; const isCurrentlyStreaming = isLoading && item.role === "assistant" && isLastMessage; return ( ); })} {status === "submitted" && }
)}
{/* Footer */}
{messages.length > 0 && !isLoading && ( )} esc {" "} close
); } // ─── AI Text Input ─────────────────────────────────────────────────────────── function AITextInput({ value, onChange, onSubmit, disabled, placeholder, }: { value: string; onChange: (v: string) => void; onSubmit: () => void; disabled: boolean; placeholder: string; }) { const ref = useRef(null); useEffect(() => { ref.current?.focus(); }, []); return (