---
title: Conversation Stream
description: A virtualized conversation scroller that sticks to the bottom and loads history.
category: AiChat
status: beta
since: 0.3.9
tags: [ai, conversation, virtual-scroll]
syncStatus: reviewed
verified: true
---

## Usage

### Basic
::::TuffDemoWrapper{demo="ConversationStreamConversationStreamDemo" code-lang="vue"}
---
code: |
  <script setup lang="ts">
  import { ref } from 'vue'

  const items = ref([{ id: '1', text: 'Hello' }])

  async function loadOlder() {
    const older = await fetchOlder()
    items.value = [...older, ...items.value]
    return { hasMore: older.length > 0 }
  }
  </script>

  <template>
    <TxConversationStream
      :items="items"
      :item-key="item => item.id"
      :load-older="loadOlder"
      streaming
    >
      <template #item="{ item }">
        <div>{{ item.text }}</div>
      </template>
    </TxConversationStream>
  </template>
---
::::

### Best Practices

- Key by the message's own id, never the index; virtualization depends on stable keys.
- Prepend inside `loadOlder`, then resolve `{ hasMore }`; the component never owns the array.
- Pass `hasMoreInitial: false` when you know there is no history; the default `undefined` means unknown.
- Keep `estimatedItemHeight` close to the real average so the scrollbar doesn't jump on first paint.
- Scroll programmatically through the exposed `scrollToBottom` and `scrollToIndex`, not the scroll container.

## API Reference

### Props

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `items` | `T[]` | — | The messages. Required. |
| `itemKey` | `ConversationStreamItemKey<T>` | — | Stable key: a field name, or a function returning the key. Required. |
| `estimatedItemHeight` | `number` | `96` | Height assumed for unmeasured items, corrected once measured. |
| `overscan` | `number` | `4` | Extra items rendered on each side of the viewport. |
| `loadOlder` | `() => Promise<ConversationStreamLoadResult>` | — | Called near the top; prepend into `items`, then resolve `{ hasMore }`. |
| `hasMoreInitial` | `boolean` | `undefined` | Whether history may exist before the first `loadOlder` answers. |
| `streaming` | `boolean` | `false` | Drives the scroll-to-bottom button's new-content state. |

### Events

| Name | Payload | Description |
|------|---------|-------------|
| `at-bottom-change` | `(atBottom: boolean)` | Fires when the at-bottom state changes. |
| `load-error` | `(error: unknown)` | Fires when `loadOlder` throws. |

### Slots

| Name | Scope | Description |
|------|-------|-------------|
| `item` | `{ item: T, index: number }` | Renders one message. |
| `empty` | — | Shown when `items` is empty. |
| `top-loading` | — | Shown while older messages load. |
| `top-error` | `{ retry: () => void }` | Shown when loading fails, with a retry callback. |
| `top-done` | — | Shown when there is no more history. |
| `scroll-to-bottom` | `{ streaming: boolean }` | Replaces the scroll-to-bottom button's content. |

### Exposed Methods

| Name | Type | Description |
|------|------|-------------|
| `scrollToBottom` | `(behavior?: ScrollBehavior) => void` | Scrolls to the bottom. |
| `scrollToIndex` | `(index: number) => void` | Scrolls to an index. |
| `tweenToBottom` | `(duration?: number) => Promise<boolean>` | Glides to the bottom over a fixed duration; resolves `false` if interrupted. |
| `atBottom` | `boolean` | Whether the view is at the bottom. Read-only. |

## Overview

- The component is generic over `T`: the element type of `items` flows to the `item` slot's scope without casts.
- At the bottom, new content is followed; once the user scrolls up, the view stays put and shows a scroll-to-bottom button.
- `streaming` only drives the button's new-content state; it doesn't decide whether the view sticks.
- While the stream glides to the bottom on its own (after a send, or a programmatic scroll), the button doesn't show.
- A prepend keeps the viewport anchored.
- `hasMoreInitial` defaults to an explicit `undefined`, not `false`, so "unknown" stays distinct from "no history".

## Technologies

- The virtual window takes `{ start, end }` from a position cache (a prefix sum of measured heights, kept by key); a scroll frame that crosses no row boundary keeps the previous window and doesn't re-invoke the `item` slot.
- Source: `packages/tuffex/packages/components/src/conversation-stream/`.
