

## Overview [#overview]

Message rendering is variant-agnostic. The same primitives work in floating, sidebar, and fullscreen. The key components are:

* **`Chat.MessageList`** — Scroll container with stick-to-bottom behavior and empty state
* **`Chat.MessageEntry`** — Role-based dispatch (user, agent, system)
* **`Chat.ChunkList`** — Renders an array of `ChunkRow` objects through the chunk registry
* **`Chat.ScrollAnchor`*&#x2A; / &#x2A;*`Chat.ScrollToBottom`** — Scroll utilities

## Message List [#message-list]

`Chat.MessageList` wraps the scroll container with automatic stick-to-bottom behavior. It handles empty state rendering when no children are provided.

```tsx
<Chat.MessageList>
  {messages.map((msg) => (
    <MessageRow key={msg.id} messageId={msg.id} />
  ))}
</Chat.MessageList>
```

When the children array is empty, the list renders a default empty state. The scroll container automatically pins to the bottom as new messages arrive and shows a scroll-to-bottom button when the user scrolls up.

## Message Entry [#message-entry]

`Chat.MessageEntry` dispatches to the correct renderer based on `message.role`:

| Role     | Renderer        | Content                                                  |
| -------- | --------------- | -------------------------------------------------------- |
| `user`   | `MessageUser`   | `bodyHtml` + attachment chips                            |
| `agent`  | `MessageAgent`  | `ChunkList` + action bar (copy, download, retry, delete) |
| `system` | `MessageSystem` | `ChunkList` (no action bar)                              |

### Colocated queries (TanStack DB example) [#colocated-queries-tanstack-db-example]

Colocate queries at the message level for optimal re-render boundaries:

```tsx
import { memo } from "react";
import { Chat } from "@tilt-legal/cubitt-components/composites";
import { useLiveQuery } from "@tanstack/react-db";

const MessageRow = memo(function MessageRow({ messageId }) {
  const { data: [message] = [] } = useLiveQuery((q) =>
    q
      .from({ m: messagesCollection })
      .where(({ m }) => m.id === messageId)
      .select(({ m }) => m),
  );

  const { data: chunks = [] } = useLiveQuery((q) =>
    q
      .from({ c: chunksCollection })
      .where(({ c }) => c.messageId === messageId)
      .orderBy(({ c }) => c.seq, "asc")
      .select(({ c }) => c),
  );

  if (!message) return null;

  return (
    <Chat.MessageEntry
      message={message}
      chunks={chunks}
      onFileOpen={handleFileOpen}
      onRetry={handleRetry}
      onDelete={handleDelete}
      onAction={handleAction}
      onCancelStream={handleCancelStream}
    />
  );
});
```

### Props [#props]

| Prop      | Type              | Description           |
| --------- | ----------------- | --------------------- |
| `message` | `ChatMessageView` | Message metadata (id, |

role,
status,
bodyHtml,
attachments) |
\| `chunks` | `readonly ChunkRow[]` | Chunk rows for this message (agent/system only) |
\| `onFileOpen` | `(fileId,
  fileName) => void` | File attachment click handler |
\| `onRetry` | `(messageId) => void` | Retry handler (shows retry button on agent messages) |
\| `onDelete` | `(messageId) => void` | Delete handler (shows delete button on agent messages) |
\| `onAction` | `(action,
  ctx) => void` | Action handler for copy/download |
\| `onCancelStream` | `(messageId) => void` | Cancel streaming handler |
\| `className` | `string` | Additional CSS classes |

### Error State [#error-state]

When an agent message has `status: "error"`,
`MessageEntry` renders a destructive alert with a retry button (if `onRetry` is provided):

```
⚠ Response interrupted
The agent couldn't finish generating this reply. [Retry]
```

## Chunk List [#chunk-list]

`Chat.ChunkList` renders an array of `ChunkRow` objects by looking up each chunk's `kind` in the chunk registry:

```tsx
import { Chat } from "@tilt-legal/cubitt-components/composites";

<Chat.ChunkList
  chunks={chunks}
  renderContext={{
    message,
    references: sources,
    isStreaming: message.status === "running",
  }}
/>;
```

### Props [#props-1]

| Prop                      | Type                        | Description                                      |
| ------------------------- | --------------------------- | ------------------------------------------------ |
| `chunks`                  | `readonly ChunkRow[]`       | Array of chunk rows to render                    |
| `renderContext`           | `Partial<ChunkRenderProps>` | Additional context passed to each chunk renderer |
| `missingRendererFallback` | `(args) => ReactNode`       | Custom fallback for unregistered chunk types     |
| `className`               | `string`                    | Additional CSS classes                           |

If a chunk's `kind` has no registered renderer, the list renders `Unknown chunk type: {kind}` in muted text. Override this with `missingRendererFallback`.

## Custom Message Rendering [#custom-message-rendering]

For full control over message appearance, use the lower-level primitives:

```tsx
import { Chat } from "@tilt-legal/cubitt-components/composites";

function CustomUserMessage({ message }) {
  return (
    <Chat.MessageBubble className="bg-blue-50">
      <Chat.HtmlRenderer html={message.bodyHtml} />
    </Chat.MessageBubble>
  );
}
```

### `Chat.MessageBubble` [#chatmessagebubble]

Styled container for message content with role-appropriate padding and border radius.

| Prop        | Type        | Description            |
| ----------- | ----------- | ---------------------- |
| `className` | `string`    | Additional CSS classes |
| `children`  | `ReactNode` | Message content        |

### `Chat.HtmlRenderer` [#chathtmlrenderer]

Renders sanitized HTML content (typically `bodyHtml` from user messages).

| Prop   | Type     | Description           |
| ------ | -------- | --------------------- |
| `html` | `string` | HTML string to render |

## Citation Sync [#citation-sync]

The `useCitationSync` hook synchronizes hover state between inline citation pills (in markdown chunks) and the references panel:

```tsx
import { useCitationSync } from "@tilt-legal/cubitt-components/composites";

const {
  highlightedIndex, // Currently highlighted source index
  handleCitationHover, // Call when an inline citation is hovered
  handleCitationBlur, // Call when an inline citation loses hover
  handleSourceHover, // Call when a reference list item is hovered
  handleSourceBlur, // Call when a reference list item loses hover
} = useCitationSync(sources);
```

Pass `highlightedIndex` and the handlers to `ChunkList` via `renderContext`:

```tsx
<Chat.ChunkList
  chunks={chunks}
  renderContext={{
    highlightedSourceIndex: highlightedIndex,
    onCitationHover: handleCitationHover,
    onCitationBlur: handleCitationBlur,
    onReferenceHover: handleSourceHover,
    onReferenceBlur: handleSourceBlur,
    references: sources,
  }}
/>
```

## Scroll Utilities [#scroll-utilities]

### `Chat.ScrollAnchor` [#chatscrollanchor]

An invisible anchor element placed after the message list. Used as a scroll target for stick-to-bottom behavior.

```tsx
<Chat.MessageList>
  {messages.map(/* ... */)}
</Chat.MessageList>
<MessageFeedScrollAnchor />
```

### `Chat.ScrollToBottom` [#chatscrolltobottom]

A floating button that appears when the user scrolls up from the bottom. Clicking it scrolls back to the latest message.

```tsx
// Typically placed inside the footer or as a sibling to the message list
<Chat.ScrollToBottom />
```

The button auto-hides when already at the bottom.
