@djangocfg/ui-tools 2.1.394 → 2.1.395

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.395",
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.395",
158
+ "@djangocfg/ui-core": "^2.1.395",
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.395",
213
+ "@djangocfg/typescript-config": "^2.1.395",
214
+ "@djangocfg/ui-core": "^2.1.395",
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.
153
165
 
154
- **Persisted prefs.** `useChatDockPrefs()` stores `mode` / `side` / `sideWidth` in localStorage. Drop it into your launcher to give users a remembered layout:
166
+ ### Header slots
167
+
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
@@ -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;
@@ -94,6 +105,19 @@ export interface ChatRootProps {
94
105
 
95
106
  export function ChatRoot(props: ChatRootProps) {
96
107
  const { transport, config, initialSessionId, autoCreateSession, streaming, audio, debug, className, listClassName, ...slots } = props;
108
+ // When mounted under a launcher-owned `<ChatProvider>`, reuse that
109
+ // provider instead of wrapping in a second one. This lets host code
110
+ // freely nest `<ChatRoot>` inside `<ChatLauncher>` without losing
111
+ // `headerSlots` access to the same session / clearMessages / audio.
112
+ const ambient = useChatContextOptional();
113
+ if (ambient) {
114
+ return <ChatRootShell className={className} listClassName={listClassName} slots={slots} />;
115
+ }
116
+ if (!transport) {
117
+ throw new Error(
118
+ '<ChatRoot> requires `transport` when mounted outside a <ChatProvider>.',
119
+ );
120
+ }
97
121
  return (
98
122
  <ChatProvider
99
123
  transport={transport}
@@ -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';
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+
5
+ import { useActiveTabStore } from '@djangocfg/ui-core/hooks';
6
+
7
+ import {
8
+ createBrowserNotifier,
9
+ createCrossTabNotifier,
10
+ isPageHidden,
11
+ onVisibilityChange,
12
+ type BrowserNotifierOptions,
13
+ type ChatNotifier,
14
+ } from '../notifier';
15
+
16
+ import { useChatUnread, type UseChatUnreadOptions } from './useChatUnread';
17
+
18
+ export interface UseChatUnreadNotifierOptions extends UseChatUnreadOptions {
19
+ /**
20
+ * Custom notifier. Pass a host-specific implementation (Wails dock
21
+ * badge etc.) to opt out of the built-in browser title/favicon
22
+ * mutation. If omitted, a `createBrowserNotifier` instance is used.
23
+ */
24
+ notifier?: ChatNotifier;
25
+ /**
26
+ * Options forwarded to the default browser notifier. Ignored when an
27
+ * explicit `notifier` is provided.
28
+ */
29
+ browser?: BrowserNotifierOptions;
30
+ /**
31
+ * Master switch. Default `true`. Set false to keep the unread
32
+ * tracking but skip all environment mutation.
33
+ */
34
+ enabled?: boolean;
35
+ /**
36
+ * Cross-tab coordination. When enabled (default), only the elected
37
+ * leader tab mutates `document.title` / favicon — other tabs stay
38
+ * silent. The unread count is broadcast so every tab's FAB badge UI
39
+ * still reflects reality.
40
+ *
41
+ * Pass `false` to disable; pass an options object to customise the
42
+ * BroadcastChannel name. Disable in single-tab hosts (Wails / Electron)
43
+ * where leadership is moot.
44
+ */
45
+ crossTab?: boolean | { channel?: string };
46
+ }
47
+
48
+ /**
49
+ * Glue between `useChatUnread` and a `ChatNotifier`.
50
+ *
51
+ * Inputs that drive the notifier:
52
+ * 1. `useChatUnread` — provider-state-derived `{ count, unread }`.
53
+ * 2. Page visibility — clear when visible; re-arm when hidden+count>0.
54
+ * 3. Tab leadership (when `crossTab` enabled) — only leader mutates
55
+ * title/favicon; followers receive count broadcasts so their
56
+ * in-tab badge UI stays in sync.
57
+ *
58
+ * Returns `useChatUnread`'s shape, with the `count` overridden by
59
+ * cross-tab broadcasts when this tab is a follower (so the FAB badge
60
+ * shows the same number across every tab).
61
+ */
62
+ export function useChatUnreadNotifier(opts: UseChatUnreadNotifierOptions = {}) {
63
+ const {
64
+ notifier: notifierProp,
65
+ browser,
66
+ enabled = true,
67
+ crossTab = true,
68
+ ...unreadOpts
69
+ } = opts;
70
+ const unread = useChatUnread(unreadOpts);
71
+
72
+ // Cross-tab count from peers (followers see this; leader publishes).
73
+ const [peerCount, setPeerCount] = useState<number | null>(null);
74
+
75
+ const crossTabChannel =
76
+ typeof crossTab === 'object' ? crossTab.channel : undefined;
77
+ const crossTabEnabled = crossTab !== false;
78
+
79
+ // Build the notifier. Inner = host-supplied OR built-in browser.
80
+ // Wrap with cross-tab decorator when enabled.
81
+ const notifier = useMemo<ChatNotifier>(() => {
82
+ const inner = notifierProp ?? createBrowserNotifier(browser);
83
+ if (!crossTabEnabled) return inner;
84
+ return createCrossTabNotifier({
85
+ inner,
86
+ isLeader: () => useActiveTabStore.getState().isLeader,
87
+ channel: crossTabChannel,
88
+ onPeerUpdate: (count) => setPeerCount(count),
89
+ });
90
+ }, [notifierProp, browser, crossTabEnabled, crossTabChannel]);
91
+
92
+ const lastSyncedCount = useRef(0);
93
+
94
+ // Visibility-driven sync. Single effect owns both the listener and
95
+ // the imperative calls to keep ordering deterministic.
96
+ useEffect(() => {
97
+ if (!enabled) {
98
+ notifier.clear();
99
+ return;
100
+ }
101
+
102
+ const sync = () => {
103
+ const hidden = isPageHidden();
104
+ if (hidden && unread.count > 0) {
105
+ notifier.setUnread(unread.count, unread.unread);
106
+ lastSyncedCount.current = unread.count;
107
+ } else {
108
+ notifier.clear();
109
+ lastSyncedCount.current = 0;
110
+ }
111
+ };
112
+
113
+ sync();
114
+ const unsub = onVisibilityChange(sync);
115
+ return () => {
116
+ unsub();
117
+ notifier.clear();
118
+ };
119
+ }, [enabled, notifier, unread.count, unread.unread]);
120
+
121
+ // Final cleanup — release any host-side resources.
122
+ useEffect(() => () => notifier.dispose?.(), [notifier]);
123
+
124
+ // Effective count: max of local (this tab's own unread tracking) and
125
+ // peer broadcast. The max handles the case where a peer hasn't sent
126
+ // a broadcast yet (peerCount === null) — we trust local.
127
+ const effectiveCount =
128
+ peerCount !== null ? Math.max(unread.count, peerCount) : unread.count;
129
+
130
+ return {
131
+ ...unread,
132
+ count: effectiveCount,
133
+ };
134
+ }
@@ -103,6 +103,10 @@ export {
103
103
  type ChatLauncherProps,
104
104
  type ChatLauncherHotkey,
105
105
  type ChatLauncherGreeting,
106
+ type ChatHeaderSlots,
107
+ type ChatHeaderResetSlot,
108
+ type ChatHeaderLanguageSlot,
109
+ type ChatHeaderModeToggleSlot,
106
110
  type ChatGreetingProps,
107
111
  type ChatUnreadPreviewProps,
108
112
  type ChatPresencePhase,
@@ -124,8 +128,10 @@ export {
124
128
  DEFAULT_DOCK_PREFS,
125
129
  useFocusOnEmptyClick,
126
130
  useChatUnread,
131
+ useChatUnreadNotifier,
127
132
  type UseChatUnreadOptions,
128
133
  type UseChatUnreadReturn,
134
+ type UseChatUnreadNotifierOptions,
129
135
  type UseChatConfig,
130
136
  type UseChatReturn,
131
137
  type UseChatComposerOptions,
@@ -146,6 +152,23 @@ export {
146
152
  type UseFocusOnEmptyClickOptions,
147
153
  } from './hooks';
148
154
 
155
+ // Notifier — title rotation + favicon badge + page-visibility + cross-tab
156
+ export {
157
+ createBrowserNotifier,
158
+ createNoopNotifier,
159
+ createTitleRotator,
160
+ createFaviconBadge,
161
+ createCrossTabNotifier,
162
+ isPageHidden,
163
+ onVisibilityChange,
164
+ type ChatNotifier,
165
+ type BrowserNotifierOptions,
166
+ type TitleRotatorOptions,
167
+ type TitleMode,
168
+ type FaviconBadgeOptions,
169
+ type CrossTabNotifierOptions,
170
+ } from './notifier';
171
+
149
172
  // Audio
150
173
  export type {
151
174
  ChatAudioEvent,