# Chat Assistant

> An AI chat that shows the model working, as a full screen, a panel docked beside the work, or a launcher that opens a small window.

A shadcn/ui block from the [@miseui](https://miseui.junsu4621.workers.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/miseui/chat-assistant).

- Page, with a live preview: https://shadcn.exchange/r/miseui/chat-assistant
- Screenshot: https://shadcn.exchange/shot/miseui/chat-assistant/light.png
- Registry item: https://miseui.junsu4621.workers.dev/r/chat-assistant.json
- Categories: blocks
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/miseui/chat-assistant.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://miseui.junsu4621.workers.dev/r/motion.json`, `https://miseui.junsu4621.workers.dev/r/agent-avatar.json`, `https://miseui.junsu4621.workers.dev/r/beam-border.json`, `https://miseui.junsu4621.workers.dev/r/button.json`, `https://miseui.junsu4621.workers.dev/r/reasoning-steps.json`, `https://miseui.junsu4621.workers.dev/r/shimmer-text.json`, `https://miseui.junsu4621.workers.dev/r/streaming-text.json`, `https://miseui.junsu4621.workers.dev/r/textarea.json`, `https://miseui.junsu4621.workers.dev/r/thinking-orb.json`, `https://miseui.junsu4621.workers.dev/r/tool-call-card.json`

## Files

### `registry/miseui/blocks/chat-assistant.tsx`

```tsx
import type * as React from "react"
import { ChatDocked } from "@/registry/miseui/blocks/chat-assistant-docked"
import type { ChatPanelOptions } from "@/registry/miseui/blocks/chat-assistant-panel-state"
import type { ChatMessage, ChatStatus, ChatToolCall } from "@/registry/miseui/blocks/chat-assistant-parts"
import { ChatPopup } from "@/registry/miseui/blocks/chat-assistant-popup"
import { useSampleChat } from "@/registry/miseui/blocks/chat-assistant-sample"
import { ChatScreen, type ChatConversation, type ChatOptions } from "@/registry/miseui/blocks/chat-assistant-screen"

type ChatAssistantVariant = "full" | "docked" | "popup"

interface ChatAssistantProps extends React.ComponentProps<"section">, ChatOptions, ChatPanelOptions {
  /** `full` is the whole screen, `docked` a panel beside the work, `popup` a launcher that opens a small window. Anything else is `full`. */
  variant?: ChatAssistantVariant
  /** The conversation. Leave it out and the block runs a scripted sample, so it can be previewed as is. */
  messages?: readonly ChatMessage[]
  /** "generating" swaps Send for Stop and lights the composer. Ignored while the sample runs. */
  status?: ChatStatus
  /** `docked` and `popup`: the page the chat sits beside or floats over. Leave it out to see a sample report. */
  children?: React.ReactNode
}

type ChatLayoutProps = Omit<ChatAssistantProps, "messages" | "status"> & ChatConversation

/** `full` has no work to sit beside, so children and the open state only reach the two that do. */
function ChatLayout({ variant, children, open, defaultOpen, onOpenChange, ...props }: ChatLayoutProps) {
  const panel = { open, defaultOpen, onOpenChange }
  if (variant === "docked") return <ChatDocked {...props} {...panel}>{children}</ChatDocked>
  if (variant === "popup") return <ChatPopup {...props} {...panel}>{children}</ChatPopup>
  return <ChatScreen data-variant="full" {...props} />
}

function SampleChat({ onSend, onStop, onNewChat, ...props }: Omit<ChatAssistantProps, "messages" | "status">) {
  const sample = useSampleChat()
  return (
    <ChatLayout
      {...props}
      messages={sample.messages}
      status={sample.status}
      onSend={(text) => {
        onSend?.(text)
        sample.send(text)
      }}
      onStop={() => {
        onStop?.()
        sample.stop()
      }}
      onNewChat={() => {
        onNewChat?.()
        sample.reset()
      }}
    />
  )
}

function ChatAssistant({ messages, status = "idle", ...props }: ChatAssistantProps) {
  if (messages) return <ChatLayout messages={messages} status={status} {...props} />
  return <SampleChat {...props} />
}

export { ChatAssistant, type ChatAssistantProps, type ChatAssistantVariant, type ChatMessage, type ChatStatus, type ChatToolCall }
```

### `registry/miseui/blocks/chat-assistant-screen.tsx`

```tsx
import type * as React from "react"
import { useEffect, useRef } from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { ChatComposer } from "@/registry/miseui/blocks/chat-assistant-composer"
import { ChatError, ChatHeader, ChatStarter, COLUMN_CLASS, type ChatMessage, type ChatStatus } from "@/registry/miseui/blocks/chat-assistant-parts"
import { ChatThread } from "@/registry/miseui/blocks/chat-assistant-thread"

/** Within this distance of the end still counts as reading the end, so a small scroll does not stop the follow. */
const STICK_THRESHOLD_PX = 32
const DEFAULT_AGENT_NAME = "Tally"

const DEFAULT_SUGGESTIONS: readonly string[] = [
  "Why did churn rise last month?",
  "Which accounts are at risk this quarter?",
  "Summarize last week's renewals",
]

interface ChatOptions {
  onSend?: (text: string) => void
  onStop?: () => void
  /** Shown when the conversation is empty, and sent as they are when picked. */
  suggestions?: readonly string[]
  agentName?: string
  /** One line under the name while nothing is running. */
  agentDescription?: string
  placeholder?: string
  /** Shows the New chat button. */
  onNewChat?: () => void
  /** The last turn failed. Shown above the composer. */
  error?: string
  onRetry?: () => void
  /** Blocks new messages, for example when the plan's quota is used up. Stop keeps working. */
  disabled?: boolean
}

interface ChatConversation extends ChatOptions {
  messages: readonly ChatMessage[]
  status: ChatStatus
}

interface ChatScreenProps extends React.ComponentProps<"section">, ChatConversation {
  /** Extra controls in the header, such as a way to close a panel. */
  headerActions?: React.ReactNode
  /** A narrow panel, with a smaller headline on the empty screen. */
  compact?: boolean
}

/** Follows new text while the reader is at the end, and lets go as soon as they scroll up to read. */
function useStickToBottom(messageCount: number) {
  const scrollRef = useRef<HTMLDivElement>(null)
  const contentRef = useRef<HTMLDivElement>(null)
  const pinnedRef = useRef(true)

  useEffect(() => {
    const scroller = scrollRef.current
    const content = contentRef.current
    if (!scroller || !content || typeof ResizeObserver === "undefined") return
    const observer = new ResizeObserver(() => {
      if (pinnedRef.current) scroller.scrollTop = scroller.scrollHeight
    })
    observer.observe(content)
    return () => observer.disconnect()
  }, [])

  // A new message means the reader just sent something, so they want the end again.
  useEffect(() => {
    pinnedRef.current = true
    const scroller = scrollRef.current
    if (scroller) scroller.scrollTop = scroller.scrollHeight
  }, [messageCount])

  const onScroll = () => {
    const scroller = scrollRef.current
    if (!scroller) return
    pinnedRef.current = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < STICK_THRESHOLD_PX
  }

  return { scrollRef, contentRef, onScroll }
}

function ChatScreen({
  messages,
  status,
  onSend,
  onStop,
  suggestions = DEFAULT_SUGGESTIONS,
  agentName = DEFAULT_AGENT_NAME,
  agentDescription = "Reads your reports",
  placeholder = "Ask about a report",
  onNewChat,
  error,
  onRetry,
  disabled = false,
  headerActions,
  compact = false,
  className,
  ...props
}: ChatScreenProps) {
  const { scrollRef, contentRef, onScroll } = useStickToBottom(messages.length)
  const generating = status === "generating"
  const send = (text: string) => onSend?.(text)

  return (
    <section data-slot="chat-assistant" data-status={status} aria-label={`Chat with ${agentName}`} className={cn("@container flex h-[640px] max-h-dvh w-full flex-col bg-muted/40 dark:bg-background", className)} {...props}>
      <ChatHeader agentName={agentName} description={agentDescription} generating={generating} onNewChat={onNewChat} actions={headerActions} />
      <div ref={scrollRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 @md:px-6">
        <div ref={contentRef} className={cn(COLUMN_CLASS, "flex min-h-full flex-col py-6")}>
          {messages.length === 0 && !generating ? (
            <ChatStarter agentName={agentName} suggestions={suggestions} disabled={disabled} onPick={send} compact={compact} />
          ) : (
            <ChatThread messages={messages} status={status} />
          )}
        </div>
      </div>
      <div className="px-4 pb-4 @md:px-6 @md:pb-6">
        <div className={cn(COLUMN_CLASS, "flex flex-col gap-2")}>
          {error ? <ChatError message={error} onRetry={onRetry} /> : null}
          <ChatComposer label={`Message ${agentName}`} placeholder={placeholder} generating={generating} disabled={disabled} onSend={send} onStop={onStop} />
        </div>
      </div>
    </section>
  )
}

/** The chat's own props go to the panel, and everything else stays with the outer block, which is the thing placed on the page. */
function splitChatProps<Rest extends object>(props: ChatConversation & Rest) {
  const { messages, status, onSend, onStop, suggestions, agentName, agentDescription, placeholder, onNewChat, error, onRetry, disabled, ...rest } = props
  const chat: ChatConversation = { messages, status, onSend, onStop, suggestions, agentName, agentDescription, placeholder, onNewChat, error, onRetry, disabled }
  return { chat, rest }
}

export { ChatScreen, DEFAULT_AGENT_NAME, splitChatProps, type ChatConversation, type ChatOptions, type ChatScreenProps }
```

### `registry/miseui/blocks/chat-assistant-docked.tsx`

```tsx
import type * as React from "react"
import { useRef } from "react"
import { MessageCircle, X } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { useChatOpen, useChatPanelFocus, type ChatPanelOptions } from "@/registry/miseui/blocks/chat-assistant-panel-state"
import { ChatScreen, DEFAULT_AGENT_NAME, splitChatProps, type ChatConversation } from "@/registry/miseui/blocks/chat-assistant-screen"
import { ChatSampleWork } from "@/registry/miseui/blocks/chat-assistant-work"

type ChatDockedProps = React.ComponentProps<"section"> & ChatConversation & ChatPanelOptions

/** The way back to the panel: a narrow strip beside the work when wide, a short bar under it when narrow. Never over the work. */
function DockedOpener({ openerRef, agentName, onOpen }: { openerRef: React.RefObject<HTMLButtonElement | null>; agentName: string; onOpen: () => void }) {
  return (
    <div className="flex shrink-0 justify-end border-t bg-background p-2 dark:bg-card @3xl:flex-col @3xl:justify-start @3xl:border-t-0 @3xl:border-l">
      <Button ref={openerRef} variant="outline" aria-expanded={false} className="pointer-coarse:h-10 @3xl:size-10 @3xl:p-0" onClick={onOpen}>
        <MessageCircle aria-hidden="true" />
        <span className="@3xl:sr-only">Ask {agentName}</span>
      </Button>
    </div>
  )
}

function ChatDocked({ open, defaultOpen = true, onOpenChange, ...props }: ChatDockedProps) {
  const { chat, rest } = splitChatProps(props)
  const { children, className, ...root } = rest
  const [isOpen, setOpen] = useChatOpen({ open, defaultOpen, onOpenChange })
  const panelRef = useRef<HTMLDivElement>(null)
  const openerRef = useRef<HTMLButtonElement>(null)
  useChatPanelFocus(isOpen, panelRef, openerRef)
  const agentName = chat.agentName ?? DEFAULT_AGENT_NAME

  return (
    <section
      data-slot="chat-assistant"
      data-variant="docked"
      data-status={chat.status}
      data-open={isOpen ? "true" : "false"}
      className={cn("@container flex h-[640px] max-h-dvh w-full flex-col bg-muted dark:bg-background", className)}
      {...root}
    >
      {/* Its own element: a container query only sees ancestors, so the root cannot change its own direction. */}
      <div className="flex min-h-0 flex-1 flex-col @3xl:flex-row">
        {/* A tab stop, because it scrolls and may hold nothing else a keyboard could reach. */}
        <div
          data-slot="chat-assistant-work"
          role="region"
          aria-label="Your work"
          tabIndex={0}
          className="min-h-0 min-w-0 flex-[2_1_0%] overflow-auto overscroll-contain outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50 @3xl:flex-1"
        >
          {children ?? <ChatSampleWork />}
        </div>
        {/* Kept mounted while closed: a half-written message and the thread are still there when it comes back. */}
        <div
          ref={panelRef}
          hidden={!isOpen}
          className={cn("min-h-0 min-w-0 flex-[3_1_0%] flex-col border-t @3xl:w-[400px] @3xl:flex-none @3xl:border-t-0 @3xl:border-l", isOpen ? "flex" : "hidden")}
        >
          <ChatScreen
            {...chat}
            data-slot="chat-assistant-panel"
            compact
            className="h-auto max-h-none min-h-0 flex-1 bg-background pointer-coarse:[&_button]:min-h-10 pointer-coarse:[&_button]:min-w-10 dark:bg-card"
            headerActions={
              <Button variant="ghost" size="icon" aria-label={`Close ${agentName}`} className="pointer-coarse:size-10" onClick={() => setOpen(false)}>
                <X aria-hidden="true" />
              </Button>
            }
          />
        </div>
        {isOpen ? null : <DockedOpener openerRef={openerRef} agentName={agentName} onOpen={() => setOpen(true)} />}
      </div>
    </section>
  )
}

export { ChatDocked, type ChatDockedProps }
```

### `registry/miseui/blocks/chat-assistant-popup.tsx`

```tsx
import type * as React from "react"
import { useId, useRef, useState } from "react"
import { MessageCircle, X } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { SPRING_DEFAULT } from "@/registry/miseui/lib/motion"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import type { ChatStatus } from "@/registry/miseui/blocks/chat-assistant-parts"
import { useChatOpen, useChatPanelFocus, type ChatPanelOptions } from "@/registry/miseui/blocks/chat-assistant-panel-state"
import { ChatScreen, DEFAULT_AGENT_NAME, splitChatProps, type ChatConversation } from "@/registry/miseui/blocks/chat-assistant-screen"
import { ChatSampleWork } from "@/registry/miseui/blocks/chat-assistant-work"

const WINDOW_SHOWN = { opacity: 1, scale: 1, y: 0 }
const WINDOW_GONE = { opacity: 0, scale: 0.96, y: 8 }
const WINDOW_GONE_REDUCED = { opacity: 0, scale: 1, y: 0 }

type ChatPopupProps = React.ComponentProps<"section"> & ChatConversation & ChatPanelOptions

/** A reply that finishes while the window is closed would otherwise go unnoticed. */
function useMissedReply(open: boolean, status: ChatStatus) {
  const [previous, setPrevious] = useState(status)
  const [missed, setMissed] = useState(false)
  if (previous !== status) {
    setPrevious(status)
    if (!open && previous === "generating" && status === "idle") setMissed(true)
  }
  if (open && missed) setMissed(false)
  return missed
}

function launcherLabel(open: boolean, missed: boolean, generating: boolean, agentName: string) {
  if (open) return "Hide chat"
  if (missed) return `New reply from ${agentName}`
  return generating ? `${agentName} is working` : `Ask ${agentName}`
}

/** Stays mounted through the exit, then leaves the layout. Under reduced motion there is no exit to wait for. */
function useWindowExit(open: boolean) {
  const reduceMotion = useReducedMotion()
  const [exited, setExited] = useState(!open)
  if (open && exited) setExited(false)
  return {
    reduceMotion,
    gone: !open && (exited || Boolean(reduceMotion)),
    onAnimationComplete: () => {
      if (!open) setExited(true)
    },
  }
}

interface PopupWindowProps {
  id: string
  panelRef: React.RefObject<HTMLDivElement | null>
  open: boolean
  agentName: string
  chat: ChatConversation
  onClose: () => void
}

function PopupWindow({ id, panelRef, open, agentName, chat, onClose }: PopupWindowProps) {
  const exit = useWindowExit(open)
  return (
    <motion.div
      ref={panelRef}
      id={id}
      role="dialog"
      aria-label={`Chat with ${agentName}`}
      aria-hidden={!open}
      inert={!open}
      initial={false}
      animate={open ? WINDOW_SHOWN : exit.reduceMotion ? WINDOW_GONE_REDUCED : WINDOW_GONE}
      transition={exit.reduceMotion ? { duration: 0 } : SPRING_DEFAULT}
      onAnimationComplete={exit.onAnimationComplete}
      style={{ transformOrigin: "100% 100%" }}
      className={cn(
        "absolute inset-2 z-10 flex-col overflow-hidden rounded-2xl border bg-background shadow-[0_24px_48px_-16px_oklch(0_0_0/24%)] dark:bg-card",
        "@md:inset-auto @md:right-4 @md:bottom-20 @md:h-[min(520px,calc(100%-96px))] @md:w-[384px]",
        exit.gone ? "hidden" : "flex",
      )}
    >
      <ChatScreen
        {...chat}
        data-slot="chat-assistant-panel"
        aria-label={undefined}
        compact
        className="h-auto max-h-none min-h-0 flex-1 bg-transparent pointer-coarse:[&_button]:min-h-10 pointer-coarse:[&_button]:min-w-10 dark:bg-transparent"
        headerActions={
          <Button variant="ghost" size="icon" aria-label="Close chat" className="pointer-coarse:size-10" onClick={onClose}>
            <X aria-hidden="true" />
          </Button>
        }
      />
    </motion.div>
  )
}

function ChatPopup({ open, defaultOpen = false, onOpenChange, ...props }: ChatPopupProps) {
  const { chat, rest } = splitChatProps(props)
  const { children, className, onKeyDown, ...root } = rest
  const [isOpen, setOpen] = useChatOpen({ open, defaultOpen, onOpenChange })
  const windowId = useId()
  const panelRef = useRef<HTMLDivElement>(null)
  const launcherRef = useRef<HTMLButtonElement>(null)
  useChatPanelFocus(isOpen, panelRef, launcherRef)
  const missed = useMissedReply(isOpen, chat.status)
  const agentName = chat.agentName ?? DEFAULT_AGENT_NAME

  const keyDown = (event: React.KeyboardEvent<HTMLElement>) => {
    onKeyDown?.(event)
    // isComposing: Escape cancels a half-typed Korean or Japanese syllable, and must not close the window with it.
    if (event.key !== "Escape" || !isOpen || event.nativeEvent.isComposing) return
    setOpen(false)
  }

  return (
    // The block is the frame: everything is placed against it, so a preview or a page section contains the window.
    <section
      data-slot="chat-assistant"
      data-variant="popup"
      data-status={chat.status}
      data-open={isOpen ? "true" : "false"}
      className={cn("@container relative h-[640px] max-h-dvh w-full overflow-hidden bg-muted dark:bg-background", className)}
      onKeyDown={keyDown}
      {...root}
    >
      {/* The bottom padding keeps the last row of the page clear of the launcher. */}
      <div data-slot="chat-assistant-work" role="region" aria-label="Your work" tabIndex={0} className="size-full overflow-auto overscroll-contain pb-20 outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50">
        {children ?? <ChatSampleWork />}
      </div>
      <PopupWindow id={windowId} panelRef={panelRef} open={isOpen} agentName={agentName} chat={chat} onClose={() => setOpen(false)} />
      {/* Quiet while open, when Send is the accent. Hidden when the window fills a narrow block: the header has a close button there. */}
      <Button
        ref={launcherRef}
        variant={isOpen ? "outline" : "mise"}
        size="lg"
        aria-expanded={isOpen}
        aria-controls={windowId}
        onClick={() => setOpen(!isOpen)}
        className={cn("absolute right-4 bottom-4 z-10 max-w-[calc(100%-32px)] shadow-md", isOpen && "@max-md:hidden")}
      >
        {isOpen ? <X aria-hidden="true" /> : <MessageCircle aria-hidden="true" />}
        <span className="truncate">{launcherLabel(isOpen, missed, chat.status === "generating", agentName)}</span>
      </Button>
    </section>
  )
}

export { ChatPopup, type ChatPopupProps }
```

### `registry/miseui/blocks/chat-assistant-panel-state.ts`

```ts
import type * as React from "react"
import { useEffect, useRef, useState } from "react"

interface ChatPanelOptions {
  /** Whether the chat is showing. Leave it out and the block keeps this itself. */
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
}

function useChatOpen({ open, defaultOpen = false, onOpenChange }: ChatPanelOptions) {
  const [internal, setInternal] = useState(defaultOpen)
  const current = open ?? internal
  const setOpen = (next: boolean) => {
    setInternal(next)
    onOpenChange?.(next)
  }
  return [current, setOpen] as const
}

/**
 * Opening puts the cursor in the composer, and closing hands focus back to the button that opens it again.
 * Only on a change: a panel that is open from the start must not steal focus from the page.
 */
function useChatPanelFocus(open: boolean, panel: React.RefObject<HTMLElement | null>, opener: React.RefObject<HTMLElement | null>) {
  const previousRef = useRef(open)
  useEffect(() => {
    if (previousRef.current === open) return
    previousRef.current = open
    if (open) panel.current?.querySelector("textarea")?.focus()
    else opener.current?.focus()
  }, [open, panel, opener])
}

export { useChatOpen, useChatPanelFocus, type ChatPanelOptions }
```

### `registry/miseui/blocks/chat-assistant-work.tsx`

```tsx
/** Invented report, shown only when no children are passed. It carries the figures the sample conversation is about. */

const BREAKDOWN = [
  { id: "starter-annual", label: "Starter, annual", count: 29 },
  { id: "starter-monthly", label: "Starter, monthly", count: 7 },
  { id: "team", label: "Team", count: 5 },
]
const TOTAL = BREAKDOWN.reduce((sum, row) => sum + row.count, 0)
const PERCENT = 100

const ACCOUNTS = [
  { id: "northgate", name: "Northgate Studio", note: "Open invoice question", value: "$1,008" },
  { id: "halden", name: "Halden Freight", note: "Open invoice question", value: "$1,008" },
  { id: "pell", name: "Pell Street Studio", note: "Never added a second seat", value: "$504" },
]

/** Sample work for the chat to sit beside. Replace it by passing your own screen as children. */
function ChatSampleWork() {
  return (
    <div className="@container">
      <div className="flex flex-col gap-6 p-4 @2xl:p-8">
        <div className="flex flex-col gap-1">
          <h2 className="text-display text-2xl text-balance @2xl:text-3xl">
            March churn. <span className="text-muted-foreground">41 accounts left, up from 18 in February.</span>
          </h2>
          <p className="text-[13px] text-muted-foreground">Sample report. Every figure is made up.</p>
        </div>
        <section aria-label="Accounts lost by plan" className="flex flex-col gap-3 rounded-2xl border bg-background p-4 dark:bg-card @2xl:p-6">
          {BREAKDOWN.map((row) => (
            <div key={row.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1">
              <span className="text-[13px] text-muted-foreground">{row.label}</span>
              <span className="text-[13px] tabular-nums">{row.count}</span>
              <span aria-hidden="true" className="col-span-2 h-2 overflow-hidden rounded-full bg-muted">
                <span className="block h-full rounded-full bg-foreground/30" style={{ width: `${(row.count / TOTAL) * PERCENT}%` }} />
              </span>
            </div>
          ))}
        </section>
        <section aria-label="Accounts to call" className="flex flex-col divide-y rounded-2xl border bg-background dark:bg-card">
          {ACCOUNTS.map((account) => (
            <div key={account.id} className="flex items-center justify-between gap-4 p-4 @2xl:px-6">
              <div className="min-w-0">
                <p className="text-[15px] text-pretty">{account.name}</p>
                <p className="text-[13px] text-pretty text-muted-foreground">{account.note}</p>
              </div>
              <span className="shrink-0 text-[13px] tabular-nums">{account.value}</span>
            </div>
          ))}
        </section>
      </div>
    </div>
  )
}

export { ChatSampleWork }
```

### `registry/miseui/blocks/chat-assistant-parts.tsx`

```tsx
import type * as React from "react"
import { ArrowRight } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { AgentAvatar } from "@/registry/miseui/ui/agent-avatar"
import { Button } from "@/registry/miseui/ui/button"
import type { ReasoningStep } from "@/registry/miseui/ui/reasoning-steps"
import { ShimmerText } from "@/registry/miseui/ui/shimmer-text"
import type { ToolCallStatus } from "@/registry/miseui/ui/tool-call-card"

type ChatStatus = "idle" | "generating"

interface ChatToolCall {
  id: string
  name: string
  status: ToolCallStatus
  input?: unknown
  output?: unknown
  durationMs?: number
}

interface ChatMessage {
  id: string
  role: "user" | "assistant"
  text: string
  /** Assistant turns only: what the model did on the way to this answer. */
  reasoning?: readonly ReasoningStep[]
  tools?: readonly ChatToolCall[]
}

/** Header, thread and composer share this column, so all three start on one left edge. */
const COLUMN_CLASS = "mx-auto w-full max-w-2xl"
const BODY_CLASS = "text-[15px] leading-relaxed text-pretty"

interface ChatHeaderProps {
  agentName: string
  description: string
  generating: boolean
  onNewChat?: () => void
  /** Extra controls after New chat, such as a way to close a panel. */
  actions?: React.ReactNode
}

function ChatHeader({ agentName, description, generating, onNewChat, actions }: ChatHeaderProps) {
  return (
    <header className="border-b px-4 py-3 @md:px-6">
      <div className={cn(COLUMN_CLASS, "flex items-center gap-3")}>
        <AgentAvatar seed={agentName} size={32} label={agentName} />
        <div className="flex min-w-0 flex-1 flex-col">
          <h2 className="truncate text-[15px] font-medium">{agentName}</h2>
          <p className="truncate text-[13px] text-muted-foreground">
            {generating ? <ShimmerText>Working on it</ShimmerText> : description}
          </p>
        </div>
        {onNewChat ? (
          <Button variant="ghost" size="sm" className={actions ? "pointer-coarse:h-10" : undefined} onClick={onNewChat}>
            New chat
          </Button>
        ) : null}
        {actions}
      </div>
    </header>
  )
}

interface ChatStarterProps {
  agentName: string
  suggestions: readonly string[]
  disabled: boolean
  onPick: (suggestion: string) => void
  /** A narrow panel: the headline would otherwise take the whole thread. */
  compact?: boolean
}

/** Sits at the bottom of the thread, just above the composer, so sending the first message moves nothing. */
function ChatStarter({ agentName, suggestions, disabled, onPick, compact = false }: ChatStarterProps) {
  return (
    <div className="mt-auto flex flex-col gap-6">
      <h3 className={cn("text-display leading-[1.12]! text-balance", compact ? "text-xl" : "text-3xl @2xl:text-4xl")}>
        What should we look at?{" "}
        <span className="text-muted-foreground">{agentName} reads your reports and answers with the numbers.</span>
      </h3>
      {suggestions.length > 0 ? (
        <ul className="-mx-3 flex flex-col">
          {suggestions.map((suggestion) => (
            <li key={suggestion}>
              <button
                type="button"
                disabled={disabled}
                onClick={() => onPick(suggestion)}
                className="group flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-[15px] text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"
              >
                {suggestion}
                <ArrowRight
                  aria-hidden="true"
                  className="size-4 shrink-0 opacity-0 transition-opacity duration-(--duration-fast) ease-(--ease-mise) group-hover:opacity-100 group-focus-visible:opacity-100"
                />
              </button>
            </li>
          ))}
        </ul>
      ) : null}
    </div>
  )
}

function ChatError({ message, onRetry }: { message: string; onRetry?: () => void }) {
  return (
    <div role="alert" className="flex items-center justify-between gap-3 text-[13px] text-destructive">
      <p className="text-pretty">{message}</p>
      {onRetry ? (
        <Button variant="ghost" size="sm" onClick={onRetry}>
          Try again
        </Button>
      ) : null}
    </div>
  )
}

export { BODY_CLASS, ChatError, ChatHeader, ChatStarter, COLUMN_CLASS, type ChatMessage, type ChatStatus, type ChatToolCall }
```

### `registry/miseui/blocks/chat-assistant-thread.tsx`

```tsx
import { useState } from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { ReasoningSteps } from "@/registry/miseui/ui/reasoning-steps"
import { ShimmerText } from "@/registry/miseui/ui/shimmer-text"
import { StreamingText } from "@/registry/miseui/ui/streaming-text"
import { ThinkingOrb } from "@/registry/miseui/ui/thinking-orb"
import { ToolCallCard } from "@/registry/miseui/ui/tool-call-card"
import { BODY_CLASS, type ChatMessage, type ChatStatus } from "@/registry/miseui/blocks/chat-assistant-parts"

const WORKING_ORB_PX = 16

function UserBubble({ text }: { text: string }) {
  return (
    <div className={cn(BODY_CLASS, "ml-auto w-fit max-w-[85%] rounded-2xl border bg-popover px-4 py-2 break-words whitespace-pre-wrap")}>
      {text}
    </div>
  )
}

function WorkingRow() {
  return (
    <div role="status" className="flex items-center gap-2 text-[15px]">
      <ThinkingOrb state="thinking" size={WORKING_ORB_PX} label="Working" />
      <ShimmerText>Thinking</ShimmerText>
    </div>
  )
}

interface AssistantTurnProps {
  message: ChatMessage
  /** Reveal the text at reading pace instead of showing it whole. */
  streaming: boolean
  working: boolean
}

function AssistantTurn({ message, streaming, working }: AssistantTurnProps) {
  const steps = message.reasoning ?? []
  const tools = message.tools ?? []
  return (
    <div className="flex flex-col gap-3">
      {steps.length > 0 ? (
        <ReasoningSteps steps={steps} defaultOpen={steps.some((step) => step.status === "active")} className="bg-popover" />
      ) : null}
      {tools.map(({ id, ...tool }) => (
        <ToolCallCard key={id} {...tool} className="bg-popover" />
      ))}
      {working && steps.length === 0 ? <WorkingRow /> : null}
      {message.text && streaming ? <StreamingText text={message.text} className={cn(BODY_CLASS, "block")} /> : null}
      {message.text && !streaming ? <p className={cn(BODY_CLASS, "whitespace-pre-wrap")}>{message.text}</p> : null}
    </div>
  )
}

/** Keeps the streaming reveal on a turn after status goes idle: the text may still be catching up, and swapping to plain text would make it jump. */
function useStreamingTurnId(messages: readonly ChatMessage[], status: ChatStatus) {
  const last = messages[messages.length - 1]
  const liveId = status === "generating" && last?.role === "assistant" ? last.id : null
  const [streamingId, setStreamingId] = useState(liveId)
  if (liveId && liveId !== streamingId) setStreamingId(liveId)
  return streamingId
}

interface ChatThreadProps {
  messages: readonly ChatMessage[]
  status: ChatStatus
}

function ChatThread({ messages, status }: ChatThreadProps) {
  const streamingId = useStreamingTurnId(messages, status)
  const generating = status === "generating"
  const last = messages[messages.length - 1]
  return (
    <ol aria-label="Conversation" className="flex flex-col gap-8">
      {messages.map((message) => (
        <li key={message.id}>
          {message.role === "user" ? (
            <UserBubble text={message.text} />
          ) : (
            <AssistantTurn
              message={message}
              streaming={message.id === streamingId}
              working={generating && message === last && !message.text}
            />
          )}
        </li>
      ))}
      {generating && last?.role !== "assistant" ? (
        <li>
          <WorkingRow />
        </li>
      ) : null}
    </ol>
  )
}

export { ChatThread }
```

### `registry/miseui/blocks/chat-assistant-composer.tsx`

```tsx
import type * as React from "react"
import { useRef, useState } from "react"
import { ArrowUp, Square } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { SPRING_PRESS } from "@/registry/miseui/lib/motion"
import { BeamBorder } from "@/registry/miseui/ui/beam-border"
import { Button } from "@/registry/miseui/ui/button"
import { Textarea } from "@/registry/miseui/ui/textarea"

const ACTION_ICON_START = { scale: 0.6, opacity: 0 }
const ACTION_ICON_END = { scale: 1, opacity: 1 }

interface ChatComposerProps {
  label: string
  placeholder: string
  generating: boolean
  disabled: boolean
  onSend: (text: string) => void
  onStop?: () => void
}

function ChatComposer({ label, placeholder, generating, disabled, onSend, onStop }: ChatComposerProps) {
  const [draft, setDraft] = useState("")
  const fieldRef = useRef<HTMLTextAreaElement>(null)
  const canSend = !generating && !disabled && draft.trim().length > 0

  const send = () => {
    if (!canSend) return
    onSend(draft.trim())
    setDraft("")
    fieldRef.current?.focus()
  }
  const stop = () => {
    onStop?.()
    fieldRef.current?.focus()
  }
  const sendOnEnter = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
    // isComposing: Enter confirms a Korean or Japanese syllable; sending here would post half a word.
    if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return
    event.preventDefault()
    send()
  }

  return (
    <BeamBorder active={generating} className="rounded-2xl">
      <div className="flex flex-col gap-2 rounded-[inherit] border bg-popover p-3 shadow-[0_16px_40px_-24px_color-mix(in_oklab,var(--mise)_24%,oklch(0_0_0/40%))] transition-[border-color,box-shadow] duration-(--duration-fast) ease-(--ease-mise) focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50">
        <Textarea
          ref={fieldRef}
          aria-label={label}
          rows={1}
          value={draft}
          disabled={disabled}
          placeholder={placeholder}
          onChange={(event) => setDraft(event.target.value)}
          onKeyDown={sendOnEnter}
          className="max-h-48 min-h-12 resize-none overflow-y-auto rounded-none border-0 bg-transparent px-1 py-1 leading-relaxed shadow-none focus-visible:ring-0 md:text-[15px]"
        />
        <div className="flex items-center justify-between gap-3">
          <p className="hidden pl-1 text-[13px] text-muted-foreground @md:block">Enter to send, Shift + Enter for a new line</p>
          <ComposerAction generating={generating} canSend={canSend} onSend={send} onStop={stop} />
        </div>
      </div>
    </BeamBorder>
  )
}

interface ComposerActionProps {
  generating: boolean
  canSend: boolean
  onSend: () => void
  onStop: () => void
}

/**
 * One button that changes job, not two that swap: it keeps keyboard focus across the change. Always
 * type="button", because a type that flips mid-click could submit a form right after Stop.
 */
function ComposerAction({ generating, canSend, onSend, onStop }: ComposerActionProps) {
  const reduceMotion = useReducedMotion()
  return (
    <Button
      type="button"
      size="icon"
      variant={generating ? "secondary" : "mise"}
      aria-label={generating ? "Stop generating" : "Send message"}
      disabled={!generating && !canSend}
      onClick={generating ? onStop : onSend}
      className="ml-auto"
    >
      <motion.span
        key={generating ? "stop" : "send"}
        aria-hidden="true"
        className="grid place-items-center"
        initial={reduceMotion ? false : ACTION_ICON_START}
        animate={ACTION_ICON_END}
        transition={SPRING_PRESS}
      >
        {generating ? <Square className="size-3.5 fill-current" /> : <ArrowUp />}
      </motion.span>
    </Button>
  )
}

export { ChatComposer }
```

### `registry/miseui/blocks/chat-assistant-sample.ts`

```ts
import { useEffect, useReducer } from "react"
import { useReducedMotion } from "motion/react"
import type { ReasoningStep, ReasoningStepStatus } from "@/registry/miseui/ui/reasoning-steps"
import type { ToolCallStatus } from "@/registry/miseui/ui/tool-call-card"
import type { ChatMessage, ChatStatus, ChatToolCall } from "@/registry/miseui/blocks/chat-assistant-parts"

/**
 * A scripted conversation, used only when no `messages` are passed, so the block shows every state out
 * of the box. Once your model is wired up, delete this file together with SampleChat in chat-assistant.tsx.
 * Text arrives word by word, as a real stream does.
 */
const FRAME_MS = 700
/** About 55 characters a second, a little under StreamingText's reveal, so the caret never stalls. */
const WORD_MS = 110

const STEP_LABELS = ["Reading your question", "Searching the reports", "Writing the answer"] as const

function stepsAt(statuses: readonly ReasoningStepStatus[]): readonly ReasoningStep[] {
  return STEP_LABELS.map((label, index) => ({ id: `step-${index}`, label, status: statuses[index] }))
}

function toolAt(status: ToolCallStatus): readonly ChatToolCall[] {
  return [
    {
      id: "search",
      name: "search_reports",
      status,
      input: { period: "last_90_days" },
      output: status === "success" ? { reports: 4 } : undefined,
      durationMs: status === "success" ? 840 : undefined,
    },
  ]
}

const FRAMES: readonly Pick<ChatMessage, "reasoning" | "tools">[] = [
  { reasoning: stepsAt(["active", "pending", "pending"]), tools: [] },
  { reasoning: stepsAt(["done", "active", "pending"]), tools: toolAt("running") },
  { reasoning: stepsAt(["done", "done", "active"]), tools: toolAt("success") },
  { reasoning: stepsAt(["done", "done", "done"]), tools: toolAt("success") },
]
const LAST_FRAME = FRAMES.length - 1

const SAMPLE_HISTORY: readonly ChatMessage[] = [
  { id: "m1", role: "user", text: "Why did churn jump in March?" },
  {
    id: "m2",
    role: "assistant",
    text: "41 accounts left in March, up from 18 in February. 29 of them were annual Starter renewals that came due after the price change, and most of those never added a second seat. Monthly plans held steady, so this looks like a renewal problem, not a product one.",
    reasoning: [
      { id: "r1", label: "Read the March churn export", status: "done" },
      { id: "r2", label: "Compared cohorts against February", status: "done", detail: "Starter annual stands out" },
      { id: "r3", label: "Checked billing events", status: "done" },
    ],
    tools: [
      {
        id: "t1",
        name: "query_accounts",
        status: "success",
        input: { churned_in: "2026-03", group_by: "plan" },
        output: { starter_annual: 29, starter_monthly: 7, team: 5 },
        durationMs: 1240,
      },
    ],
  },
  { id: "m3", role: "user", text: "Which of those are worth a call this week?" },
]

const OPENING_REPLY =
  "Start with Northgate Studio and Halden Freight. Both renewed for two years before this one, both went quiet after the price change, and each still has an open invoice question. I can draft the two emails if you want."
const PREVIEW_REPLY =
  "This is a preview reply, so nothing was really read. Pass messages and status to connect your own model, and this turn will show its steps as they happen."

function toWords(text: string) {
  return text.split(" ")
}

interface SampleState {
  messages: readonly ChatMessage[]
  status: ChatStatus
  frame: number
  words: number
  script: readonly string[]
  nextId: number
}

type SampleAction = { type: "send"; text: string; instant: boolean } | { type: "tick" } | { type: "stop" } | { type: "reset" }

function initialState(instant: boolean): SampleState {
  const script = toWords(OPENING_REPLY)
  const reply: ChatMessage = { id: "m4", role: "assistant", text: instant ? OPENING_REPLY : "" }
  return {
    messages: [...SAMPLE_HISTORY, reply],
    status: instant ? "idle" : "generating",
    frame: LAST_FRAME,
    words: instant ? script.length : 0,
    script,
    nextId: 5,
  }
}

function patchLast(messages: readonly ChatMessage[], patch: Partial<ChatMessage>) {
  return messages.map((message, index) => (index === messages.length - 1 ? { ...message, ...patch } : message))
}

function sendMessage(state: SampleState, text: string, instant: boolean): SampleState {
  const user: ChatMessage = { id: `m${state.nextId}`, role: "user", text }
  const id = `m${state.nextId + 1}`
  const script = toWords(PREVIEW_REPLY)
  const reply: ChatMessage = instant
    ? { id, role: "assistant", text: PREVIEW_REPLY, ...FRAMES[LAST_FRAME] }
    : { id, role: "assistant", text: "", ...FRAMES[0] }
  return {
    messages: [...state.messages, user, reply],
    status: instant ? "idle" : "generating",
    frame: instant ? LAST_FRAME : 0,
    words: instant ? script.length : 0,
    script,
    nextId: state.nextId + 2,
  }
}

function advance(state: SampleState): SampleState {
  if (state.frame < LAST_FRAME) {
    const frame = state.frame + 1
    return { ...state, frame, messages: patchLast(state.messages, FRAMES[frame]) }
  }
  const words = state.words + 1
  return {
    ...state,
    words,
    status: words >= state.script.length ? "idle" : "generating",
    messages: patchLast(state.messages, { text: state.script.slice(0, words).join(" ") }),
  }
}

/** A stopped turn must not keep breathing: unfinished steps go back to waiting, a running tool reads as failed. */
function stopTurn(state: SampleState): SampleState {
  const last = state.messages[state.messages.length - 1]
  const reasoning = last.reasoning?.map((step) => (step.status === "active" ? { ...step, status: "pending" as const } : step))
  const tools = last.tools?.map((tool) => (tool.status === "running" ? { ...tool, status: "error" as const } : tool))
  return { ...state, status: "idle", messages: patchLast(state.messages, { reasoning, tools }) }
}

function reducer(state: SampleState, action: SampleAction): SampleState {
  if (action.type === "send") return sendMessage(state, action.text, action.instant)
  if (action.type === "tick") return advance(state)
  if (action.type === "stop") return stopTurn(state)
  return { ...state, messages: [], status: "idle" }
}

function useSampleChat() {
  const instant = Boolean(useReducedMotion())
  const [state, dispatch] = useReducer(reducer, instant, initialState)
  const { status, frame } = state

  useEffect(() => {
    if (status !== "generating") return
    const id = window.setInterval(() => dispatch({ type: "tick" }), frame < LAST_FRAME ? FRAME_MS : WORD_MS)
    return () => window.clearInterval(id)
  }, [status, frame])

  return {
    messages: state.messages,
    status,
    send: (text: string) => dispatch({ type: "send", text, instant }),
    stop: () => dispatch({ type: "stop" }),
    reset: () => dispatch({ type: "reset" }),
  }
}

export { useSampleChat }
```
