"use client"; import { useRef, useEffect, useState, useCallback, type PointerEvent as ReactPointerEvent, } from "react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { Streamdown } from "streamdown"; import Link from "next/link"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; const STORAGE_KEY = "docs-chat-messages"; const transport = new DefaultChatTransport({ api: "/api/docs-chat" }); const DESKTOP_DEFAULT_WIDTH = 400; const DESKTOP_MIN_WIDTH = 300; const DESKTOP_MAX_WIDTH = 700; function setCookie(name: string, value: string) { document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`; } 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\//g, "/") .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({ defaultOpen = false, defaultWidth = DESKTOP_DEFAULT_WIDTH, }: { defaultOpen?: boolean; defaultWidth?: number; }) { const [open, setOpen] = useState(defaultOpen); const [input, setInput] = useState(""); const [isDesktop, setIsDesktop] = useState(false); const [hasMounted, setHasMounted] = useState(false); const [desktopWidth, setDesktopWidth] = useState( Math.min(DESKTOP_MAX_WIDTH, Math.max(DESKTOP_MIN_WIDTH, defaultWidth)), ); const messagesScrollRef = useRef(null); const inputRef = useRef(null); const restoredRef = useRef(false); const isDraggingRef = useRef(false); const { messages, sendMessage, status, setMessages, error } = useChat({ transport, }); const isLoading = status === "streaming" || status === "submitted"; const showMessages = messages.length > 0 || !!error || isLoading; // Detect desktop vs mobile. Close sidebar on mobile if it was open from cookie. useEffect(() => { const mq = window.matchMedia("(min-width: 640px)"); setIsDesktop(mq.matches); setHasMounted(true); // If on mobile but sidebar was open from cookie, close it if (!mq.matches && defaultOpen) { setOpen(false); } const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches); mq.addEventListener("change", handler); return () => mq.removeEventListener("change", handler); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Persist open state to cookie (only after mount to avoid overwriting on mobile) useEffect(() => { if (hasMounted) { setCookie("docs-chat-open", String(open)); } }, [open, hasMounted]); // Push page content on desktop when pane is open. // Use padding on body so the page scrollbar stays at the viewport edge (behind the sidebar) // instead of appearing right next to the sidebar's scrollbar. useEffect(() => { const body = document.body; if (isDesktop && open) { body.style.paddingRight = `${desktopWidth}px`; if (!isDraggingRef.current) { body.style.transition = "padding-right 150ms ease"; } } else if (isDesktop) { body.style.paddingRight = "0px"; body.style.transition = "padding-right 150ms ease"; } return () => { body.style.paddingRight = "0px"; body.style.transition = ""; }; }, [isDesktop, open, desktopWidth]); // Resize handle drag const handleResizePointerDown = useCallback( (e: ReactPointerEvent) => { e.preventDefault(); isDraggingRef.current = true; document.documentElement.style.transition = "none"; const startX = e.clientX; const startWidth = desktopWidth; const onPointerMove = (ev: globalThis.PointerEvent) => { const delta = startX - ev.clientX; const newWidth = Math.min( DESKTOP_MAX_WIDTH, Math.max(DESKTOP_MIN_WIDTH, startWidth + delta), ); setDesktopWidth(newWidth); }; const onPointerUp = () => { isDraggingRef.current = false; document.documentElement.style.transition = ""; document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerup", onPointerUp); }; document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); }, [desktopWidth], ); // Persist width to cookie useEffect(() => { setCookie("docs-chat-width", String(desktopWidth)); }, [desktopWidth]); // 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]); // Cmd+K to open sidebar and focus prompt, Escape to close useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen((prev) => { if (!prev) { setTimeout(() => inputRef.current?.focus(), 200); } return !prev; }); } if (e.key === "Escape" && open && isDesktop) { setOpen(false); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [open, isDesktop]); // Auto-focus input when opened useEffect(() => { if (open) { const timer = setTimeout(() => inputRef.current?.focus(), 200); return () => clearTimeout(timer); } }, [open]); // Auto-open when error occurs useEffect(() => { if (error) setOpen(true); }, [error]); // Scroll to bottom when messages change or error occurs useEffect(() => { const el = messagesScrollRef.current; if (!el) return; requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }); }, [messages, error]); const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); if (!input.trim() || isLoading) return; sendMessage({ text: input }); setInput(""); }, [input, isLoading, sendMessage], ); const handleClear = useCallback(() => { setMessages([]); sessionStorage.removeItem(STORAGE_KEY); }, [setMessages]); const hasVisibleContent = ( parts: (typeof messages)[number]["parts"], ): boolean => { return parts.some( (p) => (p.type === "text" && p.text.length > 0) || isToolPart(p), ); }; // Shared chat panel content used by both desktop and mobile const chatPanel = ( <> {/* Header */}
json-render Docs
{showMessages && ( )}
{/* Content: suggestions or messages */} {showMessages ? (
{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." ); } })()}
)}
) : (
{SUGGESTIONS.map((s) => ( ))}
)} {/* Input bar */}