Chat

A composed chat experience with messages, tool turns, thinking state, and a composer.

Installation

pnpm add @wandercom/design-system-web

Usage

ChatComposed wires together the seven chat primitives — ChatContainer, ChatMessage, Markdown, ChatThinking, ChatMultiChoiceQuestion, ChatMultiOptionQuestion, and ChatInput — into a single drop-in block. Pass an ordered items array describing the transcript and forward the composer state through props.

Each entry is either a ChatMessageItem (a standard message bubble, with optional markdown content, action row, avatar, and thinking shimmer) or a ChatToolItem (a right-aligned user bubble that surfaces a past Q:/A: for a tool call the user has already responded to).

Live, unanswered questions belong on the activeQuestion prop instead — when set, the corresponding question component replaces the composer at the bottom of the layout. The question's onSubmit is responsible for clearing activeQuestion upstream and appending a ChatToolItem to items so the response shows up in the transcript.

28 lines
import { ChatComposed, type ChatItem } from '@wandercom/design-system-web/blocks/chat';
import type { Attachment, ContextChip } from '@wandercom/design-system-web/ui/chat-input';
import { useState } from 'react';

export function Example() {
  const [message, setMessage] = useState('');
  const [items, setItems] = useState<ChatItem[]>([
    { id: '1', from: 'user', content: 'Looking for a quiet weekend in November.' },
    {
      id: '2',
      from: 'assistant',
      content: '### A few options\n\n- Big Sur cliffside\n- Ojai retreat\n- Aspen lodge',
      markdown: true,
    },
  ]);

  return (
    <div className="h-[600px]">
      <ChatComposed
        items={items}
        inputValue={message}
        onInputChange={setMessage}
        onSubmit={(payload) => sendMessage(payload)}
        placeholder="Ask about a stay…"
      />
    </div>
  );
}

The wrapper defaults to h-full w-full so it fills its parent. Wrap it in a sized container (e.g. h-[600px]) so the transcript scroll region is bounded.

Example

Loading example...

States

The block renders different surfaces based on item shape:

Assistant message with markdown — set markdown: true and pass a markdown string as content. The body is rendered through Markdown so headings, lists, code blocks, and links pick up the design system's typography.

Assistant thinking — set status: "thinking" on a message. The body is replaced with ChatThinking (a polite live-region shimmer). Swap status back to undefined and provide real content once the response resolves.

Active multi-choice question — pass activeQuestion={{ kind: "multi-choice", ...ChatMultiChoiceQuestionProps }}. Replaces the composer with a vertical list of single-select option cards with optional free-form "Other" answer.

Active multi-option question — pass activeQuestion={{ kind: "multi-option", ...ChatMultiOptionQuestionProps }}. Replaces the composer with a checklist with select-all and a footer count + submit button.

Past tool response — append a ChatToolItem to items with from: "user" and a tool snapshot of the question and the user's selection. Renders as a right-aligned user bubble showing Q: {question} / A: {selected}.

User message with attachments — pass attachments and onAttachmentsChange to control the composer's image attachments row. After submit, append the image URLs to the next message via the meta slot or as inline ReactNode content.

Items shape

The items array uses a discriminated union — entries with a tool field render as right-aligned user-response bubbles, everything else renders as a ChatMessage.

41 lines
import { IconClipboard } from '@central-icons-react/round-outlined-radius-2-stroke-1.5/IconClipboard';
import type { ChatItem } from '@wandercom/design-system-web/blocks/chat';

const items: ChatItem[] = [
  // Plain user message
  { id: 'u-1', from: 'user', content: 'Hello' },

  // Markdown assistant response with action row
  {
    id: 'a-1',
    from: 'assistant',
    content: '### Here are a few options\n\n- Big Sur\n- Ojai',
    markdown: true,
    actions: [{ label: 'Copy', icon: <IconClipboard />, onClick: copy }],
  },

  // Thinking shimmer
  { id: 'a-2', from: 'assistant', status: 'thinking', content: '' },

  // Past multi-choice response — right-aligned user bubble.
  {
    id: 'tool-1',
    from: 'user',
    tool: {
      kind: 'multi-choice',
      question: 'How should we handle photos?',
      selected: 'Use new high quality photos',
    },
  },

  // Past multi-option response — right-aligned user bubble.
  {
    id: 'tool-2',
    from: 'user',
    tool: {
      kind: 'multi-option',
      question: 'Which amenities matter most?',
      selected: ['Private pool', 'On-property chef'],
    },
  },
];

Live questions go through activeQuestion rather than items — they replace the composer until the user submits:

28 lines
<ChatComposed
  items={items}
  activeQuestion={{
    kind: 'multi-option',
    question: 'Which amenities matter most?',
    options: amenityOptions,
    value: selected,
    onChange: setSelected,
    onSubmit: (values) => {
      setItems((prev) => [
        ...prev,
        {
          id: crypto.randomUUID(),
          from: 'user',
          tool: {
            kind: 'multi-option',
            question: 'Which amenities matter most?',
            selected: values.map((v) => labelByValue.get(v) ?? v),
          },
        },
      ]);
      setActiveQuestion(undefined);
    },
  }}
  inputValue={message}
  onInputChange={setMessage}
  onSubmit={handleSubmit}
/>

Header and empty state

Use header to render content above the transcript (e.g. a thread title, a back button, a "clear" affordance). Use emptyState to replace the transcript when items is empty — the composer remains visible.

8 lines
<ChatComposed
  header={<ChatHeader title="New thread" onClear={resetThread} />}
  emptyState={<EmptyChat onPromptClick={handlePromptClick} />}
  items={items}
  inputValue={message}
  onInputChange={setMessage}
  onSubmit={handleSubmit}
/>

Composer state

The composer is fully controlled. Forward inputValue / onInputChange for the textarea, and optionally attachments / onAttachmentsChange and contexts / onContextRemove for the attachments row and context chips. onSubmit receives a ChatInputSubmitPayload containing the trimmed message, the snapshot of attachments, and the active context chips at submit time.

22 lines
const [message, setMessage] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [contexts, setContexts] = useState<ContextChip[]>([
  { id: 'calendar', label: 'Calendar', icon: <IconCalendar1 /> },
]);

<ChatComposed
  accept={{ 'image/*': ['.jpg', '.jpeg', '.png'] }}
  attachments={attachments}
  contexts={contexts}
  inputValue={message}
  items={items}
  maxFiles={4}
  onAttachmentsChange={setAttachments}
  onContextRemove={(id) => setContexts((prev) => prev.filter((c) => c.id !== id))}
  onInputChange={setMessage}
  onSubmit={(payload) => {
    sendMessage(payload);
    setMessage('');
  }}
  placeholder="Ask about a stay…"
/>

Props

items:

ChatItem[]
Ordered transcript of messages and tool-response bubbles. Each entry is either a ChatMessageItem or a ChatToolItem (discriminated by the presence of a tool field).

inputValue:

string
Controlled textarea value for the composer.

onInputChange:

(value: string) => void
Called whenever the composer textarea value changes.

attachments?:

Attachment[]
Controlled list of image attachments shown in the composer.

onAttachmentsChange?:

(attachments: Attachment[]) => void
Called whenever the composer attachments list changes (add or remove).

contexts?:

ContextChip[]
Controlled list of context chips rendered above the textarea.

onContextRemove?:

(id: string) => void
Called when a context chip's remove button is clicked.

onSubmit?:

(payload: ChatInputSubmitPayload) => void
Called when the user submits a message (Enter without Shift, or the send button). Receives the trimmed message plus snapshots of attachments and contexts.

placeholder?:

string
Placeholder text rendered in the composer textarea.

accept?:

Accept
MIME type / extension map forwarded to the file picker (from react-dropzone).

maxFiles?:

number
Maximum number of files accepted at once by the composer.

header?:

ReactNode
Optional content rendered above the transcript.

emptyState?:

ReactNode
Optional content rendered in place of the transcript when items is empty. The composer is still rendered.

showNewMessageButton?:

boolean
When false, suppresses the floating "new messages" pill that appears after scrolling away from the bottom. Defaults to true.

newMessageLabel?:

string
Visible label for the "new messages" pill. Defaults to "New messages".

activeQuestion?:

{ kind: "multi-choice", ...ChatMultiChoiceQuestionProps } | { kind: "multi-option", ...ChatMultiOptionQuestionProps }
Active assistant tool call awaiting a user response. When set, replaces the composer with the corresponding question component. The question is responsible for calling its own onSubmit to clear this state upstream and append a ChatToolItem to items.

renderResponse?:

(item: ChatToolItem) => ReactNode
Custom renderer for ChatToolItem user-response bubbles. Defaults to a `Q: {question}` / `A: {selected}` user bubble.

className?:

string
Additional CSS classes for the root element.

ChatItem

A discriminated union — either a ChatMessageItem (standard message) or a ChatToolItem (inline tool turn).

1 lines
type ChatItem = ChatMessageItem | ChatToolItem;

ChatMessageItem

id:

string
Stable identifier used as the React key.

from:

"user" | "assistant"
Author. Drives alignment and bubble vs. prose styling.

content:

ReactNode | string
Body content. A string is rendered as plain text (or markdown when markdown is true). A ReactNode is rendered verbatim.

markdown?:

boolean
When true, treat content as a markdown string and render it through Markdown. Ignored when content is not a string.

actions?:

ChatMessageActionItem[]
Optional row of icon actions rendered below the message (copy, regenerate, thumbs up/down, etc.).

avatar?:

ReactNode
Optional avatar rendered alongside the message body.

status?:

"thinking"
When "thinking", renders a ChatThinking shimmer in place of the message body.

live?:

boolean
When true, marks this message as freshly arriving so screen readers announce the settled text once streaming stops. Set only on the most recent streaming message.

ChatToolItem

A snapshot of the user's response to a past tool call. Rendered as a right-aligned user bubble showing the original question and the selected answer for transcript context. Live, unanswered questions belong on activeQuestion instead.

id:

string
Stable identifier used as the React key.

from:

"user"
Always "user" — this is the user's response to a past tool call.

tool:

{ kind: "multi-choice", question: string, selected: string } | { kind: "multi-option", question: string, selected: string[] }
Snapshot of the question and the user's answer. `selected` is the display label (or labels for multi-option). Multi-choice "other" free-text values are passed through as the `selected` string directly.
Chat