@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,85 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '@slipstream-ui/core';
5
+ import type { ReasoningStep } from '../../types';
6
+
7
+ export type ReasoningProps = {
8
+ steps: ReasoningStep[];
9
+ className?: string;
10
+ collapsible?: boolean;
11
+ defaultExpanded?: boolean;
12
+ /** Elapsed thinking time, e.g. 4 (seconds) or "4s"; rendered in the header. */
13
+ duration?: number | string;
14
+ /** Marks reasoning as still arriving: animated header state. */
15
+ isStreaming?: boolean;
16
+ };
17
+
18
+ const Reasoning = React.forwardRef<HTMLDivElement, ReasoningProps>(
19
+ ({ steps, className, collapsible = true, defaultExpanded = false, duration, isStreaming = false, ...props }, ref) => {
20
+ const [isExpanded, setIsExpanded] = React.useState(defaultExpanded);
21
+ const contentId = React.useId();
22
+
23
+ if (steps.length === 0) {
24
+ return null;
25
+ }
26
+
27
+ const headerText = [
28
+ 'Reasoning',
29
+ `(${steps.length} ${steps.length === 1 ? 'step' : 'steps'})`,
30
+ typeof duration === 'number' ? `· ${duration}s` : duration ? `· ${duration}` : '',
31
+ isStreaming ? '· thinking…' : '',
32
+ ]
33
+ .filter(Boolean)
34
+ .join(' ');
35
+
36
+ return (
37
+ <div ref={ref} className={cn('border-l-2 border-muted-foreground/30 pl-4', className)} {...props}>
38
+ {collapsible ? (
39
+ <button
40
+ onClick={() => setIsExpanded(!isExpanded)}
41
+ className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
42
+ aria-expanded={isExpanded}
43
+ aria-controls={contentId}
44
+ >
45
+ <span className={cn('transition-transform', isExpanded ? 'rotate-90' : '')}>▶</span>
46
+ <span>{headerText}</span>
47
+ {isStreaming && (
48
+ <span className="flex gap-0.5" aria-hidden="true">
49
+ {[0, 150, 300].map((delay) => (
50
+ <span
51
+ key={delay}
52
+ className="h-1 w-1 rounded-full bg-muted-foreground animate-pulse motion-reduce:animate-none"
53
+ style={{ animationDelay: `${delay}ms` }}
54
+ />
55
+ ))}
56
+ </span>
57
+ )}
58
+ </button>
59
+ ) : null}
60
+
61
+ {(isExpanded || !collapsible) && (
62
+ <div id={contentId} className="mt-3 space-y-3">
63
+ {steps.map((step, index) => (
64
+ <div key={step.id} className="flex gap-3">
65
+ <div className="w-3 h-3 rounded-full bg-primary/20 border-2 border-primary flex-shrink-0 mt-1" />
66
+ <div className="text-sm flex-1">
67
+ <div className="font-medium text-foreground mb-1">Step {index + 1}</div>
68
+ <div className="text-muted-foreground">{step.content}</div>
69
+ {step.timestamp && (
70
+ <div className="text-xs text-muted-foreground mt-1">
71
+ {new Date(step.timestamp).toLocaleTimeString()}
72
+ </div>
73
+ )}
74
+ </div>
75
+ </div>
76
+ ))}
77
+ </div>
78
+ )}
79
+ </div>
80
+ );
81
+ }
82
+ );
83
+ Reasoning.displayName = 'Reasoning';
84
+
85
+ export { Reasoning };
@@ -0,0 +1,2 @@
1
+ export { Reasoning } from './Reasoning';
2
+ export type { ReasoningProps } from './Reasoning';
@@ -0,0 +1,69 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { Response } from './Response';
4
+ import { closeIncompleteMarkdown } from './close-incomplete-markdown';
5
+
6
+ describe('closeIncompleteMarkdown', () => {
7
+ it('leaves complete markdown untouched', () => {
8
+ const text = 'Hello **world** and `code`';
9
+ expect(closeIncompleteMarkdown(text)).toBe(text);
10
+ });
11
+
12
+ it('closes an unclosed bold marker', () => {
13
+ expect(closeIncompleteMarkdown('Hello **wor')).toBe('Hello **wor**');
14
+ });
15
+
16
+ it('closes unclosed inline code', () => {
17
+ expect(closeIncompleteMarkdown('Run `npm dev')).toBe('Run `npm dev`');
18
+ });
19
+
20
+ it('closes an unterminated code fence', () => {
21
+ const result = closeIncompleteMarkdown('Example:\n```js\nconst x = 1;');
22
+ expect(result.endsWith('```')).toBe(true);
23
+ });
24
+
25
+ it('does not touch content inside an open fence', () => {
26
+ // The unclosed `**` after the fence is code content and must stay as-is.
27
+ const result = closeIncompleteMarkdown('```js\n// ** not emphasis');
28
+ expect(result.endsWith('```')).toBe(true);
29
+ expect(result).toContain('// ** not emphasis\n```');
30
+ });
31
+ });
32
+
33
+ describe('Response', () => {
34
+ it('renders markdown paragraphs', () => {
35
+ render(<Response>{'# Heading\n\nParagraph with **bold**.'}</Response>);
36
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Heading');
37
+ expect(screen.getByText(/bold/)).toBeInTheDocument();
38
+ });
39
+
40
+ it('renders GFM tables and task lists', () => {
41
+ render(
42
+ <Response>{'| a | b |\n|---|---|\n| 1 | 2 |'}</Response>
43
+ );
44
+ expect(screen.getByRole('table')).toBeInTheDocument();
45
+ });
46
+
47
+ it('renders fenced code through CodeBlock with language label', () => {
48
+ render(<Response>{'```python\nprint("hi")\n```'}</Response>);
49
+ expect(screen.getByText('python')).toBeInTheDocument();
50
+ });
51
+
52
+ it('repairs incomplete bold while streaming', () => {
53
+ render(<Response>{'Partial **mess'}</Response>);
54
+ // The repaired marker means no stray asterisks appear as literal text nodes
55
+ // beyond the closed pair; assert the strong element rendered.
56
+ expect(screen.getByText('mess').closest('strong') ?? screen.queryByText('**')).toBeTruthy();
57
+ });
58
+
59
+ it('passes raw content through when parseIncompleteMarkdown is false', () => {
60
+ render(<Response parseIncompleteMarkdown={false}>{'Partial **mess'}</Response>);
61
+ expect(screen.getByText(/\*\*/)).toBeInTheDocument();
62
+ });
63
+
64
+ it('renders inline code distinctly', () => {
65
+ render(<Response>{'Use `npm install` first.'}</Response>);
66
+ const inline = screen.getByText('npm install');
67
+ expect(inline).toHaveClass('font-mono');
68
+ });
69
+ });
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import ReactMarkdown from 'react-markdown';
5
+ import remarkGfm from 'remark-gfm';
6
+ import { cn } from '@slipstream-ui/core';
7
+ import { CodeBlock } from '../code-block';
8
+ import { closeIncompleteMarkdown } from './close-incomplete-markdown';
9
+
10
+ export type ResponseProps = {
11
+ /** Markdown content to render. */
12
+ children: string;
13
+ /**
14
+ * Repair unclosed emphasis/inline-code/fences while streaming so partial
15
+ * chunks render cleanly (default true).
16
+ */
17
+ parseIncompleteMarkdown?: boolean;
18
+ className?: string;
19
+ };
20
+
21
+ /**
22
+ * Streaming-optimized markdown renderer for AI responses.
23
+ *
24
+ * Wraps react-markdown with GFM support and routes fenced code blocks to
25
+ * the CodeBlock component for highlighting + copy.
26
+ */
27
+ const Response = React.forwardRef<HTMLDivElement, ResponseProps>(
28
+ ({ children, parseIncompleteMarkdown = true, className, ...props }, ref) => {
29
+ const content = React.useMemo(
30
+ () => (parseIncompleteMarkdown ? closeIncompleteMarkdown(children) : children),
31
+ [children, parseIncompleteMarkdown]
32
+ );
33
+
34
+ return (
35
+ <div
36
+ ref={ref}
37
+ className={cn(
38
+ 'text-sm space-y-3 [&_a]:text-primary [&_a]:underline underline-offset-2',
39
+ '[&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5',
40
+ '[&_h1]:text-lg [&_h1]:font-semibold [&_h2]:text-base [&_h2]:font-semibold',
41
+ '[&_h3]:text-sm [&_h3]:font-semibold',
42
+ '[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
43
+ '[&_table]:w-full [&_table]:text-xs [&_th]:border [&_th]:border-border [&_th]:p-1.5 [&_td]:border [&_td]:border-border [&_td]:p-1.5',
44
+ className
45
+ )}
46
+ {...props}
47
+ >
48
+ <ReactMarkdown
49
+ remarkPlugins={[remarkGfm]}
50
+ components={{
51
+ pre: ({ children }) => <>{children}</>,
52
+ code: ({ className: codeClassName, children: codeChildren }) => {
53
+ const raw = String(codeChildren).replace(/\n$/, '');
54
+ const match = /language-(\w+)/.exec(codeClassName ?? '');
55
+ const isInline = !match && !raw.includes('\n');
56
+ if (isInline) {
57
+ return (
58
+ <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono before:content-none after:content-none">
59
+ {raw}
60
+ </code>
61
+ );
62
+ }
63
+ return <CodeBlock code={raw} language={match?.[1] ?? 'text'} />;
64
+ },
65
+ }}
66
+ >
67
+ {content}
68
+ </ReactMarkdown>
69
+ </div>
70
+ );
71
+ }
72
+ );
73
+ Response.displayName = 'Response';
74
+
75
+ export { Response };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Repairs markdown that is mid-stream: closes unclosed bold/italic/strikethrough
3
+ * markers and unterminated inline code / code fences so partial chunks render
4
+ * without flickering or broken syntax.
5
+ *
6
+ * Deliberately conservative — only appends closing characters, never rewrites content.
7
+ */
8
+ export function closeIncompleteMarkdown(markdown: string): string {
9
+ let text = markdown;
10
+
11
+ // Close an unterminated fenced code block (odd number of ``` lines).
12
+ const fenceMatches = text.match(/^`{3,}/gm);
13
+ if (fenceMatches && fenceMatches.length % 2 === 1) {
14
+ text = `${text}\n\`\`\``;
15
+ }
16
+
17
+ // Inline constructs are only repaired outside of code fences for simplicity:
18
+ // if we just opened a fence, the remainder is code and should not be touched.
19
+ const firstFence = text.indexOf('```');
20
+ const hasUnclosedFence = fenceMatches !== null && fenceMatches.length % 2 === 1;
21
+ const prose = hasUnclosedFence && firstFence !== -1 ? text.slice(0, firstFence) : text;
22
+ const rest = hasUnclosedFence && firstFence !== -1 ? text.slice(firstFence) : '';
23
+
24
+ let repaired = prose;
25
+ // Count unescaped emphasis markers; close whichever are odd.
26
+ for (const marker of ['**', '__', '~~']) {
27
+ const occurrences = prose.split(marker).length - 1;
28
+ if (occurrences % 2 === 1) {
29
+ repaired += marker;
30
+ }
31
+ }
32
+ // Single asterisk/underscore italics.
33
+ for (const marker of ['*', '_']) {
34
+ // Only count markers not part of **/__ pairs by stripping doubles first.
35
+ const singles = repaired.replace(/\*\*/g, '').replace(/__/g, '').split(marker).length - 1;
36
+ if (singles % 2 === 1) {
37
+ repaired += marker;
38
+ }
39
+ }
40
+ // Inline code.
41
+ const backtickCount = repaired.replace(/```/g, '').split('`').length - 1;
42
+ if (backtickCount % 2 === 1) {
43
+ repaired += '`';
44
+ }
45
+
46
+ return repaired + rest;
47
+ }
@@ -0,0 +1,3 @@
1
+ export { Response } from './Response';
2
+ export type { ResponseProps } from './Response';
3
+ export { closeIncompleteMarkdown } from './close-incomplete-markdown';
@@ -0,0 +1,51 @@
1
+ import * as React from 'react';
2
+ import { cn } from '@slipstream-ui/core';
3
+ import type { SourceItem } from '../../types';
4
+
5
+ export type SourceProps = {
6
+ source: SourceItem;
7
+ /**
8
+ * 1-based citation marker rendered as a `[n]` prefix.
9
+ * Omit to render the citation without numbering.
10
+ */
11
+ index?: number;
12
+ className?: string;
13
+ };
14
+
15
+ const Source = React.forwardRef<HTMLDivElement, SourceProps>(
16
+ ({ source, index, className, ...props }, ref) => {
17
+ return (
18
+ <div ref={ref} className={cn('flex gap-2 items-start text-sm', className)} {...props}>
19
+ {index !== undefined && (
20
+ <span className="text-muted-foreground select-none" aria-hidden="true">
21
+ [{index}]
22
+ </span>
23
+ )}
24
+ <div className="flex-1 min-w-0">
25
+ <div className="font-medium text-foreground">
26
+ {source.url ? (
27
+ <a
28
+ href={source.url}
29
+ target="_blank"
30
+ rel="noopener noreferrer"
31
+ className="hover:text-primary transition-colors"
32
+ >
33
+ {source.title}
34
+ </a>
35
+ ) : (
36
+ <span>{source.title}</span>
37
+ )}
38
+ </div>
39
+ {source.snippet && (
40
+ <div className="text-xs text-muted-foreground mt-1 line-clamp-2">
41
+ {source.snippet}
42
+ </div>
43
+ )}
44
+ </div>
45
+ </div>
46
+ );
47
+ }
48
+ );
49
+ Source.displayName = 'Source';
50
+
51
+ export { Source };
@@ -0,0 +1,42 @@
1
+ import * as React from 'react';
2
+ import { cn } from '@slipstream-ui/core';
3
+ import type { SourceItem } from '../../types';
4
+ import { Source } from './Source';
5
+
6
+ export type SourcesProps = {
7
+ sources: SourceItem[];
8
+ className?: string;
9
+ maxDisplay?: number;
10
+ /** Heading label; override for localization. */
11
+ label?: string;
12
+ };
13
+
14
+ const Sources = React.forwardRef<HTMLDivElement, SourcesProps>(
15
+ ({ sources, className, maxDisplay = 3, label = 'Sources', ...props }, ref) => {
16
+ const displaySources = maxDisplay ? sources.slice(0, maxDisplay) : sources;
17
+ const hasMore = maxDisplay && sources.length > maxDisplay;
18
+
19
+ if (sources.length === 0) {
20
+ return null;
21
+ }
22
+
23
+ return (
24
+ <div ref={ref} className={cn('mt-4 pt-4 border-t border-border', className)} {...props}>
25
+ <div className="text-xs font-medium text-muted-foreground mb-2">{label}</div>
26
+ <div className="space-y-2">
27
+ {displaySources.map((source, index) => (
28
+ <Source key={source.id} source={source} index={index + 1} />
29
+ ))}
30
+ {hasMore && (
31
+ <div className="text-xs text-muted-foreground">
32
+ +{sources.length - (maxDisplay ?? 0)} more sources
33
+ </div>
34
+ )}
35
+ </div>
36
+ </div>
37
+ );
38
+ }
39
+ );
40
+ Sources.displayName = 'Sources';
41
+
42
+ export { Sources };
@@ -0,0 +1,5 @@
1
+ export { Source } from './Source';
2
+ export type { SourceProps } from './Source';
3
+ export { Sources } from './Sources';
4
+ export type { SourcesProps } from './Sources';
5
+
@@ -0,0 +1,72 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { axe } from 'jest-axe';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { Source } from './Source';
5
+ import { Sources } from './Sources';
6
+
7
+ describe('Source', () => {
8
+ it('renders the title as a link when a URL is provided', () => {
9
+ render(<Source source={{ id: '1', title: 'React docs', url: 'https://react.dev' }} index={1} />);
10
+ const link = screen.getByRole('link', { name: 'React docs' });
11
+ expect(link).toHaveAttribute('href', 'https://react.dev');
12
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
13
+ });
14
+
15
+ it('renders plain text without a URL', () => {
16
+ render(<Source source={{ id: '1', title: 'Internal note' }} />);
17
+ expect(screen.getByText('Internal note')).toBeInTheDocument();
18
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
19
+ });
20
+
21
+ it('shows a citation marker that is hidden from assistive tech', () => {
22
+ render(<Source source={{ id: '1', title: 'Docs' }} index={3} />);
23
+ expect(screen.getByText('[3]')).toHaveAttribute('aria-hidden', 'true');
24
+ });
25
+
26
+ it('omits the marker when no index is given', () => {
27
+ const { container } = render(<Source source={{ id: '1', title: 'Docs' }} />);
28
+ expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
29
+ });
30
+
31
+ it('shows the snippet when provided', () => {
32
+ render(<Source source={{ id: '1', title: 'Docs', snippet: 'Useful excerpt' }} />);
33
+ expect(screen.getByText('Useful excerpt')).toBeInTheDocument();
34
+ });
35
+ });
36
+
37
+ describe('Sources', () => {
38
+ const sources = [
39
+ { id: '1', title: 'First', url: 'https://example.com/1' },
40
+ { id: '2', title: 'Second', url: 'https://example.com/2' },
41
+ { id: '3', title: 'Third', url: 'https://example.com/3' },
42
+ { id: '4', title: 'Fourth', url: 'https://example.com/4' },
43
+ ];
44
+
45
+ it('renders numbered citations in order', () => {
46
+ render(<Sources sources={sources.slice(0, 2)} />);
47
+ expect(screen.getByText('[1]')).toBeInTheDocument();
48
+ expect(screen.getByText('[2]')).toBeInTheDocument();
49
+ expect(screen.getByRole('link', { name: 'First' })).toBeInTheDocument();
50
+ });
51
+
52
+ it('caps display at maxDisplay and shows an overflow count', () => {
53
+ render(<Sources sources={sources} maxDisplay={3} />);
54
+ expect(screen.queryByRole('link', { name: 'Fourth' })).not.toBeInTheDocument();
55
+ expect(screen.getByText('+1 more sources')).toBeInTheDocument();
56
+ });
57
+
58
+ it('supports a custom label', () => {
59
+ render(<Sources sources={sources.slice(0, 1)} label="Referencias" />);
60
+ expect(screen.getByText('Referencias')).toBeInTheDocument();
61
+ });
62
+
63
+ it('renders nothing for an empty list', () => {
64
+ const { container } = render(<Sources sources={[]} />);
65
+ expect(container).toBeEmptyDOMElement();
66
+ });
67
+
68
+ it('has no accessibility violations', async () => {
69
+ const { container } = render(<Sources sources={sources.slice(0, 2)} />);
70
+ expect(await axe(container)).toHaveNoViolations();
71
+ });
72
+ });
@@ -0,0 +1,26 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { axe } from 'jest-axe';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { StreamingIndicator } from './StreamingIndicator';
5
+
6
+ describe('StreamingIndicator', () => {
7
+ it('shows the default text while streaming', () => {
8
+ render(<StreamingIndicator isStreaming />);
9
+ expect(screen.getByText('Generating...')).toBeInTheDocument();
10
+ });
11
+
12
+ it('supports custom text', () => {
13
+ render(<StreamingIndicator isStreaming text="Denkt nach..." />);
14
+ expect(screen.getByText('Denkt nach...')).toBeInTheDocument();
15
+ });
16
+
17
+ it('renders nothing when not streaming', () => {
18
+ const { container } = render(<StreamingIndicator isStreaming={false} />);
19
+ expect(container).toBeEmptyDOMElement();
20
+ });
21
+
22
+ it('has no accessibility violations', async () => {
23
+ const { container } = render(<StreamingIndicator isStreaming />);
24
+ expect(await axe(container)).toHaveNoViolations();
25
+ });
26
+ });
@@ -0,0 +1,46 @@
1
+ import * as React from 'react';
2
+ import { cn } from '@slipstream-ui/core';
3
+
4
+ export type StreamingIndicatorProps = {
5
+ isStreaming: boolean;
6
+ className?: string;
7
+ text?: string;
8
+ };
9
+
10
+ /**
11
+ * Animated three-dot streaming indicator. Announces the streaming state
12
+ * to screen readers via role="status" / aria-live so assistive tech
13
+ * users know generation is in progress.
14
+ */
15
+ const StreamingIndicator = React.forwardRef<HTMLDivElement, StreamingIndicatorProps>(
16
+ ({ isStreaming, className, text = 'Generating...', ...props }, ref) => {
17
+ if (!isStreaming) {
18
+ return null;
19
+ }
20
+
21
+ return (
22
+ <div
23
+ ref={ref}
24
+ role="status"
25
+ aria-live="polite"
26
+ aria-label={text}
27
+ className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}
28
+ {...props}
29
+ >
30
+ <div className="flex gap-1" aria-hidden="true">
31
+ {[0, 150, 300].map((delay) => (
32
+ <span
33
+ key={delay}
34
+ className="w-2 h-2 bg-primary rounded-full animate-pulse motion-reduce:animate-none"
35
+ style={{ animationDelay: `${delay}ms` }}
36
+ />
37
+ ))}
38
+ </div>
39
+ <span>{text}</span>
40
+ </div>
41
+ );
42
+ }
43
+ );
44
+ StreamingIndicator.displayName = 'StreamingIndicator';
45
+
46
+ export { StreamingIndicator };
@@ -0,0 +1,2 @@
1
+ export { StreamingIndicator } from './StreamingIndicator';
2
+ export type { StreamingIndicatorProps } from './StreamingIndicator';
@@ -0,0 +1,43 @@
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, vi } from 'vitest';
5
+ import { Suggestions } from './Suggestion';
6
+
7
+ const suggestions = [
8
+ { id: '1', text: 'Explain recursion' },
9
+ { id: '2', text: 'Write a haiku', type: 'action' as const },
10
+ { id: '3', text: 'Summarize this page' },
11
+ ];
12
+
13
+ describe('Suggestions', () => {
14
+ it('renders every suggestion as a button', () => {
15
+ render(<Suggestions suggestions={suggestions} />);
16
+ expect(screen.getByRole('button', { name: 'Explain recursion' })).toBeInTheDocument();
17
+ expect(screen.getByRole('button', { name: 'Write a haiku' })).toBeInTheDocument();
18
+ });
19
+
20
+ it('calls onSelect with the clicked suggestion', async () => {
21
+ const user = userEvent.setup();
22
+ const handleSelect = vi.fn();
23
+ render(<Suggestions suggestions={suggestions} onSelect={handleSelect} />);
24
+ await user.click(screen.getByRole('button', { name: 'Write a haiku' }));
25
+ expect(handleSelect).toHaveBeenCalledWith(suggestions[1]);
26
+ });
27
+
28
+ it('limits visible suggestions to maxDisplay', () => {
29
+ render(<Suggestions suggestions={suggestions} maxDisplay={2} />);
30
+ expect(screen.getByRole('button', { name: 'Explain recursion' })).toBeInTheDocument();
31
+ expect(screen.queryByRole('button', { name: 'Summarize this page' })).not.toBeInTheDocument();
32
+ });
33
+
34
+ it('renders nothing for an empty list', () => {
35
+ const { container } = render(<Suggestions suggestions={[]} />);
36
+ expect(container).toBeEmptyDOMElement();
37
+ });
38
+
39
+ it('has no accessibility violations', async () => {
40
+ const { container } = render(<Suggestions suggestions={suggestions} />);
41
+ expect(await axe(container)).toHaveNoViolations();
42
+ });
43
+ });
@@ -0,0 +1,47 @@
1
+ import * as React from 'react';
2
+ import { cn } from '@slipstream-ui/core';
3
+
4
+ export type Suggestion = {
5
+ id: string;
6
+ text: string;
7
+ type?: 'action' | 'query' | 'follow-up';
8
+ };
9
+
10
+ export type SuggestionsProps = {
11
+ suggestions: Suggestion[];
12
+ onSelect?: (suggestion: Suggestion) => void;
13
+ className?: string;
14
+ maxDisplay?: number;
15
+ };
16
+
17
+ const Suggestions = React.forwardRef<HTMLDivElement, SuggestionsProps>(
18
+ ({ suggestions, onSelect, className, maxDisplay = 4, ...props }, ref) => {
19
+ const displaySuggestions = maxDisplay ? suggestions.slice(0, maxDisplay) : suggestions;
20
+
21
+ if (suggestions.length === 0) {
22
+ return null;
23
+ }
24
+
25
+ return (
26
+ <div ref={ref} className={cn('mt-4 space-y-2', className)} {...props}>
27
+ {displaySuggestions.map((suggestion) => (
28
+ <button
29
+ key={suggestion.id}
30
+ className={cn(
31
+ 'w-full text-left h-auto py-2 px-3 text-sm rounded-md',
32
+ 'hover:bg-accent hover:text-accent-foreground',
33
+ 'transition-colors',
34
+ 'text-muted-foreground',
35
+ 'focus:outline-none focus:ring-2 focus:ring-ring'
36
+ )}
37
+ onClick={() => onSelect?.(suggestion)}
38
+ >
39
+ {suggestion.text}
40
+ </button>
41
+ ))}
42
+ </div>
43
+ );
44
+ });
45
+ Suggestions.displayName = 'Suggestions';
46
+
47
+ export { Suggestions };
@@ -0,0 +1,2 @@
1
+ export { Suggestions } from './Suggestion';
2
+ export type { SuggestionsProps, Suggestion } from './Suggestion';