@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,205 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Avatar, AvatarImage, AvatarFallback, Button } from '@slipstream-ui/core';
5
+ import { cn } from '@slipstream-ui/core';
6
+ import type { Message as MessageType } from '../../types';
7
+
8
+ export type MessageProps = {
9
+ message: MessageType;
10
+ className?: string;
11
+ showAvatar?: boolean;
12
+ avatarSrc?: string;
13
+ avatarFallback?: string;
14
+ /** Display name for the user role; override for localization. */
15
+ userLabel?: string;
16
+ /** Display name for the assistant role; override for localization. */
17
+ assistantLabel?: string;
18
+ /**
19
+ * Custom content renderer, e.g. for markdown/code blocks.
20
+ * When provided, it fully replaces the default plain-text rendering.
21
+ */
22
+ renderContent?: (content: string) => React.ReactNode;
23
+ /**
24
+ * Marks the response as still being generated: shows a typing cursor
25
+ * and sets aria-busy so assistive tech knows more content is coming.
26
+ */
27
+ isStreaming?: boolean;
28
+ /** Generation failure text; renders an error card in place of content. */
29
+ error?: string;
30
+ /** Called when the user requests a retry after `error`; omit to hide the action. */
31
+ onRetry?: () => void;
32
+ labels?: {
33
+ retry?: string;
34
+ edit?: string;
35
+ save?: string;
36
+ cancel?: string;
37
+ };
38
+ /**
39
+ * Edit-and-resubmit affordance for user messages: renders an Edit action
40
+ * that swaps content into an inline textarea and calls `onEditContent`.
41
+ */
42
+ onEditContent?: (content: string) => void;
43
+ };
44
+
45
+ const Message = React.forwardRef<HTMLDivElement, MessageProps>(
46
+ (
47
+ {
48
+ message,
49
+ className,
50
+ showAvatar = true,
51
+ avatarSrc,
52
+ avatarFallback,
53
+ userLabel = 'You',
54
+ assistantLabel = 'Assistant',
55
+ renderContent,
56
+ isStreaming = false,
57
+ error,
58
+ onRetry,
59
+ onEditContent,
60
+ labels,
61
+ ...props
62
+ },
63
+ ref
64
+ ) => {
65
+ const [isEditing, setIsEditing] = React.useState(false);
66
+ const [draft, setDraft] = React.useState(message.content);
67
+
68
+ const t = {
69
+ retry: labels?.retry ?? 'Retry',
70
+ edit: labels?.edit ?? 'Edit',
71
+ save: labels?.save ?? 'Save & resend',
72
+ cancel: labels?.cancel ?? 'Cancel',
73
+ };
74
+
75
+ const isUser = message.role === 'user';
76
+ const isSystem = message.role === 'system';
77
+ const displayLabel = isUser
78
+ ? userLabel
79
+ : message.role === 'assistant'
80
+ ? assistantLabel
81
+ : message.role;
82
+
83
+ const startEdit = () => {
84
+ setDraft(message.content);
85
+ setIsEditing(true);
86
+ };
87
+
88
+ const commitEdit = () => {
89
+ const trimmed = draft.trim();
90
+ if (!trimmed) return;
91
+ setIsEditing(false);
92
+ onEditContent?.(trimmed);
93
+ };
94
+
95
+ return (
96
+ <div
97
+ ref={ref}
98
+ aria-busy={isStreaming || undefined}
99
+ className={cn(
100
+ 'flex gap-3 p-4 rounded-lg',
101
+ isUser ? 'bg-primary/10' : 'bg-muted',
102
+ isSystem && 'border-l-4 border-muted-foreground',
103
+ error && 'border border-destructive/40',
104
+ className
105
+ )}
106
+ {...props}
107
+ >
108
+ {showAvatar && !isSystem && (
109
+ <Avatar className="h-8 w-8 flex-shrink-0">
110
+ {avatarSrc && <AvatarImage src={avatarSrc} alt={displayLabel} />}
111
+ <AvatarFallback className={cn(
112
+ 'text-xs font-medium',
113
+ isUser ? 'bg-primary text-primary-foreground' : 'bg-secondary text-secondary-foreground'
114
+ )}>
115
+ {avatarFallback || (isUser ? 'U' : 'A')}
116
+ </AvatarFallback>
117
+ </Avatar>
118
+ )}
119
+ <div className="flex-1 min-w-0">
120
+ <div className="flex items-center gap-2 mb-1">
121
+ <span className="text-sm font-medium">{displayLabel}</span>
122
+ {message.timestamp && (
123
+ <span className="text-xs text-muted-foreground">
124
+ {new Date(message.timestamp).toLocaleTimeString()}
125
+ </span>
126
+ )}
127
+ </div>
128
+
129
+ {error ? (
130
+ <div role="alert" className="text-sm">
131
+ <div className="text-destructive mb-2">{error}</div>
132
+ {onRetry && (
133
+ <Button size="sm" variant="outline" onClick={onRetry}>
134
+ ↻ {t.retry}
135
+ </Button>
136
+ )}
137
+ </div>
138
+ ) : isEditing ? (
139
+ <div className="space-y-2">
140
+ <textarea
141
+ value={draft}
142
+ onChange={(event) => setDraft(event.target.value)}
143
+ onKeyDown={(event) => {
144
+ if (event.key === 'Enter' && !event.shiftKey) {
145
+ event.preventDefault();
146
+ commitEdit();
147
+ }
148
+ if (event.key === 'Escape') setIsEditing(false);
149
+ }}
150
+ aria-label="Edit message"
151
+ autoFocus
152
+ rows={Math.min(6, draft.split('\n').length + 1)}
153
+ className="w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
154
+ />
155
+ <div className="flex gap-2">
156
+ <Button size="sm" onClick={commitEdit}>{t.save}</Button>
157
+ <Button size="sm" variant="ghost" onClick={() => setIsEditing(false)}>{t.cancel}</Button>
158
+ </div>
159
+ </div>
160
+ ) : (
161
+ <div className="text-sm whitespace-pre-wrap break-words">
162
+ {renderContent ? (
163
+ renderContent(message.content)
164
+ ) : (
165
+ <>
166
+ {message.content}
167
+ {isStreaming && (
168
+ <span
169
+ className="inline-block w-[2px] h-[1em] ml-0.5 align-text-bottom bg-current animate-pulse motion-reduce:animate-none"
170
+ data-testid="streaming-cursor"
171
+ />
172
+ )}
173
+ </>
174
+ )}
175
+ {onEditContent && !isStreaming && (
176
+ <div>
177
+ <button
178
+ type="button"
179
+ onClick={startEdit}
180
+ aria-label={t.edit}
181
+ className="mt-1 rounded px-1 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
182
+ >
183
+ ✎ {t.edit}
184
+ </button>
185
+ </div>
186
+ )}
187
+ </div>
188
+ )}
189
+ </div>
190
+ </div>
191
+ );
192
+ }
193
+ );
194
+ Message.displayName = 'Message';
195
+
196
+ /**
197
+ * Memoized: during token streaming the parent re-renders on every tick, but
198
+ * only the streaming message's object identity changes. Memo skips re-render
199
+ * (and timestamp re-formatting) for every other message in the transcript.
200
+ * Callback props must be referentially stable for memo to help.
201
+ */
202
+ const MemoizedMessage = React.memo(Message);
203
+ MemoizedMessage.displayName = 'Message';
204
+
205
+ export { MemoizedMessage as Message };
@@ -0,0 +1,2 @@
1
+ export { Message } from './Message';
2
+ export type { MessageProps } from './Message';
@@ -0,0 +1,77 @@
1
+ import { render, screen, waitFor } 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 { MessageActions } from './MessageActions';
6
+
7
+ describe('MessageActions', () => {
8
+ it('copies content and confirms via the copied label', async () => {
9
+ const user = userEvent.setup();
10
+ const onCopy = vi.fn();
11
+ const writeText = vi.fn().mockResolvedValue(undefined);
12
+ Object.defineProperty(navigator, 'clipboard', {
13
+ value: { writeText },
14
+ configurable: true,
15
+ });
16
+
17
+ render(<MessageActions content="Hello world" onCopy={onCopy} />);
18
+ await user.click(screen.getByRole('button', { name: 'Copy' }));
19
+
20
+ expect(writeText).toHaveBeenCalledWith('Hello world');
21
+ expect(onCopy).toHaveBeenCalledWith('Hello world');
22
+ expect(screen.getByText('Copied')).toBeInTheDocument();
23
+
24
+ await waitFor(() => expect(screen.getByText('Copy')).toBeInTheDocument(), {
25
+ timeout: 2500,
26
+ });
27
+ });
28
+
29
+ it('hides copy action when no content is provided', () => {
30
+ render(<MessageActions />);
31
+ expect(screen.queryByText('Copy')).not.toBeInTheDocument();
32
+ });
33
+
34
+ it('reports a rating and toggles it off on second click', async () => {
35
+ const user = userEvent.setup();
36
+ const onFeedback = vi.fn();
37
+ render(<MessageActions onFeedback={onFeedback} />);
38
+
39
+ await user.click(screen.getByRole('button', { name: 'Good response' }));
40
+ expect(onFeedback).toHaveBeenLastCalledWith('up');
41
+
42
+ await user.click(screen.getByRole('button', { name: 'Good response' }));
43
+ expect(onFeedback).toHaveBeenLastCalledWith(undefined);
44
+ });
45
+
46
+ it('exposes pressed state for rating buttons', async () => {
47
+ const user = userEvent.setup();
48
+ render(<MessageActions />);
49
+ const up = screen.getByRole('button', { name: 'Good response' });
50
+ expect(up).toHaveAttribute('aria-pressed', 'false');
51
+ await user.click(up);
52
+ expect(up).toHaveAttribute('aria-pressed', 'true');
53
+ });
54
+
55
+ it('hides regenerate without a callback', () => {
56
+ render(<MessageActions />);
57
+ expect(screen.queryByRole('button', { name: 'Regenerate' })).not.toBeInTheDocument();
58
+ });
59
+
60
+ it('calls onRegenerate', async () => {
61
+ const user = userEvent.setup();
62
+ const onRegenerate = vi.fn();
63
+ render(<MessageActions onRegenerate={onRegenerate} />);
64
+ await user.click(screen.getByRole('button', { name: 'Regenerate' }));
65
+ expect(onRegenerate).toHaveBeenCalledOnce();
66
+ });
67
+
68
+ it('supports localized labels', () => {
69
+ render(<MessageActions content="x" labels={{ copy: 'Kopieren', thumbUp: 'Gut' }} />);
70
+ expect(screen.getByText('Kopieren')).toBeInTheDocument();
71
+ });
72
+
73
+ it('has no accessibility violations', async () => {
74
+ const { container } = render(<MessageActions content="Hi" onRegenerate={() => {}} />);
75
+ expect(await axe(container)).toHaveNoViolations();
76
+ });
77
+ });
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '@slipstream-ui/core';
5
+
6
+ /** User sentiment for an AI response. */
7
+ export type FeedbackRating = 'up' | 'down';
8
+
9
+ export type MessageActionsProps = {
10
+ /**
11
+ * Visibility rule:
12
+ * - 'always': always visible (default)
13
+ * - 'on-hover': fades in when the parent message is hovered/focused
14
+ * (add the `group` class to the parent element)
15
+ * - 'not-last': like 'on-hover', intended for non-latest messages
16
+ */
17
+ autohide?: 'always' | 'on-hover' | 'not-last';
18
+ /**
19
+ * Content to copy. Omit to hide the copy action, or pass a getter when
20
+ * the content lives outside this component.
21
+ */
22
+ content?: string;
23
+ /** Called after the user copies the message; receives the copied text. */
24
+ onCopy?: (content: string) => void;
25
+ /** Called with the selected rating; selecting the same rating clears it (passes undefined). */
26
+ onFeedback?: (rating: FeedbackRating | undefined) => void;
27
+ /** Called when the user requests a regenerated response; omit to hide the action. */
28
+ onRegenerate?: () => void;
29
+ labels?: {
30
+ copy?: string;
31
+ copied?: string;
32
+ thumbUp?: string;
33
+ thumbDown?: string;
34
+ regenerate?: string;
35
+ };
36
+ className?: string;
37
+ };
38
+
39
+ const MessageActions = React.forwardRef<HTMLDivElement, MessageActionsProps>(
40
+ (
41
+ { content, onCopy, onFeedback, onRegenerate, autohide = 'always', labels, className, ...props },
42
+ ref
43
+ ) => {
44
+ const [copied, setCopied] = React.useState(false);
45
+ const [rating, setRating] = React.useState<FeedbackRating | undefined>();
46
+
47
+ const t = {
48
+ copy: labels?.copy ?? 'Copy',
49
+ copied: labels?.copied ?? 'Copied',
50
+ thumbUp: labels?.thumbUp ?? 'Good response',
51
+ thumbDown: labels?.thumbDown ?? 'Bad response',
52
+ regenerate: labels?.regenerate ?? 'Regenerate',
53
+ };
54
+
55
+ const handleCopy = async () => {
56
+ if (!content) return;
57
+ try {
58
+ await navigator.clipboard.writeText(content);
59
+ } catch {
60
+ // Clipboard API may be unavailable (insecure context); still notify so consumers can react.
61
+ }
62
+ setCopied(true);
63
+ window.setTimeout(() => setCopied(false), 2000);
64
+ onCopy?.(content);
65
+ };
66
+
67
+ const handleRating = (next: FeedbackRating) => {
68
+ const updated = rating === next ? undefined : next;
69
+ setRating(updated);
70
+ onFeedback?.(updated);
71
+ };
72
+
73
+ const actionButton =
74
+ 'rounded px-1.5 py-0.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
75
+ const actionButtonIdle =
76
+ 'text-muted-foreground hover:bg-accent hover:text-accent-foreground';
77
+
78
+ return (
79
+ <div
80
+ ref={ref}
81
+ role="toolbar"
82
+ aria-label="Message actions"
83
+ className={cn(
84
+ 'flex items-center gap-1 mt-2',
85
+ autohide !== 'always' &&
86
+ 'opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100 motion-reduce:opacity-100',
87
+ className
88
+ )}
89
+ {...props}
90
+ >
91
+ {content !== undefined && (
92
+ <button type="button" onClick={handleCopy} className={cn(actionButton, actionButtonIdle)}>
93
+ {copied ? t.copied : t.copy}
94
+ </button>
95
+ )}
96
+
97
+ <button
98
+ type="button"
99
+ onClick={() => handleRating('up')}
100
+ aria-label={t.thumbUp}
101
+ aria-pressed={rating === 'up'}
102
+ className={cn(
103
+ actionButton,
104
+ rating === 'up' ? 'text-primary' : actionButtonIdle
105
+ )}
106
+ >
107
+ 👍
108
+ </button>
109
+
110
+ <button
111
+ type="button"
112
+ onClick={() => handleRating('down')}
113
+ aria-label={t.thumbDown}
114
+ aria-pressed={rating === 'down'}
115
+ className={cn(
116
+ actionButton,
117
+ rating === 'down' ? 'text-destructive' : actionButtonIdle
118
+ )}
119
+ >
120
+ 👎
121
+ </button>
122
+
123
+ {onRegenerate && (
124
+ <button type="button" onClick={onRegenerate} className={cn(actionButton, actionButtonIdle)}>
125
+ {t.regenerate}
126
+ </button>
127
+ )}
128
+ </div>
129
+ );
130
+ }
131
+ );
132
+ MessageActions.displayName = 'MessageActions';
133
+
134
+ export { MessageActions };
@@ -0,0 +1,2 @@
1
+ export { MessageActions } from './MessageActions';
2
+ export type { MessageActionsProps, FeedbackRating } from './MessageActions';
@@ -0,0 +1,150 @@
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 { PromptInput, PromptInputFooter, PromptInputSubmit, PromptInputTextarea, PromptInputTools, PromptInputActionAddAttachments, usePromptInputAttachments } from './PromptInput';
5
+
6
+ describe('PromptInput compound primitives', () => {
7
+ function CompoundDemo({ onSubmit }: { onSubmit: (value: string) => void }) {
8
+ return (
9
+ <PromptInput onSubmit={onSubmit}>
10
+ <PromptInputTextarea placeholder="Compound" />
11
+ <PromptInputFooter>
12
+ <PromptInputTools>
13
+ <span>tool</span>
14
+ </PromptInputTools>
15
+ <PromptInputSubmit />
16
+ </PromptInputFooter>
17
+ </PromptInput>
18
+ );
19
+ }
20
+
21
+ it('submits through the compound layout', async () => {
22
+ const user = userEvent.setup();
23
+ const handleSubmit = vi.fn();
24
+ render(<CompoundDemo onSubmit={handleSubmit} />);
25
+
26
+ const textarea = screen.getByPlaceholderText('Compound');
27
+ await user.type(textarea, 'Hello compound');
28
+ expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled();
29
+ await user.click(screen.getByRole('button', { name: 'Send' }));
30
+ expect(handleSubmit).toHaveBeenCalledWith('Hello compound');
31
+ });
32
+
33
+ it('clears the textarea after submit', async () => {
34
+ const user = userEvent.setup();
35
+ render(<CompoundDemo onSubmit={() => {}} />);
36
+ const textarea = screen.getByPlaceholderText('Compound');
37
+ await user.type(textarea, 'x');
38
+ await user.click(screen.getByRole('button', { name: 'Send' }));
39
+ expect(textarea).toHaveValue('');
40
+ });
41
+
42
+ it('throws when a primitive renders outside the root', () => {
43
+ // Silence the expected error boundary noise.
44
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
45
+ expect(() => render(<PromptInputTextarea />)).toThrow();
46
+ spy.mockRestore();
47
+ });
48
+ });
49
+
50
+ describe('PromptInput status-aware submit control', () => {
51
+ it('reflects an error state via the accessible label', () => {
52
+ render(<PromptInput onSubmit={() => {}} hasError />);
53
+ expect(screen.getByRole('button', { name: /Failed/ })).toBeInTheDocument();
54
+ });
55
+
56
+ it('shows the stop control while loading with onStop', () => {
57
+ render(<PromptInput onSubmit={() => {}} onStop={() => {}} isLoading />);
58
+ expect(screen.getByRole('button', { name: 'Stop generating' })).toBeInTheDocument();
59
+ });
60
+ });
61
+
62
+ describe('PromptInput managed attachments', () => {
63
+ it('validates maxFiles and reports typed errors', async () => {
64
+ const user = userEvent.setup();
65
+ const onError = vi.fn();
66
+ function Demo() {
67
+ const attachments = usePromptInputAttachments({
68
+ maxFiles: 1,
69
+ multiple: true,
70
+ onError,
71
+ });
72
+ return (
73
+ <div>
74
+ <button type="button" onClick={() => attachments.add([new File(['a'], 'a.txt'), new File(['b'], 'b.txt')])}>
75
+ add
76
+ </button>
77
+ <span data-testid="count">{attachments.files.length}</span>
78
+ </div>
79
+ );
80
+ }
81
+ render(<Demo />);
82
+
83
+ await user.click(screen.getByRole('button', { name: 'add' }));
84
+ expect(screen.getByTestId('count')).toHaveTextContent('1');
85
+ expect(onError).toHaveBeenCalledWith(
86
+ expect.objectContaining({ code: 'max_files' })
87
+ );
88
+ });
89
+
90
+ it('rejects files that do not match accept', async () => {
91
+ const user = userEvent.setup();
92
+ const onError = vi.fn();
93
+ function Demo() {
94
+ const attachments = usePromptInputAttachments({ accept: 'image/*', onError });
95
+ return (
96
+ <button type="button" onClick={() => attachments.add([new File(['a'], 'a.txt', { type: 'text/plain' })])}>
97
+ add
98
+ </button>
99
+ );
100
+ }
101
+ render(<Demo />);
102
+ await user.click(screen.getByRole('button', { name: 'add' }));
103
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ code: 'accept' }));
104
+ });
105
+
106
+ it('renders managed attachment chips and removes them', async () => {
107
+ const user = userEvent.setup();
108
+ let added = false;
109
+ function Demo() {
110
+ const attachments = usePromptInputAttachments({});
111
+ return (
112
+ <div>
113
+ <button
114
+ type="button"
115
+ onClick={() => {
116
+ if (!added) {
117
+ added = true;
118
+ attachments.add([new File(['a'], 'notes.txt')]);
119
+ }
120
+ }}
121
+ >
122
+ add
123
+ </button>
124
+ {attachments.files.length > 0 && (
125
+ <span>
126
+ {attachments.files.map((f) => (
127
+ <span key={f.id}>{f.name}</span>
128
+ ))}
129
+ </span>
130
+ )}
131
+ </div>
132
+ );
133
+ }
134
+ render(<Demo />);
135
+ await user.click(screen.getByRole('button', { name: 'add' }));
136
+ expect(await screen.findByText('notes.txt')).toBeInTheDocument();
137
+ });
138
+
139
+ it('renders the dictation button only where speech is supported', () => {
140
+ // jsdom has no SpeechRecognition, so dictate tools render nothing.
141
+ render(
142
+ <PromptInput onSubmit={() => {}}>
143
+ <PromptInputTools>
144
+ <PromptInputActionAddAttachments />
145
+ </PromptInputTools>
146
+ </PromptInput>
147
+ );
148
+ expect(screen.queryByRole('button', { name: /Dictate|dictation/i })).not.toBeInTheDocument();
149
+ });
150
+ });
@@ -0,0 +1,74 @@
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 { PromptInput } from './PromptInput';
6
+
7
+ describe('PromptInput', () => {
8
+ it('submits on Enter and clears the field', async () => {
9
+ const user = userEvent.setup();
10
+ const handleSubmit = vi.fn();
11
+ render(<PromptInput onSubmit={handleSubmit} />);
12
+ const textarea = screen.getByRole('textbox');
13
+ await user.type(textarea, 'Hello world');
14
+ await user.keyboard('{Enter}');
15
+ expect(handleSubmit).toHaveBeenCalledWith('Hello world');
16
+ expect(textarea).toHaveValue('');
17
+ });
18
+
19
+ it('inserts a newline on Shift+Enter instead of submitting', async () => {
20
+ const user = userEvent.setup();
21
+ const handleSubmit = vi.fn();
22
+ render(<PromptInput onSubmit={handleSubmit} />);
23
+ await user.type(screen.getByRole('textbox'), 'Line one');
24
+ await user.keyboard('{Shift>}{Enter}{/Shift}');
25
+ expect(handleSubmit).not.toHaveBeenCalled();
26
+ });
27
+
28
+ it('does not submit whitespace-only input', async () => {
29
+ const user = userEvent.setup();
30
+ const handleSubmit = vi.fn();
31
+ render(<PromptInput onSubmit={handleSubmit} />);
32
+ await user.type(screen.getByRole('textbox'), ' ');
33
+ await user.keyboard('{Enter}');
34
+ expect(handleSubmit).not.toHaveBeenCalled();
35
+ });
36
+
37
+ it('disables the send button while loading without an onStop handler', async () => {
38
+ const user = userEvent.setup();
39
+ const handleSubmit = vi.fn();
40
+ render(<PromptInput onSubmit={handleSubmit} isLoading />);
41
+ const send = screen.getByRole('button', { name: 'Send' });
42
+ expect(send).toBeDisabled();
43
+ await user.click(send);
44
+ expect(handleSubmit).not.toHaveBeenCalled();
45
+ });
46
+
47
+ it('turns the send button into a stop button while loading with onStop', async () => {
48
+ const user = userEvent.setup();
49
+ const handleStop = vi.fn();
50
+ render(<PromptInput onSubmit={vi.fn()} onStop={handleStop} isLoading />);
51
+
52
+ const stopButton = screen.getByRole('button', { name: 'Stop generating' });
53
+ await user.click(stopButton);
54
+ expect(handleStop).toHaveBeenCalledOnce();
55
+ });
56
+
57
+ it('labels the icon-only send button accessibly', () => {
58
+ render(<PromptInput onSubmit={vi.fn()} sendButtonText="Absenden" />);
59
+ expect(screen.getByRole('button', { name: 'Absenden' })).toBeInTheDocument();
60
+ });
61
+
62
+ it('respects maxLength', async () => {
63
+ const user = userEvent.setup();
64
+ render(<PromptInput onSubmit={vi.fn()} maxLength={5} />);
65
+ const textarea = screen.getByRole('textbox');
66
+ await user.type(textarea, 'abcdefgh');
67
+ expect(textarea).toHaveValue('abcde');
68
+ });
69
+
70
+ it('has no accessibility violations', async () => {
71
+ const { container } = render(<PromptInput onSubmit={vi.fn()} />);
72
+ expect(await axe(container)).toHaveNoViolations();
73
+ });
74
+ });