@djangocfg/ui-tools 2.1.335 → 2.1.337

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 (194) hide show
  1. package/README.md +68 -2
  2. package/dist/ChatRoot-PNNGQCYF.css +7 -0
  3. package/dist/ChatRoot-PNNGQCYF.css.map +1 -0
  4. package/dist/ChatRoot-XV2QXMV4.mjs +5 -0
  5. package/dist/ChatRoot-XV2QXMV4.mjs.map +1 -0
  6. package/dist/ChatRoot-YX4RLHQX.cjs +14 -0
  7. package/dist/ChatRoot-YX4RLHQX.cjs.map +1 -0
  8. package/dist/{CronScheduler.client-3O3VU4CI.mjs → CronScheduler.client-DLMXCPAJ.mjs} +4 -4
  9. package/dist/{CronScheduler.client-3O3VU4CI.mjs.map → CronScheduler.client-DLMXCPAJ.mjs.map} +1 -1
  10. package/dist/{CronScheduler.client-A4GO6YBY.cjs → CronScheduler.client-WEJF4PWQ.cjs} +14 -14
  11. package/dist/{CronScheduler.client-A4GO6YBY.cjs.map → CronScheduler.client-WEJF4PWQ.cjs.map} +1 -1
  12. package/dist/{DocsLayout-XLDB6CJ2.cjs → DocsLayout-N5ZJZPBY.cjs} +200 -199
  13. package/dist/DocsLayout-N5ZJZPBY.cjs.map +1 -0
  14. package/dist/{DocsLayout-CTJINVBM.mjs → DocsLayout-VFPPNKSQ.mjs} +7 -6
  15. package/dist/DocsLayout-VFPPNKSQ.mjs.map +1 -0
  16. package/dist/JsonSchemaForm-DD7CLRIG.cjs +13 -0
  17. package/dist/{JsonSchemaForm-6WMS4CIY.cjs.map → JsonSchemaForm-DD7CLRIG.cjs.map} +1 -1
  18. package/dist/JsonSchemaForm-XKUIVELK.mjs +4 -0
  19. package/dist/{JsonSchemaForm-KX4JT3M4.mjs.map → JsonSchemaForm-XKUIVELK.mjs.map} +1 -1
  20. package/dist/JsonTree-55625VVH.mjs +5 -0
  21. package/dist/{JsonTree-F27RMYSI.cjs.map → JsonTree-55625VVH.mjs.map} +1 -1
  22. package/dist/JsonTree-DCM5QGWF.cjs +11 -0
  23. package/dist/{JsonTree-QTJYSHCV.mjs.map → JsonTree-DCM5QGWF.cjs.map} +1 -1
  24. package/dist/{LottiePlayer.client-6WVWDO75.cjs → LottiePlayer.client-2S7ISJ2S.cjs} +6 -6
  25. package/dist/{LottiePlayer.client-6WVWDO75.cjs.map → LottiePlayer.client-2S7ISJ2S.cjs.map} +1 -1
  26. package/dist/{LottiePlayer.client-B4I6WNZM.mjs → LottiePlayer.client-5LDSSJWS.mjs} +4 -4
  27. package/dist/{LottiePlayer.client-B4I6WNZM.mjs.map → LottiePlayer.client-5LDSSJWS.mjs.map} +1 -1
  28. package/dist/{MapContainer-RYG4HPH4.cjs → MapContainer-76YL2JXL.cjs} +8 -8
  29. package/dist/{MapContainer-RYG4HPH4.cjs.map → MapContainer-76YL2JXL.cjs.map} +1 -1
  30. package/dist/{MapContainer-GXQLP5WY.mjs → MapContainer-7HXBI3OH.mjs} +3 -3
  31. package/dist/{MapContainer-GXQLP5WY.mjs.map → MapContainer-7HXBI3OH.mjs.map} +1 -1
  32. package/dist/{Mermaid.client-SXRRI2YW.mjs → Mermaid.client-NL4SVR7F.mjs} +4 -4
  33. package/dist/{Mermaid.client-SXRRI2YW.mjs.map → Mermaid.client-NL4SVR7F.mjs.map} +1 -1
  34. package/dist/{Mermaid.client-W76R5AKJ.cjs → Mermaid.client-NNTI6DFX.cjs} +26 -26
  35. package/dist/{Mermaid.client-W76R5AKJ.cjs.map → Mermaid.client-NNTI6DFX.cjs.map} +1 -1
  36. package/dist/Player-BRV7XTWR.mjs +4 -0
  37. package/dist/{Player-M3GC3VPE.mjs.map → Player-BRV7XTWR.mjs.map} +1 -1
  38. package/dist/Player-PM7F7DD7.cjs +13 -0
  39. package/dist/{Player-ZL2X5LGG.cjs.map → Player-PM7F7DD7.cjs.map} +1 -1
  40. package/dist/{PrettyCode.client-RPDIE5CH.cjs → PrettyCode.client-KOHDVPPN.cjs} +13 -13
  41. package/dist/{PrettyCode.client-RPDIE5CH.cjs.map → PrettyCode.client-KOHDVPPN.cjs.map} +1 -1
  42. package/dist/{PrettyCode.client-SPMTQEG4.mjs → PrettyCode.client-ZGYGKE7G.mjs} +4 -4
  43. package/dist/{PrettyCode.client-SPMTQEG4.mjs.map → PrettyCode.client-ZGYGKE7G.mjs.map} +1 -1
  44. package/dist/TreeRoot-N72OYKXU.cjs +19 -0
  45. package/dist/{TreeRoot-A3J65L6F.mjs.map → TreeRoot-N72OYKXU.cjs.map} +1 -1
  46. package/dist/TreeRoot-VGAIXCUA.mjs +4 -0
  47. package/dist/{TreeRoot-DSK5JILT.cjs.map → TreeRoot-VGAIXCUA.mjs.map} +1 -1
  48. package/dist/chunk-2ZLKZ5VR.mjs +631 -0
  49. package/dist/chunk-2ZLKZ5VR.mjs.map +1 -0
  50. package/dist/{chunk-LFWQ36LJ.mjs → chunk-5G5YBFS6.mjs} +4 -4
  51. package/dist/{chunk-LFWQ36LJ.mjs.map → chunk-5G5YBFS6.mjs.map} +1 -1
  52. package/dist/{chunk-IHAY6FO6.cjs → chunk-5I5QNGUG.cjs} +17 -17
  53. package/dist/{chunk-IHAY6FO6.cjs.map → chunk-5I5QNGUG.cjs.map} +1 -1
  54. package/dist/{chunk-F2CMIIOH.cjs → chunk-76NNDZH6.cjs} +42 -42
  55. package/dist/{chunk-F2CMIIOH.cjs.map → chunk-76NNDZH6.cjs.map} +1 -1
  56. package/dist/chunk-B5AWZOHJ.cjs +649 -0
  57. package/dist/chunk-B5AWZOHJ.cjs.map +1 -0
  58. package/dist/{chunk-KR6B3LVY.mjs → chunk-B6IR5KSC.mjs} +3 -3
  59. package/dist/{chunk-KR6B3LVY.mjs.map → chunk-B6IR5KSC.mjs.map} +1 -1
  60. package/dist/{chunk-5LBDYFWH.mjs → chunk-C6GXVH5J.mjs} +3 -3
  61. package/dist/{chunk-5LBDYFWH.mjs.map → chunk-C6GXVH5J.mjs.map} +1 -1
  62. package/dist/{chunk-NRKD4F5X.cjs → chunk-FEN5S772.cjs} +36 -36
  63. package/dist/{chunk-NRKD4F5X.cjs.map → chunk-FEN5S772.cjs.map} +1 -1
  64. package/dist/{chunk-2SMCH62O.cjs → chunk-FP2RLYQZ.cjs} +11 -11
  65. package/dist/{chunk-2SMCH62O.cjs.map → chunk-FP2RLYQZ.cjs.map} +1 -1
  66. package/dist/{chunk-MOME6KYD.mjs → chunk-G5IEC7SR.mjs} +3 -3
  67. package/dist/{chunk-MOME6KYD.mjs.map → chunk-G5IEC7SR.mjs.map} +1 -1
  68. package/dist/{chunk-SE5IERVH.mjs → chunk-GYIO7W7M.mjs} +3 -3
  69. package/dist/{chunk-SE5IERVH.mjs.map → chunk-GYIO7W7M.mjs.map} +1 -1
  70. package/dist/{chunk-3Z3A7FHA.cjs → chunk-IEEAENLX.cjs} +48 -48
  71. package/dist/{chunk-3Z3A7FHA.cjs.map → chunk-IEEAENLX.cjs.map} +1 -1
  72. package/dist/{chunk-DFTVB66S.cjs → chunk-KNDLV4PI.cjs} +85 -85
  73. package/dist/{chunk-DFTVB66S.cjs.map → chunk-KNDLV4PI.cjs.map} +1 -1
  74. package/dist/{chunk-SSUOENAZ.mjs → chunk-KNEQRUBA.mjs} +3 -3
  75. package/dist/{chunk-SSUOENAZ.mjs.map → chunk-KNEQRUBA.mjs.map} +1 -1
  76. package/dist/{chunk-CGILA3WO.mjs → chunk-N2XQF2OL.mjs} +5 -3
  77. package/dist/{chunk-CGILA3WO.mjs.map → chunk-N2XQF2OL.mjs.map} +1 -1
  78. package/dist/{chunk-EUADAUBQ.mjs → chunk-N4MZYNR4.mjs} +4 -4
  79. package/dist/{chunk-EUADAUBQ.mjs.map → chunk-N4MZYNR4.mjs.map} +1 -1
  80. package/dist/{chunk-GGKGH5PM.mjs → chunk-OBRSGM64.mjs} +4 -4
  81. package/dist/{chunk-GGKGH5PM.mjs.map → chunk-OBRSGM64.mjs.map} +1 -1
  82. package/dist/{chunk-6JTB2X72.mjs → chunk-ODO4GMW7.mjs} +3 -3
  83. package/dist/{chunk-6JTB2X72.mjs.map → chunk-ODO4GMW7.mjs.map} +1 -1
  84. package/dist/{chunk-WGEGR3DF.cjs → chunk-OLISEQHS.cjs} +5 -2
  85. package/dist/{chunk-WGEGR3DF.cjs.map → chunk-OLISEQHS.cjs.map} +1 -1
  86. package/dist/{chunk-PZKAH7WQ.mjs → chunk-PVAX67JG.mjs} +3 -3
  87. package/dist/{chunk-PZKAH7WQ.mjs.map → chunk-PVAX67JG.mjs.map} +1 -1
  88. package/dist/{chunk-PRPG2T2E.cjs → chunk-QJ6GTUCO.cjs} +6 -6
  89. package/dist/{chunk-PRPG2T2E.cjs.map → chunk-QJ6GTUCO.cjs.map} +1 -1
  90. package/dist/chunk-QW4RBGHN.cjs +961 -0
  91. package/dist/chunk-QW4RBGHN.cjs.map +1 -0
  92. package/dist/{chunk-33AMWFBZ.cjs → chunk-SGP7V2UW.cjs} +15 -15
  93. package/dist/{chunk-33AMWFBZ.cjs.map → chunk-SGP7V2UW.cjs.map} +1 -1
  94. package/dist/{chunk-FX2QFYWF.mjs → chunk-VWQ5WOIL.mjs} +3 -3
  95. package/dist/{chunk-FX2QFYWF.mjs.map → chunk-VWQ5WOIL.mjs.map} +1 -1
  96. package/dist/{chunk-ZLQHUZDU.cjs → chunk-YDPDTOSP.cjs} +139 -139
  97. package/dist/{chunk-ZLQHUZDU.cjs.map → chunk-YDPDTOSP.cjs.map} +1 -1
  98. package/dist/{chunk-77HQWEQ6.cjs → chunk-YW5IVWHQ.cjs} +33 -33
  99. package/dist/{chunk-77HQWEQ6.cjs.map → chunk-YW5IVWHQ.cjs.map} +1 -1
  100. package/dist/chunk-YWSQDBNU.mjs +2339 -0
  101. package/dist/chunk-YWSQDBNU.mjs.map +1 -0
  102. package/dist/{chunk-YXBOAGIM.cjs → chunk-YXZ6GU7H.cjs} +7 -7
  103. package/dist/{chunk-YXBOAGIM.cjs.map → chunk-YXZ6GU7H.cjs.map} +1 -1
  104. package/dist/{chunk-62Y65TGK.mjs → chunk-ZUFTH5IR.mjs} +8 -631
  105. package/dist/chunk-ZUFTH5IR.mjs.map +1 -0
  106. package/dist/chunk-ZWPBBAR2.cjs +2379 -0
  107. package/dist/chunk-ZWPBBAR2.cjs.map +1 -0
  108. package/dist/components-EHOGXATG.cjs +22 -0
  109. package/dist/{components-5UXYNAKR.cjs.map → components-EHOGXATG.cjs.map} +1 -1
  110. package/dist/components-MQ6DR7TX.cjs +26 -0
  111. package/dist/{components-CFXOEVPN.mjs.map → components-MQ6DR7TX.cjs.map} +1 -1
  112. package/dist/components-XRX7QGLB.mjs +5 -0
  113. package/dist/{components-WYEZL5TE.cjs.map → components-XRX7QGLB.mjs.map} +1 -1
  114. package/dist/components-YATKRWLH.mjs +5 -0
  115. package/dist/{components-ZAGG2PBO.mjs.map → components-YATKRWLH.mjs.map} +1 -1
  116. package/dist/file-icon/index.cjs +6 -6
  117. package/dist/file-icon/index.mjs +1 -1
  118. package/dist/index.cjs +739 -215
  119. package/dist/index.cjs.map +1 -1
  120. package/dist/index.d.cts +1025 -39
  121. package/dist/index.d.ts +1025 -39
  122. package/dist/index.mjs +387 -31
  123. package/dist/index.mjs.map +1 -1
  124. package/dist/tree/index.cjs +38 -38
  125. package/dist/tree/index.d.cts +2 -2
  126. package/dist/tree/index.d.ts +2 -2
  127. package/dist/tree/index.mjs +3 -3
  128. package/package.json +6 -6
  129. package/src/index.ts +5 -0
  130. package/src/stories/index.ts +3 -1
  131. package/src/tools/Chat/Chat.story.tsx +1006 -0
  132. package/src/tools/Chat/README.md +528 -0
  133. package/src/tools/Chat/components/Attachments.tsx +192 -0
  134. package/src/tools/Chat/components/ChatRoot.tsx +208 -0
  135. package/src/tools/Chat/components/Composer.tsx +134 -0
  136. package/src/tools/Chat/components/EmptyState.tsx +47 -0
  137. package/src/tools/Chat/components/ErrorBanner.tsx +47 -0
  138. package/src/tools/Chat/components/JumpToLatest.tsx +30 -0
  139. package/src/tools/Chat/components/MessageActions.tsx +72 -0
  140. package/src/tools/Chat/components/MessageBubble.tsx +228 -0
  141. package/src/tools/Chat/components/MessageList.tsx +82 -0
  142. package/src/tools/Chat/components/Sources.tsx +55 -0
  143. package/src/tools/Chat/components/StreamingIndicator.tsx +29 -0
  144. package/src/tools/Chat/components/ToolCalls.tsx +172 -0
  145. package/src/tools/Chat/components/index.ts +24 -0
  146. package/src/tools/Chat/config.ts +55 -0
  147. package/src/tools/Chat/context/ChatProvider.tsx +126 -0
  148. package/src/tools/Chat/context/index.ts +9 -0
  149. package/src/tools/Chat/core/audio/audioBus.ts +172 -0
  150. package/src/tools/Chat/core/audio/index.ts +8 -0
  151. package/src/tools/Chat/core/audio/preferences.ts +68 -0
  152. package/src/tools/Chat/core/audio/types.ts +49 -0
  153. package/src/tools/Chat/core/ids.ts +16 -0
  154. package/src/tools/Chat/core/index.ts +5 -0
  155. package/src/tools/Chat/core/logger.ts +73 -0
  156. package/src/tools/Chat/core/markdown.ts +56 -0
  157. package/src/tools/Chat/core/payload-dispatch.ts +54 -0
  158. package/src/tools/Chat/core/persona.ts +35 -0
  159. package/src/tools/Chat/core/reducer.ts +335 -0
  160. package/src/tools/Chat/core/transport/http.ts +167 -0
  161. package/src/tools/Chat/core/transport/index.ts +13 -0
  162. package/src/tools/Chat/core/transport/mock.ts +134 -0
  163. package/src/tools/Chat/core/transport/sse.ts +116 -0
  164. package/src/tools/Chat/core/transport/types.ts +24 -0
  165. package/src/tools/Chat/hooks/index.ts +26 -0
  166. package/src/tools/Chat/hooks/useChat.ts +555 -0
  167. package/src/tools/Chat/hooks/useChatAudio.ts +191 -0
  168. package/src/tools/Chat/hooks/useChatComposer.ts +227 -0
  169. package/src/tools/Chat/hooks/useChatHistory.ts +59 -0
  170. package/src/tools/Chat/hooks/useChatLayout.ts +111 -0
  171. package/src/tools/Chat/hooks/useChatLightbox.ts +34 -0
  172. package/src/tools/Chat/hooks/useChatScroll.ts +132 -0
  173. package/src/tools/Chat/index.ts +161 -0
  174. package/src/tools/Chat/lazy.tsx +14 -0
  175. package/src/tools/Chat/types.ts +237 -0
  176. package/src/tools/Chat/utils/collectImageAttachments.ts +13 -0
  177. package/src/tools/Map/README.md +384 -0
  178. package/dist/DocsLayout-CTJINVBM.mjs.map +0 -1
  179. package/dist/DocsLayout-XLDB6CJ2.cjs.map +0 -1
  180. package/dist/JsonSchemaForm-6WMS4CIY.cjs +0 -13
  181. package/dist/JsonSchemaForm-KX4JT3M4.mjs +0 -4
  182. package/dist/JsonTree-F27RMYSI.cjs +0 -11
  183. package/dist/JsonTree-QTJYSHCV.mjs +0 -5
  184. package/dist/Player-M3GC3VPE.mjs +0 -4
  185. package/dist/Player-ZL2X5LGG.cjs +0 -13
  186. package/dist/TreeRoot-A3J65L6F.mjs +0 -4
  187. package/dist/TreeRoot-DSK5JILT.cjs +0 -19
  188. package/dist/chunk-62Y65TGK.mjs.map +0 -1
  189. package/dist/chunk-TKSFZHCG.cjs +0 -1597
  190. package/dist/chunk-TKSFZHCG.cjs.map +0 -1
  191. package/dist/components-5UXYNAKR.cjs +0 -22
  192. package/dist/components-CFXOEVPN.mjs +0 -5
  193. package/dist/components-WYEZL5TE.cjs +0 -26
  194. package/dist/components-ZAGG2PBO.mjs +0 -5
@@ -0,0 +1,191 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
4
+
5
+ import { createAudioBus, type ChatAudioBus } from '../core/audio/audioBus';
6
+ import { useChatAudioPrefs } from '../core/audio/preferences';
7
+ import type {
8
+ ChatAudioConfig,
9
+ ChatAudioEvent,
10
+ UseChatAudioReturn,
11
+ } from '../core/audio/types';
12
+
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
+
37
+ 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;
191
+ }
@@ -0,0 +1,227 @@
1
+ 'use client';
2
+
3
+ import {
4
+ type ChangeEvent,
5
+ type ClipboardEvent,
6
+ type KeyboardEvent,
7
+ type RefObject,
8
+ useCallback,
9
+ useEffect,
10
+ useRef,
11
+ useState,
12
+ } from 'react';
13
+
14
+ import type { ChatAttachment } from '../types';
15
+ import { LIMITS } from '../config';
16
+
17
+ export interface UseChatComposerOptions {
18
+ onSubmit: (content: string, attachments: ChatAttachment[]) => void | Promise<void>;
19
+ initialValue?: string;
20
+ maxLength?: number;
21
+ maxAttachments?: number;
22
+ disabled?: boolean;
23
+ /** 'enter' = Enter sends, Shift+Enter newline. 'cmd+enter' = Enter inserts newline, Cmd/Ctrl+Enter sends. */
24
+ submitOn?: 'enter' | 'cmd+enter';
25
+ history?: { enabled?: boolean; size?: number };
26
+ onPasteFiles?: (files: File[]) => void;
27
+ }
28
+
29
+ export interface UseChatComposerReturn {
30
+ value: string;
31
+ setValue: (next: string) => void;
32
+ attachments: ChatAttachment[];
33
+ addAttachment: (a: ChatAttachment) => void;
34
+ removeAttachment: (id: string) => void;
35
+ isSubmitting: boolean;
36
+ canSubmit: boolean;
37
+ submit: () => Promise<void>;
38
+ reset: () => void;
39
+ focus: () => void;
40
+ textareaRef: RefObject<HTMLTextAreaElement | null>;
41
+ textareaProps: {
42
+ ref: RefObject<HTMLTextAreaElement | null>;
43
+ value: string;
44
+ disabled: boolean;
45
+ onChange: (e: ChangeEvent<HTMLTextAreaElement>) => void;
46
+ onKeyDown: (e: KeyboardEvent<HTMLTextAreaElement>) => void;
47
+ onPaste: (e: ClipboardEvent<HTMLTextAreaElement>) => void;
48
+ };
49
+ recallPrevious: () => void;
50
+ recallNext: () => void;
51
+ }
52
+
53
+ const MAX_TEXTAREA_HEIGHT = 240;
54
+
55
+ export function useChatComposer(options: UseChatComposerOptions): UseChatComposerReturn {
56
+ const {
57
+ onSubmit,
58
+ initialValue = '',
59
+ maxLength = LIMITS.messageMaxLength,
60
+ maxAttachments = LIMITS.attachmentsMax,
61
+ disabled = false,
62
+ submitOn = 'enter',
63
+ history = { enabled: true, size: LIMITS.composerHistorySize },
64
+ onPasteFiles,
65
+ } = options;
66
+
67
+ const [value, setValueState] = useState(initialValue);
68
+ const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
69
+ const [isSubmitting, setIsSubmitting] = useState(false);
70
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
71
+
72
+ const historyRef = useRef<{ items: string[]; index: number }>({ items: [], index: -1 });
73
+
74
+ const setValue = useCallback(
75
+ (next: string) => {
76
+ setValueState(next.length > maxLength ? next.slice(0, maxLength) : next);
77
+ },
78
+ [maxLength],
79
+ );
80
+
81
+ // Autosize textarea on value change.
82
+ useEffect(() => {
83
+ const el = textareaRef.current;
84
+ if (!el) return;
85
+ el.style.height = 'auto';
86
+ el.style.height = `${Math.min(el.scrollHeight, MAX_TEXTAREA_HEIGHT)}px`;
87
+ }, [value]);
88
+
89
+ const reset = useCallback(() => {
90
+ setValueState('');
91
+ setAttachments([]);
92
+ historyRef.current.index = -1;
93
+ }, []);
94
+
95
+ const focus = useCallback(() => {
96
+ requestAnimationFrame(() => textareaRef.current?.focus());
97
+ }, []);
98
+
99
+ const submit = useCallback(async () => {
100
+ const trimmed = value.trim();
101
+ if ((!trimmed && attachments.length === 0) || isSubmitting || disabled) return;
102
+ setIsSubmitting(true);
103
+ try {
104
+ if (history.enabled !== false && trimmed) {
105
+ const buf = historyRef.current.items;
106
+ if (buf[buf.length - 1] !== trimmed) {
107
+ buf.push(trimmed);
108
+ if (buf.length > (history.size ?? LIMITS.composerHistorySize)) buf.shift();
109
+ }
110
+ historyRef.current.index = -1;
111
+ }
112
+ const snapshot = [...attachments];
113
+ const text = value;
114
+ reset();
115
+ await onSubmit(text, snapshot);
116
+ } finally {
117
+ setIsSubmitting(false);
118
+ }
119
+ }, [value, attachments, isSubmitting, disabled, history, onSubmit, reset]);
120
+
121
+ const addAttachment = useCallback(
122
+ (a: ChatAttachment) => {
123
+ setAttachments((prev) => {
124
+ if (prev.some((p) => p.id === a.id)) {
125
+ return prev.map((p) => (p.id === a.id ? a : p));
126
+ }
127
+ if (prev.length >= maxAttachments) return prev;
128
+ return [...prev, a];
129
+ });
130
+ },
131
+ [maxAttachments],
132
+ );
133
+
134
+ const removeAttachment = useCallback((id: string) => {
135
+ setAttachments((prev) => prev.filter((a) => a.id !== id));
136
+ }, []);
137
+
138
+ const recallPrevious = useCallback(() => {
139
+ const { items } = historyRef.current;
140
+ if (!items.length) return;
141
+ const next = historyRef.current.index < 0 ? items.length - 1 : Math.max(0, historyRef.current.index - 1);
142
+ historyRef.current.index = next;
143
+ setValueState(items[next]);
144
+ }, []);
145
+
146
+ const recallNext = useCallback(() => {
147
+ const { items } = historyRef.current;
148
+ if (!items.length || historyRef.current.index < 0) return;
149
+ const next = historyRef.current.index + 1;
150
+ if (next >= items.length) {
151
+ historyRef.current.index = -1;
152
+ setValueState('');
153
+ return;
154
+ }
155
+ historyRef.current.index = next;
156
+ setValueState(items[next]);
157
+ }, []);
158
+
159
+ const onChange = useCallback(
160
+ (e: ChangeEvent<HTMLTextAreaElement>) => {
161
+ setValue(e.target.value);
162
+ },
163
+ [setValue],
164
+ );
165
+
166
+ const onKeyDown = useCallback(
167
+ (e: KeyboardEvent<HTMLTextAreaElement>) => {
168
+ if (e.key === 'Enter') {
169
+ const isCmd = e.metaKey || e.ctrlKey;
170
+ const shouldSend = submitOn === 'cmd+enter' ? isCmd : !e.shiftKey;
171
+ if (shouldSend) {
172
+ e.preventDefault();
173
+ void submit();
174
+ }
175
+ return;
176
+ }
177
+ if (history.enabled !== false && value === '' && attachments.length === 0) {
178
+ if (e.key === 'ArrowUp') {
179
+ e.preventDefault();
180
+ recallPrevious();
181
+ } else if (e.key === 'ArrowDown' && historyRef.current.index >= 0) {
182
+ e.preventDefault();
183
+ recallNext();
184
+ }
185
+ }
186
+ },
187
+ [submitOn, submit, history, value, attachments.length, recallPrevious, recallNext],
188
+ );
189
+
190
+ const onPaste = useCallback(
191
+ (e: ClipboardEvent<HTMLTextAreaElement>) => {
192
+ const files = Array.from(e.clipboardData?.files ?? []);
193
+ if (files.length && onPasteFiles) {
194
+ e.preventDefault();
195
+ onPasteFiles(files);
196
+ }
197
+ },
198
+ [onPasteFiles],
199
+ );
200
+
201
+ const canSubmit =
202
+ !disabled && !isSubmitting && (value.trim().length > 0 || attachments.length > 0);
203
+
204
+ return {
205
+ value,
206
+ setValue,
207
+ attachments,
208
+ addAttachment,
209
+ removeAttachment,
210
+ isSubmitting,
211
+ canSubmit,
212
+ submit,
213
+ reset,
214
+ focus,
215
+ textareaRef,
216
+ textareaProps: {
217
+ ref: textareaRef,
218
+ value,
219
+ disabled,
220
+ onChange,
221
+ onKeyDown,
222
+ onPaste,
223
+ },
224
+ recallPrevious,
225
+ recallNext,
226
+ };
227
+ }
@@ -0,0 +1,59 @@
1
+ 'use client';
2
+
3
+ import { type RefObject, useEffect, useRef } from 'react';
4
+
5
+ export interface UseChatHistoryOptions {
6
+ enabled?: boolean;
7
+ containerRef: RefObject<HTMLElement | null>;
8
+ topSentinelRef: RefObject<HTMLElement | null>;
9
+ hasMore: boolean;
10
+ isLoadingMore: boolean;
11
+ loadMore: () => Promise<void>;
12
+ }
13
+
14
+ /** Triggers `loadMore` when the top sentinel enters the container's viewport.
15
+ * Preserves scroll anchor: if the container's height grows after load, we
16
+ * bump scrollTop by the delta so the previously-visible message stays put. */
17
+ export function useChatHistory(options: UseChatHistoryOptions): void {
18
+ const { enabled = true, containerRef, topSentinelRef, hasMore, isLoadingMore, loadMore } = options;
19
+ const heightBeforeRef = useRef<number | null>(null);
20
+
21
+ // Restore anchor after content prepends.
22
+ useEffect(() => {
23
+ if (heightBeforeRef.current == null) return;
24
+ const el = containerRef.current;
25
+ if (!el) {
26
+ heightBeforeRef.current = null;
27
+ return;
28
+ }
29
+ if (!isLoadingMore) {
30
+ const delta = el.scrollHeight - heightBeforeRef.current;
31
+ if (delta > 0) {
32
+ el.scrollTop += delta;
33
+ }
34
+ heightBeforeRef.current = null;
35
+ }
36
+ }, [containerRef, isLoadingMore]);
37
+
38
+ useEffect(() => {
39
+ if (!enabled || !hasMore) return;
40
+ const sentinel = topSentinelRef.current;
41
+ const root = containerRef.current;
42
+ if (!sentinel || !root) return;
43
+
44
+ const observer = new IntersectionObserver(
45
+ (entries) => {
46
+ const entry = entries[0];
47
+ if (!entry?.isIntersecting) return;
48
+ if (isLoadingMore) return;
49
+ const el = containerRef.current;
50
+ if (el) heightBeforeRef.current = el.scrollHeight;
51
+ void loadMore();
52
+ },
53
+ { root, threshold: 0, rootMargin: '200px 0px 0px 0px' },
54
+ );
55
+
56
+ observer.observe(sentinel);
57
+ return () => observer.disconnect();
58
+ }, [enabled, hasMore, isLoadingMore, containerRef, topSentinelRef, loadMore]);
59
+ }
@@ -0,0 +1,111 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+
5
+ import { useLocalStorage, useMediaQuery } from '@djangocfg/ui-core/hooks';
6
+
7
+ import { CSS_VARS, DEFAULT_SIDEBAR, STORAGE_KEYS } from '../config';
8
+ import type { ChatDisplayMode } from '../types';
9
+
10
+ export interface UseChatLayoutConfig {
11
+ defaultMode?: ChatDisplayMode;
12
+ storageKey?: string;
13
+ sidebarStorageKey?: string;
14
+ reserveCssVar?: string;
15
+ defaultSidebarWidth?: number;
16
+ minSidebarWidth?: number;
17
+ maxSidebarWidth?: number;
18
+ /** Mobile breakpoint, e.g. '(max-width: 640px)'. */
19
+ mobileQuery?: string;
20
+ }
21
+
22
+ export interface UseChatLayoutReturn {
23
+ mode: ChatDisplayMode;
24
+ setMode: (m: ChatDisplayMode) => void;
25
+ open: () => void;
26
+ close: () => void;
27
+ toggle: () => void;
28
+ sidebarWidth: number;
29
+ setSidebarWidth: (w: number) => void;
30
+ isMobile: boolean;
31
+ /** Mode after mobile collapse rules — sidebar/floating become fullscreen on mobile. */
32
+ effectiveMode: ChatDisplayMode;
33
+ }
34
+
35
+ const DEFAULT_MOBILE_QUERY = '(max-width: 640px)';
36
+
37
+ export function useChatLayout(config: UseChatLayoutConfig = {}): UseChatLayoutReturn {
38
+ const {
39
+ defaultMode = 'closed',
40
+ storageKey = STORAGE_KEYS.mode,
41
+ sidebarStorageKey = STORAGE_KEYS.sidebarWidth,
42
+ reserveCssVar = CSS_VARS.reserve,
43
+ defaultSidebarWidth = DEFAULT_SIDEBAR.width,
44
+ minSidebarWidth = DEFAULT_SIDEBAR.min,
45
+ maxSidebarWidth = DEFAULT_SIDEBAR.max,
46
+ mobileQuery = DEFAULT_MOBILE_QUERY,
47
+ } = config;
48
+
49
+ const [mode, setMode] = useLocalStorage<ChatDisplayMode>(storageKey, defaultMode);
50
+ const [sidebarWidthRaw, setSidebarWidthRaw] = useLocalStorage<number>(
51
+ sidebarStorageKey,
52
+ defaultSidebarWidth,
53
+ );
54
+ const isMobile = useMediaQuery(mobileQuery);
55
+
56
+ const setSidebarWidth = useCallback(
57
+ (w: number) => {
58
+ const clamped = Math.max(minSidebarWidth, Math.min(maxSidebarWidth, w));
59
+ setSidebarWidthRaw(clamped);
60
+ },
61
+ [setSidebarWidthRaw, minSidebarWidth, maxSidebarWidth],
62
+ );
63
+
64
+ const [previousOpenMode, setPreviousOpenMode] = useState<ChatDisplayMode>(
65
+ mode === 'closed' ? 'embedded' : mode,
66
+ );
67
+ useEffect(() => {
68
+ if (mode !== 'closed') setPreviousOpenMode(mode);
69
+ }, [mode]);
70
+
71
+ const open = useCallback(() => {
72
+ setMode(previousOpenMode === 'closed' ? 'embedded' : previousOpenMode);
73
+ }, [setMode, previousOpenMode]);
74
+
75
+ const close = useCallback(() => setMode('closed'), [setMode]);
76
+ const toggle = useCallback(() => {
77
+ setMode((mode === 'closed' ? previousOpenMode : 'closed') as ChatDisplayMode);
78
+ }, [setMode, mode, previousOpenMode]);
79
+
80
+ const effectiveMode = useMemo<ChatDisplayMode>(() => {
81
+ if (mode === 'closed') return 'closed';
82
+ if (isMobile && (mode === 'sidebar' || mode === 'floating')) return 'fullscreen';
83
+ return mode;
84
+ }, [mode, isMobile]);
85
+
86
+ // Reserve right padding when in sidebar mode.
87
+ useEffect(() => {
88
+ if (typeof document === 'undefined') return;
89
+ const root = document.documentElement;
90
+ if (effectiveMode === 'sidebar') {
91
+ root.style.setProperty(reserveCssVar, `${sidebarWidthRaw}px`);
92
+ } else {
93
+ root.style.removeProperty(reserveCssVar);
94
+ }
95
+ return () => {
96
+ root.style.removeProperty(reserveCssVar);
97
+ };
98
+ }, [effectiveMode, sidebarWidthRaw, reserveCssVar]);
99
+
100
+ return {
101
+ mode,
102
+ setMode,
103
+ open,
104
+ close,
105
+ toggle,
106
+ sidebarWidth: sidebarWidthRaw,
107
+ setSidebarWidth,
108
+ isMobile,
109
+ effectiveMode,
110
+ };
111
+ }
@@ -0,0 +1,34 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useState } from 'react';
4
+
5
+ import type { ChatAttachment } from '../types';
6
+
7
+ export type ChatLightboxScope = 'message' | 'conversation';
8
+
9
+ export interface ChatLightboxState {
10
+ gallery: ChatAttachment[];
11
+ index: number;
12
+ }
13
+
14
+ export interface UseChatLightboxReturn {
15
+ state: ChatLightboxState | null;
16
+ open: (att: ChatAttachment, gallery?: ChatAttachment[]) => void;
17
+ close: () => void;
18
+ }
19
+
20
+ /** Tiny state container for an image lightbox. The host owns the modal +
21
+ * `<LazyImageViewer>` mount; we just track which gallery to show. */
22
+ export function useChatLightbox(): UseChatLightboxReturn {
23
+ const [state, setState] = useState<ChatLightboxState | null>(null);
24
+
25
+ const open = useCallback((att: ChatAttachment, gallery?: ChatAttachment[]) => {
26
+ const list = gallery && gallery.length ? gallery : [att];
27
+ const idx = list.findIndex((a) => a.id === att.id);
28
+ setState({ gallery: list, index: idx === -1 ? 0 : idx });
29
+ }, []);
30
+
31
+ const close = useCallback(() => setState(null), []);
32
+
33
+ return { state, open, close };
34
+ }