@djangocfg/ui-tools 2.1.381 → 2.1.382
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 +132 -899
- package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
- package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
- package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
- package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
- package/dist/DictationField-2ZLQWLYV.mjs +4 -0
- package/dist/DictationField-2ZLQWLYV.mjs.map +1 -0
- package/dist/DictationField-IPPJ54CU.cjs +13 -0
- package/dist/DictationField-IPPJ54CU.cjs.map +1 -0
- package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
- package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
- package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
- package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
- package/dist/chunk-4LXG3NBV.mjs +833 -0
- package/dist/chunk-4LXG3NBV.mjs.map +1 -0
- package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
- package/dist/chunk-FIRK5CEH.cjs.map +1 -0
- package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
- package/dist/chunk-HIK6BPL7.mjs.map +1 -0
- package/dist/chunk-KMSBGNVC.cjs +835 -0
- package/dist/chunk-KMSBGNVC.cjs.map +1 -0
- package/dist/chunk-OZAU3QWD.cjs +2493 -0
- package/dist/chunk-OZAU3QWD.cjs.map +1 -0
- package/dist/chunk-UWVP6LCW.mjs +2447 -0
- package/dist/chunk-UWVP6LCW.mjs.map +1 -0
- package/dist/index.cjs +1532 -100
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1148 -107
- package/dist/index.d.ts +1148 -107
- package/dist/index.mjs +1421 -51
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -8
- package/src/audio-assets.d.ts +8 -0
- package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
- package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
- package/src/stories/index.ts +32 -2
- package/src/tools/Chat/README.md +347 -530
- package/src/tools/Chat/components/Attachments.tsx +6 -1
- package/src/tools/Chat/components/ChatRoot.tsx +30 -2
- package/src/tools/Chat/components/Composer.tsx +20 -3
- package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
- package/src/tools/Chat/components/MessageActions.tsx +3 -1
- package/src/tools/Chat/components/MessageBubble.tsx +6 -5
- package/src/tools/Chat/components/MessageList.tsx +87 -1
- package/src/tools/Chat/components/ToolCalls.tsx +21 -3
- package/src/tools/Chat/context/ChatProvider.tsx +21 -3
- package/src/tools/Chat/core/audio/audioBus.ts +10 -163
- package/src/tools/Chat/core/audio/defaults.ts +43 -0
- package/src/tools/Chat/core/audio/index.ts +1 -0
- package/src/tools/Chat/core/audio/preferences.ts +5 -59
- package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
- package/src/tools/Chat/core/audio/types.ts +28 -0
- package/src/tools/Chat/core/reducer.ts +33 -0
- package/src/tools/Chat/core/transport/index.ts +13 -0
- package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
- package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
- package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
- package/src/tools/Chat/core/transport/sse.ts +18 -5
- package/src/tools/Chat/hooks/index.ts +25 -0
- package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
- package/src/tools/Chat/hooks/useChat.ts +28 -0
- package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
- package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
- package/src/tools/Chat/hooks/useChatReset.ts +70 -0
- package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
- package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
- package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
- package/src/tools/Chat/index.ts +69 -1
- package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
- package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
- package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
- package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
- package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
- package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
- package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
- package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
- package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
- package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
- package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
- package/src/tools/Chat/launcher/index.ts +46 -0
- package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
- package/src/tools/Chat/stories/01-basic.story.tsx +64 -0
- package/src/tools/Chat/stories/02-bubbles.story.tsx +21 -0
- package/src/tools/Chat/stories/03-tool-calls.story.tsx +59 -0
- package/src/tools/Chat/stories/04-personas.story.tsx +78 -0
- package/src/tools/Chat/stories/05-launcher.story.tsx +321 -0
- package/src/tools/Chat/stories/06-header.story.tsx +147 -0
- package/src/tools/Chat/stories/07-audio-actions.story.tsx +112 -0
- package/src/tools/Chat/stories/shared/Frame.tsx +21 -0
- package/src/tools/Chat/stories/shared/index.ts +5 -0
- package/src/tools/Chat/stories/shared/messages.ts +39 -0
- package/src/tools/Chat/stories/shared/personas.ts +13 -0
- package/src/tools/Chat/stories/shared/seeds.ts +92 -0
- package/src/tools/Chat/stories/shared/transports.ts +36 -0
- package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
- package/src/tools/Chat/styles/index.ts +16 -0
- package/src/tools/Chat/styles/useChatStyles.ts +101 -0
- package/src/tools/Chat/types/attachment.ts +25 -0
- package/src/tools/Chat/types/config.ts +48 -0
- package/src/tools/Chat/types/events.ts +35 -0
- package/src/tools/Chat/types/index.ts +34 -0
- package/src/tools/Chat/types/labels.ts +38 -0
- package/src/tools/Chat/types/message.ts +32 -0
- package/src/tools/Chat/types/persona.ts +31 -0
- package/src/tools/Chat/types/session.ts +43 -0
- package/src/tools/Chat/types/tool-call.ts +17 -0
- package/src/tools/Chat/types/transport.ts +28 -0
- package/src/tools/Chat/types.ts +5 -240
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
- package/src/tools/MarkdownEditor/index.ts +1 -1
- package/src/tools/SpeechRecognition/README.md +336 -0
- package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
- package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
- package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
- package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
- package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
- package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
- package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
- package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
- package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
- package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
- package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
- package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
- package/src/tools/SpeechRecognition/components/index.ts +16 -0
- package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
- package/src/tools/SpeechRecognition/context/index.ts +6 -0
- package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
- package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
- package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
- package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
- package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
- package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
- package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
- package/src/tools/SpeechRecognition/core/ids.ts +11 -0
- package/src/tools/SpeechRecognition/core/index.ts +14 -0
- package/src/tools/SpeechRecognition/core/language.ts +78 -0
- package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
- package/src/tools/SpeechRecognition/core/logger.ts +3 -0
- package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
- package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
- package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
- package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
- package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
- package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
- package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
- package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
- package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
- package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
- package/src/tools/SpeechRecognition/index.ts +82 -0
- package/src/tools/SpeechRecognition/lazy.tsx +19 -0
- package/src/tools/SpeechRecognition/store/index.ts +2 -0
- package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
- package/src/tools/SpeechRecognition/stories/01-basic.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/02-dictation-field.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/03-push-to-talk.story.tsx +27 -0
- package/src/tools/SpeechRecognition/stories/04-mic-meter.story.tsx +35 -0
- package/src/tools/SpeechRecognition/stories/05-custom-engine-http.story.tsx +40 -0
- package/src/tools/SpeechRecognition/stories/06-custom-engine-ws.story.tsx +48 -0
- package/src/tools/SpeechRecognition/stories/07-language-device.story.tsx +57 -0
- package/src/tools/SpeechRecognition/stories/08-errors-permissions.story.tsx +25 -0
- package/src/tools/SpeechRecognition/stories/09-chat-voice.story.tsx +90 -0
- package/src/tools/SpeechRecognition/stories/shared.tsx +123 -0
- package/src/tools/SpeechRecognition/types.ts +133 -0
- package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
- package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
- package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
- package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
- package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
- package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
- package/dist/chunk-NWUT327A.mjs.map +0 -1
- package/dist/chunk-QLMKCSR6.mjs +0 -2420
- package/dist/chunk-QLMKCSR6.mjs.map +0 -1
- package/dist/chunk-SI5RD2GD.cjs +0 -2460
- package/dist/chunk-SI5RD2GD.cjs.map +0 -1
- package/dist/chunk-XACCHZH2.cjs.map +0 -1
- package/src/tools/Chat/Chat.story.tsx +0 -1457
package/src/tools/Chat/README.md
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
# Chat
|
|
2
2
|
|
|
3
|
-
Decomposed, transport-agnostic chat. Streaming-aware, markdown-native, mobile-ready.
|
|
3
|
+
Decomposed, transport-agnostic chat. Streaming-aware, markdown-native, mobile-ready, role-aware styling, pluggable backends.
|
|
4
4
|
|
|
5
5
|
## TL;DR
|
|
6
6
|
|
|
7
7
|
```tsx
|
|
8
|
-
import { ChatRoot,
|
|
8
|
+
import { ChatRoot, createPydanticAIChatTransport, ChatLauncher } from '@djangocfg/ui-tools';
|
|
9
9
|
|
|
10
|
-
const transport =
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const transport = createPydanticAIChatTransport({
|
|
11
|
+
buildStreamUrl: (sid, msg) => `${API}/chat/sessions/${sid}/stream?message=${encodeURIComponent(msg)}`,
|
|
12
|
+
streamMethod: 'GET',
|
|
13
|
+
buildHeaders: async () => ({ Authorization: `Bearer ${getToken()}` }),
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
export function MyChat() {
|
|
16
17
|
return (
|
|
17
|
-
<
|
|
18
|
+
<ChatLauncher
|
|
19
|
+
hotkey={{ key: '/', meta: true }}
|
|
20
|
+
fab={{ variant: 'animated', tooltip: 'Open chat (⌘/)' }}
|
|
21
|
+
dock={{ title: 'Assistant', height: 600 }}
|
|
22
|
+
greeting="Hi 👋 Need help?"
|
|
23
|
+
>
|
|
18
24
|
<ChatRoot transport={transport} config={{ greeting: 'How can I help?' }} />
|
|
19
|
-
</
|
|
25
|
+
</ChatLauncher>
|
|
20
26
|
);
|
|
21
27
|
}
|
|
22
28
|
```
|
|
@@ -24,23 +30,33 @@ export function MyChat() {
|
|
|
24
30
|
## What you get
|
|
25
31
|
|
|
26
32
|
- **Headless first.** Pure reducer + hooks; UI is optional and replaceable.
|
|
33
|
+
- **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).
|
|
27
35
|
- **Decomposed.** `MessageList`, `MessageBubble`, `Composer`, `Sources`, `ToolCalls`, `Attachments`, `EmptyState`, `ErrorBanner`, `JumpToLatest`, `StreamingIndicator` — every part exported.
|
|
28
36
|
- **Markdown-native.** Sits on top of `MarkdownMessage` (GFM, code, mermaid, sanitized HTML).
|
|
29
|
-
- **Streaming.** SSE-based `AsyncGenerator` transport.
|
|
30
|
-
- **
|
|
31
|
-
- **
|
|
32
|
-
- **
|
|
33
|
-
- **
|
|
34
|
-
- **
|
|
35
|
-
- **
|
|
36
|
-
- **
|
|
37
|
-
- **
|
|
37
|
+
- **Streaming.** SSE-based `AsyncGenerator` transport. Spec-compliant `parseSSE`, token coalescing, cancel, regenerate.
|
|
38
|
+
- **Pydantic-AI mapper.** Built-in adapter for Django/pydantic-AI backends — `text_delta` / `tool_call` / `tool_result` → canonical `ChatStreamEvent`. FIFO tool-id queue.
|
|
39
|
+
- **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
|
+
- **Live push.** `chat.injectMessage(msg)` / `updateMessage(id, patch)` for admin takeover / Centrifugo / WebSocket inbound. `useChatUnread()` tracks unseen; `<ChatUnreadPreview>` surfaces them next to the FAB.
|
|
41
|
+
- **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).
|
|
43
|
+
- **Rich attachments.** `AttachmentsGrid` for thumbnails, `AttachmentsList` for custom renderers; `onAttachmentOpen` for lightbox.
|
|
44
|
+
- **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.
|
|
46
|
+
- **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
|
+
- **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
|
+
- **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.
|
|
50
|
+
- **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
|
+
- **Centralized colors.** Role-aware className tokens (`BUBBLE_SURFACE` / `ANCHOR` / `TOGGLE` / `DESTRUCTIVE_SURFACE`) + hooks (`useChatBubbleStyles`, `useChatRoleStyles`, `useChatDestructiveStyles`).
|
|
52
|
+
- **Responsive.** FAB `size='responsive'` (default): phone → `sm`, tablet → `md`, desktop → `lg`. Side mode is desktop-only and falls back to popover below `lg`.
|
|
53
|
+
- **Mobile fullscreen.** Dock auto-fills viewport below 768px via `useIsMobile`. Heights use `dvh/svh/lvh` so iOS Safari URL bar doesn't clip the chat.
|
|
38
54
|
- **A11y.** `role="log"` + polite live region, `aria-busy` on streaming bubbles, `role="alert"` errors, focus-visible actions.
|
|
39
55
|
|
|
40
56
|
## Architecture
|
|
41
57
|
|
|
42
58
|
```
|
|
43
|
-
Transport (interface) ← HTTP+SSE / Wails /
|
|
59
|
+
Transport (interface) ← Pydantic-AI / HTTP+SSE / Wails / mock
|
|
44
60
|
↓
|
|
45
61
|
Reducer (pure state machine)
|
|
46
62
|
↓
|
|
@@ -50,507 +66,342 @@ ChatProvider (context)
|
|
|
50
66
|
↓
|
|
51
67
|
Components (MessageList, MessageBubble, Composer, Sources, ToolCalls, …)
|
|
52
68
|
↓
|
|
53
|
-
ChatRoot (one-line preset)
|
|
69
|
+
ChatRoot (one-line preset) ◄── optionally wrapped by ──► ChatLauncher (FAB + Dock + Greeting)
|
|
54
70
|
```
|
|
55
71
|
|
|
56
72
|
Module boundaries:
|
|
57
73
|
|
|
58
74
|
| Layer | May import | May NOT import |
|
|
59
75
|
| ---------------- | -------------------------------- | ------------------------ |
|
|
60
|
-
| `
|
|
61
|
-
| `core/
|
|
76
|
+
| `types/` | — | anything |
|
|
77
|
+
| `core/transport` | `types/` | React, hooks, components |
|
|
78
|
+
| `core/reducer` | `types/` | React, transport |
|
|
62
79
|
| `hooks` | `core/*`, `ui-core` hooks | components |
|
|
63
80
|
| `context` | `hooks` | components |
|
|
64
|
-
| `
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
| `styles/` | `types/` | hooks, components |
|
|
82
|
+
| `components` | `hooks`, `context`, `styles`, `ui-core` UI | transport implementations |
|
|
83
|
+
| `launcher/` | `components`, `styles`, `ui-core` UI | transport implementations |
|
|
84
|
+
|
|
85
|
+
## Types
|
|
86
|
+
|
|
87
|
+
Single source of truth in [`types/`](./types/index.ts). Split by domain — import from the top-level `@djangocfg/ui-tools` barrel.
|
|
88
|
+
|
|
89
|
+
| Domain | What's there |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `persona` | `ChatRole`, `ChatPersona`, `ChatUserContext`, `ChatAssistantContext` |
|
|
92
|
+
| `message` | `ChatMessage` |
|
|
93
|
+
| `tool-call` | `ChatToolCall` |
|
|
94
|
+
| `attachment` | `ChatAttachment`, `ChatSource` |
|
|
95
|
+
| `labels` | `ChatLabels` + `DEFAULT_LABELS` |
|
|
96
|
+
| `config` | `ChatConfig`, `ChatPrefs`, `ChatDisplayMode` |
|
|
97
|
+
| `events` | `ChatStreamEvent` SSE union |
|
|
98
|
+
| `session` | `SessionInfo`, `HistoryPage`, `Stream/Send/CreateSessionOptions` |
|
|
99
|
+
| `transport` | `ChatTransport` |
|
|
100
|
+
|
|
101
|
+
## Launcher (FAB + Dock + Greeting)
|
|
102
|
+
|
|
103
|
+
Picks the highest level that fits.
|
|
104
|
+
|
|
105
|
+
| Component | Use when |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `<ChatLauncher>` | FAB + dock + greeting + push-preview + hotkey + audio toggle (~99% of cases) |
|
|
108
|
+
| `<ChatDock>` | Custom trigger (header button, inline link). Popover or side mode. |
|
|
109
|
+
| `<ChatFAB>` | Floating button only |
|
|
110
|
+
| `<ChatGreeting>` | Standalone proactive bubble |
|
|
111
|
+
| `<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`) |
|
|
117
|
+
|
|
118
|
+
### FAB
|
|
69
119
|
|
|
70
120
|
```tsx
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
composerAttachmentTray={<MyTray />} // replaces default attachment tray
|
|
83
|
-
jumpToLatest={<MyJumpPill />} // replaces floating "↓ N new" pill
|
|
84
|
-
|
|
85
|
-
// Render-prop slots (need access to data)
|
|
86
|
-
renderMessage={(m, i) => <MyBubble message={m} />}
|
|
87
|
-
renderHeader={(ctx) => (ctx.isStreaming ? <Streaming /> : <Idle />)}
|
|
88
|
-
renderEmpty={({ setValue, focus }) => <MyOnboarding onPick={setValue} />}
|
|
89
|
-
|
|
90
|
-
// Tool-call payload renderers — forwarded to <MessageBubble toolCallsProps>
|
|
91
|
-
toolCallsProps={{
|
|
92
|
-
defaultExpanded: true,
|
|
93
|
-
renderInput: (v) => <LazyJsonTree data={v} mode="compact" />,
|
|
94
|
-
renderOutput: (v) => <LazyJsonTree data={v} mode="compact" />,
|
|
95
|
-
}}
|
|
96
|
-
|
|
97
|
-
// Hide composer while agent is paused waiting for human input
|
|
98
|
-
hideComposer={hasPendingApprovals}
|
|
99
|
-
/>
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
### Slot inventory
|
|
103
|
-
|
|
104
|
-
```
|
|
105
|
-
┌───────────────────────────────┐
|
|
106
|
-
│ banner (sticky) │
|
|
107
|
-
├───────────────────────────────┤
|
|
108
|
-
│ header │
|
|
109
|
-
├───────────────────────────────┤
|
|
110
|
-
│ messages (jumpToLatest) │
|
|
111
|
-
├───────────────────────────────┤
|
|
112
|
-
│ composerToolbarStart │ ← hidden when hideComposer={true}
|
|
113
|
-
│ [textarea] composerToolbarEnd │
|
|
114
|
-
│ composerAttachmentTray │
|
|
115
|
-
├───────────────────────────────┤
|
|
116
|
-
│ footer │ ← use for approval panels, disclaimers
|
|
117
|
-
└───────────────────────────────┘
|
|
121
|
+
fab={{
|
|
122
|
+
variant: 'simple' | 'animated' | 'glass', // default 'simple'
|
|
123
|
+
size: 'responsive', // default — phone=sm/tablet=md/desktop=lg
|
|
124
|
+
position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left',
|
|
125
|
+
offset: 24,
|
|
126
|
+
pulse: true, // attention dot + ping
|
|
127
|
+
badge: 5, // unread count (renders "9+")
|
|
128
|
+
tooltip: 'Open chat', // hover/focus label
|
|
129
|
+
icon: <Sparkles />,
|
|
130
|
+
inline: true, // no fixed positioning (stories/previews)
|
|
131
|
+
}}
|
|
118
132
|
```
|
|
119
133
|
|
|
120
|
-
###
|
|
121
|
-
|
|
122
|
-
Set `hideComposer={true}` to remove the composer while the agent is waiting for a human decision (approval gate, HITL). Combine with `footer` to show action buttons:
|
|
134
|
+
### Dock
|
|
123
135
|
|
|
124
136
|
```tsx
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
137
|
+
dock={{
|
|
138
|
+
title: 'Assistant',
|
|
139
|
+
mode: 'popover' | 'side', // default 'popover'; 'side' = full-height edge panel
|
|
140
|
+
side: 'left' | 'right', // default 'right' (only for mode='side')
|
|
141
|
+
width: 480, height: 720,
|
|
142
|
+
position: 'bottom-right', // popover mode only
|
|
143
|
+
offset: { horizontal: 24, vertical: 96 },
|
|
144
|
+
mobileFullscreen: true, // default — fills viewport < 768px
|
|
145
|
+
reserveBodySpace: true, // side mode — sets body padding so content shifts
|
|
146
|
+
disablePortal: true, // render in-place (stories/iframes)
|
|
147
|
+
hideHeader: true, // children render their own header
|
|
148
|
+
headerActions: <ChatHeaderResetButton ... />,
|
|
149
|
+
}}
|
|
134
150
|
```
|
|
135
151
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
### ToolCalls — expand behavior
|
|
152
|
+
**Side mode is desktop-only.** Below `lg` (1024px) it silently falls back to popover and the `<ChatHeaderModeToggle>` hides itself.
|
|
139
153
|
|
|
140
|
-
|
|
154
|
+
**Persisted prefs.** `useChatDockPrefs()` stores `mode` / `side` / `sideWidth` in localStorage. Drop it into your launcher to give users a remembered layout:
|
|
141
155
|
|
|
142
156
|
```tsx
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
157
|
+
const prefs = useChatDockPrefs({ storageKey: 'crm.chat.dock' });
|
|
158
|
+
<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} />,
|
|
164
|
+
}}
|
|
165
|
+
>{children}</ChatLauncher>
|
|
149
166
|
```
|
|
150
167
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
### ToolCalls payload renderers
|
|
154
|
-
|
|
155
|
-
`<ToolCalls>` (and `<MessageBubble toolCallsProps>` / `<ChatRoot toolCallsProps>`) accept payload renderers so you control how tool input / output / streaming text is displayed. Defaults render a cheap `<pre>`. For a richer experience, plug in `LazyJsonTree` from `@djangocfg/ui-tools/json-tree` with `mode="compact"`:
|
|
168
|
+
### Greeting
|
|
156
169
|
|
|
157
170
|
```tsx
|
|
158
|
-
|
|
171
|
+
// Short
|
|
172
|
+
greeting="Got a minute? I'm here to help."
|
|
159
173
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
174
|
+
// Full config
|
|
175
|
+
greeting={{
|
|
176
|
+
content: <>Looking for something? <strong>Chat with us</strong>.</>,
|
|
177
|
+
senderName: 'Anna · Support',
|
|
178
|
+
avatar: <Avatar><AvatarImage src="…" /></Avatar>,
|
|
179
|
+
delayMs: 1500,
|
|
180
|
+
dismissStorageKey: 'crm-greeting-v1',
|
|
181
|
+
hideOnOpen: true,
|
|
182
|
+
}}
|
|
165
183
|
```
|
|
166
184
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
We don't import `LazyJsonTree` automatically — keeping the chat dep-light. The host opts in.
|
|
170
|
-
|
|
171
|
-
### ToolCalls — rich UI after panels (`renderAfterCalls`)
|
|
172
|
-
|
|
173
|
-
`renderAfterCalls` renders **outside and below** all collapsible panels — always visible, regardless of whether panels are expanded or hidden. It receives the full `calls` array so you can aggregate across multiple tool calls (e.g. collect all vehicle IDs from `search_vehicles` + `get_vehicle` and render cards).
|
|
185
|
+
### Hotkey
|
|
174
186
|
|
|
175
187
|
```tsx
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// Rich UI below all panels — always visible, never inside collapsed accordions
|
|
179
|
-
renderAfterCalls: (calls) => <VehicleCardList calls={calls} />,
|
|
180
|
-
}}
|
|
181
|
-
/>
|
|
188
|
+
hotkey={{ key: '/', meta: true }} // ⌘/ or Ctrl+/
|
|
189
|
+
hotkey={{ key: 'K', meta: true, shift: true }}
|
|
182
190
|
```
|
|
183
191
|
|
|
184
|
-
|
|
192
|
+
When `meta` is unset, modifier-bearing combos do **not** trigger — prevents shadowing native shortcuts.
|
|
185
193
|
|
|
186
|
-
|
|
194
|
+
### Controlled mode
|
|
187
195
|
|
|
188
196
|
```tsx
|
|
189
|
-
|
|
190
|
-
toolCallsProps={{
|
|
191
|
-
hideToolCalls: true, // hides accordion panels
|
|
192
|
-
renderAfterCalls: (calls) => <VehicleCards calls={calls} />, // still runs
|
|
193
|
-
}}
|
|
194
|
-
/>
|
|
195
|
-
```
|
|
197
|
+
const [open, setOpen] = useState(false);
|
|
196
198
|
|
|
197
|
-
|
|
199
|
+
<ChatLauncher open={open} onOpenChange={setOpen} dock={{ title: 'Helper' }}>
|
|
200
|
+
<Chat />
|
|
201
|
+
</ChatLauncher>
|
|
202
|
+
```
|
|
198
203
|
|
|
199
|
-
|
|
204
|
+
### Live push notifications
|
|
200
205
|
|
|
201
|
-
|
|
206
|
+
Inbound messages from outside the chat session (admin takeover, server-pushed alerts, Centrifugo broadcasts) flow through the same reducer as normal turns:
|
|
202
207
|
|
|
203
208
|
```tsx
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
209
|
+
// Inside ChatProvider — anywhere
|
|
210
|
+
const chat = useChatContext();
|
|
211
|
+
|
|
212
|
+
centrifugo.on('chat:inbound', (msg) => {
|
|
213
|
+
chat.injectMessage({
|
|
214
|
+
id: msg.id,
|
|
215
|
+
role: 'assistant',
|
|
216
|
+
content: msg.text,
|
|
217
|
+
createdAt: Date.now(),
|
|
218
|
+
sender: { name: msg.from, avatarUrl: msg.avatar },
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
chat.updateMessage(id, { content: editedText }); // live-edit by admin
|
|
223
|
+
chat.deleteMessage(id); // retract
|
|
214
224
|
```
|
|
215
225
|
|
|
216
|
-
|
|
226
|
+
Surface them next to the FAB while the dock is closed:
|
|
217
227
|
|
|
218
228
|
```tsx
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
function Launcher() {
|
|
230
|
+
const [open, setOpen] = useState(false);
|
|
231
|
+
const { unread, markRead } = useChatUnread({ open }); // inside ChatProvider
|
|
232
|
+
return (
|
|
233
|
+
<ChatLauncher
|
|
234
|
+
open={open}
|
|
235
|
+
onOpenChange={setOpen}
|
|
236
|
+
unreadMessage={unread}
|
|
237
|
+
onMarkRead={markRead}
|
|
238
|
+
// FAB badge "1" derived automatically; override via `fab.badge`
|
|
239
|
+
>…</ChatLauncher>
|
|
240
|
+
);
|
|
226
241
|
}
|
|
227
|
-
|
|
228
|
-
// In your chat component:
|
|
229
|
-
const toolCallsProps = useMemo(() => buildVamcarToolCallsProps(), []);
|
|
230
|
-
<ChatRoot transport={transport} toolCallsProps={toolCallsProps} />
|
|
231
242
|
```
|
|
232
243
|
|
|
233
|
-
|
|
244
|
+
`<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.
|
|
234
245
|
|
|
235
|
-
|
|
246
|
+
### Audio
|
|
236
247
|
|
|
237
|
-
|
|
238
|
-
<ChatRoot
|
|
239
|
-
transport={transport}
|
|
240
|
-
config={{
|
|
241
|
-
user: {
|
|
242
|
-
name: 'Mark',
|
|
243
|
-
avatarUrl: '/me.jpg',
|
|
244
|
-
description: 'Senior engineer',
|
|
245
|
-
},
|
|
246
|
-
assistant: {
|
|
247
|
-
name: 'Claude',
|
|
248
|
-
avatarUrl: '/claude.png',
|
|
249
|
-
model: 'claude-opus-4-7',
|
|
250
|
-
},
|
|
251
|
-
}}
|
|
252
|
-
/>
|
|
253
|
-
```
|
|
248
|
+
Wire `useChatAudio` once and pass it to `ChatLauncher` — the header gets an auto-injected mute toggle:
|
|
254
249
|
|
|
255
|
-
|
|
250
|
+
```tsx
|
|
251
|
+
// Zero-setup — built-in sounds are bundled as base64 data URLs
|
|
252
|
+
const audio = useChatAudio();
|
|
256
253
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
254
|
+
<ChatLauncher audio={audio} dock={{ title: 'Helper' }}>
|
|
255
|
+
<ChatRoot transport={transport} />
|
|
256
|
+
</ChatLauncher>
|
|
257
|
+
```
|
|
260
258
|
|
|
261
|
-
|
|
259
|
+
**Built-in sounds.** The chat tool ships its own notification pack (`messageSent`, `messageReceived`, `streamStart`, `error`, `mention`, `notification`) inlined into the lazy chunk via tsup's `dataurl` loader. ~136KB total, ~180KB after base64 — paid only by hosts that actually import Chat. No assets to copy, no CDN to host.
|
|
262
260
|
|
|
263
|
-
|
|
264
|
-
{
|
|
265
|
-
id: 'm3',
|
|
266
|
-
role: 'user',
|
|
267
|
-
content: 'Flag the auth migration section.',
|
|
268
|
-
sender: { name: 'Anna', avatarUrl: '/anna.jpg' },
|
|
269
|
-
}
|
|
270
|
-
```
|
|
261
|
+
Customize:
|
|
271
262
|
|
|
272
|
-
|
|
263
|
+
```tsx
|
|
264
|
+
import { useChatAudio, DEFAULT_CHAT_SOUNDS } from '@djangocfg/ui-tools';
|
|
273
265
|
|
|
274
|
-
|
|
266
|
+
// Override one event, keep the rest
|
|
267
|
+
useChatAudio({ sounds: { ...DEFAULT_CHAT_SOUNDS, mention: '/sfx/custom.mp3' } });
|
|
275
268
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const persona = resolvePersona(message, config.user, config.assistant);
|
|
279
|
-
const fallback = deriveInitials(persona, message.role);
|
|
269
|
+
// Disable entirely (header toggle hides itself)
|
|
270
|
+
useChatAudio({ sounds: {} });
|
|
280
271
|
```
|
|
281
272
|
|
|
282
|
-
|
|
273
|
+
Per-event volume defaults — Slack / Linear / Intercom style:
|
|
283
274
|
|
|
284
|
-
|
|
275
|
+
| Event | Scale | Why |
|
|
276
|
+
|---|---|---|
|
|
277
|
+
| `error` | 0.25 | The destructive UI is the loud signal; sound is a soft ack |
|
|
278
|
+
| `streamStart` | 0.3 | Fires often, must stay subtle |
|
|
279
|
+
| `messageSent` | 0.5 | Self-confirmation |
|
|
280
|
+
| `messageReceived` | 0.7 | Baseline |
|
|
281
|
+
| `notification` | 0.9 | Push to a background tab — needs to be heard |
|
|
282
|
+
| `mention` | 1.0 | Louder than baseline — personal |
|
|
285
283
|
|
|
286
|
-
|
|
287
|
-
| ------------------- | -------------- | -------------------------------------------- |
|
|
288
|
-
| `<AttachmentsGrid>` | `flex-wrap` | Thumbnails, file chips, no custom renderers |
|
|
289
|
-
| `<AttachmentsList>` | `flex-col` | Custom renderers (audio, video, doc viewer) |
|
|
290
|
-
| `<Attachments>` | picks for you | Backwards-compat: stacks when `renderers` is given, wraps otherwise |
|
|
284
|
+
Override via `eventVolumes: { error: 0, mention: 0.8 }`. Pass `eventVolumes: {}` to skip defaults entirely.
|
|
291
285
|
|
|
292
|
-
|
|
286
|
+
`audio.muted` / `audio.toggleMute()` are persisted in localStorage. Header `<ChatHeaderAudioToggle>` is hidden automatically when `audio.isSilent` (no sounds wired).
|
|
293
287
|
|
|
294
|
-
|
|
295
|
-
import { AttachmentsList } from '@djangocfg/ui-tools';
|
|
288
|
+
**Native hosts (cmdop_go / Tauri / Electron).** Skip web playback but keep the trigger as a side-channel:
|
|
296
289
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
/>
|
|
290
|
+
```tsx
|
|
291
|
+
const audio = useChatAudio({
|
|
292
|
+
silenced: true,
|
|
293
|
+
onSoundEvent: (e) => window.go.playSound(e),
|
|
294
|
+
});
|
|
303
295
|
```
|
|
304
296
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
Optional sound effects on chat events. Off by default — you opt in with a `sounds` map. Six events:
|
|
297
|
+
See [`@djangocfg/ui-core` Audio docs](../../../../ui-core/README.md#audio) for the underlying `useNotificationSounds` primitives.
|
|
308
298
|
|
|
309
|
-
|
|
310
|
-
| ------------------ | --------------------------------------------------- |
|
|
311
|
-
| `messageSent` | After `useChat.sendMessage()` adds the user message |
|
|
312
|
-
| `messageReceived` | On `STREAM_DONE` (final assistant message) |
|
|
313
|
-
| `streamStart` | First token / placeholder created |
|
|
314
|
-
| `error` | `STREAM_ERROR` or transport throw |
|
|
315
|
-
| `mention` | Host-fired (e.g. assistant addressed the user) |
|
|
316
|
-
| `notification` | Generic — host triggers manually |
|
|
299
|
+
### Reset / clear conversation
|
|
317
300
|
|
|
318
301
|
```tsx
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
messageSent: '/audio/chat/sent.mp3',
|
|
324
|
-
messageReceived: '/audio/chat/received.mp3',
|
|
325
|
-
streamStart: '/audio/chat/start.mp3',
|
|
326
|
-
error: '/audio/chat/error.mp3',
|
|
327
|
-
},
|
|
328
|
-
// Defaults: muteWhenHidden: true, respectReducedMotion: true, respectReducedData: true
|
|
329
|
-
}}
|
|
302
|
+
<ChatHeaderResetButton
|
|
303
|
+
onReset={api.clearChat} // your backend POST /chat/reset
|
|
304
|
+
onSuccess={() => chat.clearMessages()}
|
|
305
|
+
confirmMessage="Forget this conversation? The assistant won't remember it."
|
|
330
306
|
/>
|
|
331
307
|
```
|
|
332
308
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
- **iOS/Safari autoplay block.** First `pointerdown` / `keydown` inside `<ChatProvider>` runs a transactional unlock — every cached `<audio>` is poked once with `muted=true`, then released.
|
|
336
|
-
- **Same-element race.** Each `play()` clones a fresh `Audio(url)` so two rapid events don't cancel each other; the cached element warms the HTTP cache.
|
|
337
|
-
- **Cross-tab sync.** Volume / mute / per-event toggles live in a Zustand `persist` store with `localStorage` + the native `storage` event.
|
|
338
|
-
- **Visibility.** Auto-mute while `document.visibilityState === 'hidden'`.
|
|
339
|
-
- **Reduced motion / data.** Auto-suppress on `prefers-reduced-motion: reduce` and `prefers-reduced-data: reduce` (configurable).
|
|
340
|
-
- **SSR-safe.** `audioBus` falls back to a no-op when `window` is undefined.
|
|
309
|
+
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.
|
|
341
310
|
|
|
342
|
-
|
|
311
|
+
### Anti-patterns
|
|
343
312
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
313
|
+
| Don't | Why |
|
|
314
|
+
|---|---|
|
|
315
|
+
| Two `<ChatLauncher>` on one page | Hotkey opens both at once |
|
|
316
|
+
| `hotkey={{ key: '/' }}` (no modifier) | Fires when user types `/` in any input |
|
|
317
|
+
| Mismatched `exitDurationMs` vs CSS | Dock unmounts before/after animation |
|
|
318
|
+
| Reusing `dismissStorageKey` across products | Greeting won't show on the second product |
|
|
319
|
+
| Calling `useChatUnread()` outside `ChatProvider` | Hook reads the messages store; throws without provider |
|
|
320
|
+
| Forgetting `<DialogProvider>` for `<ChatHeaderResetButton confirm>` | Falls back to native browser confirm (ugly) |
|
|
352
321
|
|
|
353
|
-
|
|
322
|
+
## Styling — role-aware tokens
|
|
354
323
|
|
|
355
|
-
|
|
324
|
+
Every chat surface that depends on role/error state uses the centralized tokens, not inline Tailwind literals. Change `bg-primary`/contrast in one file — fixes every consumer.
|
|
356
325
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
ref. The built-in `<Composer>` registers itself on mount; custom
|
|
360
|
-
composers opt in with a one-line hook.
|
|
326
|
+
```ts
|
|
327
|
+
import { BUBBLE_SURFACE, ANCHOR, TOGGLE, DESTRUCTIVE_SURFACE } from '@djangocfg/ui-tools';
|
|
361
328
|
|
|
362
|
-
|
|
363
|
-
|
|
329
|
+
// Token shape
|
|
330
|
+
BUBBLE_SURFACE.user // 'bg-primary text-primary-foreground rounded-tr-md'
|
|
331
|
+
BUBBLE_SURFACE.assistant // 'bg-muted text-foreground rounded-tl-md'
|
|
332
|
+
BUBBLE_SURFACE.error // 'bg-destructive/10 text-destructive rounded-tl-md border border-destructive/30'
|
|
364
333
|
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
const focus = useCallback(() => myEditorRef.current?.commands.focus(), []);
|
|
368
|
-
useRegisterComposer(focus);
|
|
369
|
-
// ...
|
|
370
|
-
}
|
|
334
|
+
ANCHOR.user // legible on bg-primary (uses primary-foreground, not white)
|
|
335
|
+
ANCHOR.assistant // brand-primary on neutral bubble
|
|
371
336
|
|
|
372
|
-
|
|
373
|
-
const ctx = useChatContext();
|
|
374
|
-
ctx.composer?.focus(); // null until any composer has mounted
|
|
337
|
+
DESTRUCTIVE_SURFACE.banner / .hover / .hoverStrong / .text / .menuItem
|
|
375
338
|
```
|
|
376
339
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
Standard chat UX: user types → sends → reads the reply → starts
|
|
380
|
-
typing again without reaching for the mouse. The hook listens for the
|
|
381
|
-
streaming → idle transition and calls `.focus()` exactly once. Reading
|
|
382
|
-
mid-stream is undisturbed.
|
|
383
|
-
|
|
384
|
-
Zero-config (default: focuses the registered composer):
|
|
340
|
+
Prefer hooks in components — they memoize and present a stable facade:
|
|
385
341
|
|
|
386
342
|
```tsx
|
|
387
|
-
import {
|
|
343
|
+
import { useChatBubbleStyles, useChatRoleStyles, useChatDestructiveStyles } from '@djangocfg/ui-tools';
|
|
388
344
|
|
|
389
|
-
function
|
|
390
|
-
|
|
391
|
-
return <
|
|
345
|
+
function MyBubble({ message }) {
|
|
346
|
+
const { surface, anchor } = useChatBubbleStyles(message.role, !!message.isError);
|
|
347
|
+
return <div className={cn('rounded-2xl px-3 py-2', surface)}>…</div>;
|
|
392
348
|
}
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
Options:
|
|
396
|
-
|
|
397
|
-
| option | default | when to set |
|
|
398
|
-
|---------------|------------------------|-----------------------------------------------------------------------------|
|
|
399
|
-
| `isStreaming` | `ctx.isStreaming` | Driving stream state from your own store (e.g. an external event bus). |
|
|
400
|
-
| `targetRef` | registered composer | Focus something other than the composer — an "approve" button, etc. |
|
|
401
|
-
| `enabled` | `true` | User preference toggle (off without unmounting the hook). |
|
|
402
|
-
| `delayMs` | `0` (next rAF) | 50–150ms helps when the composer re-mounts after the final chunk. |
|
|
403
|
-
|
|
404
|
-
The hook only fires on the `true → false` edge — flipping `enabled`
|
|
405
|
-
mid-stream won't steal focus.
|
|
406
349
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
helper is also exported standalone for custom composers:
|
|
412
|
-
|
|
413
|
-
```ts
|
|
414
|
-
import { sanitizeDraft, isSubmittableDraft } from '@djangocfg/ui-tools'
|
|
350
|
+
function MyToggle({ isUser }) {
|
|
351
|
+
const { toggle } = useChatRoleStyles(isUser);
|
|
352
|
+
return <button className={toggle}>Read more</button>;
|
|
353
|
+
}
|
|
415
354
|
|
|
416
|
-
|
|
417
|
-
|
|
355
|
+
function MyError() {
|
|
356
|
+
const { banner, hover } = useChatDestructiveStyles();
|
|
357
|
+
return <div className={banner}>…<button className={hover}>Dismiss</button></div>;
|
|
358
|
+
}
|
|
418
359
|
```
|
|
419
360
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
| Rule | Action | Why |
|
|
423
|
-
|---|---|---|
|
|
424
|
-
| Trim outer whitespace | ✅ | Stray newlines/spaces at the edges are never intentional. |
|
|
425
|
-
| Normalise `\r\n` / `\r` → `\n` | ✅ | Deterministic shape for LLM tokeniser + markdown render. |
|
|
426
|
-
| Strip ZWSP / ZWNJ / ZWJ / BOM | ✅ | Pasted from rich web pages, invisible, break tokenisation. |
|
|
427
|
-
| **Collapse internal whitespace runs** | ❌ | Would mangle code indentation. |
|
|
428
|
-
| **Cap consecutive blank lines** | ❌ | Could be intentional separator (markdown / structured prompt). |
|
|
429
|
-
| **Strip bidi overrides** (LRM/RLM/U+202A..E) | ❌ | Legitimately used in Arabic/Hebrew RTL content. |
|
|
430
|
-
| Touch tabs vs spaces, emoji, mentions, URLs | ❌ | Passthrough. |
|
|
431
|
-
|
|
432
|
-
Idempotent: `sanitizeDraft(sanitizeDraft(x)) === sanitizeDraft(x)`.
|
|
433
|
-
|
|
434
|
-
### Escape hatch — `preserveExactValue`
|
|
361
|
+
## Transport contract
|
|
435
362
|
|
|
436
|
-
|
|
437
|
-
byte-perfect passthrough. Opt out per-composer:
|
|
363
|
+
A single I/O seam.
|
|
438
364
|
|
|
439
365
|
```ts
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
})
|
|
366
|
+
interface ChatTransport {
|
|
367
|
+
createSession(opts?): Promise<SessionInfo>;
|
|
368
|
+
loadHistory(sessionId, cursor?, limit?): Promise<HistoryPage>;
|
|
369
|
+
stream(sessionId, content, { signal, attachments, metadata }): AsyncGenerator<ChatStreamEvent>;
|
|
370
|
+
send(sessionId, content, options?): Promise<ChatMessage>;
|
|
371
|
+
closeSession(sessionId): Promise<void>;
|
|
372
|
+
}
|
|
444
373
|
```
|
|
445
374
|
|
|
446
|
-
|
|
447
|
-
an empty message is rarely intentional even when sanitation is off.
|
|
448
|
-
|
|
449
|
-
## Attachment renderers (registry)
|
|
375
|
+
Shipped implementations:
|
|
450
376
|
|
|
451
|
-
|
|
377
|
+
- **`createPydanticAIChatTransport({ buildStreamUrl, buildHeaders, bootstrapSession, loadHistory, ... })`** — for Django/pydantic-AI–style backends. Composes `parseSSE` + `mapPydanticAIEvent` + tool-id FIFO queue. Side-channel via `onPydanticEvent` for non-stream events (e.g. `approval_required`).
|
|
378
|
+
- **`createHttpTransport({ baseUrl, getAuthHeader, slug })`** — fetch + SSE for the canonical `POST /sessions/:id/messages` shape.
|
|
379
|
+
- **`createMockTransport({ replies, latencyMs })`** — scripted in-memory replies for stories/tests.
|
|
452
380
|
|
|
453
|
-
|
|
454
|
-
import { LazyPlayer as LazyAudioPlayer } from '@djangocfg/ui-tools/audio-player';
|
|
455
|
-
|
|
456
|
-
<ChatRoot
|
|
457
|
-
transport={transport}
|
|
458
|
-
attachmentRenderers={{
|
|
459
|
-
audio: ({ attachment }) => (
|
|
460
|
-
<div className="my-1 max-w-md">
|
|
461
|
-
<LazyAudioPlayer src={attachment.url} title={attachment.name} variant="compact" />
|
|
462
|
-
</div>
|
|
463
|
-
),
|
|
464
|
-
// image / video / file / default — all optional
|
|
465
|
-
}}
|
|
466
|
-
onAttachmentOpen={(att) => openLightbox(att)}
|
|
467
|
-
/>
|
|
468
|
-
```
|
|
381
|
+
Build a custom transport directly when none of the above fit.
|
|
469
382
|
|
|
470
|
-
|
|
383
|
+
### Pydantic-AI shortcuts
|
|
471
384
|
|
|
472
385
|
```ts
|
|
473
|
-
type AttachmentRenderer = (args: {
|
|
474
|
-
attachment: ChatAttachment;
|
|
475
|
-
isInComposer: boolean; // true in the composer staging tray
|
|
476
|
-
onClick?: () => void; // forwarded from <ChatRoot onAttachmentOpen>
|
|
477
|
-
onRemove?: () => void; // present in the composer tray
|
|
478
|
-
}) => ReactNode;
|
|
479
|
-
```
|
|
480
|
-
|
|
481
|
-
`<MessageBubble attachmentsRenderer>` (a wholesale slot) still wins over the registry — use it when you need to swap the entire attachments block.
|
|
482
|
-
|
|
483
|
-
## Image lightbox helpers
|
|
484
|
-
|
|
485
|
-
```tsx
|
|
486
386
|
import {
|
|
487
|
-
|
|
488
|
-
|
|
387
|
+
createPydanticAIChatTransport,
|
|
388
|
+
createPydanticAISSEMap,
|
|
389
|
+
mapPydanticAIEvent,
|
|
390
|
+
createToolIdQueue,
|
|
489
391
|
} from '@djangocfg/ui-tools';
|
|
490
|
-
import { LazyImageViewer } from '@djangocfg/ui-tools/image-viewer';
|
|
491
|
-
|
|
492
|
-
function MyChat() {
|
|
493
|
-
const lightbox = useChatLightbox();
|
|
494
|
-
const ctx = useChatContext();
|
|
495
|
-
const gallery = useMemo(() => collectImageAttachments(ctx.messages), [ctx.messages]);
|
|
496
392
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
onAttachmentOpen={(att) => att.type === 'image' && lightbox.open(att, gallery)}
|
|
502
|
-
/>
|
|
503
|
-
<Dialog open={!!lightbox.state} onOpenChange={(o) => !o && lightbox.close()}>
|
|
504
|
-
<DialogContent className="max-w-5xl">
|
|
505
|
-
{lightbox.state && (
|
|
506
|
-
<LazyImageViewer
|
|
507
|
-
images={lightbox.state.gallery.map((a) => ({
|
|
508
|
-
file: { name: a.name ?? a.id, path: a.id },
|
|
509
|
-
src: a.url,
|
|
510
|
-
}))}
|
|
511
|
-
initialIndex={lightbox.state.index}
|
|
512
|
-
inDialog
|
|
513
|
-
/>
|
|
514
|
-
)}
|
|
515
|
-
</DialogContent>
|
|
516
|
-
</Dialog>
|
|
517
|
-
</>
|
|
518
|
-
);
|
|
393
|
+
// In a custom transport / hand-rolled stream loop:
|
|
394
|
+
const toolIds = createToolIdQueue();
|
|
395
|
+
for await (const event of parseSSE(res, { map: createPydanticAISSEMap() })) {
|
|
396
|
+
yield event;
|
|
519
397
|
}
|
|
520
398
|
```
|
|
521
399
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
## Tool-payload dispatcher
|
|
525
|
-
|
|
526
|
-
For tool-call panels, `dispatchToolPayload(matchers, fallback)` lets you pick a renderer per payload shape — without writing your own switch each time.
|
|
400
|
+
## Slots
|
|
527
401
|
|
|
528
|
-
|
|
529
|
-
import {
|
|
530
|
-
dispatchToolPayload,
|
|
531
|
-
isLatLng,
|
|
532
|
-
isPlainObject,
|
|
533
|
-
isGeoJSONFeatureCollection,
|
|
534
|
-
} from '@djangocfg/ui-tools';
|
|
535
|
-
import { LazyJsonTree } from '@djangocfg/ui-tools/json-tree';
|
|
536
|
-
import { LazyMap } from '@djangocfg/ui-tools';
|
|
537
|
-
|
|
538
|
-
const renderPayload = dispatchToolPayload(
|
|
539
|
-
[
|
|
540
|
-
{
|
|
541
|
-
match: (v) => isGeoJSONFeatureCollection(v),
|
|
542
|
-
render: (v) => <LazyMap geojson={v as GeoJSON.FeatureCollection} />,
|
|
543
|
-
},
|
|
544
|
-
{ match: (v) => isLatLng(v), render: (v) => <LazyMap markers={[v as { lat: number; lng: number }]} /> },
|
|
545
|
-
{ match: (v) => isPlainObject(v), render: (v) => <LazyJsonTree data={v} mode="compact" /> },
|
|
546
|
-
],
|
|
547
|
-
(v) => <pre className="text-[11px]">{String(v)}</pre>,
|
|
548
|
-
);
|
|
549
|
-
|
|
550
|
-
<ChatRoot toolCallsProps={{ defaultExpanded: true, renderPayload }} />;
|
|
551
|
-
```
|
|
402
|
+
`<ChatRoot>` exposes named-prop slots — `header`, `footer`, `banner`, `empty`, `composerToolbarStart/End`, `composerAttachmentTray`, `jumpToLatest`. None are required.
|
|
552
403
|
|
|
553
|
-
|
|
404
|
+
See **[Slots inventory](#slots-inventory)** below for the full list, plus `hideComposer` / `hideToolCalls` / `renderToolCall` / `renderAfterCalls` patterns.
|
|
554
405
|
|
|
555
406
|
## Three usage patterns
|
|
556
407
|
|
|
@@ -562,8 +413,6 @@ First match wins. Predicates `isLatLng` / `isPlainObject` / `isGeoJSONFeatureCol
|
|
|
562
413
|
|
|
563
414
|
### 2. Composition
|
|
564
415
|
|
|
565
|
-
Bring your own layout, reuse the parts.
|
|
566
|
-
|
|
567
416
|
```tsx
|
|
568
417
|
<ChatProvider transport={transport}>
|
|
569
418
|
<MyHeader />
|
|
@@ -572,170 +421,138 @@ Bring your own layout, reuse the parts.
|
|
|
572
421
|
</ChatProvider>
|
|
573
422
|
```
|
|
574
423
|
|
|
575
|
-
### 3. Headless
|
|
424
|
+
### 3. Headless
|
|
576
425
|
|
|
577
426
|
```tsx
|
|
578
427
|
const chat = useChat({ transport });
|
|
579
428
|
const composer = useChatComposer({ onSubmit: chat.sendMessage });
|
|
580
|
-
// render however you want
|
|
581
|
-
```
|
|
582
|
-
|
|
583
|
-
## Transport contract
|
|
584
|
-
|
|
585
|
-
A single I/O seam.
|
|
586
|
-
|
|
587
|
-
```ts
|
|
588
|
-
interface ChatTransport {
|
|
589
|
-
createSession(opts?): Promise<SessionInfo>;
|
|
590
|
-
loadHistory(sessionId, cursor?, limit?): Promise<HistoryPage>;
|
|
591
|
-
stream(sessionId, content, { signal, attachments, metadata }): AsyncGenerator<ChatStreamEvent>;
|
|
592
|
-
send(sessionId, content, options?): Promise<ChatMessage>;
|
|
593
|
-
closeSession(sessionId): Promise<void>;
|
|
594
|
-
}
|
|
595
429
|
```
|
|
596
430
|
|
|
597
|
-
|
|
431
|
+
## Mobile & a11y
|
|
598
432
|
|
|
599
|
-
-
|
|
600
|
-
-
|
|
601
|
-
|
|
602
|
-
|
|
433
|
+
- `useChatLayout` collapses `sidebar`/`floating` to `fullscreen` below `(max-width: 640px)`.
|
|
434
|
+
- `ChatDock` fills the viewport below 768px (`mobileFullscreen` prop, default `true`).
|
|
435
|
+
- Composer textarea is `font-size: 16px` to disable iOS focus-zoom; container uses `100dvh` and `env(safe-area-inset-bottom)`.
|
|
436
|
+
- `MessageList` is `role="log"` with `aria-live="polite"`; streaming bubbles set `aria-busy`.
|
|
437
|
+
- `ErrorBanner` is `role="alert"`.
|
|
438
|
+
- `JumpToLatest` announces unread count via `aria-live="polite"`.
|
|
439
|
+
- All visible Boundary fallbacks and the Greeting bubble carry `role="alert"` / `aria-live`.
|
|
603
440
|
|
|
604
441
|
## Public surface
|
|
605
442
|
|
|
606
443
|
```ts
|
|
607
|
-
// Types
|
|
444
|
+
// Types (re-exported from types/)
|
|
608
445
|
ChatMessage, ChatRole, ChatPersona, ChatToolCall, ChatAttachment, ChatSource,
|
|
609
|
-
ChatConfig, ChatUserContext, ChatAssistantContext, ChatPrefs, ChatLabels,
|
|
446
|
+
ChatConfig, ChatUserContext, ChatAssistantContext, ChatPrefs, ChatLabels, DEFAULT_LABELS,
|
|
610
447
|
ChatTransport, ChatStreamEvent, ChatDisplayMode,
|
|
611
|
-
SessionInfo, HistoryPage, StreamOptions, SendOptions
|
|
448
|
+
SessionInfo, HistoryPage, StreamOptions, SendOptions, CreateSessionOptions
|
|
612
449
|
|
|
613
|
-
// Core
|
|
450
|
+
// Core
|
|
614
451
|
reducer, initialState, createId, createTokenBuffer,
|
|
615
452
|
resolvePersona, deriveInitials
|
|
616
|
-
type ChatState, type ChatAction
|
|
453
|
+
type ChatState, type ChatAction, type TokenBuffer
|
|
617
454
|
|
|
618
455
|
// Transport
|
|
619
|
-
createHttpTransport, createMockTransport,
|
|
456
|
+
createHttpTransport, createMockTransport, createPydanticAIChatTransport,
|
|
457
|
+
mapPydanticAIEvent, createPydanticAISSEMap, createToolIdQueue,
|
|
458
|
+
parseSSE, TransportError
|
|
459
|
+
type HttpTransportConfig, type MockTransportOptions, type ParseSSEOptions,
|
|
460
|
+
type PydanticAIChatTransportOpts, type PydanticAIEvent, type ToolIdQueue
|
|
620
461
|
|
|
621
462
|
// Hooks
|
|
622
463
|
useChat, useChatComposer, useChatScroll, useChatHistory, useChatLayout,
|
|
623
|
-
useChatAudio, useChatLightbox,
|
|
624
|
-
|
|
625
|
-
|
|
464
|
+
useChatAudio, useChatLightbox, useAutoFocusOnStreamEnd, useRegisterComposer,
|
|
465
|
+
useChatReset, useVisitorFingerprint, useChatDockPrefs, useChatUnread,
|
|
466
|
+
useFocusOnEmptyClick
|
|
467
|
+
type ChatDockPrefs, DEFAULT_DOCK_PREFS
|
|
468
|
+
|
|
469
|
+
// Styles — role-aware tokens + hooks
|
|
470
|
+
BUBBLE_SURFACE, ANCHOR, TOGGLE, DESTRUCTIVE_SURFACE, TOOL_CALL,
|
|
471
|
+
useChatBubbleStyles, useChatRoleStyles, useChatDestructiveStyles
|
|
472
|
+
type ChatBubbleSurface, type ChatBubbleStyles, type ChatRoleStyles, type ChatDestructiveStyles
|
|
473
|
+
|
|
474
|
+
// Launcher
|
|
475
|
+
ChatFAB, ChatDock, ChatHeader, ChatHeaderActionButton, ChatHeaderModeToggle,
|
|
476
|
+
ChatHeaderAudioToggle, ChatHeaderResetButton, ChatHeaderLanguageButton,
|
|
477
|
+
ChatLauncher, ChatGreeting, ChatUnreadPreview, useChatPresence
|
|
478
|
+
type ChatFABProps, type ChatFABVariant, type ChatFABSize, type ChatFABPosition,
|
|
479
|
+
type ChatDockProps, type ChatDockMode, type ChatDockSide,
|
|
480
|
+
type ChatHeaderProps, type ChatHeaderActionButtonProps, type ChatHeaderModeToggleProps,
|
|
481
|
+
type ChatHeaderAudioToggleProps, type ChatHeaderResetButtonProps,
|
|
482
|
+
type ChatHeaderLanguageButtonProps,
|
|
483
|
+
type ChatGreetingProps, type ChatUnreadPreviewProps,
|
|
484
|
+
type ChatLauncherProps, type ChatLauncherHotkey, type ChatLauncherGreeting,
|
|
485
|
+
type ChatPresencePhase
|
|
626
486
|
|
|
627
487
|
// Audio
|
|
628
488
|
ChatAudioConfig, ChatAudioEvent, ChatAudioSounds, UseChatAudioReturn,
|
|
629
|
-
useChatAudioPrefs
|
|
489
|
+
useChatAudioPrefs, DEFAULT_CHAT_SOUNDS
|
|
630
490
|
|
|
631
491
|
// Tool-payload dispatch
|
|
632
492
|
dispatchToolPayload, isPlainObject, isLatLng,
|
|
633
493
|
isGeoJSONFeatureCollection, isStringValue,
|
|
634
|
-
type ToolPayloadMatcher, type ToolPayloadFallback,
|
|
635
|
-
type ToolPayloadKind, type ToolCallsProps
|
|
494
|
+
type ToolPayloadMatcher, type ToolPayloadFallback, type ToolPayloadKind, type ToolCallsProps
|
|
636
495
|
|
|
637
|
-
//
|
|
638
|
-
collectImageAttachments
|
|
639
|
-
|
|
640
|
-
// Draft sanitation (pre-submit cleanup; see "Draft sanitation" above)
|
|
496
|
+
// Draft sanitation
|
|
641
497
|
sanitizeDraft, isSubmittableDraft
|
|
642
498
|
|
|
643
499
|
// Context
|
|
644
|
-
ChatProvider, useChatContext, useChatContextOptional,
|
|
645
|
-
|
|
500
|
+
ChatProvider, useChatContext, useChatContextOptional, type ComposerHandle
|
|
501
|
+
// `ComposerHandle` shape: { focus, moveCursorToEnd?, getValue?, setValue? }.
|
|
502
|
+
// Voice slot, autoFocus hook, and any external driver read it from
|
|
503
|
+
// context — built-in Composer registers itself, custom composers use
|
|
504
|
+
// `useRegisterComposer({...})`.
|
|
646
505
|
|
|
647
506
|
// Components
|
|
648
507
|
ChatRoot, MessageList, MessageBubble, MessageActions, Composer,
|
|
649
508
|
Sources, ToolCalls, Attachments, EmptyState, ErrorBanner,
|
|
650
509
|
JumpToLatest, StreamingIndicator
|
|
510
|
+
// `MessageList` exposes `atBottomThreshold` (default 120 px) and
|
|
511
|
+
// `scrollAnchorId` props for ChatGPT-style autoscroll — see "What you get".
|
|
651
512
|
|
|
652
513
|
// Lazy preset
|
|
653
514
|
LazyChat
|
|
654
515
|
```
|
|
655
516
|
|
|
656
|
-
##
|
|
657
|
-
|
|
658
|
-
- `useChatLayout` collapses `sidebar`/`floating` to `fullscreen` below `(max-width: 640px)`.
|
|
659
|
-
- Composer textarea is `font-size: 16px` to disable iOS focus-zoom; container uses `100dvh` and `env(safe-area-inset-bottom)`.
|
|
660
|
-
- `MessageList` is `role="log"` with `aria-live="polite"`; streaming bubbles set `aria-busy`.
|
|
661
|
-
- `ErrorBanner` is `role="alert"`.
|
|
662
|
-
- `JumpToLatest` announces unread count via `aria-live="polite"`.
|
|
663
|
-
|
|
664
|
-
## Hotkeys
|
|
665
|
-
|
|
666
|
-
| Key | Behavior |
|
|
667
|
-
| ------------------ | --------------------------------------- |
|
|
668
|
-
| `Enter` | Send (configurable to `Cmd/Ctrl+Enter`) |
|
|
669
|
-
| `Shift+Enter` | Newline |
|
|
670
|
-
| `↑` (empty input) | Recall previous submission |
|
|
671
|
-
| `↓` | Recall next |
|
|
672
|
-
| `Esc` | (host-bound) cancel stream |
|
|
673
|
-
|
|
674
|
-
> **Custom composers built on `MarkdownEditor`:** pass `onSubmit` to
|
|
675
|
-
> the editor for the same behaviour. A React `onKeyDown` wrapper does
|
|
676
|
-
> NOT reliably intercept Enter before Tiptap commits the HardBreak
|
|
677
|
-
> — see `MarkdownEditor/README.md#submit-on-enter` for the full
|
|
678
|
-
> incident write-up and the keymap-extension fix.
|
|
679
|
-
|
|
680
|
-
## Performance
|
|
517
|
+
## Storybook
|
|
681
518
|
|
|
682
|
-
|
|
683
|
-
- **Plain text during stream.** `MessageBubble` skips ReactMarkdown until the message finishes, then re-renders once with full markdown.
|
|
684
|
-
- **Memoized bubbles.** Memo key `(id, content, isStreaming, version, toolActivity, toolCalls, sources, attachments)` — references only.
|
|
685
|
-
- **Virtualization is on by default.** As of plan64, `<MessageList>` is built on [`react-virtuoso`](https://virtuoso.dev/). Sticky-bottom + auto-follow on streaming come from `followOutput`, top-of-list pagination from `startReached`, scroll-anchor preservation on prepend from `firstItemIndex`. No host-side virtualizer needed. Set `noVirtualize` on `<MessageList>` to opt out (stories / DevTools tracing).
|
|
519
|
+
Stories live next to components — open `@djangocfg/playground`:
|
|
686
520
|
|
|
687
|
-
|
|
521
|
+
- `Tools/Chat/Basic` — Default + Composition patterns.
|
|
522
|
+
- `Tools/Chat/Bubbles` — every bubble state in one screen.
|
|
523
|
+
- `Tools/Chat/ToolCalls` — streaming panels + JsonTree payload dispatch.
|
|
524
|
+
- `Tools/Chat/Personas` — per-message persona overrides, multi-user transcripts.
|
|
525
|
+
- `Tools/Chat/Launcher` — Default / Playground / LiveChatStyle / MobileFullscreen / WithLivePush / VariantsAndSizes.
|
|
526
|
+
- `Tools/Chat/Header` — `ChatHeader` standalone, persistent dock prefs, side mode.
|
|
527
|
+
- `Tools/Chat/Audio & Actions` — audio toggle auto-inject, reset with `window.dialog.confirm`.
|
|
528
|
+
- `Tools/Chat/Voice composer` — `<VoiceComposerSlot>` wired into the composer toolbar with a mock STT engine.
|
|
688
529
|
|
|
689
|
-
|
|
530
|
+
---
|
|
690
531
|
|
|
691
|
-
|
|
692
|
-
const listRef = useRef<MessageListHandle>(null);
|
|
532
|
+
<a id="slots-inventory"></a>
|
|
693
533
|
|
|
694
|
-
|
|
695
|
-
ref={listRef}
|
|
696
|
-
onAtBottomChange={setIsAtBottom} // drives "Jump to latest" pill
|
|
697
|
-
onStartReached={() => void loadMore()} // top-of-list pagination
|
|
698
|
-
/>;
|
|
699
|
-
|
|
700
|
-
listRef.current?.scrollToBottom(true); // smooth jump
|
|
701
|
-
listRef.current?.scrollToIndex(42); // jump to a specific bubble
|
|
702
|
-
```
|
|
534
|
+
## Slots inventory
|
|
703
535
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
536
|
+
| Slot | Position | Render-prop variant |
|
|
537
|
+
|---|---|---|
|
|
538
|
+
| `header` | Above messages | `renderHeader({ messages })` |
|
|
539
|
+
| `footer` | Below composer | `renderFooter({ canSend })` |
|
|
540
|
+
| `banner` | Top of message list | `renderBanner({ error, dismiss, retry })` |
|
|
541
|
+
| `empty` | Empty state | `renderEmpty({ config, send })` |
|
|
542
|
+
| `composerToolbarStart` / `composerToolbarEnd` | Composer toolbar | — |
|
|
543
|
+
| `composerAttachmentTray` | Above composer textarea | `renderAttachmentTray({ attachments })` |
|
|
544
|
+
| `jumpToLatest` | Sticky overlay | `renderJumpToLatest({ unread, scrollToBottom })` |
|
|
545
|
+
| `renderToolCall` | Per tool-call panel | `(call) => ReactNode` |
|
|
546
|
+
| `renderAfterCalls` | After all tool panels | `(calls) => ReactNode` (rich UI like vehicle cards) |
|
|
709
547
|
|
|
710
|
-
|
|
548
|
+
Flags:
|
|
711
549
|
|
|
712
|
-
- `
|
|
713
|
-
- `
|
|
714
|
-
- `02-state-model.md` — reducer actions and invariants
|
|
715
|
-
- `03-hooks.md` — every hook signature
|
|
716
|
-
- `04-components.md` — every component prop shape
|
|
717
|
-
- `05-mobile-and-a11y.md` — breakpoints, iOS, ARIA, focus, RTL
|
|
718
|
-
- `06-integration.md` — adapter recipes
|
|
719
|
-
- `07-testing.md` — reducer / hook / component / a11y tests
|
|
720
|
-
- `08-migration.md` — step-by-step rollout
|
|
550
|
+
- `hideComposer` — agent-pause / human-in-the-loop pause; composer is unmounted.
|
|
551
|
+
- `hideToolCalls` — show only `renderAfterCalls` rich UI without raw tool panels.
|
|
721
552
|
|
|
722
|
-
##
|
|
553
|
+
## Hotkeys
|
|
723
554
|
|
|
724
|
-
`
|
|
725
|
-
|
|
726
|
-
- `
|
|
727
|
-
-
|
|
728
|
-
- `Composition` — bring your own layout (`<ChatProvider>` + parts)
|
|
729
|
-
- `Bubbles` — visual matrix of message states
|
|
730
|
-
- `Parts` — every decomposed component on its own (incl. `ToolCalls` + `LazyJsonTree` compact)
|
|
731
|
-
- `WithSlots` — every named slot at once (banner / header / empty / composer toolbar / footer)
|
|
732
|
-
- `WithJsonTreePayload` — `<ChatRoot toolCallsProps>` with `LazyJsonTree` payload renderers
|
|
733
|
-
- `WithAudio` — chat-event sound triggers + unlock-state badge
|
|
734
|
-
- `WithAudioAttachment` — attachment registry mounts `<LazyAudioPlayer>` for `audio` items
|
|
735
|
-
- `WithImageLightbox` — `useChatLightbox` + host-side `<Dialog>` + `<LazyImageViewer>`
|
|
736
|
-
- `WithMapPayload` — `dispatchToolPayload` with `LazyJsonTree` fallback
|
|
737
|
-
- `WithPersonas` — config-level `user` + `assistant` identity (avatar, name)
|
|
738
|
-
- `MultiUser` — per-message `sender` overrides (multi-user / multi-bot)
|
|
739
|
-
- `WithHideComposer` — `hideComposer` + `footer` approval gate (HITL / agent-pause pattern)
|
|
740
|
-
- `WithRenderAfterCalls` — `renderAfterCalls` (vehicle cards outside panels), `hideToolCalls`, `renderToolCall` (custom per-call renderer) — knob-controlled
|
|
741
|
-
- `Playground` — knobs (latency, streaming, suggestions)
|
|
555
|
+
- `Enter` — send (or `Cmd+Enter` if `prefs.submitOn = 'cmd+enter'`).
|
|
556
|
+
- `Shift+Enter` — newline.
|
|
557
|
+
- `Esc` — cancel current streaming turn (when focused inside the composer).
|
|
558
|
+
- Launcher hotkey: configurable via `<ChatLauncher hotkey={…}>` (e.g. `⌘/`).
|