@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.
- package/README.md +132 -899
- package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
- package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
- package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
- package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
- package/dist/DictationField-U25MEYAL.mjs +4 -0
- package/dist/DictationField-U25MEYAL.mjs.map +1 -0
- package/dist/DictationField-XWR5VOID.cjs +13 -0
- package/dist/DictationField-XWR5VOID.cjs.map +1 -0
- package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
- package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
- package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
- package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
- package/dist/chunk-4PFW7MIJ.cjs +837 -0
- package/dist/chunk-4PFW7MIJ.cjs.map +1 -0
- package/dist/chunk-C2YN6WEO.mjs +833 -0
- package/dist/chunk-C2YN6WEO.mjs.map +1 -0
- package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
- package/dist/chunk-FIRK5CEH.cjs.map +1 -0
- package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
- package/dist/chunk-HIK6BPL7.mjs.map +1 -0
- package/dist/chunk-OZAU3QWD.cjs +2493 -0
- package/dist/chunk-OZAU3QWD.cjs.map +1 -0
- package/dist/chunk-UWVP6LCW.mjs +2447 -0
- package/dist/chunk-UWVP6LCW.mjs.map +1 -0
- package/dist/index.cjs +1668 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1215 -107
- package/dist/index.d.ts +1215 -107
- package/dist/index.mjs +1555 -50
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -15
- package/src/audio-assets.d.ts +8 -0
- package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
- package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
- package/src/tools/Chat/README.md +347 -530
- package/src/tools/Chat/components/Attachments.tsx +6 -1
- package/src/tools/Chat/components/ChatRoot.tsx +30 -2
- package/src/tools/Chat/components/Composer.tsx +20 -3
- package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
- package/src/tools/Chat/components/MessageActions.tsx +3 -1
- package/src/tools/Chat/components/MessageBubble.tsx +6 -5
- package/src/tools/Chat/components/MessageList.tsx +87 -1
- package/src/tools/Chat/components/ToolCalls.tsx +21 -3
- package/src/tools/Chat/context/ChatProvider.tsx +21 -3
- package/src/tools/Chat/core/audio/audioBus.ts +10 -163
- package/src/tools/Chat/core/audio/defaults.ts +43 -0
- package/src/tools/Chat/core/audio/index.ts +1 -0
- package/src/tools/Chat/core/audio/preferences.ts +5 -59
- package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
- package/src/tools/Chat/core/audio/types.ts +28 -0
- package/src/tools/Chat/core/reducer.ts +33 -0
- package/src/tools/Chat/core/transport/index.ts +13 -0
- package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
- package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
- package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
- package/src/tools/Chat/core/transport/sse.ts +18 -5
- package/src/tools/Chat/hooks/index.ts +25 -0
- package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
- package/src/tools/Chat/hooks/useChat.ts +28 -0
- package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
- package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
- package/src/tools/Chat/hooks/useChatReset.ts +70 -0
- package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
- package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
- package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
- package/src/tools/Chat/index.ts +84 -1
- package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
- package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
- package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
- package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
- package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
- package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
- package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
- package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
- package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
- package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
- package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
- package/src/tools/Chat/launcher/index.ts +46 -0
- package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
- package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
- package/src/tools/Chat/styles/index.ts +16 -0
- package/src/tools/Chat/styles/useChatStyles.ts +101 -0
- package/src/tools/Chat/types/attachment.ts +25 -0
- package/src/tools/Chat/types/config.ts +48 -0
- package/src/tools/Chat/types/events.ts +35 -0
- package/src/tools/Chat/types/index.ts +34 -0
- package/src/tools/Chat/types/labels.ts +38 -0
- package/src/tools/Chat/types/message.ts +32 -0
- package/src/tools/Chat/types/persona.ts +31 -0
- package/src/tools/Chat/types/session.ts +43 -0
- package/src/tools/Chat/types/tool-call.ts +17 -0
- package/src/tools/Chat/types/transport.ts +28 -0
- package/src/tools/Chat/types.ts +5 -240
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
- package/src/tools/MarkdownEditor/index.ts +1 -1
- package/src/tools/SpeechRecognition/README.md +336 -0
- package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
- package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
- package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
- package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
- package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
- package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
- package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
- package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
- package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
- package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
- package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
- package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
- package/src/tools/SpeechRecognition/components/index.ts +16 -0
- package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
- package/src/tools/SpeechRecognition/context/index.ts +6 -0
- package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
- package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
- package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
- package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
- package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
- package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
- package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
- package/src/tools/SpeechRecognition/core/ids.ts +11 -0
- package/src/tools/SpeechRecognition/core/index.ts +14 -0
- package/src/tools/SpeechRecognition/core/language.ts +78 -0
- package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
- package/src/tools/SpeechRecognition/core/logger.ts +3 -0
- package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
- package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
- package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
- package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
- package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
- package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
- package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
- package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
- package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
- package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
- package/src/tools/SpeechRecognition/index.ts +82 -0
- package/src/tools/SpeechRecognition/lazy.tsx +19 -0
- package/src/tools/SpeechRecognition/store/index.ts +2 -0
- package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
- package/src/tools/SpeechRecognition/types.ts +133 -0
- package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
- package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
- package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
- package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
- package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
- package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
- package/dist/chunk-NWUT327A.mjs.map +0 -1
- package/dist/chunk-QLMKCSR6.mjs +0 -2420
- package/dist/chunk-QLMKCSR6.mjs.map +0 -1
- package/dist/chunk-SI5RD2GD.cjs +0 -2460
- package/dist/chunk-SI5RD2GD.cjs.map +0 -1
- package/dist/chunk-XACCHZH2.cjs.map +0 -1
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +0 -771
- package/src/stories/index.ts +0 -33
- package/src/tools/AudioPlayer/AudioPlayer.story.tsx +0 -481
- package/src/tools/Chat/Chat.story.tsx +0 -1457
- package/src/tools/CodeEditor/CodeEditor.story.tsx +0 -202
- package/src/tools/CronScheduler/CronScheduler.story.tsx +0 -300
- package/src/tools/Gallery/Gallery.story.tsx +0 -237
- package/src/tools/ImageViewer/ImageViewer.story.tsx +0 -85
- package/src/tools/JsonForm/JsonForm.story.tsx +0 -350
- package/src/tools/JsonTree/JsonTree.story.tsx +0 -141
- package/src/tools/LottiePlayer/LottiePlayer.story.tsx +0 -95
- package/src/tools/Map/Map.story.tsx +0 -458
- package/src/tools/MarkdownEditor/MarkdownEditor.story.tsx +0 -225
- package/src/tools/Mermaid/Mermaid.story.tsx +0 -251
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +0 -230
- package/src/tools/PrettyCode/PrettyCode.story.tsx +0 -304
- package/src/tools/Tour/Tour.story.tsx +0 -279
- package/src/tools/Tree/Tree.story.tsx +0 -620
- package/src/tools/Uploader/Uploader.story.tsx +0 -415
- 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=
|
|
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({
|
|
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
|
|
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=
|
|
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=
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
23
|
-
* parts of the chat tree can drive it
|
|
24
|
-
*
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|