Marble UI
← Components

Chat Conversation

Preview: Chat

Scrolling message log that sticks to the bottom while new tokens arrive, unless the reader scrolled up; then a "Jump to latest" button appears. role="log" announces new messages politely.

Import

import { ChatConversation } from '@marbleui/ui'

Props

<ChatConversation>

PropTypeDefaultDescription
children *requiredReactNode—
aria-labelstring—
classNamestring—
contentKey{}—Changes when new content arrives (e.g. messages.length + last message length), to keep pinned to the bottom.

Usage notes

Scrolling message log that stays pinned to the newest message while tokens stream in, unless the reader scrolled up; then a “Jump to latest” button appears.

<ChatConversation contentKey={`${messages.length}-${last?.text.length}`}>
  {messages.map((m) => <ChatMessage key={m.id} role={m.role}>{m.text}</ChatMessage>)}
</ChatConversation>
  • Change contentKey whenever content grows (including during streaming) so it re-pins to the bottom.
  • It’s a role="log", so new messages are announced without stealing focus.
Example source · show
import { useRef, useState } from 'react'
import { ChatMessage } from '../chat-message'
import { MarkdownResponse } from '../markdown-response'
import { MessageActions } from '../message-actions'
import { PromptInput } from '../prompt-input'
import { PromptSuggestions } from '../prompt-suggestions'
import { ThinkingLoader } from '../thinking-loader'
import { ChatConversation } from './chat-conversation'

type Msg = { id: number; role: 'user' | 'assistant'; text: string }
const canned = (q: string) =>
  `You asked: *${q}*\n\nHere's a short plan:\n\n1. Pull the last 90 days of usage\n2. Flag accounts with a **>30% drop**\n3. Cross-check renewal dates\n\n\`\`\`sql\nselect account, pct_change\nfrom usage_trend\nwhere pct_change < -0.3;\n\`\`\`\n\nWant me to run it?`

function Chat() {
  const [msgs, setMsgs] = useState<Msg[]>([])
  const [thinking, setThinking] = useState(false)
  const [streaming, setStreaming] = useState(false)
  const timer = useRef<ReturnType<typeof setInterval>>(undefined)
  const send = (q: string) => {
    const id = Date.now()
    setMsgs((m) => [...m, { id, role: 'user', text: q }])
    setThinking(true)
    setTimeout(() => {
      setThinking(false)
      setStreaming(true)
      const full = canned(q)
      let n = 0
      setMsgs((m) => [...m, { id: id + 1, role: 'assistant', text: '' }])
      timer.current = setInterval(() => {
        n += 8
        setMsgs((m) => m.map((x) => (x.id === id + 1 ? { ...x, text: full.slice(0, n) } : x)))
        if (n >= full.length) {
          clearInterval(timer.current)
          setStreaming(false)
        }
      }, 30)
    }, 900)
  }
  const last = msgs.at(-1)
  return (
    <div className="flex h-[30rem] w-[36rem] flex-col overflow-hidden rounded-2xl border border-border bg-bg">
      <ChatConversation className="flex-1" contentKey={`${msgs.length}-${last?.text.length}-${thinking}`}>
        {msgs.length === 0 && (
          <div className="m-auto flex flex-col items-center gap-4 text-center">
            <h3 className="text-lg font-semibold text-fg">How can I help?</h3>
            <PromptSuggestions items={[{ id: '1', title: 'Find churn risks' }, { id: '2', title: 'Summarize this week' }, { id: '3', title: 'Draft a launch email' }]} onSelect={send} />
          </div>
        )}
        {msgs.map((m) =>
          m.role === 'user' ? (
            <ChatMessage key={m.id} role="user">{m.text}</ChatMessage>
          ) : (
            <ChatMessage key={m.id} role="assistant" name="Acme AI" actions={!streaming || m !== last ? <MessageActions text={m.text} onRegenerate={() => {}} /> : undefined}>
              <MarkdownResponse isStreaming={streaming && m === last}>{m.text}</MarkdownResponse>
            </ChatMessage>
          ),
        )}
        {thinking && <ThinkingLoader />}
      </ChatConversation>
      <div className="p-3">
        <PromptInput onSubmit={(t) => t && send(t)} isStreaming={streaming || thinking} onStop={() => { clearInterval(timer.current); setStreaming(false) }} />
      </div>
    </div>
  )
}

export const styles = { Chat }

Source: packages/ui/src/chat-conversation/chat-conversation.tsx

Catalog

Category
ai-agents
Priority
Pro
Primitive
composed
React Aria
GridList