gridland
Blocks

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.

AI Chat Interface
?

This demo connects to a live LLM via a Next.js API route. Set your OPENROUTER_API_KEY in .env to try it.

Installation

Terminal
bun add @gridland/ui @ai-sdk/react @openrouter/ai-sdk-provider ai
Terminal
npm install @gridland/ui @ai-sdk/react @openrouter/ai-sdk-provider ai
Terminal
yarn add @gridland/ui @ai-sdk/react @openrouter/ai-sdk-provider ai
Terminal
pnpm add @gridland/ui @ai-sdk/react @openrouter/ai-sdk-provider ai

Server Route

Create an API route that streams responses from your model provider.

app/api/chat/route.ts
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.

chat-interface.tsx
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:

ComponentRole
SideNavSidebar navigation with conversation history and keyboard-driven switching
MessageRenders individual messages with role-based styling and streaming
PromptInputInput field with submit/stop, slash commands, and file mentions
ChainOfThoughtExpandable reasoning blocks for models with extended thinking
SelectInputModel picker dropdown inside a modal
ModalOverlay for model selection
CommandProviderRegisters /model and /clear slash commands

Customization

Using a different model

Swap the model ID in the API route to use any provider on OpenRouter:

route.ts
const result = streamText({
  model: openrouter.chat("anthropic/claude-sonnet-4"),
  messages: await convertToModelMessages(messages),
})

Direct provider (no OpenRouter)

route.ts
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>