@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
@@ -5,6 +5,7 @@ import { File as FileIcon, X } from 'lucide-react';
5
5
 
6
6
  import { cn } from '@djangocfg/ui-core/lib';
7
7
 
8
+ import { useChatDestructiveStyles } from '../styles';
8
9
  import type { ChatAttachment } from '../types';
9
10
 
10
11
  export interface AttachmentRendererArgs {
@@ -179,12 +180,16 @@ function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArg
179
180
  }
180
181
 
181
182
  function RemoveBtn({ onRemove }: { onRemove: () => void }) {
183
+ const styles = useChatDestructiveStyles();
182
184
  return (
183
185
  <button
184
186
  type="button"
185
187
  aria-label="Remove attachment"
186
188
  onClick={onRemove}
187
- className="absolute -right-1.5 -top-1.5 grid h-4 w-4 place-items-center rounded-full border border-border bg-background text-muted-foreground hover:bg-destructive hover:text-destructive-foreground"
189
+ className={cn(
190
+ 'absolute -right-1.5 -top-1.5 grid h-4 w-4 place-items-center rounded-full border border-border bg-background text-muted-foreground',
191
+ styles.hoverStrong,
192
+ )}
188
193
  >
189
194
  <X aria-hidden className="size-2.5" />
190
195
  </button>
@@ -1,13 +1,15 @@
1
1
  'use client';
2
2
 
3
- import { type ReactNode, useRef, useState } from 'react';
3
+ import { type ReactNode, useMemo, useRef, useState } from 'react';
4
4
 
5
5
  import { cn } from '@djangocfg/ui-core/lib';
6
6
 
7
7
  import type { ChatAttachment, ChatConfig, ChatMessage, ChatTransport } from '../types';
8
8
  import type { ChatAudioConfig } from '../core/audio/types';
9
9
  import { ChatProvider, useChatContext, type ChatContextValue } from '../context';
10
+ import { useAutoFocusOnStreamEnd } from '../hooks/useAutoFocusOnStreamEnd';
10
11
  import { useChatComposer, type UseChatComposerReturn } from '../hooks/useChatComposer';
12
+ import { useFocusOnEmptyClick } from '../hooks/useFocusOnEmptyClick';
11
13
  import { Composer, type ComposerSize } from './Composer';
12
14
  import { EmptyState } from './EmptyState';
13
15
  import { ErrorBanner } from './ErrorBanner';
@@ -82,6 +84,12 @@ export interface ChatRootProps {
82
84
  listClassName?: string;
83
85
  /** Extra className forwarded to the `<Composer>` wrapper div. */
84
86
  composerClassName?: string;
87
+ /**
88
+ * Click in the message area → focus the composer (Slack / ChatGPT / Linear).
89
+ * Honours selection drag, interactive elements, and touch input.
90
+ * @default true
91
+ */
92
+ focusOnEmptyClick?: boolean;
85
93
  }
86
94
 
87
95
  export function ChatRoot(props: ChatRootProps) {
@@ -113,11 +121,30 @@ function ChatRootShell({ className, listClassName, slots }: ChatRootShellProps)
113
121
  onSubmit: (content, attachments) => chat.sendMessage(content, attachments),
114
122
  disabled: chat.isStreaming,
115
123
  });
124
+ const onMessagesMouseUp = useFocusOnEmptyClick({
125
+ enabled: slots.focusOnEmptyClick !== false,
126
+ });
127
+ // Re-focus the composer on the streaming → idle edge. Was dropped in
128
+ // a previous refactor — restored here so the standard chat UX (type
129
+ // → send → read → keep typing without clicking) works again.
130
+ useAutoFocusOnStreamEnd();
116
131
  // MessageList (virtuoso) owns the scroll viewport. We talk to it
117
132
  // via the imperative handle (scrollToBottom on JumpToLatest click)
118
133
  // and via the `onAtBottomChange` callback (drives the pill).
119
134
  const listRef = useRef<MessageListHandle | null>(null);
120
135
  const [isAtBottom, setIsAtBottom] = useState(true);
136
+ // The id of the most-recent user-sent message. We pass it as
137
+ // `scrollAnchorId` to MessageList so every send re-anchors the
138
+ // viewport — fixes "I sent a message but the chat didn't scroll
139
+ // down". Recomputed lazily; the resulting id only flips when a new
140
+ // user message lands.
141
+ const lastUserMessageId = useMemo(() => {
142
+ const msgs = chat.messages;
143
+ for (let i = msgs.length - 1; i >= 0; i -= 1) {
144
+ if (msgs[i].role === 'user') return msgs[i].id;
145
+ }
146
+ return null;
147
+ }, [chat.messages]);
121
148
  const handleStartReached = chat.hasMore && !chat.isLoadingMore
122
149
  ? () => void chat.loadMore()
123
150
  : undefined;
@@ -161,7 +188,7 @@ function ChatRootShell({ className, listClassName, slots }: ChatRootShellProps)
161
188
  <div className={cn('relative flex h-full min-h-0 flex-col overflow-hidden', className)}>
162
189
  {slots.banner ?? null}
163
190
  {headerNode ?? null}
164
- <div className="relative flex min-h-0 flex-1 flex-col">
191
+ <div className="relative flex min-h-0 flex-1 flex-col" onMouseUp={onMessagesMouseUp}>
165
192
  <ErrorBanner
166
193
  error={chat.error}
167
194
  onDismiss={chat.error ? () => chat.clearMessages() : undefined}
@@ -174,6 +201,7 @@ function ChatRootShell({ className, listClassName, slots }: ChatRootShellProps)
174
201
  className={listClassName}
175
202
  onStartReached={handleStartReached}
176
203
  onAtBottomChange={setIsAtBottom}
204
+ scrollAnchorId={lastUserMessageId}
177
205
  />
178
206
  <div className="pointer-events-none absolute inset-x-0 bottom-2 flex justify-center">
179
207
  {slots.jumpToLatest ?? (
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { type ReactNode, forwardRef, useEffect } from 'react';
3
+ import { type ReactNode, forwardRef, useEffect, useRef } from 'react';
4
4
  import { Paperclip, Send, Square } from 'lucide-react';
5
5
 
6
6
  import { Button, Textarea } from '@djangocfg/ui-core/components';
@@ -104,11 +104,28 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
104
104
  // a ChatProvider.
105
105
  const register = ctx?.registerComposer;
106
106
  const composerFocus = composer.focus;
107
+ const composerSetValue = composer.setValue;
108
+ const textareaRef = composer.textareaRef;
109
+ // `getValue` reads the live ref instead of closing over `composer.value`
110
+ // so we don't need to re-register on every keystroke. Same trick for
111
+ // `setValue` — handler identity stays stable across renders.
112
+ const getValueRef = useRef<() => string>(() => composer.value);
113
+ getValueRef.current = () => composer.value;
107
114
  useEffect(() => {
108
115
  if (!register) return;
109
- register({ focus: composerFocus });
116
+ register({
117
+ focus: composerFocus,
118
+ moveCursorToEnd: () => {
119
+ const el = textareaRef.current;
120
+ if (!el) return;
121
+ const end = el.value.length;
122
+ el.setSelectionRange(end, end);
123
+ },
124
+ getValue: () => getValueRef.current(),
125
+ setValue: composerSetValue,
126
+ });
110
127
  return () => register(null);
111
- }, [register, composerFocus]);
128
+ }, [register, composerFocus, composerSetValue, textareaRef]);
112
129
 
113
130
  return (
114
131
  <div
@@ -4,6 +4,8 @@ import { AlertCircle, RefreshCw, X } from 'lucide-react';
4
4
 
5
5
  import { cn } from '@djangocfg/ui-core/lib';
6
6
 
7
+ import { useChatDestructiveStyles } from '../styles';
8
+
7
9
  export interface ErrorBannerProps {
8
10
  error: string | null;
9
11
  onDismiss?: () => void;
@@ -12,12 +14,14 @@ export interface ErrorBannerProps {
12
14
  }
13
15
 
14
16
  export function ErrorBanner({ error, onDismiss, onRetry, className }: ErrorBannerProps) {
17
+ const styles = useChatDestructiveStyles();
15
18
  if (!error) return null;
16
19
  return (
17
20
  <div
18
21
  role="alert"
19
22
  className={cn(
20
- 'mx-2.5 my-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive',
23
+ 'mx-2.5 my-2 flex items-start gap-2 rounded-md px-3 py-2 text-xs',
24
+ styles.banner,
21
25
  className,
22
26
  )}
23
27
  >
@@ -27,7 +31,7 @@ export function ErrorBanner({ error, onDismiss, onRetry, className }: ErrorBanne
27
31
  <button
28
32
  type="button"
29
33
  onClick={onRetry}
30
- className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-destructive/15"
34
+ className={cn('inline-flex items-center gap-1 rounded px-1.5 py-0.5', styles.hover)}
31
35
  >
32
36
  <RefreshCw aria-hidden className="size-3" /> Retry
33
37
  </button>
@@ -37,7 +41,7 @@ export function ErrorBanner({ error, onDismiss, onRetry, className }: ErrorBanne
37
41
  type="button"
38
42
  aria-label="Dismiss"
39
43
  onClick={onDismiss}
40
- className="rounded p-0.5 hover:bg-destructive/15"
44
+ className={cn('rounded p-0.5', styles.hover)}
41
45
  >
42
46
  <X aria-hidden className="size-3" />
43
47
  </button>
@@ -5,6 +5,7 @@ import { Copy, Pencil, RefreshCw, Trash } from 'lucide-react';
5
5
 
6
6
  import { cn } from '@djangocfg/ui-core/lib';
7
7
 
8
+ import { useChatDestructiveStyles } from '../styles';
8
9
  import type { ChatRole } from '../types';
9
10
 
10
11
  export interface MessageActionsProps {
@@ -64,6 +65,7 @@ const ActionButton = memo(function ActionButton({
64
65
  icon: typeof Copy;
65
66
  destructive?: boolean;
66
67
  }) {
68
+ const styles = useChatDestructiveStyles();
67
69
  return (
68
70
  <button
69
71
  type="button"
@@ -71,7 +73,7 @@ const ActionButton = memo(function ActionButton({
71
73
  aria-label={label}
72
74
  className={cn(
73
75
  'rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground',
74
- destructive && 'hover:bg-destructive/15 hover:text-destructive',
76
+ destructive && cn(styles.hover, 'hover:text-destructive'),
75
77
  )}
76
78
  >
77
79
  <Icon aria-hidden className="size-3" />
@@ -16,6 +16,7 @@ import type {
16
16
  } from '../types';
17
17
  import { resolvePersona, deriveInitials } from '../core/persona';
18
18
  import { useChatContextOptional } from '../context';
19
+ import { useChatBubbleStyles } from '../styles';
19
20
  import { StreamingIndicator } from './StreamingIndicator';
20
21
  import { Sources } from './Sources';
21
22
  import { ToolCalls } from './ToolCalls';
@@ -110,6 +111,10 @@ const MessageBubbleInner = ({
110
111
  const isUser = isUserProp ?? message.role === 'user';
111
112
  const isStreaming = !!message.isStreaming;
112
113
  const isErr = !!message.isError;
114
+ const { surface: bubbleSurface } = useChatBubbleStyles(
115
+ isUser ? 'user' : 'assistant',
116
+ isErr,
117
+ );
113
118
 
114
119
  const ctx = useChatContextOptional();
115
120
  const persona = resolvePersona(
@@ -174,11 +179,7 @@ const MessageBubbleInner = ({
174
179
  <div
175
180
  className={cn(
176
181
  'inline-block max-w-full rounded-2xl px-3.5 py-2 text-sm',
177
- isUser
178
- ? 'bg-primary text-primary-foreground rounded-tr-md'
179
- : isErr
180
- ? 'bg-destructive/10 text-destructive rounded-tl-md border border-destructive/30'
181
- : 'bg-muted text-foreground rounded-tl-md',
182
+ bubbleSurface,
182
183
  )}
183
184
  >
184
185
  {isStreaming && message.toolActivity ? (
@@ -9,6 +9,7 @@ import {
9
9
  useImperativeHandle,
10
10
  useMemo,
11
11
  useRef,
12
+ useState,
12
13
  } from 'react';
13
14
  import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
14
15
 
@@ -71,6 +72,20 @@ export interface MessageListProps {
71
72
  * `!isAtBottom`. Plan64.
72
73
  */
73
74
  onAtBottomChange?: (isAtBottom: boolean) => void;
75
+ /**
76
+ * Pixel distance from the bottom that still counts as "at bottom".
77
+ * Default 120 — generous enough that mid-message users keep getting
78
+ * sticky-followed (matches ChatGPT / Slack feel) while still letting
79
+ * a deliberate scroll-up break the lock. Plan11.
80
+ */
81
+ atBottomThreshold?: number;
82
+ /**
83
+ * Force-scroll to the bottom when this id changes. Wire to the id of
84
+ * the most-recently-sent user message: every send re-anchors the
85
+ * viewport so users always see their own bubble + the incoming
86
+ * reply, even if they were scrolled up. Plan11.
87
+ */
88
+ scrollAnchorId?: string | number | null;
74
89
  }
75
90
 
76
91
  export interface MessageListHandle {
@@ -90,6 +105,8 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
90
105
  noVirtualize = false,
91
106
  defaultItemHeight: _deprecatedDefaultItemHeight,
92
107
  onAtBottomChange,
108
+ atBottomThreshold = 120,
109
+ scrollAnchorId,
93
110
  },
94
111
  ref,
95
112
  ) {
@@ -100,6 +117,22 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
100
117
  const { copyToClipboard } = useCopy();
101
118
 
102
119
  const virtuosoRef = useRef<VirtuosoHandle | null>(null);
120
+ // Virtuoso's `atBottomStateChange` only fires when the list is
121
+ // scrollable AND the user actually scrolls. With ≤3 short bubbles the
122
+ // total height stays below the viewport, virtuoso never fires "at
123
+ // bottom", and the `<JumpToLatest>` pill stays stuck. Track the
124
+ // scroller directly: when content fits, force `atBottom=true`.
125
+ const scrollerRef = useRef<HTMLElement | Window | null>(null);
126
+ const [isScrollable, setIsScrollable] = useState(false);
127
+ const lastReportedAtBottomRef = useRef<boolean | null>(null);
128
+ const reportAtBottom = useCallback(
129
+ (value: boolean) => {
130
+ if (lastReportedAtBottomRef.current === value) return;
131
+ lastReportedAtBottomRef.current = value;
132
+ onAtBottomChange?.(value);
133
+ },
134
+ [onAtBottomChange],
135
+ );
103
136
  // Track whether we've already landed on the bottom for the initial
104
137
  // history. Virtuoso's `initialTopMostItemIndex` only fires on first
105
138
  // mount and uses the `messages` length at that moment. Chats almost
@@ -162,6 +195,51 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
162
195
  return () => cancelAnimationFrame(id);
163
196
  }, [messages.length]);
164
197
 
198
+ // Force-scroll to bottom whenever the consumer bumps `scrollAnchorId`
199
+ // (typically the id of the latest user-sent message). Two rAFs so
200
+ // Virtuoso has measured the freshly-pushed bubble before we land,
201
+ // otherwise the call lands on the previous height and clips the new
202
+ // message under the composer. The initial-mount effect above handles
203
+ // first paint, so we skip until it has run.
204
+ useEffect(() => {
205
+ if (scrollAnchorId == null) return;
206
+ if (!didInitialScrollRef.current) return;
207
+ let raf1 = 0;
208
+ let raf2 = 0;
209
+ raf1 = requestAnimationFrame(() => {
210
+ raf2 = requestAnimationFrame(() => {
211
+ virtuosoRef.current?.scrollToIndex({
212
+ index: 'LAST',
213
+ align: 'end',
214
+ behavior: 'smooth',
215
+ });
216
+ });
217
+ });
218
+ return () => {
219
+ cancelAnimationFrame(raf1);
220
+ cancelAnimationFrame(raf2);
221
+ };
222
+ }, [scrollAnchorId]);
223
+
224
+ // Watch the scroll container: when content fits (scrollHeight ≤ clientHeight)
225
+ // there's nothing to scroll, the user is by definition "at bottom", and the
226
+ // Jump-to-latest pill must stay hidden regardless of what virtuoso reports.
227
+ useEffect(() => {
228
+ const el = scrollerRef.current;
229
+ if (!el || el === window || !(el instanceof HTMLElement)) return;
230
+
231
+ const probe = () => {
232
+ const scrollable = el.scrollHeight > el.clientHeight + 1;
233
+ setIsScrollable(scrollable);
234
+ if (!scrollable) reportAtBottom(true);
235
+ };
236
+
237
+ probe();
238
+ const ro = new ResizeObserver(probe);
239
+ ro.observe(el);
240
+ return () => ro.disconnect();
241
+ }, [reportAtBottom, messages.length]);
242
+
165
243
  // Virtuoso may invoke `computeItemKey` for an index briefly out of
166
244
  // sync with `data` during fast state churn (streaming chunks +
167
245
  // Strict Mode double-mount). `m` arrives undefined in that window.
@@ -265,8 +343,16 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
265
343
  // extra bubbles re-rendering at 60Hz during a stream. Default
266
344
  // keeps the working set tight.
267
345
  initialTopMostItemIndex={messages.length > 0 ? messages.length - 1 : 0}
346
+ atBottomThreshold={atBottomThreshold}
268
347
  followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)}
269
- atBottomStateChange={onAtBottomChange}
348
+ scrollerRef={(el) => {
349
+ scrollerRef.current = el;
350
+ }}
351
+ atBottomStateChange={(atBottom) => {
352
+ // Force `true` when the list isn't scrollable — virtuoso's signal
353
+ // can stall in `false` on short transcripts (no scroll events fire).
354
+ reportAtBottom(!isScrollable ? true : atBottom);
355
+ }}
270
356
  startReached={startReachedHandler}
271
357
  // Spinner while older history is loading. Rendering it as the
272
358
  // Header keeps it inside the virtualized scroll, so it doesn't
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { type ReactNode, useEffect, useRef, useState } from 'react';
3
+ import { memo, type ReactNode, useEffect, useRef, useState } from 'react';
4
4
  import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
5
5
 
6
6
  import { cn } from '@djangocfg/ui-core/lib';
@@ -93,7 +93,7 @@ interface ItemProps {
93
93
  renderPayload?: ToolCallsProps['renderPayload'];
94
94
  }
95
95
 
96
- function ToolCallItem({
96
+ const ToolCallItem = memo(function ToolCallItem({
97
97
  call,
98
98
  defaultExpanded,
99
99
  expandWhileStreaming,
@@ -172,7 +172,25 @@ function ToolCallItem({
172
172
  ) : null}
173
173
  </div>
174
174
  );
175
- }
175
+ }, (prev, next) => {
176
+ // Re-render only when the call's observable surface actually changed.
177
+ // Render-prop callbacks are accepted as referentially stable by callers —
178
+ // they live in toolCallsProps which itself rarely changes.
179
+ const a = prev.call;
180
+ const b = next.call;
181
+ return (
182
+ a.id === b.id &&
183
+ a.status === b.status &&
184
+ a.output === b.output &&
185
+ a.streamingText === b.streamingText &&
186
+ prev.defaultExpanded === next.defaultExpanded &&
187
+ prev.expandWhileStreaming === next.expandWhileStreaming &&
188
+ prev.renderInput === next.renderInput &&
189
+ prev.renderOutput === next.renderOutput &&
190
+ prev.renderStreaming === next.renderStreaming &&
191
+ prev.renderPayload === next.renderPayload
192
+ );
193
+ });
176
194
 
177
195
  function DefaultPayload({ value, kind }: { value: unknown; kind: ToolPayloadKind }) {
178
196
  const isStreamingOrString = kind === 'streaming' || typeof value === 'string';
@@ -19,11 +19,29 @@ import { useChatLayout, type UseChatLayoutReturn } from '../hooks/useChatLayout'
19
19
  import { useChatAudio } from '../hooks/useChatAudio';
20
20
  import type { ChatAudioConfig, UseChatAudioReturn } from '../core/audio/types';
21
21
 
22
- /** Minimal handle a composer (built-in or custom) registers so other
23
- * parts of the chat tree can drive it imperatively `.focus()` is
24
- * enough today; expand the surface as new needs arise. */
22
+ /** Imperative handle a composer (built-in or custom) registers so
23
+ * other parts of the chat tree can drive it without prop-drilling a
24
+ * ref. `focus()` is the baseline; the rest is optional so non-textarea
25
+ * hosts can keep returning `{ focus }` only.
26
+ *
27
+ * Implemented by:
28
+ * - built-in `<Composer>` — backed by `useChatComposer.textareaRef`.
29
+ * - `@djangocfg/ui-tools/markdown-editor` — backed by the TipTap
30
+ * editor (`editor.commands.focus('end')`).
31
+ * Consumed by `VoiceComposerSlot` for the focus / move-caret behaviour
32
+ * during live dictation.
33
+ */
25
34
  export interface ComposerHandle {
26
35
  focus: () => void;
36
+ /** Move the caret to the very end of the input. */
37
+ moveCursorToEnd?: () => void;
38
+ /** Read the current draft text. Needed by voice dictation to anchor
39
+ * partial transcripts onto the user's already-typed prefix. */
40
+ getValue?: () => string;
41
+ /** Replace the current draft text. Voice dictation uses this to push
42
+ * interim + final transcripts into the composer without owning a
43
+ * controlled binding. */
44
+ setValue?: (value: string) => void;
27
45
  }
28
46
 
29
47
  export interface ChatContextValue extends UseChatReturn {
@@ -1,172 +1,19 @@
1
- // Per-provider audio bus.
2
- //
3
- // Owns the actual `<audio>` elements (one per (bus × event)) plus the global
4
- // "unlocked" flag. Pure module / class — no React. The hook layer is in
5
- // `hooks/useChatAudio.ts`.
6
- //
7
- // Pitfalls this addresses (lessons from AudioPlayer/audio):
8
- // - Safari needs a user-gesture transaction to unlock playback. We pre-allocate
9
- // an `<audio>` per event and call `play()` on each during the unlock event.
10
- // - Multiple `play()` calls in quick succession on the same element: we clone
11
- // a fresh `HTMLAudioElement` from the cache for each fire so they don't
12
- // cancel each other (cheap — same `src` reuses the HTTP cache).
13
- // - SSR safety: ALL DOM access is gated; the bus is only constructed in
14
- // a `'use client'` component (the provider).
15
- // - `play()` returns a Promise; uncaught rejections show up as warnings on
16
- // Chrome. We attach `.catch()` everywhere.
17
- // - Module unload cleanup: `dispose()` revokes blob URLs, drops listeners,
18
- // and clears the cache.
1
+ // Thin re-export — the audio bus implementation lives in `@djangocfg/ui-core/hooks`.
2
+ // Kept as a re-export so existing imports keep working.
3
+
4
+ import { createSoundBus, type SoundBus } from '@djangocfg/ui-core/hooks';
19
5
 
20
6
  import type { ChatAudioEvent, ChatAudioSounds } from './types';
21
7
 
22
- interface BusOptions {
8
+ export type ChatAudioBus = SoundBus<ChatAudioEvent>;
9
+
10
+ export function createAudioBus(options: {
23
11
  sounds: ChatAudioSounds;
24
- /** Returns the current master volume 0..1. Read on each play. */
25
12
  getVolume: () => number;
26
- /** Returns master mute. Read on each play. */
27
13
  getMuted: () => boolean;
28
- /** Per-event predicate. */
29
14
  isEnabled: (event: ChatAudioEvent) => boolean;
15
+ }): ChatAudioBus {
16
+ return createSoundBus<ChatAudioEvent>(options);
30
17
  }
31
18
 
32
- export interface ChatAudioBus {
33
- play: (event: ChatAudioEvent) => void;
34
- preload: (event: ChatAudioEvent) => void;
35
- unlock: () => void;
36
- isUnlocked: () => boolean;
37
- /** Lets the provider re-publish unlock changes to React via a listener. */
38
- subscribeUnlock: (cb: (unlocked: boolean) => void) => () => void;
39
- /** Hot-swap the sounds map without re-creating the bus. */
40
- setSounds: (sounds: ChatAudioSounds) => void;
41
- dispose: () => void;
42
- }
43
-
44
- // One unlock state per tab — first gesture inside ANY <ChatProvider> unlocks
45
- // every bus. AudioPlayer follows the same "global per tab" rule for its
46
- // AudioContext (ADR-004).
47
- let unlocked = false;
48
- const unlockListeners = new Set<(v: boolean) => void>();
49
-
50
- function setUnlocked(value: boolean) {
51
- if (unlocked === value) return;
52
- unlocked = value;
53
- for (const cb of unlockListeners) cb(value);
54
- }
55
-
56
- export function _resetUnlockForTesting(): void {
57
- unlocked = false;
58
- unlockListeners.clear();
59
- }
60
-
61
- export function createAudioBus(options: BusOptions): ChatAudioBus {
62
- // SSR guard.
63
- if (typeof window === 'undefined') {
64
- return noopBus();
65
- }
66
-
67
- let sounds = options.sounds;
68
- /** Cache of "template" audio elements per URL — reused across plays. */
69
- const cache = new Map<string, HTMLAudioElement>();
70
-
71
- const getOrCreate = (url: string): HTMLAudioElement => {
72
- const hit = cache.get(url);
73
- if (hit) return hit;
74
- const el = new Audio(url);
75
- el.preload = 'auto';
76
- el.crossOrigin = 'anonymous';
77
- cache.set(url, el);
78
- return el;
79
- };
80
-
81
- const resolveUrl = (event: ChatAudioEvent): string | null => {
82
- const v = sounds[event];
83
- if (!v) return null;
84
- return v;
85
- };
86
-
87
- const play = (event: ChatAudioEvent) => {
88
- if (options.getMuted()) return;
89
- if (!options.isEnabled(event)) return;
90
- const url = resolveUrl(event);
91
- if (!url) return;
92
-
93
- // Use the cached template just for HTTP cache warming; clone so two rapid
94
- // events don't cut each other off on the same element.
95
- getOrCreate(url);
96
- const fresh = new Audio(url);
97
- fresh.preload = 'auto';
98
- fresh.volume = options.getVolume();
99
- // Fire-and-forget; the promise rejects when autoplay is blocked.
100
- const p = fresh.play();
101
- if (p && typeof p.catch === 'function') {
102
- p.catch(() => {
103
- // Browser blocked playback (no gesture yet) — ignore.
104
- });
105
- }
106
- };
107
-
108
- const preload = (event: ChatAudioEvent) => {
109
- const url = resolveUrl(event);
110
- if (!url) return;
111
- const el = getOrCreate(url);
112
- // Trigger a low-priority load.
113
- try {
114
- el.load();
115
- } catch {
116
- // ignore
117
- }
118
- };
119
-
120
- const unlock = () => {
121
- if (unlocked) return;
122
- // Play (silently) every cached element in one user-gesture transaction so
123
- // Safari/iOS lifts the autoplay block for the whole bus at once.
124
- for (const el of cache.values()) {
125
- const wasMuted = el.muted;
126
- el.muted = true;
127
- const p = el.play();
128
- if (p && typeof p.then === 'function') {
129
- p.then(() => {
130
- el.pause();
131
- el.currentTime = 0;
132
- el.muted = wasMuted;
133
- }).catch(() => {
134
- el.muted = wasMuted;
135
- });
136
- } else {
137
- el.pause();
138
- el.muted = wasMuted;
139
- }
140
- }
141
- setUnlocked(true);
142
- };
143
-
144
- return {
145
- play,
146
- preload,
147
- unlock,
148
- isUnlocked: () => unlocked,
149
- subscribeUnlock(cb) {
150
- unlockListeners.add(cb);
151
- return () => unlockListeners.delete(cb);
152
- },
153
- setSounds(next) {
154
- sounds = next;
155
- },
156
- dispose() {
157
- cache.clear();
158
- },
159
- };
160
- }
161
-
162
- function noopBus(): ChatAudioBus {
163
- return {
164
- play: () => undefined,
165
- preload: () => undefined,
166
- unlock: () => undefined,
167
- isUnlocked: () => false,
168
- subscribeUnlock: () => () => undefined,
169
- setSounds: () => undefined,
170
- dispose: () => undefined,
171
- };
172
- }
19
+ export { _resetUnlockForTesting } from '@djangocfg/ui-core/hooks';
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Built-in chat notification sounds.
3
+ *
4
+ * Bundled into the JS at build time as base64 data URLs (`tsup.config.ts`
5
+ * loader: `.mp3 → dataurl`). Total ≈ 136KB, gzipped ≈ 130KB — added to
6
+ * the lazy Chat tool so hosts get notification sounds with zero setup.
7
+ *
8
+ * Source files live in `./sounds/`. Re-encode via ffmpeg if you tweak:
9
+ *
10
+ * ffmpeg -i in.mp3 -ac 2 -ar 44100 -b:a 128k -af 'atrim=0:1.4,afade=t=out:st=1.22:d=0.18' out.mp3
11
+ */
12
+
13
+ import sent from './sounds/sent.mp3';
14
+ import received from './sounds/received.mp3';
15
+ import start from './sounds/start.mp3';
16
+ import errorSound from './sounds/error.mp3';
17
+ import mention from './sounds/mention.mp3';
18
+ import notification from './sounds/notification.mp3';
19
+
20
+ import type { ChatAudioSounds } from './types';
21
+
22
+ /**
23
+ * Default chat notification sounds. Pass to `useChatAudio` (or spread
24
+ * into a custom map) so hosts don't have to ship assets themselves.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * const audio = useChatAudio({ sounds: DEFAULT_CHAT_SOUNDS });
29
+ *
30
+ * // Override one event:
31
+ * const audio = useChatAudio({
32
+ * sounds: { ...DEFAULT_CHAT_SOUNDS, mention: '/custom-mention.mp3' },
33
+ * });
34
+ * ```
35
+ */
36
+ export const DEFAULT_CHAT_SOUNDS: ChatAudioSounds = {
37
+ messageSent: sent,
38
+ messageReceived: received,
39
+ streamStart: start,
40
+ error: errorSound,
41
+ mention,
42
+ notification,
43
+ };