Conversation

Talk with an assistant or with other people, and follow each reply as it arrives.

BetaSince 1.0.0
On this page

Import

import {
  Button,
  Column,
  Composer,
  CopyButton,
  Grid,
  IconButton,
  Message,
  MessageList,
  Row,
  Spinner,
  Text,
} from '@envsemble/envui';
import { Markdown } from '@envsemble/envui/markdown';

Usage

A conversation is a MessageList above a Composer, in a Column of a set height: the list takes the space and scrolls, and the composer sits at its foot.

  • Each entry is a Message, from="self" for the person using the app and from="other" for an assistant or anyone else.
  • An assistant's text renders through Markdown, from @envsemble/envui/markdown.
  • CopyButton in actions copies a complete answer.
AssistantChat.tsx
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
import { Button, Column, Composer, CopyButton, Grid, Message, MessageList } from '@envsemble/envui';
import { Markdown } from '@envsemble/envui/markdown';
import { IconArrowDownward } from '@envsemble/envui/icons/ArrowDownward';

interface Entry {
  id: number;
  from: 'self' | 'other';
  text: string;
  stopped?: boolean;
}

const history: Entry[] = [
  { id: 1, from: 'self', text: 'What is still open for the launch on Friday?' },
  {
    id: 2,
    from: 'other',
    text: `Three things:

1. **Pricing page**: Sam Lee is checking the last prices.
2. **Press kit**: waiting for two photos.
3. **Support rota**: the weekend shifts are empty.`,
  },
  { id: 3, from: 'self', text: 'Who owns the press kit?' },
  {
    id: 4,
    from: 'other',
    text: 'Priya Shah. She expects the photos from the studio on Wednesday morning.',
  },
];

const reply = `Here is a plan for the rest of the week:

- **Tuesday**: Sam Lee signs off the pricing page.
- **Wednesday**: Priya Shah adds the photos and sends the press kit for review.
- **Thursday**: Alex Morgan fills the weekend shifts of the support rota.

Shall I send this to the launch channel?`;

export default function AssistantChat() {
  const [entries, setEntries] = useState(history);
  const [replying, setReplying] = useState(false);
  // Whether people are at the end of the log, where new words keep them.
  const [atEnd, setAtEnd] = useState(true);
  const log = useRef<HTMLDivElement>(null);
  const timer = useRef<number>(undefined);
  useEffect(() => () => window.clearInterval(timer.current), []);

  // At the end, each new word keeps the newest line in view; once people scroll up to read, the
  // log stays where they are.
  useLayoutEffect(() => {
    if (atEnd && log.current) log.current.scrollTop = log.current.scrollHeight;
  }, [entries, atEnd]);

  function finish(stopped: boolean) {
    window.clearInterval(timer.current);
    setReplying(false);
    // A stopped reply keeps what arrived, marked as stopped.
    if (stopped)
      setEntries((list) =>
        list.map((entry, index) =>
          index === list.length - 1 ? { ...entry, stopped: true } : entry,
        ),
      );
  }

  function send(text: string) {
    const id = Date.now();
    const words = reply.split(' ');
    let shown = 1;
    setAtEnd(true);
    setEntries((list) => [
      ...list,
      { id, from: 'self', text },
      { id: id + 1, from: 'other', text: words[0] },
    ]);
    setReplying(true);
    // A timer stands in for a reply that streams in a few words at a time.
    timer.current = window.setInterval(() => {
      shown += 2;
      const text = words.slice(0, shown).join(' ');
      setEntries((list) => list.map((entry) => (entry.id === id + 1 ? { ...entry, text } : entry)));
      if (shown >= words.length) finish(false);
    }, 90);
  }

  function jumpToLatest() {
    const node = log.current;
    if (!node) return;
    const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    node.scrollTo({ top: node.scrollHeight, behavior: still ? 'auto' : 'smooth' });
    // The button leaves once the end is in view, so focus moves to the log it scrolled.
    node.focus();
  }

  return (
    // As tall as a chat panel, so the log scrolls above the composer that stays at its foot.
    <Column gap={4} style={{ inlineSize: 'min(100%, 36rem)', blockSize: '30rem' }}>
      {/* The log and the jump button share one cell, the button over the log's lower edge. */}
      <Grid style={{ flex: 1, minBlockSize: 0, gridTemplateRows: 'minmax(0, 1fr)' }}>
        <MessageList
          ref={log}
          aria-label="Conversation with the assistant"
          busy={replying}
          // The log scrolls, so it takes focus for people who scroll with the keyboard.
          tabIndex={0}
          onScroll={(event) => {
            const node = event.currentTarget;
            setAtEnd(node.scrollHeight - node.scrollTop - node.clientHeight < 24);
          }}
          style={{ gridArea: '1 / 1', overflowY: 'auto', paddingInline: '0.25rem' }}
        >
          {entries.map((entry, index) =>
            entry.from === 'self' ? (
              <Message key={entry.id} from="self">
                {entry.text}
              </Message>
            ) : (
              <Message
                key={entry.id}
                from="other"
                author="Assistant"
                meta={entry.stopped ? 'Stopped' : undefined}
                actions={
                  // Copy waits until the reply is complete.
                  !(replying && index === entries.length - 1) && (
                    <CopyButton variant="text" value={entry.text} aria-label="Copy answer" />
                  )
                }
              >
                <Markdown>{entry.text}</Markdown>
              </Message>
            ),
          )}
        </MessageList>
        {!atEnd && (
          <Button
            variant="outline"
            size="sm"
            startIcon={<IconArrowDownward />}
            onClick={jumpToLatest}
            style={
              {
                // Over the log, whose list items are positioned and would otherwise paint above it.
                position: 'relative',
                gridArea: '1 / 1',
                alignSelf: 'end',
                justifySelf: 'center',
                marginBlockEnd: '0.5rem',
                // Filled with the page's color, so the messages it floats over don't show through.
                '--env-button-container': 'var(--env-color-bg-page)',
              } as CSSProperties
            }
          >
            Latest
          </Button>
        )}
      </Grid>
      <Composer
        aria-label="Message the assistant"
        placeholder="Ask anything"
        onSend={send}
        running={replying}
        onStop={() => finish(true)}
      />
    </Column>
  );
}

Streaming and stopping

Add the reply to the log as soon as the request starts, and append each chunk to its text as it arrives. Set busy on MessageList and running on Composer until it ends: the log waits to announce the reply, and the send button becomes Stop, which calls onStop. Abort the request there, keep the text that arrived, and mark the reply, such as meta="Stopped", with a Try again action.

async function ask(question: string) {
  const controller = new AbortController();
  request.current = controller;
  const id = addReply(''); // The reply's entry, in the log from the start.
  setRunning(true);
  try {
    const response = await fetch('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ question }),
      signal: controller.signal,
    });
    const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
    for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read())
      appendToReply(id, chunk.value);
  } catch {
    // Stop aborts the request; anything else failed. Both keep the text that arrived.
    markReply(id, controller.signal.aborted ? 'stopped' : 'failed');
  } finally {
    setRunning(false);
  }
}

<MessageList aria-label="Conversation with the assistant" busy={running}>…</MessageList>
<Composer
  aria-label="Message the assistant"
  onSend={send}
  running={running}
  onStop={() => request.current?.abort()}
/>

A failed send

Keep a message that couldn't be sent in the log, with status="failed" and a Try again Button in actions; set status="sending" while it goes again, and clear it once the server takes it. Try again leaves with the failure, so move focus to the composer through its ref, as the FailedSend example does.

<Message
  from="self"
  status={entry.status}
  actions={
    entry.status === 'failed' && (
      <Button
        variant="text"
        onClick={() => {
          deliver(entry.id);
          composer.current?.focus();
        }}
      >
        Try again
      </Button>
    )
  }
>
  {entry.text}
</Message>

Scrolling

The list doesn't scroll by itself. Give it overflowY: 'auto' and tabIndex={0}, track whether people are at its end in onScroll, and while they are, scroll to the end in a layout effect each time the entries change, without moving focus. When they aren't, show a Latest button that scrolls to the end, smoothly unless people prefer reduced motion.

const [atEnd, setAtEnd] = useState(true);
useLayoutEffect(() => {
  if (atEnd && log.current) log.current.scrollTop = log.current.scrollHeight;
}, [entries, atEnd]);

<MessageList
  ref={log}
  tabIndex={0}
  onScroll={({ currentTarget: node }) =>
    setAtEnd(node.scrollHeight - node.scrollTop - node.clientHeight < 24)
  }
  style={{ overflowY: 'auto' }}
>
  …
</MessageList>;

The first message

Render the MessageList from the start, empty, so the log exists before the first message and announces it.

  • Put the welcome in the same Grid cell, a heading and suggestion Buttons in a Row.
  • A suggestion sends its text and focuses the composer, since the suggestions leave with the first message.
<Grid style={{ gridTemplateRows: 'minmax(0, 1fr)' }}>
  <MessageList aria-label="Conversation with the assistant" style={{ gridArea: '1 / 1' }}>
    {messages}
  </MessageList>
  {entries.length === 0 && (
    <Column align="center" justify="center" gap={4} style={{ gridArea: '1 / 1' }}>
      <Text variant="title" render={<h3 />}>
        What can I help with?
      </Text>
      <Row wrap gap={2} justify="center">
        {suggestionButtons}
      </Row>
    </Column>
  )}
</Grid>

Composition

The Column holds a Grid of one cell, whose row is minmax(0, 1fr), with the MessageList and the Latest button in it, the button aligned to the end and centred over the log; then the Composer. An assistant's message takes author, Markdown as its content and CopyButton in actions once complete; your own messages take neither author nor actions. Between people, every message from someone else has its author and its time in meta, your own carry status while they are sent, and the composer's start can hold an attach IconButton. A placeholder reply while the first words are awaited is the reply's own skeleton, skeleton on its Message, never a Spinner in its place.

Accessibility

You provide

  • A name for the log and for the composer, such as Conversation with the assistant and Message the assistant.
  • busy on the log and running on the composer while a reply streams, and stopping the request in onStop.
  • Keeping the newest message in view without moving focus, tabIndex={0} on a log that scrolls, and focus on the composer when an action leaves, such as Try again or a suggestion.

envUI provides

  • MessageList as a polite log, with aria-busy while busy, so a streaming reply is announced once it is complete.
  • A send button that becomes Stop while running, keeps its place and focus, and doesn't send on Enter.
  • Focus back in the composer after each send, and the status of a message as text beside its icon.

Changelog

1.0.0

The pattern.