@slipstream-ui/ai 0.0.0-stage → 0.3.0
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/LICENSE +25 -0
- package/dist/index.d.mts +579 -0
- package/dist/index.d.ts +579 -0
- package/dist/index.js +1778 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1706 -0
- package/dist/index.mjs.map +1 -0
- package/dist/manifests/attachment.manifest.json +33 -0
- package/dist/manifests/branch.manifest.json +65 -0
- package/dist/manifests/chain-thought.manifest.json +39 -0
- package/dist/manifests/code-block.manifest.json +50 -0
- package/dist/manifests/command-palette.manifest.json +80 -0
- package/dist/manifests/confirmation.manifest.json +143 -0
- package/dist/manifests/conversation.manifest.json +290 -0
- package/dist/manifests/inline-citation.manifest.json +46 -0
- package/dist/manifests/message-actions.manifest.json +35 -0
- package/dist/manifests/message.manifest.json +235 -0
- package/dist/manifests/prompt-input.manifest.json +334 -0
- package/dist/manifests/reasoning.manifest.json +177 -0
- package/dist/manifests/response.manifest.json +51 -0
- package/dist/manifests/source.manifest.json +78 -0
- package/dist/manifests/streaming-indicator.manifest.json +39 -0
- package/dist/manifests/suggestions.manifest.json +45 -0
- package/dist/manifests/tool-call.manifest.json +158 -0
- package/package.json +61 -3
- package/src/adapter/adapter.test.tsx +83 -0
- package/src/adapter/hooks.ts +82 -0
- package/src/adapter/index.ts +3 -0
- package/src/adapter/messages.ts +49 -0
- package/src/adapter/types.ts +24 -0
- package/src/components/attachment/Attachment.test.tsx +72 -0
- package/src/components/attachment/Attachment.tsx +84 -0
- package/src/components/attachment/AttachmentList.tsx +30 -0
- package/src/components/attachment/index.ts +5 -0
- package/src/components/branch/Branch.test.tsx +52 -0
- package/src/components/branch/Branch.tsx +77 -0
- package/src/components/branch/index.ts +2 -0
- package/src/components/chainthought/ChainThought.test.tsx +34 -0
- package/src/components/chainthought/ChainThought.tsx +45 -0
- package/src/components/chainthought/index.ts +2 -0
- package/src/components/code-block/CodeBlock.test.tsx +46 -0
- package/src/components/code-block/CodeBlock.tsx +95 -0
- package/src/components/code-block/index.ts +2 -0
- package/src/components/command-palette/CommandPalette.test.tsx +74 -0
- package/src/components/command-palette/CommandPalette.tsx +156 -0
- package/src/components/command-palette/index.ts +2 -0
- package/src/components/confirmation/Confirmation.test.tsx +97 -0
- package/src/components/confirmation/Confirmation.tsx +146 -0
- package/src/components/confirmation/index.ts +2 -0
- package/src/components/conversation/Conversation.test.tsx +49 -0
- package/src/components/conversation/Conversation.tsx +301 -0
- package/src/components/conversation/Conversation.welcome.test.tsx +73 -0
- package/src/components/conversation/index.ts +2 -0
- package/src/components/inline-citation/InlineCitation.test.tsx +42 -0
- package/src/components/inline-citation/InlineCitation.tsx +56 -0
- package/src/components/inline-citation/index.ts +2 -0
- package/src/components/message/Message.error-edit.test.tsx +60 -0
- package/src/components/message/Message.test.tsx +80 -0
- package/src/components/message/Message.tsx +205 -0
- package/src/components/message/index.ts +2 -0
- package/src/components/message-actions/MessageActions.test.tsx +77 -0
- package/src/components/message-actions/MessageActions.tsx +134 -0
- package/src/components/message-actions/index.ts +2 -0
- package/src/components/prompt-input/PromptInput.compound.test.tsx +150 -0
- package/src/components/prompt-input/PromptInput.test.tsx +74 -0
- package/src/components/prompt-input/PromptInput.tsx +584 -0
- package/src/components/prompt-input/index.ts +21 -0
- package/src/components/reasoning/Reasoning.duration.test.tsx +35 -0
- package/src/components/reasoning/Reasoning.test.tsx +58 -0
- package/src/components/reasoning/Reasoning.tsx +85 -0
- package/src/components/reasoning/index.ts +2 -0
- package/src/components/response/Response.test.tsx +69 -0
- package/src/components/response/Response.tsx +75 -0
- package/src/components/response/close-incomplete-markdown.ts +47 -0
- package/src/components/response/index.ts +3 -0
- package/src/components/source/Source.tsx +51 -0
- package/src/components/source/Sources.tsx +42 -0
- package/src/components/source/index.ts +5 -0
- package/src/components/source/source.test.tsx +72 -0
- package/src/components/streaming/StreamingIndicator.test.tsx +26 -0
- package/src/components/streaming/StreamingIndicator.tsx +46 -0
- package/src/components/streaming/index.ts +2 -0
- package/src/components/suggestion/Suggestion.test.tsx +43 -0
- package/src/components/suggestion/Suggestion.tsx +47 -0
- package/src/components/suggestion/index.ts +2 -0
- package/src/components/tool-call/ToolCall.test.tsx +45 -0
- package/src/components/tool-call/ToolCall.tsx +101 -0
- package/src/components/tool-call/index.ts +2 -0
- package/src/index.ts +29 -0
- package/src/manifests/attachment.manifest.json +33 -0
- package/src/manifests/branch.manifest.json +65 -0
- package/src/manifests/chain-thought.manifest.json +39 -0
- package/src/manifests/code-block.manifest.json +50 -0
- package/src/manifests/command-palette.manifest.json +80 -0
- package/src/manifests/confirmation.manifest.json +143 -0
- package/src/manifests/conversation.manifest.json +290 -0
- package/src/manifests/inline-citation.manifest.json +46 -0
- package/src/manifests/message-actions.manifest.json +35 -0
- package/src/manifests/message.manifest.json +235 -0
- package/src/manifests/prompt-input.manifest.json +334 -0
- package/src/manifests/reasoning.manifest.json +177 -0
- package/src/manifests/response.manifest.json +51 -0
- package/src/manifests/source.manifest.json +78 -0
- package/src/manifests/streaming-indicator.manifest.json +39 -0
- package/src/manifests/suggestions.manifest.json +45 -0
- package/src/manifests/tool-call.manifest.json +158 -0
- package/src/test/jest-axe.d.ts +16 -0
- package/src/test/setup.ts +18 -0
- package/src/test/vitest-matchers.d.ts +10 -0
- package/src/types/index.ts +40 -0
- package/README.md +0 -3
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '@slipstream-ui/core';
|
|
5
|
+
import { Message } from '../message';
|
|
6
|
+
import type { Message as MessageType } from '../../types';
|
|
7
|
+
|
|
8
|
+
export type ConversationProps = {
|
|
9
|
+
messages: MessageType[];
|
|
10
|
+
className?: string;
|
|
11
|
+
showAvatars?: boolean;
|
|
12
|
+
userAvatarSrc?: string;
|
|
13
|
+
userAvatarFallback?: string;
|
|
14
|
+
assistantAvatarSrc?: string;
|
|
15
|
+
assistantAvatarFallback?: string;
|
|
16
|
+
isLoading?: boolean;
|
|
17
|
+
loadingMessage?: string;
|
|
18
|
+
/** Custom content renderer applied to every message (e.g. markdown). */
|
|
19
|
+
renderContent?: (content: string) => React.ReactNode;
|
|
20
|
+
/** ID of the message still being generated; renders its streaming cursor. */
|
|
21
|
+
streamingMessageId?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Show a floating "scroll to latest" pill when the user has scrolled up.
|
|
24
|
+
* Default true.
|
|
25
|
+
*/
|
|
26
|
+
showScrollButton?: boolean;
|
|
27
|
+
/** Show a download button that exports the transcript as Markdown. Default false. */
|
|
28
|
+
showDownload?: boolean;
|
|
29
|
+
/** Per-message generation failures keyed by message id; renders retry cards. */
|
|
30
|
+
messageErrors?: Record<string, string>;
|
|
31
|
+
/** Retry handler for failed messages in `messageErrors`. */
|
|
32
|
+
onRetryMessage?: (message: MessageType) => void;
|
|
33
|
+
|
|
34
|
+
// --- Empty state -------------------------------------------------------
|
|
35
|
+
/** Heading for the empty-transcript welcome state. */
|
|
36
|
+
welcomeTitle?: string;
|
|
37
|
+
/** Supporting copy for the welcome state. */
|
|
38
|
+
welcomeDescription?: string;
|
|
39
|
+
/** Starter prompts rendered as buttons under the welcome copy. */
|
|
40
|
+
welcomeSuggestions?: string[];
|
|
41
|
+
/** Called with the chosen starter prompt text. */
|
|
42
|
+
onWelcomeSelect?: (prompt: string) => void;
|
|
43
|
+
|
|
44
|
+
// --- History pagination --------------------------------------------------
|
|
45
|
+
/** Older history exists; renders a "load earlier" control at the top. */
|
|
46
|
+
hasMoreHistory?: boolean;
|
|
47
|
+
/** Older history request is in flight; shows a loading row. */
|
|
48
|
+
isLoadingMore?: boolean;
|
|
49
|
+
/** Called when the user requests older history. */
|
|
50
|
+
onLoadMore?: () => void;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Distance (px) from the bottom within which the view counts as "pinned". */
|
|
54
|
+
const PINNED_THRESHOLD = 80;
|
|
55
|
+
|
|
56
|
+
const DEFAULT_WELCOME_TITLE = 'Start the conversation';
|
|
57
|
+
const DEFAULT_WELCOME_DESCRIPTION = 'Ask anything — responses stream in real time.';
|
|
58
|
+
const DEFAULT_WELCOME_SUGGESTIONS = [
|
|
59
|
+
'Explain what you can do',
|
|
60
|
+
'Summarize a topic for me',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function transcriptToMarkdown(messages: MessageType[]): string {
|
|
64
|
+
return messages
|
|
65
|
+
.map((message) => {
|
|
66
|
+
const speaker =
|
|
67
|
+
message.role === 'user' ? 'You' : message.role === 'assistant' ? 'Assistant' : message.role;
|
|
68
|
+
const time = message.timestamp ? ` (${new Date(message.timestamp).toISOString()})` : '';
|
|
69
|
+
return `**${speaker}${time}:**\n\n${message.content}`;
|
|
70
|
+
})
|
|
71
|
+
.join('\n\n---\n\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const Conversation = React.forwardRef<HTMLDivElement, ConversationProps>(
|
|
75
|
+
({
|
|
76
|
+
messages,
|
|
77
|
+
className,
|
|
78
|
+
showAvatars = true,
|
|
79
|
+
userAvatarSrc,
|
|
80
|
+
userAvatarFallback,
|
|
81
|
+
assistantAvatarSrc,
|
|
82
|
+
assistantAvatarFallback,
|
|
83
|
+
isLoading = false,
|
|
84
|
+
loadingMessage = 'Thinking...',
|
|
85
|
+
renderContent,
|
|
86
|
+
streamingMessageId,
|
|
87
|
+
showScrollButton = true,
|
|
88
|
+
showDownload = false,
|
|
89
|
+
messageErrors,
|
|
90
|
+
onRetryMessage,
|
|
91
|
+
welcomeTitle = DEFAULT_WELCOME_TITLE,
|
|
92
|
+
welcomeDescription = DEFAULT_WELCOME_DESCRIPTION,
|
|
93
|
+
welcomeSuggestions = DEFAULT_WELCOME_SUGGESTIONS,
|
|
94
|
+
onWelcomeSelect,
|
|
95
|
+
hasMoreHistory = false,
|
|
96
|
+
isLoadingMore = false,
|
|
97
|
+
onLoadMore,
|
|
98
|
+
...props
|
|
99
|
+
}, ref) => {
|
|
100
|
+
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
101
|
+
const messagesEndRef = React.useRef<HTMLDivElement>(null);
|
|
102
|
+
const [isUnpinned, setIsUnpinned] = React.useState(false);
|
|
103
|
+
const prevScrollMetrics = React.useRef<{ height: number; top: number } | null>(null);
|
|
104
|
+
|
|
105
|
+
// Announce stream completion to assistive tech: when the streaming id
|
|
106
|
+
// transitions from set to unset, a response just finished.
|
|
107
|
+
const [announcement, setAnnouncement] = React.useState('');
|
|
108
|
+
const prevStreamingRef = React.useRef<string | undefined>(undefined);
|
|
109
|
+
React.useEffect(() => {
|
|
110
|
+
if (prevStreamingRef.current && !streamingMessageId) {
|
|
111
|
+
setAnnouncement('Response complete.');
|
|
112
|
+
}
|
|
113
|
+
prevStreamingRef.current = streamingMessageId;
|
|
114
|
+
}, [streamingMessageId]);
|
|
115
|
+
|
|
116
|
+
// Track pinned state on scroll so the jump-to-latest pill can appear.
|
|
117
|
+
React.useEffect(() => {
|
|
118
|
+
const container = scrollRef.current;
|
|
119
|
+
if (!container) return;
|
|
120
|
+
|
|
121
|
+
const updatePinned = () => {
|
|
122
|
+
const distanceFromBottom =
|
|
123
|
+
container.scrollHeight - container.scrollTop - container.clientHeight;
|
|
124
|
+
setIsUnpinned(distanceFromBottom > PINNED_THRESHOLD);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
updatePinned();
|
|
128
|
+
container.addEventListener('scroll', updatePinned, { passive: true });
|
|
129
|
+
return () => container.removeEventListener('scroll', updatePinned);
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
// Scroll anchoring across history loads: capture metrics before new rows
|
|
133
|
+
// render, then restore the same visual position after they land.
|
|
134
|
+
React.useLayoutEffect(() => {
|
|
135
|
+
const container = scrollRef.current;
|
|
136
|
+
if (!container || prevScrollMetrics.current === null) return;
|
|
137
|
+
|
|
138
|
+
const { height, top } = prevScrollMetrics.current;
|
|
139
|
+
container.scrollTop = container.scrollHeight - height + top;
|
|
140
|
+
prevScrollMetrics.current = null;
|
|
141
|
+
}, [messages]);
|
|
142
|
+
|
|
143
|
+
const captureAnchor = () => {
|
|
144
|
+
const container = scrollRef.current;
|
|
145
|
+
if (!container) return;
|
|
146
|
+
prevScrollMetrics.current = { height: container.scrollHeight, top: container.scrollTop };
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const handleLoadMore = () => {
|
|
150
|
+
captureAnchor();
|
|
151
|
+
onLoadMore?.();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Auto-scroll only when the user is already near the bottom, so reading
|
|
155
|
+
// history is never yanked. During streaming use instant scrolls; smooth
|
|
156
|
+
// animations restarted per token cause jank.
|
|
157
|
+
//
|
|
158
|
+
// Scroll the container itself (never scrollIntoView): scrollIntoView
|
|
159
|
+
// bubbles through every scrollable ancestor, including the document,
|
|
160
|
+
// which yanked the whole page when the demo/preview frames were small.
|
|
161
|
+
React.useEffect(() => {
|
|
162
|
+
const container = scrollRef.current;
|
|
163
|
+
if (!container) return;
|
|
164
|
+
|
|
165
|
+
const distanceFromBottom =
|
|
166
|
+
container.scrollHeight - container.scrollTop - container.clientHeight;
|
|
167
|
+
if (distanceFromBottom > PINNED_THRESHOLD) return;
|
|
168
|
+
|
|
169
|
+
container.scrollTo?.({
|
|
170
|
+
top: container.scrollHeight,
|
|
171
|
+
behavior: streamingMessageId ? 'auto' : 'smooth',
|
|
172
|
+
});
|
|
173
|
+
}, [messages, streamingMessageId]);
|
|
174
|
+
|
|
175
|
+
const scrollToLatest = () => {
|
|
176
|
+
const container = scrollRef.current;
|
|
177
|
+
if (!container) return;
|
|
178
|
+
container.scrollTo?.({ top: container.scrollHeight, behavior: 'smooth' });
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const handleDownload = () => {
|
|
182
|
+
const blob = new Blob([transcriptToMarkdown(messages)], { type: 'text/markdown' });
|
|
183
|
+
const url = URL.createObjectURL(blob);
|
|
184
|
+
const anchor = document.createElement('a');
|
|
185
|
+
anchor.href = url;
|
|
186
|
+
anchor.download = 'conversation.md';
|
|
187
|
+
anchor.click();
|
|
188
|
+
URL.revokeObjectURL(url);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const isEmpty = messages.length === 0 && !isLoading;
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div ref={ref} className={cn('relative h-full w-full', className)} {...props}>
|
|
195
|
+
<div ref={scrollRef} className="flex flex-col gap-4 p-4 overflow-y-auto h-full">
|
|
196
|
+
{(hasMoreHistory || isLoadingMore) && (
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
onClick={handleLoadMore}
|
|
200
|
+
disabled={isLoadingMore}
|
|
201
|
+
className="mx-auto rounded-full border border-border bg-background px-3 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
202
|
+
>
|
|
203
|
+
{isLoadingMore ? 'Loading…' : 'Load earlier messages'}
|
|
204
|
+
</button>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{isEmpty ? (
|
|
208
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-center py-16">
|
|
209
|
+
<h3 className="text-lg font-semibold">{welcomeTitle}</h3>
|
|
210
|
+
<p className="text-sm text-muted-foreground max-w-sm">{welcomeDescription}</p>
|
|
211
|
+
{welcomeSuggestions.length > 0 && (
|
|
212
|
+
<div className="mt-2 flex flex-col gap-2 w-full max-w-sm">
|
|
213
|
+
{welcomeSuggestions.map((suggestion) => (
|
|
214
|
+
<button
|
|
215
|
+
key={suggestion}
|
|
216
|
+
type="button"
|
|
217
|
+
onClick={() => onWelcomeSelect?.(suggestion)}
|
|
218
|
+
className="rounded-md border border-border bg-background px-4 py-2.5 text-sm text-left text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
219
|
+
>
|
|
220
|
+
{suggestion}
|
|
221
|
+
</button>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
) : (
|
|
227
|
+
messages.map((message) => (
|
|
228
|
+
<Message
|
|
229
|
+
key={message.id}
|
|
230
|
+
message={message}
|
|
231
|
+
showAvatar={showAvatars}
|
|
232
|
+
avatarSrc={
|
|
233
|
+
message.role === 'user' ? userAvatarSrc : assistantAvatarSrc
|
|
234
|
+
}
|
|
235
|
+
avatarFallback={
|
|
236
|
+
message.role === 'user' ? userAvatarFallback : assistantAvatarFallback
|
|
237
|
+
}
|
|
238
|
+
renderContent={renderContent}
|
|
239
|
+
isStreaming={message.id === streamingMessageId}
|
|
240
|
+
error={messageErrors?.[message.id]}
|
|
241
|
+
onRetry={
|
|
242
|
+
onRetryMessage && messageErrors?.[message.id]
|
|
243
|
+
? () => onRetryMessage(message)
|
|
244
|
+
: undefined
|
|
245
|
+
}
|
|
246
|
+
/>
|
|
247
|
+
))
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{isLoading && !isEmpty && (
|
|
251
|
+
<Message
|
|
252
|
+
message={{
|
|
253
|
+
id: '__loading__',
|
|
254
|
+
role: 'assistant',
|
|
255
|
+
content: loadingMessage,
|
|
256
|
+
}}
|
|
257
|
+
showAvatar={showAvatars}
|
|
258
|
+
avatarSrc={assistantAvatarSrc}
|
|
259
|
+
avatarFallback={assistantAvatarFallback}
|
|
260
|
+
className="bg-muted/50"
|
|
261
|
+
renderContent={(content) => (
|
|
262
|
+
<span className="text-muted-foreground animate-pulse motion-reduce:animate-none">{content}</span>
|
|
263
|
+
)}
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
{/* Screen-reader announcement when a streamed response completes. */}
|
|
268
|
+
<div aria-live="polite" className="sr-only">{announcement}</div>
|
|
269
|
+
|
|
270
|
+
<div ref={messagesEndRef} />
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
{showDownload && messages.length > 0 && (
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
onClick={handleDownload}
|
|
277
|
+
aria-label="Download transcript"
|
|
278
|
+
className="absolute top-2 right-2 z-10 rounded-md border border-border bg-background/90 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
279
|
+
>
|
|
280
|
+
↓ .md
|
|
281
|
+
</button>
|
|
282
|
+
)}
|
|
283
|
+
|
|
284
|
+
{showScrollButton && isUnpinned && (
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
onClick={scrollToLatest}
|
|
288
|
+
aria-label="Scroll to latest message"
|
|
289
|
+
className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full border border-border bg-background/95 px-3 py-1.5 text-xs font-medium shadow-sm hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
290
|
+
>
|
|
291
|
+
↓ Latest
|
|
292
|
+
</button>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
Conversation.displayName = 'Conversation';
|
|
300
|
+
|
|
301
|
+
export { Conversation };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Conversation } from './Conversation';
|
|
5
|
+
|
|
6
|
+
describe('Conversation welcome state', () => {
|
|
7
|
+
it('renders welcome copy and starter prompts when empty', async () => {
|
|
8
|
+
const user = userEvent.setup();
|
|
9
|
+
const onWelcomeSelect = vi.fn();
|
|
10
|
+
render(
|
|
11
|
+
<Conversation
|
|
12
|
+
messages={[]}
|
|
13
|
+
welcomeTitle="Hello"
|
|
14
|
+
welcomeDescription="Pick a starter"
|
|
15
|
+
welcomeSuggestions={['Tell me a joke']}
|
|
16
|
+
onWelcomeSelect={onWelcomeSelect}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect(screen.getByText('Hello')).toBeInTheDocument();
|
|
21
|
+
await user.click(screen.getByRole('button', { name: 'Tell me a joke' }));
|
|
22
|
+
expect(onWelcomeSelect).toHaveBeenCalledWith('Tell me a joke');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('does not show the welcome state once messages exist', () => {
|
|
26
|
+
render(
|
|
27
|
+
<Conversation
|
|
28
|
+
messages={[{ id: '1', role: 'user', content: 'Hi' }]}
|
|
29
|
+
welcomeTitle="Hello"
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
expect(screen.queryByText('Hello')).not.toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('hides the welcome state while loading the first response', () => {
|
|
36
|
+
render(<Conversation messages={[]} isLoading welcomeTitle="Hello" />);
|
|
37
|
+
expect(screen.queryByText('Hello')).not.toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('Conversation history pagination', () => {
|
|
42
|
+
it('shows the load-earlier control only when history exists', async () => {
|
|
43
|
+
const user = userEvent.setup();
|
|
44
|
+
const onLoadMore = vi.fn();
|
|
45
|
+
const { rerender } = render(
|
|
46
|
+
<Conversation messages={[{ id: '1', role: 'user', content: 'Hi' }]} onLoadMore={onLoadMore} />
|
|
47
|
+
);
|
|
48
|
+
// No control without hasMoreHistory.
|
|
49
|
+
expect(screen.queryByRole('button', { name: /Load earlier/ })).not.toBeInTheDocument();
|
|
50
|
+
|
|
51
|
+
rerender(
|
|
52
|
+
<Conversation
|
|
53
|
+
messages={[{ id: '1', role: 'user', content: 'Hi' }]}
|
|
54
|
+
hasMoreHistory
|
|
55
|
+
onLoadMore={onLoadMore}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
await user.click(screen.getByRole('button', { name: /Load earlier/ }));
|
|
59
|
+
expect(onLoadMore).toHaveBeenCalledOnce();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('disables the control while loading more', () => {
|
|
63
|
+
render(
|
|
64
|
+
<Conversation
|
|
65
|
+
messages={[{ id: '1', role: 'user', content: 'Hi' }]}
|
|
66
|
+
hasMoreHistory
|
|
67
|
+
isLoadingMore
|
|
68
|
+
onLoadMore={() => {}}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
expect(screen.getByRole('button', { name: /Loading/ })).toBeDisabled();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { axe } from 'jest-axe';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { InlineCitation } from './InlineCitation';
|
|
6
|
+
|
|
7
|
+
const source = {
|
|
8
|
+
id: 'src-1',
|
|
9
|
+
title: 'React docs',
|
|
10
|
+
url: 'https://react.dev',
|
|
11
|
+
snippet: 'The library for web and native user interfaces.',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('InlineCitation', () => {
|
|
15
|
+
it('renders the citation chip with a tooltip target', () => {
|
|
16
|
+
render(<InlineCitation source={source} />);
|
|
17
|
+
const trigger = screen.getByRole('button');
|
|
18
|
+
expect(trigger).toHaveAttribute('aria-describedby', 'citation-src-1-card');
|
|
19
|
+
expect(screen.getByRole('tooltip')).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('wraps custom children instead of the title', () => {
|
|
23
|
+
render(
|
|
24
|
+
<InlineCitation source={source}>
|
|
25
|
+
as stated in the docs
|
|
26
|
+
</InlineCitation>
|
|
27
|
+
);
|
|
28
|
+
expect(screen.getByRole('button')).toHaveTextContent('as stated in the docs');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('is keyboard reachable', async () => {
|
|
32
|
+
const user = userEvent.setup();
|
|
33
|
+
render(<InlineCitation source={source} />);
|
|
34
|
+
await user.tab();
|
|
35
|
+
expect(screen.getByRole('button')).toHaveFocus();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('has no accessibility violations', async () => {
|
|
39
|
+
const { container } = render(<InlineCitation source={source} />);
|
|
40
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { cn } from '@slipstream-ui/core';
|
|
3
|
+
import type { SourceItem } from '../../types';
|
|
4
|
+
|
|
5
|
+
export type InlineCitationProps = {
|
|
6
|
+
/** The cited source; powers the hover/focus preview card. */
|
|
7
|
+
source: SourceItem;
|
|
8
|
+
/** Citation text to wrap (defaults to the source title). */
|
|
9
|
+
children?: React.ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Perplexity-style inline citation: a superscript chip that reveals the
|
|
15
|
+
* source snippet on hover or keyboard focus.
|
|
16
|
+
*/
|
|
17
|
+
const InlineCitation = React.forwardRef<HTMLSpanElement, InlineCitationProps>(
|
|
18
|
+
({ source, children, className, ...props }, ref) => {
|
|
19
|
+
return (
|
|
20
|
+
<span
|
|
21
|
+
ref={ref}
|
|
22
|
+
className={cn('group relative inline focus-within:outline-none', className)}
|
|
23
|
+
{...props}
|
|
24
|
+
>
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
aria-describedby={`citation-${source.id}-card`}
|
|
28
|
+
className="inline-flex items-baseline rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
29
|
+
>
|
|
30
|
+
{children ?? <span>{source.title}</span>}
|
|
31
|
+
<span
|
|
32
|
+
aria-hidden="true"
|
|
33
|
+
className="inline-flex items-center justify-center align-super ml-0.5 h-4 min-w-4 px-1 rounded-full border border-border bg-muted text-[10px] font-medium text-muted-foreground group-hover:text-foreground transition-colors"
|
|
34
|
+
>
|
|
35
|
+
{source.title.charAt(0).toUpperCase()}
|
|
36
|
+
</span>
|
|
37
|
+
</button>
|
|
38
|
+
<span
|
|
39
|
+
id={`citation-${source.id}-card`}
|
|
40
|
+
role="tooltip"
|
|
41
|
+
className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 -translate-x-1/2 w-64 rounded-md border border-border bg-popover p-2.5 text-xs text-popover-foreground shadow-md opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity"
|
|
42
|
+
>
|
|
43
|
+
<span className="block font-medium text-foreground truncate">{source.title}</span>
|
|
44
|
+
{source.snippet && (
|
|
45
|
+
<span className="mt-0.5 block text-muted-foreground line-clamp-3">
|
|
46
|
+
{source.snippet}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
</span>
|
|
50
|
+
</span>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
InlineCitation.displayName = 'InlineCitation';
|
|
55
|
+
|
|
56
|
+
export { InlineCitation };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Message } from './Message';
|
|
5
|
+
|
|
6
|
+
const base = { id: 'm1', role: 'assistant' as const, content: 'Hello' };
|
|
7
|
+
|
|
8
|
+
describe('Message error/retry', () => {
|
|
9
|
+
it('renders an error alert with retry action', async () => {
|
|
10
|
+
const user = userEvent.setup();
|
|
11
|
+
const onRetry = vi.fn();
|
|
12
|
+
render(<Message message={base} error="Generation failed" onRetry={onRetry} />);
|
|
13
|
+
|
|
14
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Generation failed');
|
|
15
|
+
await user.click(screen.getByRole('button', { name: /Retry/ }));
|
|
16
|
+
expect(onRetry).toHaveBeenCalledOnce();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('hides retry without a handler', () => {
|
|
20
|
+
render(<Message message={base} error="Generation failed" />);
|
|
21
|
+
expect(screen.getByText('Generation failed')).toBeInTheDocument();
|
|
22
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('Message edit flow', () => {
|
|
27
|
+
it('swaps content into an editable textarea and reports the commit', async () => {
|
|
28
|
+
const user = userEvent.setup();
|
|
29
|
+
const onEditContent = vi.fn();
|
|
30
|
+
render(
|
|
31
|
+
<Message message={{ id: 'u1', role: 'user', content: 'Original question' }} onEditContent={onEditContent} />
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
await user.click(screen.getByRole('button', { name: 'Edit' }));
|
|
35
|
+
const textarea = screen.getByLabelText('Edit message');
|
|
36
|
+
expect(textarea).toHaveValue('Original question');
|
|
37
|
+
|
|
38
|
+
await user.clear(textarea);
|
|
39
|
+
await user.type(textarea, 'Revised question');
|
|
40
|
+
await user.click(screen.getByRole('button', { name: 'Save & resend' }));
|
|
41
|
+
|
|
42
|
+
expect(onEditContent).toHaveBeenCalledWith('Revised question');
|
|
43
|
+
expect(screen.queryByLabelText('Edit message')).not.toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('cancels editing without reporting', async () => {
|
|
47
|
+
const user = userEvent.setup();
|
|
48
|
+
const onEditContent = vi.fn();
|
|
49
|
+
render(<Message message={base} onEditContent={onEditContent} />);
|
|
50
|
+
|
|
51
|
+
await user.click(screen.getByRole('button', { name: 'Edit' }));
|
|
52
|
+
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
53
|
+
expect(onEditContent).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not render Edit without onEditContent', () => {
|
|
57
|
+
render(<Message message={base} />);
|
|
58
|
+
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { axe } from 'jest-axe';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { Message } from './Message';
|
|
5
|
+
import type { Message as MessageType } from '../../types';
|
|
6
|
+
|
|
7
|
+
const userMessage: MessageType = {
|
|
8
|
+
id: 'm1',
|
|
9
|
+
role: 'user',
|
|
10
|
+
content: 'What is the capital of France?',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const assistantMessage: MessageType = {
|
|
14
|
+
id: 'm2',
|
|
15
|
+
role: 'assistant',
|
|
16
|
+
content: 'The capital of France is Paris.',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('Message', () => {
|
|
20
|
+
it('renders message content', () => {
|
|
21
|
+
render(<Message message={userMessage} />);
|
|
22
|
+
expect(screen.getByText('What is the capital of France?')).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('defaults to English role labels', () => {
|
|
26
|
+
render(
|
|
27
|
+
<div>
|
|
28
|
+
<Message message={userMessage} />
|
|
29
|
+
<Message message={assistantMessage} />
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
expect(screen.getByText('You')).toBeInTheDocument();
|
|
33
|
+
expect(screen.getByText('Assistant')).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('supports localized role labels', () => {
|
|
37
|
+
render(<Message message={userMessage} userLabel="Sie" />);
|
|
38
|
+
expect(screen.getByText('Sie')).toBeInTheDocument();
|
|
39
|
+
expect(screen.queryByText('You')).not.toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('shows a streaming cursor and aria-busy while isStreaming', () => {
|
|
43
|
+
render(<Message message={assistantMessage} isStreaming />);
|
|
44
|
+
expect(screen.getByTestId('streaming-cursor')).toBeInTheDocument();
|
|
45
|
+
const bubble = screen.getByText(assistantMessage.content).closest('[aria-busy]');
|
|
46
|
+
expect(bubble).not.toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not show a cursor when not streaming', () => {
|
|
50
|
+
render(<Message message={assistantMessage} />);
|
|
51
|
+
expect(screen.queryByTestId('streaming-cursor')).not.toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('delegates rendering to renderContent when provided', () => {
|
|
55
|
+
render(
|
|
56
|
+
<Message
|
|
57
|
+
message={{ ...assistantMessage, content: '**bold** text' }}
|
|
58
|
+
renderContent={(content) => <strong data-testid="custom">{content}</strong>}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
const custom = screen.getByTestId('custom');
|
|
62
|
+
expect(custom).toHaveTextContent('**bold** text');
|
|
63
|
+
// Default plain-text node is replaced by the custom renderer output.
|
|
64
|
+
expect(custom.parentElement).not.toHaveTextContent('whitespace-pre-wrap');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('shows a timestamp when provided', () => {
|
|
68
|
+
render(
|
|
69
|
+
<Message
|
|
70
|
+
message={{ ...assistantMessage, timestamp: new Date('2026-01-01T12:00:00') }}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
expect(screen.getByText(/:\d{2}/)).toBeInTheDocument();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('has no accessibility violations', async () => {
|
|
77
|
+
const { container } = render(<Message message={userMessage} />);
|
|
78
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
79
|
+
});
|
|
80
|
+
});
|