# 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';

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
      hotkey={{ key: '/', meta: true }}
      fab={{ variant: 'animated', tooltip: 'Open chat (⌘/)' }}
      dock={{ title: 'Assistant', height: 600 }}
      greeting="Hi 👋 Need help?"
    >
      <ChatRoot transport={transport} config={{ greeting: 'How can I help?' }} />
    </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.** `ChatHeader` + `ChatHeaderActionButton` + ready-made `ChatHeaderModeToggle` / `ChatHeaderAudioToggle` / `ChatHeaderResetButton` (with `window.dialog.confirm`) / `ChatHeaderLanguageButton` (flag picker for speech-recognition language).
- **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.
- **Personas.** `config.user` + `config.assistant` for default identity; `message.sender` for per-message overrides.
- **Audio.** `useChatAudio` over `@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) — `useChatAudio()` with no arguments 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.** `useChatDockPrefs()` stores `mode` / `side` / `width` in localStorage; `<ChatHeaderModeToggle>` lets users flip popover ↔ side and survives reloads.
- **UX guards.** Auto-focus composer on dock open, two-step Escape (textarea blur → close), click-to-focus on empty message area (Slack / Linear 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 `composerToolbarEnd` 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.** `<ChatHeaderLanguageButton />` slots into the dock header — 28×28 country flag 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.
- **Auto-focus on stream end.** `useAutoFocusOnStreamEnd()` (active inside `ChatRoot` by default) re-focuses the composer on the streaming → idle edge — type → send → read → keep typing without reaching for the mouse.
- **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 / useChatScroll / useChatHistory / useChatLayout)
   ↓
ChatProvider (context)
   ↓
Components (MessageList, MessageBubble, Composer, Sources, ToolCalls, …)
   ↓
ChatRoot (one-line preset)  ◄──  optionally wrapped by  ──►  ChatLauncher (FAB + Dock + Greeting)
```

Module boundaries:

| Layer            | May import                       | May NOT import           |
| ---------------- | -------------------------------- | ------------------------ |
| `types/`         | —                                | anything                 |
| `core/transport` | `types/`                         | React, hooks, components |
| `core/reducer`   | `types/`                         | React, transport         |
| `hooks`          | `core/*`, `ui-core` hooks        | components               |
| `context`        | `hooks`                          | components               |
| `styles/`        | `types/`                         | hooks, components        |
| `components`     | `hooks`, `context`, `styles`, `ui-core` UI | transport implementations |
| `launcher/`      | `components`, `styles`, `ui-core` UI | 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` |

## 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 |
| `<ChatHeader>` + `<ChatHeaderActionButton>` | Custom header chrome |
| `<ChatHeaderModeToggle>` | Popover ↔ side toggle (desktop-only, auto-hides below `lg`) |
| `<ChatHeaderAudioToggle>` | Mute / unmute notification sounds |
| `<ChatHeaderResetButton>` | Clear conversation with `window.dialog.confirm` |
| `<ChatHeaderLanguageButton>` | Flag-button language picker (66 BCP-47 tags, persists in `useSpeechPrefs`) |

### 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
  headerActions: <ChatHeaderResetButton ... />,
}}
```

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

**Persisted prefs.** `useChatDockPrefs()` stores `mode` / `side` / `sideWidth` in localStorage. Drop it into your launcher to give users a remembered layout:

```tsx
const prefs = useChatDockPrefs({ storageKey: 'crm.chat.dock' });
<ChatLauncher
  dock={{
    mode: prefs.mode,
    side: prefs.side,
    width: prefs.mode === 'side' ? prefs.sideWidth : 480,
    headerActions: <ChatHeaderModeToggle mode={prefs.mode} onToggle={prefs.toggleMode} />,
  }}
>{children}</ChatLauncher>
```

#### 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.

### Audio

Wire `useChatAudio` once and pass it to `ChatLauncher` — the header gets an auto-injected mute toggle:

```tsx
// Zero-setup — built-in sounds are bundled as base64 data URLs
const audio = useChatAudio();

<ChatLauncher audio={audio} dock={{ title: 'Helper' }}>
  <ChatRoot transport={transport} />
</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 { useChatAudio, DEFAULT_CHAT_SOUNDS } from '@djangocfg/ui-tools';

// Override one event, keep the rest
useChatAudio({ sounds: { ...DEFAULT_CHAT_SOUNDS, mention: '/sfx/custom.mp3' } });

// Disable entirely (header toggle hides itself)
useChatAudio({ 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.

`audio.muted` / `audio.toggleMute()` are persisted in localStorage. Header `<ChatHeaderAudioToggle>` is hidden automatically when `audio.isSilent` (no sounds wired).

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

```tsx
const audio = useChatAudio({
  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

```tsx
<ChatHeaderResetButton
  onReset={api.clearChat}            // your backend POST /chat/reset
  onSuccess={() => chat.clearMessages()}
  confirmMessage="Forget this conversation? The assistant won't remember it."
/>
```

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.

### 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 `<ChatHeaderResetButton confirm>` | Falls back to native browser confirm (ugly) |
| 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';
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';

// 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';

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>;
}
```

## 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';

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

## Slots

`<ChatRoot>` exposes named-prop slots — `header`, `footer`, `banner`, `empty`, `composerToolbarStart/End`, `composerAttachmentTray`, `jumpToLatest`. None are required.

See **[Slots inventory](#slots-inventory)** below for the full list, plus `hideComposer` / `hideToolCalls` / `renderToolCall` / `renderAfterCalls` patterns.

## 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, useChatScroll, useChatHistory, useChatLayout,
useChatAudio, useChatLightbox, useAutoFocusOnStreamEnd, useRegisterComposer,
useChatReset, useVisitorFingerprint, useChatDockPrefs, useChatUnread,
useFocusOnEmptyClick
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 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".

// Lazy preset
LazyChat
```

## Storybook

Stories live next to components — open `@djangocfg/playground`:

- `Tools/Chat/Basic` — Default + Composition patterns.
- `Tools/Chat/Bubbles` — every bubble state in one screen.
- `Tools/Chat/ToolCalls` — streaming panels + JsonTree payload dispatch.
- `Tools/Chat/Personas` — per-message persona overrides, multi-user transcripts.
- `Tools/Chat/Launcher` — Default / Playground / LiveChatStyle / MobileFullscreen / WithLivePush / VariantsAndSizes.
- `Tools/Chat/Header` — `ChatHeader` standalone, persistent dock prefs, side mode.
- `Tools/Chat/Audio & Actions` — audio toggle auto-inject, reset with `window.dialog.confirm`.
- `Tools/Chat/Voice composer` — `<VoiceComposerSlot>` wired into the composer toolbar with a mock STT engine.

---

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

## Slots inventory

| Slot | Position | Render-prop variant |
|---|---|---|
| `header` | Above messages | `renderHeader({ messages })` |
| `footer` | Below composer | `renderFooter({ canSend })` |
| `banner` | Top of message list | `renderBanner({ error, dismiss, retry })` |
| `empty` | Empty state | `renderEmpty({ config, send })` |
| `composerToolbarStart` / `composerToolbarEnd` | Composer toolbar | — |
| `composerAttachmentTray` | Above composer textarea | `renderAttachmentTray({ attachments })` |
| `jumpToLatest` | Sticky overlay | `renderJumpToLatest({ unread, scrollToBottom })` |
| `renderToolCall` | Per tool-call panel | `(call) => ReactNode` |
| `renderAfterCalls` | After all tool panels | `(calls) => ReactNode` (rich UI like vehicle cards) |

Flags:

- `hideComposer` — agent-pause / human-in-the-loop pause; composer is unmounted.
- `hideToolCalls` — show only `renderAfterCalls` rich UI without raw tool panels.

## 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. `⌘/`).
