# AI Chat

> An AI chat app with a conversation sidebar, a message thread and a composer with model switching and attachments, plus simulated replies.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/templates/ai-chat
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/ai-chat.json
```

Adds the page at `app/ai-chat/page.tsx` together with the components below. Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Components

- [App Sidebar](https://chromotion.dev/r/app-sidebar.md): `@/components/app-sidebar`
- [AI Chat Input](https://chromotion.dev/r/ai-chat-input.md): `@/components/ai-chat-input`

## Dependencies

```bash
npm install lucide-react
```

## Source

```tsx title="app/ai-chat/page.tsx"
"use client"

import * as React from "react"
import { Bot, Hexagon, MessageSquare, Paperclip, Plus, Settings, Sparkles } from "lucide-react"

import { AIChatInput, type ChatSubmission } from "@/components/ai-chat-input"
import { AppSidebar } from "@/components/app-sidebar"
import { cn } from "@/lib/utils"

type Message = { id: number; role: "user" | "assistant"; content: string; model?: string; files?: string[] }

const conversations = ["Landing page copy", "Refactor the auth hook", "SQL for weekly retention", "Trip to Kyoto"]

const suggestions = [
  "Write a hero headline for a deploy platform",
  "Explain React Server Components simply",
  "Draft a changelog entry for v2.4",
]

const replies = [
  "Here's a direction that keeps it short and concrete: lead with the outcome, back it with one number, and end with a low-friction action. For example — “Ship to the edge in seconds. 40,000 teams deploy with Hexa every day. Start free.”",
  "Think of it as splitting your component tree in two. Server components run once on the server, can read data directly and send only HTML. Client components ship JavaScript and handle interaction. You compose them, so most of the page stays light.",
  "Sure. I'd structure it as a one-line summary, then grouped bullet points for new features, improvements and fixes, and finally any migration notes. Want me to fill it in from your merged pull requests?",
]

const firstMessages: Message[] = [
  { id: 1, role: "user", content: "Can you help me tighten the copy on our landing page?" },
  {
    id: 2,
    role: "assistant",
    model: "Claude Opus 5",
    content:
      "Of course. Paste the current hero and the first feature section, and tell me who the page is for. I'll suggest a shorter headline, a clearer subheading and a call to action that matches the visitor's intent.",
  },
]

function Avatar({ role }: { role: Message["role"] }) {
  return role === "user" ? (
    <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">AL</span>
  ) : (
    <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
      <Sparkles className="size-4" />
    </span>
  )
}

export default function AIChat() {
  const [messages, setMessages] = React.useState(firstMessages)
  const [loading, setLoading] = React.useState(false)
  const timer = React.useRef<ReturnType<typeof setInterval>>(undefined)
  const nextId = React.useRef(firstMessages.length + 1)
  const scroller = React.useRef<HTMLDivElement>(null)

  React.useEffect(() => () => clearInterval(timer.current), [])

  React.useEffect(() => {
    scroller.current?.scrollTo({ top: scroller.current.scrollHeight, behavior: "smooth" })
  }, [messages])

  const stop = () => {
    clearInterval(timer.current)
    setLoading(false)
  }

  // Streams a canned reply word by word, like a real model would.
  const send = ({ message, model, files }: ChatSubmission) => {
    const userId = nextId.current++
    const replyId = nextId.current++
    const words = replies[(userId - 1) % replies.length].split(" ")
    setMessages((current) => [
      ...current,
      { id: userId, role: "user", content: message, files: files.map((file) => file.name) },
      { id: replyId, role: "assistant", content: "", model },
    ])
    setLoading(true)

    let shown = 0
    clearInterval(timer.current)
    timer.current = setInterval(() => {
      shown += 1
      setMessages((current) =>
        current.map((item) => (item.id === replyId ? { ...item, content: words.slice(0, shown).join(" ") } : item))
      )
      if (shown >= words.length) stop()
    }, 45)
  }

  return (
    <AppSidebar
      collapseBelow={768}
      brand={
        <>
          <Hexagon className="size-5 shrink-0 fill-primary text-primary" /> Hexa AI
        </>
      }
      brandIcon={<Hexagon className="size-5 shrink-0 fill-primary text-primary" />}
      groups={[
        { items: [{ label: "New chat", href: "#", icon: <Plus /> }] },
        {
          label: "Recent",
          items: conversations.map((title, index) => ({
            label: title,
            href: "#",
            icon: <MessageSquare />,
            active: index === 0,
          })),
        },
        {
          items: [
            { label: "Assistants", href: "#", icon: <Bot /> },
            { label: "Settings", href: "#", icon: <Settings /> },
          ],
        },
      ]}
      user={{ name: "Ada Lovelace", email: "ada@hexa.dev", initials: "AL" }}
      accentColor="var(--ring)"
    >
      <div className="flex h-svh flex-col bg-background">
        <header className="flex h-14 shrink-0 items-center border-b px-6">
          <h1 className="truncate text-sm font-medium">{conversations[0]}</h1>
        </header>

        <div ref={scroller} className="min-h-0 flex-1 overflow-y-auto">
          <div className="mx-auto flex max-w-2xl flex-col gap-6 px-6 py-8">
            {messages.map((message) => (
              <div key={message.id} className={cn("flex gap-3", message.role === "user" && "flex-row-reverse")}>
                <Avatar role={message.role} />
                <div className={cn("flex min-w-0 flex-col gap-1.5", message.role === "user" && "items-end")}>
                  {message.model && <span className="text-xs text-muted-foreground">{message.model}</span>}
                  <div
                    className={cn(
                      "rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap",
                      message.role === "user" ? "bg-muted" : "border bg-card text-card-foreground"
                    )}
                  >
                    {message.content || (
                      <span aria-label="Thinking" className="flex h-5 items-center gap-1">
                        {[0, 150, 300].map((delay) => (
                          <span
                            key={delay}
                            className="size-1.5 animate-pulse rounded-full bg-muted-foreground"
                            style={{ animationDelay: `${delay}ms` }}
                          />
                        ))}
                      </span>
                    )}
                  </div>
                  {message.files?.map((name) => (
                    <span key={name} className="flex items-center gap-1 text-xs text-muted-foreground">
                      <Paperclip className="size-3" /> {name}
                    </span>
                  ))}
                </div>
              </div>
            ))}
          </div>
        </div>

        <div className="shrink-0 px-6 pb-6">
          <div className="mx-auto flex max-w-2xl flex-col gap-3">
            {messages.length === firstMessages.length && (
              <div className="flex flex-wrap gap-2">
                {suggestions.map((suggestion) => (
                  <button
                    key={suggestion}
                    type="button"
                    disabled={loading}
                    onClick={() => send({ message: suggestion, model: "Claude Opus 5", files: [] })}
                    className="rounded-full border bg-background px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
                  >
                    {suggestion}
                  </button>
                ))}
              </div>
            )}
            <AIChatInput onSubmit={send} loading={loading} onStop={stop} placeholder="Message Hexa AI…" />
            <p className="text-center text-[11px] text-muted-foreground">Replies are simulated in this template.</p>
          </div>
        </div>
      </div>
    </AppSidebar>
  )
}
```
