"use client"; import { useState, useCallback, useRef, useEffect } from "react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, type UIMessage } from "ai"; import { SPEC_DATA_PART, SPEC_DATA_PART_TYPE, type SpecDataPart, } from "@json-render/core"; import { useJsonRenderMessage } from "@json-render/react"; import { ExplorerRenderer } from "@/lib/render/renderer"; import { ThemeToggle } from "@/components/theme-toggle"; import { ArrowDown, ArrowUp, ChevronRight, Loader2, Sparkles, } from "lucide-react"; import { Streamdown } from "streamdown"; import { code } from "@streamdown/code"; // ============================================================================= // Types // ============================================================================= type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart }; type AppMessage = UIMessage; // ============================================================================= // Transport // ============================================================================= const transport = new DefaultChatTransport({ api: "/api/generate" }); // ============================================================================= // Suggestions (shown in empty state) // ============================================================================= const SUGGESTIONS = [ { label: "Weather comparison", prompt: "Compare the weather in New York, London, and Tokyo", }, { label: "GitHub repo stats", prompt: "Show me stats for the vercel/next.js and vercel/ai GitHub repos", }, { label: "Crypto dashboard", prompt: "Build a crypto dashboard for Bitcoin, Ethereum, and Solana", }, { label: "Hacker News top stories", prompt: "Show me the top 15 Hacker News stories right now", }, ]; // ============================================================================= // Tool Call Display // ============================================================================= /** Readable labels for tool names: [loading, done] */ const TOOL_LABELS: Record = { getWeather: ["Getting weather data", "Got weather data"], getGitHubRepo: ["Fetching GitHub repo", "Fetched GitHub repo"], getGitHubPullRequests: ["Fetching pull requests", "Fetched pull requests"], getCryptoPrice: ["Looking up crypto price", "Looked up crypto price"], getCryptoPriceHistory: ["Fetching price history", "Fetched price history"], getHackerNewsTop: ["Loading Hacker News", "Loaded Hacker News"], webSearch: ["Searching the web", "Searched the web"], }; function ToolCallDisplay({ toolName, state, result, }: { toolName: string; state: string; result: unknown; }) { const [expanded, setExpanded] = useState(false); const isLoading = state !== "output-available" && state !== "output-error" && state !== "output-denied"; const labels = TOOL_LABELS[toolName]; const label = labels ? (isLoading ? labels[0] : labels[1]) : toolName; return (
{expanded && !isLoading && result != null && (
            {typeof result === "string"
              ? result
              : JSON.stringify(result, null, 2)}
          
)}
); } // ============================================================================= // Message Bubble // ============================================================================= function MessageBubble({ message, isLast, isStreaming, }: { message: AppMessage; isLast: boolean; isStreaming: boolean; }) { const isUser = message.role === "user"; const { spec, text, hasSpec } = useJsonRenderMessage(message.parts); // Build ordered segments from parts, collapsing adjacent text and adjacent tools. // Spec data parts are tracked so the rendered UI appears inline where the AI // placed it rather than always at the bottom. const segments: Array< | { kind: "text"; text: string } | { kind: "tools"; tools: Array<{ toolCallId: string; toolName: string; state: string; output?: unknown; }>; } | { kind: "spec" } > = []; let specInserted = false; for (const part of message.parts) { if (part.type === "text") { if (!part.text.trim()) continue; const last = segments[segments.length - 1]; if (last?.kind === "text") { last.text += part.text; } else { segments.push({ kind: "text", text: part.text }); } } else if (part.type.startsWith("tool-")) { const tp = part as { type: string; toolCallId: string; state: string; output?: unknown; }; const last = segments[segments.length - 1]; if (last?.kind === "tools") { last.tools.push({ toolCallId: tp.toolCallId, toolName: tp.type.replace(/^tool-/, ""), state: tp.state, output: tp.output, }); } else { segments.push({ kind: "tools", tools: [ { toolCallId: tp.toolCallId, toolName: tp.type.replace(/^tool-/, ""), state: tp.state, output: tp.output, }, ], }); } } else if (part.type === SPEC_DATA_PART_TYPE && !specInserted) { // First spec data part — mark where the rendered UI should appear segments.push({ kind: "spec" }); specInserted = true; } } const hasAnything = segments.length > 0 || hasSpec; const showLoader = isLast && isStreaming && message.role === "assistant" && !hasAnything; if (isUser) { return (
{text && (
{text}
)}
); } // If there's a spec but no spec segment was inserted (edge case), // append it so it still renders. const specRenderedInline = specInserted; const showSpecAtEnd = hasSpec && !specRenderedInline; return (
{segments.map((seg, i) => { if (seg.kind === "text") { const isLastSegment = i === segments.length - 1; return (
{seg.text}
); } if (seg.kind === "spec") { if (!hasSpec) return null; return (
); } return (
{seg.tools.map((t) => ( ))}
); })} {/* Loading indicator */} {showLoader && (
Thinking...
)} {/* Fallback: render spec at end if no inline position was found */} {showSpecAtEnd && (
)}
); } // ============================================================================= // Page // ============================================================================= export default function ChatPage() { const [input, setInput] = useState(""); const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); const [showScrollButton, setShowScrollButton] = useState(false); const isStickToBottom = useRef(true); const isAutoScrolling = useRef(false); const inputRef = useRef(null); const { messages, sendMessage, setMessages, status, error } = useChat({ transport }); const isStreaming = status === "streaming" || status === "submitted"; // Track whether the user has scrolled away from the bottom. // During programmatic scrolling, suppress button updates until we arrive. useEffect(() => { const container = scrollContainerRef.current; if (!container) return; const THRESHOLD = 80; const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = container; const atBottom = scrollTop + clientHeight >= scrollHeight - THRESHOLD; if (isAutoScrolling.current) { // Wait for the programmatic scroll to reach the bottom before // handing control back to the user-scroll tracker. if (atBottom) { isAutoScrolling.current = false; } return; } isStickToBottom.current = atBottom; setShowScrollButton(!atBottom); }; container.addEventListener("scroll", handleScroll, { passive: true }); return () => container.removeEventListener("scroll", handleScroll); }, []); // Auto-scroll to bottom on new messages, unless user scrolled up. // Uses instant scrollTop assignment (no smooth animation) to avoid // an ongoing animation that fights user scroll input. useEffect(() => { const container = scrollContainerRef.current; if (!container || !isStickToBottom.current) return; isAutoScrolling.current = true; container.scrollTop = container.scrollHeight; requestAnimationFrame(() => { isAutoScrolling.current = false; }); }, [messages, isStreaming]); const scrollToBottom = useCallback(() => { const container = scrollContainerRef.current; if (!container) return; isStickToBottom.current = true; setShowScrollButton(false); isAutoScrolling.current = true; container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }); // isAutoScrolling is cleared by the scroll handler once it reaches bottom }, []); const handleSubmit = useCallback( async (text?: string) => { const message = text || input; if (!message.trim() || isStreaming) return; setInput(""); await sendMessage({ text: message.trim() }); }, [input, isStreaming, sendMessage], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }, [handleSubmit], ); const handleClear = useCallback(() => { setMessages([]); setInput(""); inputRef.current?.focus(); }, [setMessages]); const isEmpty = messages.length === 0; return (
{/* Header */}

json-render Chat Example

{messages.length > 0 && ( )}
{/* Messages area */}
{isEmpty ? ( /* Empty state */

What would you like to explore?

Ask about weather, GitHub repos, crypto prices, or Hacker News -- the agent will fetch real data and build a dashboard.

{/* Suggestions */}
{SUGGESTIONS.map((s) => ( ))}
) : ( /* Message thread */
{messages.map((message, index) => ( ))} {/* Error display */} {error && (
{error.message}
)}
)}
{/* Input bar - always visible at bottom */}
{/* Scroll to bottom button */} {showScrollButton && !isEmpty && ( )}