Files
json-render/examples/chat/app/page.tsx
Chris Tate ddae61805e inline mode (#107)
* ai sdk

* chat

* move more to lib

* fixes

* refactor

* fixes

* streamdown

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* data-spec

* fixes

* sortable

* github

* tools

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* tables

* fixes

* fixes

* rename to chat

* fixes docs

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fix lint

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes
2026-02-13 17:53:03 -06:00

494 lines
16 KiB
TypeScript

"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<unknown, AppDataParts>;
// =============================================================================
// 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<string, [string, string]> = {
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 (
<div className="text-sm group">
<button
type="button"
className="flex items-center gap-1.5"
onClick={() => setExpanded((e) => !e)}
>
<span
className={`text-muted-foreground ${isLoading ? "animate-shimmer" : ""}`}
>
{label}
</span>
{!isLoading && (
<ChevronRight
className={`h-3 w-3 text-muted-foreground/0 group-hover:text-muted-foreground transition-all ${expanded ? "rotate-90" : ""}`}
/>
)}
</button>
{expanded && !isLoading && result != null && (
<div className="mt-1 max-h-64 overflow-auto">
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
);
}
// =============================================================================
// 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 (
<div className="flex justify-end">
{text && (
<div className="max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap bg-primary text-primary-foreground rounded-tr-md">
{text}
</div>
)}
</div>
);
}
// 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 (
<div className="w-full flex flex-col gap-3">
{segments.map((seg, i) => {
if (seg.kind === "text") {
const isLastSegment = i === segments.length - 1;
return (
<div
key={`text-${i}`}
className="text-sm leading-relaxed [&_p+p]:mt-3 [&_ul]:mt-2 [&_ol]:mt-2 [&_pre]:mt-2"
>
<Streamdown
plugins={{ code }}
animated={isLast && isStreaming && isLastSegment}
>
{seg.text}
</Streamdown>
</div>
);
}
if (seg.kind === "spec") {
if (!hasSpec) return null;
return (
<div key="spec" className="w-full">
<ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
</div>
);
}
return (
<div key={`tools-${i}`} className="flex flex-col gap-1">
{seg.tools.map((t) => (
<ToolCallDisplay
key={t.toolCallId}
toolName={t.toolName}
state={t.state}
result={t.output}
/>
))}
</div>
);
})}
{/* Loading indicator */}
{showLoader && (
<div className="text-sm text-muted-foreground animate-shimmer">
Thinking...
</div>
)}
{/* Fallback: render spec at end if no inline position was found */}
{showSpecAtEnd && (
<div className="w-full">
<ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
</div>
)}
</div>
);
}
// =============================================================================
// Page
// =============================================================================
export default function ChatPage() {
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLElement>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const isStickToBottom = useRef(true);
const isAutoScrolling = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const { messages, sendMessage, setMessages, status, error } =
useChat<AppMessage>({ 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 (
<div className="h-screen flex flex-col overflow-hidden">
{/* Header */}
<header className="border-b px-6 py-3 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">json-render Chat Example</h1>
</div>
<div className="flex items-center gap-2">
{messages.length > 0 && (
<button
onClick={handleClear}
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
Start Over
</button>
)}
<ThemeToggle />
</div>
</header>
{/* Messages area */}
<main ref={scrollContainerRef} className="flex-1 overflow-auto">
{isEmpty ? (
/* Empty state */
<div className="h-full flex flex-col items-center justify-center px-6 py-12">
<div className="max-w-2xl w-full space-y-8">
<div className="text-center space-y-2">
<h2 className="text-2xl font-semibold tracking-tight">
What would you like to explore?
</h2>
<p className="text-muted-foreground">
Ask about weather, GitHub repos, crypto prices, or Hacker News
-- the agent will fetch real data and build a dashboard.
</p>
</div>
{/* Suggestions */}
<div className="flex flex-wrap gap-2 justify-center">
{SUGGESTIONS.map((s) => (
<button
key={s.label}
onClick={() => handleSubmit(s.prompt)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<Sparkles className="h-3 w-3" />
{s.label}
</button>
))}
</div>
</div>
</div>
) : (
/* Message thread */
<div className="max-w-4xl mx-auto px-10 py-6 space-y-6">
{messages.map((message, index) => (
<MessageBubble
key={message.id}
message={message}
isLast={index === messages.length - 1}
isStreaming={isStreaming}
/>
))}
{/* Error display */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</main>
{/* Input bar - always visible at bottom */}
<div className="px-6 pb-3 flex-shrink-0 bg-background relative">
{/* Scroll to bottom button */}
{showScrollButton && !isEmpty && (
<button
onClick={scrollToBottom}
className="absolute left-1/2 -translate-x-1/2 -top-10 z-10 h-8 w-8 rounded-full border border-border bg-background text-muted-foreground shadow-md flex items-center justify-center hover:text-foreground hover:bg-accent transition-colors"
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</button>
)}
<div className="max-w-4xl mx-auto relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isEmpty
? "e.g., Compare weather in NYC, London, and Tokyo..."
: "Ask a follow-up..."
}
rows={2}
className="w-full resize-none rounded-xl border border-input bg-card px-4 py-3 pr-12 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
autoFocus
/>
<button
onClick={() => handleSubmit()}
disabled={!input.trim() || isStreaming}
className="absolute right-3 bottom-3 h-8 w-8 rounded-lg bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isStreaming ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
);
}