Domain

Chat

Opinionated conversation surface bundling message list, composer, attachments, and scroll affordances for AI or support transcripts.

import { Chat } from '@lostgradient/chat';
chatconversationdomain-suite
01

Overview

Reference usage

Opinionated conversation surface bundling message list, composer, attachments, and scroll affordances for AI or support transcripts.

Usage

svelte
<script lang="ts">
  import {
    Chat,
    appendAssistantMessage,
    appendUserMessage,
    createConversation,
  } from '@lostgradient/chat';

  const conversation = appendAssistantMessage(
    appendUserMessage(createConversation({ id: 'demo' }), 'Can you summarize this plan?'),
    'The plan is ready to implement.',
  );
</script>

<!-- Chat fills its parent; give the ancestor a definite height. -->
<div style="height: 34rem;">
  <Chat id="demo-chat" {conversation} />
</div>

Chat needs a definite-height ancestor Chat's root element fills its parent (

height: 100%). Place it inside an ancestor with a resolved height—a fixed height, a flex or grid cell, or height: 100dvh—or the message viewport collapses and Chat renders as a small card instead of filling its space. See Layout and sizing.

Adapter-driven streaming

ChatAdapter.sendMessage deliberately stays a Promise<void> command. The adapter owns the conversation snapshot, while Chat owns the transient visual stream buffer. Pair the public streaming builders with the imperative instance methods so both surfaces advance together:

svelte
<script lang="ts">
  import {
    Chat,
    appendStreamingMessage,
    appendUserMessage,
    cancelStreamingMessage,
    createConversation,
    finalizeStreamingMessage,
    updateStreamingMessage,
    type ChatAdapter,
    type ConversationHistory,
  } from '@lostgradient/chat';

  let chat: ReturnType<typeof Chat> | undefined;
  let conversation = $state<ConversationHistory>(createConversation({ id: 'streaming' }));
  let streaming = $state(false);

  const adapter: ChatAdapter = {
    async sendMessage(message, _attachments) {
      conversation = appendUserMessage(conversation, message.content);
      const { conversation: started, messageId } = appendStreamingMessage(
        conversation,
        'assistant',
      );
      conversation = started;
      streaming = true;
      chat?.beginStreaming(messageId);

      let content = '';
      try {
        for await (const chunk of streamFromYourBackend(message)) {
          content += chunk;
          conversation = updateStreamingMessage(conversation, messageId, content);
          chat?.pushToken(chunk);
        }
        conversation = finalizeStreamingMessage(conversation, messageId);
      } catch (error) {
        conversation = cancelStreamingMessage(conversation, messageId);
        throw error;
      } finally {
        chat?.endStreaming();
        streaming = false;
      }
    },
  };
</script>

<Chat
  bind:this={chat}
  id="adapter-streaming-chat"
  {conversation}
  {adapter}
  {streaming}
  capabilities={{ attachments: false }}
/>

The assistant placeholder must exist before beginStreaming; update the same snapshot for each token, finalize it before endStreaming, and cancel it when the backend fails. The package's Adapter-driven streaming example uses a complete finite stream without assuming a particular model provider.

Failed delivery and retry

ChatAdapter.sendMessage receives a MessageInput, not a persisted Message, so it has no message ID. Append the optimistic user message to the authoritative snapshot first and retain the generated ID. Chat awaits adapter commands and reports rejected commands through onAdapterError; adapter implementations should mark delivery status and then rethrow so Chat can handle the error consistently:

ts
import {
  appendUserMessage,
  clearMessageDeliveryStatus,
  markMessageDeliveryFailed,
} from '@lostgradient/chat';

// Inside your ChatAdapter implementation:
async sendMessage(message, attachments) {
  conversation = appendUserMessage(conversation, message.content);
  const messageId = conversation.ids.at(-1)!;
  try {
    await yourBackend.send(message, attachments);
    conversation = clearMessageDeliveryStatus(conversation, messageId);
  } catch (error) {
    conversation = markMessageDeliveryFailed(conversation, messageId);
    throw error; // rethrow so Chat routes it to onAdapterError
  }
}

The helpers immutably set or clear the transient _deliveryStatus metadata used by Chat's failed-message label and Retry button. Apply the same pair around retryMessage and editMessage resends; the successful attempt clears the marker.

Follow-ups during a run

By default the composer is disabled while streaming is true, and the session controller refuses a second turn while a run is working or a tool approval is pending. A host that owns the run on the server, and can deliver input to it in flight, opts in with sendRunInput. Follow-ups are supported only for server-owned runs (executionOwner: 'server'): with a browser-owned run the controller does not expose the hook, the composer does not offer follow-ups, and submitting during the run behaves as it did before. There are two hooks, and they are different types:

  • ChatAdapter.sendRunInput(conversationId, message, attachments) is the component-facing hook. It takes the same message and attachments as sendMessage, preceded by the conversation id. A raw adapter used without the controller gets no row id, and correlating its follow-ups is the host's problem.
  • ChatSessionControllerOptions.sendRunInput(conversationId, message, attachments, { messageId }) is the host-facing hook. The controller appends a transcript row for the follow-up and fills messageId with that row's id, so a host can pass it on as the session input's own id (submitSessionInput accepts a caller-supplied id). A retry of the same row passes the same id. Providing it makes controller.adapter expose the adapter hook.
ts
import { assertRunInputAccepted } from '@lostgradient/chat';

const session = createChatSessionController({
  getConversation: () => conversation,
  setConversation: (next) => (conversation = next),
  transport,
  executionOwner: 'server',
  async sendRunInput(conversationId, message, attachments, { messageId }) {
    const result = await yourBackend.submitSessionInput({
      id: messageId,
      conversationId,
      message,
      attachments,
    });
    if (!result.receipt) throw new Error(`Input was not admitted: ${result.outcome}`);
    assertRunInputAccepted(result.receipt);
  },
});

The promise resolving is the acknowledgement, and it resolves only when the input's receipt is accepted, queued, or promoted. Every other receipt rejects with an error that names its state, so the row is marked failed with a Retry button. assertRunInputAccepted(receipt) does this for you. Read the receipt, not just the submission outcome: a canceled receipt from an immediate self-cancel (no run is live) and a replayed outcome whose receipt is already canceled both reject. Admission is not delivery, because the server cancels input a run ends without delivering it, and never answers it in a continuation run.

With the hook present:

  • The textarea stays enabled during a run, Send and Stop sit side by side, and Enter submits a follow-up.
  • The follow-up appears in the transcript at once as a row stamped metadata._followUp, with a pending "Sending…" state that the acknowledgement clears.
  • Follow-ups are handed over one at a time, in the order they were sent, so a slow acknowledgement cannot be overtaken. Chat also serializes calls to a raw adapter the same way.
  • A follow-up is accepted while an approval is pending and does not resolve it.
  • A follow-up row never owns the run. Retrying after a failed run, finding the owner of a tool call that arrives after a follow-up, an approval continuation's turn and attachments, and the AG-UI transport's choice of turn all stay with the turn that opened the run. Rows without the marker are not follow-ups, including rows installed with adoptHistory.
  • When the run's terminal history names the follow-up's row, the transcript keeps one row, moved to the position the terminal gives it, and the client's row wins, so its marker and delivery status survive.
  • A rejection marks that row failed with the usual Retry and leaves the run alone. Retrying resends it through the hook with the same messageId while a run is in progress, and otherwise promotes it to an ordinary turn.
  • A follow-up still waiting for the hook when the run ends (queued behind a slow acknowledgement) goes out as an ordinary turn on its existing row when the run completes or fails, one after another in the order sent. If the user stopped the run, it is marked failed with a Retry instead. A run parked on a pending approval has not ended.
  • Once a run ends, each follow-up the host accepted is classified. The run delivered it when its terminal history (the history a run.completed frame carries) names the row's id. Otherwise the server cancelled it, and that includes a run that ended with no terminal history at all, such as a stop mid-stream or a failure. If this client pressed stop, the row is marked failed with a Retry. If the run completed or failed, the row is promoted to an ordinary turn after the run ends, reusing its row and attachments. The decision waits for both the acknowledgement and the run's end, in either order, so an acknowledgement that arrives late is still classified. A follow-up the hook rejects is failed as above.
  • controller.invalidate() retires every acknowledgement still owed: a late acknowledgement or rejection writes nothing, and the sender's promise rejects with an invalidation error. A follow-up still queued when it happens is marked failed and reported through onError. A stop does not retire anything.
  • The controller's adapter orders its own input (ChatAdapter.ordersRunInput), so Chat hands it each follow-up at once and the row shows immediately; for a raw adapter, Chat queues the calls itself, one at a time, and drops that queue when the conversation changes.
  • A follow-up queued when the conversation changes is rejected through onAdapterError rather than dropped silently.

A controller without sendRunInput, or with a browser-owned run, behaves exactly as before. The "Follow-ups during a run" example shows the flow.

Edit and resend

ChatAdapter.editMessage hands you the edited message's ID and new content; the superseded branch (the old reply, any tool calls after it) should be discarded before the edited content is re-sent. rewindBeforeMessage performs exactly that cut — it drops the named message and everything after it in the transcript, keeps tool-call/tool-result pairs atomic, and renumbers positions — so an edit flow never assembles ids/messages/updatedAt by hand:

ts
import { appendUserMessage, rewindBeforeMessage } from '@lostgradient/chat';

// Inside your ChatAdapter implementation:
async editMessage({ messageId, content }) {
  conversation = appendUserMessage(rewindBeforeMessage(conversation, messageId), content);
  await yourBackend.send({ role: 'user', content });
}

rewindBeforePosition is the position-keyed form, and both accept a RewindOptions bag (preserveToolPairs: false cuts strictly at the boundary, leaving a straddled tool call pending).

Transcript mutation and query helpers

@lostgradient/chat re-exports Conversationalist's canonical transcript mutation and query helpers so consumers never hand-walk conversation.ids / conversation.messages or import conversationalist directly:

ts
import {
  findToolResultMessage,
  getMessages,
  getUnresolvedToolApprovals,
  removeMessage,
  replaceToolResult,
  setMessageHidden,
  updateMessage,
} from '@lostgradient/chat';

// Edit a message's content in place, keeping identity, role, order, and
// creation time intact.
conversation = updateMessage(conversation, messageId, { content: 'Edited' });

// Hide or reveal a message without removing it.
conversation = setMessageHidden(conversation, messageId, true);

// Remove a message and renumber the surviving positions.
conversation = removeMessage(conversation, messageId);

// Swap in the final outcome for a tool call that was parked on
// `action_required` once the consumer approves or denies it.
conversation = replaceToolResult(conversation, toolCallId, {
  callId: toolCallId,
  outcome: 'success',
  content: { approved: true },
});

updateMessage, removeMessage, setMessageHidden, and replaceToolResult are no-ops—they return the original history unchanged—when given an unknown message or tool-call identifier, so callers do not need to guard against a stale ID.

getMessages(conversation, { includeHidden? }) returns the ordered, non-hidden messages Chat renders (pass includeHidden: true to include hidden ones too). getUnresolvedToolApprovals(conversation) finds every tool-result message still parked on outcome: 'action_required' with a pending action—the transcript-level source of truth for "does this conversation have an approval waiting," independent of any UI-only approved/denied state. findToolResultMessage(conversation, toolCallId) locates the tool-result message for a given tool-call ID, which replaceToolResult needs. Both query helpers accept the same { includeHidden? } option as getMessages.

Warning

ChatAdapter.subscribe runs inside Chat's own effect Chat opens subscribe from inside its internal mount $effect, so a synchronous $state write inside subscribe can throw effect_update_depth_exceeded. Defer it with queueMicrotask or tick(). The same applies if subscribe replays a buffered event by calling a handler (onMessage, onTypingChange, etc.) synchronously before returning — defer the CALL to the handler itself, not just any write of your own, since onTypingChange/onReadReceipt write Chat's own internal state before your code runs at all. See the ChatAdapter.subscribe and ChatPushHandlers JSDoc for the full explanation and working examples.

Conversation data contract

Chat declares @lostgradient/cinder and svelte as peer dependencies — host applications install those alongside @lostgradient/chat. conversationalist (and its own zod dependency) is an implementation detail Chat owns: it ships as a regular dependency of @lostgradient/chat and installs automatically, so using Chat never requires a host application to add or version-pick it. Import the conversation types, builders, CURRENT_SCHEMA_VERSION, and isJSONValue from @lostgradient/chat rather than importing conversationalist yourself. An application that uses conversationalist APIs beyond what Chat re-exports may still depend on it directly — that is supported, it just is not something Chat requires.

The exported schema version comes from the conversationalist version Chat depends on. Histories produced by an older compatible schema can render as-is; a newer schema causes Chat to emit a console warning and requires upgrading @lostgradient/chat before relying on that history.

Layout and sizing

Chat's outer layout element is height: 100%. When no ancestor on the chain resolves to a definite height, that 100% resolves against auto and Chat collapses to the intrinsic height of its empty state plus composer. There is no browser error: it just silently shrinks to a small card. Give Chat a sized ancestor with one of the three patterns below.

Fixed-height container. The simplest option—wrap Chat in an element with an explicit height. This is what every example in this component uses.

svelte
<div style="height: 34rem;">
  <Chat id="support-chat" {conversation} />
</div>

Full-viewport flex column. For an app shell where Chat should fill the remaining space, give the column a definite height and let the Chat cell flex: 1 with min-height: 0 (so it can shrink below its content and scroll internally rather than overflowing the page).

svelte
<div class="chat-page">
  <header>…</header>
  <Chat id="app-chat" class="chat-page-surface" {conversation} />
</div>

<style>
  .chat-page {
    display: flex;
    flex-direction: column;
    height: 100dvh;
  }

  /* `:global` because the class lands on Chat's own outer layout element. */
  .chat-page :global(.chat-page-surface) {
    flex: 1;
    min-height: 0;
  }
</style>

Grid cell. The same shape works in a grid: give the container a resolved track (grid-template-rows: auto 1fr), place Chat in the 1fr row, and keep min-height: 0 on the Chat cell so it can shrink and scroll internally instead of overflowing the page.

svelte
<div class="chat-grid">
  <header>…</header>
  <Chat id="grid-chat" class="chat-grid-surface" {conversation} />
</div>

<style>
  .chat-grid {
    display: grid;
    grid-template-rows: auto 1fr;
    height: 100dvh;
  }

  .chat-grid :global(.chat-grid-surface) {
    min-height: 0;
  }
</style>

Quoting agent messages

A reader can reply to part of an agent message, or to several parts at once. Quoting needs no props and no adapter support: the quotes travel as Markdown in the message itself, so the model sees them, and a reloaded transcript shows them.

  • Selecting text inside one assistant message's content opens a Quote popover (Cinder's SelectionPopover). A selection that spans messages, or reaches outside assistant content, opens nothing.
  • Every assistant message also has a Quote message action in its action row. It quotes the current selection when that selection lies inside the message, and otherwise the message's whole rendered text, so quoting works from the keyboard.
  • Each quote appears as a chip above the composer showing its first 80 characters, with a Remove button. Chips keep the order they were added in, and quoting the same text twice adds it once.
  • A reply carries at most 10 quotes. At 10, both Quote actions are disabled and the chip row says why. A quote longer than 4,000 characters is cut to 3,999 characters plus an ellipsis. Surrounding whitespace is trimmed from every quote.

A quote is the selected text exactly as the browser reports it (Selection.toString()), not a link back to the message it came from, and Chat sends no quote metadata. Sending puts the quotes at the top of the message as Markdown block quotes: every line is prefixed with > , quotes are separated by one blank line, and one more blank line precedes the typed text.

md
> alpha is the first item we track.

> It was added before every other item. That is why it leads.

Why does order matter here?

The composed text goes through ChatAdapter.sendMessage (or onSubmit), or through sendRunInput when a run is active, exactly as a plain reply would. ChatSubmitEvent.message.content and the adapter's message.content already hold the composed text. The composer still needs typed text or an attachment to send, so quotes alone cannot be sent. With an attachment and no typed text, the content ends at the last quote.

The quotes leave the chip row the moment the message is handed off, not when the send finishes. The session controller settles sendMessage only when the whole run ends, so a follow-up sent during that run would otherwise repeat them. If the send fails, the quotes come back ahead of any chips added since, in their original order and without repeating any text. When they would not all fit within the limit of 10, the first 10 stay and Chat announces how many were dropped. A send that fails after the conversation has changed restores nothing. onSubmit is not awaited, so only a synchronous throw from it counts as a failure. Changing the conversation clears the chips.

Quoting is on by default. capabilities.quote set to false removes the Quote message action and the selection popover, and clears any chips.

Quoting covers assistant messages only. User messages and tool output offer no Quote action, and a selection inside them opens no popover. The "Quoting agent messages" example shows the flow.

Composer actions

composerActions is an optional snippet rendered in the composer footer, beside Send, so a host can put its own controls next to the composer; the usual case is a model picker built from Cinder's Select (or Combobox). Chat does not ship a picker: the host owns the value and what it means.

svelte
<script lang="ts">
  import { Chat } from '@lostgradient/chat';
  import { Select } from '@lostgradient/cinder';

  let model = $state('balanced');
</script>

<Chat id="assistant-chat" {conversation} {adapter}>
  {#snippet composerActions()}
    <Select
      id="assistant-chat-model"
      bind:value={model}
      options={models}
      label="Model"
      labelVisible={false}
    />
  {/snippet}
</Chat>

The snippet takes no arguments. It stays interactive while a run streams and while a tool approval is pending, because Chat only disables the composer textarea, never the footer; the host decides whether a control should lock during a run. Without the snippet the composer renders as it always has. The "Composer actions" example shows the whole flow.

Building composer overlays

Slash-command, mention, and autocomplete overlays should use Chat's composer API instead of querying .chat-input-editor directly.

Use bind:this to read or control the composer:

svelte
<script lang="ts">
  import { Chat } from '@lostgradient/chat';

  let chat: ReturnType<typeof Chat> | undefined;
</script>

<Chat
  bind:this={chat}
  id="assistant-chat"
  {conversation}
  onComposerInput={(value) => updateOverlayQuery(value)}
  oncomposerkeydown={(event) => {
    if (!overlayOpen) return;

    if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
      event.preventDefault();
      handleOverlayKey(event);
    }
  }}
/>

getComposerValue() returns the current plain-text value, clearInput() clears it, and getEditorElement() returns the textarea element (or null before mount and after teardown) for overlay anchoring and focus management. insertAtRange({ start, end }, text) replaces that composer range without a synthetic DOM event, focuses the textarea, and places the caret after the inserted text. It uses native HTMLTextAreaElement.setRangeText() boundaries: out-of-bounds indexes are clamped and a reversed range throws a DOMException. Calls before mount or after teardown are safe no-ops.

oncomposerkeydown runs before Chat's internal Enter-to-send handling for normal composer keydowns. If the callback calls event.preventDefault(), Chat skips its internal key handling for that event. Chat does not call the hook during IME composition, so Enter can still confirm the active candidate instead of sending or being consumed by an overlay.

For ARIA combobox patterns, pass textarea-specific attributes through the composer-prefixed props:

svelte
<Chat
  id="assistant-chat"
  {conversation}
  composerRole="combobox"
  composerAriaExpanded={overlayOpen ? 'true' : 'false'}
  composerAriaControls="slash-command-listbox"
  composerAriaActiveDescendant={activeOptionId}
  composerAriaAutocomplete="list"
/>

Announcing custom action-required rows

Chat owns always-rendered polite and assertive live regions outside the role="log" timeline. If you render action-required UI through a custom row or messagePart snippet, announce it through Chat instead of mounting another aria-live region inside the log:

svelte
<script lang="ts">
  import { Chat } from '@lostgradient/chat';

  let chat: ReturnType<typeof Chat> | undefined;

  function showCustomApproval() {
    chat?.announce('Action required: Review the deployment approval.', 'assertive');
  }
</script>

<Chat bind:this={chat} id="assistant-chat" {conversation} row={customRow} />

Use the default polite channel for non-urgent status updates:

ts
chat?.announce('Attachment scan finished.');

Consumer announcements clear after a short interval so Chat's own history, unread, typing, and tool-approval announcements can continue to flow. Built-in tool-approval rows keep precedence on the assertive channel. If a consumer assertive announcement races with Chat's derived Action required: ... tool-approval announcement, Chat announces the built-in tool approval and drops the consumer assertive text to avoid double output.

Per-row snippet context

row, messageActions, and messageStatus receive the same ChatRowContext. The context contains the message that owns the visible row and optional resolved toolCallPair and artifact values. When a row has cinder:artifact metadata, Chat renders an always-visible Open artifact: <title> row action and opens the built-in artifact panel by default:

svelte
<script lang="ts">
  import { Chat, type ChatRowContext, type ResolvedChatArtifact } from '@lostgradient/chat';
  import { Button } from '@lostgradient/cinder';

  let selectedArtifact = $state<ResolvedChatArtifact | undefined>();
</script>

<Chat
  id="assistant-chat"
  {conversation}
  onArtifactOpen={(artifact) => (selectedArtifact = artifact)}
>
  {#snippet messageActions({ artifact }: ChatRowContext)}
    {#if artifact}
      <Button size="xs" variant="ghost" onclick={() => (selectedArtifact = artifact)}>
        Track externally
      </Button>
    {/if}
  {/snippet}
</Chat>

onArtifactOpen is an ownership override: when supplied, Chat calls it with the resolved artifact and does not open the internal panel. Omit it to use Chat's built-in ChatArtifactLayout, ArtifactPanel, and ArtifactViewer composition. The resolved artifact keeps the raw descriptor fields and adds id from the source message plus a normalized non-empty title (Artifact when metadata is blank).

messageActions is rendered in the same hover/focus action row as Chat's built-in copy and edit controls. Artifact actions are separate persistent row actions. For native buttons in messageActions, add the shared chat-message-action-button class to opt into the action-row sizing, focus, hover, and touch styles:

svelte
<script lang="ts">
  let panelOpen = $state(false);
</script>

{#snippet messageActions()}
  <button type="button" class="chat-message-action-button" onclick={() => (panelOpen = true)}>
    Open panel
  </button>
{/snippet}

The class is intentionally opt-in so consumers can use their own button component or visual treatment without Chat overriding it. Cinder Button instances already provide their own complete styling and do not need this class.

A paired tool-result message does not render a second visible row and does not invoke the snippets separately. Chat folds that result into the corresponding tool-call row's toolCallPair and resolves cinder:artifact metadata from the folded result into artifact. Metadata on the visible message takes precedence. Ordinary rows and unpaired tool-result rows resolve their own artifact metadata, so consumers do not need an external message lookup or a hand-rolled type guard. Artifact identity always comes from the source message id, so duplicate titles and duplicate content remain independent.

Guidance

Use When

  • Shipping a full chat surface with composer, scroll-anchor, unread indicator, and attachments bundled as one heavyweight drop-in.
  • Building an AI assistant or support thread where conversation state is modeled as a transcript of role-tagged messages.

Avoid When

  • Rendering a one-off message list — compose lighter primitives directly instead of pulling the full suite.
  • The transcript is read-only and needs no composer — a simple list of message bubbles is a better fit.

Attachment serialization

When capabilities.attachments is enabled, onSubmit receives ready ChatAttachment[] values. Use serializeChatAttachment() or serializeChatAttachments() from @lostgradient/chat to convert those files into transportable base64 payloads without spreading the full byte array onto the JavaScript stack.

ts
import { serializeChatAttachments } from '@lostgradient/chat';

const attachments = await serializeChatAttachments(chatAttachments);

Each serialized attachment has this shape:

ts
type SerializedChatAttachment = {
  name: string;
  mimeType: string;
  kind: ChatAttachment['kind'];
  content: string;
};

The output is intentionally the base64 source payload for conversationalist's proposed DocumentContent bridge in stevekinney/agent-bureau#153. A consumer can wrap it as { type: 'document', name, mimeType, source: { kind: 'base64', data: content } } when adding composer attachments to conversation state.

Live preview

Adapter-driven streaming

No messages yet

0 messages in conversation

02

When to use

Use when
  • Shipping a full chat surface with composer, scroll-anchor, unread indicator, and attachments bundled as one heavyweight drop-in.
  • Building an AI assistant or support thread where conversation state is modeled as a transcript of role-tagged messages.
  • Embedding Chat in a definite-height flex or grid region where its internal transcript should own scrolling.
Avoid when
  • Rendering a one-off message list — compose lighter primitives directly instead of pulling the full suite.
  • The transcript is read-only and needs no composer — a simple list of message bubbles is a better fit.
03

Examples

Adapter-driven streaming

Use ChatAdapter.sendMessage with the public streaming builders while keeping the conversation snapshot synchronized with Chat's imperative stream.

Basic chat

A conversation with editable input and markdown rendering.

Density and variant

Compare comfortable vs compact density and bubble vs flat visual variant on the same conversationalist-shaped transcript.

Full-height layout

Place Chat in a definite-height flex or grid cell with min-height: 0 so the transcript owns the scroll region.

Streaming chat

Streaming status keeps the composer in stop mode.

With reasoning and steps

Assistant messages can surface a collapsible reasoning block (extended thinking) and an ordered step list before the final answer. Both are UI-only overlays derived from message metadata — the underlying transcript is unchanged.

With suggested replies

After the final assistant message, suggested follow-up labels appear as clickable chips. Clicking a chip calls onSuggestionSelect(label). The chips are derived from message metadata — the underlying transcript is unchanged.

With tool approval

Action-required tool results render as approval prompts the user must accept or reject before the tool can continue.

With tool calls

Tool results fold into the visible row with validated artifact metadata for opening a panel.

04

Props

Props for chat
Name
Type
Default
Description
conversation required ConversationHistory The conversation transcript to render. Pass a {@link ConversationHistory} snapshot; consumers holding a stateful conversation object pass its current snapshot (e.g. conversation.current).
atBottom bindable boolean true Whether the message viewport is scrolled to the bottom. Bindable; updated automatically as the user scrolls. Default true.
unreadCount bindable number 0 Number of messages received while the viewport was scrolled away from the bottom. Bindable; resets to 0 when the user scrolls to the bottom. Default 0.
newMessageIndicatorVisible bindable boolean false Whether the "new messages" indicator is currently visible above the composer. Bindable; cleared automatically when the viewport reaches the bottom. Default false.