JDS.

Search docs

Find a page or component

Recipes

Chat app

A full chat screen: searchable conversation list, model picker and context meter in the header, and a composer you can swap for one from the Prompt Input Studio.

Q3 pricing announcement

What are we working on?

Mention a file with @, or dictate.

Use it in your app

  1. 1
    Bring your composerDesign the composer in the Prompt Input Studio and pass it as composer; it gets status, sendMessage and stop. The conversation is scripted, so wire your own messages in the source.
    <ChatApp
      composer={({ status, sendMessage, stop }) => (
        <PromptInput status={status} onSubmit={({ text, files }) => sendMessage({ text, files })}>
          …
        </PromptInput>
      )}
    />

Install

pnpm dlx shadcn@latest add @jds/chat-app

Installs the recipe and every component it uses into your project, ready to run.

Source

components/chat-app/chat-app.tsx
"use client"

import * as React from "react"

import { ContextMeter } from "@/components/ai/context-meter"
import {
  Conversation,
  ConversationContent,
  ConversationEmpty,
  ConversationScrollButton,
} from "@/components/ai/conversation"
import { Message, MessageAction, MessageActions, MessageContent } from "@/components/ai/message"
import { ModelPicker, type Model } from "@/components/ai/model-picker"
import {
  PromptInput,
  PromptInputAttachButton,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputToolbar,
  PromptInputTools,
  type ChatStatus,
} from "@/components/ai/prompt-input"
import { PromptInputMentions, type Mention } from "@/components/ai/prompt-input-mentions"
import { PromptInputMic } from "@/components/ai/prompt-input-mic"
import { Response } from "@/components/ai/response"
import { TypingIndicator } from "@/components/ai/shimmer"
import { Suggestion, Suggestions } from "@/components/ai/suggestions"
import { Button } from "@/components/ui/button"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { CopyIcon, RegenerateIcon, SearchIcon, SparkleIcon } from "@/lib/icons"

type Thread = { id: string; title: string; group: "Today" | "Yesterday" | "Last week" }
type Turn = { id: string; from: "user" | "assistant"; text: string }

const threads: Thread[] = [
  { id: "t1", title: "Q3 pricing announcement", group: "Today" },
  { id: "t2", title: "Refund policy for damaged items", group: "Today" },
  { id: "t3", title: "Onboarding email sequence", group: "Yesterday" },
  { id: "t4", title: "Competitor research: voice agents", group: "Yesterday" },
  { id: "t5", title: "Sprint retro summary", group: "Last week" },
  { id: "t6", title: "Hiring plan for design", group: "Last week" },
]

const models: Model[] = [
  { id: "opus", name: "Claude Opus 5.5", description: "Hardest problems", capabilities: ["reasoning", "vision"] },
  { id: "sonnet", name: "Claude Sonnet 5.5", description: "Everyday tasks", capabilities: ["reasoning", "vision"] },
  { id: "haiku", name: "Claude Haiku 4.5", description: "Quick answers", capabilities: ["fast"] },
]

const mentionables: Mention[] = [
  { id: "m1", label: "pricing.md", type: "file", description: "docs/" },
  { id: "m2", label: "web-search", type: "tool", description: "Search the web" },
  { id: "m3", label: "researcher", type: "agent", description: "Deep research" },
]

const REPLY = `Here's a tighter version:

> **Team plan: €12 → €14 per seat, from 1 October.** Annual plans keep today's price until renewal.

I moved the annual-plan reassurance up front, since that's what most customers will ask first.`

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))

/** What a custom composer gets: the same names the Prompt Input Studio's code uses. */
type ComposerApi = {
  status: ChatStatus
  sendMessage: (message: { text: string; files?: File[] }) => void
  stop: () => void
}

/** Renders a custom composer as its own component, so its callbacks are ordinary props. */
function ComposerSlot({ render, ...api }: ComposerApi & { render: (api: ComposerApi) => React.ReactNode }) {
  return render(api)
}

/**
 * A full chat screen. Pass `composer` to replace the default composer, e.g. with code from
 * the Prompt Input Studio; the conversation itself is scripted.
 */
export default function ChatApp({ composer }: { composer?: (api: ComposerApi) => React.ReactNode }) {
  const [active, setActive] = React.useState("t1")
  const [query, setQuery] = React.useState("")
  const [turns, setTurns] = React.useState<Turn[]>([])
  const [status, setStatus] = React.useState<ChatStatus>("ready")
  const [used, setUsed] = React.useState(38_000)
  const run = React.useRef(0)

  const send = async (text: string) => {
    const id = ++run.current
    const alive = () => run.current === id
    const replyId = `a-${Date.now()}`
    setTurns((t) => [...t, { id: `u-${Date.now()}`, from: "user", text }])
    setStatus("submitted")
    await wait(600)
    if (!alive()) return
    setStatus("streaming")
    setTurns((t) => [...t, { id: replyId, from: "assistant", text: "" }])
    let out = ""
    for (const w of REPLY.split(/(\s+)/)) {
      if (!alive()) return
      out += w
      const snapshot = out
      setTurns((t) => t.map((x) => (x.id === replyId ? { ...x, text: snapshot } : x)))
      await wait(14 + Math.random() * 14)
    }
    setUsed((u) => u + 2_400)
    setStatus("ready")
  }

  const stop = () => {
    run.current++
    setStatus("ready")
  }

  const visible = threads.filter((t) => t.title.toLowerCase().includes(query.toLowerCase()))
  const groups = ["Today", "Yesterday", "Last week"] as const
  const title = threads.find((t) => t.id === active)?.title ?? "New conversation"

  return (
    <div className="flex h-160 w-full overflow-hidden rounded-2xl border bg-background">
      <aside className="hidden w-64 shrink-0 flex-col border-r bg-sidebar md:flex">
        <div className="flex flex-col gap-2 p-3">
          <Button
            variant="outline"
            size="sm"
            className="justify-start"
            onClick={() => {
              run.current++
              setTurns([])
              setStatus("ready")
              setActive("")
            }}
          >
            <SparkleIcon />
            New conversation
          </Button>
          <InputGroup>
            <InputGroupAddon>
              <SearchIcon />
            </InputGroupAddon>
            <InputGroupInput placeholder="Search" value={query} onChange={(e) => setQuery(e.target.value)} />
          </InputGroup>
        </div>
        <nav className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-2 pb-3">
          {groups.map((g) => {
            const items = visible.filter((t) => t.group === g)
            if (items.length === 0) return null
            return (
              <div key={g} className="flex flex-col gap-0.5">
                <span className="px-2 pb-1 text-xs text-muted-foreground">{g}</span>
                {items.map((t) => (
                  <button
                    key={t.id}
                    type="button"
                    aria-current={active === t.id ? "page" : undefined}
                    onClick={() => {
                      run.current++
                      setActive(t.id)
                      setTurns([])
                      setStatus("ready")
                    }}
                    className="truncate rounded-md px-2 py-1.5 text-left text-sm text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground aria-[current=page]:bg-sidebar-accent aria-[current=page]:text-foreground"
                  >
                    {t.title}
                  </button>
                ))}
              </div>
            )
          })}
        </nav>
      </aside>

      <section className="flex min-w-0 flex-1 flex-col">
        <header className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
          <h3 className="truncate text-sm font-medium">{title}</h3>
          <div className="ml-auto flex items-center gap-1">
            <ContextMeter
              used={used}
              max={200_000}
              breakdown={[
                { label: "System and tools", tokens: 9_800 },
                { label: "Messages", tokens: used - 9_800 },
              ]}
            />
            <ModelPicker models={models} defaultValue="sonnet" />
          </div>
        </header>

        <Conversation>
          {turns.length === 0 ? (
            <ConversationEmpty title="What are we working on?" description="Mention a file with @, or dictate.">
              <Suggestions className="justify-center">
                <Suggestion suggestion="Tighten the pricing announcement" onSelect={send} />
                <Suggestion suggestion="Summarize yesterday's thread" onSelect={send} />
              </Suggestions>
            </ConversationEmpty>
          ) : (
            <ConversationContent className="gap-5">
              {turns.map((t) => (
                <Message key={t.id} from={t.from}>
                  <MessageContent>
                    {t.from === "user" ? (
                      t.text
                    ) : t.text ? (
                      <>
                        <Response isAnimating={status === "streaming"}>{t.text}</Response>
                        {status === "ready" && (
                          <MessageActions>
                            <MessageAction label="Copy">
                              <CopyIcon />
                            </MessageAction>
                            <MessageAction label="Regenerate">
                              <RegenerateIcon />
                            </MessageAction>
                          </MessageActions>
                        )}
                      </>
                    ) : (
                      <TypingIndicator />
                    )}
                  </MessageContent>
                </Message>
              ))}
              {status === "submitted" && (
                <Message from="assistant">
                  <MessageContent>
                    <TypingIndicator />
                  </MessageContent>
                </Message>
              )}
            </ConversationContent>
          )}
          <ConversationScrollButton />
        </Conversation>

        <div className="mx-auto w-full max-w-3xl p-3 pt-0">
          {composer ? (
            <ComposerSlot render={composer} status={status} sendMessage={({ text }) => text && send(text)} stop={stop} />
          ) : (
            <PromptInput status={status} onSubmit={({ text }) => text && send(text)}>
              <PromptInputMentions items={mentionables} />
              <PromptInputTextarea placeholder="Message, or @ to mention" />
              <PromptInputToolbar>
                <PromptInputTools>
                  <PromptInputAttachButton />
                </PromptInputTools>
                <div className="flex items-center gap-1">
                  <PromptInputMic />
                  <PromptInputSubmit onStop={stop} />
                </div>
              </PromptInputToolbar>
            </PromptInput>
          )}
        </div>
      </section>
    </div>
  )
}