@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.
- package/README.md +132 -899
- package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
- package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
- package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
- package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
- package/dist/DictationField-2ZLQWLYV.mjs +4 -0
- package/dist/DictationField-2ZLQWLYV.mjs.map +1 -0
- package/dist/DictationField-IPPJ54CU.cjs +13 -0
- package/dist/DictationField-IPPJ54CU.cjs.map +1 -0
- package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
- package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
- package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
- package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
- package/dist/chunk-4LXG3NBV.mjs +833 -0
- package/dist/chunk-4LXG3NBV.mjs.map +1 -0
- package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
- package/dist/chunk-FIRK5CEH.cjs.map +1 -0
- package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
- package/dist/chunk-HIK6BPL7.mjs.map +1 -0
- package/dist/chunk-KMSBGNVC.cjs +835 -0
- package/dist/chunk-KMSBGNVC.cjs.map +1 -0
- package/dist/chunk-OZAU3QWD.cjs +2493 -0
- package/dist/chunk-OZAU3QWD.cjs.map +1 -0
- package/dist/chunk-UWVP6LCW.mjs +2447 -0
- package/dist/chunk-UWVP6LCW.mjs.map +1 -0
- package/dist/index.cjs +1532 -100
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1148 -107
- package/dist/index.d.ts +1148 -107
- package/dist/index.mjs +1421 -51
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -8
- package/src/audio-assets.d.ts +8 -0
- package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
- package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
- package/src/stories/index.ts +32 -2
- package/src/tools/Chat/README.md +347 -530
- package/src/tools/Chat/components/Attachments.tsx +6 -1
- package/src/tools/Chat/components/ChatRoot.tsx +30 -2
- package/src/tools/Chat/components/Composer.tsx +20 -3
- package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
- package/src/tools/Chat/components/MessageActions.tsx +3 -1
- package/src/tools/Chat/components/MessageBubble.tsx +6 -5
- package/src/tools/Chat/components/MessageList.tsx +87 -1
- package/src/tools/Chat/components/ToolCalls.tsx +21 -3
- package/src/tools/Chat/context/ChatProvider.tsx +21 -3
- package/src/tools/Chat/core/audio/audioBus.ts +10 -163
- package/src/tools/Chat/core/audio/defaults.ts +43 -0
- package/src/tools/Chat/core/audio/index.ts +1 -0
- package/src/tools/Chat/core/audio/preferences.ts +5 -59
- package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
- package/src/tools/Chat/core/audio/types.ts +28 -0
- package/src/tools/Chat/core/reducer.ts +33 -0
- package/src/tools/Chat/core/transport/index.ts +13 -0
- package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
- package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
- package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
- package/src/tools/Chat/core/transport/sse.ts +18 -5
- package/src/tools/Chat/hooks/index.ts +25 -0
- package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
- package/src/tools/Chat/hooks/useChat.ts +28 -0
- package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
- package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
- package/src/tools/Chat/hooks/useChatReset.ts +70 -0
- package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
- package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
- package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
- package/src/tools/Chat/index.ts +69 -1
- package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
- package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
- package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
- package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
- package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
- package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
- package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
- package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
- package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
- package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
- package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
- package/src/tools/Chat/launcher/index.ts +46 -0
- package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
- package/src/tools/Chat/stories/01-basic.story.tsx +64 -0
- package/src/tools/Chat/stories/02-bubbles.story.tsx +21 -0
- package/src/tools/Chat/stories/03-tool-calls.story.tsx +59 -0
- package/src/tools/Chat/stories/04-personas.story.tsx +78 -0
- package/src/tools/Chat/stories/05-launcher.story.tsx +321 -0
- package/src/tools/Chat/stories/06-header.story.tsx +147 -0
- package/src/tools/Chat/stories/07-audio-actions.story.tsx +112 -0
- package/src/tools/Chat/stories/shared/Frame.tsx +21 -0
- package/src/tools/Chat/stories/shared/index.ts +5 -0
- package/src/tools/Chat/stories/shared/messages.ts +39 -0
- package/src/tools/Chat/stories/shared/personas.ts +13 -0
- package/src/tools/Chat/stories/shared/seeds.ts +92 -0
- package/src/tools/Chat/stories/shared/transports.ts +36 -0
- package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
- package/src/tools/Chat/styles/index.ts +16 -0
- package/src/tools/Chat/styles/useChatStyles.ts +101 -0
- package/src/tools/Chat/types/attachment.ts +25 -0
- package/src/tools/Chat/types/config.ts +48 -0
- package/src/tools/Chat/types/events.ts +35 -0
- package/src/tools/Chat/types/index.ts +34 -0
- package/src/tools/Chat/types/labels.ts +38 -0
- package/src/tools/Chat/types/message.ts +32 -0
- package/src/tools/Chat/types/persona.ts +31 -0
- package/src/tools/Chat/types/session.ts +43 -0
- package/src/tools/Chat/types/tool-call.ts +17 -0
- package/src/tools/Chat/types/transport.ts +28 -0
- package/src/tools/Chat/types.ts +5 -240
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
- package/src/tools/MarkdownEditor/index.ts +1 -1
- package/src/tools/SpeechRecognition/README.md +336 -0
- package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
- package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
- package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
- package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
- package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
- package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
- package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
- package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
- package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
- package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
- package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
- package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
- package/src/tools/SpeechRecognition/components/index.ts +16 -0
- package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
- package/src/tools/SpeechRecognition/context/index.ts +6 -0
- package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
- package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
- package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
- package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
- package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
- package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
- package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
- package/src/tools/SpeechRecognition/core/ids.ts +11 -0
- package/src/tools/SpeechRecognition/core/index.ts +14 -0
- package/src/tools/SpeechRecognition/core/language.ts +78 -0
- package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
- package/src/tools/SpeechRecognition/core/logger.ts +3 -0
- package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
- package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
- package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
- package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
- package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
- package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
- package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
- package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
- package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
- package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
- package/src/tools/SpeechRecognition/index.ts +82 -0
- package/src/tools/SpeechRecognition/lazy.tsx +19 -0
- package/src/tools/SpeechRecognition/store/index.ts +2 -0
- package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
- package/src/tools/SpeechRecognition/stories/01-basic.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/02-dictation-field.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/03-push-to-talk.story.tsx +27 -0
- package/src/tools/SpeechRecognition/stories/04-mic-meter.story.tsx +35 -0
- package/src/tools/SpeechRecognition/stories/05-custom-engine-http.story.tsx +40 -0
- package/src/tools/SpeechRecognition/stories/06-custom-engine-ws.story.tsx +48 -0
- package/src/tools/SpeechRecognition/stories/07-language-device.story.tsx +57 -0
- package/src/tools/SpeechRecognition/stories/08-errors-permissions.story.tsx +25 -0
- package/src/tools/SpeechRecognition/stories/09-chat-voice.story.tsx +90 -0
- package/src/tools/SpeechRecognition/stories/shared.tsx +123 -0
- package/src/tools/SpeechRecognition/types.ts +133 -0
- package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
- package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
- package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
- package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
- package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
- package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
- package/dist/chunk-NWUT327A.mjs.map +0 -1
- package/dist/chunk-QLMKCSR6.mjs +0 -2420
- package/dist/chunk-QLMKCSR6.mjs.map +0 -1
- package/dist/chunk-SI5RD2GD.cjs +0 -2460
- package/dist/chunk-SI5RD2GD.cjs.map +0 -1
- package/dist/chunk-XACCHZH2.cjs.map +0 -1
- package/src/tools/Chat/Chat.story.tsx +0 -1457
|
@@ -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"><pre></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"><EmptyState></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 <LazyMap></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
|
-
}
|