@djangocfg/ui-tools 2.1.381 → 2.1.382

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 (183) 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-2ZLQWLYV.mjs +4 -0
  7. package/dist/DictationField-2ZLQWLYV.mjs.map +1 -0
  8. package/dist/DictationField-IPPJ54CU.cjs +13 -0
  9. package/dist/DictationField-IPPJ54CU.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-4LXG3NBV.mjs +833 -0
  15. package/dist/chunk-4LXG3NBV.mjs.map +1 -0
  16. package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
  17. package/dist/chunk-FIRK5CEH.cjs.map +1 -0
  18. package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
  19. package/dist/chunk-HIK6BPL7.mjs.map +1 -0
  20. package/dist/chunk-KMSBGNVC.cjs +835 -0
  21. package/dist/chunk-KMSBGNVC.cjs.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 +1532 -100
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +1148 -107
  29. package/dist/index.d.ts +1148 -107
  30. package/dist/index.mjs +1421 -51
  31. package/dist/index.mjs.map +1 -1
  32. package/package.json +16 -8
  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/stories/index.ts +32 -2
  37. package/src/tools/Chat/README.md +347 -530
  38. package/src/tools/Chat/components/Attachments.tsx +6 -1
  39. package/src/tools/Chat/components/ChatRoot.tsx +30 -2
  40. package/src/tools/Chat/components/Composer.tsx +20 -3
  41. package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
  42. package/src/tools/Chat/components/MessageActions.tsx +3 -1
  43. package/src/tools/Chat/components/MessageBubble.tsx +6 -5
  44. package/src/tools/Chat/components/MessageList.tsx +87 -1
  45. package/src/tools/Chat/components/ToolCalls.tsx +21 -3
  46. package/src/tools/Chat/context/ChatProvider.tsx +21 -3
  47. package/src/tools/Chat/core/audio/audioBus.ts +10 -163
  48. package/src/tools/Chat/core/audio/defaults.ts +43 -0
  49. package/src/tools/Chat/core/audio/index.ts +1 -0
  50. package/src/tools/Chat/core/audio/preferences.ts +5 -59
  51. package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
  52. package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
  53. package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
  54. package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
  55. package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
  56. package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
  57. package/src/tools/Chat/core/audio/types.ts +28 -0
  58. package/src/tools/Chat/core/reducer.ts +33 -0
  59. package/src/tools/Chat/core/transport/index.ts +13 -0
  60. package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
  61. package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
  62. package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
  63. package/src/tools/Chat/core/transport/sse.ts +18 -5
  64. package/src/tools/Chat/hooks/index.ts +25 -0
  65. package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
  66. package/src/tools/Chat/hooks/useChat.ts +28 -0
  67. package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
  68. package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
  69. package/src/tools/Chat/hooks/useChatReset.ts +70 -0
  70. package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
  71. package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
  72. package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
  73. package/src/tools/Chat/index.ts +69 -1
  74. package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
  75. package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
  76. package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
  77. package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
  78. package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
  79. package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
  80. package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
  81. package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
  82. package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
  83. package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
  84. package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
  85. package/src/tools/Chat/launcher/index.ts +46 -0
  86. package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
  87. package/src/tools/Chat/stories/01-basic.story.tsx +64 -0
  88. package/src/tools/Chat/stories/02-bubbles.story.tsx +21 -0
  89. package/src/tools/Chat/stories/03-tool-calls.story.tsx +59 -0
  90. package/src/tools/Chat/stories/04-personas.story.tsx +78 -0
  91. package/src/tools/Chat/stories/05-launcher.story.tsx +321 -0
  92. package/src/tools/Chat/stories/06-header.story.tsx +147 -0
  93. package/src/tools/Chat/stories/07-audio-actions.story.tsx +112 -0
  94. package/src/tools/Chat/stories/shared/Frame.tsx +21 -0
  95. package/src/tools/Chat/stories/shared/index.ts +5 -0
  96. package/src/tools/Chat/stories/shared/messages.ts +39 -0
  97. package/src/tools/Chat/stories/shared/personas.ts +13 -0
  98. package/src/tools/Chat/stories/shared/seeds.ts +92 -0
  99. package/src/tools/Chat/stories/shared/transports.ts +36 -0
  100. package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
  101. package/src/tools/Chat/styles/index.ts +16 -0
  102. package/src/tools/Chat/styles/useChatStyles.ts +101 -0
  103. package/src/tools/Chat/types/attachment.ts +25 -0
  104. package/src/tools/Chat/types/config.ts +48 -0
  105. package/src/tools/Chat/types/events.ts +35 -0
  106. package/src/tools/Chat/types/index.ts +34 -0
  107. package/src/tools/Chat/types/labels.ts +38 -0
  108. package/src/tools/Chat/types/message.ts +32 -0
  109. package/src/tools/Chat/types/persona.ts +31 -0
  110. package/src/tools/Chat/types/session.ts +43 -0
  111. package/src/tools/Chat/types/tool-call.ts +17 -0
  112. package/src/tools/Chat/types/transport.ts +28 -0
  113. package/src/tools/Chat/types.ts +5 -240
  114. package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
  115. package/src/tools/MarkdownEditor/index.ts +1 -1
  116. package/src/tools/SpeechRecognition/README.md +336 -0
  117. package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
  118. package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
  119. package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
  120. package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
  121. package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
  122. package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
  123. package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
  124. package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
  125. package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
  126. package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
  127. package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
  128. package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
  129. package/src/tools/SpeechRecognition/components/index.ts +16 -0
  130. package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
  131. package/src/tools/SpeechRecognition/context/index.ts +6 -0
  132. package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
  133. package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
  134. package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
  135. package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
  136. package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
  137. package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
  138. package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
  139. package/src/tools/SpeechRecognition/core/ids.ts +11 -0
  140. package/src/tools/SpeechRecognition/core/index.ts +14 -0
  141. package/src/tools/SpeechRecognition/core/language.ts +78 -0
  142. package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
  143. package/src/tools/SpeechRecognition/core/logger.ts +3 -0
  144. package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
  145. package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
  146. package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
  147. package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
  148. package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
  149. package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
  150. package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
  151. package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
  152. package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
  153. package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
  154. package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
  155. package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
  156. package/src/tools/SpeechRecognition/index.ts +82 -0
  157. package/src/tools/SpeechRecognition/lazy.tsx +19 -0
  158. package/src/tools/SpeechRecognition/store/index.ts +2 -0
  159. package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
  160. package/src/tools/SpeechRecognition/stories/01-basic.story.tsx +32 -0
  161. package/src/tools/SpeechRecognition/stories/02-dictation-field.story.tsx +32 -0
  162. package/src/tools/SpeechRecognition/stories/03-push-to-talk.story.tsx +27 -0
  163. package/src/tools/SpeechRecognition/stories/04-mic-meter.story.tsx +35 -0
  164. package/src/tools/SpeechRecognition/stories/05-custom-engine-http.story.tsx +40 -0
  165. package/src/tools/SpeechRecognition/stories/06-custom-engine-ws.story.tsx +48 -0
  166. package/src/tools/SpeechRecognition/stories/07-language-device.story.tsx +57 -0
  167. package/src/tools/SpeechRecognition/stories/08-errors-permissions.story.tsx +25 -0
  168. package/src/tools/SpeechRecognition/stories/09-chat-voice.story.tsx +90 -0
  169. package/src/tools/SpeechRecognition/stories/shared.tsx +123 -0
  170. package/src/tools/SpeechRecognition/types.ts +133 -0
  171. package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
  172. package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
  173. package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
  174. package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
  175. package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
  176. package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
  177. package/dist/chunk-NWUT327A.mjs.map +0 -1
  178. package/dist/chunk-QLMKCSR6.mjs +0 -2420
  179. package/dist/chunk-QLMKCSR6.mjs.map +0 -1
  180. package/dist/chunk-SI5RD2GD.cjs +0 -2460
  181. package/dist/chunk-SI5RD2GD.cjs.map +0 -1
  182. package/dist/chunk-XACCHZH2.cjs.map +0 -1
  183. package/src/tools/Chat/Chat.story.tsx +0 -1457
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+
3
+ import { forwardRef } from 'react';
4
+ import type { ButtonHTMLAttributes, ReactNode } from 'react';
5
+
6
+ import { cn } from '@djangocfg/ui-core/lib';
7
+
8
+ export interface ChatHeaderActionButtonProps
9
+ extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
10
+ /** Icon (required). */
11
+ icon: ReactNode;
12
+ /** Accessible label + native tooltip. */
13
+ ariaLabel: string;
14
+ /** Optional unread / status badge — small number on top-right. */
15
+ badge?: number;
16
+ /** Mark as destructive — uses destructive hover tokens. */
17
+ destructive?: boolean;
18
+ /** Optional visual loading state (e.g. while reset is in flight). */
19
+ loading?: boolean;
20
+ }
21
+
22
+ /**
23
+ * Compact icon button for the chat header actions slot.
24
+ *
25
+ * Standard chrome: 28×28 ghost button, hover bg-accent, focus ring, optional
26
+ * destructive variant, optional numeric badge for unread / pending.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * <ChatHeader
31
+ * title="Assistant"
32
+ * onClose={close}
33
+ * actions={
34
+ * <>
35
+ * <ChatHeaderActionButton
36
+ * icon={<RotateCcw className="h-3.5 w-3.5" />}
37
+ * ariaLabel="Clear context"
38
+ * onClick={handleReset}
39
+ * loading={isResetting}
40
+ * />
41
+ * <ChatHeaderActionButton
42
+ * icon={<Settings className="h-3.5 w-3.5" />}
43
+ * ariaLabel="Settings"
44
+ * onClick={openSettings}
45
+ * />
46
+ * </>
47
+ * }
48
+ * />
49
+ * ```
50
+ */
51
+ export const ChatHeaderActionButton = forwardRef<HTMLButtonElement, ChatHeaderActionButtonProps>(
52
+ function ChatHeaderActionButton(
53
+ { icon, ariaLabel, badge, destructive, loading, disabled, className, ...rest },
54
+ ref,
55
+ ) {
56
+ return (
57
+ <button
58
+ ref={ref}
59
+ type="button"
60
+ aria-label={ariaLabel}
61
+ title={ariaLabel}
62
+ disabled={disabled || loading}
63
+ className={cn(
64
+ 'relative inline-flex h-7 w-7 items-center justify-center rounded-md',
65
+ 'text-muted-foreground transition-colors',
66
+ 'hover:bg-accent hover:text-foreground',
67
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
68
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
69
+ destructive && 'hover:bg-destructive/15 hover:text-destructive',
70
+ loading && 'animate-pulse',
71
+ className,
72
+ )}
73
+ {...rest}
74
+ >
75
+ {icon}
76
+ {badge !== undefined && (
77
+ <span
78
+ aria-hidden="true"
79
+ className="absolute -right-0.5 -top-0.5 inline-flex min-w-[14px] h-[14px] items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold leading-none text-destructive-foreground ring-2 ring-background"
80
+ >
81
+ {badge > 9 ? '9+' : badge}
82
+ </span>
83
+ )}
84
+ </button>
85
+ );
86
+ },
87
+ );
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import { Volume2, VolumeX } from 'lucide-react';
4
+
5
+ import { ChatHeaderActionButton } from './ChatHeaderActionButton';
6
+
7
+ export interface ChatHeaderAudioToggleProps {
8
+ /** Current muted state. */
9
+ muted: boolean;
10
+ /** Toggle handler. Wire to `useChatAudio().setMuted` or `toggleMute`. */
11
+ onToggle: () => void;
12
+ /** Override tooltip label for muted → unmuted. */
13
+ unmuteLabel?: string;
14
+ /** Override tooltip label for unmuted → muted. */
15
+ muteLabel?: string;
16
+ }
17
+
18
+ /**
19
+ * Mute / unmute notification sounds. Drop into a `<ChatHeader>` actions
20
+ * slot or into `<ChatDock headerActions>`.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * const audio = useChatAudio({ sounds: {...} });
25
+ * <ChatHeaderAudioToggle muted={audio.muted} onToggle={() => audio.setMuted(!audio.muted)} />
26
+ * ```
27
+ */
28
+ export function ChatHeaderAudioToggle({
29
+ muted,
30
+ onToggle,
31
+ unmuteLabel = 'Unmute notifications',
32
+ muteLabel = 'Mute notifications',
33
+ }: ChatHeaderAudioToggleProps) {
34
+ return (
35
+ <ChatHeaderActionButton
36
+ icon={
37
+ muted ? (
38
+ <VolumeX className="h-3.5 w-3.5" />
39
+ ) : (
40
+ <Volume2 className="h-3.5 w-3.5" />
41
+ )
42
+ }
43
+ ariaLabel={muted ? unmuteLabel : muteLabel}
44
+ onClick={onToggle}
45
+ />
46
+ );
47
+ }
@@ -0,0 +1,179 @@
1
+ 'use client';
2
+
3
+ import type * as React from 'react';
4
+ import { useMemo } from 'react';
5
+ import { Globe } from 'lucide-react';
6
+
7
+ import {
8
+ Combobox,
9
+ type ComboboxOption,
10
+ Flag,
11
+ } from '@djangocfg/ui-core/components';
12
+ import { cn } from '@djangocfg/ui-core/lib';
13
+
14
+ import { findSpeechLanguage } from '../../SpeechRecognition';
15
+
16
+ import {
17
+ WEB_SPEECH_LANGUAGES,
18
+ countryFromTag,
19
+ useResolvedLanguage,
20
+ useSpeechPrefs,
21
+ } from '../../SpeechRecognition';
22
+
23
+ export interface ChatHeaderLanguageButtonProps {
24
+ /** Override aria-label. Default "Speech language". */
25
+ ariaLabel?: string;
26
+ /**
27
+ * Subset of BCP-47 tags to offer. Default: every entry from the
28
+ * Web Speech catalogue (~66 tags incl. regional variants). Pass a
29
+ * tighter list when your backend STT only supports a subset.
30
+ */
31
+ allowedTags?: string[];
32
+ /** Hide the globe-fallback icon when no flag resolves. */
33
+ hideFallbackIcon?: boolean;
34
+ className?: string;
35
+ }
36
+
37
+ /**
38
+ * Compact flag-button language picker for the chat header. Built on
39
+ * top of the ui-core `<Combobox>` — searchable autocomplete with
40
+ * flags, ~66 BCP-47 tags from the official Chrome Web Speech demo, and
41
+ * a custom 28×28 trigger via `renderTrigger`.
42
+ *
43
+ * The selection persists into `useSpeechPrefs` (zustand+localStorage)
44
+ * so `useSpeechRecognition` picks it up as the second-priority resolver
45
+ * value (above i18n locale, below an explicit `language` prop).
46
+ */
47
+ export function ChatHeaderLanguageButton({
48
+ ariaLabel = 'Speech language',
49
+ allowedTags,
50
+ hideFallbackIcon,
51
+ className,
52
+ }: ChatHeaderLanguageButtonProps): React.ReactElement {
53
+ const prefs = useSpeechPrefs();
54
+ const active = useResolvedLanguage();
55
+
56
+ // Flatten every dialect into one Combobox option. Display name keeps
57
+ // the native language label; we stash the English aliases + BCP-47
58
+ // tag + region in `description` purely as a hidden search index.
59
+ // (We intentionally hide the description from the rendered row in
60
+ // `renderOption` — it would just clutter the dropdown.)
61
+ const options = useMemo<ComboboxOption[]>(() => {
62
+ const allow = allowedTags ? new Set(allowedTags) : null;
63
+ const out: ComboboxOption[] = [];
64
+ for (const lang of WEB_SPEECH_LANGUAGES) {
65
+ for (const d of lang.dialects) {
66
+ if (allow && !allow.has(d.code)) continue;
67
+ out.push({
68
+ value: d.code,
69
+ // "Русский" / "Español — Argentina" / "English — United States"
70
+ label:
71
+ lang.dialects.length === 1
72
+ ? lang.name
73
+ : `${lang.name} — ${d.region}`,
74
+ // Search-only index: English name, BCP-47 tag, ISO, region.
75
+ // Lets users type "russian" / "ru-RU" / "ru" / "argentina"
76
+ // and still find the row regardless of native script.
77
+ description: `${lang.englishName} ${d.code} ${lang.iso} ${d.region}`.toLowerCase(),
78
+ });
79
+ }
80
+ }
81
+ return out;
82
+ }, [allowedTags]);
83
+
84
+ return (
85
+ <Combobox
86
+ options={options}
87
+ value={prefs.language ?? active}
88
+ onValueChange={(v) => prefs.setLanguage(v || null)}
89
+ placeholder={ariaLabel}
90
+ searchPlaceholder="Search language…"
91
+ filterFunction={(opt, search) => {
92
+ const s = search.toLowerCase();
93
+ // Match label (native script), value (BCP-47), and our packed
94
+ // search index in description (English name + tag + region).
95
+ return (
96
+ opt.label.toLowerCase().includes(s) ||
97
+ opt.value.toLowerCase().includes(s) ||
98
+ (opt.description?.includes(s) ?? false)
99
+ );
100
+ }}
101
+ // Popover width follows the trigger by default (28px → narrow).
102
+ // Force a usable width and bump z-index above ChatDock (z-10000).
103
+ contentClassName="w-[280px]"
104
+ contentStyle={{ zIndex: 10001 }}
105
+ // Custom row: country flag + native language label + BCP-47 tag.
106
+ // ui-core Combobox default rendering only shows label/description
107
+ // — feeding the flag here keeps every list row instantly
108
+ // identifiable. Fallback to a neutral globe glyph when the tag
109
+ // has no resolvable country (rare: bn-BD etc. all have flags).
110
+ renderOption={(option) => {
111
+ const country = countryFromTag(option.value);
112
+ return (
113
+ <div className="flex min-w-0 flex-1 items-center gap-2">
114
+ {country ? (
115
+ <Flag
116
+ countryCode={country}
117
+ className="h-4 w-5 shrink-0 overflow-hidden rounded-[2px] border border-border/60 ring-1 ring-black/5"
118
+ />
119
+ ) : (
120
+ <Globe className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
121
+ )}
122
+ {/* Native-script label only — BCP-47 subtitle removed to
123
+ keep the dropdown visually quiet. Tag + region still
124
+ live in `description` for search. */}
125
+ <span className="truncate text-sm">{option.label}</span>
126
+ </div>
127
+ );
128
+ }}
129
+ // Compact icon-only trigger — replaces the default wide outline
130
+ // button. Stays the same 28×28 footprint as ChatHeaderActionButton
131
+ // siblings (audio toggle, reset).
132
+ renderTrigger={(selected, open) => {
133
+ const tag = selected?.value ?? active;
134
+ const country = countryFromTag(tag);
135
+ const found = findSpeechLanguage(tag);
136
+ // Tooltip copy: native language name + dialect region + tag.
137
+ // Falls back to just the tag for unknown / custom-engine codes.
138
+ const tooltipLabel = found
139
+ ? `${found.language.name}${
140
+ found.language.dialects.length > 1 ? ` — ${found.dialect.region}` : ''
141
+ } · ${tag}`
142
+ : tag;
143
+ // Note on tooltip: we intentionally use the native `title`
144
+ // attribute instead of `<Tooltip>` here. The button is already
145
+ // wrapped by Combobox's `PopoverTrigger asChild`, and stacking
146
+ // a second `TooltipTrigger asChild` on the same node makes
147
+ // Radix Slot merge two competing refs/handlers — popover click
148
+ // stops working. Native `title` is "good enough" for a single
149
+ // status label and ships zero extra DOM.
150
+ return (
151
+ <button
152
+ type="button"
153
+ aria-label={`${ariaLabel}: ${tooltipLabel}`}
154
+ aria-expanded={open}
155
+ title={tooltipLabel}
156
+ className={cn(
157
+ 'inline-flex h-7 w-7 items-center justify-center rounded-md',
158
+ 'text-muted-foreground transition-colors',
159
+ 'hover:bg-accent hover:text-foreground',
160
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
161
+ className,
162
+ )}
163
+ >
164
+ {country ? (
165
+ <Flag
166
+ countryCode={country}
167
+ // Subtle hairline so flags with light edges (Japan,
168
+ // Switzerland, …) don't blend into the header bg.
169
+ className="h-4 w-5 overflow-hidden rounded-[2px] border border-border/60 ring-1 ring-black/5"
170
+ />
171
+ ) : hideFallbackIcon ? null : (
172
+ <Globe className="h-3.5 w-3.5" aria-hidden />
173
+ )}
174
+ </button>
175
+ );
176
+ }}
177
+ />
178
+ );
179
+ }
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+
3
+ import { PanelRightOpen, PanelRightClose } from 'lucide-react';
4
+
5
+ import { useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
6
+
7
+ import { ChatHeaderActionButton } from './ChatHeaderActionButton';
8
+ import type { ChatDockMode } from './ChatDock';
9
+
10
+ export interface ChatHeaderModeToggleProps {
11
+ /** Current dock mode. */
12
+ mode: ChatDockMode;
13
+ /** Toggle handler. Wire to `useChatDockPrefs().toggleMode`. */
14
+ onToggle: () => void;
15
+ /** Override aria/tooltip label for popover→side. */
16
+ expandLabel?: string;
17
+ /** Override aria/tooltip label for side→popover. */
18
+ collapseLabel?: string;
19
+ /**
20
+ * Always render — useful for stories. By default the toggle hides itself on
21
+ * viewports below `lg` (1024px) since side mode falls back to popover there.
22
+ */
23
+ forceVisible?: boolean;
24
+ }
25
+
26
+ /**
27
+ * "Dock to side" / "back to popover" toggle.
28
+ *
29
+ * Side mode is desktop-only — on viewports below `lg` (1024px) `<ChatDock>`
30
+ * silently falls back to popover anyway, so the toggle has nothing to do
31
+ * and auto-hides. Pass `forceVisible` to override (e.g. in stories).
32
+ */
33
+ export function ChatHeaderModeToggle({
34
+ mode,
35
+ onToggle,
36
+ expandLabel = 'Dock to side',
37
+ collapseLabel = 'Back to popover',
38
+ forceVisible = false,
39
+ }: ChatHeaderModeToggleProps) {
40
+ const isBelowDesktop = useIsTabletOrBelow();
41
+ if (isBelowDesktop && !forceVisible) return null;
42
+
43
+ const isSide = mode === 'side';
44
+ return (
45
+ <ChatHeaderActionButton
46
+ icon={
47
+ isSide ? (
48
+ <PanelRightClose className="h-3.5 w-3.5" />
49
+ ) : (
50
+ <PanelRightOpen className="h-3.5 w-3.5" />
51
+ )
52
+ }
53
+ ariaLabel={isSide ? collapseLabel : expandLabel}
54
+ onClick={onToggle}
55
+ />
56
+ );
57
+ }
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+
3
+ import { RotateCcw } from 'lucide-react';
4
+
5
+ import { useChatReset } from '../hooks/useChatReset';
6
+ import { ChatHeaderActionButton } from './ChatHeaderActionButton';
7
+
8
+ export interface ChatHeaderResetButtonProps {
9
+ /**
10
+ * Backend reset call. Should resolve to `true` on success.
11
+ * Plugged into `useChatReset` for in-flight state.
12
+ */
13
+ onReset: () => Promise<boolean>;
14
+ /** Called after a successful reset (e.g. clear local messages, refetch). */
15
+ onSuccess?: () => void;
16
+ /** Called when reset fails (returned `false` or threw). */
17
+ onError?: (error?: unknown) => void;
18
+ /**
19
+ * Show a `window.dialog.confirm` before calling `onReset`. @default true
20
+ *
21
+ * Requires the host to mount `<DialogProvider>` from `@djangocfg/ui-core`
22
+ * — it installs the `window.dialog` API used here.
23
+ */
24
+ confirm?: boolean;
25
+ /** Confirm dialog title. */
26
+ confirmTitle?: string;
27
+ /** Confirm dialog message. */
28
+ confirmMessage?: string;
29
+ /** Override tooltip / aria label. */
30
+ ariaLabel?: string;
31
+ }
32
+
33
+ const DEFAULT_TITLE = 'Clear conversation?';
34
+ const DEFAULT_MESSAGE =
35
+ 'The assistant will forget this session and start a new one. This cannot be undone.';
36
+ const DEFAULT_LABEL = 'Clear conversation';
37
+
38
+ /**
39
+ * Standard chat-reset action: prompts the user via `window.dialog.confirm`,
40
+ * then runs the backend reset call through `useChatReset` so the button
41
+ * spins while it's in flight.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <ChatHeaderResetButton
46
+ * onReset={api.clearChat}
47
+ * onSuccess={() => chat.clearMessages()}
48
+ * />
49
+ * ```
50
+ */
51
+ export function ChatHeaderResetButton({
52
+ onReset,
53
+ onSuccess,
54
+ onError,
55
+ confirm = true,
56
+ confirmTitle = DEFAULT_TITLE,
57
+ confirmMessage = DEFAULT_MESSAGE,
58
+ ariaLabel = DEFAULT_LABEL,
59
+ }: ChatHeaderResetButtonProps) {
60
+ const { reset, isResetting } = useChatReset({ onReset, onSuccess, onError });
61
+
62
+ const handleClick = async () => {
63
+ if (confirm) {
64
+ const api = typeof window !== 'undefined' ? window.dialog : undefined;
65
+ if (api?.confirm) {
66
+ const ok = await api.confirm({
67
+ title: confirmTitle,
68
+ message: confirmMessage,
69
+ variant: 'destructive',
70
+ confirmText: 'Clear',
71
+ cancelText: 'Cancel',
72
+ });
73
+ if (!ok) return;
74
+ } else if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
75
+ // Fallback to the native browser confirm when the dialog service
76
+ // isn't wired (e.g. host forgot to mount DialogProvider).
77
+ const ok = window.confirm(`${confirmTitle}\n\n${confirmMessage}`);
78
+ if (!ok) return;
79
+ }
80
+ }
81
+ await reset();
82
+ };
83
+
84
+ return (
85
+ <ChatHeaderActionButton
86
+ icon={<RotateCcw className="h-3.5 w-3.5" />}
87
+ ariaLabel={ariaLabel}
88
+ onClick={handleClick}
89
+ loading={isResetting}
90
+ destructive
91
+ />
92
+ );
93
+ }