@djangocfg/ui-tools 2.1.381 → 2.1.383

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.
Files changed (178) hide show
  1. package/README.md +132 -899
  2. package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
  3. package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
  4. package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
  5. package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
  6. package/dist/DictationField-U25MEYAL.mjs +4 -0
  7. package/dist/DictationField-U25MEYAL.mjs.map +1 -0
  8. package/dist/DictationField-XWR5VOID.cjs +13 -0
  9. package/dist/DictationField-XWR5VOID.cjs.map +1 -0
  10. package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
  11. package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
  12. package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
  13. package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
  14. package/dist/chunk-4PFW7MIJ.cjs +837 -0
  15. package/dist/chunk-4PFW7MIJ.cjs.map +1 -0
  16. package/dist/chunk-C2YN6WEO.mjs +833 -0
  17. package/dist/chunk-C2YN6WEO.mjs.map +1 -0
  18. package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
  19. package/dist/chunk-FIRK5CEH.cjs.map +1 -0
  20. package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
  21. package/dist/chunk-HIK6BPL7.mjs.map +1 -0
  22. package/dist/chunk-OZAU3QWD.cjs +2493 -0
  23. package/dist/chunk-OZAU3QWD.cjs.map +1 -0
  24. package/dist/chunk-UWVP6LCW.mjs +2447 -0
  25. package/dist/chunk-UWVP6LCW.mjs.map +1 -0
  26. package/dist/index.cjs +1668 -99
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +1215 -107
  29. package/dist/index.d.ts +1215 -107
  30. package/dist/index.mjs +1555 -50
  31. package/dist/index.mjs.map +1 -1
  32. package/package.json +16 -15
  33. package/src/audio-assets.d.ts +8 -0
  34. package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
  35. package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
  36. package/src/tools/Chat/README.md +347 -530
  37. package/src/tools/Chat/components/Attachments.tsx +6 -1
  38. package/src/tools/Chat/components/ChatRoot.tsx +30 -2
  39. package/src/tools/Chat/components/Composer.tsx +20 -3
  40. package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
  41. package/src/tools/Chat/components/MessageActions.tsx +3 -1
  42. package/src/tools/Chat/components/MessageBubble.tsx +6 -5
  43. package/src/tools/Chat/components/MessageList.tsx +87 -1
  44. package/src/tools/Chat/components/ToolCalls.tsx +21 -3
  45. package/src/tools/Chat/context/ChatProvider.tsx +21 -3
  46. package/src/tools/Chat/core/audio/audioBus.ts +10 -163
  47. package/src/tools/Chat/core/audio/defaults.ts +43 -0
  48. package/src/tools/Chat/core/audio/index.ts +1 -0
  49. package/src/tools/Chat/core/audio/preferences.ts +5 -59
  50. package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
  51. package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
  52. package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
  53. package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
  54. package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
  55. package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
  56. package/src/tools/Chat/core/audio/types.ts +28 -0
  57. package/src/tools/Chat/core/reducer.ts +33 -0
  58. package/src/tools/Chat/core/transport/index.ts +13 -0
  59. package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
  60. package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
  61. package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
  62. package/src/tools/Chat/core/transport/sse.ts +18 -5
  63. package/src/tools/Chat/hooks/index.ts +25 -0
  64. package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
  65. package/src/tools/Chat/hooks/useChat.ts +28 -0
  66. package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
  67. package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
  68. package/src/tools/Chat/hooks/useChatReset.ts +70 -0
  69. package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
  70. package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
  71. package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
  72. package/src/tools/Chat/index.ts +84 -1
  73. package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
  74. package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
  75. package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
  76. package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
  77. package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
  78. package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
  79. package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
  80. package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
  81. package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
  82. package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
  83. package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
  84. package/src/tools/Chat/launcher/index.ts +46 -0
  85. package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
  86. package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
  87. package/src/tools/Chat/styles/index.ts +16 -0
  88. package/src/tools/Chat/styles/useChatStyles.ts +101 -0
  89. package/src/tools/Chat/types/attachment.ts +25 -0
  90. package/src/tools/Chat/types/config.ts +48 -0
  91. package/src/tools/Chat/types/events.ts +35 -0
  92. package/src/tools/Chat/types/index.ts +34 -0
  93. package/src/tools/Chat/types/labels.ts +38 -0
  94. package/src/tools/Chat/types/message.ts +32 -0
  95. package/src/tools/Chat/types/persona.ts +31 -0
  96. package/src/tools/Chat/types/session.ts +43 -0
  97. package/src/tools/Chat/types/tool-call.ts +17 -0
  98. package/src/tools/Chat/types/transport.ts +28 -0
  99. package/src/tools/Chat/types.ts +5 -240
  100. package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
  101. package/src/tools/MarkdownEditor/index.ts +1 -1
  102. package/src/tools/SpeechRecognition/README.md +336 -0
  103. package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
  104. package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
  105. package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
  106. package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
  107. package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
  108. package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
  109. package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
  110. package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
  111. package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
  112. package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
  113. package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
  114. package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
  115. package/src/tools/SpeechRecognition/components/index.ts +16 -0
  116. package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
  117. package/src/tools/SpeechRecognition/context/index.ts +6 -0
  118. package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
  119. package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
  120. package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
  121. package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
  122. package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
  123. package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
  124. package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
  125. package/src/tools/SpeechRecognition/core/ids.ts +11 -0
  126. package/src/tools/SpeechRecognition/core/index.ts +14 -0
  127. package/src/tools/SpeechRecognition/core/language.ts +78 -0
  128. package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
  129. package/src/tools/SpeechRecognition/core/logger.ts +3 -0
  130. package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
  131. package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
  132. package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
  133. package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
  134. package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
  135. package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
  136. package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
  137. package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
  138. package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
  139. package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
  140. package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
  141. package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
  142. package/src/tools/SpeechRecognition/index.ts +82 -0
  143. package/src/tools/SpeechRecognition/lazy.tsx +19 -0
  144. package/src/tools/SpeechRecognition/store/index.ts +2 -0
  145. package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
  146. package/src/tools/SpeechRecognition/types.ts +133 -0
  147. package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
  148. package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
  149. package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
  150. package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
  151. package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
  152. package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
  153. package/dist/chunk-NWUT327A.mjs.map +0 -1
  154. package/dist/chunk-QLMKCSR6.mjs +0 -2420
  155. package/dist/chunk-QLMKCSR6.mjs.map +0 -1
  156. package/dist/chunk-SI5RD2GD.cjs +0 -2460
  157. package/dist/chunk-SI5RD2GD.cjs.map +0 -1
  158. package/dist/chunk-XACCHZH2.cjs.map +0 -1
  159. package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +0 -771
  160. package/src/stories/index.ts +0 -33
  161. package/src/tools/AudioPlayer/AudioPlayer.story.tsx +0 -481
  162. package/src/tools/Chat/Chat.story.tsx +0 -1457
  163. package/src/tools/CodeEditor/CodeEditor.story.tsx +0 -202
  164. package/src/tools/CronScheduler/CronScheduler.story.tsx +0 -300
  165. package/src/tools/Gallery/Gallery.story.tsx +0 -237
  166. package/src/tools/ImageViewer/ImageViewer.story.tsx +0 -85
  167. package/src/tools/JsonForm/JsonForm.story.tsx +0 -350
  168. package/src/tools/JsonTree/JsonTree.story.tsx +0 -141
  169. package/src/tools/LottiePlayer/LottiePlayer.story.tsx +0 -95
  170. package/src/tools/Map/Map.story.tsx +0 -458
  171. package/src/tools/MarkdownEditor/MarkdownEditor.story.tsx +0 -225
  172. package/src/tools/Mermaid/Mermaid.story.tsx +0 -251
  173. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +0 -230
  174. package/src/tools/PrettyCode/PrettyCode.story.tsx +0 -304
  175. package/src/tools/Tour/Tour.story.tsx +0 -279
  176. package/src/tools/Tree/Tree.story.tsx +0 -620
  177. package/src/tools/Uploader/Uploader.story.tsx +0 -415
  178. package/src/tools/VideoPlayer/VideoPlayer.story.tsx +0 -87
@@ -0,0 +1,321 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import type { ReactNode } from 'react';
5
+
6
+ import { useHotkey } from '@djangocfg/ui-core/hooks';
7
+
8
+ import { ChatFAB, type ChatFABPosition, type ChatFABProps } from './ChatFAB';
9
+ import { ChatDock, type ChatDockProps } from './ChatDock';
10
+ import { ChatGreeting, type ChatGreetingProps } from './ChatGreeting';
11
+ import { ChatHeaderAudioToggle } from './ChatHeaderAudioToggle';
12
+ import { ChatUnreadPreview, type ChatUnreadPreviewProps } from './ChatUnreadPreview';
13
+ import type { ChatMessage } from '../types';
14
+
15
+ export interface ChatLauncherHotkey {
16
+ /** Key (case-sensitive single char or named like 'Escape'). */
17
+ key: string;
18
+ /** Require Cmd (mac) or Ctrl (other). */
19
+ meta?: boolean;
20
+ /** Require Shift. */
21
+ shift?: boolean;
22
+ /** Require Alt. */
23
+ alt?: boolean;
24
+ }
25
+
26
+ export interface ChatLauncherGreeting
27
+ extends Omit<ChatGreetingProps, 'open' | 'onClick' | 'onDismiss' | 'position' | 'fabOffset' | 'children'> {
28
+ /** Greeting body — string for the default style, or any ReactNode. */
29
+ content: ReactNode;
30
+ /** Persistence key for "user dismissed this greeting" in `localStorage`. Pass `null` to disable persistence. @default null */
31
+ dismissStorageKey?: string | null;
32
+ /** Hide the greeting once the user opens the chat. @default true */
33
+ hideOnOpen?: boolean;
34
+ }
35
+
36
+ export interface ChatLauncherProps {
37
+ /** Dock contents — typically a `<Chat>` instance. */
38
+ children: ReactNode;
39
+ /** FAB customization (icon, position, label, pulse, badge, tooltip, variant, size). */
40
+ fab?: Omit<ChatFABProps, 'onClick'>;
41
+ /** Dock customization (size, title, position, transition, mobileFullscreen). */
42
+ dock?: Omit<ChatDockProps, 'open' | 'onClose' | 'children'>;
43
+ /**
44
+ * Proactive greeting bubble shown next to the FAB before the user opens the chat.
45
+ * Set to a string or full config object. Omit to disable.
46
+ */
47
+ greeting?: string | ChatLauncherGreeting;
48
+ /** Open/close via a keyboard shortcut. */
49
+ hotkey?: ChatLauncherHotkey;
50
+ /** Initial open state for uncontrolled mode. @default false */
51
+ defaultOpen?: boolean;
52
+ /** Controlled open state (pair with `onOpenChange`). */
53
+ open?: boolean;
54
+ /** Controlled open state setter. */
55
+ onOpenChange?: (open: boolean) => void;
56
+ /**
57
+ * Focus the composer textarea when the dock opens. Saves a click for
58
+ * every "FAB → start typing" interaction. @default true
59
+ */
60
+ autoFocusComposerOnOpen?: boolean;
61
+ /**
62
+ * Close the dock on `Escape`. Mirrors standard popover / drawer UX.
63
+ * Set to `false` to disable (e.g. if you want Escape to do something
64
+ * else inside the chat). @default true
65
+ */
66
+ closeOnEscape?: boolean;
67
+ /**
68
+ * Last inbound message (admin reply / system notice / agent push) the
69
+ * user hasn't seen yet. Drives the `<ChatUnreadPreview>` bubble next
70
+ * to the FAB and (by default) the FAB badge.
71
+ *
72
+ * Source it from `useChatUnread()` inside your `<ChatProvider>`.
73
+ */
74
+ unreadMessage?: ChatMessage | null;
75
+ /**
76
+ * Called when the user opens the chat via FAB/preview/hotkey or
77
+ * dismisses the preview with ×. Wire to `useChatUnread().markRead`.
78
+ */
79
+ onMarkRead?: () => void;
80
+ /**
81
+ * Customize the unread bubble (`truncate`, `dismissLabel`, …).
82
+ * `open`/`message`/`onClick`/`onDismiss`/`position`/`fabOffset` are
83
+ * wired automatically.
84
+ */
85
+ unreadPreview?: Omit<
86
+ ChatUnreadPreviewProps,
87
+ 'open' | 'message' | 'onClick' | 'onDismiss' | 'position' | 'fabOffset'
88
+ >;
89
+ /**
90
+ * Auto-inject a mute / unmute button into the header. Pass the
91
+ * `useChatAudio()` (or any compatible `{ muted, toggleMute }`)
92
+ * instance — the launcher renders `<ChatHeaderAudioToggle>` in the
93
+ * header's actions slot when audio is actually configured (not silent).
94
+ *
95
+ * Hosts that manage their own header can ignore this prop and render
96
+ * `<ChatHeaderAudioToggle>` directly.
97
+ */
98
+ audio?: { muted: boolean; toggleMute: () => void; isSilent?: boolean } | null;
99
+ /**
100
+ * Suppress the auto-injected audio toggle even when `audio` is passed.
101
+ * @default false
102
+ */
103
+ hideAudioToggle?: boolean;
104
+ }
105
+
106
+ function readDismissed(storageKey: string | null | undefined): boolean {
107
+ if (!storageKey) return false;
108
+ if (typeof window === 'undefined') return false;
109
+ try {
110
+ return window.localStorage.getItem(storageKey) === '1';
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ function writeDismissed(storageKey: string | null | undefined): void {
117
+ if (!storageKey) return;
118
+ if (typeof window === 'undefined') return;
119
+ try {
120
+ window.localStorage.setItem(storageKey, '1');
121
+ } catch {
122
+ // private mode / storage full — silently ignore
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Floating chat launcher = FAB + Dock + presence + optional greeting + hotkey.
128
+ *
129
+ * 99% of hosts use this directly. For non-FAB triggers (e.g. an inline
130
+ * link in the page) compose `<ChatDock>` with your own button.
131
+ */
132
+ export function ChatLauncher({
133
+ children,
134
+ fab,
135
+ dock,
136
+ greeting,
137
+ hotkey,
138
+ defaultOpen = false,
139
+ open: controlledOpen,
140
+ onOpenChange,
141
+ autoFocusComposerOnOpen = true,
142
+ closeOnEscape = true,
143
+ unreadMessage,
144
+ onMarkRead,
145
+ unreadPreview,
146
+ audio,
147
+ hideAudioToggle = false,
148
+ }: ChatLauncherProps) {
149
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
150
+ const isControlled = controlledOpen !== undefined;
151
+ const open = isControlled ? controlledOpen : uncontrolledOpen;
152
+ const dockContentRef = useRef<HTMLDivElement>(null);
153
+
154
+ // Auto-focus the composer when the dock opens.
155
+ // We probe the dock subtree for the first textarea/input after the
156
+ // enter-transition settles. Keeps the hook self-contained — no need to
157
+ // bridge into the ChatProvider context which lives inside `children`.
158
+ useEffect(() => {
159
+ if (!autoFocusComposerOnOpen || !open) return;
160
+ const t = setTimeout(() => {
161
+ const root = dockContentRef.current;
162
+ if (!root) return;
163
+ const target = root.querySelector<HTMLElement>(
164
+ 'textarea:not([disabled]):not([readonly]), input[type="text"]:not([disabled]):not([readonly])',
165
+ );
166
+ target?.focus();
167
+ }, 120);
168
+ return () => clearTimeout(t);
169
+ }, [open, autoFocusComposerOnOpen]);
170
+
171
+ const setOpen = useCallback(
172
+ (next: boolean) => {
173
+ if (!isControlled) setUncontrolledOpen(next);
174
+ onOpenChange?.(next);
175
+ },
176
+ [isControlled, onOpenChange],
177
+ );
178
+ const toggleOpen = useCallback(() => setOpen(!open), [open, setOpen]);
179
+
180
+ // Two-step Escape (ChatGPT / Slack behaviour):
181
+ // - When focus is inside a textarea / input / contenteditable, first Esc
182
+ // just blurs — drafts survive accidental presses.
183
+ // - When focus is elsewhere (the user already left the composer or never
184
+ // focused it), Esc closes the dock.
185
+ // Disabled when chat is shut so we don't intercept page-level Esc bindings.
186
+ useHotkey(
187
+ 'escape',
188
+ (e) => {
189
+ const target = (e?.target as HTMLElement | null) ?? null;
190
+ const inEditable =
191
+ !!target &&
192
+ (target.matches?.('input, textarea, [contenteditable="true"]') ?? false);
193
+ if (inEditable) {
194
+ target.blur();
195
+ return;
196
+ }
197
+ setOpen(false);
198
+ },
199
+ { enabled: closeOnEscape && open },
200
+ );
201
+
202
+ // Normalize greeting prop.
203
+ const greetingConfig: ChatLauncherGreeting | null =
204
+ greeting === undefined
205
+ ? null
206
+ : typeof greeting === 'string'
207
+ ? { content: greeting }
208
+ : greeting;
209
+
210
+ const [dismissed, setDismissed] = useState(() =>
211
+ readDismissed(greetingConfig?.dismissStorageKey),
212
+ );
213
+
214
+ // Hotkey.
215
+ useEffect(() => {
216
+ if (!hotkey) return;
217
+ const handler = (e: KeyboardEvent) => {
218
+ const metaOk = hotkey.meta ? e.metaKey || e.ctrlKey : !e.metaKey && !e.ctrlKey;
219
+ const shiftOk = hotkey.shift ? e.shiftKey : !e.shiftKey;
220
+ const altOk = hotkey.alt ? e.altKey : !e.altKey;
221
+ if (!metaOk || !shiftOk || !altOk) return;
222
+ if (e.key !== hotkey.key) return;
223
+ e.preventDefault();
224
+ setOpen(!open);
225
+ };
226
+ window.addEventListener('keydown', handler);
227
+ return () => window.removeEventListener('keydown', handler);
228
+ }, [hotkey?.key, hotkey?.meta, hotkey?.shift, hotkey?.alt, open, setOpen, hotkey]);
229
+
230
+ // Greeting visibility: respect dismissal, hideOnOpen, and the actual open state.
231
+ const greetingOpen = !!greetingConfig
232
+ && !dismissed
233
+ && (greetingConfig.hideOnOpen === false || !open);
234
+
235
+ const fabPosition: ChatFABPosition = fab?.position ?? 'bottom-right';
236
+ const fabOffset = fab?.offset ?? 24;
237
+
238
+ const handleGreetingDismiss = () => {
239
+ setDismissed(true);
240
+ writeDismissed(greetingConfig?.dismissStorageKey);
241
+ };
242
+
243
+ const handleGreetingClick = () => {
244
+ setOpen(true);
245
+ // Tap-to-open also clears the proactive bubble — pushing it again on
246
+ // the same visit would feel spammy. Persisted dismissal honours the
247
+ // storage key so it doesn't reappear after navigation either.
248
+ setDismissed(true);
249
+ writeDismissed(greetingConfig?.dismissStorageKey);
250
+ };
251
+
252
+ // Mark-as-read also fires when the chat opens through any path (FAB,
253
+ // hotkey, controlled state) — symmetric with click-to-open via the
254
+ // preview itself.
255
+ useEffect(() => {
256
+ if (open && unreadMessage) onMarkRead?.();
257
+ }, [open, unreadMessage, onMarkRead]);
258
+
259
+ // Unread preview replaces the greeting when there's a real inbound
260
+ // message to surface — same anchor, more relevant content.
261
+ const unreadOpen = !open && !!unreadMessage;
262
+ const handleUnreadClick = () => {
263
+ setOpen(true);
264
+ onMarkRead?.();
265
+ };
266
+ const handleUnreadDismiss = () => {
267
+ onMarkRead?.();
268
+ };
269
+
270
+ // Auto-derive a "1" badge from unread when the host didn't set one.
271
+ const resolvedFab = unreadMessage && fab?.badge === undefined
272
+ ? { ...fab, badge: 1 }
273
+ : fab;
274
+
275
+ return (
276
+ <>
277
+ <ChatFAB {...resolvedFab} onClick={toggleOpen} />
278
+ {unreadMessage ? (
279
+ <ChatUnreadPreview
280
+ {...unreadPreview}
281
+ open={unreadOpen}
282
+ message={unreadMessage}
283
+ onClick={handleUnreadClick}
284
+ onDismiss={handleUnreadDismiss}
285
+ position={fabPosition}
286
+ fabOffset={fabOffset}
287
+ />
288
+ ) : greetingConfig ? (
289
+ <ChatGreeting
290
+ {...greetingConfig}
291
+ open={greetingOpen}
292
+ onClick={handleGreetingClick}
293
+ onDismiss={handleGreetingDismiss}
294
+ position={fabPosition}
295
+ fabOffset={fabOffset}
296
+ >
297
+ {greetingConfig.content}
298
+ </ChatGreeting>
299
+ ) : null}
300
+ <ChatDock
301
+ {...dock}
302
+ open={open}
303
+ onClose={() => setOpen(false)}
304
+ headerActions={
305
+ (audio && !audio.isSilent && !hideAudioToggle) || dock?.headerActions ? (
306
+ <>
307
+ {dock?.headerActions}
308
+ {audio && !audio.isSilent && !hideAudioToggle ? (
309
+ <ChatHeaderAudioToggle muted={audio.muted} onToggle={audio.toggleMute} />
310
+ ) : null}
311
+ </>
312
+ ) : undefined
313
+ }
314
+ >
315
+ <div ref={dockContentRef} className="flex h-full min-h-0 min-w-0 flex-col">
316
+ {children}
317
+ </div>
318
+ </ChatDock>
319
+ </>
320
+ );
321
+ }
@@ -0,0 +1,197 @@
1
+ 'use client';
2
+
3
+ import { X } from 'lucide-react';
4
+ import type { CSSProperties, ReactNode } from 'react';
5
+
6
+ import { Avatar, AvatarFallback, AvatarImage } from '@djangocfg/ui-core/components';
7
+ import { cn } from '@djangocfg/ui-core/lib';
8
+
9
+ import type { ChatMessage, ChatPersona } from '../types';
10
+ import type { ChatFABPosition } from './ChatFAB';
11
+ import { useChatPresence } from './useChatPresence';
12
+
13
+ export interface ChatUnreadPreviewProps {
14
+ /** Controlled — usually `!dockOpen && !!message`. */
15
+ open: boolean;
16
+ /** Inbound message to preview. `null` hides the bubble. */
17
+ message: ChatMessage | null;
18
+ /** Tap → open chat + mark read. */
19
+ onClick?: () => void;
20
+ /** × → mark read without opening. */
21
+ onDismiss?: () => void;
22
+ /** Anchor corner — match the FAB so the bubble sits above it. @default 'bottom-right' */
23
+ position?: ChatFABPosition;
24
+ /** Horizontal offset from screen edge, matches the FAB. @default 24 */
25
+ fabOffset?: number;
26
+ /** Vertical clearance above/below the FAB. @default 96 */
27
+ fabClearance?: number;
28
+ /** Lines of body text before ellipsis. @default 2 */
29
+ truncate?: number;
30
+ /** z-index. @default 9998 */
31
+ zIndex?: number;
32
+ /** Render in-place (stories / previews). @default false */
33
+ inline?: boolean;
34
+ /** Override classes on the bubble. */
35
+ className?: string;
36
+ /** Override styles on the bubble. */
37
+ style?: CSSProperties;
38
+ /** ARIA label for the dismiss button. @default 'Mark as read' */
39
+ dismissLabel?: string;
40
+ /** Override the avatar — defaults to derived from `message.sender`. */
41
+ avatar?: ReactNode;
42
+ /** Override the sender label — defaults to `message.sender?.name`. */
43
+ senderName?: string;
44
+ }
45
+
46
+ const TIME_FORMAT = new Intl.DateTimeFormat(undefined, {
47
+ hour: '2-digit',
48
+ minute: '2-digit',
49
+ });
50
+
51
+ function anchorStyle(
52
+ position: ChatFABPosition,
53
+ fabOffset: number,
54
+ fabClearance: number,
55
+ ): CSSProperties {
56
+ const [vert, horiz] = position.split('-') as ['bottom' | 'top', 'right' | 'left'];
57
+ return { [vert]: fabClearance, [horiz]: fabOffset } as CSSProperties;
58
+ }
59
+
60
+ function originClass(position: ChatFABPosition): string {
61
+ if (position === 'bottom-right') return 'origin-bottom-right';
62
+ if (position === 'bottom-left') return 'origin-bottom-left';
63
+ if (position === 'top-right') return 'origin-top-right';
64
+ return 'origin-top-left';
65
+ }
66
+
67
+ function deriveAvatar(persona?: ChatPersona, name?: string): ReactNode {
68
+ const initials =
69
+ persona?.initials ??
70
+ (name ?? persona?.name ?? '?')
71
+ .split(/\s+/)
72
+ .map((p) => p[0])
73
+ .filter(Boolean)
74
+ .slice(0, 2)
75
+ .join('')
76
+ .toUpperCase();
77
+ return (
78
+ <Avatar className="h-9 w-9">
79
+ {persona?.avatarUrl ? <AvatarImage src={persona.avatarUrl} /> : null}
80
+ <AvatarFallback>{initials || '?'}</AvatarFallback>
81
+ </Avatar>
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Push-notification bubble next to the chat FAB.
87
+ *
88
+ * Shows the last inbound message while the chat is closed —
89
+ * Intercom / LiveChat-style. Tap → open chat (host wires `onClick`).
90
+ * Dismiss × → keep chat closed but stop nagging.
91
+ *
92
+ * Pair with `useChatUnread()` (inside `<ChatProvider>`) for state.
93
+ */
94
+ export function ChatUnreadPreview({
95
+ open,
96
+ message,
97
+ onClick,
98
+ onDismiss,
99
+ position = 'bottom-right',
100
+ fabOffset = 24,
101
+ fabClearance = 96,
102
+ truncate = 2,
103
+ zIndex = 9998,
104
+ inline = false,
105
+ className,
106
+ style,
107
+ dismissLabel = 'Mark as read',
108
+ avatar,
109
+ senderName,
110
+ }: ChatUnreadPreviewProps) {
111
+ const shouldShow = open && !!message;
112
+ const phase = useChatPresence(shouldShow, 200);
113
+ if (phase === 'hidden' || !message) return null;
114
+
115
+ const animating = phase === 'entering' || phase === 'leaving';
116
+ const clickable = !!onClick;
117
+ const displayName = senderName ?? message.sender?.name ?? 'New message';
118
+ const stamp = TIME_FORMAT.format(new Date(message.createdAt));
119
+
120
+ return (
121
+ <div
122
+ role={clickable ? 'button' : 'status'}
123
+ aria-live="polite"
124
+ tabIndex={clickable ? 0 : -1}
125
+ onClick={clickable ? onClick : undefined}
126
+ onKeyDown={
127
+ clickable
128
+ ? (e) => {
129
+ if (e.key === 'Enter' || e.key === ' ') {
130
+ e.preventDefault();
131
+ onClick?.();
132
+ }
133
+ }
134
+ : undefined
135
+ }
136
+ className={cn(
137
+ inline ? 'relative inline-flex' : 'fixed',
138
+ 'flex items-start gap-2.5 max-w-[300px]',
139
+ 'rounded-2xl border border-border bg-popover text-popover-foreground',
140
+ 'px-3.5 py-2.5 shadow-2xl transition-all duration-200 ease-out',
141
+ clickable &&
142
+ 'cursor-pointer hover:bg-accent/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
143
+ originClass(position),
144
+ animating ? 'opacity-0 scale-95 translate-y-1' : 'opacity-100 scale-100 translate-y-0',
145
+ className,
146
+ )}
147
+ style={{
148
+ ...(inline ? {} : anchorStyle(position, fabOffset, fabClearance)),
149
+ ...(inline ? {} : { zIndex }),
150
+ pointerEvents: phase === 'visible' ? 'auto' : 'none',
151
+ ...style,
152
+ }}
153
+ >
154
+ <div className="mt-0.5 shrink-0">
155
+ {avatar ?? deriveAvatar(message.sender, displayName)}
156
+ </div>
157
+
158
+ <div className="min-w-0 flex-1 text-sm leading-snug">
159
+ <div className="flex items-baseline justify-between gap-2">
160
+ <div className="truncate text-[12px] font-semibold text-foreground">
161
+ {displayName}
162
+ </div>
163
+ <div className="shrink-0 text-[10px] text-muted-foreground">{stamp}</div>
164
+ </div>
165
+ <div
166
+ className="text-foreground/90 mt-0.5 break-words"
167
+ style={{
168
+ display: '-webkit-box',
169
+ WebkitLineClamp: truncate,
170
+ WebkitBoxOrient: 'vertical',
171
+ overflow: 'hidden',
172
+ }}
173
+ >
174
+ {message.content}
175
+ </div>
176
+ </div>
177
+
178
+ {onDismiss ? (
179
+ <button
180
+ type="button"
181
+ aria-label={dismissLabel}
182
+ onClick={(e) => {
183
+ e.stopPropagation();
184
+ onDismiss();
185
+ }}
186
+ className={cn(
187
+ '-mr-1 -mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full',
188
+ 'text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
189
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
190
+ )}
191
+ >
192
+ <X className="h-3.5 w-3.5" />
193
+ </button>
194
+ ) : null}
195
+ </div>
196
+ );
197
+ }
@@ -0,0 +1,46 @@
1
+ export {
2
+ ChatFAB,
3
+ type ChatFABProps,
4
+ type ChatFABPosition,
5
+ type ChatFABVariant,
6
+ type ChatFABSize,
7
+ } from './ChatFAB';
8
+ export {
9
+ ChatDock,
10
+ type ChatDockProps,
11
+ type ChatDockMode,
12
+ type ChatDockSide,
13
+ } from './ChatDock';
14
+ export { ChatHeader, type ChatHeaderProps } from './ChatHeader';
15
+ export {
16
+ ChatHeaderActionButton,
17
+ type ChatHeaderActionButtonProps,
18
+ } from './ChatHeaderActionButton';
19
+ export {
20
+ ChatHeaderModeToggle,
21
+ type ChatHeaderModeToggleProps,
22
+ } from './ChatHeaderModeToggle';
23
+ export {
24
+ ChatHeaderAudioToggle,
25
+ type ChatHeaderAudioToggleProps,
26
+ } from './ChatHeaderAudioToggle';
27
+ export {
28
+ ChatHeaderResetButton,
29
+ type ChatHeaderResetButtonProps,
30
+ } from './ChatHeaderResetButton';
31
+ export {
32
+ ChatHeaderLanguageButton,
33
+ type ChatHeaderLanguageButtonProps,
34
+ } from './ChatHeaderLanguageButton';
35
+ export {
36
+ ChatLauncher,
37
+ type ChatLauncherProps,
38
+ type ChatLauncherHotkey,
39
+ type ChatLauncherGreeting,
40
+ } from './ChatLauncher';
41
+ export { ChatGreeting, type ChatGreetingProps } from './ChatGreeting';
42
+ export {
43
+ ChatUnreadPreview,
44
+ type ChatUnreadPreviewProps,
45
+ } from './ChatUnreadPreview';
46
+ export { useChatPresence, type ChatPresencePhase } from './useChatPresence';
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ export type ChatPresencePhase = 'hidden' | 'entering' | 'visible' | 'leaving';
6
+
7
+ /**
8
+ * Presence state machine for floating popovers.
9
+ *
10
+ * Drives a four-phase lifecycle so enter/leave CSS transitions actually fire:
11
+ *
12
+ * hidden → entering (mount, transition class starts) → visible
13
+ * visible → leaving (transition runs) → hidden (unmount)
14
+ *
15
+ * Mounting in `entering` and ticking to `visible` on the next paint is what
16
+ * lets transition classes animate. Without it the element appears already
17
+ * at its final state and CSS transitions never observe a change.
18
+ *
19
+ * @param open - controlled open state
20
+ * @param exitDurationMs - how long the leave transition runs; should match CSS
21
+ */
22
+ export function useChatPresence(open: boolean, exitDurationMs = 200): ChatPresencePhase {
23
+ const [phase, setPhase] = useState<ChatPresencePhase>('hidden');
24
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
25
+
26
+ useEffect(() => {
27
+ if (timerRef.current) clearTimeout(timerRef.current);
28
+
29
+ if (open) {
30
+ setPhase('entering');
31
+ // One paint later: switch to 'visible' so the CSS transition runs.
32
+ timerRef.current = setTimeout(() => setPhase('visible'), 16);
33
+ } else {
34
+ setPhase('leaving');
35
+ timerRef.current = setTimeout(() => setPhase('hidden'), exitDurationMs);
36
+ }
37
+
38
+ return () => {
39
+ if (timerRef.current) clearTimeout(timerRef.current);
40
+ };
41
+ }, [open, exitDurationMs]);
42
+
43
+ return phase;
44
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Chat color tokens — single source of truth.
3
+ *
4
+ * Every role-conditional class for chat surfaces lives here.
5
+ * Components consume via `useChatBubbleStyles` / `useChatRoleStyles`,
6
+ * never via raw Tailwind literals.
7
+ *
8
+ * Why centralize:
9
+ * 1. One file to edit when the design system changes (e.g. light-theme
10
+ * contrast tweaks, palette swap).
11
+ * 2. Eliminates the "first-token-on-bg-primary-was-text-white" class
12
+ * of bugs where each call site picks its own foreground.
13
+ * 3. Lets us snapshot-test color decisions later.
14
+ */
15
+
16
+ /** Bubble surface classes (background + text), keyed by message state. */
17
+ export const BUBBLE_SURFACE = {
18
+ /** User-authored bubble — solid brand color. */
19
+ user: 'bg-primary text-primary-foreground rounded-tr-md',
20
+ /** Assistant bubble in normal state — neutral muted surface. */
21
+ assistant: 'bg-muted text-foreground rounded-tl-md',
22
+ /** Assistant bubble when the turn failed — destructive tint. */
23
+ error: 'bg-destructive/10 text-destructive rounded-tl-md border border-destructive/30',
24
+ } as const;
25
+
26
+ /**
27
+ * Anchor (link) classes for markdown content rendered inside a bubble.
28
+ *
29
+ * On `bg-primary` the link MUST stay legible against the cyan/brand fill —
30
+ * `text-primary-foreground` matches the bubble's foreground token, so
31
+ * contrast tracks the design system automatically.
32
+ *
33
+ * On the neutral assistant bubble we keep the brand-primary color so links
34
+ * still pop without competing with the body text.
35
+ */
36
+ export const ANCHOR = {
37
+ user:
38
+ 'text-primary-foreground underline decoration-primary-foreground/60 underline-offset-2 ' +
39
+ 'hover:decoration-primary-foreground transition-colors break-all',
40
+ assistant: 'text-primary underline hover:text-primary/80 transition-colors break-all',
41
+ } as const;
42
+
43
+ /** Inline secondary action (e.g. "Show more / less"). Same logic as anchors. */
44
+ export const TOGGLE = {
45
+ user: 'text-primary-foreground/80 hover:text-primary-foreground',
46
+ assistant: 'text-primary hover:text-primary/80',
47
+ } as const;
48
+
49
+ /** Destructive surface — used by ErrorBanner and the delete action. */
50
+ export const DESTRUCTIVE_SURFACE = {
51
+ /** Banner / card variant: border + tint + text. */
52
+ banner:
53
+ 'border border-destructive/40 bg-destructive/10 text-destructive',
54
+ /** Subtle hover for destructive buttons inside the banner / menu. */
55
+ hover: 'hover:bg-destructive/15',
56
+ /** Strong-hover variant (e.g. trash overlay on attachments). */
57
+ hoverStrong:
58
+ 'hover:bg-destructive hover:text-destructive-foreground',
59
+ /** Inline destructive text utility. */
60
+ text: 'text-destructive',
61
+ /** Hover style for menu items that delete data. */
62
+ menuItem:
63
+ 'text-destructive focus:text-destructive hover:bg-destructive/15 hover:text-destructive',
64
+ } as const;
65
+
66
+ /** Tool-call result text. */
67
+ export const TOOL_CALL = {
68
+ errorText: 'text-destructive',
69
+ } as const;
70
+
71
+ export type ChatBubbleSurface = keyof typeof BUBBLE_SURFACE;
@@ -0,0 +1,16 @@
1
+ export {
2
+ BUBBLE_SURFACE,
3
+ ANCHOR,
4
+ TOGGLE,
5
+ DESTRUCTIVE_SURFACE,
6
+ TOOL_CALL,
7
+ type ChatBubbleSurface,
8
+ } from './bubbleTokens';
9
+ export {
10
+ useChatBubbleStyles,
11
+ useChatRoleStyles,
12
+ useChatDestructiveStyles,
13
+ type ChatBubbleStyles,
14
+ type ChatRoleStyles,
15
+ type ChatDestructiveStyles,
16
+ } from './useChatStyles';