# Chat

Decomposed, transport-agnostic chat. Streaming-aware, markdown-native, mobile-ready, role-aware styling, pluggable backends.

## TL;DR

```tsx
import { ChatRoot, createPydanticAIChatTransport, ChatLauncher } from '@djangocfg/ui-tools/chat';

const transport = createPydanticAIChatTransport({
  buildStreamUrl: (sid, msg) => `${API}/chat/sessions/${sid}/stream?message=${encodeURIComponent(msg)}`,
  streamMethod: 'GET',
  buildHeaders: async () => ({ Authorization: `Bearer ${getToken()}` }),
});

export function MyChat() {
  return (
    <ChatLauncher
      transport={transport}
      config={{ greeting: 'How can I help?' }}
      audio={{}}
      hotkey={{ key: '/', meta: true }}
      fab={{ variant: 'animated', tooltip: 'Open chat (⌘/)' }}
      dock={{ title: 'Assistant', height: 600 }}
      greeting="Hi 👋 Need help?"
      headerSlots={{
        languagePicker: true,
        modeToggle: { persistAs: 'my.chat.dock' },
      }}
    >
      <ChatRoot />
    </ChatLauncher>
  );
}
```

## What you get

- **Headless first.** Pure reducer + hooks; UI is optional and replaceable.
- **Launcher primitives.** `ChatLauncher` / `ChatFAB` (3 variants, responsive sizing) / `ChatDock` (popover + side modes, mobile fullscreen) / `ChatGreeting` (proactive invite) / `ChatUnreadPreview` (live-push notification).
- **Header actions.** Declarative `headerSlots` prop on `<ChatLauncher>` — `audio` / `modeToggle` / `languagePicker` / `reset` / `custom`. Renders inside the provider, every slot has `useChatContext()` access. Raw `ChatHeader` / `ChatHeaderActionButton` / `ChatHeaderModeToggle` / `ChatHeaderAudioToggle` / `ChatHeaderResetButton` / `ChatHeaderLanguageButton` still exported for advanced custom shells.
- **Decomposed.** `MessageList`, `MessageBubble`, `Composer`, `Sources`, `ToolCalls`, `Attachments`, `EmptyState`, `ErrorBanner`, `JumpToLatest`, `StreamingIndicator` — every part exported.
- **Markdown-native.** Sits on top of `MarkdownMessage` (GFM, code, mermaid, sanitized HTML).
- **Streaming.** SSE-based `AsyncGenerator` transport. Spec-compliant `parseSSE`, token coalescing, cancel, regenerate.
- **Pydantic-AI mapper.** Built-in adapter for Django/pydantic-AI backends — `text_delta` / `tool_call` / `tool_result` → canonical `ChatStreamEvent`. FIFO tool-id queue.
- **Tool calls.** Live streaming panels, auto-open while running, auto-close on completion. Per-call `memo` so streaming one tool doesn't re-render siblings.
- **Live push.** `chat.injectMessage(msg)` / `updateMessage(id, patch)` for admin takeover / Centrifugo / WebSocket inbound. `useChatUnread()` tracks unseen; `<ChatUnreadPreview>` surfaces them next to the FAB.
- **Facebook-style unread notifier.** `useChatUnreadNotifier()` prefixes `document.title` with `(N)` and paints a small red dot over the favicon while the tab is in background. Sober by default — the favicon is the attention signal, the title stays readable. Title rotation is available as opt-in (`title: { mode: 'rotate' }`) for hosts without a favicon. Cross-tab coordinated via `useActiveTab` from `@djangocfg/ui-core`: only the elected leader tab mutates title/favicon, the count is broadcast so every tab's FAB badge stays in sync. Pluggable `ChatNotifier` interface lets Wails / Electron hosts route to a native dock badge instead.
- **Personas.** `config.user` + `config.assistant` for default identity; `message.sender` for per-message overrides.
- **Audio.** Pass `audio: ChatAudioConfig` (sounds map) to `<ChatLauncher>` — the hook runs internally. Built on `@djangocfg/ui-core/hooks/useNotificationSounds`: Safari unlock, persisted mute, per-event toggles, reduced-motion respect. Slack/Linear-style per-event volume scale (error ≈ 0.25, mention ≈ 1.0). **Built-in sounds bundled as base64 data URLs** in the lazy chat chunk (~136KB) — `audio={{}}` just works. `silenced` + `onSoundEvent` for native hosts (cmdop_go / Tauri).
- **Rich attachments.** `AttachmentsGrid` for thumbnails, `AttachmentsList` for custom renderers; `onAttachmentOpen` for lightbox.
- **Tool-payload dispatcher.** `dispatchToolPayload(matchers, fallback)` — pluggable predicates render `<LazyJsonTree>` / `<LazyMap>` / etc.
- **Persisted dock prefs.** `headerSlots.modeToggle.persistAs: 'my.key'` stores `mode` / `side` / `width` in localStorage; the toggle lets users flip popover ↔ side and survives reloads. (`useChatDockPrefs()` is now owned by the launcher internally — no longer a consumer-facing hook.)
- **UX guards.** Auto-focus composer on dock open, two-step Escape (textarea blur → close), click-to-focus on empty message area **and** on the composer surface padding (Slack / Linear / ChatGPT style), `useHotkey('mod+/')` toggle.
- **ChatGPT-style autoscroll.** `MessageList` follows the bottom while the user is within `atBottomThreshold` px (default 120). Every user-sent message bumps `scrollAnchorId` and re-anchors the viewport with `behavior: 'smooth'` — sending no longer leaves your own bubble stuck above the fold. Scrolling up by hand breaks the lock; `<JumpToLatest>` brings it back.
- **Voice composer slot.** `<VoiceComposerSlot />` drops into `composer.slots.blockStart` with **zero props** — reads/writes the composer through the `ComposerHandle` registered in chat context. The built-in `<Composer>` and TipTap-backed `MarkdownEditor` register themselves automatically; custom composers wire it via `useRegisterComposer({ focus, moveCursorToEnd, getValue, setValue })`. Auto-gates on Firefox / in-app WebViews / missing `getUserMedia`, preserves typed prefix, 90-second countdown, silence auto-stop, Esc / Enter hotkeys, start / stop earcons. See [`SpeechRecognition`](../SpeechRecognition/README.md).
- **Language flag button.** `headerSlots.languagePicker: true` slots a 28×28 country flag into the dock header — opens a searchable `<Combobox>` with 66 BCP-47 tags from the Chrome Web Speech catalogue. Selection persists via `useSpeechPrefs`, picked up by every `useSpeechRecognition` downstream. (Raw `<ChatHeaderLanguageButton>` still exported for custom shells.)
- **Auto-focus on stream end.** `<ChatProvider>` re-focuses the registered composer on the streaming → idle edge — type → send → read → keep typing without reaching for the mouse. Works for **every** usage pattern (`ChatRoot`, hand-rolled `ChatProvider` + `Composer`, headless), not just `ChatRoot`. Opt out with `<ChatProvider autoFocusOnStreamEnd={false}>`. The standalone `useAutoFocusOnStreamEnd()` hook is still exported for advanced cases (focus a non-composer target, drive `isStreaming` from your own store).
- **Page-context snapshot.** Optional `getDynamicMetadata` contributor on `<ChatProvider>` / `<ChatRoot>` — called fresh at send time, merged into transport `metadata`. Pairs with the `page-snapshot` engine (`src/lib/page-snapshot`) to attach a token-efficient, redacted snapshot of the page the user is looking at, so the assistant can answer in context. The snapshot rides a separate `metadata` field, never the message text.
- **AI bridge directives.** `bridge/` — when the assistant returns `point` directives, `<HighlightOverlay>` resolves each CST ref to a live element and draws an SVG-mask spotlight (optionally moves focus). The bridge also exposes a command registry the AI drives the page through. Read-only by default: it points at the UI, never changes data. See [`bridge/README.md`](./bridge/README.md).
- **Centralized colors.** Role-aware className tokens (`BUBBLE_SURFACE` / `ANCHOR` / `TOGGLE` / `DESTRUCTIVE_SURFACE`) + hooks (`useChatBubbleStyles`, `useChatRoleStyles`, `useChatDestructiveStyles`).
- **Responsive.** FAB `size='responsive'` (default): phone → `sm`, tablet → `md`, desktop → `lg`. Side mode is desktop-only and falls back to popover below `lg`.
- **Mobile fullscreen.** Dock auto-fills viewport below 768px via `useIsMobile`. Heights use `dvh/svh/lvh` so iOS Safari URL bar doesn't clip the chat.
- **A11y.** `role="log"` + polite live region, `aria-busy` on streaming bubbles, `role="alert"` errors, focus-visible actions.

## Architecture

```
Transport (interface)              ←  Pydantic-AI / HTTP+SSE / Wails / mock
   ↓
Reducer (pure state machine)
   ↓
Hooks (useChat / useChatComposer / useChatHistory / useChatLayout)
   ↓
ChatProvider (context)
   ↓
Components (MessageList, MessageBubble, Composer, Sources, ToolCalls, …)
   ↓
ChatRoot (one-line preset)  ◄──  optionally wrapped by  ──►  ChatLauncher (FAB + Dock + Greeting)
```

`ChatLauncher` mounts the `ChatProvider` itself — pass `transport` / `config` / `audio` / `initialSessionId` / `autoCreateSession` / `streaming` / `debug` to the launcher and use `<ChatRoot />` without props as the child. `ChatRoot` detects the ambient provider and reuses it; standalone `<ChatRoot transport={…}>` still works for non-launcher embeds. This is what makes declarative `headerSlots` (which render in the dock header) able to call `useChatContext()` and read `sessionId` / `clearMessages` / etc.

`ChatProvider` also owns chat-wide UX behaviour that must hold regardless of which preset wraps it — notably **stream-end composer re-focus** (`autoFocusOnStreamEnd`, default `true`). Putting it in the provider means a hand-rolled `ChatProvider` + `MessageList` + `Composer` layout behaves identically to `ChatRoot` with no extra wiring.

Module boundaries:

| Layer            | May import                       | May NOT import           |
| ---------------- | -------------------------------- | ------------------------ |
| `types/`         | —                                | anything                 |
| `constants.ts`   | —                                | anything                 |
| `core/transport` | `types/`                         | React, hooks, UI         |
| `core/reducer`   | `types/`                         | React, transport         |
| `hooks`          | `core/*`, `ui-core` hooks        | UI folders               |
| `context`        | `hooks`                          | UI folders               |
| `styles/`        | `types/`                         | hooks, UI folders        |
| `utils/`         | `types/`                         | React, UI folders        |
| `messages/`      | `hooks`, `context`, `styles`, `ui-core` UI | transport implementations |
| `composer/`      | `hooks`, `context`, `styles`, `messages`, `ui-core` UI | transport implementations |
| `shell/`         | `messages`, `composer`, `hooks`, `context`, `ui-core` UI | transport implementations |
| `launcher/`      | `shell`, `messages`, `composer`, `styles`, `ui-core` UI | transport implementations |
| `launcher/header/` | `context`, `hooks`, `launcher` siblings | transport implementations |

## Types

Single source of truth in [`types/`](./types/index.ts). Split by domain — import from the top-level `@djangocfg/ui-tools` barrel.

| Domain | What's there |
|---|---|
| `persona` | `ChatRole`, `ChatPersona`, `ChatUserContext`, `ChatAssistantContext` |
| `message` | `ChatMessage` |
| `tool-call` | `ChatToolCall` |
| `attachment` | `ChatAttachment`, `ChatSource` |
| `labels` | `ChatLabels` + `DEFAULT_LABELS` |
| `config` | `ChatConfig`, `ChatPrefs`, `ChatDisplayMode` |
| `events` | `ChatStreamEvent` SSE union |
| `session` | `SessionInfo`, `HistoryPage`, `Stream/Send/CreateSessionOptions` |
| `transport` | `ChatTransport` |
| `block` | `MessageBlock` union + `BlockAppearance` (see Message blocks) |

## Launcher (FAB + Dock + Greeting)

Picks the highest level that fits.

| Component | Use when |
|---|---|
| `<ChatLauncher>` | FAB + dock + greeting + push-preview + hotkey + audio toggle (~99% of cases) |
| `<ChatDock>` | Custom trigger (header button, inline link). Popover or side mode. |
| `<ChatFAB>` | Floating button only |
| `<ChatGreeting>` | Standalone proactive bubble |
| `<ChatUnreadPreview>` | Standalone push-notification bubble |
| `headerSlots` (prop on `<ChatLauncher>`) | Declarative header buttons — `audio` / `modeToggle` / `languagePicker` / `reset` / `custom`. Renders inside the provider, so every slot has `useChatContext()` access. **Prefer this over the raw header components below.** |
| `<ChatHeader>` + `<ChatHeaderActionButton>` | Custom header chrome (advanced — when you build your own dock shell) |
| `<ChatHeaderModeToggle>` | Popover ↔ side toggle (desktop-only, auto-hides below `lg`). Used internally by `headerSlots.modeToggle`. |
| `<ChatHeaderAudioToggle>` | Mute / unmute notification sounds. Used internally by `headerSlots.audio`. |
| `<ChatHeaderResetButton>` | Clear conversation with `window.dialog.confirm`. Used internally by `headerSlots.reset`. |
| `<ChatHeaderLanguageButton>` | Flag-button language picker (66 BCP-47 tags, persists in `useSpeechPrefs`). Used internally by `headerSlots.languagePicker`. |

### FAB

```tsx
fab={{
  variant: 'simple' | 'animated' | 'glass',   // default 'simple'
  size: 'responsive',                         // default — phone=sm/tablet=md/desktop=lg
  position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left',
  offset: 24,
  pulse: true,                                // attention dot + ping
  badge: 5,                                   // unread count (renders "9+")
  tooltip: 'Open chat',                       // hover/focus label
  icon: <Sparkles />,
  inline: true,                               // no fixed positioning (stories/previews)
}}
```

### Dock

```tsx
dock={{
  title: 'Assistant',
  mode: 'popover' | 'side',                   // default 'popover'; 'side' = full-height edge panel
  side: 'left' | 'right',                     // default 'right' (only for mode='side')
  width: 480, height: 720,
  position: 'bottom-right',                   // popover mode only
  offset: { horizontal: 24, vertical: 96 },
  mobileFullscreen: true,                     // default — fills viewport < 768px
  reserveBodySpace: true,                     // side mode — sets body padding so content shifts
  disablePortal: true,                        // render in-place (stories/iframes)
  hideHeader: true,                           // children render their own header
}}
```

**Side mode is desktop-only.** Below `lg` (1024px) it silently falls back to popover and the mode-toggle slot hides itself.

**Header buttons go through `headerSlots`** (see below), not `dock.headerActions` (removed). The launcher computes header actions from the declarative slot config and forwards them to `<ChatDock>` internally.

### Header slots

Declarative header buttons rendered inside the launcher's `<ChatProvider>`. Every slot has access to `sessionId`, `clearMessages`, etc. via `useChatContext()` — no JSX plumbing.

```tsx
<ChatLauncher
  transport={transport}
  audio={{}}                              // ChatAudioConfig (sounds map)
  dock={{ title: 'Assistant' }}
  headerSlots={{
    languagePicker: true,                 // 66 BCP-47 tags
    modeToggle: {
      persistAs: 'crm.chat.dock',         // localStorage key for useChatDockPrefs
      defaults: { mode: 'popover', side: 'right' },
    },
    reset: {
      onReset: async () => {              // backend call — return true on success
        await api.clearChat();
        return true;
      },
      confirmMessage: "Forget this conversation?",
      // onSuccess defaults to ctx.clearMessages(); override for analytics, refetch, …
    },
    custom: (ctx) => <MyButton sessionId={ctx.sessionId} />,
  }}
>
  <ChatRoot />
</ChatLauncher>
```

Order is fixed left → right: `custom · languagePicker · modeToggle · audio · reset` (close icon stays right-most, owned by `<ChatHeader>`).

| Slot | Value | Default |
|---|---|---|
| `audio` | `boolean` | auto-on when launcher `audio` is configured and not silent |
| `modeToggle` | `boolean \| { persistAs?, defaults?, forceVisible? }` | off |
| `languagePicker` | `boolean \| { allowedTags?, ariaLabel?, hideFallbackIcon? }` | off |
| `reset` | `{ onReset, confirm?, confirmMessage?, onSuccess?, onError? }` | off (no `onReset`) |
| `custom` | `(ctx: ChatContextValue) => ReactNode` | off |

`useChatDockPrefs()` is now absorbed by `headerSlots.modeToggle.persistAs` — the launcher owns the hook internally. Set the key, get persisted `mode` / `side` / `sideWidth` across reloads. Below `lg` the toggle hides itself unless `forceVisible: true`.

#### Side-mode body reserve + the `--chat-dock-reserve` token

When the dock is in `mode='side'` it pushes the rest of the page out of its way by writing two things to `<body>`:

| Surface | Value | Set by |
|---|---|---|
| `body.style.paddingRight` (or `paddingLeft`) | dock width in `px` | `<ChatDock>` mount effect |
| `--chat-dock-reserve` (CSS custom property) | same `px` value, as a token | same effect |

That covers two layout flows automatically:

- **Static content inside `<body>`** — `paddingRight` shifts it inward so the chat doesn't cover the right edge.
- **CSS-only consumers** — read the token from anywhere:
  ```css
  .my-floating-thing {
    padding-right: var(--chat-dock-reserve, 0px);
  }
  ```

**`position: fixed` consumers (navbars, FABs, sticky banners) are NOT auto-shifted** — body padding doesn't propagate to fixed positioning. They have to opt in by reading the token themselves. Example for a fixed top navbar:

```tsx
'use client';

import { useEffect, useState } from 'react';

/** Mirrors `<body>` padding onto a fixed element so it leaves room for the docked chat. */
function useChatDockReserve() {
  const [reserve, setReserve] = useState({ left: 0, right: 0 });
  useEffect(() => {
    const read = () => {
      const cs = getComputedStyle(document.body);
      setReserve({
        left: parseFloat(cs.paddingLeft) || 0,
        right: parseFloat(cs.paddingRight) || 0,
      });
    };
    read();
    const mo = new MutationObserver(read);
    mo.observe(document.body, { attributes: true, attributeFilter: ['style'] });
    window.addEventListener('resize', read);
    return () => { mo.disconnect(); window.removeEventListener('resize', read); };
  }, []);
  return reserve;
}

export function MyNavbar() {
  const reserve = useChatDockReserve();
  return (
    <header
      className="fixed top-0 z-50 transition-all"
      style={{ left: reserve.left, right: reserve.right }}
    >
      …
    </header>
  );
}
```

Same recipe works for any other fixed element (cookie banner, FAB, sticky toolbar). When the dock is in `popover` mode (or closed) both values are `0` and the navbar spans the full viewport.

### Greeting

```tsx
// Short
greeting="Got a minute? I'm here to help."

// Full config
greeting={{
  content: <>Looking for something? <strong>Chat with us</strong>.</>,
  senderName: 'Anna · Support',
  avatar: <Avatar><AvatarImage src="…" /></Avatar>,
  delayMs: 1500,
  dismissStorageKey: 'crm-greeting-v1',
  hideOnOpen: true,
}}
```

### Hotkey

```tsx
hotkey={{ key: '/', meta: true }}           // ⌘/ or Ctrl+/
hotkey={{ key: 'K', meta: true, shift: true }}
```

When `meta` is unset, modifier-bearing combos do **not** trigger — prevents shadowing native shortcuts.

### Controlled mode

```tsx
const [open, setOpen] = useState(false);

<ChatLauncher open={open} onOpenChange={setOpen} dock={{ title: 'Helper' }}>
  <Chat />
</ChatLauncher>
```

### Live push notifications

Inbound messages from outside the chat session (admin takeover, server-pushed alerts, Centrifugo broadcasts) flow through the same reducer as normal turns:

```tsx
// Inside ChatProvider — anywhere
const chat = useChatContext();

centrifugo.on('chat:inbound', (msg) => {
  chat.injectMessage({
    id: msg.id,
    role: 'assistant',
    content: msg.text,
    createdAt: Date.now(),
    sender: { name: msg.from, avatarUrl: msg.avatar },
  });
});

chat.updateMessage(id, { content: editedText });   // live-edit by admin
chat.deleteMessage(id);                            // retract
```

Surface them next to the FAB while the dock is closed:

```tsx
function Launcher() {
  const [open, setOpen] = useState(false);
  const { unread, markRead } = useChatUnread({ open });  // inside ChatProvider
  return (
    <ChatLauncher
      open={open}
      onOpenChange={setOpen}
      unreadMessage={unread}
      onMarkRead={markRead}
      // FAB badge "1" derived automatically; override via `fab.badge`
    >…</ChatLauncher>
  );
}
```

`<ChatUnreadPreview>` (auto-rendered by `ChatLauncher` when `unreadMessage` is set) shows avatar + sender + 2-line truncated body + timestamp. Click → open + mark read. × → mark read without opening.

### Tab/favicon notifier (Facebook-style)

For when the user is in another tab and you want them to notice. Drop-in replacement for `useChatUnread`:

```tsx
function Launcher() {
  const [open, setOpen] = useState(false);
  // Same shape as useChatUnread, plus mutates document.title + favicon
  // when the tab is hidden + count > 0. Restores on focus / open.
  const { unread, count, markRead } = useChatUnreadNotifier({ open });
  // …
}
```

Defaults are sober: `document.title` becomes `(N) Original Title` (no rotation, no emoji — the favicon is the attention signal). Favicon gets a small red dot in the corner. Switch to title rotation when there's no favicon to lean on: `useChatUnreadNotifier({ browser: { title: { mode: 'rotate' } } })`. Number-in-badge: `useChatUnreadNotifier({ browser: { favicon: { showCount: true } } })`.

**Cross-tab.** Three tabs open, a message arrives — only one tab (the elected leader) blinks the title; all three show the same FAB-badge count. Built on `useActiveTab` in `@djangocfg/ui-core/hooks` (BroadcastChannel + leader election). Disable with `crossTab: false` for single-tab hosts (Wails / Electron).

**Native hosts.** Pass your own `notifier: ChatNotifier` to skip the browser implementation and emit Wails / Electron / Tauri events instead:

```tsx
const dockBadge: ChatNotifier = {
  setUnread: (count) => window.runtime.EventsEmit('chat:unread', count),
  clear:     ()       => window.runtime.EventsEmit('chat:unread', 0),
};
useChatUnreadNotifier({ open, notifier: dockBadge, crossTab: false });
```

### Audio

Pass an `audio` config (`ChatAudioConfig` — the sounds map) to `<ChatLauncher>` and the header auto-injects a mute toggle. The launcher owns `useChatAudio()` internally; consumers no longer wire the hook themselves.

```tsx
// Zero-setup — built-in sounds are bundled as base64 data URLs
<ChatLauncher transport={transport} audio={{}} dock={{ title: 'Helper' }}>
  <ChatRoot />
</ChatLauncher>
```

**Built-in sounds.** The chat tool ships its own notification pack (`messageSent`, `messageReceived`, `streamStart`, `error`, `mention`, `notification`) inlined as `data:audio/mpeg;base64,…` URLs inside `core/audio/sounds/*.ts` modules. ~136KB total — paid only by hosts that actually import Chat. No `.mp3` loader, no `*.d.ts` shim, no CDN to host. Re-encode source mp3s via ffmpeg + `base64 -i …` and paste back into the matching `.ts` file (see `core/audio/defaults.ts` header for the recipe).

Customize:

```tsx
import { DEFAULT_CHAT_SOUNDS } from '@djangocfg/ui-tools/chat';

// Override one event, keep the rest
<ChatLauncher audio={{ sounds: { ...DEFAULT_CHAT_SOUNDS, mention: '/sfx/custom.mp3' } }} ... />

// Disable entirely (header toggle auto-hides via headerSlots.audio = false default)
<ChatLauncher audio={{ sounds: {} }} ... />
```

Per-event volume defaults — Slack / Linear / Intercom style:

| Event | Scale | Why |
|---|---|---|
| `error` | 0.25 | The destructive UI is the loud signal; sound is a soft ack |
| `streamStart` | 0.3 | Fires often, must stay subtle |
| `messageSent` | 0.5 | Self-confirmation |
| `messageReceived` | 0.7 | Baseline |
| `notification` | 0.9 | Push to a background tab — needs to be heard |
| `mention` | 1.0 | Louder than baseline — personal |

Override via `eventVolumes: { error: 0, mention: 0.8 }`. Pass `eventVolumes: {}` to skip defaults entirely.

`muted` / `toggleMute()` state is persisted in localStorage by the internal hook. The `headerSlots.audio` toggle auto-hides when the resolved audio instance is silent (no sounds wired, or `silenced: true`).

**Native hosts (cmdop_go / Tauri / Electron).** Skip web playback but keep the trigger as a side-channel:

```tsx
<ChatLauncher
  audio={{ silenced: true, onSoundEvent: (e) => window.go.playSound(e) }}
  ...
/>
```

See [`@djangocfg/ui-core` Audio docs](../../../../ui-core/README.md#audio) for the underlying `useNotificationSounds` primitives.

### Reset / clear conversation

Use `headerSlots.reset` — declarative, no JSX. `sessionId` and `clearMessages()` are pulled from chat context automatically; the button is hidden until a `sessionId` exists.

```tsx
<ChatLauncher
  transport={transport}
  headerSlots={{
    reset: {
      onReset: async () => {                        // backend POST /chat/reset
        await api.clearChat();
        return true;                                // resolve `true` on success
      },
      confirmMessage: "Forget this conversation? The assistant won't remember it.",
      // onSuccess defaults to ctx.clearMessages(); override to re-fetch history,
      // navigate, fire analytics, etc.
    },
  }}
>
  <ChatRoot />
</ChatLauncher>
```

Calls `window.dialog.confirm` (destructive variant) from `@djangocfg/ui-core/lib/dialog-service`. Host must mount `<DialogProvider>`. Falls back to native `window.confirm` if the dialog service isn't installed.

For custom dock shells that don't use `headerSlots`, the raw `<ChatHeaderResetButton onReset onSuccess confirmMessage />` is still exported.

### Anti-patterns

| Don't | Why |
|---|---|
| Two `<ChatLauncher>` on one page | Hotkey opens both at once |
| `hotkey={{ key: '/' }}` (no modifier) | Fires when user types `/` in any input |
| Mismatched `exitDurationMs` vs CSS | Dock unmounts before/after animation |
| Reusing `dismissStorageKey` across products | Greeting won't show on the second product |
| Calling `useChatUnread()` outside `ChatProvider` | Hook reads the messages store; throws without provider |
| Forgetting `<DialogProvider>` for `headerSlots.reset` (or raw `<ChatHeaderResetButton>`) | Falls back to native browser confirm (ugly) |
| Passing `dock.headerActions` (removed in the new launcher) | Use `headerSlots` instead — it renders inside the provider so reset can read `sessionId` / `clearMessages` |
| Calling `useChatAudio()` in consumer code | Pass `audio={{}}` (or `{ sounds: {…} }`) to `<ChatLauncher>` — the launcher owns the hook now |
| Mixing **root barrel** and **subpath** imports | Loads the package twice → two React contexts → `useChatContextOptional()` returns `null` → VoiceComposerSlot silently drops transcripts. See **Import discipline** below. |

#### Import discipline

`@djangocfg/ui-tools` ships its root export (`.`) through a compiled bundle in `dist/`, while most subpaths (`./chat`, `./speech-recognition`, `./audio-player`, …) resolve to raw `src/`. JavaScript bundlers treat them as **two separate modules**, so each gets its own `React.createContext(...)` call.

```tsx
// ❌ DON'T: ChatRoot from `dist`, VoiceComposerSlot from `src` → two contexts
import { ChatRoot } from '@djangocfg/ui-tools/chat';
import { VoiceComposerSlot } from '@djangocfg/ui-tools/speech-recognition';
//                                                         ^^^^^^^^^^^^^^^^^^^
// VoiceComposerSlot's internal useChatContextOptional() reads a DIFFERENT
// React context than the one ChatProvider populated. ctx.composer is null,
// every transcript gets a "no composer handle registered" warning.

// ✅ DO: pull every Chat-side surface from the same `./chat` subpath
import { ChatRoot, ChatLauncher, useChatContextOptional } from '@djangocfg/ui-tools/chat';
import { VoiceComposerSlot } from '@djangocfg/ui-tools/speech-recognition';
```

The rule of thumb: **anything that talks to `ChatProvider` belongs on the `./chat` subpath**. The root barrel is for tooling glue (types, transports, generic helpers) and is safe to mix with subpaths as long as you don't import anything that uses the Chat React context from it.

If two ChatProvider context instances ever end up in the same page, `ChatProvider` emits a one-time `console.warn` in development with the same fix instructions — that's the runtime guard, look for `[@djangocfg/ui-tools/chat] Two ChatProvider context instances detected …` in DevTools.

## Styling — role-aware tokens

Every chat surface that depends on role/error state uses the centralized tokens, not inline Tailwind literals. Change `bg-primary`/contrast in one file — fixes every consumer.

```ts
import { BUBBLE_SURFACE, ANCHOR, TOGGLE, DESTRUCTIVE_SURFACE } from '@djangocfg/ui-tools/chat';

// Token shape
BUBBLE_SURFACE.user        // 'bg-primary text-primary-foreground rounded-tr-md'
BUBBLE_SURFACE.assistant   // 'bg-muted text-foreground rounded-tl-md'
BUBBLE_SURFACE.error       // 'bg-destructive/10 text-destructive rounded-tl-md border border-destructive/30'

ANCHOR.user                // legible on bg-primary (uses primary-foreground, not white)
ANCHOR.assistant           // brand-primary on neutral bubble

DESTRUCTIVE_SURFACE.banner / .hover / .hoverStrong / .text / .menuItem
```

Prefer hooks in components — they memoize and present a stable facade:

```tsx
import { useChatBubbleStyles, useChatRoleStyles, useChatDestructiveStyles } from '@djangocfg/ui-tools/chat';

function MyBubble({ message }) {
  const { surface, anchor } = useChatBubbleStyles(message.role, !!message.isError);
  return <div className={cn('rounded-2xl px-3 py-2', surface)}>…</div>;
}

function MyToggle({ isUser }) {
  const { toggle } = useChatRoleStyles(isUser);
  return <button className={toggle}>Read more</button>;
}

function MyError() {
  const { banner, hover } = useChatDestructiveStyles();
  return <div className={banner}>…<button className={hover}>Dismiss</button></div>;
}
```

## Message blocks

A message can carry typed, serializable rich content beyond its markdown
`content`: an optional `blocks: MessageBlock[]`. `MessageBlock` is a
discriminated union on `kind` — `text`, `markdown`, `audio`, `video`,
`image`, `gallery`, `map`, `json`, `mermaid`, `code`, `lottie`, plus a
`custom` escape hatch. Payloads are plain JSON, so blocks survive history
persistence and SSE transport.

`<MessageBubble>` renders blocks **after** the markdown bubble, before
`toolCalls`. `text`/`markdown` blocks keep the bubble surface; media
blocks render full-width below it. Legacy messages (no `blocks`) render
byte-for-byte unchanged.

```ts
const msg: ChatMessage = {
  id: 'm-42', role: 'assistant', createdAt: Date.now(), content: '',
  blocks: [
    { kind: 'markdown', id: 'b1', markdown: 'Recording from **vps-audi**:' },
    { kind: 'audio', id: 'b2', src: 'https://cdn…/call.mp3', title: 'Call · 04:12' },
    { kind: 'map', id: 'b3', caption: 'Server', center: { lat: 52.52, lng: 13.405 }, zoom: 11 },
  ],
};
```

### The registry

Each `kind` maps to a `BlockRenderer` in a `BlockRegistry` — same idea as
`AttachmentRendererMap`, but keyed by the discriminant. Built-in renderers
cover the in-house tools (`BUILTIN_BLOCK_REGISTRY`); each is reached
through a `React.lazy` chunk, so a chat with no map block never downloads
MapLibre.

Merge host overrides with `createBlockRegistry`:

```ts
const registry = createBlockRegistry({
  json: (block) => <MyJsonCard data={block.data} />, // override one kind
});
```

Pass it via `<ChatRoot messages={{ blockRegistry }}>` — it is threaded
through context to every `<MessageBubble>`. `<MessageBubble>` also accepts
a direct `blockRegistry` prop (prop beats context) for standalone /
Storybook use. An unknown `kind` is skipped silently (lenient default);
`<MessageBlocks strict>` surfaces a dev notice. Each block is wrapped in
an error boundary so one malformed block can't blank the transcript.

Exports (`@djangocfg/ui-tools/chat`): `MessageBlocks`,
`createBlockRegistry`, `BUILTIN_BLOCK_REGISTRY`, and the `MessageBlock` /
`BlockRegistry` / `BlockRenderer` / `BlockRenderContext` types.

## Transport contract

A single I/O seam.

```ts
interface ChatTransport {
  createSession(opts?): Promise<SessionInfo>;
  loadHistory(sessionId, cursor?, limit?): Promise<HistoryPage>;
  stream(sessionId, content, { signal, attachments, metadata }): AsyncGenerator<ChatStreamEvent>;
  send(sessionId, content, options?): Promise<ChatMessage>;
  closeSession(sessionId): Promise<void>;
}
```

Shipped implementations:

- **`createPydanticAIChatTransport({ buildStreamUrl, buildHeaders, bootstrapSession, loadHistory, ... })`** — for Django/pydantic-AI–style backends. Composes `parseSSE` + `mapPydanticAIEvent` + tool-id FIFO queue. Side-channel via `onPydanticEvent` for non-stream events (e.g. `approval_required`).
- **`createHttpTransport({ baseUrl, getAuthHeader, slug })`** — fetch + SSE for the canonical `POST /sessions/:id/messages` shape.
- **`createMockTransport({ replies, latencyMs })`** — scripted in-memory replies for stories/tests.

Build a custom transport directly when none of the above fit.

### Pydantic-AI shortcuts

```ts
import {
  createPydanticAIChatTransport,
  createPydanticAISSEMap,
  mapPydanticAIEvent,
  createToolIdQueue,
} from '@djangocfg/ui-tools/chat';

// In a custom transport / hand-rolled stream loop:
const toolIds = createToolIdQueue();
for await (const event of parseSSE(res, { map: createPydanticAISSEMap() })) {
  yield event;
}
```

## `ChatRoot` props

`<ChatRootProps>` groups ~13 keys by concern — no flat `composer*` namespace:

```tsx
<ChatRoot
  // core wiring
  transport={transport}
  config={{ greeting: 'Hi!' }}
  session={{ initialId, autoCreate: true }}
  streaming
  audio={{}}
  debug

  // presentation
  appearance="compact"            // 'compact' | 'full' — scales the whole chat
  className="…"
  listClassName="px-6 pt-6"       // padding inside the scroll viewport

  // composition (grouped)
  slots={{
    banner: <QuotaWarning />,
    header: (ctx) => <MyHeader sessionId={ctx.sessionId} />,  // node or fn
    empty: ({ setValue, focus }) => <MyEmpty seed={setValue} />,  // node or fn
    jumpToLatest: <MyPill />,
  }}
  composer={{
    size: 'lg',                   // 'sm' | 'md' | 'lg'
    layout: 'stacked',            // 'stacked' | 'inline'
    className: '…',
    hidden: false,                // unmount the composer (human-in-the-loop pause)
    showAttachmentButton: true,
    onPickFiles: () => openPicker(),
    slots: { actionsStart, actionsEnd, blockStart },  // ComposerSlots
    footer: { showCounter: true } /* | false to hide */,
    render: ({ composer, config }) => <MyComposer composer={composer} />,
  }}
  messages={{
    render: (m, i) => <MyBubble message={m} />,
    renderAfter: (m) => <SideChannelWidget messageId={m.id} />,
    toolCallsProps: { defaultExpanded: true },
    attachmentRenderers: { image: MyImageTile },
    onAttachmentOpen: (a) => openLightbox(a),
  }}

  // behavior
  focusOnEmptyClick                // default true

  // page context — extra metadata computed fresh per send
  getDynamicMetadata={() => ({ pageContext })}
/>
```

`getDynamicMetadata` is forwarded to the `<ChatProvider>` that `ChatRoot`
creates; it is **ignored** when `ChatRoot` mounts under an ambient
provider — set it on that provider instead. Typically wired from
`usePageSnapshot().getChatMetadata` (see `src/lib/page-snapshot`).

The `slots.header` / `slots.empty` accept either a `ReactNode` or a render
function — there is no separate `renderHeader` / `renderEmpty` prop and no
"which one wins" precedence. `composer.render` fully replaces the built-in
`<Composer>`; it receives `{ composer, config }` — the live composer hook
plus the same config object. See **[Slots inventory](#slots-inventory)** below.

## Composer slot system

`<Composer>` is a single bordered input surface — textarea on top, an
action bar pinned to the bottom inside the same frame (attach
bottom-left, mic + send bottom-right). Two layouts:

- `layout="stacked"` (default) — Telegram-style two-tier surface.
- `layout="inline"` — compact single row. `size="sm"` defaults to this.

### Tier A — declarative actions

Pass `ComposerAction[]` arrays; the composer renders consistent,
correctly-aligned, fully-labelled buttons. Built-in send/stop/attach are
themselves descriptors — host extras land between attach and mic/send.

```tsx
<Composer
  composer={composer}
  composerSlots={{
    actionsStart: [{ id: 'emoji', icon: <Smile />, label: 'Emoji', onClick }],
    actionsEnd: [{ id: 'model', icon: <Sparkles />, label: 'Model', onClick }],
    blockStart: <ReplyBanner />,        // full-width row above the textarea
  }}
/>
```

`ComposerAction` fields: `id`, `icon`, `label` (required — `aria-label` +
tooltip), `onClick`, `disabled?`, `pressed?` (→ `aria-pressed`),
`variant?`, `hideWhen?` (`streaming` | `empty` | `hasText` | `disabled`),
`order?`. Memoize the arrays so the action bar does not churn.

### Tier B — slot replacement

Replace a primitive entirely via `slots` / tweak its props via
`slotProps`:

```tsx
<Composer composer={composer}
  slots={{ SendButton: MyBrandedSend, Textarea: MyEditor }}
  slotProps={{ textarea: { className: 'font-mono' } }}
/>
```

Swappable: `SendButton`, `AttachButton`, `Textarea`, `ActionBar`.

### mic ↔ send swap

`micSendSwap` (default `true`) — Telegram behaviour: an action with
`id: 'mic'` (or `hideWhen: 'hasText'`) shows only while the draft is
empty; once there is text, send takes its place. Set `false` to keep
both visible (voice-message hosts).

### Footer toolbar

`<ComposerFooter>` is the quiet strip below the surface — three zones:
`start` (auto keyboard hint), `center` (host extras), `end` (auto char
counter that only appears near `maxLength`). Configure via the
`footer` prop on `<Composer>` or `composer.footer` on `<ChatRoot>`;
pass `false` to hide.

### Composer kit — ready-made slot widgets

ChatGPT / Gemini-style controls that drop straight into `composerSlots`.
All are exported from `@djangocfg/ui-tools/chat` and size-aware (they
inherit the composer `size` via context, no prop needed).

| Component | Slot | What it is |
|---|---|---|
| `<ComposerMenuButton items={MenuItem[]}>` | `inlineStart` | The ChatGPT `+` button — opens a declarative `MenuBuilder` dropdown (sections, submenus, shortcuts). |
| `<ComposerToolPill icon label active onRemove>` | `inlineStart` | Gemini-style capsule for a selected tool/mode with an optional `×` to clear it. |
| `<ComposerModelPicker value options onChange>` | `inlineEnd` | "Flash-Lite ▾" pill — opens a radio-group model picker. |
| `<ComposerBanner variant title description actions onDismiss>` | `blockStart` | Standalone notice bubble above the composer (upsell / quota / info). |
| `<ComposerRichTextarea mentions>` | `slots.Textarea` (Tier B) | Ready-made TipTap chat editor — unstyled, no toolbar, size-matched height, Enter-to-send, `@`-mention support. |

```tsx
<Composer
  composer={composer}
  composerSlots={{
    blockStart: <ComposerBanner variant="upgrade" title="Free plan limit" … />,
    inlineStart: <ComposerMenuButton items={MENU_ITEMS} />,
    inlineEnd: <ComposerModelPicker value={model} options={MODELS} onChange={setModel} />,
  }}
/>
```

### `appearance` — compact vs full

`<Composer appearance="full">` layers extra spaciousness (radius,
padding, taller textarea, larger text) over the chosen `size` — for a
full-page ChatGPT/Gemini surface. `compact` (default) keeps the embedded
docked-chat geometry. Orthogonal to `size`; `<ChatRoot appearance="full">`
threads it to both the composer and the message bubbles.

### Click-to-focus surface

The whole input panel reads as a text field — the padding around the
editor shows a text caret and a `mousedown` on the bare surface (not a
button) focuses the editor. Works for both the plain `<textarea>` and the
TipTap (`contenteditable`) backend.

## Three usage patterns

### 1. One-line preset

```tsx
<ChatRoot transport={transport} config={{ greeting: 'Hi!' }} />
```

### 2. Composition

```tsx
<ChatProvider transport={transport}>
  <MyHeader />
  <MessageList renderEmpty={() => <EmptyState greeting="Custom" />} />
  <MyComposer />
</ChatProvider>
```

### 3. Headless

```tsx
const chat = useChat({ transport });
const composer = useChatComposer({ onSubmit: chat.sendMessage });
```

## Mobile & a11y

- `useChatLayout` collapses `sidebar`/`floating` to `fullscreen` below `(max-width: 640px)`.
- `ChatDock` fills the viewport below 768px (`mobileFullscreen` prop, default `true`).
- Composer textarea is `font-size: 16px` to disable iOS focus-zoom; container uses `100dvh` and `env(safe-area-inset-bottom)`.
- `MessageList` is `role="log"` with `aria-live="polite"`; streaming bubbles set `aria-busy`.
- `ErrorBanner` is `role="alert"`.
- `JumpToLatest` announces unread count via `aria-live="polite"`.
- All visible Boundary fallbacks and the Greeting bubble carry `role="alert"` / `aria-live`.

## Public surface

```ts
// Types (re-exported from types/)
ChatMessage, ChatRole, ChatPersona, ChatToolCall, ChatAttachment, ChatSource,
ChatConfig, ChatUserContext, ChatAssistantContext, ChatPrefs, ChatLabels, DEFAULT_LABELS,
ChatTransport, ChatStreamEvent, ChatDisplayMode,
SessionInfo, HistoryPage, StreamOptions, SendOptions, CreateSessionOptions

// Core
reducer, initialState, createId, createTokenBuffer,
resolvePersona, deriveInitials
type ChatState, type ChatAction, type TokenBuffer

// Transport
createHttpTransport, createMockTransport, createPydanticAIChatTransport,
mapPydanticAIEvent, createPydanticAISSEMap, createToolIdQueue,
parseSSE, TransportError
type HttpTransportConfig, type MockTransportOptions, type ParseSSEOptions,
type PydanticAIChatTransportOpts, type PydanticAIEvent, type ToolIdQueue

// Hooks
useChat, useChatComposer, useChatHistory, useChatLayout,
useChatLightbox, useAutoFocusOnStreamEnd, useRegisterComposer,
useChatReset, useVisitorFingerprint, useChatUnread, useChatUnreadNotifier,
createBrowserNotifier, createCrossTabNotifier, createTitleRotator, createFaviconBadge,
useFocusOnEmptyClick
// `useChatAudio` and `useChatDockPrefs` are still exported for advanced custom
// shells, but `<ChatLauncher>` owns them via `audio` prop + `headerSlots.modeToggle.persistAs`.
type ChatDockPrefs, DEFAULT_DOCK_PREFS

// Styles — role-aware tokens + hooks
BUBBLE_SURFACE, ANCHOR, TOGGLE, DESTRUCTIVE_SURFACE, TOOL_CALL,
useChatBubbleStyles, useChatRoleStyles, useChatDestructiveStyles
type ChatBubbleSurface, type ChatBubbleStyles, type ChatRoleStyles, type ChatDestructiveStyles

// Launcher
ChatFAB, ChatDock, ChatHeader, ChatHeaderActionButton, ChatHeaderModeToggle,
ChatHeaderAudioToggle, ChatHeaderResetButton, ChatHeaderLanguageButton,
ChatLauncher, ChatGreeting, ChatUnreadPreview, useChatPresence
type ChatFABProps, type ChatFABVariant, type ChatFABSize, type ChatFABPosition,
type ChatDockProps, type ChatDockMode, type ChatDockSide,
type ChatHeaderProps, type ChatHeaderActionButtonProps, type ChatHeaderModeToggleProps,
type ChatHeaderAudioToggleProps, type ChatHeaderResetButtonProps,
type ChatHeaderLanguageButtonProps,
type ChatGreetingProps, type ChatUnreadPreviewProps,
type ChatLauncherProps, type ChatLauncherHotkey, type ChatLauncherGreeting,
type ChatHeaderSlots, type ChatHeaderResetSlot, type ChatHeaderLanguageSlot,
type ChatHeaderModeToggleSlot,
type ChatPresencePhase

// Audio
ChatAudioConfig, ChatAudioEvent, ChatAudioSounds, UseChatAudioReturn,
useChatAudioPrefs, DEFAULT_CHAT_SOUNDS

// Tool-payload dispatch
dispatchToolPayload, isPlainObject, isLatLng,
isGeoJSONFeatureCollection, isStringValue,
type ToolPayloadMatcher, type ToolPayloadFallback, type ToolPayloadKind, type ToolCallsProps

// Draft sanitation
sanitizeDraft, isSubmittableDraft

// Context
ChatProvider, useChatContext, useChatContextOptional, type ComposerHandle
// `ComposerHandle` shape: { focus, moveCursorToEnd?, getValue?, setValue? }.
// Voice slot, autoFocus hook, and any external driver read it from
// context — built-in Composer registers itself, custom composers use
// `useRegisterComposer({...})`.

// Components
ChatRoot, MessageList, MessageBubble, MessageActions, Composer,
Sources, ToolCalls, Attachments, EmptyState, ErrorBanner,
JumpToLatest, StreamingIndicator
// `MessageList` exposes `atBottomThreshold` (default 120 px) and
// `scrollAnchorId` props for ChatGPT-style autoscroll — see "What you get".

// Composer kit — slot widgets + types
ComposerButton, ComposerActionBar, ComposerFooter,
ComposerMenuButton, ComposerToolPill, ComposerModelPicker,
ComposerBanner, ComposerRichTextarea, useComposerActions, useResolvedComposerSize
type ComposerProps, type ComposerSize, type ComposerAppearance,
type ComposerLayout, type ComposerAction, type ComposerActionVisibility,
type ComposerSlots, type ComposerSlotComponents, type ComposerSlotProps,
type ComposerFooterProps, type ComposerMenuButtonProps, type ComposerToolPillProps,
type ComposerModelPickerProps, type ComposerModelOption,
type ComposerBannerProps, type ComposerBannerAction, type ComposerRichTextareaProps,
type ComposerTextareaProps, type SendButtonProps, type AttachButtonProps

// Message blocks
MessageBlocks, createBlockRegistry, BUILTIN_BLOCK_REGISTRY
type MessageBlock, type BlockAppearance, type BlockRegistry,
type BlockRenderer, type BlockRenderContext

// Lazy preset
LazyChat
```

## Storybook

Stories are grouped into five feature sub-groups under `UI Tools/Chat`,
plus `Overview` (MDX) and `Showcase` (a one-screen living demo):

- **Getting Started** — `ChatRoot`, `Composition` (bring-your-own-layout),
  `Appearance` (compact / full / huge).
- **Messages** — `Bubbles`, `Markdown`, `Message Blocks`,
  `Tool Calls & Sources`, `Streaming`, `Personas`.
- **Composer** — `Playground`, `Layout & Actions`, `Menu & Tools`,
  `Banner`, `Mentions`, `Speech & Attachments`.
- **Launcher** — `Playground`, `Parts`, `Header`,
  `Unread & Notifications`.
- **Transports** — the three shipped transports with a stubbed `fetchImpl`.

---

<a id="slots-inventory"></a>

## Slots inventory

| Key | Position | Type |
|---|---|---|
| `slots.banner` | Top of message list | `ReactNode` |
| `slots.header` | Above messages | `ReactNode \| (ctx) => ReactNode` |
| `slots.empty` | Empty state | `ReactNode \| ({ setValue, focus }) => ReactNode` |
| `slots.jumpToLatest` | Sticky overlay | `ReactNode` |
| `composer.slots.actionsStart` / `actionsEnd` | Composer action clusters | `ComposerAction[]` |
| `composer.slots.blockStart` | Above composer textarea | `ReactNode` |
| `composer.footer` | Below composer | `ComposerFooterProps \| false` |
| `composer.render` | Replace `<Composer>` | `({ composer, config }) => ReactNode` |
| `messages.render` | Replace each bubble | `(m, i) => ReactNode` |
| `messages.renderAfter` | Below every assistant bubble | `(m) => ReactNode` — fires for every message, independent of `toolCalls` |

Flags:

- `composer.hidden` — agent-pause / human-in-the-loop pause; composer is unmounted.

### Which slot for product widgets — `renderAfterCalls` vs `messages.renderAfter`?

Both put custom content under the assistant bubble; the difference is **what triggers them**.

- `renderAfterCalls` is gated on `message.toolCalls?.length > 0`. Use it when the widget is **derived from raw tool output** (read `call.output`) and you can rely on the host streaming the `tool_call` / `tool_result` SSE frames. Admin / dev flows typically work this way.
- `messages.renderAfter` fires **for every message**, regardless of `toolCalls`. Use it when the widget is driven by a side channel — e.g. typed `ui_payload` SSE frames the host emits independently of the raw tool surface. This is the correct slot when the public-prod stream **hides** `tool_call` events for security: the message lands with `toolCalls === undefined`, so `renderAfterCalls` would never mount, but `messages.renderAfter` still runs and the widget renders from the side channel.

Recommended pairing for a "vehicle cards" / "tax breakdown" / "chart" widget on a public chat:

```tsx
<ChatRoot
  transport={transport}
  messages={{ renderAfter: (m) => <VehicleCardsForMessage messageId={m.id} /> }}
/>
```

`VehicleCardsForMessage` subscribes to your `ui_payload` event bus (filtering by `m.id`) and returns `null` when there's nothing to show. Streaming-safe: the renderer is called for streaming messages too, so progressive UI (skeletons that fill in as payloads arrive) works as expected.

## Hotkeys

- `Enter` — send (or `Cmd+Enter` if `prefs.submitOn = 'cmd+enter'`).
- `Shift+Enter` — newline.
- `Esc` — cancel current streaming turn (when focused inside the composer).
- Launcher hotkey: configurable via `<ChatLauncher hotkey={…}>` (e.g. `⌘/`).
