@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
@@ -1,1457 +0,0 @@
1
- import { useMemo, useState } from 'react';
2
- import { Settings, Sparkles, Volume2, VolumeX } from 'lucide-react';
3
- import { defineStory, useBoolean, useSelect } from '@djangocfg/playground';
4
-
5
- import {
6
- Dialog,
7
- DialogContent,
8
- DialogHeader,
9
- DialogTitle,
10
- } from '@djangocfg/ui-core/components';
11
-
12
- import { LazyJsonTree } from '../JsonTree/lazy';
13
- import { LazyPlayer as LazyAudioPlayer } from '../AudioPlayer/lazy';
14
- import { LazyImageViewer } from '../ImageViewer/lazy';
15
-
16
- import { ChatRoot } from './components/ChatRoot';
17
- import { MessageBubble } from './components/MessageBubble';
18
- import { MessageList } from './components/MessageList';
19
- import { Composer } from './components/Composer';
20
- import { Sources } from './components/Sources';
21
- import { ToolCalls } from './components/ToolCalls';
22
- import { Attachments } from './components/Attachments';
23
- import { EmptyState } from './components/EmptyState';
24
- import { ErrorBanner } from './components/ErrorBanner';
25
- import { JumpToLatest } from './components/JumpToLatest';
26
- import { StreamingIndicator } from './components/StreamingIndicator';
27
-
28
- import { ChatProvider, useChatContext } from './context';
29
- import { useChatComposer } from './hooks/useChatComposer';
30
- import { useChatLightbox } from './hooks/useChatLightbox';
31
- import { useAutoFocusOnStreamEnd } from './hooks/useAutoFocusOnStreamEnd';
32
- import { createMockTransport } from './core/transport/mock';
33
- import { dispatchToolPayload, isLatLng, isPlainObject } from './core/payload-dispatch';
34
- import { collectImageAttachments } from './utils/collectImageAttachments';
35
- import type { ChatAttachment, ChatMessage, ChatStreamEvent } from './types';
36
- import type { ChatAudioSounds } from './core/audio/types';
37
-
38
- const CHAT_SOUNDS: ChatAudioSounds = {
39
- messageSent: '/audio/chat/sent.mp3',
40
- messageReceived: '/audio/chat/received.mp3',
41
- streamStart: '/audio/chat/start.mp3',
42
- error: '/audio/chat/error.mp3',
43
- mention: '/audio/chat/mention.mp3',
44
- notification: '/audio/chat/notification.mp3',
45
- };
46
-
47
- export default defineStory({
48
- title: 'Tools/Chat',
49
- component: ChatRoot,
50
- description:
51
- 'Decomposed, transport-agnostic chat. Markdown reuse, sticky scroll, streaming, tools, attachments.',
52
- });
53
-
54
- // ---------------------------------------------------------------------------
55
- // Frame helper — fixed-size container so the chat has somewhere to live.
56
- // ---------------------------------------------------------------------------
57
-
58
- function Frame({ children, h = 560, w = 480 }: { children: React.ReactNode; h?: number; w?: number }) {
59
- return (
60
- <div
61
- className="overflow-hidden rounded-lg border border-border bg-background shadow-sm"
62
- style={{ height: h, width: w }}
63
- >
64
- {children}
65
- </div>
66
- );
67
- }
68
-
69
- // ---------------------------------------------------------------------------
70
- // 1) Default — full ChatRoot with mock transport
71
- // ---------------------------------------------------------------------------
72
-
73
- export const Default = () => {
74
- const transport = useMemo(
75
- () =>
76
- createMockTransport({
77
- replies: [
78
- 'Hello! I am a mock assistant. Ask me anything — I will reply with scripted text.',
79
- '**Sure!** Here is a list:\n\n- alpha\n- beta\n- gamma\n\nAnd a snippet:\n\n```ts\nconst answer = 42;\n```',
80
- 'Streaming chunked text works too. Each token arrives separately and the UI sticks to the bottom while the stream is in flight.',
81
- ],
82
- latencyMs: 35,
83
- }),
84
- [],
85
- );
86
-
87
- return (
88
- <Frame>
89
- <ChatRoot
90
- transport={transport}
91
- config={{
92
- greeting: 'Hi there 👋',
93
- description: 'Try sending a message — replies are scripted by the mock transport.',
94
- placeholder: 'Ask anything…',
95
- suggestions: [
96
- { label: 'Show me a markdown reply', prompt: 'Give me a markdown sample' },
97
- { label: 'Explain streaming', prompt: 'How does streaming work here?' },
98
- ],
99
- }}
100
- />
101
- </Frame>
102
- );
103
- };
104
-
105
- // ---------------------------------------------------------------------------
106
- // 1b) ComposerSizes — sm / md / lg side-by-side
107
- // ---------------------------------------------------------------------------
108
-
109
- export const ComposerSizes = () => {
110
- const make = () =>
111
- createMockTransport({
112
- replies: ['I scale with the composer size — try `lg` on a primary surface.'],
113
- latencyMs: 25,
114
- });
115
- const cells: Array<{ size: 'sm' | 'md' | 'lg'; label: string }> = [
116
- { size: 'sm', label: 'sm — 32px (compact)' },
117
- { size: 'md', label: 'md — 36px (default)' },
118
- { size: 'lg', label: 'lg — 48px (primary surface)' },
119
- ];
120
- return (
121
- <div className="grid h-[640px] grid-cols-3 gap-3">
122
- {cells.map(({ size, label }) => (
123
- <Frame key={size} h={620}>
124
- <ChatRoot
125
- transport={make()}
126
- config={{
127
- greeting: label,
128
- placeholder: 'Type a message…',
129
- }}
130
- composerSize={size}
131
- />
132
- </Frame>
133
- ))}
134
- </div>
135
- );
136
- };
137
-
138
- // ---------------------------------------------------------------------------
139
- // 2) WithToolCalls — scripted tool invocations
140
- // ---------------------------------------------------------------------------
141
-
142
- export const WithToolCalls = () => {
143
- const transport = useMemo(() => {
144
- const sequence: ChatStreamEvent[] = [
145
- { type: 'chunk', delta: 'Let me check the docs for you.\n\n' },
146
- {
147
- type: 'tool_call_start',
148
- toolId: 't1',
149
- name: 'search_docs',
150
- input: { query: 'streaming tokens' },
151
- },
152
- { type: 'tool_call_delta', toolId: 't1', delta: 'Reading index…\n' },
153
- { type: 'tool_call_delta', toolId: 't1', delta: 'Matched 3 chunks.\n' },
154
- {
155
- type: 'tool_call_end',
156
- toolId: 't1',
157
- output: { hits: 3, top: 'streaming.md' },
158
- status: 'success',
159
- },
160
- { type: 'chunk', delta: 'Found 3 relevant chunks. Here is a summary…' },
161
- {
162
- type: 'message_end',
163
- sources: [
164
- { title: 'streaming.md', url: 'https://example.com/streaming', snippet: 'How streaming works' },
165
- { title: 'sse.md', url: 'https://example.com/sse', snippet: 'Server-sent events' },
166
- ],
167
- },
168
- ];
169
- return createMockTransport({ replies: [sequence], latencyMs: 60 });
170
- }, []);
171
-
172
- return (
173
- <Frame>
174
- <ChatRoot
175
- transport={transport}
176
- config={{ greeting: 'Tool calls demo', placeholder: 'Ask "search the docs"…' }}
177
- />
178
- </Frame>
179
- );
180
- };
181
-
182
- // ---------------------------------------------------------------------------
183
- // 3) Composition — bring your own layout, just hooks + parts
184
- // ---------------------------------------------------------------------------
185
-
186
- export const Composition = () => {
187
- const transport = useMemo(
188
- () => createMockTransport({ replies: ['Composed shell — full control over layout.'], latencyMs: 30 }),
189
- [],
190
- );
191
- return (
192
- <Frame h={520}>
193
- <ChatProvider transport={transport} config={{ placeholder: 'Type and press Enter…' }}>
194
- <CustomShell />
195
- </ChatProvider>
196
- </Frame>
197
- );
198
- };
199
-
200
- function CustomShell() {
201
- const chat = useChatContext();
202
- const composer = useChatComposer({
203
- onSubmit: (c, a) => chat.sendMessage(c, a),
204
- disabled: chat.isStreaming,
205
- });
206
- return (
207
- <div className="flex h-full flex-col">
208
- <header className="flex items-center justify-between border-b border-border bg-muted/40 px-3 py-2">
209
- <div className="flex items-center gap-2 text-xs">
210
- <span className="font-semibold">Custom shell</span>
211
- {chat.isStreaming ? <StreamingIndicator label="thinking…" /> : null}
212
- </div>
213
- <button
214
- type="button"
215
- onClick={() => void chat.newSession()}
216
- className="rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent"
217
- >
218
- New chat
219
- </button>
220
- </header>
221
- <ErrorBanner error={chat.error} onDismiss={() => chat.clearMessages()} />
222
- <MessageList
223
- renderEmpty={() => <EmptyState greeting="Bring your own layout" />}
224
- />
225
- <Composer composer={composer} />
226
- </div>
227
- );
228
- }
229
-
230
- // ---------------------------------------------------------------------------
231
- // 4) Bubbles — visual matrix of message states (no transport)
232
- // ---------------------------------------------------------------------------
233
-
234
- const sampleMessages: ChatMessage[] = [
235
- {
236
- id: 'u1',
237
- role: 'user',
238
- content: 'Quick user message',
239
- createdAt: Date.now() - 60_000,
240
- },
241
- {
242
- id: 'a1',
243
- role: 'assistant',
244
- content:
245
- '**Markdown** is supported.\n\n- bullet one\n- bullet two\n\n```js\nconsole.log("hi")\n```',
246
- createdAt: Date.now() - 50_000,
247
- sources: [
248
- { title: 'docs.md', url: 'https://example.com/docs' },
249
- { title: 'guide.md', url: 'https://example.com/guide' },
250
- ],
251
- },
252
- {
253
- id: 'a2',
254
- role: 'assistant',
255
- content: 'Streaming…',
256
- isStreaming: true,
257
- toolActivity: 'Searching the knowledge base…',
258
- createdAt: Date.now() - 30_000,
259
- },
260
- {
261
- id: 'a3',
262
- role: 'assistant',
263
- content: '',
264
- isError: true,
265
- createdAt: Date.now() - 20_000,
266
- },
267
- {
268
- id: 'a4',
269
- role: 'assistant',
270
- content: 'Here is the analysis.',
271
- createdAt: Date.now() - 10_000,
272
- toolCalls: [
273
- {
274
- id: 't1',
275
- name: 'fetch',
276
- input: { url: 'https://api.example.com' },
277
- output: { status: 200 },
278
- status: 'success',
279
- startedAt: Date.now() - 12_000,
280
- endedAt: Date.now() - 11_000,
281
- },
282
- {
283
- id: 't2',
284
- name: 'parse',
285
- input: { format: 'json' },
286
- streamingText: 'parsing…\nstep 1…\nstep 2…',
287
- status: 'running',
288
- startedAt: Date.now() - 9_000,
289
- },
290
- ],
291
- },
292
- ];
293
-
294
- export const Bubbles = () => (
295
- <Frame h={620}>
296
- <div className="h-full overflow-y-auto py-2">
297
- {sampleMessages.map((m) => (
298
- <MessageBubble key={m.id} message={m} showActions={false} />
299
- ))}
300
- </div>
301
- </Frame>
302
- );
303
-
304
- // ---------------------------------------------------------------------------
305
- // 5) Parts — each decomposed component on its own
306
- // ---------------------------------------------------------------------------
307
-
308
- export const Parts = () => (
309
- <div className="flex flex-col gap-6 p-3">
310
- <section className="space-y-2">
311
- <h3 className="text-xs font-semibold text-muted-foreground">StreamingIndicator</h3>
312
- <div className="flex items-center gap-4">
313
- <StreamingIndicator />
314
- <StreamingIndicator label="searching…" />
315
- <StreamingIndicator variant="pulse" label="thinking" />
316
- </div>
317
- </section>
318
- <section className="space-y-2">
319
- <h3 className="text-xs font-semibold text-muted-foreground">Sources</h3>
320
- <Sources
321
- sources={[
322
- { title: 'guide.md', url: 'https://example.com/guide', snippet: 'How to use the chat' },
323
- { title: 'api.md', url: 'https://example.com/api' },
324
- { title: 'faq.md', url: 'https://example.com/faq' },
325
- ]}
326
- />
327
- </section>
328
- <section className="space-y-2">
329
- <h3 className="text-xs font-semibold text-muted-foreground">
330
- ToolCalls (default <code className="font-mono">&lt;pre&gt;</code> renderer)
331
- </h3>
332
- <ToolCalls
333
- defaultExpanded
334
- calls={[
335
- {
336
- id: 'a',
337
- name: 'search',
338
- input: { q: 'react' },
339
- output: { hits: 7 },
340
- status: 'success',
341
- startedAt: 0,
342
- endedAt: 1,
343
- },
344
- {
345
- id: 'b',
346
- name: 'fetch',
347
- input: { url: '/api' },
348
- streamingText: 'connecting…',
349
- status: 'running',
350
- startedAt: 0,
351
- },
352
- {
353
- id: 'c',
354
- name: 'parse',
355
- input: {},
356
- output: 'syntax error',
357
- status: 'error',
358
- startedAt: 0,
359
- endedAt: 1,
360
- },
361
- ]}
362
- />
363
- </section>
364
-
365
- <section className="space-y-2">
366
- <h3 className="text-xs font-semibold text-muted-foreground">
367
- ToolCalls + LazyJsonTree payloads (compact mode)
368
- </h3>
369
- <ToolCalls
370
- defaultExpanded
371
- calls={[
372
- {
373
- id: 'd',
374
- name: 'fetch_user',
375
- input: { id: 'usr_42', fields: ['email', 'roles', 'created_at'] },
376
- output: {
377
- id: 'usr_42',
378
- email: 'mark@example.com',
379
- roles: ['admin', 'editor'],
380
- created_at: '2026-04-01T12:34:56Z',
381
- metadata: { plan: 'pro', seats: 5, trial: false },
382
- },
383
- status: 'success',
384
- startedAt: 0,
385
- endedAt: 1,
386
- },
387
- ]}
388
- renderInput={(input) => (
389
- <LazyJsonTree data={input} mode="compact" />
390
- )}
391
- renderOutput={(output) => (
392
- <LazyJsonTree data={output} mode="compact" />
393
- )}
394
- />
395
- </section>
396
- <section className="space-y-2">
397
- <h3 className="text-xs font-semibold text-muted-foreground">Attachments</h3>
398
- <Attachments
399
- attachments={[
400
- {
401
- id: '1',
402
- type: 'image',
403
- url: 'https://images.unsplash.com/photo-1503023345310-bd7c1de61c7d?w=128',
404
- name: 'photo.jpg',
405
- },
406
- {
407
- id: '2',
408
- type: 'file',
409
- url: '#',
410
- name: 'spec.pdf',
411
- mimeType: 'application/pdf',
412
- },
413
- {
414
- id: '3',
415
- type: 'image',
416
- url: 'https://images.unsplash.com/photo-1494526585095-c41746248156?w=128',
417
- status: 'uploading',
418
- progress: 0.55,
419
- },
420
- ]}
421
- />
422
- </section>
423
- <section className="space-y-2">
424
- <h3 className="text-xs font-semibold text-muted-foreground">EmptyState</h3>
425
- <div className="rounded-lg border border-border bg-card">
426
- <EmptyState
427
- greeting="How can I help?"
428
- description="Pick a starter prompt or just type."
429
- suggestions={[
430
- { label: 'Summarise an article', prompt: 'Summarise the latest blog post' },
431
- { label: 'Generate code', prompt: 'Write a Pydantic model for users' },
432
- ]}
433
- />
434
- </div>
435
- </section>
436
- <section className="space-y-2">
437
- <h3 className="text-xs font-semibold text-muted-foreground">ErrorBanner</h3>
438
- <ErrorBanner
439
- error="Something went wrong while contacting the assistant."
440
- onRetry={() => undefined}
441
- onDismiss={() => undefined}
442
- />
443
- </section>
444
- <section className="space-y-2">
445
- <h3 className="text-xs font-semibold text-muted-foreground">JumpToLatest</h3>
446
- <div className="flex items-center gap-3">
447
- <JumpToLatest visible unreadCount={0} onClick={() => undefined} />
448
- <JumpToLatest visible unreadCount={3} onClick={() => undefined} />
449
- </div>
450
- </section>
451
- </div>
452
- );
453
-
454
- // ---------------------------------------------------------------------------
455
- // 6) WithSlots — every named slot on ChatRoot
456
- // ---------------------------------------------------------------------------
457
-
458
- export const WithSlots = () => {
459
- const transport = useMemo(
460
- () =>
461
- createMockTransport({
462
- replies: [
463
- 'I will use **the slots above and below**, plus a custom empty state. Try sending a message to see the composer toolbar buttons.',
464
- 'Each slot is optional — omit any of them to fall back to the default.',
465
- ],
466
- latencyMs: 30,
467
- }),
468
- [],
469
- );
470
-
471
- return (
472
- <Frame h={620}>
473
- <ChatRoot
474
- transport={transport}
475
- config={{ placeholder: 'Slots demo…' }}
476
- banner={
477
- <div className="border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-[11px] text-amber-700 dark:text-amber-400">
478
- Banner slot — sticky note above the conversation
479
- </div>
480
- }
481
- header={
482
- <header className="flex items-center justify-between border-b border-border bg-muted/30 px-3 py-2">
483
- <div className="flex items-center gap-2 text-xs">
484
- <Sparkles aria-hidden className="size-3.5 text-primary" />
485
- <span className="font-semibold">Custom header slot</span>
486
- </div>
487
- <button
488
- type="button"
489
- className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
490
- aria-label="Settings"
491
- >
492
- <Settings aria-hidden className="size-3.5" />
493
- </button>
494
- </header>
495
- }
496
- empty={
497
- <div className="grid place-items-center px-6 py-16 text-center">
498
- <Sparkles aria-hidden className="mb-3 size-6 text-primary" />
499
- <h3 className="text-sm font-semibold">Custom empty slot</h3>
500
- <p className="mt-1 max-w-sm text-xs text-muted-foreground">
501
- Replace the default <code className="font-mono">&lt;EmptyState&gt;</code> wholesale.
502
- Type below to start.
503
- </p>
504
- </div>
505
- }
506
- composerToolbarStart={
507
- <button
508
- type="button"
509
- aria-label="Slash commands"
510
- className="grid h-9 w-9 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
511
- >
512
- /
513
- </button>
514
- }
515
- composerToolbarEnd={
516
- <button
517
- type="button"
518
- aria-label="Mentions"
519
- className="grid h-9 w-9 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
520
- >
521
- @
522
- </button>
523
- }
524
- footer={
525
- <div className="border-t border-border bg-muted/20 px-3 py-1.5 text-center text-[10px] text-muted-foreground">
526
- Footer slot — model: gpt-4o · responses can be inaccurate
527
- </div>
528
- }
529
- />
530
- </Frame>
531
- );
532
- };
533
-
534
- // ---------------------------------------------------------------------------
535
- // 7) WithJsonTreePayload — wire LazyJsonTree into ChatRoot.toolCallsProps
536
- // ---------------------------------------------------------------------------
537
-
538
- export const WithJsonTreePayload = () => {
539
- const transport = useMemo(() => {
540
- const sequence: ChatStreamEvent[] = [
541
- { type: 'chunk', delta: 'Looking that up for you.\n\n' },
542
- {
543
- type: 'tool_call_start',
544
- toolId: 't1',
545
- name: 'fetch_user',
546
- input: { id: 'usr_42', fields: ['email', 'roles', 'metadata'] },
547
- },
548
- {
549
- type: 'tool_call_end',
550
- toolId: 't1',
551
- output: {
552
- id: 'usr_42',
553
- email: 'mark@example.com',
554
- roles: ['admin', 'editor'],
555
- metadata: {
556
- plan: 'pro',
557
- seats: 5,
558
- trial: false,
559
- features: ['streaming', 'tools', 'attachments'],
560
- },
561
- },
562
- status: 'success',
563
- },
564
- { type: 'chunk', delta: 'Here is what I found — open the tool panel to inspect the payload.' },
565
- { type: 'message_end' },
566
- ];
567
- return createMockTransport({ replies: [sequence], latencyMs: 40 });
568
- }, []);
569
-
570
- return (
571
- <Frame h={620}>
572
- <ChatRoot
573
- transport={transport}
574
- config={{
575
- greeting: 'JsonTree payload demo',
576
- placeholder: 'Ask "fetch user 42"…',
577
- }}
578
- toolCallsProps={{
579
- defaultExpanded: true,
580
- renderInput: (input) => <LazyJsonTree data={input} mode="compact" />,
581
- renderOutput: (output) => <LazyJsonTree data={output} mode="compact" />,
582
- }}
583
- />
584
- </Frame>
585
- );
586
- };
587
-
588
- // ---------------------------------------------------------------------------
589
- // 8) WithAudio — chat-event sound triggers
590
- // ---------------------------------------------------------------------------
591
-
592
- export const WithAudio = () => {
593
- const [sentOn] = useBoolean('sent', { defaultValue: true, label: 'Play on sent' });
594
- const [receivedOn] = useBoolean('received', { defaultValue: true, label: 'Play on received' });
595
- const [errorOn] = useBoolean('error-toggle', { defaultValue: true, label: 'Play on error' });
596
-
597
- const transport = useMemo(
598
- () =>
599
- createMockTransport({
600
- replies: [
601
- 'Pings! Listen for the sent / received cues. Try sending a couple of messages.',
602
- 'Each event has its own sound — toggle them in the knobs above.',
603
- 'The `error` event uses a longer drumroll so it stands out.',
604
- ],
605
- latencyMs: 35,
606
- }),
607
- [],
608
- );
609
-
610
- const sounds: ChatAudioSounds = useMemo(
611
- () => ({
612
- messageSent: sentOn ? CHAT_SOUNDS.messageSent : false,
613
- messageReceived: receivedOn ? CHAT_SOUNDS.messageReceived : false,
614
- streamStart: CHAT_SOUNDS.streamStart,
615
- error: errorOn ? CHAT_SOUNDS.error : false,
616
- notification: CHAT_SOUNDS.notification,
617
- }),
618
- [sentOn, receivedOn, errorOn],
619
- );
620
-
621
- return (
622
- <Frame>
623
- <ChatRoot
624
- transport={transport}
625
- config={{
626
- greeting: 'Audio triggers',
627
- description:
628
- 'First click anywhere inside the chat unlocks audio (Safari/iOS quirk). After that, sounds play on send / receive.',
629
- placeholder: 'Send a message to hear the cue…',
630
- }}
631
- audio={{
632
- sounds,
633
- // Default off-when-hidden + reduced-motion / reduced-data respect.
634
- }}
635
- header={
636
- <header className="flex items-center justify-between border-b border-border bg-muted/30 px-3 py-1.5 text-[11px]">
637
- <AudioStatusBadge />
638
- <span className="text-muted-foreground">/audio/chat/*.mp3</span>
639
- </header>
640
- }
641
- />
642
- </Frame>
643
- );
644
- };
645
-
646
- function AudioStatusBadge() {
647
- const ctx = useChatContext();
648
- return (
649
- <button
650
- type="button"
651
- onClick={() => ctx.audio.setMuted(!ctx.audio.muted)}
652
- className="inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 hover:bg-accent"
653
- >
654
- {ctx.audio.muted ? (
655
- <VolumeX aria-hidden className="size-3.5 text-muted-foreground" />
656
- ) : (
657
- <Volume2 aria-hidden className="size-3.5 text-primary" />
658
- )}
659
- <span className="font-mono">
660
- {ctx.audio.isUnlocked ? 'unlocked' : 'locked'} · {ctx.audio.muted ? 'muted' : `${Math.round(ctx.audio.volume * 100)}%`}
661
- </span>
662
- </button>
663
- );
664
- }
665
-
666
- // ---------------------------------------------------------------------------
667
- // 9) WithAudioAttachment — registry mounts <LazyAudioPlayer> for audio msgs
668
- // ---------------------------------------------------------------------------
669
-
670
- const audioAttachmentMessages: ChatMessage[] = [
671
- {
672
- id: 'u1',
673
- role: 'user',
674
- content: "Here's the voice memo I recorded.",
675
- createdAt: Date.now() - 60_000,
676
- attachments: [
677
- {
678
- id: 'voice-1',
679
- type: 'audio',
680
- url: '/audio/voice.mp3',
681
- name: 'voice memo.mp3',
682
- mimeType: 'audio/mpeg',
683
- },
684
- ],
685
- },
686
- {
687
- id: 'a1',
688
- role: 'assistant',
689
- content: 'Got it — playing back inline. Click play to listen.',
690
- createdAt: Date.now() - 50_000,
691
- attachments: [
692
- {
693
- id: 'reply-1',
694
- type: 'audio',
695
- url: '/audio/short.mp3',
696
- name: 'reply.mp3',
697
- mimeType: 'audio/mpeg',
698
- },
699
- ],
700
- },
701
- ];
702
-
703
- export const WithAudioAttachment = () => (
704
- <Frame h={620}>
705
- <div className="h-full overflow-y-auto py-2">
706
- {audioAttachmentMessages.map((m) => (
707
- <MessageBubble
708
- key={m.id}
709
- message={m}
710
- showActions={false}
711
- attachmentRenderers={{
712
- audio: ({ attachment }) => (
713
- <div className="my-1 w-full max-w-md">
714
- <LazyAudioPlayer
715
- src={attachment.url}
716
- title={attachment.name ?? 'audio'}
717
- variant="compact"
718
- />
719
- </div>
720
- ),
721
- }}
722
- />
723
- ))}
724
- </div>
725
- </Frame>
726
- );
727
-
728
- // ---------------------------------------------------------------------------
729
- // 10) WithImageLightbox — useChatLightbox + LazyImageViewer in a Dialog
730
- // ---------------------------------------------------------------------------
731
-
732
- const imageMessages: ChatMessage[] = [
733
- {
734
- id: 'u1',
735
- role: 'user',
736
- content: 'Found these in the assets folder.',
737
- createdAt: Date.now() - 60_000,
738
- attachments: [
739
- {
740
- id: 'img-1',
741
- type: 'image',
742
- url: 'https://images.unsplash.com/photo-1503023345310-bd7c1de61c7d?w=800',
743
- thumbnailUrl: 'https://images.unsplash.com/photo-1503023345310-bd7c1de61c7d?w=128',
744
- name: 'cliffside.jpg',
745
- },
746
- {
747
- id: 'img-2',
748
- type: 'image',
749
- url: 'https://images.unsplash.com/photo-1494526585095-c41746248156?w=800',
750
- thumbnailUrl: 'https://images.unsplash.com/photo-1494526585095-c41746248156?w=128',
751
- name: 'cabin.jpg',
752
- },
753
- ],
754
- },
755
- {
756
- id: 'a1',
757
- role: 'assistant',
758
- content: 'Here is one more from the same set.',
759
- createdAt: Date.now() - 50_000,
760
- attachments: [
761
- {
762
- id: 'img-3',
763
- type: 'image',
764
- url: 'https://images.unsplash.com/photo-1518791841217-8f162f1e1131?w=800',
765
- thumbnailUrl: 'https://images.unsplash.com/photo-1518791841217-8f162f1e1131?w=128',
766
- name: 'cat.jpg',
767
- },
768
- ],
769
- },
770
- ];
771
-
772
- export const WithImageLightbox = () => {
773
- const lightbox = useChatLightbox();
774
- const gallery = useMemo(() => collectImageAttachments(imageMessages), []);
775
-
776
- return (
777
- <Frame h={560}>
778
- <div className="h-full overflow-y-auto py-2">
779
- {imageMessages.map((m) => (
780
- <MessageBubble
781
- key={m.id}
782
- message={m}
783
- showActions={false}
784
- onAttachmentOpen={(att) => {
785
- if (att.type === 'image') lightbox.open(att, gallery);
786
- }}
787
- />
788
- ))}
789
- </div>
790
- <Dialog open={lightbox.state !== null} onOpenChange={(open) => !open && lightbox.close()}>
791
- <DialogContent className="max-w-5xl">
792
- <DialogHeader>
793
- <DialogTitle>Image preview</DialogTitle>
794
- </DialogHeader>
795
- {lightbox.state ? (
796
- <div className="h-[60vh]">
797
- <LazyImageViewer
798
- images={lightbox.state.gallery.map((a: ChatAttachment) => ({
799
- file: { name: a.name ?? a.id, path: a.id },
800
- src: a.url,
801
- }))}
802
- initialIndex={lightbox.state.index}
803
- inDialog
804
- />
805
- </div>
806
- ) : null}
807
- </DialogContent>
808
- </Dialog>
809
- </Frame>
810
- );
811
- };
812
-
813
- // ---------------------------------------------------------------------------
814
- // 11) WithMapPayload — dispatchToolPayload + JsonTree fallback
815
- // ---------------------------------------------------------------------------
816
-
817
- export const WithMapPayload = () => {
818
- const transport = useMemo(() => {
819
- const sequence: ChatStreamEvent[] = [
820
- { type: 'chunk', delta: 'Looking up the location.\n\n' },
821
- {
822
- type: 'tool_call_start',
823
- toolId: 't1',
824
- name: 'geocode',
825
- input: { query: 'Bali, Indonesia' },
826
- },
827
- {
828
- type: 'tool_call_end',
829
- toolId: 't1',
830
- output: { lat: -8.4095, lng: 115.1889, label: 'Bali, Indonesia' },
831
- status: 'success',
832
- },
833
- { type: 'chunk', delta: 'Here are the coordinates — opening the panel renders a tree by default. Map embed wiring is host-side via `dispatchToolPayload`.' },
834
- { type: 'message_end' },
835
- ];
836
- return createMockTransport({ replies: [sequence], latencyMs: 30 });
837
- }, []);
838
-
839
- const renderPayload = useMemo(
840
- () =>
841
- dispatchToolPayload(
842
- [
843
- {
844
- // Single point → host would mount a Map preview here.
845
- match: (v) => isLatLng(v),
846
- render: (v) => {
847
- const point = v as { lat: number; lng: number; label?: string };
848
- return (
849
- <div className="space-y-1">
850
- <div className="rounded-md border border-border bg-emerald-500/5 px-2 py-1.5 text-[11px]">
851
- <strong className="font-mono">{point.lat.toFixed(4)}, {point.lng.toFixed(4)}</strong>
852
- {point.label ? <span className="ml-2 text-muted-foreground">— {point.label}</span> : null}
853
- <span className="ml-2 text-emerald-500">→ would mount &lt;LazyMap&gt;</span>
854
- </div>
855
- <LazyJsonTree data={v} mode="inline" />
856
- </div>
857
- );
858
- },
859
- },
860
- {
861
- // Other objects → JsonTree.
862
- match: (v) => isPlainObject(v),
863
- render: (v) => <LazyJsonTree data={v} mode="compact" />,
864
- },
865
- ],
866
- (v) => <pre className="text-[11px]">{String(v)}</pre>,
867
- ),
868
- [],
869
- );
870
-
871
- return (
872
- <Frame>
873
- <ChatRoot
874
- transport={transport}
875
- config={{
876
- greeting: 'Tool payload dispatcher',
877
- placeholder: 'Ask "where is Bali"…',
878
- }}
879
- toolCallsProps={{ defaultExpanded: true, renderPayload }}
880
- />
881
- </Frame>
882
- );
883
- };
884
-
885
- // ---------------------------------------------------------------------------
886
- // 12) WithPersonas — user / assistant identity (avatar, name, multi-user)
887
- // ---------------------------------------------------------------------------
888
-
889
- const MARK_PERSONA = {
890
- name: 'Mark',
891
- avatarUrl: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=128',
892
- description: 'Senior engineer',
893
- };
894
- const CLAUDE_PERSONA = {
895
- name: 'Claude',
896
- avatarUrl: 'https://avatars.githubusercontent.com/u/76263028?s=128&v=4',
897
- description: 'Anthropic · Opus 4.7',
898
- model: 'claude-opus-4-7',
899
- };
900
-
901
- export const WithPersonas = () => {
902
- const transport = useMemo(
903
- () =>
904
- createMockTransport({
905
- replies: [
906
- 'Hi Mark — happy to help. What are we working on?',
907
- 'Got it. Streaming a response now…',
908
- ],
909
- latencyMs: 30,
910
- }),
911
- [],
912
- );
913
- return (
914
- <Frame>
915
- <ChatRoot
916
- transport={transport}
917
- config={{
918
- greeting: 'Hi Mark 👋',
919
- placeholder: 'Ask Claude anything…',
920
- user: MARK_PERSONA,
921
- assistant: CLAUDE_PERSONA,
922
- }}
923
- />
924
- </Frame>
925
- );
926
- };
927
-
928
- // Multi-user / multi-bot — per-message `sender` overrides config defaults.
929
- const MULTI_USER_MESSAGES: ChatMessage[] = [
930
- {
931
- id: 'm1',
932
- role: 'user',
933
- content: '@claude can you summarise the spec?',
934
- createdAt: Date.now() - 90_000,
935
- sender: { name: 'Mark', avatarUrl: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=128' },
936
- },
937
- {
938
- id: 'm2',
939
- role: 'assistant',
940
- content: "Sure — pulling the latest version from the docs index now.",
941
- createdAt: Date.now() - 80_000,
942
- sender: { ...CLAUDE_PERSONA, initials: 'CL' },
943
- },
944
- {
945
- id: 'm3',
946
- role: 'user',
947
- content: 'Make sure to flag the auth migration section.',
948
- createdAt: Date.now() - 60_000,
949
- sender: { name: 'Anna', avatarUrl: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=128' },
950
- },
951
- {
952
- id: 'm4',
953
- role: 'assistant',
954
- content: '👍 Flagged. Section 4.2 — needs DBA review before Friday.',
955
- createdAt: Date.now() - 30_000,
956
- sender: { ...CLAUDE_PERSONA, initials: 'CL' },
957
- },
958
- {
959
- id: 'm5',
960
- role: 'user',
961
- content: 'Thanks both — I will sync with Anna tomorrow.',
962
- createdAt: Date.now() - 10_000,
963
- sender: { name: 'Lukas', initials: 'LK' },
964
- },
965
- ];
966
-
967
- export const MultiUser = () => (
968
- <Frame h={620}>
969
- <div className="h-full overflow-y-auto py-2">
970
- {MULTI_USER_MESSAGES.map((m) => (
971
- <MessageBubble key={m.id} message={m} showActions={false} showTimestamp />
972
- ))}
973
- </div>
974
- </Frame>
975
- );
976
-
977
- // ---------------------------------------------------------------------------
978
- // 13) WithHideComposer — agent-pause pattern (approval gate, human-in-the-loop)
979
- // ---------------------------------------------------------------------------
980
-
981
- export const WithHideComposer = () => {
982
- const [paused, setPaused] = useState(false);
983
-
984
- const transport = useMemo(
985
- () =>
986
- createMockTransport({
987
- replies: [
988
- 'Sure — I will send that email. Waiting for your approval before proceeding.',
989
- ],
990
- latencyMs: 30,
991
- }),
992
- [],
993
- );
994
-
995
- return (
996
- <Frame h={480}>
997
- <ChatRoot
998
- transport={transport}
999
- config={{
1000
- greeting: 'Agent-pause demo',
1001
- placeholder: 'Ask to do something requiring approval…',
1002
- }}
1003
- hideComposer={paused}
1004
- footer={
1005
- paused ? (
1006
- <div className="flex flex-col gap-2 border-t bg-background px-3 py-2">
1007
- <div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40 p-3 text-sm">
1008
- <div className="mb-1 font-medium text-amber-900 dark:text-amber-200">
1009
- Approval required
1010
- <span className="ml-2 font-mono text-xs text-amber-700 dark:text-amber-400">send_email</span>
1011
- </div>
1012
- <pre className="mb-2 rounded bg-muted px-2 py-1.5 text-[11px] text-muted-foreground">
1013
- {`{ "to": "mark@example.com", "subject": "Hello" }`}
1014
- </pre>
1015
- <div className="flex gap-2">
1016
- <button
1017
- type="button"
1018
- onClick={() => setPaused(false)}
1019
- className="flex-1 rounded bg-amber-600 hover:bg-amber-700 px-3 py-1.5 text-xs font-medium text-white transition-colors"
1020
- >
1021
- Approve
1022
- </button>
1023
- <button
1024
- type="button"
1025
- onClick={() => setPaused(false)}
1026
- className="flex-1 rounded border border-amber-300 hover:bg-amber-100 px-3 py-1.5 text-xs font-medium text-amber-800 transition-colors"
1027
- >
1028
- Deny
1029
- </button>
1030
- </div>
1031
- </div>
1032
- </div>
1033
- ) : (
1034
- <div className="border-t border-border bg-muted/20 px-3 py-1.5 text-center">
1035
- <button
1036
- type="button"
1037
- onClick={() => setPaused(true)}
1038
- className="text-[11px] text-muted-foreground hover:text-foreground underline"
1039
- >
1040
- Simulate approval gate
1041
- </button>
1042
- </div>
1043
- )
1044
- }
1045
- />
1046
- </Frame>
1047
- );
1048
- };
1049
-
1050
- // ---------------------------------------------------------------------------
1051
- // 14) Playground — knobs
1052
- // ---------------------------------------------------------------------------
1053
-
1054
- export const Playground = () => {
1055
- const [latencyStr] = useSelect('latency', {
1056
- options: ['10', '35', '80', '200'] as const,
1057
- defaultValue: '35',
1058
- label: 'Latency (ms/chunk)',
1059
- });
1060
- const latency = Number(latencyStr);
1061
- const [streaming] = useBoolean('streaming', {
1062
- defaultValue: true,
1063
- label: 'Streaming',
1064
- });
1065
- const [showSuggestions] = useBoolean('suggestions', {
1066
- defaultValue: true,
1067
- label: 'Show suggestions',
1068
- });
1069
- const [seed, setSeed] = useState(0);
1070
-
1071
- const transport = useMemo(
1072
- () =>
1073
- createMockTransport({
1074
- latencyMs: latency,
1075
- replies: [
1076
- 'Token by token, this reply streams in. Try interrupting with the Stop button mid-stream.',
1077
- 'Here is a longer reply with **markdown** including a code block:\n\n```ts\nfunction add(a: number, b: number) {\n return a + b;\n}\n```\n\nAnd a list:\n- one\n- two\n- three',
1078
- 'Cancel mid-stream to keep partial text with a [cancelled] marker.',
1079
- ],
1080
- }),
1081
- // eslint-disable-next-line react-hooks/exhaustive-deps
1082
- [latency, seed],
1083
- );
1084
-
1085
- return (
1086
- <div className="flex flex-col gap-2">
1087
- <button
1088
- type="button"
1089
- className="self-start rounded border border-border bg-background px-2 py-0.5 text-xs hover:bg-accent"
1090
- onClick={() => setSeed((n) => n + 1)}
1091
- >
1092
- Reset session
1093
- </button>
1094
- <Frame h={580}>
1095
- <ChatRoot
1096
- transport={transport}
1097
- streaming={streaming}
1098
- config={{
1099
- greeting: 'Playground',
1100
- description: 'Tweak knobs above to change behavior.',
1101
- placeholder: 'Type a message…',
1102
- suggestions: showSuggestions
1103
- ? [
1104
- { label: 'Markdown sample', prompt: 'Show me markdown' },
1105
- { label: 'Long reply', prompt: 'Give me a long answer' },
1106
- ]
1107
- : undefined,
1108
- }}
1109
- />
1110
- </Frame>
1111
- </div>
1112
- );
1113
- };
1114
-
1115
- // ---------------------------------------------------------------------------
1116
- // 15) WailsLikeVirtualization — stress-test for the virtuoso integration
1117
- //
1118
- // Mirrors the cmdop-desktop (Wails) chat use case that drove the
1119
- // plan64 migration:
1120
- // - 200 pre-loaded history bubbles of varying heights (short, long
1121
- // prose, code blocks, tool calls) — exercises Virtuoso's dynamic
1122
- // measurement path and the bug where bubbles "disappeared" when
1123
- // scrolling to the bottom.
1124
- // - Streamed reply with token-by-token deltas — exercises
1125
- // `followOutput` sticky-bottom behaviour.
1126
- // - Tool call mid-stream — exercises bubble-resize during streaming
1127
- // (the second virtuoso jitter source).
1128
- //
1129
- // Use this story to verify:
1130
- // 1. Mount → viewport lands at the last bubble, not the top.
1131
- // 2. Scroll up → JumpToLatest pill appears; click → smooth jump.
1132
- // 3. Sit at the bottom → streaming reply tracks without flicker.
1133
- // 4. Drag scrollbar to the very bottom → bubbles stay visible (no
1134
- // empty viewport).
1135
- // ---------------------------------------------------------------------------
1136
-
1137
- function makeWailsLikeHistory(count: number): ChatMessage[] {
1138
- // Mix of bubble shapes so virtuoso has to measure heterogeneous
1139
- // heights instead of locking onto one cheap estimate.
1140
- const SHORT_USER = 'Quick ping — can you check the latest deploy?';
1141
- const SHORT_AI = 'Yes, all clear. CI passed, no regressions.';
1142
- const LONG_PROSE =
1143
- 'Sure, here is the rundown.\n\nThe service has been migrated to the new transport layer. The reducer split is in plan64 phase 4, and the audio cues now respect `prefers-reduced-motion`.\n\n' +
1144
- 'A few things to double-check before we sign off:\n\n' +
1145
- '- The auto-scroll behaviour on streaming chunks\n' +
1146
- '- Tool-call panels expanding mid-stream\n' +
1147
- '- The mention popup positioning inside the composer\n\n' +
1148
- 'Let me know if any of those need a second pass.';
1149
- const CODE_REPLY =
1150
- 'Here is the snippet you asked for:\n\n```ts\n' +
1151
- 'export function createWailsTransport(): ChatTransport {\n' +
1152
- ' return {\n' +
1153
- ' async *stream(sid, content, { signal }) {\n' +
1154
- ' const queue = createAsyncQueue<ChatStreamEvent>();\n' +
1155
- ' const off = subscribeChatStreamEvents(sid, (e) => queue.push(e));\n' +
1156
- ' try {\n' +
1157
- ' await ChatService.SendMessage(sid, content);\n' +
1158
- ' for await (const evt of queue) {\n' +
1159
- ' yield evt;\n' +
1160
- ' if (isTerminalStreamEvent(evt)) break;\n' +
1161
- ' }\n' +
1162
- ' } finally {\n' +
1163
- ' off(); queue.close();\n' +
1164
- ' }\n' +
1165
- ' },\n' +
1166
- ' };\n' +
1167
- '}\n' +
1168
- '```\n\nDoes that match what you had in mind?';
1169
-
1170
- const shapes: Array<(i: number) => ChatMessage> = [
1171
- (i) => ({
1172
- id: `u${i}`,
1173
- role: 'user',
1174
- content: SHORT_USER,
1175
- createdAt: Date.now() - (count - i) * 10_000,
1176
- }),
1177
- (i) => ({
1178
- id: `a${i}`,
1179
- role: 'assistant',
1180
- content: SHORT_AI,
1181
- createdAt: Date.now() - (count - i) * 10_000,
1182
- }),
1183
- (i) => ({
1184
- id: `a${i}`,
1185
- role: 'assistant',
1186
- content: LONG_PROSE,
1187
- createdAt: Date.now() - (count - i) * 10_000,
1188
- }),
1189
- (i) => ({
1190
- id: `a${i}`,
1191
- role: 'assistant',
1192
- content: CODE_REPLY,
1193
- createdAt: Date.now() - (count - i) * 10_000,
1194
- }),
1195
- (i) => ({
1196
- id: `a${i}`,
1197
- role: 'assistant',
1198
- content: 'Running diagnostics…',
1199
- createdAt: Date.now() - (count - i) * 10_000,
1200
- toolCalls: [
1201
- {
1202
- id: `t${i}`,
1203
- name: 'read_file',
1204
- input: { path: `/var/log/app-${i}.log`, lines: 50 },
1205
- output: { ok: true, bytes: 4096 + (i % 7) * 512 },
1206
- status: 'success',
1207
- startedAt: Date.now() - (count - i) * 10_000,
1208
- endedAt: Date.now() - (count - i) * 10_000 + 250,
1209
- },
1210
- ],
1211
- }),
1212
- ];
1213
-
1214
- return Array.from({ length: count }, (_, i) => shapes[i % shapes.length](i));
1215
- }
1216
-
1217
- export const WailsLikeVirtualization = () => {
1218
- const [count] = useSelect('history', {
1219
- options: ['50', '200', '500', '1000'] as const,
1220
- defaultValue: '200',
1221
- label: 'History size',
1222
- });
1223
-
1224
- // Pre-seeded history simulating a long-running cmdop session.
1225
- // `resumeSession` is the key bit: the mock transport returns these
1226
- // as `SessionInfo.messages` so they're available at mount time
1227
- // (mirrors `ChatService.GetHistoryPaginated` on the Wails side).
1228
- const history = useMemo(() => makeWailsLikeHistory(Number(count)), [count]);
1229
-
1230
- const transport = useMemo(() => {
1231
- const sequence: ChatStreamEvent[] = [
1232
- { type: 'chunk', delta: 'Let me pull the latest stats.\n\n' },
1233
- {
1234
- type: 'tool_call_start',
1235
- toolId: 'live-1',
1236
- name: 'fetch_metrics',
1237
- input: { window: '15m' },
1238
- },
1239
- { type: 'tool_call_delta', toolId: 'live-1', delta: 'connecting…\n' },
1240
- { type: 'tool_call_delta', toolId: 'live-1', delta: 'fetched 3214 rows\n' },
1241
- {
1242
- type: 'tool_call_end',
1243
- toolId: 'live-1',
1244
- output: { rows: 3214, p95_ms: 142, errors: 0 },
1245
- status: 'success',
1246
- },
1247
- {
1248
- type: 'chunk',
1249
- delta:
1250
- 'All green:\n\n- **3214 rows** in the last 15 min\n- **p95 latency**: 142 ms\n- **errors**: 0\n\n',
1251
- },
1252
- {
1253
- type: 'chunk',
1254
- delta:
1255
- 'If you want to stress-test virtualization, scroll up and check the JumpToLatest pill — it should appear once you move out of the sticky-bottom zone.',
1256
- },
1257
- { type: 'message_end' },
1258
- ];
1259
- return createMockTransport({
1260
- replies: [sequence, sequence, sequence],
1261
- latencyMs: 25,
1262
- // Mock transport returns `initialMessages` from `createSession`
1263
- // as `SessionInfo.messages` — mimics the Wails path where the
1264
- // default chat session resumes with its on-disk history.
1265
- initialMessages: history,
1266
- });
1267
- }, [history]);
1268
-
1269
- return (
1270
- <Frame h={620} w={520}>
1271
- <ChatRoot
1272
- transport={transport}
1273
- config={{
1274
- greeting: `Wails-like session — ${history.length} bubbles preloaded`,
1275
- description:
1276
- 'Stress test for the virtuoso integration: long history, dynamic bubble heights, streaming tool calls. Scroll to the bottom; bubbles must stay visible. Streaming should follow without flicker.',
1277
- placeholder: 'Send a message to trigger a streamed reply…',
1278
- user: { name: 'Mark', initials: 'MO' },
1279
- assistant: { name: 'cmdop', initials: 'CM' },
1280
- }}
1281
- />
1282
- </Frame>
1283
- );
1284
- };
1285
-
1286
- // ---------------------------------------------------------------------------
1287
- // 16) WithRenderAfterCalls — rich UI outside tool panels
1288
- // Demonstrates: renderAfterCalls, hideToolCalls, renderToolCall
1289
- // ---------------------------------------------------------------------------
1290
-
1291
- const SEARCH_SEQUENCE: ChatStreamEvent[] = [
1292
- { type: 'chunk', delta: 'Searching the catalog for you.\n\n' },
1293
- {
1294
- type: 'tool_call_start',
1295
- toolId: 'v1',
1296
- name: 'search_vehicles',
1297
- input: { make: 'BMW', year_min: 2022 },
1298
- },
1299
- { type: 'tool_call_delta', toolId: 'v1', delta: 'querying…\n' },
1300
- {
1301
- type: 'tool_call_end',
1302
- toolId: 'v1',
1303
- output: {
1304
- items: [
1305
- { id: 'car-001', make: 'BMW', model: '5 Series', year: 2023, price: 45000 },
1306
- { id: 'car-002', make: 'BMW', model: 'X5', year: 2022, price: 72000 },
1307
- ],
1308
- total: 2,
1309
- },
1310
- status: 'success',
1311
- },
1312
- { type: 'chunk', delta: 'Found 2 vehicles — see the cards below.' },
1313
- { type: 'message_end' },
1314
- ];
1315
-
1316
- function MockVehicleCards({ calls }: { calls: import('./types').ChatToolCall[] }) {
1317
- const items: Array<{ id: string; make: string; model: string; year: number; price: number }> = [];
1318
- for (const call of calls) {
1319
- if (call.status !== 'success' || call.name !== 'search_vehicles') continue;
1320
- const out = call.output as { items?: typeof items } | null;
1321
- for (const item of out?.items ?? []) items.push(item);
1322
- }
1323
- if (items.length === 0) return null;
1324
- return (
1325
- <div className="mt-2 space-y-1.5">
1326
- {items.map((v) => (
1327
- <div
1328
- key={v.id}
1329
- className="flex items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs"
1330
- >
1331
- <div className="size-10 shrink-0 rounded bg-muted" />
1332
- <div className="min-w-0 flex-1">
1333
- <p className="font-semibold">{v.make} {v.model}</p>
1334
- <p className="text-muted-foreground">{v.year}</p>
1335
- </div>
1336
- <span className="font-mono text-sm font-semibold">${v.price.toLocaleString()}</span>
1337
- </div>
1338
- ))}
1339
- </div>
1340
- );
1341
- }
1342
-
1343
- export const WithRenderAfterCalls = () => {
1344
- const [hide] = useBoolean('hide-panels', { defaultValue: false, label: 'hideToolCalls (hide accordion panels)' });
1345
- const [useCustom] = useBoolean('custom-call', { defaultValue: false, label: 'renderToolCall (custom per-call renderer)' });
1346
-
1347
- const transport = useMemo(
1348
- () => createMockTransport({ replies: [SEARCH_SEQUENCE, SEARCH_SEQUENCE], latencyMs: 40 }),
1349
- [],
1350
- );
1351
-
1352
- const toolCallsProps = useMemo(
1353
- () => ({
1354
- hideToolCalls: hide,
1355
- renderAfterCalls: (calls: import('./types').ChatToolCall[]) => <MockVehicleCards calls={calls} />,
1356
- ...(useCustom
1357
- ? {
1358
- renderToolCall: (call: import('./types').ChatToolCall) => (
1359
- <div className="flex items-center gap-2 rounded border border-border bg-muted/40 px-2 py-1 text-xs">
1360
- <span className="font-mono text-muted-foreground">{call.name}</span>
1361
- <span className={`ml-auto rounded px-1 py-0.5 text-[10px] font-medium ${
1362
- call.status === 'success' ? 'bg-emerald-500/10 text-emerald-600' : 'bg-amber-500/10 text-amber-600'
1363
- }`}>{call.status}</span>
1364
- </div>
1365
- ),
1366
- }
1367
- : {}),
1368
- }),
1369
- [hide, useCustom],
1370
- );
1371
-
1372
- return (
1373
- <Frame h={560}>
1374
- <ChatRoot
1375
- transport={transport}
1376
- config={{
1377
- greeting: 'Vehicle search demo',
1378
- placeholder: 'Ask "find BMW vehicles"…',
1379
- suggestions: [{ label: 'Find BMW vehicles', prompt: 'Find BMW vehicles from 2022' }],
1380
- }}
1381
- toolCallsProps={toolCallsProps}
1382
- />
1383
- </Frame>
1384
- );
1385
- };
1386
-
1387
- // ---------------------------------------------------------------------------
1388
- // AutoFocusOnStreamEnd — refocus composer the moment a reply lands
1389
- // ---------------------------------------------------------------------------
1390
- //
1391
- // Standard chat UX: user types → sends → reads → starts typing again.
1392
- // `useAutoFocusOnStreamEnd` watches `isStreaming` and fires `.focus()`
1393
- // on the true → false edge. Pass any object with `.focus()` —
1394
- // `useChatComposer`'s textareaRef, a forwarded handle, or a raw
1395
- // HTMLTextAreaElement.
1396
-
1397
- export const AutoFocusOnStreamEnd = () => {
1398
- const [enabled] = useBoolean('autofocus', { defaultValue: true, label: 'Auto-focus composer when reply lands' });
1399
-
1400
- const transport = useMemo(
1401
- () =>
1402
- createMockTransport({
1403
- replies: [
1404
- 'Reply landed — composer should be focused now. Start typing without reaching for the mouse.',
1405
- 'Toggle the knob off to see the difference: focus stays wherever it was.',
1406
- 'The hook only fires on the streaming → idle transition, so reading mid-stream is undisturbed.',
1407
- ],
1408
- latencyMs: 25,
1409
- }),
1410
- [],
1411
- );
1412
-
1413
- return (
1414
- <Frame>
1415
- <ChatProvider
1416
- transport={transport}
1417
- config={{
1418
- greeting: 'Auto-focus demo',
1419
- description:
1420
- 'Send a message, wait for the reply to finish streaming, then start typing — the composer is already focused.',
1421
- placeholder: 'Send anything…',
1422
- }}
1423
- >
1424
- <AutoFocusWiring enabled={enabled} />
1425
- <AutoFocusShell />
1426
- </ChatProvider>
1427
- </Frame>
1428
- );
1429
- };
1430
-
1431
- /** Story-internal wiring component — sits inside ChatProvider. The
1432
- * built-in `<Composer>` registers itself with the context on mount,
1433
- * so the hook needs no args at all: it reads `isStreaming` and the
1434
- * composer handle from context automatically. */
1435
- function AutoFocusWiring({ enabled }: { enabled: boolean }) {
1436
- useAutoFocusOnStreamEnd({ enabled });
1437
- return null;
1438
- }
1439
-
1440
- /** Minimal shell to render the message list + composer. ChatRoot
1441
- * doesn't take children so we drop a slim layout here. */
1442
- function AutoFocusShell() {
1443
- const ctx = useChatContext();
1444
- const composer = useChatComposer({ onSubmit: (text) => ctx.sendMessage(text) });
1445
- return (
1446
- <div className="flex h-full flex-col">
1447
- <MessageList
1448
- messages={ctx.messages}
1449
- renderItem={(m) => (
1450
- <MessageBubble key={m.id} message={m} />
1451
- )}
1452
- className="flex-1"
1453
- />
1454
- <Composer composer={composer} placeholder={ctx.config.placeholder} />
1455
- </div>
1456
- );
1457
- }