# Chat

Decomposed, transport-agnostic chat. Streaming-aware, markdown-native, mobile-ready.

## TL;DR

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

const transport = createHttpTransport({
  baseUrl: '/api/chat',
  getAuthHeader: () => ({ Authorization: `Bearer ${getToken()}` }),
});

export function MyChat() {
  return (
    <div className="h-[600px]">
      <ChatRoot transport={transport} config={{ greeting: 'How can I help?' }} />
    </div>
  );
}
```

## What you get

- **Headless first.** Pure reducer + hooks; UI is optional and replaceable.
- **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. Token coalescing, cancel, regenerate, partial-keep on cancel.
- **Tool calls.** Live streaming panels — auto-open while running, auto-close on completion. User toggles are remembered.
- **Personas.** `config.user` + `config.assistant` for default identity; `message.sender` for per-message overrides (multi-user / multi-bot).
- **Audio triggers.** Optional sounds on `messageSent`, `messageReceived`, `streamStart`, `error`, `mention`, `notification`. iOS/Safari unlock handled automatically.
- **Rich attachments.** `AttachmentsGrid` for thumbnails, `AttachmentsList` for custom renderers; `onAttachmentOpen` for host-side lightbox.
- **Tool-payload dispatcher.** `dispatchToolPayload(matchers, fallback)` — pluggable predicates choose `<LazyJsonTree>` / `<LazyMap>` / etc. without ui-tools owning those deps.
- **Sources.** RAG citation chips on assistant messages.
- **Slots everywhere.** `header`, `footer`, `banner`, `empty`, `composerToolbarStart/End`, `composerAttachmentTray`, `jumpToLatest` — plus render-prop variants.
- **Mobile.** `100dvh`, safe-area inset, 16px textarea (no iOS zoom), responsive `useChatLayout`.
- **A11y.** `role="log"` + polite live region, `aria-busy` on streaming bubbles, `role="alert"` errors, focus-visible actions.

## Architecture

```
Transport (interface)              ←  HTTP+SSE / Wails / WebSocket / mock
   ↓
Reducer (pure state machine)
   ↓
Hooks (useChat / useChatComposer / useChatScroll / useChatHistory / useChatLayout)
   ↓
ChatProvider (context)
   ↓
Components (MessageList, MessageBubble, Composer, Sources, ToolCalls, …)
   ↓
ChatRoot (one-line preset)
```

Module boundaries:

| Layer            | May import                       | May NOT import           |
| ---------------- | -------------------------------- | ------------------------ |
| `core/transport` | `core/types`                     | React, hooks, components |
| `core/reducer`   | `core/types`                     | React, transport         |
| `hooks`          | `core/*`, `ui-core` hooks        | components               |
| `context`        | `hooks`                          | components               |
| `components`     | `hooks`, `context`, `ui-core` UI | transport implementations |

## Slots

`<ChatRoot>` exposes named-prop slots for the most common roles. None are required; omit any slot to fall back to the default.

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

  // ReactNode slots
  banner={<UpgradeBanner />}              // sticky note above the conversation
  header={<MyChatHeader />}               // title / actions row
  footer={<Disclaimer />}                 // below the composer
  empty={<MyEmptyState />}                // replaces default <EmptyState>
  composerToolbarStart={<VoiceButton />}  // left of the textarea
  composerToolbarEnd={<MentionButton />}  // right of the textarea
  composerAttachmentTray={<MyTray />}     // replaces default attachment tray
  jumpToLatest={<MyJumpPill />}           // replaces floating "↓ N new" pill

  // Render-prop slots (need access to data)
  renderMessage={(m, i) => <MyBubble message={m} />}
  renderHeader={(ctx) => (ctx.isStreaming ? <Streaming /> : <Idle />)}
  renderEmpty={({ setValue, focus }) => <MyOnboarding onPick={setValue} />}

  // Tool-call payload renderers — forwarded to <MessageBubble toolCallsProps>
  toolCallsProps={{
    defaultExpanded: true,
    renderInput:  (v) => <LazyJsonTree data={v} mode="compact" />,
    renderOutput: (v) => <LazyJsonTree data={v} mode="compact" />,
  }}

  // Hide composer while agent is paused waiting for human input
  hideComposer={hasPendingApprovals}
/>
```

### Slot inventory

```
┌───────────────────────────────┐
│ banner          (sticky)      │
├───────────────────────────────┤
│ header                        │
├───────────────────────────────┤
│ messages    (jumpToLatest)    │
├───────────────────────────────┤
│ composerToolbarStart          │  ← hidden when hideComposer={true}
│   [textarea]   composerToolbarEnd │
│ composerAttachmentTray        │
├───────────────────────────────┤
│ footer                        │  ← use for approval panels, disclaimers
└───────────────────────────────┘
```

### hideComposer — agent-pause / human-in-the-loop

Set `hideComposer={true}` to remove the composer while the agent is waiting for a human decision (approval gate, HITL). Combine with `footer` to show action buttons:

```tsx
<ChatRoot
  transport={transport}
  hideComposer={pendingActions.length > 0}
  footer={
    pendingActions.length > 0 ? (
      <AgentActions actions={pendingActions} onResult={handleResult} />
    ) : null
  }
/>
```

The composer reappears automatically once `hideComposer` returns `false`.

### ToolCalls — expand behavior

Panels are **collapsed by default**. While a tool is `running`, the panel auto-expands so you can see live `streamingText`; on completion it auto-collapses again. Manual user toggles (open or close) are remembered and override the auto-behavior for that call.

```tsx
<ToolCalls
  calls={message.toolCalls}
  // Defaults:
  defaultExpanded={false}        // start closed
  expandWhileStreaming={true}    // open during run, close on completion
/>
```

Force every panel open from the start (e.g. for debug views) with `defaultExpanded`.

### ToolCalls payload renderers

`<ToolCalls>` (and `<MessageBubble toolCallsProps>` / `<ChatRoot toolCallsProps>`) accept payload renderers so you control how tool input / output / streaming text is displayed. Defaults render a cheap `<pre>`. For a richer experience, plug in `LazyJsonTree` from `@djangocfg/ui-tools/json-tree` with `mode="compact"`:

```tsx
import { LazyJsonTree } from '@djangocfg/ui-tools/json-tree';

<ToolCalls
  calls={message.toolCalls}
  renderInput={(input) => <LazyJsonTree data={input} mode="compact" />}
  renderOutput={(output) => <LazyJsonTree data={output} mode="compact" />}
/>
```

`renderInput` / `renderOutput` / `renderStreaming` take precedence; `renderPayload(value, kind, call)` is a single fallback if you want one renderer for all three.

We don't import `LazyJsonTree` automatically — keeping the chat dep-light. The host opts in.

## Personas (user & assistant identity)

Pass identities once on `<ChatRoot config>` — every bubble gets the right name, avatar and `aria-label`. Outgoing user messages get the `sender` field auto-stamped from `config.user`.

```tsx
<ChatRoot
  transport={transport}
  config={{
    user: {
      name: 'Mark',
      avatarUrl: '/me.jpg',
      description: 'Senior engineer',
    },
    assistant: {
      name: 'Claude',
      avatarUrl: '/claude.png',
      model: 'claude-opus-4-7',
    },
  }}
/>
```

Resolution cascade per bubble:

1. `message.sender` — per-message override (multi-user / multi-bot chats)
2. `config.user` / `config.assistant` — provider-level default
3. Hardcoded `'You'` / `'AI'` fallback

Multi-user example — server returns `sender` per message:

```ts
{
  id: 'm3',
  role: 'user',
  content: 'Flag the auth migration section.',
  sender: { name: 'Anna', avatarUrl: '/anna.jpg' },
}
```

Initials are auto-derived from `name` (`'Mark Olofsen'` → `MO`); set `initials` explicitly to override.

Helpers exposed for hosts that build their own bubbles:

```ts
import { resolvePersona, deriveInitials } from '@djangocfg/ui-tools';
const persona = resolvePersona(message, config.user, config.assistant);
const fallback = deriveInitials(persona, message.role);
```

## Attachments

Two specialised components plus a backwards-compatible facade:

| Component           | Layout         | Use it for                                   |
| ------------------- | -------------- | -------------------------------------------- |
| `<AttachmentsGrid>` | `flex-wrap`    | Thumbnails, file chips, no custom renderers  |
| `<AttachmentsList>` | `flex-col`     | Custom renderers (audio, video, doc viewer)  |
| `<Attachments>`     | picks for you  | Backwards-compat: stacks when `renderers` is given, wraps otherwise |

`<MessageBubble>` automatically uses `AttachmentsList` when you pass `attachmentRenderers` and `AttachmentsGrid` otherwise — `flex-wrap` shrinks block-level renderers to min-content, so picking the right component matters.

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

<AttachmentsList
  attachments={msg.attachments}
  renderers={{
    audio: ({ attachment }) => <LazyAudioPlayer src={attachment.url} variant="compact" />,
  }}
/>
```

## Audio triggers

Optional sound effects on chat events. Off by default — you opt in with a `sounds` map. Six events:

| Event              | Fires when                                          |
| ------------------ | --------------------------------------------------- |
| `messageSent`      | After `useChat.sendMessage()` adds the user message |
| `messageReceived`  | On `STREAM_DONE` (final assistant message)          |
| `streamStart`      | First token / placeholder created                   |
| `error`            | `STREAM_ERROR` or transport throw                   |
| `mention`          | Host-fired (e.g. assistant addressed the user)      |
| `notification`     | Generic — host triggers manually                    |

```tsx
<ChatRoot
  transport={transport}
  audio={{
    sounds: {
      messageSent:     '/audio/chat/sent.mp3',
      messageReceived: '/audio/chat/received.mp3',
      streamStart:     '/audio/chat/start.mp3',
      error:           '/audio/chat/error.mp3',
    },
    // Defaults: muteWhenHidden: true, respectReducedMotion: true, respectReducedData: true
  }}
/>
```

Pitfalls handled inside (lifted from `AudioPlayer/audio`):

- **iOS/Safari autoplay block.** First `pointerdown` / `keydown` inside `<ChatProvider>` runs a transactional unlock — every cached `<audio>` is poked once with `muted=true`, then released.
- **Same-element race.** Each `play()` clones a fresh `Audio(url)` so two rapid events don't cancel each other; the cached element warms the HTTP cache.
- **Cross-tab sync.** Volume / mute / per-event toggles live in a Zustand `persist` store with `localStorage` + the native `storage` event.
- **Visibility.** Auto-mute while `document.visibilityState === 'hidden'`.
- **Reduced motion / data.** Auto-suppress on `prefers-reduced-motion: reduce` and `prefers-reduced-data: reduce` (configurable).
- **SSR-safe.** `audioBus` falls back to a no-op when `window` is undefined.

For programmatic control (e.g. mute toggle in your header):

```tsx
const ctx = useChatContext();
ctx.audio.setMuted(!ctx.audio.muted);
ctx.audio.setVolume(0.6);
ctx.audio.setEventEnabled('messageSent', false);
ctx.audio.play('mention');         // host-fired
ctx.audio.isUnlocked;              // boolean
```

Or directly: `useChatAudio(config)` — returns the same surface, no provider required.

## Attachment renderers (registry)

`<Attachments>` and `<ChatRoot>` accept a per-type renderer map. Default tile is used when no renderer matches. Plug in heavy viewers (`LazyAudioPlayer`, `LazyImageViewer`, `LazyMap`) host-side without forcing `ui-tools/Chat` to depend on them.

```tsx
import { LazyPlayer as LazyAudioPlayer } from '@djangocfg/ui-tools/audio-player';

<ChatRoot
  transport={transport}
  attachmentRenderers={{
    audio: ({ attachment }) => (
      <div className="my-1 max-w-md">
        <LazyAudioPlayer src={attachment.url} title={attachment.name} variant="compact" />
      </div>
    ),
    // image / video / file / default — all optional
  }}
  onAttachmentOpen={(att) => openLightbox(att)}
/>
```

Renderer signature:

```ts
type AttachmentRenderer = (args: {
  attachment: ChatAttachment;
  isInComposer: boolean;     // true in the composer staging tray
  onClick?: () => void;      // forwarded from <ChatRoot onAttachmentOpen>
  onRemove?: () => void;     // present in the composer tray
}) => ReactNode;
```

`<MessageBubble attachmentsRenderer>` (a wholesale slot) still wins over the registry — use it when you need to swap the entire attachments block.

## Image lightbox helpers

```tsx
import {
  useChatLightbox,
  collectImageAttachments,
} from '@djangocfg/ui-tools';
import { LazyImageViewer } from '@djangocfg/ui-tools/image-viewer';

function MyChat() {
  const lightbox = useChatLightbox();
  const ctx = useChatContext();
  const gallery = useMemo(() => collectImageAttachments(ctx.messages), [ctx.messages]);

  return (
    <>
      <ChatRoot
        transport={transport}
        onAttachmentOpen={(att) => att.type === 'image' && lightbox.open(att, gallery)}
      />
      <Dialog open={!!lightbox.state} onOpenChange={(o) => !o && lightbox.close()}>
        <DialogContent className="max-w-5xl">
          {lightbox.state && (
            <LazyImageViewer
              images={lightbox.state.gallery.map((a) => ({
                file: { name: a.name ?? a.id, path: a.id },
                src: a.url,
              }))}
              initialIndex={lightbox.state.index}
              inDialog
            />
          )}
        </DialogContent>
      </Dialog>
    </>
  );
}
```

`useChatLightbox` is just a tiny `{ gallery, index } | null` state container — the host owns the modal + viewer mount, so `<LazyImageViewer>` (~50 KB) stays out of `ui-tools/Chat`.

## Tool-payload dispatcher

For tool-call panels, `dispatchToolPayload(matchers, fallback)` lets you pick a renderer per payload shape — without writing your own switch each time.

```tsx
import {
  dispatchToolPayload,
  isLatLng,
  isPlainObject,
  isGeoJSONFeatureCollection,
} from '@djangocfg/ui-tools';
import { LazyJsonTree } from '@djangocfg/ui-tools/json-tree';
import { LazyMap } from '@djangocfg/ui-tools';

const renderPayload = dispatchToolPayload(
  [
    {
      match: (v) => isGeoJSONFeatureCollection(v),
      render: (v) => <LazyMap geojson={v as GeoJSON.FeatureCollection} />,
    },
    { match: (v) => isLatLng(v), render: (v) => <LazyMap markers={[v as { lat: number; lng: number }]} /> },
    { match: (v) => isPlainObject(v), render: (v) => <LazyJsonTree data={v} mode="compact" /> },
  ],
  (v) => <pre className="text-[11px]">{String(v)}</pre>,
);

<ChatRoot toolCallsProps={{ defaultExpanded: true, renderPayload }} />;
```

First match wins. Predicates `isLatLng` / `isPlainObject` / `isGeoJSONFeatureCollection` / `isStringValue` are exported as building blocks.

## Three usage patterns

### 1. One-line preset

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

### 2. Composition

Bring your own layout, reuse the parts.

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

### 3. Headless (just hooks)

```tsx
const chat = useChat({ transport });
const composer = useChatComposer({ onSubmit: chat.sendMessage });
// render however you want
```

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

- **`createHttpTransport({ baseUrl, getAuthHeader, slug })`** — fetch + SSE, default for web.
- **`createMockTransport({ replies, latencyMs })`** — scripted in-memory replies for stories/tests.

Custom hosts (Wails RPC, WebSocket, gRPC) implement the same interface — see [`@dev/@refactoring7-chat/06-integration.md`](../../../@dev/@refactoring7-chat/06-integration.md) for adapter recipes.

## Public surface

```ts
// Types
ChatMessage, ChatRole, ChatPersona, ChatToolCall, ChatAttachment, ChatSource,
ChatConfig, ChatUserContext, ChatAssistantContext, ChatPrefs, ChatLabels,
ChatTransport, ChatStreamEvent, ChatDisplayMode,
SessionInfo, HistoryPage, StreamOptions, SendOptions

// Core (pure)
reducer, initialState, createId, createTokenBuffer,
resolvePersona, deriveInitials
type ChatState, type ChatAction

// Transport
createHttpTransport, createMockTransport, parseSSE, TransportError

// Hooks
useChat, useChatComposer, useChatScroll, useChatHistory, useChatLayout,
useChatAudio, useChatLightbox

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

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

// Lightbox helpers
collectImageAttachments

// Context
ChatProvider, useChatContext, useChatContextOptional

// Components
ChatRoot, MessageList, MessageBubble, MessageActions, Composer,
Sources, ToolCalls, Attachments, EmptyState, ErrorBanner,
JumpToLatest, StreamingIndicator

// Lazy preset
LazyChat
```

## Mobile & a11y notes

- `useChatLayout` collapses `sidebar`/`floating` to `fullscreen` below `(max-width: 640px)`.
- 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"`.

## Hotkeys

| Key                | Behavior                                |
| ------------------ | --------------------------------------- |
| `Enter`            | Send (configurable to `Cmd/Ctrl+Enter`) |
| `Shift+Enter`      | Newline                                 |
| `↑` (empty input)  | Recall previous submission              |
| `↓`                | Recall next                             |
| `Esc`              | (host-bound) cancel stream              |

## Performance

- **Token coalescing.** `createTokenBuffer` aggregates stream chunks within ~16ms before dispatching → ≤1 render per frame.
- **Plain text during stream.** `MessageBubble` skips ReactMarkdown until the message finishes, then re-renders once with full markdown.
- **Memoized bubbles.** Memo key `(id, content, isStreaming, version, toolActivity, toolCalls, sources, attachments)` — references only.
- **Virtualization is opt-in.** `@tanstack/react-virtual` stays a host-side decision via `MessageList renderItem` slot.

## Design docs

Full implementation plan and rationale lives at [`@dev/@refactoring7-chat/`](../../../@dev/@refactoring7-chat/):

- `00-overview.md` — file layout & public API
- `01-architecture.md` — layered model, dataflow, transport contract
- `02-state-model.md` — reducer actions and invariants
- `03-hooks.md` — every hook signature
- `04-components.md` — every component prop shape
- `05-mobile-and-a11y.md` — breakpoints, iOS, ARIA, focus, RTL
- `06-integration.md` — adapter recipes
- `07-testing.md` — reducer / hook / component / a11y tests
- `08-migration.md` — step-by-step rollout

## Storybook

`Tools/Chat` covers:

- `Default` — full ChatRoot with mock transport and suggestions
- `WithToolCalls` — scripted tool invocation + sources
- `Composition` — bring your own layout (`<ChatProvider>` + parts)
- `Bubbles` — visual matrix of message states
- `Parts` — every decomposed component on its own (incl. `ToolCalls` + `LazyJsonTree` compact)
- `WithSlots` — every named slot at once (banner / header / empty / composer toolbar / footer)
- `WithJsonTreePayload` — `<ChatRoot toolCallsProps>` with `LazyJsonTree` payload renderers
- `WithAudio` — chat-event sound triggers + unlock-state badge
- `WithAudioAttachment` — attachment registry mounts `<LazyAudioPlayer>` for `audio` items
- `WithImageLightbox` — `useChatLightbox` + host-side `<Dialog>` + `<LazyImageViewer>`
- `WithMapPayload` — `dispatchToolPayload` with `LazyJsonTree` fallback
- `WithPersonas` — config-level `user` + `assistant` identity (avatar, name)
- `MultiUser` — per-message `sender` overrides (multi-user / multi-bot)
- `WithHideComposer` — `hideComposer` + `footer` approval gate (HITL / agent-pause pattern)
- `Playground` — knobs (latency, streaming, suggestions)
