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>
| Prop | Type | Default | Description |
|---|---|---|---|
| children *required | ReactNode | — | |
| aria-label | string | — | |
| className | string | — | |
| 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
contentKeywhenever 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· hide
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