@djangocfg/ui-tools 2.1.394 → 2.1.397

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -111,7 +111,6 @@ import {
111
111
  ChatLauncher,
112
112
  ChatRoot,
113
113
  createPydanticAIChatTransport,
114
- useChatAudio,
115
114
  } from '@djangocfg/ui-tools/chat';
116
115
 
117
116
  const transport = createPydanticAIChatTransport({
@@ -121,16 +120,25 @@ const transport = createPydanticAIChatTransport({
121
120
  });
122
121
 
123
122
  function Chat() {
124
- const audio = useChatAudio(); // zero setup uses built-in sounds bundled into the lazy chunk
123
+ // ChatLauncher mounts <ChatProvider> internallypass transport / audio /
124
+ // config here, then render <ChatRoot /> as a child without props.
125
125
  return (
126
126
  <ChatLauncher
127
+ transport={transport}
128
+ audio={{}} // ChatAudioConfig (sounds map). `{}` uses bundled defaults.
127
129
  hotkey={{ key: '/', meta: true }}
128
130
  fab={{ variant: 'animated' }} // size='responsive' default — phone/tablet/desktop
129
131
  dock={{ title: 'Assistant', height: 600 }}
130
132
  greeting="Hi 👋 Need help?"
131
- audio={audio} // auto-injects mute toggle into header
133
+ headerSlots={{ // declarative header buttons, rendered inside the provider
134
+ languagePicker: true,
135
+ modeToggle: { persistAs: 'my.chat.dock' },
136
+ reset: {
137
+ onReset: async () => { await api.clearChat(); return true; },
138
+ },
139
+ }}
132
140
  >
133
- <ChatRoot transport={transport} />
141
+ <ChatRoot />
134
142
  </ChatLauncher>
135
143
  );
136
144
  }
@@ -138,7 +146,7 @@ function Chat() {
138
146
 
139
147
  **What's wired by default:** desktop side-mode toggle (auto-hides on narrow screens), persisted dock prefs, two-step Escape, click-to-focus composer, mobile fullscreen with `dvh` heights, push-preview bubble for inbound messages while closed, **ChatGPT-style autoscroll** (sticky-to-bottom within 120 px, every user-send re-anchors the viewport), **bundled chat notification sounds** (sent/received/start/error/mention/notification, ~136KB inlined as `data:`-URLs inside the lazy chat chunk — zero host setup). Native hosts (cmdop_go / Tauri) pass `audio={{ silenced: true, onSoundEvent }}` to keep web silent while routing triggers to the backend.
140
148
 
141
- Drop `<VoiceComposerSlot />` from `@djangocfg/ui-tools/speech-recognition` into `composerToolbarEnd` for live mic-to-text — **zero props**, reads / writes the composer through `ComposerHandle` registered in chat context. Drop `<ChatHeaderLanguageButton />` into `dock.headerActions` for a flag-button language picker (66 BCP-47 tags). Both auto-hide on Firefox / in-app browsers / missing `getUserMedia`. See [`SpeechRecognition`](#speech-recognition--quick-start) below.
149
+ Drop `<VoiceComposerSlot />` from `@djangocfg/ui-tools/speech-recognition` into `composerToolbarEnd` for live mic-to-text — **zero props**, reads / writes the composer through `ComposerHandle` registered in chat context. Set `headerSlots={{ languagePicker: true }}` on `<ChatLauncher>` for a flag-button language picker (66 BCP-47 tags). Both auto-hide on Firefox / in-app browsers / missing `getUserMedia`. See [`SpeechRecognition`](#speech-recognition--quick-start) below.
142
150
 
143
151
  Full docs: [`Chat/README.md`](src/tools/Chat/README.md). Stories: `Tools/Chat/{Basic,Bubbles,ToolCalls,Personas,Launcher,Header,Audio & Actions,Voice composer}`.
144
152
 
@@ -182,7 +190,7 @@ import {
182
190
  />
183
191
 
184
192
  // Plus a flag-button language picker in the dock header:
185
- <ChatLauncher dock={{ headerActions: <ChatHeaderLanguageButton /> }}>
193
+ <ChatLauncher headerSlots={{ languagePicker: true }} ... >
186
194
  ```
187
195
 
188
196
  **What's wired by default:** auto-hide on Firefox / in-app WebViews / missing `getUserMedia` (via `useVoiceSupport`); live interim+final stream into the composer via `ComposerHandle` (works transparently for the built-in textarea Composer and a TipTap MarkdownEditor); typed prefix anchored; focus + cursor-to-end on start and every partial; 90-second countdown + 2.5-second silence auto-stop; Esc cancels (without closing the chat) / Enter finishes (without submitting); persisted prefs (`djangocfg-stt:prefs`); `<SpeechRecognitionProvider>` for sharing engine state across the tree. Language picker shows 66 BCP-47 tags sourced from the Chrome Web Speech demo with country flags. Custom engines through `createHttpEngine` (REST/Whisper), `createWebSocketEngine` (Deepgram-style streaming), or `createExternalEngine` (Wails / Tauri / native sidecar — `onStart` / `onStop` / `subscribe` and you're done).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.394",
3
+ "version": "2.1.397",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -154,8 +154,8 @@
154
154
  "test:watch": "vitest"
155
155
  },
156
156
  "peerDependencies": {
157
- "@djangocfg/i18n": "^2.1.394",
158
- "@djangocfg/ui-core": "^2.1.394",
157
+ "@djangocfg/i18n": "^2.1.397",
158
+ "@djangocfg/ui-core": "^2.1.397",
159
159
  "consola": "^3.4.2",
160
160
  "lodash-es": "^4.18.1",
161
161
  "lucide-react": "^0.545.0",
@@ -209,9 +209,9 @@
209
209
  "material-file-icons": "^2.4.0"
210
210
  },
211
211
  "devDependencies": {
212
- "@djangocfg/i18n": "^2.1.394",
213
- "@djangocfg/typescript-config": "^2.1.394",
214
- "@djangocfg/ui-core": "^2.1.394",
212
+ "@djangocfg/i18n": "^2.1.397",
213
+ "@djangocfg/typescript-config": "^2.1.397",
214
+ "@djangocfg/ui-core": "^2.1.397",
215
215
  "@types/lodash-es": "^4.17.12",
216
216
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
217
217
  "@types/node": "^24.7.2",
@@ -1,5 +1,5 @@
1
1
  /* Focus ring when scroll isolation is unlocked */
2
2
  .scroll-unlocked {
3
- box-shadow: 0 0 0 2px hsl(var(--ring));
3
+ box-shadow: 0 0 0 2px var(--ring);
4
4
  transition: box-shadow 150ms;
5
5
  }
@@ -16,12 +16,19 @@ const transport = createPydanticAIChatTransport({
16
16
  export function MyChat() {
17
17
  return (
18
18
  <ChatLauncher
19
+ transport={transport}
20
+ config={{ greeting: 'How can I help?' }}
21
+ audio={{}}
19
22
  hotkey={{ key: '/', meta: true }}
20
23
  fab={{ variant: 'animated', tooltip: 'Open chat (⌘/)' }}
21
24
  dock={{ title: 'Assistant', height: 600 }}
22
25
  greeting="Hi 👋 Need help?"
26
+ headerSlots={{
27
+ languagePicker: true,
28
+ modeToggle: { persistAs: 'my.chat.dock' },
29
+ }}
23
30
  >
24
- <ChatRoot transport={transport} config={{ greeting: 'How can I help?' }} />
31
+ <ChatRoot />
25
32
  </ChatLauncher>
26
33
  );
27
34
  }
@@ -31,22 +38,23 @@ export function MyChat() {
31
38
 
32
39
  - **Headless first.** Pure reducer + hooks; UI is optional and replaceable.
33
40
  - **Launcher primitives.** `ChatLauncher` / `ChatFAB` (3 variants, responsive sizing) / `ChatDock` (popover + side modes, mobile fullscreen) / `ChatGreeting` (proactive invite) / `ChatUnreadPreview` (live-push notification).
34
- - **Header actions.** `ChatHeader` + `ChatHeaderActionButton` + ready-made `ChatHeaderModeToggle` / `ChatHeaderAudioToggle` / `ChatHeaderResetButton` (with `window.dialog.confirm`) / `ChatHeaderLanguageButton` (flag picker for speech-recognition language).
41
+ - **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.
35
42
  - **Decomposed.** `MessageList`, `MessageBubble`, `Composer`, `Sources`, `ToolCalls`, `Attachments`, `EmptyState`, `ErrorBanner`, `JumpToLatest`, `StreamingIndicator` — every part exported.
36
43
  - **Markdown-native.** Sits on top of `MarkdownMessage` (GFM, code, mermaid, sanitized HTML).
37
44
  - **Streaming.** SSE-based `AsyncGenerator` transport. Spec-compliant `parseSSE`, token coalescing, cancel, regenerate.
38
45
  - **Pydantic-AI mapper.** Built-in adapter for Django/pydantic-AI backends — `text_delta` / `tool_call` / `tool_result` → canonical `ChatStreamEvent`. FIFO tool-id queue.
39
46
  - **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.
40
47
  - **Live push.** `chat.injectMessage(msg)` / `updateMessage(id, patch)` for admin takeover / Centrifugo / WebSocket inbound. `useChatUnread()` tracks unseen; `<ChatUnreadPreview>` surfaces them next to the FAB.
48
+ - **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.
41
49
  - **Personas.** `config.user` + `config.assistant` for default identity; `message.sender` for per-message overrides.
42
- - **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).
50
+ - **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).
43
51
  - **Rich attachments.** `AttachmentsGrid` for thumbnails, `AttachmentsList` for custom renderers; `onAttachmentOpen` for lightbox.
44
52
  - **Tool-payload dispatcher.** `dispatchToolPayload(matchers, fallback)` — pluggable predicates render `<LazyJsonTree>` / `<LazyMap>` / etc.
45
- - **Persisted dock prefs.** `useChatDockPrefs()` stores `mode` / `side` / `width` in localStorage; `<ChatHeaderModeToggle>` lets users flip popover ↔ side and survives reloads.
53
+ - **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.)
46
54
  - **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.
47
55
  - **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.
48
56
  - **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).
49
- - **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.
57
+ - **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.)
50
58
  - **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.
51
59
  - **Centralized colors.** Role-aware className tokens (`BUBBLE_SURFACE` / `ANCHOR` / `TOGGLE` / `DESTRUCTIVE_SURFACE`) + hooks (`useChatBubbleStyles`, `useChatRoleStyles`, `useChatDestructiveStyles`).
52
60
  - **Responsive.** FAB `size='responsive'` (default): phone → `sm`, tablet → `md`, desktop → `lg`. Side mode is desktop-only and falls back to popover below `lg`.
@@ -69,6 +77,8 @@ Components (MessageList, MessageBubble, Composer, Sources, ToolCalls, …)
69
77
  ChatRoot (one-line preset) ◄── optionally wrapped by ──► ChatLauncher (FAB + Dock + Greeting)
70
78
  ```
71
79
 
80
+ `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.
81
+
72
82
  Module boundaries:
73
83
 
74
84
  | Layer | May import | May NOT import |
@@ -109,11 +119,12 @@ Picks the highest level that fits.
109
119
  | `<ChatFAB>` | Floating button only |
110
120
  | `<ChatGreeting>` | Standalone proactive bubble |
111
121
  | `<ChatUnreadPreview>` | Standalone push-notification bubble |
112
- | `<ChatHeader>` + `<ChatHeaderActionButton>` | Custom header chrome |
113
- | `<ChatHeaderModeToggle>` | Popover side toggle (desktop-only, auto-hides below `lg`) |
114
- | `<ChatHeaderAudioToggle>` | Mute / unmute notification sounds |
115
- | `<ChatHeaderResetButton>` | Clear conversation with `window.dialog.confirm` |
116
- | `<ChatHeaderLanguageButton>` | Flag-button language picker (66 BCP-47 tags, persists in `useSpeechPrefs`) |
122
+ | `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.** |
123
+ | `<ChatHeader>` + `<ChatHeaderActionButton>` | Custom header chrome (advanced when you build your own dock shell) |
124
+ | `<ChatHeaderModeToggle>` | Popover side toggle (desktop-only, auto-hides below `lg`). Used internally by `headerSlots.modeToggle`. |
125
+ | `<ChatHeaderAudioToggle>` | Mute / unmute notification sounds. Used internally by `headerSlots.audio`. |
126
+ | `<ChatHeaderResetButton>` | Clear conversation with `window.dialog.confirm`. Used internally by `headerSlots.reset`. |
127
+ | `<ChatHeaderLanguageButton>` | Flag-button language picker (66 BCP-47 tags, persists in `useSpeechPrefs`). Used internally by `headerSlots.languagePicker`. |
117
128
 
118
129
  ### FAB
119
130
 
@@ -145,26 +156,55 @@ dock={{
145
156
  reserveBodySpace: true, // side mode — sets body padding so content shifts
146
157
  disablePortal: true, // render in-place (stories/iframes)
147
158
  hideHeader: true, // children render their own header
148
- headerActions: <ChatHeaderResetButton ... />,
149
159
  }}
150
160
  ```
151
161
 
152
- **Side mode is desktop-only.** Below `lg` (1024px) it silently falls back to popover and the `<ChatHeaderModeToggle>` hides itself.
162
+ **Side mode is desktop-only.** Below `lg` (1024px) it silently falls back to popover and the mode-toggle slot hides itself.
163
+
164
+ **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.
165
+
166
+ ### Header slots
153
167
 
154
- **Persisted prefs.** `useChatDockPrefs()` stores `mode` / `side` / `sideWidth` in localStorage. Drop it into your launcher to give users a remembered layout:
168
+ Declarative header buttons rendered inside the launcher's `<ChatProvider>`. Every slot has access to `sessionId`, `clearMessages`, etc. via `useChatContext()` no JSX plumbing.
155
169
 
156
170
  ```tsx
157
- const prefs = useChatDockPrefs({ storageKey: 'crm.chat.dock' });
158
171
  <ChatLauncher
159
- dock={{
160
- mode: prefs.mode,
161
- side: prefs.side,
162
- width: prefs.mode === 'side' ? prefs.sideWidth : 480,
163
- headerActions: <ChatHeaderModeToggle mode={prefs.mode} onToggle={prefs.toggleMode} />,
172
+ transport={transport}
173
+ audio={{}} // ChatAudioConfig (sounds map)
174
+ dock={{ title: 'Assistant' }}
175
+ headerSlots={{
176
+ languagePicker: true, // 66 BCP-47 tags
177
+ modeToggle: {
178
+ persistAs: 'crm.chat.dock', // localStorage key for useChatDockPrefs
179
+ defaults: { mode: 'popover', side: 'right' },
180
+ },
181
+ reset: {
182
+ onReset: async () => { // backend call — return true on success
183
+ await api.clearChat();
184
+ return true;
185
+ },
186
+ confirmMessage: "Forget this conversation?",
187
+ // onSuccess defaults to ctx.clearMessages(); override for analytics, refetch, …
188
+ },
189
+ custom: (ctx) => <MyButton sessionId={ctx.sessionId} />,
164
190
  }}
165
- >{children}</ChatLauncher>
191
+ >
192
+ <ChatRoot />
193
+ </ChatLauncher>
166
194
  ```
167
195
 
196
+ Order is fixed left → right: `custom · languagePicker · modeToggle · audio · reset` (close icon stays right-most, owned by `<ChatHeader>`).
197
+
198
+ | Slot | Value | Default |
199
+ |---|---|---|
200
+ | `audio` | `boolean` | auto-on when launcher `audio` is configured and not silent |
201
+ | `modeToggle` | `boolean \| { persistAs?, defaults?, forceVisible? }` | off |
202
+ | `languagePicker` | `boolean \| { allowedTags?, ariaLabel?, hideFallbackIcon? }` | off |
203
+ | `reset` | `{ onReset, confirm?, confirmMessage?, onSuccess?, onError? }` | off (no `onReset`) |
204
+ | `custom` | `(ctx: ChatContextValue) => ReactNode` | off |
205
+
206
+ `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`.
207
+
168
208
  #### Side-mode body reserve + the `--chat-dock-reserve` token
169
209
 
170
210
  When the dock is in `mode='side'` it pushes the rest of the page out of its way by writing two things to `<body>`:
@@ -304,16 +344,42 @@ function Launcher() {
304
344
 
305
345
  `<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.
306
346
 
347
+ ### Tab/favicon notifier (Facebook-style)
348
+
349
+ For when the user is in another tab and you want them to notice. Drop-in replacement for `useChatUnread`:
350
+
351
+ ```tsx
352
+ function Launcher() {
353
+ const [open, setOpen] = useState(false);
354
+ // Same shape as useChatUnread, plus mutates document.title + favicon
355
+ // when the tab is hidden + count > 0. Restores on focus / open.
356
+ const { unread, count, markRead } = useChatUnreadNotifier({ open });
357
+ // …
358
+ }
359
+ ```
360
+
361
+ 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 } } })`.
362
+
363
+ **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).
364
+
365
+ **Native hosts.** Pass your own `notifier: ChatNotifier` to skip the browser implementation and emit Wails / Electron / Tauri events instead:
366
+
367
+ ```tsx
368
+ const dockBadge: ChatNotifier = {
369
+ setUnread: (count) => window.runtime.EventsEmit('chat:unread', count),
370
+ clear: () => window.runtime.EventsEmit('chat:unread', 0),
371
+ };
372
+ useChatUnreadNotifier({ open, notifier: dockBadge, crossTab: false });
373
+ ```
374
+
307
375
  ### Audio
308
376
 
309
- Wire `useChatAudio` once and pass it to `ChatLauncher` the header gets an auto-injected mute toggle:
377
+ 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.
310
378
 
311
379
  ```tsx
312
380
  // Zero-setup — built-in sounds are bundled as base64 data URLs
313
- const audio = useChatAudio();
314
-
315
- <ChatLauncher audio={audio} dock={{ title: 'Helper' }}>
316
- <ChatRoot transport={transport} />
381
+ <ChatLauncher transport={transport} audio={{}} dock={{ title: 'Helper' }}>
382
+ <ChatRoot />
317
383
  </ChatLauncher>
318
384
  ```
319
385
 
@@ -322,13 +388,13 @@ const audio = useChatAudio();
322
388
  Customize:
323
389
 
324
390
  ```tsx
325
- import { useChatAudio, DEFAULT_CHAT_SOUNDS } from '@djangocfg/ui-tools/chat';
391
+ import { DEFAULT_CHAT_SOUNDS } from '@djangocfg/ui-tools/chat';
326
392
 
327
393
  // Override one event, keep the rest
328
- useChatAudio({ sounds: { ...DEFAULT_CHAT_SOUNDS, mention: '/sfx/custom.mp3' } });
394
+ <ChatLauncher audio={{ sounds: { ...DEFAULT_CHAT_SOUNDS, mention: '/sfx/custom.mp3' } }} ... />
329
395
 
330
- // Disable entirely (header toggle hides itself)
331
- useChatAudio({ sounds: {} });
396
+ // Disable entirely (header toggle auto-hides via headerSlots.audio = false default)
397
+ <ChatLauncher audio={{ sounds: {} }} ... />
332
398
  ```
333
399
 
334
400
  Per-event volume defaults — Slack / Linear / Intercom style:
@@ -344,31 +410,46 @@ Per-event volume defaults — Slack / Linear / Intercom style:
344
410
 
345
411
  Override via `eventVolumes: { error: 0, mention: 0.8 }`. Pass `eventVolumes: {}` to skip defaults entirely.
346
412
 
347
- `audio.muted` / `audio.toggleMute()` are persisted in localStorage. Header `<ChatHeaderAudioToggle>` is hidden automatically when `audio.isSilent` (no sounds wired).
413
+ `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`).
348
414
 
349
415
  **Native hosts (cmdop_go / Tauri / Electron).** Skip web playback but keep the trigger as a side-channel:
350
416
 
351
417
  ```tsx
352
- const audio = useChatAudio({
353
- silenced: true,
354
- onSoundEvent: (e) => window.go.playSound(e),
355
- });
418
+ <ChatLauncher
419
+ audio={{ silenced: true, onSoundEvent: (e) => window.go.playSound(e) }}
420
+ ...
421
+ />
356
422
  ```
357
423
 
358
424
  See [`@djangocfg/ui-core` Audio docs](../../../../ui-core/README.md#audio) for the underlying `useNotificationSounds` primitives.
359
425
 
360
426
  ### Reset / clear conversation
361
427
 
428
+ Use `headerSlots.reset` — declarative, no JSX. `sessionId` and `clearMessages()` are pulled from chat context automatically; the button is hidden until a `sessionId` exists.
429
+
362
430
  ```tsx
363
- <ChatHeaderResetButton
364
- onReset={api.clearChat} // your backend POST /chat/reset
365
- onSuccess={() => chat.clearMessages()}
366
- confirmMessage="Forget this conversation? The assistant won't remember it."
367
- />
431
+ <ChatLauncher
432
+ transport={transport}
433
+ headerSlots={{
434
+ reset: {
435
+ onReset: async () => { // backend POST /chat/reset
436
+ await api.clearChat();
437
+ return true; // resolve `true` on success
438
+ },
439
+ confirmMessage: "Forget this conversation? The assistant won't remember it.",
440
+ // onSuccess defaults to ctx.clearMessages(); override to re-fetch history,
441
+ // navigate, fire analytics, etc.
442
+ },
443
+ }}
444
+ >
445
+ <ChatRoot />
446
+ </ChatLauncher>
368
447
  ```
369
448
 
370
449
  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.
371
450
 
451
+ For custom dock shells that don't use `headerSlots`, the raw `<ChatHeaderResetButton onReset onSuccess confirmMessage />` is still exported.
452
+
372
453
  ### Anti-patterns
373
454
 
374
455
  | Don't | Why |
@@ -378,7 +459,9 @@ Calls `window.dialog.confirm` (destructive variant) from `@djangocfg/ui-core/lib
378
459
  | Mismatched `exitDurationMs` vs CSS | Dock unmounts before/after animation |
379
460
  | Reusing `dismissStorageKey` across products | Greeting won't show on the second product |
380
461
  | Calling `useChatUnread()` outside `ChatProvider` | Hook reads the messages store; throws without provider |
381
- | Forgetting `<DialogProvider>` for `<ChatHeaderResetButton confirm>` | Falls back to native browser confirm (ugly) |
462
+ | Forgetting `<DialogProvider>` for `headerSlots.reset` (or raw `<ChatHeaderResetButton>`) | Falls back to native browser confirm (ugly) |
463
+ | Passing `dock.headerActions` (removed in the new launcher) | Use `headerSlots` instead — it renders inside the provider so reset can read `sessionId` / `clearMessages` |
464
+ | Calling `useChatAudio()` in consumer code | Pass `audio={{}}` (or `{ sounds: {…} }`) to `<ChatLauncher>` — the launcher owns the hook now |
382
465
  | Mixing **root barrel** and **subpath** imports | Loads the package twice → two React contexts → `useChatContextOptional()` returns `null` → VoiceComposerSlot silently drops transcripts. See **Import discipline** below. |
383
466
 
384
467
  #### Import discipline
@@ -545,9 +628,12 @@ type PydanticAIChatTransportOpts, type PydanticAIEvent, type ToolIdQueue
545
628
 
546
629
  // Hooks
547
630
  useChat, useChatComposer, useChatScroll, useChatHistory, useChatLayout,
548
- useChatAudio, useChatLightbox, useAutoFocusOnStreamEnd, useRegisterComposer,
549
- useChatReset, useVisitorFingerprint, useChatDockPrefs, useChatUnread,
631
+ useChatLightbox, useAutoFocusOnStreamEnd, useRegisterComposer,
632
+ useChatReset, useVisitorFingerprint, useChatUnread, useChatUnreadNotifier,
633
+ createBrowserNotifier, createCrossTabNotifier, createTitleRotator, createFaviconBadge,
550
634
  useFocusOnEmptyClick
635
+ // `useChatAudio` and `useChatDockPrefs` are still exported for advanced custom
636
+ // shells, but `<ChatLauncher>` owns them via `audio` prop + `headerSlots.modeToggle.persistAs`.
551
637
  type ChatDockPrefs, DEFAULT_DOCK_PREFS
552
638
 
553
639
  // Styles — role-aware tokens + hooks
@@ -566,6 +652,8 @@ type ChatHeaderAudioToggleProps, type ChatHeaderResetButtonProps,
566
652
  type ChatHeaderLanguageButtonProps,
567
653
  type ChatGreetingProps, type ChatUnreadPreviewProps,
568
654
  type ChatLauncherProps, type ChatLauncherHotkey, type ChatLauncherGreeting,
655
+ type ChatHeaderSlots, type ChatHeaderResetSlot, type ChatHeaderLanguageSlot,
656
+ type ChatHeaderModeToggleSlot,
569
657
  type ChatPresencePhase
570
658
 
571
659
  // Audio
@@ -627,13 +715,32 @@ Stories live next to components — open `@djangocfg/playground`:
627
715
  | `composerAttachmentTray` | Above composer textarea | `renderAttachmentTray({ attachments })` |
628
716
  | `jumpToLatest` | Sticky overlay | `renderJumpToLatest({ unread, scrollToBottom })` |
629
717
  | `renderToolCall` | Per tool-call panel | `(call) => ReactNode` |
630
- | `renderAfterCalls` | After all tool panels | `(calls) => ReactNode` (rich UI like vehicle cards) |
718
+ | `renderAfterCalls` | After all tool panels | `(calls) => ReactNode` **only renders when the message has tool calls** |
719
+ | `renderAfterMessage` | Below every assistant bubble | `(message) => ReactNode` — fires for every message, independent of `toolCalls` |
631
720
 
632
721
  Flags:
633
722
 
634
723
  - `hideComposer` — agent-pause / human-in-the-loop pause; composer is unmounted.
635
724
  - `hideToolCalls` — show only `renderAfterCalls` rich UI without raw tool panels.
636
725
 
726
+ ### Which slot for product widgets — `renderAfterCalls` vs `renderAfterMessage`?
727
+
728
+ Both put custom content under the assistant bubble; the difference is **what triggers them**.
729
+
730
+ - `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.
731
+ - `renderAfterMessage` 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 `renderAfterMessage` still runs and the widget renders from the side channel.
732
+
733
+ Recommended pairing for a "vehicle cards" / "tax breakdown" / "chart" widget on a public chat:
734
+
735
+ ```tsx
736
+ <ChatRoot
737
+ transport={transport}
738
+ renderAfterMessage={(m) => <VehicleCardsForMessage messageId={m.id} />}
739
+ />
740
+ ```
741
+
742
+ `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.
743
+
637
744
  ## Hotkeys
638
745
 
639
746
  - `Enter` — send (or `Cmd+Enter` if `prefs.submitOn = 'cmd+enter'`).
@@ -6,7 +6,12 @@ import { cn } from '@djangocfg/ui-core/lib';
6
6
 
7
7
  import type { ChatAttachment, ChatConfig, ChatMessage, ChatTransport } from '../types';
8
8
  import type { ChatAudioConfig } from '../core/audio/types';
9
- import { ChatProvider, useChatContext, type ChatContextValue } from '../context';
9
+ import {
10
+ ChatProvider,
11
+ useChatContext,
12
+ useChatContextOptional,
13
+ type ChatContextValue,
14
+ } from '../context';
10
15
  import { useAutoFocusOnStreamEnd } from '../hooks/useAutoFocusOnStreamEnd';
11
16
  import { useChatComposer, type UseChatComposerReturn } from '../hooks/useChatComposer';
12
17
  import { useFocusOnEmptyClick } from '../hooks/useFocusOnEmptyClick';
@@ -21,7 +26,13 @@ import type { ToolCallsProps } from './ToolCalls';
21
26
 
22
27
  export interface ChatRootProps {
23
28
  // ---- core wiring -------------------------------------------------------
24
- transport: ChatTransport;
29
+ /**
30
+ * Transport. Required UNLESS `<ChatRoot>` is rendered inside an
31
+ * existing `<ChatProvider>` (e.g. mounted by `<ChatLauncher>`), in
32
+ * which case the ambient provider is reused and `transport` is
33
+ * ignored.
34
+ */
35
+ transport?: ChatTransport;
25
36
  config?: ChatConfig;
26
37
  initialSessionId?: string;
27
38
  autoCreateSession?: boolean;
@@ -57,6 +68,14 @@ export interface ChatRootProps {
57
68
  // ---- render-prop slots (need access to data) --------------------------
58
69
  /** Replace `<MessageBubble>` per message. */
59
70
  renderMessage?: (m: ChatMessage, i: number) => ReactNode;
71
+ /**
72
+ * Render arbitrary content beneath every default `<MessageBubble>`
73
+ * (not invoked when `renderMessage` is set — the host owns layout
74
+ * in that case). Useful for product widgets driven by a side channel
75
+ * (e.g. `ui_payload` SSE frames) where the widget is per-message but
76
+ * not tied to the bubble's tool-calls array.
77
+ */
78
+ renderAfterMessage?: (m: ChatMessage) => ReactNode;
60
79
  /** Render the header lazily — receives the chat context. */
61
80
  renderHeader?: (ctx: ChatContextValue) => ReactNode;
62
81
  /** Render the empty-state lazily — receives a `setValue` to seed the composer. */
@@ -94,6 +113,19 @@ export interface ChatRootProps {
94
113
 
95
114
  export function ChatRoot(props: ChatRootProps) {
96
115
  const { transport, config, initialSessionId, autoCreateSession, streaming, audio, debug, className, listClassName, ...slots } = props;
116
+ // When mounted under a launcher-owned `<ChatProvider>`, reuse that
117
+ // provider instead of wrapping in a second one. This lets host code
118
+ // freely nest `<ChatRoot>` inside `<ChatLauncher>` without losing
119
+ // `headerSlots` access to the same session / clearMessages / audio.
120
+ const ambient = useChatContextOptional();
121
+ if (ambient) {
122
+ return <ChatRootShell className={className} listClassName={listClassName} slots={slots} />;
123
+ }
124
+ if (!transport) {
125
+ throw new Error(
126
+ '<ChatRoot> requires `transport` when mounted outside a <ChatProvider>.',
127
+ );
128
+ }
97
129
  return (
98
130
  <ChatProvider
99
131
  transport={transport}
@@ -178,6 +210,7 @@ function ChatRootShell({ className, listClassName, slots }: ChatRootShellProps)
178
210
  toolCallsProps={slots.toolCallsProps}
179
211
  attachmentRenderers={slots.attachmentRenderers}
180
212
  onAttachmentOpen={slots.onAttachmentOpen}
213
+ renderAfterMessage={slots.renderAfterMessage}
181
214
  onCopy={() => copy(m.content)}
182
215
  onRegenerate={() => void chat.regenerate(m.id)}
183
216
  onDelete={() => chat.deleteMessage(m.id)}
@@ -79,6 +79,21 @@ export interface MessageBubbleProps {
79
79
  * you need different behaviour per slot.
80
80
  */
81
81
  streamingIndicator?: (m: ChatMessage) => ReactNode;
82
+ /**
83
+ * Render arbitrary content beneath the message body, regardless of
84
+ * whether the message carries tool calls. Used for product widgets
85
+ * that ride a side channel (e.g. `ui_payload` SSE frames driving
86
+ * vehicle cards, tax tables, charts) and therefore can't piggy-back
87
+ * on the `toolCallsRenderer` slot — the latter is gated on the
88
+ * message having a non-empty `toolCalls` array, which doesn't hold
89
+ * when raw tool events are hidden from public clients.
90
+ *
91
+ * Receives the message so the host can scope the widget by id /
92
+ * role / timing. Renders even on streaming messages, so progressive
93
+ * UI hints (skeletons that fill in as payloads arrive) work as
94
+ * expected.
95
+ */
96
+ renderAfterMessage?: (m: ChatMessage) => ReactNode;
82
97
  }
83
98
 
84
99
  const MessageBubbleInner = ({
@@ -107,6 +122,7 @@ const MessageBubbleInner = ({
107
122
  onDelete,
108
123
  messageActionsExtra,
109
124
  streamingIndicator,
125
+ renderAfterMessage,
110
126
  }: MessageBubbleProps) => {
111
127
  const isUser = isUserProp ?? message.role === 'user';
112
128
  const isStreaming = !!message.isStreaming;
@@ -208,6 +224,8 @@ const MessageBubbleInner = ({
208
224
  : <ToolCalls calls={message.toolCalls} {...toolCallsProps} />
209
225
  : null}
210
226
 
227
+ {renderAfterMessage ? renderAfterMessage(message) : null}
228
+
211
229
  {message.sources?.length && !isStreaming
212
230
  ? sourcesRenderer
213
231
  ? sourcesRenderer(message.sources)
@@ -55,3 +55,7 @@ export {
55
55
  type UseChatUnreadOptions,
56
56
  type UseChatUnreadReturn,
57
57
  } from './useChatUnread';
58
+ export {
59
+ useChatUnreadNotifier,
60
+ type UseChatUnreadNotifierOptions,
61
+ } from './useChatUnreadNotifier';