@slipstream-ui/ai 0.0.0-stage → 0.4.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,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,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,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
|
+
});
|