@djangocfg/ui-tools 2.1.381 → 2.1.383

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +132 -899
  2. package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
  3. package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
  4. package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
  5. package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
  6. package/dist/DictationField-U25MEYAL.mjs +4 -0
  7. package/dist/DictationField-U25MEYAL.mjs.map +1 -0
  8. package/dist/DictationField-XWR5VOID.cjs +13 -0
  9. package/dist/DictationField-XWR5VOID.cjs.map +1 -0
  10. package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
  11. package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
  12. package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
  13. package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
  14. package/dist/chunk-4PFW7MIJ.cjs +837 -0
  15. package/dist/chunk-4PFW7MIJ.cjs.map +1 -0
  16. package/dist/chunk-C2YN6WEO.mjs +833 -0
  17. package/dist/chunk-C2YN6WEO.mjs.map +1 -0
  18. package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
  19. package/dist/chunk-FIRK5CEH.cjs.map +1 -0
  20. package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
  21. package/dist/chunk-HIK6BPL7.mjs.map +1 -0
  22. package/dist/chunk-OZAU3QWD.cjs +2493 -0
  23. package/dist/chunk-OZAU3QWD.cjs.map +1 -0
  24. package/dist/chunk-UWVP6LCW.mjs +2447 -0
  25. package/dist/chunk-UWVP6LCW.mjs.map +1 -0
  26. package/dist/index.cjs +1668 -99
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +1215 -107
  29. package/dist/index.d.ts +1215 -107
  30. package/dist/index.mjs +1555 -50
  31. package/dist/index.mjs.map +1 -1
  32. package/package.json +16 -15
  33. package/src/audio-assets.d.ts +8 -0
  34. package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
  35. package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
  36. package/src/tools/Chat/README.md +347 -530
  37. package/src/tools/Chat/components/Attachments.tsx +6 -1
  38. package/src/tools/Chat/components/ChatRoot.tsx +30 -2
  39. package/src/tools/Chat/components/Composer.tsx +20 -3
  40. package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
  41. package/src/tools/Chat/components/MessageActions.tsx +3 -1
  42. package/src/tools/Chat/components/MessageBubble.tsx +6 -5
  43. package/src/tools/Chat/components/MessageList.tsx +87 -1
  44. package/src/tools/Chat/components/ToolCalls.tsx +21 -3
  45. package/src/tools/Chat/context/ChatProvider.tsx +21 -3
  46. package/src/tools/Chat/core/audio/audioBus.ts +10 -163
  47. package/src/tools/Chat/core/audio/defaults.ts +43 -0
  48. package/src/tools/Chat/core/audio/index.ts +1 -0
  49. package/src/tools/Chat/core/audio/preferences.ts +5 -59
  50. package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
  51. package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
  52. package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
  53. package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
  54. package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
  55. package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
  56. package/src/tools/Chat/core/audio/types.ts +28 -0
  57. package/src/tools/Chat/core/reducer.ts +33 -0
  58. package/src/tools/Chat/core/transport/index.ts +13 -0
  59. package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
  60. package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
  61. package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
  62. package/src/tools/Chat/core/transport/sse.ts +18 -5
  63. package/src/tools/Chat/hooks/index.ts +25 -0
  64. package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
  65. package/src/tools/Chat/hooks/useChat.ts +28 -0
  66. package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
  67. package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
  68. package/src/tools/Chat/hooks/useChatReset.ts +70 -0
  69. package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
  70. package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
  71. package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
  72. package/src/tools/Chat/index.ts +84 -1
  73. package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
  74. package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
  75. package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
  76. package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
  77. package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
  78. package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
  79. package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
  80. package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
  81. package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
  82. package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
  83. package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
  84. package/src/tools/Chat/launcher/index.ts +46 -0
  85. package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
  86. package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
  87. package/src/tools/Chat/styles/index.ts +16 -0
  88. package/src/tools/Chat/styles/useChatStyles.ts +101 -0
  89. package/src/tools/Chat/types/attachment.ts +25 -0
  90. package/src/tools/Chat/types/config.ts +48 -0
  91. package/src/tools/Chat/types/events.ts +35 -0
  92. package/src/tools/Chat/types/index.ts +34 -0
  93. package/src/tools/Chat/types/labels.ts +38 -0
  94. package/src/tools/Chat/types/message.ts +32 -0
  95. package/src/tools/Chat/types/persona.ts +31 -0
  96. package/src/tools/Chat/types/session.ts +43 -0
  97. package/src/tools/Chat/types/tool-call.ts +17 -0
  98. package/src/tools/Chat/types/transport.ts +28 -0
  99. package/src/tools/Chat/types.ts +5 -240
  100. package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
  101. package/src/tools/MarkdownEditor/index.ts +1 -1
  102. package/src/tools/SpeechRecognition/README.md +336 -0
  103. package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
  104. package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
  105. package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
  106. package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
  107. package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
  108. package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
  109. package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
  110. package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
  111. package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
  112. package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
  113. package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
  114. package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
  115. package/src/tools/SpeechRecognition/components/index.ts +16 -0
  116. package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
  117. package/src/tools/SpeechRecognition/context/index.ts +6 -0
  118. package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
  119. package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
  120. package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
  121. package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
  122. package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
  123. package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
  124. package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
  125. package/src/tools/SpeechRecognition/core/ids.ts +11 -0
  126. package/src/tools/SpeechRecognition/core/index.ts +14 -0
  127. package/src/tools/SpeechRecognition/core/language.ts +78 -0
  128. package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
  129. package/src/tools/SpeechRecognition/core/logger.ts +3 -0
  130. package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
  131. package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
  132. package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
  133. package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
  134. package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
  135. package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
  136. package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
  137. package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
  138. package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
  139. package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
  140. package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
  141. package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
  142. package/src/tools/SpeechRecognition/index.ts +82 -0
  143. package/src/tools/SpeechRecognition/lazy.tsx +19 -0
  144. package/src/tools/SpeechRecognition/store/index.ts +2 -0
  145. package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
  146. package/src/tools/SpeechRecognition/types.ts +133 -0
  147. package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
  148. package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
  149. package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
  150. package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
  151. package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
  152. package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
  153. package/dist/chunk-NWUT327A.mjs.map +0 -1
  154. package/dist/chunk-QLMKCSR6.mjs +0 -2420
  155. package/dist/chunk-QLMKCSR6.mjs.map +0 -1
  156. package/dist/chunk-SI5RD2GD.cjs +0 -2460
  157. package/dist/chunk-SI5RD2GD.cjs.map +0 -1
  158. package/dist/chunk-XACCHZH2.cjs.map +0 -1
  159. package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +0 -771
  160. package/src/stories/index.ts +0 -33
  161. package/src/tools/AudioPlayer/AudioPlayer.story.tsx +0 -481
  162. package/src/tools/Chat/Chat.story.tsx +0 -1457
  163. package/src/tools/CodeEditor/CodeEditor.story.tsx +0 -202
  164. package/src/tools/CronScheduler/CronScheduler.story.tsx +0 -300
  165. package/src/tools/Gallery/Gallery.story.tsx +0 -237
  166. package/src/tools/ImageViewer/ImageViewer.story.tsx +0 -85
  167. package/src/tools/JsonForm/JsonForm.story.tsx +0 -350
  168. package/src/tools/JsonTree/JsonTree.story.tsx +0 -141
  169. package/src/tools/LottiePlayer/LottiePlayer.story.tsx +0 -95
  170. package/src/tools/Map/Map.story.tsx +0 -458
  171. package/src/tools/MarkdownEditor/MarkdownEditor.story.tsx +0 -225
  172. package/src/tools/Mermaid/Mermaid.story.tsx +0 -251
  173. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +0 -230
  174. package/src/tools/PrettyCode/PrettyCode.story.tsx +0 -304
  175. package/src/tools/Tour/Tour.story.tsx +0 -279
  176. package/src/tools/Tree/Tree.story.tsx +0 -620
  177. package/src/tools/Uploader/Uploader.story.tsx +0 -415
  178. package/src/tools/VideoPlayer/VideoPlayer.story.tsx +0 -87
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import type * as React from 'react';
4
+
5
+ import { cn } from '@djangocfg/ui-core/lib';
6
+
7
+ import { DictationButton } from '../components/DictationButton';
8
+ import { ErrorBanner } from '../components/ErrorBanner';
9
+ import { MicMeter } from '../components/MicMeter';
10
+ import { PushToTalkHint } from '../components/PushToTalkHint';
11
+ import { useDictation } from '../hooks/useDictation';
12
+ import { usePushToTalk } from '../hooks/usePushToTalk';
13
+ import type { RecognitionEngine } from '../types';
14
+
15
+ export interface DictationFieldProps {
16
+ value: string;
17
+ onChange: (next: string) => void;
18
+ /** Custom engine. Defaults to Web Speech via `useSpeechRecognition`. */
19
+ engine?: RecognitionEngine;
20
+ /** Override the language stored in `useSpeechPrefs`. */
21
+ language?: string;
22
+ /** Push-to-talk chord (e.g. `'alt'`, `'mod+alt'`). Disabled when omitted. */
23
+ pushToTalk?: { key: string; enabled?: boolean };
24
+ placeholder?: string;
25
+ rows?: number;
26
+ disabled?: boolean;
27
+ /** Show the interim transcript as a ghost overlay below the textarea. */
28
+ showInterim?: boolean;
29
+ /** Show a small RMS meter inside the toolbar. */
30
+ showMeter?: boolean;
31
+ className?: string;
32
+ textareaClassName?: string;
33
+ }
34
+
35
+ /**
36
+ * Opinionated textarea + dictation button assembly. Final segments are
37
+ * appended to the controlled `value` automatically. Press-and-hold
38
+ * shortcut is optional; the mic button itself works as a toggle.
39
+ */
40
+ export function DictationField({
41
+ value,
42
+ onChange,
43
+ engine,
44
+ language,
45
+ pushToTalk,
46
+ placeholder = 'Type or press the mic to dictate…',
47
+ rows = 3,
48
+ disabled,
49
+ showInterim = true,
50
+ showMeter = true,
51
+ className,
52
+ textareaClassName,
53
+ }: DictationFieldProps): React.ReactElement {
54
+ const rec = useDictation({
55
+ value,
56
+ onChange,
57
+ engine,
58
+ language,
59
+ });
60
+
61
+ usePushToTalk(rec, {
62
+ key: pushToTalk?.key ?? 'alt',
63
+ enabled: !!pushToTalk && pushToTalk.enabled !== false,
64
+ });
65
+
66
+ return (
67
+ <div className={cn('flex flex-col gap-2', className)}>
68
+ <div className="relative">
69
+ <textarea
70
+ value={value}
71
+ onChange={(e) => onChange(e.target.value)}
72
+ placeholder={placeholder}
73
+ rows={rows}
74
+ disabled={disabled}
75
+ className={cn(
76
+ 'w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm',
77
+ 'placeholder:text-muted-foreground',
78
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
79
+ 'disabled:cursor-not-allowed disabled:opacity-50',
80
+ textareaClassName,
81
+ )}
82
+ />
83
+ {showInterim && rec.transcript.interim && (
84
+ <div className="pointer-events-none mt-1 text-xs italic text-muted-foreground">
85
+ … {rec.transcript.interim}
86
+ </div>
87
+ )}
88
+ </div>
89
+
90
+ <div className="flex items-center gap-2">
91
+ <DictationButton
92
+ status={rec.status}
93
+ isSupported={rec.isSupported}
94
+ onClick={() => void rec.toggleDictation()}
95
+ size="sm"
96
+ disabled={disabled}
97
+ />
98
+ {showMeter && <MicMeter level={rec.level} bars={10} height={20} />}
99
+ {pushToTalk && <PushToTalkHint chord={pushToTalk.key} className="ml-auto" />}
100
+ </div>
101
+
102
+ <ErrorBanner error={rec.error} />
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,305 @@
1
+ 'use client';
2
+
3
+ import type * as React from 'react';
4
+ import { useCallback, useEffect, useRef } from 'react';
5
+ import { Loader2, Mic } from 'lucide-react';
6
+
7
+ import { useCountdownFromSeconds, useNotificationSounds } from '@djangocfg/ui-core/hooks';
8
+ import { cn } from '@djangocfg/ui-core/lib';
9
+
10
+ import { useChatContextOptional } from '../../Chat/context';
11
+ import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
12
+ import { useVoiceSupport } from '../hooks/useVoiceSupport';
13
+ import { normaliseFinal } from '../core/transcript';
14
+ import { DEFAULT_VOICE_SOUNDS, type VoiceSoundEvent } from '../core/audio/defaults';
15
+ import type { RecognitionEngine } from '../types';
16
+
17
+ export interface VoiceComposerSlotProps {
18
+ /**
19
+ * Controlled composer value. Optional — when omitted, the slot
20
+ * reads/writes through the Chat context's registered `ComposerHandle`
21
+ * (built-in `<Composer>` and any host that calls
22
+ * `useRegisterComposer({ getValue, setValue })`). Pass explicitly
23
+ * only for standalone usage outside a `<ChatProvider>`.
24
+ */
25
+ value?: string;
26
+ /** Composer setter — see `value`. Optional when used inside a chat. */
27
+ onChange?: (next: string) => void;
28
+ /** Optional custom engine (Deepgram / HTTP / WS). Defaults to Web Speech. */
29
+ engine?: RecognitionEngine;
30
+ /** BCP-47 language override. Otherwise `useSpeechPrefs` decides. */
31
+ language?: string;
32
+ /** Hide the button if the host wants to disable voice on phones. */
33
+ hideOnMobile?: boolean;
34
+ /** Max session length in seconds before we auto-stop. Default 90. */
35
+ maxSeconds?: number;
36
+ /** Auto-stop after this many ms of silence. Default 2500. */
37
+ silenceMs?: number;
38
+ /** Button size. @default 'md' */
39
+ size?: 'sm' | 'md' | 'lg';
40
+ /** Override classes on the button. */
41
+ className?: string;
42
+ /** Fires when dictation finishes — useful for parent-side analytics. */
43
+ onFinish?: (transcript: string) => void;
44
+ /**
45
+ * Start/stop earcons. `true` (default) plays the bundled sounds,
46
+ * `false` disables them, or pass `{ start, stop }` data-URLs to
47
+ * override. Master mute lives in `useNotificationSounds` localStorage
48
+ * — users can silence everything via their own UI.
49
+ */
50
+ sounds?: boolean | { start?: string; stop?: string };
51
+ }
52
+
53
+ const SIZE_CLS: Record<NonNullable<VoiceComposerSlotProps['size']>, string> = {
54
+ sm: 'h-8 w-8 [&_svg]:h-4 [&_svg]:w-4',
55
+ md: 'h-9 w-9 [&_svg]:h-4 [&_svg]:w-4',
56
+ lg: 'h-12 w-12 [&_svg]:h-5 [&_svg]:w-5',
57
+ };
58
+
59
+ const STORAGE_KEY = 'djangocfg-stt:voice-sounds';
60
+
61
+ /**
62
+ * Drop-in slot for the `<Composer toolbarEnd>` (or `toolbarStart`) prop.
63
+ *
64
+ * Renders a microphone button — but only when the browser + device
65
+ * combination can actually do speech recognition. Firefox, in-app
66
+ * WebViews, and missing `getUserMedia` all collapse the component to
67
+ * `null`, so the chat composer never shows a broken affordance.
68
+ *
69
+ * While listening, interim+final transcript is pushed live into the
70
+ * composer's `value` — like dictation on iOS / Android keyboards. The
71
+ * user's already-typed prefix is preserved (anchored on press). Cancel
72
+ * with the same button or by pressing Escape (handled upstream).
73
+ *
74
+ * Soft start/stop earcons play by default. Pass `sounds={false}` to
75
+ * mute, or `sounds={{ start, stop }}` to override the audio URLs.
76
+ */
77
+ export function VoiceComposerSlot({
78
+ value,
79
+ onChange,
80
+ engine,
81
+ language,
82
+ hideOnMobile = false,
83
+ maxSeconds = 90,
84
+ silenceMs = 2500,
85
+ size = 'md',
86
+ className,
87
+ onFinish,
88
+ sounds = true,
89
+ }: VoiceComposerSlotProps): React.ReactElement | null {
90
+ const support = useVoiceSupport(engine);
91
+
92
+ // Read the composer handle from chat context — works transparently
93
+ // for the built-in `<Composer>` (registers itself) and for TipTap
94
+ // hosts that call `useRegisterComposer({ getValue, setValue, focus,
95
+ // moveCursorToEnd })`. Falls back to a no-op when mounted outside of
96
+ // a chat.
97
+ const chatCtx = useChatContextOptional();
98
+ const composerHandleRef = useRef(chatCtx?.composer ?? null);
99
+ composerHandleRef.current = chatCtx?.composer ?? null;
100
+
101
+ // Resolve value/onChange: prop wins; otherwise pull from the
102
+ // registered composer handle. The slot can therefore be dropped into
103
+ // `composerToolbarEnd` of `ChatRoot` with zero props.
104
+ const resolvedGetValue = useCallback((): string => {
105
+ if (value !== undefined) return value;
106
+ return composerHandleRef.current?.getValue?.() ?? '';
107
+ }, [value]);
108
+ const resolvedSetValue = useCallback(
109
+ (next: string): void => {
110
+ if (onChange) {
111
+ onChange(next);
112
+ return;
113
+ }
114
+ composerHandleRef.current?.setValue?.(next);
115
+ },
116
+ [onChange],
117
+ );
118
+
119
+ // Anchor: what was already in the textarea when the user pressed the
120
+ // mic. Live transcript is appended to this baseline so manual typing
121
+ // before pressing the button is never overwritten.
122
+ const anchorRef = useRef<string>('');
123
+ const onFinishRef = useRef(onFinish);
124
+ onFinishRef.current = onFinish;
125
+
126
+ // Push caret to the end on the next frame — after React commits the
127
+ // new `value` into the DOM. Without the rAF the selection lands on
128
+ // the old text length, leaving the cursor mid-string while the live
129
+ // transcript visually keeps growing.
130
+ const pinCaretToEnd = useCallback(() => {
131
+ const handle = composerHandleRef.current;
132
+ if (!handle?.moveCursorToEnd) return;
133
+ requestAnimationFrame(() => {
134
+ composerHandleRef.current?.moveCursorToEnd?.();
135
+ });
136
+ }, []);
137
+
138
+ const [countdown, startCountdown] = useCountdownFromSeconds();
139
+
140
+ // Earcon bus. `sounds === false` → pass empty map so the bus stays
141
+ // silent. Object overrides merge with bundled defaults.
142
+ const soundMap =
143
+ sounds === false
144
+ ? undefined
145
+ : sounds === true
146
+ ? DEFAULT_VOICE_SOUNDS
147
+ : { ...DEFAULT_VOICE_SOUNDS, ...sounds };
148
+ const audio = useNotificationSounds<VoiceSoundEvent>({
149
+ storageKey: STORAGE_KEY,
150
+ sounds: soundMap,
151
+ muted: sounds === false,
152
+ // Both earcons stay deliberately quiet — they're self-initiated
153
+ // micro-confirmations, not notifications. Anything louder feels
154
+ // attention-grabbing when the user pressed the button themselves.
155
+ eventVolumes: { start: 0.35, stop: 0.5 },
156
+ });
157
+
158
+ const handlePartial = useCallback(
159
+ (text: string) => {
160
+ const next = anchorRef.current
161
+ ? `${anchorRef.current} ${text}`
162
+ : text;
163
+ resolvedSetValue(next);
164
+ pinCaretToEnd();
165
+ },
166
+ [pinCaretToEnd, resolvedSetValue],
167
+ );
168
+
169
+ const handleFinal = useCallback(
170
+ (text: string) => {
171
+ const clean = normaliseFinal(text);
172
+ if (!clean) return;
173
+ const merged = anchorRef.current ? `${anchorRef.current} ${clean}` : clean;
174
+ anchorRef.current = merged;
175
+ resolvedSetValue(merged);
176
+ pinCaretToEnd();
177
+ },
178
+ [pinCaretToEnd, resolvedSetValue],
179
+ );
180
+
181
+ const rec = useSpeechRecognition({
182
+ engine,
183
+ language,
184
+ interim: true,
185
+ autoStop: { silenceMs, maxMs: maxSeconds * 1000 },
186
+ onPartial: (text) => handlePartial(text),
187
+ onFinal: (text) => handleFinal(text),
188
+ onStart: () => {
189
+ void audio.play('start');
190
+ // Focus the composer + park caret at the end so the live
191
+ // transcript visibly grows where the user expects it to.
192
+ composerHandleRef.current?.focus();
193
+ pinCaretToEnd();
194
+ },
195
+ onStop: () => {
196
+ void audio.play('stop');
197
+ // Re-focus on stop too — auto-stop on silence happens without
198
+ // a user gesture, and we want the user to keep typing seamlessly.
199
+ composerHandleRef.current?.focus();
200
+ pinCaretToEnd();
201
+ onFinishRef.current?.(resolvedGetValue());
202
+ },
203
+ });
204
+
205
+ // Drive the countdown alongside the listening state.
206
+ useEffect(() => {
207
+ if (rec.status === 'listening') {
208
+ startCountdown(maxSeconds);
209
+ }
210
+ }, [rec.status, maxSeconds, startCountdown]);
211
+
212
+ // Hotkeys while listening:
213
+ // Esc — cancel dictation (and stop event propagation so the
214
+ // outer chat doesn't *also* close — same convention as
215
+ // ChatGPT / Slack voice mode).
216
+ // Enter — finish dictation, KEEP what we already pushed into the
217
+ // composer, do NOT submit the chat (avoids accidental
218
+ // sends while the user is still talking). We block the
219
+ // Enter that would otherwise reach the composer textarea
220
+ // by listening in the capture phase.
221
+ const listening = rec.status === 'listening' || rec.status === 'starting';
222
+ useEffect(() => {
223
+ if (!listening) return undefined;
224
+ const onKey = (e: KeyboardEvent): void => {
225
+ if (e.key === 'Escape') {
226
+ e.preventDefault();
227
+ e.stopPropagation();
228
+ rec.abort();
229
+ return;
230
+ }
231
+ if (e.key === 'Enter' && !e.shiftKey) {
232
+ // Block the chat composer's "Enter to send" while we're
233
+ // dictating — finish recording instead.
234
+ e.preventDefault();
235
+ e.stopPropagation();
236
+ void rec.stop();
237
+ }
238
+ };
239
+ // `capture: true` so we run before the composer textarea's
240
+ // keydown handler (which would otherwise close the chat on Esc
241
+ // or submit the form on Enter).
242
+ window.addEventListener('keydown', onKey, true);
243
+ return () => {
244
+ window.removeEventListener('keydown', onKey, true);
245
+ };
246
+ }, [listening, rec]);
247
+
248
+ const toggle = useCallback(() => {
249
+ if (rec.status === 'listening' || rec.status === 'starting') {
250
+ void rec.stop();
251
+ return;
252
+ }
253
+ anchorRef.current = resolvedGetValue().trim();
254
+ void rec.start();
255
+ }, [rec, resolvedGetValue]);
256
+
257
+ if (!support.supported) return null;
258
+ if (hideOnMobile && support.isMobile) return null;
259
+
260
+ const stopping = rec.status === 'stopping';
261
+
262
+ // Tooltip: countdown + hotkey hint while listening, plain copy
263
+ // otherwise. Avoids the absolutely-positioned label that clipped
264
+ // against the composer bottom edge in the previous design.
265
+ const tooltip = listening
266
+ ? `Listening — ${countdown.label || `${maxSeconds}s left`} · Enter to finish · Esc to cancel`
267
+ : 'Dictate message';
268
+
269
+ return (
270
+ <span className="inline-flex items-center gap-1.5 !h-auto">
271
+ {listening && countdown.label ? (
272
+ <span
273
+ aria-hidden
274
+ className="rounded-full bg-destructive/10 px-1.5 py-0.5 font-mono text-[10px] leading-none text-destructive tabular-nums"
275
+ >
276
+ {countdown.label}
277
+ </span>
278
+ ) : null}
279
+ <button
280
+ type="button"
281
+ onClick={toggle}
282
+ aria-pressed={listening}
283
+ aria-label={listening ? 'Stop dictation' : 'Dictate message'}
284
+ title={tooltip}
285
+ className={cn(
286
+ 'relative inline-flex items-center justify-center rounded-full transition-colors',
287
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
288
+ SIZE_CLS[size],
289
+ listening
290
+ ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
291
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
292
+ className,
293
+ )}
294
+ >
295
+ {listening && (
296
+ <span
297
+ aria-hidden
298
+ className="absolute inset-0 rounded-full bg-destructive/30 animate-ping"
299
+ />
300
+ )}
301
+ {stopping ? <Loader2 className="animate-spin" /> : <Mic />}
302
+ </button>
303
+ </span>
304
+ );
305
+ }
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import type * as React from 'react';
4
+ import { useEffect } from 'react';
5
+
6
+ import { cn } from '@djangocfg/ui-core/lib';
7
+
8
+ import { DictationButton } from '../components/DictationButton';
9
+ import { ErrorBanner } from '../components/ErrorBanner';
10
+ import { MicMeter } from '../components/MicMeter';
11
+ import { TranscriptView } from '../components/TranscriptView';
12
+ import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
13
+ import type { RecognitionEngine, Segment } from '../types';
14
+
15
+ export interface VoiceMessageRecorderProps {
16
+ /** Called once when the user stops, with the full final transcript. */
17
+ onSubmit: (text: string, segments: Segment[]) => void;
18
+ onCancel?: () => void;
19
+ engine?: RecognitionEngine;
20
+ language?: string;
21
+ /** Auto-stop after this many ms of silence. Default 1500. */
22
+ silenceMs?: number;
23
+ /** Hard cap on session length. Default 60000 ms. */
24
+ maxMs?: number;
25
+ className?: string;
26
+ }
27
+
28
+ /**
29
+ * Press-to-record assembly for "voice memo" UIs. Records until the user
30
+ * presses stop or silence-detection fires, then emits the full final
31
+ * transcript via `onSubmit`. Pair with a chat composer or any send-button
32
+ * flow.
33
+ */
34
+ export function VoiceMessageRecorder({
35
+ onSubmit,
36
+ onCancel,
37
+ engine,
38
+ language,
39
+ silenceMs = 1500,
40
+ maxMs = 60000,
41
+ className,
42
+ }: VoiceMessageRecorderProps): React.ReactElement {
43
+ const rec = useSpeechRecognition({
44
+ engine,
45
+ language,
46
+ autoStop: { silenceMs, maxMs },
47
+ });
48
+
49
+ // Auto-submit on transition out of listening — fires once the engine
50
+ // finalises and closes. Reducer resets after.
51
+ useEffect(() => {
52
+ if (rec.status !== 'idle') return;
53
+ if (!rec.transcript.final) return;
54
+ onSubmit(rec.transcript.final, rec.transcript.segments);
55
+ rec.reset();
56
+ // run only when status flips to idle
57
+ }, [rec.status, rec.transcript.final, rec.transcript.segments, onSubmit, rec.reset]);
58
+
59
+ return (
60
+ <div
61
+ className={cn(
62
+ 'flex w-full flex-col gap-3 rounded-lg border border-border bg-card p-3',
63
+ className,
64
+ )}
65
+ >
66
+ <div className="flex items-center gap-3">
67
+ <DictationButton
68
+ status={rec.status}
69
+ isSupported={rec.isSupported}
70
+ onClick={() => void rec.toggle()}
71
+ size="lg"
72
+ />
73
+ <MicMeter level={rec.level} bars={14} height={28} className="flex-1" />
74
+ {onCancel && (
75
+ <button
76
+ type="button"
77
+ onClick={onCancel}
78
+ className="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
79
+ >
80
+ Cancel
81
+ </button>
82
+ )}
83
+ </div>
84
+ <TranscriptView transcript={rec.transcript} />
85
+ <ErrorBanner error={rec.error} />
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,6 @@
1
+ export { DictationField } from './DictationField';
2
+ export type { DictationFieldProps } from './DictationField';
3
+ export { VoiceMessageRecorder } from './VoiceMessageRecorder';
4
+ export type { VoiceMessageRecorderProps } from './VoiceMessageRecorder';
5
+ export { VoiceComposerSlot } from './VoiceComposerSlot';
6
+ export type { VoiceComposerSlotProps } from './VoiceComposerSlot';
@@ -1,14 +0,0 @@
1
- 'use strict';
2
-
3
- var chunkSI5RD2GD_cjs = require('./chunk-SI5RD2GD.cjs');
4
- require('./chunk-XACCHZH2.cjs');
5
- require('./chunk-OLISEQHS.cjs');
6
-
7
-
8
-
9
- Object.defineProperty(exports, "ChatRoot", {
10
- enumerable: true,
11
- get: function () { return chunkSI5RD2GD_cjs.ChatRoot; }
12
- });
13
- //# sourceMappingURL=ChatRoot-EJC5Y2YM.cjs.map
14
- //# sourceMappingURL=ChatRoot-EJC5Y2YM.cjs.map
@@ -1,5 +0,0 @@
1
- export { ChatRoot } from './chunk-QLMKCSR6.mjs';
2
- import './chunk-NWUT327A.mjs';
3
- import './chunk-N2XQF2OL.mjs';
4
- //# sourceMappingURL=ChatRoot-QOSKJPM6.mjs.map
5
- //# sourceMappingURL=ChatRoot-QOSKJPM6.mjs.map