For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0
Sponsor

Chat App

A whole chat surface for a page. A thread that follows its own tail, a rail that mirrors it, turns that carry their reasoning and their actions, and a composer that holds files and a model — assembled, not rebuilt, from the pieces this registry already publishes.

Assistant

How does the conversation frame keep up with a streaming answer?

Starting from the question rather than from the components. A thread is a scroll region that follows its own tail, so the follow belongs to the region, not to the page that happens to render it.

The pieces already existed, so the template is mostly arrangement:

  • Conversation owns the follow: it pins to the newest turn while you are at the end, and lets go the moment you scroll away.
  • Message owns a turn's anatomy — the body, and the actions that appear once you are done reading.
  • PromptInput owns the draft, and ThinkingBlock owns the reasoning behind an answer.

Swap the transport for a real one and nothing above it moves.

"use client";

import { Button } from "@/components/ui/button";

Installation

$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/chat-app.json

What it is made of

The block adds no surfaces of its own. Every part of it is a component this registry already publishes, and shadcn add brings them along through the registry dependencies:

SurfaceWhat it does here
conversationThe thread: the follow, and the way back to the newest turn
messageA turn's anatomy — the body, and the actions under it
thinking-blockThe reasoning behind an answer, streaming in its own window
agent-indicatorThe badge before the first token, when there is no reasoning to show
prompt-inputThe composer, with its attach button and its model picker
scroll-railThe rail beside the thread: one tick per turn, the reading line lit
attachmentThe chips riding with a reader's turn, before and after it is sent

chat-app.tsx is the wiring: how they sit, and nothing else. Swap any one of them for your own and the rest does not move.

Usage

The surface takes a thread and four doors out of it. It owns the draft's attachments and nothing else — the turns belong to whatever transport you point it at.

import { ChatApp } from "@/components/chat/chat-app";
import { useDemoChat } from "@/components/chat/use-demo-chat";
 
export default function ChatPage() {
  const chat = useDemoChat();
 
  return (
    <div className="h-dvh">
      <ChatApp {...chat} title="Assistant" />
    </div>
  );
}
PropWhat it is
turnsThe thread, as data — markdown content, optional reasoning, files
onSendA message and the files riding with it
onStopStop the answer that is arriving
onRetryRe-run an answer. Omit it and the retry control is not drawn
onResetClear the thread. Omit it and the header keeps only its title
streamingSwaps the composer's send for stop, and blocks Enter
emptyFirst-run surface, drawn in place of the thread while there are no turns
modelsThe composer's model picker, passed straight through

The transport is the seam

use-demo-chat is the smallest thing that behaves like a real one: it appends the reader's turn, appends an empty answer, then drips reasoning and answer into it a couple of words at a time. It streams on a chain of timeouts rather than an interval, so it stops when the passage does and leaves nothing behind to clear.

It is a stand-in, not a framework. Replace it with a fetch to a streaming endpoint, a socket, or an SDK and keep the shape: the turns, whether an answer is arriving, and the handful of things the composer can ask it to do. The surface does not know the difference.

The empty slot

The block does not decide what a thread with no turns looks like. It hands you a slot in the middle of the viewport and takes whatever you put in it — a greeting, a list of starting points, a fan of covers.

Assistant

What are you working on?

The empty slot is the caller's — a greeting, a fan of covers, a list of starting points.

With nothing passed, the slot draws nothing, and the thread is simply empty.

The rail

The thread carries a scroll-rail in its right gutter — one tick per turn, the tick under the reading line lit, and a preview when one is held. useMessageFocus reads the viewport into a motion value, so the rail follows the scroll on the compositor while React stays out of the render path entirely.

Taking a tick walks the thread to that turn. The jump is computed by hand rather than with scrollIntoView, which would drag every scrollable ancestor along with it — including the page the app happens to be embedded in — and under reduced motion it lands instantly. The reading line is the middle of the viewport, which is what makes the lit tick read as "where I am" rather than "what I last clicked".

The rail leaves the layout when the thread is empty: with no turns there are no ticks, and ScrollRail renders nothing at all.

Height

ChatApp fills its parent — h-full, min-h-0 — and the page gives it the window with h-dvh, which is the whole of the layout. A preview gives it a fixed height instead. That is the entire difference between the demo above and a real app.

Component source

chat-app.tsx
"use client";

import { Check, Copy, RotateCcw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { Streamdown } from "streamdown";

import { AgentIndicator } from "@/components/agents/agent-indicator";
import {
  ModelPicker,
  PromptInput,
  PromptInputAttach,
  PromptInputAttachment,
  PromptInputFooter,
  PromptInputHeader,
  PromptInputSubmit,
  PromptInputTextarea,
} from "@/components/agents/prompt-input";
import type {
  PromptAttachment,
  PromptModel,
} from "@/components/agents/prompt-input";
import { ThinkingBlock } from "@/components/agents/thinking-block";
import { ScrollRail, ScrollRailPreview } from "@/components/scroll-rail";
import type { ScrollRailItem } from "@/components/scroll-rail";
import { Attachment } from "@/components/ui/attachment";
import { Button } from "@/components/ui/button";
import type { ButtonProps } from "@/components/ui/button";
import {
  Conversation,
  ConversationContent,
  ConversationScrollButton,
  useConversation,
} from "@/components/ui/conversation";
import {
  Message,
  MessageActions,
  MessageContent,
} from "@/components/ui/message";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { useMessageFocus } from "@/lib/hooks/use-scroll-focus";
import { cn } from "@/lib/utils";

/* -- The app -------------------------------------------------------------------
 * A chat app is four surfaces stacked: a frame, a thread, a composer, and the state
 * that ties them together. Every one of them already exists in this registry — the
 * thread is `Conversation`, a turn is `Message`, the reasoning behind an answer is
 * `ThinkingBlock`, the gap before the first token is `AgentIndicator`, the rail beside the
 * thread is `ScrollRail`, and the box the message is written in is `PromptInput` on top
 * of `Attachment`. This file is the wiring: it decides how they sit, and nothing else.
 *
 * The state it owns is the draft's attachments and nothing more. The turns belong to
 * the caller, because they come from a transport this component has no opinion about
 * — swap `use-demo-chat` for a real one and the surface does not move.
 * --------------------------------------------------------------------------- */

/** A file riding with a reader's turn, as the thread renders it. */
export interface ChatFile {
  id: string;
  name: string;
  src?: string;
}

/** One turn in the thread. */
export interface ChatTurn {
  id: string;
  from: "assistant" | "user";
  /** The answer, as markdown. Empty while the turn is still thinking. */
  content: string;
  /** The reasoning the answer was written from, when the model exposed one. */
  reasoning?: string;
  /** Epoch ms the pass started, so the thinking block's clock survives a re-render. */
  startedAt?: number;
  /** The turn is still arriving. */
  streaming?: boolean;
  /** Files riding with a reader's turn. */
  files?: ChatFile[];
}

export interface ChatAppProps {
  className?: string;
  /** First-run surface, in place of the thread while there are no turns. */
  empty?: ReactNode;
  /** The models the composer can be pointed at. */
  models: PromptModel[];
  /** Controlled model id. */
  model?: string;
  onModelChange?: (id: string) => void;
  /** Clear the thread. Omit it and the header keeps only its title. */
  onReset?: () => void;
  /** Re-run an answer. Omit it and the retry control is not drawn. */
  onRetry?: (id: string) => void;
  onSend: (message: string, files: ChatFile[]) => void;
  onStop?: () => void;
  /** An answer is arriving: the composer swaps send for stop and blocks Enter. */
  streaming?: boolean;
  title?: string;
  turns: ChatTurn[];
}

/** Reduced motion turns the rail's jump into an instant move: the destination is the
 *  information, and the glide across the thread is not worth the wait it costs. */
const prefersJump = () =>
  typeof window !== "undefined" &&
  Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches);

/** One control in the actions row: the platform button at its small icon size, with
 *  the label it cannot carry spelled out in a tooltip. */
const Action = ({
  children,
  label,
  ...props
}: ButtonProps & { label: string }) => (
  <Tooltip>
    <TooltipTrigger asChild>
      <Button
        aria-label={label}
        size="icon-sm"
        type="button"
        variant="ghost"
        {...props}
      >
        {children}
      </Button>
    </TooltipTrigger>
    <TooltipContent side="bottom">{label}</TooltipContent>
  </Tooltip>
);

/** Copy, with the one beat of confirmation a copy button owes. */
const CopyAnswer = ({ value }: { value: string }) => {
  const [copied, setCopied] = useState(false);

  useEffect(() => {
    if (!copied) {
      return;
    }
    const timer = window.setTimeout(() => setCopied(false), 1600);
    return () => window.clearTimeout(timer);
  }, [copied]);

  const copy = async () => {
    if (!navigator.clipboard) {
      return;
    }
    try {
      await navigator.clipboard.writeText(value);
      setCopied(true);
    } catch {
      // Clipboard access can be denied; the label simply stays where it is.
    }
  };

  return (
    <Action label={copied ? "Copied" : "Copy answer"} onClick={copy}>
      {copied ? (
        <Check className="size-3.5" strokeWidth={2} />
      ) : (
        <Copy className="size-3.5" strokeWidth={1.75} />
      )}
    </Action>
  );
};

/** The answer half of a turn: the reasoning behind it, the body itself, and the
 *  actions for a body that has stopped moving. */
const Answer = ({
  onRetry,
  showRetry,
  turn,
}: {
  onRetry?: (id: string) => void;
  showRetry: boolean;
  turn: ChatTurn;
}) => {
  const answered = turn.content.length > 0;
  // Reasoning is over the moment the answer starts: the block freezes on its finished
  // count while the text below it keeps arriving.
  const thinking = Boolean(turn.streaming && !turn.content);
  // With no reasoning to show, the row is the only thing a thinking turn has to say.
  const waiting = !answered && turn.streaming && turn.reasoning === undefined;

  return (
    <>
      {turn.reasoning === undefined ? null : (
        <ThinkingBlock
          label="Thinking"
          startedAt={turn.startedAt}
          streaming={thinking}
        >
          {turn.reasoning}
        </ThinkingBlock>
      )}

      {answered ? (
        <Streamdown
          className="text-sm leading-relaxed"
          controls={false}
          lineNumbers={false}
          mode={turn.streaming ? "streaming" : "static"}
        >
          {turn.content}
        </Streamdown>
      ) : null}

      {waiting ? (
        <span
          className="inline-flex items-center gap-2 text-sm text-muted-foreground"
          role="status"
        >
          <AgentIndicator state="thinking" />
          <span>Thinking</span>
        </span>
      ) : null}

      {answered && !turn.streaming ? (
        <MessageActions>
          <CopyAnswer value={turn.content} />
          {showRetry && onRetry ? (
            <Action label="Regenerate" onClick={() => onRetry(turn.id)}>
              <RotateCcw className="size-3.5" strokeWidth={1.75} />
            </Action>
          ) : null}
        </MessageActions>
      ) : null}
    </>
  );
};

const Turn = ({
  onRetry,
  showRetry,
  turn,
}: {
  onRetry?: (id: string) => void;
  showRetry: boolean;
  turn: ChatTurn;
}) => {
  const user = turn.from === "user";

  return (
    <Message from={turn.from}>
      <MessageContent>
        {user ? (
          <>
            {turn.files?.length ? (
              <div className="flex flex-wrap gap-2.5">
                {turn.files.map((file) => (
                  <Attachment key={file.id} name={file.name} src={file.src} />
                ))}
              </div>
            ) : null}
            {turn.content}
          </>
        ) : (
          <Answer onRetry={onRetry} showRetry={showRetry} turn={turn} />
        )}
      </MessageContent>
    </Message>
  );
};

/** The line a rail tick and its preview both need: the turn's text with its whitespace
 *  flattened and its tail cut off. */
const gist = (turn: ChatTurn) => {
  const text = turn.content.split(/\s+/).join(" ").trim();
  if (text.length === 0) {
    return "Thinking…";
  }

  return text.length > 140 ? `${text.slice(0, 140)}…` : text;
};

/**
 * The thread and the rail beside it. It sits inside `Conversation` because it is the
 * part that reads the viewport the rail measures against: one tick per turn, the tick
 * under the reading line lit, and a jump when a tick is taken.
 */
const Thread = ({
  empty,
  onRetry,
  turns,
}: {
  empty?: ReactNode;
  onRetry?: (id: string) => void;
  turns: ChatTurn[];
}) => {
  const { viewport } = useConversation();
  const nodes = useRef<(HTMLElement | null)[]>([]);
  const focus = useMessageFocus(viewport, nodes);

  // A turn is the only unit a reader navigates a thread by, so the rail is the thread's
  // table of contents: one tick each, and the role as the title of the preview.
  const items: ScrollRailItem[] = turns.map((turn) => ({
    id: turn.id,
    label: `${turn.from === "user" ? "You" : "Assistant"}: ${gist(turn).slice(0, 60)}`,
    preview: (
      <ScrollRailPreview title={turn.from === "user" ? "You" : "Assistant"}>
        {gist(turn)}
      </ScrollRailPreview>
    ),
  }));

  // The jump is by hand rather than `scrollIntoView`, which would drag every
  // scrollable ancestor along with it — including the page the app is embedded in.
  const jump = (index: number) => {
    const box = viewport.current;
    const node = nodes.current[index];
    if (!(box && node)) {
      return;
    }

    const top =
      box.scrollTop +
      node.getBoundingClientRect().top -
      box.getBoundingClientRect().top -
      16;
    box.scrollTo({
      behavior: prefersJump() ? "auto" : "smooth",
      top: Math.max(0, top),
    });
  };

  return (
    <div className="flex min-h-0 flex-1">
      <div className="relative flex min-h-0 min-w-0 flex-1">
        <ConversationContent className="min-w-0 px-4 pt-6 pb-16">
          {turns.length === 0 ? (
            <div className="m-auto w-full max-w-xl">{empty}</div>
          ) : (
            <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
              {turns.map((turn, index) => (
                <div
                  className="scroll-mt-4"
                  key={turn.id}
                  ref={(node) => {
                    nodes.current[index] = node;
                  }}
                >
                  <Turn
                    onRetry={onRetry}
                    showRetry={index === turns.length - 1}
                    turn={turn}
                  />
                </div>
              ))}
            </div>
          )}
        </ConversationContent>

        {/* Centred on the text, not on the row: the rail is a gutter and the button
            belongs over the column a reader is actually reading. */}
        <ConversationScrollButton />
      </div>

      {/* The rail is a read-out of the thread's shape and a way to walk it: width
          carries how far a turn is from the reading line, and a held tick previews the
          turn before the jump. */}
      <ScrollRail
        className="mr-1 self-center"
        focus={focus}
        items={items}
        onSelect={jump}
        side="right"
      />
    </div>
  );
};

export const ChatApp = ({
  className,
  empty,
  model,
  models,
  onModelChange,
  onReset,
  onRetry,
  onSend,
  onStop,
  streaming = false,
  title = "Chat",
  turns,
}: ChatAppProps) => {
  const [files, setFiles] = useState<PromptAttachment[]>([]);
  // Previews for picked files are object URLs, which nothing else will free. They are
  // kept past a send — the turn that lands reuses the same `src` — and freed when the
  // app goes away or the chip is taken back.
  const previews = useRef<string[]>([]);

  useEffect(
    () => () => {
      for (const url of previews.current) {
        URL.revokeObjectURL(url);
      }
    },
    []
  );

  const pick = (chosen: File[]) => {
    setFiles((current) => [
      ...current,
      ...chosen.map((file, index) => {
        const preview = file.type.startsWith("image/")
          ? URL.createObjectURL(file)
          : undefined;
        if (preview) {
          previews.current.push(preview);
        }
        return {
          id: `${file.name}-${Date.now()}-${index}`,
          name: file.name,
          src: preview,
        };
      }),
    ]);
  };

  const remove = (id: string) =>
    setFiles((current) => current.filter((file) => file.id !== id));

  const submit = (message: string) => {
    onSend(
      message,
      files.map(({ id, name, src }) => ({ id, name, src }))
    );
    setFiles([]);
  };

  return (
    <div
      className={cn("flex h-full min-h-0 flex-col bg-background", className)}
      data-slot="chat-app"
    >
      <header className="flex h-14 shrink-0 items-center gap-4 border-b border-border px-4">
        <h1 className="truncate text-sm font-medium">{title}</h1>
        {onReset ? (
          <Button
            className="ml-auto"
            onClick={onReset}
            size="sm"
            type="button"
            variant="ghost"
          >
            New chat
          </Button>
        ) : null}
      </header>

      <Conversation>
        <Thread empty={empty} onRetry={onRetry} turns={turns} />
      </Conversation>

      <div className="shrink-0 px-4 pb-4">
        <div className="mx-auto w-full max-w-3xl">
          <PromptInput onStop={onStop} onSubmit={submit} streaming={streaming}>
            <PromptInputHeader>
              {files.map(({ id, ...chip }) => (
                <PromptInputAttachment
                  key={id}
                  {...chip}
                  onRemove={() => remove(id)}
                />
              ))}
            </PromptInputHeader>

            <PromptInputTextarea placeholder="Ask anything, or drop in a file." />

            <PromptInputFooter>
              <PromptInputAttach onFiles={pick} />
              <ModelPicker
                models={models}
                onValueChange={onModelChange}
                value={model}
              />
              <PromptInputSubmit />
            </PromptInputFooter>
          </PromptInput>
        </div>
      </div>
    </div>
  );
};
use-demo-chat.ts
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

import type { PromptModel } from "@/components/agents/prompt-input";

import type { ChatTurn } from "./chat-app";

/* -- The stand-in transport ----------------------------------------------------
 * A chat surface is only half a chat app; the other half is whatever the turns come
 * from. This is the smallest thing that behaves like that half: it appends a reader's
 * turn, appends an empty answer, then drips reasoning and answer into it a couple of
 * words at a time. It streams on a chain of timeouts rather than an interval, so it
 * stops when the passage does and leaves nothing behind to clear.
 *
 * Replace it with a real transport — a fetch to a streaming endpoint, a socket, an
 * SDK — and keep the shape: returns the turns, whether an answer is arriving, and the
 * four things the composer can ask it to do.
 * --------------------------------------------------------------------------- */

const REASONING = `Starting from the question rather than from the components. A thread is a scroll region that follows its own tail, so the follow belongs to the region, not to the page that happens to render it.

The turns belong to the caller. What the surface owns is the draft and the files riding with it, which is exactly what a composer owns everywhere else it appears.`;

const ANSWER = `The pieces already existed, so the template is mostly arrangement:

- **Conversation** owns the follow: it pins to the newest turn while you are at the end, and stops the moment you scroll away to re-read something.
- **Message** owns a turn's anatomy — the body, and the actions that appear once you are done reading.
- **PromptInput** owns the draft, and **ThinkingBlock** owns the reasoning an answer was written from.

Swap this transport for a real one and nothing above it moves.`;

const REASONING_WORDS = REASONING.split(" ");
const ANSWER_WORDS = ANSWER.split(" ");

/** Reasoning drips two words at a time because a thought arrives faster than an
 *  answer; the answer goes one at a time, which is the speed it is read at. */
const REASONING_MS = 45;
const ANSWER_MS = 55;

/** Three models, so the picker's menu says what it is for without a list to read. */
export const DEMO_MODELS: PromptModel[] = [
  { id: "gpt-5.2", name: "GPT-5.2", provider: "openai" },
  { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", provider: "anthropic" },
  { id: "gemini-3-pro", name: "Gemini 3 Pro", provider: "gemini" },
];

/** The shape `ChatApp` spreads: everything the surface needs, nothing it does not. */
export interface DemoChat {
  model: string;
  models: PromptModel[];
  onModelChange: (id: string) => void;
  onReset: () => void;
  onRetry: (id: string) => void;
  onSend: (message: string) => void;
  onStop: () => void;
  streaming: boolean;
  turns: ChatTurn[];
}

/**
 * The scripted chat. Pass `initialTurns` for a thread that is already underway, or
 * nothing for a surface that opens on its empty state.
 */
export const useDemoChat = (initialTurns: ChatTurn[] = []): DemoChat => {
  const [turns, setTurns] = useState<ChatTurn[]>(initialTurns);
  const [streaming, setStreaming] = useState(false);
  const [model, setModel] = useState(DEMO_MODELS[0].id);
  const counter = useRef(0);
  const timer = useRef<number | null>(null);

  const clear = useCallback(() => {
    if (timer.current !== null) {
      window.clearTimeout(timer.current);
      timer.current = null;
    }
  }, []);

  useEffect(() => clear, [clear]);

  const onStop = useCallback(() => {
    clear();
    setStreaming(false);
    setTurns((current) =>
      current.map((turn) =>
        turn.streaming ? { ...turn, streaming: false } : turn
      )
    );
  }, [clear]);

  const run = useCallback((id: string) => {
    const startedAt = Date.now();
    setStreaming(true);

    let reasoning = 0;
    let answered = 0;

    const step = () => {
      reasoning = Math.min(reasoning + 2, REASONING_WORDS.length);
      const reasoningDone = reasoning >= REASONING_WORDS.length;
      if (reasoningDone) {
        answered += 1;
      }

      const finished = reasoningDone && answered >= ANSWER_WORDS.length;

      setTurns((current) =>
        current.map((turn) =>
          turn.id === id
            ? {
                ...turn,
                content: ANSWER_WORDS.slice(0, answered).join(" "),
                reasoning: REASONING_WORDS.slice(0, reasoning).join(" "),
                startedAt,
                streaming: !finished,
              }
            : turn
        )
      );

      if (finished) {
        timer.current = null;
        setStreaming(false);
        return;
      }
      timer.current = window.setTimeout(
        step,
        reasoningDone ? ANSWER_MS : REASONING_MS
      );
    };

    step();
  }, []);

  const onSend = useCallback(
    (message: string) => {
      const userId = `u-${(counter.current += 1)}`;
      const assistantId = `a-${(counter.current += 1)}`;

      setTurns((current) => [
        ...current,
        { content: message, from: "user", id: userId },
        {
          content: "",
          from: "assistant",
          id: assistantId,
          reasoning: "",
          streaming: true,
        },
      ]);
      run(assistantId);
    },
    [run]
  );

  const onRetry = useCallback(
    (id: string) => {
      const assistantId = `a-${(counter.current += 1)}`;

      setTurns((current) => {
        const at = current.findIndex((turn) => turn.id === id);
        return [
          ...current.slice(0, at === -1 ? current.length : at),
          {
            content: "",
            from: "assistant",
            id: assistantId,
            reasoning: "",
            streaming: true,
          },
        ];
      });
      run(assistantId);
    },
    [run]
  );

  const onReset = useCallback(() => {
    onStop();
    setTurns([]);
  }, [onStop]);

  return {
    model,
    models: DEMO_MODELS,
    onModelChange: setModel,
    onReset,
    onRetry,
    onSend,
    onStop,
    streaming,
    turns,
  };
};
page.tsx
"use client";

import { ChatApp } from "@/components/chat/chat-app";
import { useDemoChat } from "@/components/chat/use-demo-chat";
import { Button } from "@/components/ui/button";

/* -- The page ------------------------------------------------------------------
 * The whole app in one screen: the thread fills the window, the composer is anchored
 * to the bottom, and the only state above the surface is the thread itself. The
 * transport is the demo one — swap the hook and the page is a real chat app.
 * --------------------------------------------------------------------------- */

const SUGGESTIONS = [
  "How does the conversation frame keep up with a streaming answer?",
  "Write a migration plan from polling to streaming.",
  "What is the thinking block doing instead of jumping the scroll?",
];

const Empty = ({ onPick }: { onPick: (prompt: string) => void }) => (
  <div className="flex flex-col items-center gap-6 text-center">
    <div className="flex flex-col gap-1.5">
      <p className="font-medium">What are you working on?</p>
      <p className="text-sm text-muted-foreground">
        Ask a question, drop in a file, or start from one of these.
      </p>
    </div>

    <div className="flex w-full flex-col gap-2">
      {SUGGESTIONS.map((prompt) => (
        <Button
          className="h-auto justify-start rounded-xl px-3.5 py-2.5 text-left font-normal"
          key={prompt}
          onClick={() => onPick(prompt)}
          type="button"
          variant="outline"
        >
          {prompt}
        </Button>
      ))}
    </div>
  </div>
);

export function ChatPage() {
  const chat = useDemoChat();

  return (
    <div className="h-dvh">
      <ChatApp
        {...chat}
        empty={<Empty onPick={chat.onSend} />}
        title="Assistant"
      />
    </div>
  );
}