@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.
Files changed (111) hide show
  1. package/LICENSE +25 -0
  2. package/dist/index.d.mts +579 -0
  3. package/dist/index.d.ts +579 -0
  4. package/dist/index.js +1778 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +1706 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/dist/manifests/attachment.manifest.json +33 -0
  9. package/dist/manifests/branch.manifest.json +65 -0
  10. package/dist/manifests/chain-thought.manifest.json +39 -0
  11. package/dist/manifests/code-block.manifest.json +50 -0
  12. package/dist/manifests/command-palette.manifest.json +80 -0
  13. package/dist/manifests/confirmation.manifest.json +143 -0
  14. package/dist/manifests/conversation.manifest.json +290 -0
  15. package/dist/manifests/inline-citation.manifest.json +46 -0
  16. package/dist/manifests/message-actions.manifest.json +35 -0
  17. package/dist/manifests/message.manifest.json +235 -0
  18. package/dist/manifests/prompt-input.manifest.json +334 -0
  19. package/dist/manifests/reasoning.manifest.json +177 -0
  20. package/dist/manifests/response.manifest.json +51 -0
  21. package/dist/manifests/source.manifest.json +78 -0
  22. package/dist/manifests/streaming-indicator.manifest.json +39 -0
  23. package/dist/manifests/suggestions.manifest.json +45 -0
  24. package/dist/manifests/tool-call.manifest.json +158 -0
  25. package/package.json +61 -3
  26. package/src/adapter/adapter.test.tsx +83 -0
  27. package/src/adapter/hooks.ts +82 -0
  28. package/src/adapter/index.ts +3 -0
  29. package/src/adapter/messages.ts +49 -0
  30. package/src/adapter/types.ts +24 -0
  31. package/src/components/attachment/Attachment.test.tsx +72 -0
  32. package/src/components/attachment/Attachment.tsx +84 -0
  33. package/src/components/attachment/AttachmentList.tsx +30 -0
  34. package/src/components/attachment/index.ts +5 -0
  35. package/src/components/branch/Branch.test.tsx +52 -0
  36. package/src/components/branch/Branch.tsx +77 -0
  37. package/src/components/branch/index.ts +2 -0
  38. package/src/components/chainthought/ChainThought.test.tsx +34 -0
  39. package/src/components/chainthought/ChainThought.tsx +45 -0
  40. package/src/components/chainthought/index.ts +2 -0
  41. package/src/components/code-block/CodeBlock.test.tsx +46 -0
  42. package/src/components/code-block/CodeBlock.tsx +95 -0
  43. package/src/components/code-block/index.ts +2 -0
  44. package/src/components/command-palette/CommandPalette.test.tsx +74 -0
  45. package/src/components/command-palette/CommandPalette.tsx +156 -0
  46. package/src/components/command-palette/index.ts +2 -0
  47. package/src/components/confirmation/Confirmation.test.tsx +97 -0
  48. package/src/components/confirmation/Confirmation.tsx +146 -0
  49. package/src/components/confirmation/index.ts +2 -0
  50. package/src/components/conversation/Conversation.test.tsx +49 -0
  51. package/src/components/conversation/Conversation.tsx +301 -0
  52. package/src/components/conversation/Conversation.welcome.test.tsx +73 -0
  53. package/src/components/conversation/index.ts +2 -0
  54. package/src/components/inline-citation/InlineCitation.test.tsx +42 -0
  55. package/src/components/inline-citation/InlineCitation.tsx +56 -0
  56. package/src/components/inline-citation/index.ts +2 -0
  57. package/src/components/message/Message.error-edit.test.tsx +60 -0
  58. package/src/components/message/Message.test.tsx +80 -0
  59. package/src/components/message/Message.tsx +205 -0
  60. package/src/components/message/index.ts +2 -0
  61. package/src/components/message-actions/MessageActions.test.tsx +77 -0
  62. package/src/components/message-actions/MessageActions.tsx +134 -0
  63. package/src/components/message-actions/index.ts +2 -0
  64. package/src/components/prompt-input/PromptInput.compound.test.tsx +150 -0
  65. package/src/components/prompt-input/PromptInput.test.tsx +74 -0
  66. package/src/components/prompt-input/PromptInput.tsx +584 -0
  67. package/src/components/prompt-input/index.ts +21 -0
  68. package/src/components/reasoning/Reasoning.duration.test.tsx +35 -0
  69. package/src/components/reasoning/Reasoning.test.tsx +58 -0
  70. package/src/components/reasoning/Reasoning.tsx +85 -0
  71. package/src/components/reasoning/index.ts +2 -0
  72. package/src/components/response/Response.test.tsx +69 -0
  73. package/src/components/response/Response.tsx +75 -0
  74. package/src/components/response/close-incomplete-markdown.ts +47 -0
  75. package/src/components/response/index.ts +3 -0
  76. package/src/components/source/Source.tsx +51 -0
  77. package/src/components/source/Sources.tsx +42 -0
  78. package/src/components/source/index.ts +5 -0
  79. package/src/components/source/source.test.tsx +72 -0
  80. package/src/components/streaming/StreamingIndicator.test.tsx +26 -0
  81. package/src/components/streaming/StreamingIndicator.tsx +46 -0
  82. package/src/components/streaming/index.ts +2 -0
  83. package/src/components/suggestion/Suggestion.test.tsx +43 -0
  84. package/src/components/suggestion/Suggestion.tsx +47 -0
  85. package/src/components/suggestion/index.ts +2 -0
  86. package/src/components/tool-call/ToolCall.test.tsx +45 -0
  87. package/src/components/tool-call/ToolCall.tsx +101 -0
  88. package/src/components/tool-call/index.ts +2 -0
  89. package/src/index.ts +29 -0
  90. package/src/manifests/attachment.manifest.json +33 -0
  91. package/src/manifests/branch.manifest.json +65 -0
  92. package/src/manifests/chain-thought.manifest.json +39 -0
  93. package/src/manifests/code-block.manifest.json +50 -0
  94. package/src/manifests/command-palette.manifest.json +80 -0
  95. package/src/manifests/confirmation.manifest.json +143 -0
  96. package/src/manifests/conversation.manifest.json +290 -0
  97. package/src/manifests/inline-citation.manifest.json +46 -0
  98. package/src/manifests/message-actions.manifest.json +35 -0
  99. package/src/manifests/message.manifest.json +235 -0
  100. package/src/manifests/prompt-input.manifest.json +334 -0
  101. package/src/manifests/reasoning.manifest.json +177 -0
  102. package/src/manifests/response.manifest.json +51 -0
  103. package/src/manifests/source.manifest.json +78 -0
  104. package/src/manifests/streaming-indicator.manifest.json +39 -0
  105. package/src/manifests/suggestions.manifest.json +45 -0
  106. package/src/manifests/tool-call.manifest.json +158 -0
  107. package/src/test/jest-axe.d.ts +16 -0
  108. package/src/test/setup.ts +18 -0
  109. package/src/test/vitest-matchers.d.ts +10 -0
  110. package/src/types/index.ts +40 -0
  111. 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,2 @@
1
+ export { Conversation } from './Conversation';
2
+ export type { ConversationProps } from './Conversation';
@@ -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,2 @@
1
+ export { InlineCitation } from './InlineCitation';
2
+ export type { InlineCitationProps } from './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
+ });