Chat Thread

The conversation: turns from you and the agent that follow the newest message and group by speaker.

You

How should I brew the new Huila beans?

Brew assistant

A pour-over brings out the red fruit. Want the recipe?

'use client';

import { useState } from 'react';
import { ChatComposer, ChatMessage, ChatThread, MessageActions, StreamingText } from '@brand-studio/ui';

const replies = [
  'For a 250 ml cup, use 15 g of coffee ground like coarse sand, and water around 94°C.',
  'Pour in three stages over about three minutes. If it tastes sour, grind a little finer next time.',
  'The Huila beans rested nine days, so they are ready. The Guji needs two more.',
];

interface Turn { id: number; from: 'user' | 'agent'; text: string }

export default function ChatThreadDemo() {
  const [turns, setTurns] = useState<Turn[]>([
    { id: 1, from: 'user', text: 'How should I brew the new Huila beans?' },
    { id: 2, from: 'agent', text: 'A pour-over brings out the red fruit. Want the recipe?' },
  ]);
  const [busy, setBusy] = useState(false);
  const send = (text: string) => {
    const reply = replies[(turns.length / 2 - 1) % replies.length];
    setTurns(list => [...list, { id: list.length + 1, from: 'user', text }]);
    setBusy(true);
    setTimeout(() => { setTurns(list => [...list, { id: list.length + 1, from: 'agent', text: reply }]); setBusy(false); }, 700);
  };
  return (
    <div style={{ width: '100%', maxWidth: 560, height: 440, display: 'grid', gridTemplateRows: '1fr auto', gap: 12 }}>
      <ChatThread label="Brew assistant">
        {turns.map(turn => (
          <ChatMessage key={turn.id} from={turn.from} name={turn.from === 'user' ? 'You' : 'Brew assistant'} avatar={turn.from === 'agent' ? 'B' : undefined}
            actions={turn.from === 'agent' ? <MessageActions copyText={turn.text} /> : undefined}>
            {turn.from === 'agent' && turn.id > 2 ? <StreamingText text={turn.text} /> : <p>{turn.text}</p>}
          </ChatMessage>
        ))}
      </ChatThread>
      <ChatComposer placeholder="Ask about your coffee" busy={busy} onSubmit={send} onStop={() => setBusy(false)} />
    </div>
  );
}

Installation

pnpm add @brand-studio/ui

Usage

import { ChatThread, ChatMessage } from '@brand-studio/ui';
<ChatThread label="Brew assistant">
  <ChatMessage from="user" name="You">How should I brew the new beans?</ChatMessage>
  <ChatMessage name="Brew assistant" avatar="B" actions={<MessageActions copyText={answer} />}>
    <StreamingText text={answer} />
  </ChatMessage>
</ChatThread>

Give the thread a height, or put it in a grid row that has one. While you are at the bottom, the thread follows each new message and each streamed word. Scroll up to read and it stays put; a Jump to latest button brings you back. Older messages added above keep your place in browsers that support scroll anchoring.

Your messages sit on the right in a bubble; the agent's read as open text. Messages in a row from one side share one name.

Accessibility

  • The thread is a log named by label, so screen readers announce new messages politely as they arrive.
  • The scroll area takes focus, so arrow keys scroll it.
  • Each message is an article named by its speaker.

API Reference

ChatThread

PropTypeDefault
childrenReact.ReactNode
label?string'Conversation'
jumpLabel?string'Jump to latest'
className?string''

ChatMessage

PropTypeDefault
from?"agent" | "user"'agent'
name?string
avatar?React.ReactNode
time?string
actions?React.ReactNode
childrenReact.ReactNode
className?string''

Still, Deskhand and Hollis are fictional brands. The coffee photographs are original generated artwork. @brand-studio/ui is MIT licensed.