"use client"; import { useRef, useEffect, useState } from "react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { Streamdown } from "streamdown"; import Link from "next/link"; const STORAGE_KEY = "docs-chat-messages"; const transport = new DefaultChatTransport({ api: "/api/docs-chat" }); const TOOL_LABELS: Record< string, { label: string; pastLabel: string; argKey?: string } > = { readFile: { label: "Reading", pastLabel: "Read", argKey: "path" }, bash: { label: "Running", pastLabel: "Ran", argKey: "command" }, }; function isToolPart(part: { type: string }): part is { type: string; toolCallId: string; toolName?: string; state: string; input?: Record; output?: unknown; errorText?: string; } { return part.type.startsWith("tool-") || part.type === "dynamic-tool"; } function getToolName(part: { type: string; toolName?: string }): string { if (part.type === "dynamic-tool") return part.toolName ?? "tool"; return part.type.replace(/^tool-/, ""); } function ToolCallDisplay({ part, }: { part: { type: string; toolCallId: string; toolName?: string; state: string; input?: Record; output?: unknown; errorText?: string; }; }) { const toolName = getToolName(part); const config = TOOL_LABELS[toolName] ?? { label: toolName, pastLabel: toolName, }; const isDone = part.state === "output-available"; const isError = part.state === "output-error"; const isRunning = !isDone && !isError; const displayLabel = isRunning ? config.label : config.pastLabel; const args = (part.input ?? {}) as Record; const argValue = config.argKey ? args[config.argKey] : undefined; const argPreview = argValue != null ? String(argValue) .replace(/^\/workspace\//, "/") .replace(/\.md$/, "") .replace(/\/index$/, "") : ""; // Link to the docs page if it's a /docs/ path from readFile const docsLink = toolName === "readFile" && (argPreview === "/docs" || argPreview.startsWith("/docs/")) ? argPreview : null; const argEl = argPreview ? ( docsLink ? ( {argPreview} ) : ( {argPreview} ) ) : null; return (
{isRunning ? ( {displayLabel} {argEl} ) : ( {displayLabel} {argEl} {isError && failed} )}
); } const SUGGESTIONS = [ "What is json-render?", "How do I install it?", "How does streaming work?", "What components are available?", "How do I create a custom schema?", ]; export function DocsChat() { const [open, setOpen] = useState(false); const [input, setInput] = useState(""); const [focused, setFocused] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); const containerRef = useRef(null); const restoredRef = useRef(false); const { messages, sendMessage, status, setMessages, error } = useChat({ transport, }); const isLoading = status === "streaming" || status === "submitted"; // Restore messages from sessionStorage on mount useEffect(() => { if (restoredRef.current) return; restoredRef.current = true; try { const stored = sessionStorage.getItem(STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed) && parsed.length > 0) { setMessages(parsed); } } } catch { // ignore parse errors } }, [setMessages]); // Save completed messages to sessionStorage useEffect(() => { if (!restoredRef.current) return; if (isLoading) return; if (messages.length === 0) { sessionStorage.removeItem(STORAGE_KEY); return; } try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); } catch { // ignore quota errors } }, [messages, isLoading]); // Auto-open when new messages arrive (but not on initial restore) const prevMessageCount = useRef(null); const initializedRef = useRef(false); useEffect(() => { // Skip until after the first sessionStorage restore cycle if (!initializedRef.current) { // Wait one tick after mount to let restore settle const id = requestAnimationFrame(() => { prevMessageCount.current = messages.length; initializedRef.current = true; }); return () => cancelAnimationFrame(id); } if ( prevMessageCount.current !== null && messages.length > prevMessageCount.current ) { setOpen(true); } prevMessageCount.current = messages.length; }, [messages.length]); // Scroll to bottom when messages change or error occurs useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, error]); // Cmd+K to focus prompt, Esc to close useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); inputRef.current?.focus(); } if (e.key === "Escape" && open) { setOpen(false); inputRef.current?.blur(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [open]); // Close message area when clicking outside useEffect(() => { if (!open) return; const handleClickOutside = (e: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(e.target as Node) ) { setOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [open]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!input.trim() || isLoading) return; sendMessage({ text: input }); setInput(""); }; const handleClear = () => { setMessages([]); sessionStorage.removeItem(STORAGE_KEY); setOpen(false); inputRef.current?.focus(); }; const hasVisibleContent = ( parts: (typeof messages)[number]["parts"], ): boolean => { return parts.some( (p) => (p.type === "text" && p.text.length > 0) || isToolPart(p), ); }; // Auto-open when error occurs useEffect(() => { if (error) setOpen(true); }, [error]); const showMessages = open && (messages.length > 0 || !!error); const showSuggestions = focused && messages.length === 0 && !isLoading; return (
*]:pointer-events-auto transition-all duration-300 ${focused || showMessages ? "max-w-xl" : "max-w-56"}`} >
{/* Suggestions panel */} {showSuggestions && (
json-render Docs
{SUGGESTIONS.map((s) => ( ))}
)} {/* Messages panel */} {showMessages && (
json-render Docs
{ if ((e.target as HTMLElement).closest("a")) { setOpen(false); } }} > {messages.map((message) => { if (!hasVisibleContent(message.parts)) return null; return (
{message.role === "user" ? (
{message.parts .filter( (p): p is Extract => p.type === "text", ) .map((p) => p.text) .join("")}
) : (
{message.parts.map((part, i) => { if (part.type === "text" && part.text) { return (
{part.text}
); } if (isToolPart(part)) { return ( ); } return null; })}
)}
); })} {error && (
{(() => { try { const parsed = JSON.parse(error.message); return parsed.message || parsed.error || error.message; } catch { return ( error.message || "Something went wrong. Please try again." ); } })()}
)}
)} {/* Input bar */}
inputRef.current?.focus()} className={`relative flex items-end gap-2 px-3 py-2 cursor-text${showMessages ? " border-t border-background" : ""}`} > {!input && (
Ask a question... {!focused && !showMessages && ( ⌘K )}
)}