"use client"; import { cn } from "@midday/ui/cn"; import { Icons } from "@midday/ui/icons"; import { TextShimmer } from "@midday/ui/text-shimmer"; import Link from "next/link"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, } from "react"; import { Streamdown } from "streamdown"; type Message = { id: string; role: "user" | "assistant"; content: string; }; type ChatPanelProps = { isOpen: boolean; onClose: () => void; initialMessage?: string; }; export type ChatPanelRef = { sendMessage: (message: string) => void; }; export const ChatPanel = forwardRef( function ChatPanel({ isOpen, onClose, initialMessage }, ref) { const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [rateLimitReset, setRateLimitReset] = useState(null); const [input, setInput] = useState(""); const messagesEndRef = useRef(null); const inputRef = useRef(null); const abortControllerRef = useRef(null); const initialMessageSent = useRef(false); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); // Disable body scroll when panel is open useEffect(() => { if (isOpen) { document.body.style.overflow = "hidden"; } else { document.body.style.overflow = ""; } return () => { document.body.style.overflow = ""; }; }, [isOpen]); // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" && isOpen) { onClose(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, onClose]); // Send initial message when panel opens with a message useEffect(() => { if ( isOpen && initialMessage && !initialMessageSent.current && messages.length === 0 ) { initialMessageSent.current = true; sendMessageFn(initialMessage); } }, [isOpen, initialMessage]); // Reset when panel closes useEffect(() => { if (!isOpen) { initialMessageSent.current = false; } }, [isOpen]); const sendMessageFn = useCallback( async (userMessage: string) => { if (!userMessage.trim() || isLoading) return; setError(null); setIsLoading(true); const userMsg: Message = { id: crypto.randomUUID(), role: "user", content: userMessage, }; setMessages((prev) => [...prev, userMsg]); abortControllerRef.current = new AbortController(); try { const response = await fetch("/api/docs/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: [...messages, userMsg].map((m) => ({ role: m.role, content: m.content, })), }), signal: abortControllerRef.current.signal, }); if (response.status === 429) { const resetHeader = response.headers.get("X-RateLimit-Reset"); setRateLimitReset( resetHeader ? Number(resetHeader) : Date.now() + 3600000, ); setIsLoading(false); return; } if (!response.ok) { throw new Error("Failed to send message"); } const reader = response.body?.getReader(); const decoder = new TextDecoder(); if (!reader) { throw new Error("No response body"); } const assistantId = crypto.randomUUID(); let assistantContent = ""; setMessages((prev) => [ ...prev, { id: assistantId, role: "assistant", content: "" }, ]); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); assistantContent += chunk; setMessages((prev) => prev.map((m) => m.id === assistantId ? { ...m, content: assistantContent } : m, ), ); } } catch (err) { if (err instanceof Error && err.name === "AbortError") { return; } setError("Something went wrong. Please try again."); console.error("Chat error:", err); } finally { setIsLoading(false); abortControllerRef.current = null; } }, [isLoading, messages], ); // Expose sendMessage to parent via ref useImperativeHandle( ref, () => ({ sendMessage: sendMessageFn, }), [sendMessageFn], ); const isRateLimited = rateLimitReset && rateLimitReset > Date.now(); return ( <> {/* Backdrop for mobile */} {isOpen && (
)} {/* Panel */}
{/* Header */}

Assistant

{/* Messages */}
{messages.length === 0 && !isLoading && (

Ask anything about Midday

{[ "How do I create an invoice?", "Connect my bank", "Set up recurring invoices", "Track time on projects", ].map((suggestion) => ( ))}
)} {(messages.length > 0 || isLoading) && (
{messages.map((message) => (
{message.role === "user" ? ( message.content ) : ( ( {children} ), p: ({ children }) => (

{children}

), ul: ({ children }) => (
    {children}
), ol: ({ children }) => (
    {children}
), li: ({ children }) => (
  • {children}
  • ), h1: ({ children }) => (

    {children}

    ), h2: ({ children }) => (

    {children}

    ), h3: ({ children }) => (

    {children}

    ), strong: ({ children }) => ( {children} ), code: ({ children }) => ( {children} ), pre: ({ children }) => (
                                    {children}
                                  
    ), }} > {message.content}
    )}
    ))} {isLoading && messages[messages.length - 1]?.role === "user" && (
    Thinking...
    )}
    )} {/* Error */} {error && (

    {error}

    )} {/* Rate limit */} {isRateLimited && (

    Rate limit reached. Try again later or{" "} browse the docs .

    )}
    {/* Input - shown when there are messages */} {messages.length > 0 && (
    { e.preventDefault(); if (isLoading) { // Stop streaming abortControllerRef.current?.abort(); setIsLoading(false); return; } if (!input.trim()) return; sendMessageFn(input.trim()); setInput(""); }} >
    setInput(e.target.value)} placeholder="Ask a follow-up..." disabled={isLoading} className="w-full bg-transparent px-4 py-3 pr-12 text-sm outline-none placeholder:text-[rgba(102,102,102,0.5)] disabled:opacity-50" />
    )}
    ); }, );