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

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>

[!IMPORTANT] 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.

[!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 root (.chat-container) 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 root 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>

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:

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

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

<ChatArtifactLayout
  open={selectedArtifact !== undefined}
  panelTitle={selectedArtifact?.title}
  onClose={() => (selectedArtifact = undefined)}
>
  <Chat id="assistant-chat" {conversation}>
    {#snippet messageActions({ artifact }: ChatRowContext)}
      {#if artifact}
        <Button size="xs" variant="ghost" onclick={() => (selectedArtifact = artifact)}>
          Open artifact
        </Button>
      {/if}
    {/snippet}
  </Chat>

  {#snippet panel()}
    {#if selectedArtifact}
      <ArtifactViewer {...selectedArtifact} />
    {/if}
  {/snippet}
</ChatArtifactLayout>

messageActions is rendered in the same action row as Chat's built-in copy and edit controls. For native buttons, 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.

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
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.

Interactive harness

Drive Chat from a control panel: reply as the other side (instant / typing / streaming), inject tool calls, toggle features, and watch every callback fire.

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 Required Bindable Description
conversation ConversationHistory req 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 boolean true bind Whether the message viewport is scrolled to the bottom. Bindable; updated automatically as the user scrolls. Default true.
unreadCount number 0 bind 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 boolean false bind Whether the "new messages" indicator is currently visible above the composer. Bindable; cleared automatically when the viewport reaches the bottom. Default false.