

## Overview [#overview]

Cubitt's chat components consume **normalized records** and emit **structured payloads**. This page covers:

* **Input**: `ChatMessageView` + `ChunkRow[]` — what you feed the UI
* **Output**: `MessagePayload` — what the prompt emits on submit
* **Callbacks**: Message-level events for persistence and side effects

***

## Input: ChatMessageView [#input-chatmessageview]

Messages are metadata rows. You control ordering in your query.

```ts
import type { ChatMessageView } from "@tilt-legal/cubitt-components/composites";

const agentMessage: ChatMessageView = {
  id: "msg_456",
  conversationId: "conv_123",
  role: "agent",
  createdAt: Date.now(),
  status: "running",
};

const userMessage: ChatMessageView = {
  id: "user_123",
  conversationId: "conv_123",
  role: "user",
  createdAt: Date.now(),
  status: "completed",
  bodyHtml: "<p>Summarise the contract.</p>",
};
```

| Field            | Type                            | Description                                             |
| ---------------- | ------------------------------- | ------------------------------------------------------- |
| `id`             | `MessageId` (string)            | Unique message identifier                               |
| `conversationId` | `string`                        | Conversation scope                                      |
| `role`           | `"user" \| "agent" \| "system"` | Message author role                                     |
| `createdAt`      | `number`                        | Epoch timestamp                                         |
| `status`         | `MessageStatus`                 | `"running"`, `"completed"`, `"error"`, or `"suspended"` |
| `bodyHtml`       | `string`                        | HTML content (user messages)                            |
| `attachments`    | `AttachableFile[]`              | Attached files (user messages)                          |

User messages carry `bodyHtml` and can omit chunks entirely. Agent and system messages carry chunks.

## Input: ChunkRow [#input-chunkrow]

Stream chunk rows per message. The UI reads them in arrival order (`seq`).

```ts
import type { ChunkRow } from "@tilt-legal/cubitt-components/composites";

const chunks: ChunkRow[] = [
  {
    id: "msg_456-chunk-0",
    messageId: "msg_456",
    kind: "reasoning",
    type: "reasoning",
    seq: 0,
    text: "Thinking through the filings...",
    label: "Analyzing...",
    status: "running",
  },
  {
    id: "msg_456-chunk-1",
    messageId: "msg_456",
    kind: "markdown",
    type: "markdown",
    seq: 1,
    text: "Quarterly filing is due **July 15**.",
    citations: [{ sourceIndex: 0, from: 24, to: 31 }],
  },
  {
    id: "msg_456-chunk-2",
    messageId: "msg_456",
    kind: "references",
    type: "references",
    seq: 2,
    sources: [
      {
        id: "src_1",
        title: "Compliance Calendar",
        url: "https://example.com/compliance-calendar",
      },
    ],
  },
];
```

### Base fields [#base-fields]

| Field       | Type                 | Description                                                                  |
| ----------- | -------------------- | ---------------------------------------------------------------------------- |
| `id`        | `ChunkId` (string)   | Unique chunk identifier                                                      |
| `messageId` | `MessageId` (string) | Parent message ID                                                            |
| `kind`      | `ChunkKind`          | Chunk type discriminator                                                     |
| `seq`       | `number`             | Ordering sequence within the message                                         |
| `createdAt` | `number`             | Epoch timestamp (optional)                                                   |
| `status`    | `TaskStatus`         | `"pending"`, `"running"`, `"completed"`, `"cancelled"`, `"error"` (optional) |

### Built-in chunk payloads [#built-in-chunk-payloads]

| Type         | Key fields                                                                    |
| ------------ | ----------------------------------------------------------------------------- |
| `markdown`   | `text: string`, `citations?: CitationRange[]`                                 |
| `reasoning`  | `text: string`, `label?: string`, `defaultOpen?: boolean`                     |
| `task`       | `group: TaskGroup` (title, status, items with progress)                       |
| `table`      | `rows: string[][]`, `headers?: string[]`, `citations?: CitationRange[]`       |
| `card`       | `title: string`, `description?: string`, `url?: string`                       |
| `alert`      | `variant: "info" \| "warning" \| "success" \| "destructive"`, `text: string`  |
| `references` | `sources: ReferenceSource[]`                                                  |
| `artifact`   | `artifactId: string`, `title: string`, `content: string`, `language?: string` |

### Streaming updates [#streaming-updates]

Write rows in place as streaming updates land from your LLM. The example below uses TanStack DB, but any reactive data store works — just update the underlying rows and pass them to the chat components:

1. Append or patch the `reasoning` row while tokens arrive
2. Update the `task` row to reflect progress
3. Push content rows (`markdown`, `table`, `alert`, etc.) in arrival order
4. Replace the `references` row once citations stabilise
5. Flip `status` on the owning message to `"completed"`, `"error"`, etc.

```ts
import {
  db,
  messagesCollection,
  chunksCollection } from "./db";

// As chunks arrive from your LLM stream
function handleChunkReceived(messageId: string,
  chunk: ChunkRow) {
  db.write((tx) => {
    tx.upsert(chunksCollection,
  chunk);
  });
  // All useLiveQuery hooks subscribed to this message re-render
}

// Update message status when complete
function handleStreamComplete(messageId: string) {
  db.write((tx) => {
    tx.upsert(messagesCollection,
  { id: messageId,
  status: "completed" });
  });
}
```

Reuse message IDs when retrying so the list reuses DOM nodes instead of flashing new entries.

***

## Output: MessagePayload [#output-messagepayload]

`ChatFooter` emits a `MessagePayload` every time the user submits:

```ts
import type { MessagePayload } from "@tilt-legal/cubitt-components/composites";

const payload: MessagePayload = {
  content_text: "Review §{file_abc}§ please",
  content_html: '<span data-attachment-chip="true" data-id="file_abc">engagement.pdf</span>',
  submitted_at: Date.now(),
  attachments: [
    {
      type: "file",
      id: "file_abc",
      display_name: "engagement.pdf",
      sources: ["inline"],
      file_metadata: {
        mime_type: "application/pdf",
        size_bytes: 245680,
      },
    },
  ],
  attachments_by_type: {
    files: ["file_abc"],
  },
};
```

### Payload fields [#payload-fields]

| Field                 | Type                | Description                                                                     |
| --------------------- | ------------------- | ------------------------------------------------------------------------------- |
| `content_text`        | `string`            | Plain text with instruction content expanded inline, file refs as `§{file_id}§` |
| `content_html`        | `string`            | HTML with attachment chips rendered inline                                      |
| `submitted_at`        | `number`            | Epoch timestamp                                                                 |
| `attachments`         | `Attachment[]`      | All attachment metadata (discriminated union)                                   |
| `attachments_by_type` | `AttachmentsByType` | IDs grouped by type for backend convenience                                     |

### Attachment types [#attachment-types]

Attachments use a discriminated union on `type`:

| Type          | Fields                                            | Description                                      |
| ------------- | ------------------------------------------------- | ------------------------------------------------ |
| `"file"`      | `id`, `display_name`, `sources`, `file_metadata?` | File attachment with optional MIME type and size |
| `"container"` | `id`, `display_name`, `sources`                   | Container/folder attachment                      |
| `"person"`    | `id`, `display_name`, `sources`                   | Person mention (@-mention)                       |

### Attachment sources [#attachment-sources]

| `sources` value          | Meaning                                                                  |
| ------------------------ | ------------------------------------------------------------------------ |
| `["inline"]`             | Mentioned via `@file` in the editor. In `content_text` as `§{file_id}§`. |
| `["external"]`           | Attached via paperclip button. NOT in `content_text`.                    |
| `["inline", "external"]` | Both mentioned inline and attached via button.                           |

**Instructions** are NOT attachments — their content is injected directly into `content_text`.

***

## Callbacks [#callbacks]

### Message-level events [#message-level-events]

`Chat.MessageEntry` forwards these events through props:

| Callback         | Signature                                                  | Description                                             |
| ---------------- | ---------------------------------------------------------- | ------------------------------------------------------- |
| `onAction`       | `(action: ChatAction, ctx: { messageId: string }) => void` | Fires after copy or download. Use for analytics.        |
| `onRetry`        | `(messageId: string) => void`                              | Regenerate button clicked. Required to show the button. |
| `onDelete`       | `(messageId: string) => void`                              | Delete confirmed. Required to show the button.          |
| `onCancelStream` | `(messageId: string) => void`                              | Cancel streaming on a specific message.                 |
| `onFileOpen`     | `(fileId: string, fileName: string) => void`               | File attachment clicked.                                |

Copy and download perform local side effects automatically:

* **Copy** writes combined chunk text to the clipboard
* **Download** streams the text to the browser as a `.txt` file

### Conversation management [#conversation-management]

Use `useChatSelection` for URL-synced conversation state:

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

const {
  conversationId,             // Current conversation ID (from URL)
  handleSelect,               // Switch to a conversation
  handleNew,                  // Start a new conversation (clears URL param)
  isNewConversation,          // No conversation selected
} = useChatSelection({
  urlParam: "c",              // Query param name (default: "c")
  initialId: "conversation-1", // Optional initial conversation
});
```

Pass `handleSelect` and `handleNew` directly to variant headers:

```tsx
<Chat.Floating.Header
  conversations={conversationHistory}
  onConversationSelect={handleSelect}
  onNewConversation={handleNew}
/>
```

For side effects (cancel streams, clear DB, call APIs), wrap the handlers:

```tsx
const handleNewConversation = useCallback(() => {
  cancelAllStreams();
  db.write((tx) => {
    for (const msg of messagesCollection.getAll()) {
      tx.delete(messagesCollection, msg.id);
    }
  });
  handleNew(); // Update URL
}, [handleNew, cancelAllStreams]);
```
