AI Chat Interface
Full-featured AI chat with SideNav, conversation history, model switching, and streaming
A full-featured AI chat interface with SideNav for conversation history, multi-model
switching via slash commands, ChainOfThought reasoning blocks, and streaming support.
Built on the Vercel AI SDK and compatible with any model provider via OpenRouter, OpenAI,
Anthropic, or custom endpoints.
This demo connects to a live LLM via a Next.js API route. Set your OPENROUTER_API_KEY in .env to try it.
Installation
bun add @gridland/ui @ai-sdk/react @openrouter/ai-sdk-provider ainpm install @gridland/ui @ai-sdk/react @openrouter/ai-sdk-provider aiyarn add @gridland/ui @ai-sdk/react @openrouter/ai-sdk-provider aipnpm add @gridland/ui @ai-sdk/react @openrouter/ai-sdk-provider aiServer Route
Create an API route that streams responses from your model provider.
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { streamText, convertToModelMessages, type UIMessage } from "ai"
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
})
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json()
const result = streamText({
model: openrouter.chat("openai/gpt-4o-mini"),
messages: await convertToModelMessages(messages),
})
return result.toUIMessageStreamResponse()
}Client Component
The chat interface uses SideNav for conversation history, useChat from the Vercel AI SDK
for streaming, and CommandProvider for slash commands like /model and /clear.
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
import { SideNav } from "@/components/ui/side-nav"
import { Message, MessageContent, MessageText } from "@/components/ui/message"
import { PromptInput, CommandProvider, useRegisterCommands } from "@/components/ui/prompt-input"
import type { ChatStatus } from "@/components/ui/prompt-input"
import { SelectInput } from "@/components/ui/select-input"
import { Modal } from "@/components/ui/modal"
import {
ChainOfThought,
ChainOfThoughtHeader,
ChainOfThoughtContent,
ChainOfThoughtStep,
} from "@/components/ui/chain-of-thought"
import { useChat } from "@ai-sdk/react"
function ChatPanel({ conversationId, selectedModelId, isInteracting }) {
const [showModelPicker, setShowModelPicker] = useState(false)
const { messages, status, sendMessage, stop, setMessages } = useChat({
id: conversationId,
api: "/api/chat",
})
const chatStatus: ChatStatus =
status === "streaming" ? "streaming"
: status === "submitted" ? "submitted"
: status === "error" ? "error"
: "ready"
return (
<CommandProvider>
<box flexDirection="column" flexGrow={1}>
{/* Message area */}
<box flexDirection="column" paddingX={1} gap={1} flexGrow={1} overflow="hidden" justifyContent="flex-end">
{messages.map((msg, i) => {
const isLast = i === messages.length - 1
const msgStreaming = isLast && msg.role === "assistant" && status === "streaming"
return (
<Message key={msg.id} role={msg.role} isStreaming={msgStreaming}>
<MessageContent>
{msg.parts?.map((part, j) => {
switch (part.type) {
case "text":
return <MessageText key={j}>{part.text}</MessageText>
default:
return null
}
})}
</MessageContent>
</Message>
)
})}
</box>
{/* Prompt input — registers with the focus system via focusId */}
<PromptInput
focusId="chat-prompt"
autoFocus={isInteracting}
onSubmit={sendMessage}
onStop={stop}
status={chatStatus}
placeholder="Type a message..."
showDividers
/>
</box>
</CommandProvider>
)
}
function ChatApp() {
const [navItems] = useState([{ id: "new-chat", name: "+ New chat" }])
return (
<SideNav items={navItems} title="Claude" sidebarWidth={22}>
{({ activeItem, isInteracting }) => (
<ChatPanel
conversationId={activeItem.id}
selectedModelId="anthropic/claude-sonnet-4"
isInteracting={isInteracting}
/>
)}
</SideNav>
)
}Components Used
This block combines several Gridland components:
| Component | Role |
|---|---|
SideNav | Sidebar navigation with conversation history and keyboard-driven switching |
Message | Renders individual messages with role-based styling and streaming |
PromptInput | Input field with submit/stop, slash commands, and file mentions |
ChainOfThought | Expandable reasoning blocks for models with extended thinking |
SelectInput | Model picker dropdown inside a modal |
Modal | Overlay for model selection |
CommandProvider | Registers /model and /clear slash commands |
Customization
Using a different model
Swap the model ID in the API route to use any provider on OpenRouter:
const result = streamText({
model: openrouter.chat("anthropic/claude-sonnet-4"),
messages: await convertToModelMessages(messages),
})Direct provider (no OpenRouter)
import { anthropic } from "@ai-sdk/anthropic"
const result = streamText({
model: anthropic("claude-sonnet-4-20250514"),
messages: await convertToModelMessages(messages),
})Adding ChainOfThought for reasoning models
When using a model that supports extended thinking (e.g. deepseek-r1, o1),
reasoning parts appear automatically. Compose ChainOfThought directly as a
child of Message, above MessageContent:
const [expanded, setExpanded] = useState(false)
const hasReasoning = msg.parts?.some(p => p.type === "reasoning")
<Message role={msg.role} isStreaming={msgStreaming}>
{hasReasoning && (
<ChainOfThought defaultOpen={expanded}>
<ChainOfThoughtHeader />
</ChainOfThought>
)}
<MessageContent>
{msg.parts?.filter(p => p.type === "text").map((part, j) => (
<MessageText key={j}>{part.text}</MessageText>
))}
</MessageContent>
</Message>