@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 +14 -6
- package/package.json +6 -6
- package/src/components/FloatingToolbar/FloatingToolbar.css +1 -1
- package/src/tools/Chat/README.md +150 -43
- package/src/tools/Chat/components/ChatRoot.tsx +35 -2
- package/src/tools/Chat/components/MessageBubble.tsx +18 -0
- package/src/tools/Chat/hooks/index.ts +4 -0
- package/src/tools/Chat/hooks/useChatUnreadNotifier.ts +134 -0
- package/src/tools/Chat/index.ts +23 -0
- package/src/tools/Chat/launcher/ChatLauncher.tsx +135 -81
- package/src/tools/Chat/launcher/HeaderSlots.tsx +93 -0
- package/src/tools/Chat/launcher/index.ts +6 -0
- package/src/tools/Chat/launcher/types.ts +132 -0
- package/src/tools/Chat/lazy.tsx +24 -0
- package/src/tools/Chat/notifier/createBrowserNotifier.ts +64 -0
- package/src/tools/Chat/notifier/createCrossTabNotifier.ts +99 -0
- package/src/tools/Chat/notifier/faviconBadge.ts +280 -0
- package/src/tools/Chat/notifier/index.ts +20 -0
- package/src/tools/Chat/notifier/titleRotator.ts +119 -0
- package/src/tools/Chat/notifier/types.ts +38 -0
- package/src/tools/Chat/notifier/visibility.ts +47 -0
- package/src/tools/ImageViewer/components/ImageViewer.tsx +2 -2
- package/src/tools/MarkdownEditor/styles.css +7 -7
- package/src/tools/Mermaid/hooks/useMermaidRenderer.ts +1 -1
- package/src/tools/SpeechRecognition/core/languages-catalog.ts +1 -1
- package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +1 -1
- package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +1 -1
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
|
-
|
|
123
|
+
// ChatLauncher mounts <ChatProvider> internally — pass 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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
158
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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.
|
|
213
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
214
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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",
|
package/src/tools/Chat/README.md
CHANGED
|
@@ -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
|
|
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.** `
|
|
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.** `
|
|
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.** `
|
|
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.**
|
|
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
|
-
|
|
|
113
|
-
| `<
|
|
114
|
-
| `<
|
|
115
|
-
| `<
|
|
116
|
-
| `<
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
>
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
391
|
+
import { DEFAULT_CHAT_SOUNDS } from '@djangocfg/ui-tools/chat';
|
|
326
392
|
|
|
327
393
|
// Override one event, keep the rest
|
|
328
|
-
|
|
394
|
+
<ChatLauncher audio={{ sounds: { ...DEFAULT_CHAT_SOUNDS, mention: '/sfx/custom.mp3' } }} ... />
|
|
329
395
|
|
|
330
|
-
// Disable entirely (header toggle hides
|
|
331
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
353
|
-
silenced: true,
|
|
354
|
-
|
|
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
|
-
<
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
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
|
-
|
|
549
|
-
useChatReset, useVisitorFingerprint,
|
|
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`
|
|
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 {
|
|
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
|
-
|
|
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)
|