@djangocfg/ui-tools 2.1.393 → 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 +14 -6
- package/package.json +6 -6
- package/src/components/FloatingToolbar/FloatingToolbar.css +1 -1
- package/src/tools/Chat/README.md +130 -42
- package/src/tools/Chat/components/ChatRoot.tsx +26 -2
- 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/PrettyCode/lazy.tsx +6 -0
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.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.
|
|
158
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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.
|
|
213
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
214
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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",
|
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.
|
|
153
165
|
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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;
|
|
@@ -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}
|
|
@@ -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
|
+
}
|
package/src/tools/Chat/index.ts
CHANGED
|
@@ -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,
|