

## Overview [#overview]

Each chat variant mounts a **per-instance chunk registry**. Built-in renderers handle `markdown`, `reasoning`, `task`, `table`, `card`, `alert`, and `references`. Add your own chunk kinds by defining a shape, building a renderer, and registering it before the feed renders.

<Steps>
  <Step>
    ## Define the chunk shape [#define-the-chunk-shape]

    Extend `MessageChunkDefinition` so both your transport and UI layers agree on the payload. Custom chunks inherit base metadata (`id`, `messageId`, `kind`, `seq`) when normalized into `ChunkRow`.

    ```ts title="types/chat.ts"
    import type {
      MessageChunkDefinition,
      ChunkRow,
      ChunkKind,
    } from "@tilt-legal/cubitt-components/composites";

    // 1. Define the chunk payload
    export type TimelineChunk = MessageChunkDefinition<"timeline"> & {
      events: Array<{ timestamp: string; title: string; summary?: string }>;
    };

    // 2. Define the full row type (payload + metadata)
    export type TimelineChunkRow = TimelineChunk & {
      id: string;
      messageId: string;
      kind: ChunkKind;
      seq: number;
      createdAt?: number;
    };
    ```
  </Step>

  <Step>
    ## Build the renderer [#build-the-renderer]

    Chunk renderers are ordinary React components receiving `ChunkRenderProps<T>`. Cubitt passes the owning message, reference metadata, and streaming state automatically.

    ```tsx title="components/timeline-chunk.tsx"
    import type { ChunkRenderProps } from "@tilt-legal/cubitt-components/composites";
    import {
      Badge,
      Card,
      CardContent,
      CardHeader,
      CardTitle,
    } from "@tilt-legal/cubitt-components/primitives";
    import type {
      createChunkRegistry,
      registerDefaultChunkRenderers,
      registerChunk,
      MessageFeedScrollAnchor,
    } from "@tilt-legal/cubitt-components/composites";
    import type { TimelineChunkRow } from "../types/chat";

    export function TimelineChunkRenderer({
      chunk,
      references,
    }: ChunkRenderProps<TimelineChunkRow>) {
      return (
        <Card className="space-y-3">
          <CardHeader>
            <CardTitle className="flex items-center gap-2">
              <Badge variant="secondary">Timeline</Badge>
              <span>Workflow progress</span>
            </CardTitle>
          </CardHeader>
          <CardContent className="space-y-2">
            {chunk.events.map((event, index) => (
              <div
                key={`${event.timestamp}-${index}`}
                className="flex items-start gap-3 rounded-md border border-border/60 p-3 text-sm"
              >
                <span className="mt-0.5 size-2 rounded-full bg-primary" />
                <div className="space-y-1">
                  <div className="flex items-center gap-2 font-medium">
                    <span>{event.title}</span>
                    <Badge variant="secondary" appearance="outline">{event.timestamp}</Badge>
                  </div>
                  {event.summary ? (
                    <p className="text-muted-foreground">{event.summary}</p>
                  ) : null}
                </div>
              </div>
            ))}
          </CardContent>
        </Card>
      );
    }
    ```
  </Step>

  <Step>
    ## Register the renderer [#register-the-renderer]

    Use `registerChunk` with a **type guard** to register your custom renderer. The guard proves to TypeScript that a generic `ChunkRow` is your specific type,

    so the renderer receives fully typed props without assertions.

    ```tsx title="components/chat-with-timeline.tsx"
    import { useMemo } from "react";
    import { Chat } from "@tilt-legal/cubitt-components/composites";
    import {
      registerDefaultChunkRenderers,
      registerChunk,
    } from "@tilt-legal/cubitt-components/composites";
    import { TimelineChunkRenderer } from "./timeline-chunk";
    import type { TimelineChunkRow } from "../types/chat";

    export function ChatWithTimeline({ messages, onSend }) {
      const registry = useMemo(() => {
        const instance = createChunkRegistry();

        // Register all built-in chunk renderers
        registerDefaultChunkRenderers(instance);

        // Register your custom chunk with a type guard
        registerChunk(
          instance,
          "timeline",
          (c): c is TimelineChunkRow => c.type === "timeline",
          TimelineChunkRenderer,
        );

        return instance;
      }, []);

      return (
        <Chat.Floating
          registry={registry}
          hasMessages={messages.length > 0}
          onSend={onSend}
        >
          <Chat.Floating.Shell>
            <Chat.Floating.Content>
              <Chat.MessageList>
                {messages.map((msg) => (
                  <MessageRow key={msg.id} messageId={msg.id} />
                ))}
              </Chat.MessageList>
              <MessageFeedScrollAnchor />
            </Chat.Floating.Content>
            <Chat.Floating.Footer />
          </Chat.Floating.Shell>
        </Chat.Floating>
      );
    }
    ```
  </Step>

  <Step>
    ## Stream the chunk [#stream-the-chunk]

    Emit the new chunk alongside built-ins. Ensure the transport layer adds metadata before writing to your data store so React keys remain stable.

    ```ts title="transport/messages.ts"
    import type { TimelineChunkRow } from "../types/chat";
    import { chunksCollection, db } from "./db";

    const chunk: TimelineChunkRow = {
      id: "msg_timeline-chunk-0",
      messageId: "msg_timeline",
      kind: "timeline",
      seq: 0,
      createdAt: Date.now(),
      type: "timeline",
      events: [
        {
          timestamp: "13:01",
          title: "Ran discovery query",
          summary: "Indexed 12 new filings from the knowledge base.",
        },
      ],
    };

    // Write to your data store — e.g. TanStack DB live queries re-execute automatically
    db.write((tx) => {
      tx.upsert(chunksCollection, chunk);
    });
    ```
  </Step>
</Steps>

## Registry API [#registry-api]

### `createChunkRegistry()` [#createchunkregistry]

Creates an empty registry instance.

### \`registerDefaultChunkRenderers(registry, [#registerdefaultchunkrenderersregistry]

options?)\`

Registers all built-in chunk renderers. Optionally filter with `options.only`:

```ts
// Register only markdown and references
registerDefaultChunkRenderers(registry, {
  only: ["markdown", "references"],
});
```

### \`registerChunk(registry, [#registerchunkregistry]

kind,
guard,
Component,
options?)\`

Registers a typed chunk renderer with a type guard. Optionally co-locates a panel definition.

```ts
registerChunk<TimelineChunkRow>(
  registry,
  "timeline",
  (c): c is TimelineChunkRow => c.type === "timeline",
  TimelineChunkRenderer,
);
```

| Parameter   | Type                                 | Description                               |
| ----------- | ------------------------------------ | ----------------------------------------- |
| `registry`  | `ChunkRegistry`                      | Registry instance                         |
| `kind`      | `string`                             | Chunk kind identifier                     |
| `guard`     | `(chunk: ChunkRow) => chunk is T`    | Type guard for runtime narrowing          |
| `Component` | `ComponentType<ChunkRenderProps<T>>` | Renderer component                        |
| `options`   | `{ panel?: ChunkPanelDescriptor }`   | Optional panel co-located with this chunk |

### `useChunkRegistry()` [#usechunkregistry]

Hook to access the chunk registry from context. Must be used within a `ChatProvider` or `ChunkRegistryProvider`.

## Chunks with Panels [#chunks-with-panels]

A panel is an **extended view of a chunk** — for example,
the artifact chunk has an inline card in the message and a full document viewer as its panel. Panels are co-located with their chunk registration via the `panel` option on `registerChunk`.

Panels work across all variants. In the fullscreen variant,
panels render as resizable side panels. In floating and sidebar variants,
panels open in a Sheet overlay. The chunk renderer code is unchanged — `usePanelSlot` returns `isAvailable: true` in all three variants.

### Registering a chunk with a panel [#registering-a-chunk-with-a-panel]

```tsx title="registry-setup.ts"
import { createChunkRegistry } from "@tilt-legal/cubitt-components/composites";
import { CodeDiffChunkRenderer } from "./code-diff-chunk";
import { CodeDiffPanel } from "./code-diff-panel";

const registry = createChunkRegistry();
registerDefaultChunkRenderers(registry);

registerChunk(
  registry,
  "code-diff",
  (c): c is CodeDiffChunkRow => c.type === "code-diff",
  CodeDiffChunkRenderer,
  {
    panel: {
      label: "Code Diff",
      component: CodeDiffPanel,
      minSize: 300,
      collapsible: true,
    },
  },
);
```

### `ChunkPanelDescriptor` [#chunkpaneldescriptor]

| Field         | Type                        | Default    | Description                                    |
| ------------- | --------------------------- | ---------- | ---------------------------------------------- |
| `panelId`     | `string`                    | chunk kind | Unique panel identifier used by `usePanelSlot` |
| `label`       | `string`                    | —          | Display label                                  |
| `icon`        | `ReactNode`                 | —          | Panel icon                                     |
| `component`   | `ComponentType<PanelProps>` | —          | Panel content component                        |
| `defaultSize` | `number`                    | —          | Default panel width                            |
| `minSize`     | `number`                    | —          | Minimum panel width                            |
| `maxSize`     | `number`                    | —          | Maximum panel width                            |
| `collapsible` | `boolean`                   | —          | Whether the panel can be collapsed             |

### Chunk renderer with panel support [#chunk-renderer-with-panel-support]

Use `usePanelSlot` in the chunk renderer to open/close the co-located panel. The hook returns `isAvailable: true` in all variants (fullscreen,
floating,
and sidebar).

```tsx title="components/code-diff-chunk.tsx"
import type { ChunkRenderProps } from "@tilt-legal/cubitt-components/composites";
import { usePanelSlot } from "@tilt-legal/cubitt-components/composites";
import {
  Button,
  Card,
  CardHeader,
  CardTitle,
} from "@tilt-legal/cubitt-components/primitives";
import {
  Expand,
  LayoutSidebar } from "@tilt-legal/cubitt-icons/ui/outline";

export function CodeDiffChunkRenderer({ chunk }: ChunkRenderProps<CodeDiffChunkRow>) {
  const panel = usePanelSlot("code-diff");

  // Choose icon based on panel mode:
  // "inline" (fullscreen) → Expand icon,
  "overlay" (floating/sidebar) → LayoutSidebar icon
  const PanelIcon = panel.mode === "inline" ? Expand : LayoutSidebar;

  return (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center justify-between">
          <span>{chunk.fileName}</span>
          {panel.isAvailable && (
            <Button mode="icon" onClick={panel.open} size="sm" variant="ghost">
              <PanelIcon className="size-3.5" />
            </Button>
          )}
        </CardTitle>
      </CardHeader>
      {/* inline preview */}
    </Card>
  );
}
```

### Panel component [#panel-component]

```tsx title="components/code-diff-panel.tsx"
import type { PanelProps } from "@tilt-legal/cubitt-components/composites";
import { Button } from "@tilt-legal/cubitt-components/primitives";
import { Expand, Xmark } from "@tilt-legal/cubitt-icons/ui/outline";

function CodeDiffPanel({ panelId, onClose, mode, onExpand }: PanelProps) {
  return (
    <div className="flex h-full flex-col">
      <div className="flex items-center justify-between border-b px-4 py-3">
        <h3 className="font-medium">Code Diff</h3>
        <div className="flex items-center gap-1">
          {mode === "overlay" && onExpand && (
            <Button mode="icon" onClick={onExpand} size="sm" variant="ghost">
              <Expand className="size-4" />
            </Button>
          )}
          {onClose && (
            <Button mode="icon" onClick={onClose} size="sm" variant="ghost">
              <Xmark className="size-4" />
            </Button>
          )}
        </div>
      </div>
      <div className="flex-1 p-4">{/* Panel content */}</div>
    </div>
  );
}
```

To share state between the chunk renderer and the panel component (e.g. which diff is selected), provide your own context above both — for example, in a wrapper around `Chat.Fullscreen`.

## ChunkRenderProps [#chunkrenderprops]

Every renderer receives these props:

| Field                                  | Type                         | Description                                                 |
| -------------------------------------- | ---------------------------- | ----------------------------------------------------------- |
| `chunk`                                | `T`                          | The narrowed chunk row (includes `id`, `seq`, `kind`, etc.) |
| `chunkIndex`                           | `number`                     | Position of this chunk in the message's chunk array         |
| `message`                              | `ChatMessageView`            | Owning message metadata                                     |
| `references`                           | `ReferenceSource[]`          | De-duplicated references from any `references` chunk        |
| `isStreaming`                          | `boolean`                    | `true` while the message reports `status: "running"`        |
| `onCancelStreaming`                    | `() => void`                 | Cancel streaming callback                                   |
| `highlightedSourceIndex`               | `number`                     | Index of the currently highlighted reference                |
| `onCitationHover` / `onCitationBlur`   | `(index) => void`            | Sync inline citations with references panel                 |
| `onReferenceHover` / `onReferenceBlur` | `(index) => void`            | Highlight inline citations from references list             |
| `onFileOpen`                           | `(fileId, fileName) => void` | File open handler from the host                             |
