@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
@@ -65,6 +65,18 @@ export interface UseChatReturn extends ChatState {
65
65
  loadMore: () => Promise<void>;
66
66
  newSession: () => Promise<void>;
67
67
  lastError: Error | null;
68
+ /**
69
+ * Inject a complete message from outside (push notification, admin
70
+ * takeover, system notice). De-duped by id. Position defaults to
71
+ * `append` — pass `prepend` for retroactive inserts.
72
+ */
73
+ injectMessage: (message: ChatMessage, position?: 'append' | 'prepend') => void;
74
+ /**
75
+ * Patch fields of an existing message in place (e.g. live-edit the
76
+ * admin's last reply, mark a message as resolved). No-op if the id
77
+ * doesn't exist.
78
+ */
79
+ updateMessage: (id: string, patch: Partial<ChatMessage>) => void;
68
80
  }
69
81
 
70
82
  export function useChat(config: UseChatConfig): UseChatReturn {
@@ -519,6 +531,20 @@ export function useChat(config: UseChatConfig): UseChatReturn {
519
531
  dispatch({ type: 'MESSAGE_DELETE', id });
520
532
  }, []);
521
533
 
534
+ const injectMessage = useCallback(
535
+ (message: ChatMessage, position?: 'append' | 'prepend') => {
536
+ dispatch({ type: 'MESSAGE_INJECT', message, position });
537
+ },
538
+ [],
539
+ );
540
+
541
+ const updateMessage = useCallback(
542
+ (id: string, patch: Partial<ChatMessage>) => {
543
+ dispatch({ type: 'MESSAGE_PATCH', id, patch });
544
+ },
545
+ [],
546
+ );
547
+
522
548
  const clearMessages = useCallback(() => {
523
549
  abortRef.current?.abort();
524
550
  dispatch({ type: 'MESSAGES_CLEAR' });
@@ -591,6 +617,8 @@ export function useChat(config: UseChatConfig): UseChatReturn {
591
617
  loadMore,
592
618
  newSession,
593
619
  lastError: lastErrorRef.current,
620
+ injectMessage,
621
+ updateMessage,
594
622
  };
595
623
  }
596
624
 
@@ -1,191 +1,70 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
+ import { useNotificationSounds } from '@djangocfg/ui-core/hooks';
4
4
 
5
- import { createAudioBus, type ChatAudioBus } from '../core/audio/audioBus';
6
- import { useChatAudioPrefs } from '../core/audio/preferences';
5
+ import { DEFAULT_CHAT_SOUNDS } from '../core/audio/defaults';
7
6
  import type {
8
7
  ChatAudioConfig,
9
8
  ChatAudioEvent,
10
9
  UseChatAudioReturn,
11
10
  } from '../core/audio/types';
12
11
 
13
- const ALL_EVENTS: ChatAudioEvent[] = [
14
- 'messageSent',
15
- 'messageReceived',
16
- 'streamStart',
17
- 'error',
18
- 'mention',
19
- 'notification',
20
- ];
21
-
22
- function readReducedMotion(): boolean {
23
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
24
- return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
25
- }
26
-
27
- function readReducedData(): boolean {
28
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
29
- return window.matchMedia('(prefers-reduced-data: reduce)').matches;
30
- }
31
-
32
- function readVisibilityHidden(): boolean {
33
- if (typeof document === 'undefined') return false;
34
- return document.visibilityState === 'hidden';
35
- }
36
-
12
+ const STORAGE_KEY = 'djangocfg-chat-audio:prefs';
13
+
14
+ /**
15
+ * Slack / Linear / Intercom-style per-event volume scale. Applied on top
16
+ * of the master volume so error / status sounds don't startle the user
17
+ * sitting next to a busy chat. Override per-event via
18
+ * `config.eventVolumes`, or pass `eventVolumes: {}` to disable defaults.
19
+ */
20
+ const DEFAULT_EVENT_VOLUMES: Partial<Record<ChatAudioEvent, number>> = {
21
+ error: 0.25,
22
+ mention: 1,
23
+ messageSent: 0.5,
24
+ messageReceived: 0.7,
25
+ streamStart: 0.3,
26
+ notification: 0.9,
27
+ };
28
+
29
+ /**
30
+ * Chat-specific audio facade. Thin wrapper over `useNotificationSounds`
31
+ * from `@djangocfg/ui-core` — keeps the `ChatAudioEvent` typing while
32
+ * the underlying bus / prefs / Safari-unlock logic lives in ui-core.
33
+ */
37
34
  export function useChatAudio(config: ChatAudioConfig = {}): UseChatAudioReturn {
38
- const {
39
- sounds = {},
40
- volume: volumeOverride,
41
- muted: mutedOverride,
42
- shouldPlay,
43
- respectReducedMotion = true,
44
- respectReducedData = true,
45
- muteWhenHidden = true,
46
- } = config;
47
-
48
- const volume = useChatAudioPrefs((s) => (volumeOverride != null ? volumeOverride : s.volume));
49
- const mutedPersisted = useChatAudioPrefs((s) => s.muted);
50
- const muted = mutedOverride != null ? mutedOverride : mutedPersisted;
51
- const enabledMap = useChatAudioPrefs((s) => s.enabled);
52
- const setVolumePref = useChatAudioPrefs((s) => s.setVolume);
53
- const setMutedPref = useChatAudioPrefs((s) => s.setMuted);
54
- const setEventEnabledPref = useChatAudioPrefs((s) => s.setEventEnabled);
55
-
56
- // Refs to keep `play()` referentially-stable while still reading current prefs.
57
- const volumeRef = useRef(volume);
58
- volumeRef.current = volume;
59
- const mutedRef = useRef(muted);
60
- mutedRef.current = muted;
61
- const enabledRef = useRef(enabledMap);
62
- enabledRef.current = enabledMap;
63
- const reducedMotionRef = useRef(readReducedMotion());
64
- const reducedDataRef = useRef(readReducedData());
65
- const hiddenRef = useRef(readVisibilityHidden());
66
- const shouldPlayRef = useRef(shouldPlay);
67
- shouldPlayRef.current = shouldPlay;
68
-
69
- // Watch reduced-motion / reduced-data preference changes.
70
- useEffect(() => {
71
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
72
- const mqMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
73
- const mqData = window.matchMedia('(prefers-reduced-data: reduce)');
74
- const onMotion = () => {
75
- reducedMotionRef.current = mqMotion.matches;
76
- };
77
- const onData = () => {
78
- reducedDataRef.current = mqData.matches;
79
- };
80
- mqMotion.addEventListener('change', onMotion);
81
- mqData.addEventListener('change', onData);
82
- return () => {
83
- mqMotion.removeEventListener('change', onMotion);
84
- mqData.removeEventListener('change', onData);
85
- };
86
- }, []);
87
-
88
- // Visibility tracking — mute while tab is hidden.
89
- useEffect(() => {
90
- if (!muteWhenHidden || typeof document === 'undefined') return;
91
- const onVis = () => {
92
- hiddenRef.current = document.visibilityState === 'hidden';
93
- };
94
- document.addEventListener('visibilitychange', onVis);
95
- return () => document.removeEventListener('visibilitychange', onVis);
96
- }, [muteWhenHidden]);
97
-
98
- // Bus instance — created once per provider, sounds map hot-swapped on change.
99
- const busRef = useRef<ChatAudioBus | null>(null);
100
- if (busRef.current === null) {
101
- busRef.current = createAudioBus({
102
- sounds,
103
- getVolume: () => volumeRef.current,
104
- getMuted: () => effectiveMuted(),
105
- isEnabled: (event) => isEnabledImpl(event),
106
- });
107
- }
108
-
109
- function effectiveMuted(): boolean {
110
- if (mutedRef.current) return true;
111
- if (muteWhenHidden && hiddenRef.current) return true;
112
- if (respectReducedMotion && reducedMotionRef.current) return true;
113
- if (respectReducedData && reducedDataRef.current) return true;
114
- return false;
115
- }
116
-
117
- function isEnabledImpl(event: ChatAudioEvent): boolean {
118
- if (shouldPlayRef.current && shouldPlayRef.current(event) === false) return false;
119
- const flag = enabledRef.current[event];
120
- if (flag === false) return false;
121
- return true;
122
- }
123
-
124
- // Hot-swap sounds when caller-provided map changes.
125
- useEffect(() => {
126
- busRef.current?.setSounds(sounds);
127
- }, [sounds]);
128
-
129
- // Preload all configured events once.
130
- useEffect(() => {
131
- const bus = busRef.current;
132
- if (!bus) return;
133
- for (const event of ALL_EVENTS) {
134
- bus.preload(event);
135
- }
136
- }, [sounds]);
137
-
138
- // Dispose on unmount.
139
- useEffect(() => {
140
- const bus = busRef.current;
141
- return () => {
142
- bus?.dispose();
143
- };
144
- }, []);
145
-
146
- // Reactive `isUnlocked`.
147
- const isUnlocked = useSyncExternalStore(
148
- useCallback((cb) => busRef.current?.subscribeUnlock(cb) ?? (() => undefined), []),
149
- () => busRef.current?.isUnlocked() ?? false,
150
- () => false,
151
- );
152
-
153
- const play = useCallback((event: ChatAudioEvent) => {
154
- busRef.current?.play(event);
155
- }, []);
156
- const preload = useCallback((event: ChatAudioEvent) => {
157
- busRef.current?.preload(event);
158
- }, []);
159
- const unlock = useCallback(() => {
160
- busRef.current?.unlock();
161
- }, []);
162
-
163
- const isEventEnabled = useCallback(
164
- (event: ChatAudioEvent) => isEnabledImpl(event),
165
- // eslint-disable-next-line react-hooks/exhaustive-deps
166
- [],
167
- );
168
-
169
- const api = useMemo<UseChatAudioReturn>(
170
- () => ({
171
- play,
172
- preload,
173
- unlock,
174
- isUnlocked,
175
- muted,
176
- setMuted: (m: boolean) => setMutedPref(m),
177
- volume,
178
- setVolume: (v: number) => setVolumePref(v),
179
- isEventEnabled,
180
- setEventEnabled: (event, enabled) => setEventEnabledPref(event, enabled),
181
- }),
182
- [play, preload, unlock, isUnlocked, muted, volume, isEventEnabled, setMutedPref, setVolumePref, setEventEnabledPref],
183
- );
184
-
185
- // We need a useState here just to register a re-render trigger when the
186
- // bus reports unlock changes — but we already wired `useSyncExternalStore`
187
- // above, so this is a no-op holder.
188
- void useState;
189
-
190
- return api;
35
+ // Auto-fallback to built-in sounds when:
36
+ // - host hasn't passed a `sounds` map (most apps)
37
+ // - host hasn't opted into `silenced` mode (native hosts skip this)
38
+ // Pass `sounds: {}` explicitly to disable both defaults and embedded.
39
+ const resolvedSounds =
40
+ config.sounds === undefined && !config.silenced ? DEFAULT_CHAT_SOUNDS : config.sounds;
41
+
42
+ const api = useNotificationSounds<ChatAudioEvent>({
43
+ storageKey: STORAGE_KEY,
44
+ sounds: resolvedSounds,
45
+ volume: config.volume,
46
+ eventVolumes: config.eventVolumes ?? DEFAULT_EVENT_VOLUMES,
47
+ muted: config.muted,
48
+ shouldPlay: config.shouldPlay,
49
+ respectReducedMotion: config.respectReducedMotion,
50
+ respectReducedData: config.respectReducedData,
51
+ muteWhenHidden: config.muteWhenHidden,
52
+ silenced: config.silenced,
53
+ onSoundEvent: config.onSoundEvent,
54
+ });
55
+
56
+ return {
57
+ play: api.play,
58
+ preload: api.preload,
59
+ unlock: api.unlock,
60
+ isUnlocked: api.isUnlocked,
61
+ muted: api.muted,
62
+ setMuted: api.setMuted,
63
+ toggleMute: api.toggleMute,
64
+ volume: api.volume,
65
+ setVolume: api.setVolume,
66
+ isEventEnabled: api.isEventEnabled,
67
+ setEventEnabled: api.setEventEnabled,
68
+ isSilent: api.isSilent,
69
+ };
191
70
  }
@@ -0,0 +1,74 @@
1
+ 'use client';
2
+
3
+ import { useCallback } from 'react';
4
+
5
+ import { useLocalStorage } from '@djangocfg/ui-core/hooks';
6
+
7
+ import type { ChatDockMode, ChatDockSide } from '../launcher/ChatDock';
8
+
9
+ export interface ChatDockPrefs {
10
+ /** Popover (FAB-style) or side-docked panel. */
11
+ mode: ChatDockMode;
12
+ /** Which edge the side dock attaches to. */
13
+ side: ChatDockSide;
14
+ /** Width in px when side-docked (resizable in the future). */
15
+ sideWidth: number;
16
+ }
17
+
18
+ export const DEFAULT_DOCK_PREFS: ChatDockPrefs = {
19
+ mode: 'popover',
20
+ side: 'right',
21
+ sideWidth: 420,
22
+ };
23
+
24
+ const DEFAULT_KEY = 'chat.dock.prefs';
25
+
26
+ export interface UseChatDockPrefsOptions {
27
+ /** localStorage key. @default 'chat.dock.prefs' */
28
+ storageKey?: string;
29
+ /** Override the baseline defaults (per-product branding, etc.). */
30
+ defaults?: Partial<ChatDockPrefs>;
31
+ }
32
+
33
+ export interface UseChatDockPrefsReturn extends ChatDockPrefs {
34
+ /** Merge-update — pass only the fields that changed. */
35
+ setPrefs: (patch: Partial<ChatDockPrefs>) => void;
36
+ /** Convenience toggle: popover ⇆ side. */
37
+ toggleMode: () => void;
38
+ /** Convenience toggle: right ⇆ left (only affects side mode). */
39
+ toggleSide: () => void;
40
+ /** Reset to defaults. */
41
+ reset: () => void;
42
+ }
43
+
44
+ /**
45
+ * Persistent dock preferences (mode / side / width) via ui-core localStorage.
46
+ *
47
+ * SSR-safe: returns defaults on the server, hydrates on mount.
48
+ * Survives reloads — power users keep their preferred chat layout.
49
+ */
50
+ export function useChatDockPrefs(opts: UseChatDockPrefsOptions = {}): UseChatDockPrefsReturn {
51
+ const key = opts.storageKey ?? DEFAULT_KEY;
52
+ const initial: ChatDockPrefs = { ...DEFAULT_DOCK_PREFS, ...opts.defaults };
53
+
54
+ const [prefs, setStored] = useLocalStorage<ChatDockPrefs>(key, initial);
55
+
56
+ const setPrefs = useCallback(
57
+ (patch: Partial<ChatDockPrefs>) => {
58
+ setStored((prev) => ({ ...prev, ...patch }));
59
+ },
60
+ [setStored],
61
+ );
62
+
63
+ const toggleMode = useCallback(() => {
64
+ setStored((prev) => ({ ...prev, mode: prev.mode === 'side' ? 'popover' : 'side' }));
65
+ }, [setStored]);
66
+
67
+ const toggleSide = useCallback(() => {
68
+ setStored((prev) => ({ ...prev, side: prev.side === 'right' ? 'left' : 'right' }));
69
+ }, [setStored]);
70
+
71
+ const reset = useCallback(() => setStored(initial), [setStored, initial]);
72
+
73
+ return { ...prefs, setPrefs, toggleMode, toggleSide, reset };
74
+ }
@@ -0,0 +1,70 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useState } from 'react';
4
+
5
+ export interface UseChatResetOptions {
6
+ /**
7
+ * Backend call that performs the actual reset (e.g. POST /chat/reset).
8
+ * Should resolve to `true` on success, `false` on failure.
9
+ * Throwing also counts as failure — caught and logged.
10
+ */
11
+ onReset: () => Promise<boolean>;
12
+ /**
13
+ * Called after a successful reset (status === true). Use to clear the
14
+ * local message list, navigate, or fire analytics.
15
+ */
16
+ onSuccess?: () => void;
17
+ /**
18
+ * Called when reset fails (returned `false` or threw). Defaults to no-op
19
+ * — wire to a toast/banner if you want to surface it.
20
+ */
21
+ onError?: (error?: unknown) => void;
22
+ }
23
+
24
+ export interface UseChatResetReturn {
25
+ /** Trigger the reset. Safe to call multiple times — re-entrant guard. */
26
+ reset: () => Promise<boolean>;
27
+ /** True while the reset is in flight. */
28
+ isResetting: boolean;
29
+ }
30
+
31
+ /**
32
+ * Generic "clear chat context" hook.
33
+ *
34
+ * Stays generic — the backend call lives in the host (it knows the URL,
35
+ * auth, project slug). Provides the in-flight state and success/error
36
+ * callbacks every consumer wires up the same way.
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * const { reset, isResetting } = useChatReset({
41
+ * onReset: async () => {
42
+ * const res = await fetch('/api/chat/reset', { method: 'POST', credentials: 'include' });
43
+ * return res.ok;
44
+ * },
45
+ * onSuccess: () => chat.clearMessages(),
46
+ * });
47
+ * ```
48
+ */
49
+ export function useChatReset(opts: UseChatResetOptions): UseChatResetReturn {
50
+ const { onReset, onSuccess, onError } = opts;
51
+ const [isResetting, setIsResetting] = useState(false);
52
+
53
+ const reset = useCallback(async (): Promise<boolean> => {
54
+ if (isResetting) return false;
55
+ setIsResetting(true);
56
+ try {
57
+ const ok = await onReset();
58
+ if (ok) onSuccess?.();
59
+ else onError?.();
60
+ return ok;
61
+ } catch (err) {
62
+ onError?.(err);
63
+ return false;
64
+ } finally {
65
+ setIsResetting(false);
66
+ }
67
+ }, [isResetting, onReset, onSuccess, onError]);
68
+
69
+ return { reset, isResetting };
70
+ }
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+
5
+ import { useChatContext } from '../context';
6
+ import type { ChatMessage } from '../types';
7
+
8
+ export interface UseChatUnreadOptions {
9
+ /**
10
+ * When true, unread state is auto-cleared (treated as "user is reading
11
+ * the chat right now"). Pass your dock-open boolean here so the badge
12
+ * resets the moment the user opens the chat.
13
+ */
14
+ open?: boolean;
15
+ /**
16
+ * Which message roles count as "unread". Defaults to `['assistant']` —
17
+ * we only count inbound replies, not the user's own messages.
18
+ */
19
+ countRoles?: Array<ChatMessage['role']>;
20
+ }
21
+
22
+ export interface UseChatUnreadReturn {
23
+ /** Most-recent inbound message since the last mark-as-read. */
24
+ unread: ChatMessage | null;
25
+ /** Total inbound messages since the last mark-as-read. */
26
+ count: number;
27
+ /** Manually clear the unread state. */
28
+ markRead: () => void;
29
+ }
30
+
31
+ /**
32
+ * Track inbound chat messages while the user isn't watching.
33
+ *
34
+ * Must be called **inside** the chat's `<ChatProvider>` (i.e. inside the
35
+ * `children` of `ChatLauncher`, alongside `ChatRoot`).
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * function ChatRootWithUnreadBadge({ open, onUnread }: { open: boolean; onUnread: (m: ChatMessage | null) => void }) {
40
+ * const { unread, count, markRead } = useChatUnread({ open });
41
+ * useEffect(() => onUnread(unread), [unread, onUnread]);
42
+ * // pass `count` to your FAB badge via the host's state
43
+ * return <ChatRoot transport={transport} />;
44
+ * }
45
+ * ```
46
+ *
47
+ * For end-to-end wiring with `<ChatLauncher unreadMessage onMarkRead>`,
48
+ * see the `Launcher / WithLivePush` story.
49
+ */
50
+ export function useChatUnread(opts: UseChatUnreadOptions = {}): UseChatUnreadReturn {
51
+ const { open = false, countRoles = ['assistant'] } = opts;
52
+ const ctx = useChatContext();
53
+
54
+ const [lastSeenId, setLastSeenId] = useState<string | null>(null);
55
+ // On first mount, treat the current tail as already seen so old history
56
+ // doesn't immediately register as unread.
57
+ const initialized = useRef(false);
58
+ useEffect(() => {
59
+ if (initialized.current) return;
60
+ initialized.current = true;
61
+ const tail = ctx.messages[ctx.messages.length - 1];
62
+ setLastSeenId(tail?.id ?? null);
63
+ }, [ctx.messages]);
64
+
65
+ // While the dock is open, auto-advance the seen pointer to the tail so
66
+ // count stays at 0.
67
+ useEffect(() => {
68
+ if (!open) return;
69
+ const tail = ctx.messages[ctx.messages.length - 1];
70
+ setLastSeenId(tail?.id ?? null);
71
+ }, [open, ctx.messages]);
72
+
73
+ // Compute unread tail since `lastSeenId`.
74
+ const seenIdx = lastSeenId
75
+ ? ctx.messages.findIndex((m) => m.id === lastSeenId)
76
+ : -1;
77
+ const after = seenIdx === -1 ? ctx.messages : ctx.messages.slice(seenIdx + 1);
78
+ const inbound = after.filter((m) => countRoles.includes(m.role));
79
+ const unread = inbound.length > 0 ? inbound[inbound.length - 1]! : null;
80
+
81
+ const markRead = useCallback(() => {
82
+ const tail = ctx.messages[ctx.messages.length - 1];
83
+ setLastSeenId(tail?.id ?? null);
84
+ }, [ctx.messages]);
85
+
86
+ return { unread, count: inbound.length, markRead };
87
+ }
@@ -0,0 +1,111 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useRef } from 'react';
4
+ import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
5
+
6
+ import { useChatContextOptional, type ComposerHandle } from '../context';
7
+ import type { Focusable } from './useAutoFocusOnStreamEnd';
8
+
9
+ export interface UseFocusOnEmptyClickOptions {
10
+ /**
11
+ * Custom focus target. Defaults to the composer registered in the chat
12
+ * context.
13
+ */
14
+ targetRef?: RefObject<Focusable | HTMLElement | null>;
15
+ /** Opt-out without unmounting the hook. @default true */
16
+ enabled?: boolean;
17
+ /**
18
+ * Don't focus if currently streaming (user is reading the reply).
19
+ * @default true
20
+ */
21
+ skipWhileStreaming?: boolean;
22
+ }
23
+
24
+ /**
25
+ * "Click anywhere in the chat → focus the composer" — Slack / Linear /
26
+ * ChatGPT behaviour.
27
+ *
28
+ * Returns a single `onMouseUp` handler to attach to the scrollable
29
+ * messages container. Heuristics that mirror the popular chat apps:
30
+ *
31
+ * 1. Ignore clicks on interactive elements (`button`, `a`, `input`,
32
+ * `textarea`, `[role="button"]`, `[contenteditable]`, …) — they
33
+ * have their own behaviour.
34
+ * 2. Ignore clicks that produced a text selection (drag-to-select).
35
+ * Stealing focus would break copy-paste flow.
36
+ * 3. Ignore touch input — on mobile the system focuses the textarea
37
+ * via tap on the composer itself; touching messages should never
38
+ * open the keyboard.
39
+ * 4. Optionally skip while the assistant is streaming so the user
40
+ * can keep reading without the keyboard popping up.
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * const onMouseUp = useFocusOnEmptyClick();
45
+ * <div ref={scrollRef} onMouseUp={onMouseUp}>{messages}</div>
46
+ * ```
47
+ */
48
+ export function useFocusOnEmptyClick(
49
+ options: UseFocusOnEmptyClickOptions = {},
50
+ ): (event: ReactMouseEvent<HTMLElement>) => void {
51
+ const { targetRef, enabled = true, skipWhileStreaming = true } = options;
52
+ const ctx = useChatContextOptional();
53
+
54
+ const composerHandleRef = useRef<ComposerHandle | null>(null);
55
+ composerHandleRef.current = ctx?.composer ?? null;
56
+
57
+ const isStreamingRef = useRef(false);
58
+ isStreamingRef.current = ctx?.isStreaming ?? false;
59
+
60
+ return useCallback(
61
+ (event: ReactMouseEvent<HTMLElement>) => {
62
+ if (!enabled) return;
63
+ if (skipWhileStreaming && isStreamingRef.current) return;
64
+
65
+ // Touch / pen → never steal focus (mobile keyboard pop is hostile).
66
+ const pointerType = (event.nativeEvent as PointerEvent).pointerType;
67
+ if (pointerType && pointerType !== 'mouse') return;
68
+
69
+ // Drag-selected text → don't steal focus.
70
+ const selection = typeof window !== 'undefined' ? window.getSelection?.() : null;
71
+ if (selection && !selection.isCollapsed && selection.toString().length > 0) {
72
+ return;
73
+ }
74
+
75
+ // Click landed on something interactive → it handles itself.
76
+ const target = event.target as HTMLElement | null;
77
+ if (!target) return;
78
+ if (isInteractive(target)) return;
79
+
80
+ // Refocus.
81
+ const explicit = targetRef?.current as Focusable | HTMLElement | null | undefined;
82
+ if (explicit) {
83
+ explicit.focus?.();
84
+ return;
85
+ }
86
+ composerHandleRef.current?.focus?.();
87
+ },
88
+ [enabled, skipWhileStreaming, targetRef],
89
+ );
90
+ }
91
+
92
+ const INTERACTIVE_SELECTORS = [
93
+ 'a[href]',
94
+ 'button',
95
+ 'input',
96
+ 'textarea',
97
+ 'select',
98
+ 'label',
99
+ 'summary',
100
+ '[role="button"]',
101
+ '[role="link"]',
102
+ '[role="menuitem"]',
103
+ '[role="tab"]',
104
+ '[contenteditable]:not([contenteditable="false"])',
105
+ '[data-no-autofocus]',
106
+ ].join(',');
107
+
108
+ function isInteractive(el: HTMLElement | null): boolean {
109
+ if (!el) return false;
110
+ return !!el.closest(INTERACTIVE_SELECTORS);
111
+ }