@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,584 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Button } from '@slipstream-ui/core';
5
+ import { cn } from '@slipstream-ui/core';
6
+ import { AttachmentList } from '../attachment';
7
+ import type { AttachmentItem } from '../attachment';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Attachments hook: validation + state, reusable outside the input.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export type UseAttachmentsOptions = {
14
+ /** File types to accept, e.g. "image/*" (HTML accept syntax). */
15
+ accept?: string;
16
+ /** Maximum number of queued files. */
17
+ maxFiles?: number;
18
+ /** Maximum size per file in bytes. */
19
+ maxFileSize?: number;
20
+ multiple?: boolean;
21
+ onError?: (error: { code: 'max_files' | 'max_file_size' | 'accept'; message: string }) => void;
22
+ };
23
+
24
+ export type UseAttachmentsResult = {
25
+ files: AttachmentItem[];
26
+ add: (fileList: FileList | File[]) => void;
27
+ remove: (id: string) => void;
28
+ clear: () => void;
29
+ openFileDialog: () => void;
30
+ /** Hidden file input element; render it once somewhere in your tree. */
31
+ input: React.ReactElement;
32
+ };
33
+
34
+ /**
35
+ * Headless attachment queue with constraint validation.
36
+ * Used internally by PromptInput and exported for custom composers.
37
+ */
38
+ export function usePromptInputAttachments(options: UseAttachmentsOptions = {}): UseAttachmentsResult {
39
+ const { accept, maxFiles, maxFileSize, multiple = true, onError } = options;
40
+ const [files, setFiles] = React.useState<AttachmentItem[]>([]);
41
+ const inputRef = React.useRef<HTMLInputElement>(null);
42
+
43
+ const add = React.useCallback(
44
+ (fileList: FileList | File[]) => {
45
+ const incoming = Array.from(fileList);
46
+ setFiles((prev) => {
47
+ const next = [...prev];
48
+ for (const file of incoming) {
49
+ if (accept && !file.type.match(accept.replace(/\*/g, '.*'))) {
50
+ onError?.({ code: 'accept', message: `"${file.name}" is not an accepted file type.` });
51
+ continue;
52
+ }
53
+ if (maxFileSize && file.size > maxFileSize) {
54
+ onError?.({
55
+ code: 'max_file_size',
56
+ message: `"${file.name}" exceeds the ${Math.round(maxFileSize / 1024)} KB limit.`,
57
+ });
58
+ continue;
59
+ }
60
+ if (maxFiles !== undefined && next.length >= maxFiles) {
61
+ onError?.({ code: 'max_files', message: `At most ${maxFiles} files can be attached.` });
62
+ break;
63
+ }
64
+ if (!multiple && next.length >= 1) {
65
+ onError?.({ code: 'max_files', message: 'Only one file can be attached.' });
66
+ break;
67
+ }
68
+ next.push({
69
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
70
+ name: file.name,
71
+ size: `${Math.max(1, Math.round(file.size / 1024))} KB`,
72
+ type: file.type.split('/')[1]?.toUpperCase() ?? file.name.split('.').pop()?.toUpperCase(),
73
+ });
74
+ }
75
+ return next;
76
+ });
77
+ },
78
+ [accept, maxFileSize, maxFiles, multiple, onError]
79
+ );
80
+
81
+ const remove = React.useCallback((id: string) => {
82
+ setFiles((prev) => prev.filter((file) => file.id !== id));
83
+ }, []);
84
+
85
+ const clear = React.useCallback(() => setFiles([]), []);
86
+
87
+ const openFileDialog = React.useCallback(() => inputRef.current?.click(), []);
88
+
89
+ const input = (
90
+ <input
91
+ ref={inputRef}
92
+ type="file"
93
+ hidden
94
+ accept={accept}
95
+ multiple={multiple}
96
+ onChange={(event) => {
97
+ if (event.target.files) add(event.target.files);
98
+ event.target.value = '';
99
+ }}
100
+ />
101
+ );
102
+
103
+ return { files, add, remove, clear, openFileDialog, input };
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Speech recognition (Web Speech API), no-op where unsupported.
108
+ // ---------------------------------------------------------------------------
109
+
110
+ type SpeechRecognitionLike = {
111
+ lang: string;
112
+ continuous: boolean;
113
+ interimResults: boolean;
114
+ start: () => void;
115
+ stop: () => void;
116
+ onresult: ((event: { results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null;
117
+ onend: (() => void) | null;
118
+ };
119
+
120
+ type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
121
+
122
+ /** Web Speech API dictation; returns supported=false on unsupported browsers. */
123
+ export function useSpeechRecognition(onTranscript: (text: string) => void): {
124
+ supported: boolean;
125
+ isListening: boolean;
126
+ toggle: () => void;
127
+ } {
128
+ const [isListening, setIsListening] = React.useState(false);
129
+ // Cast through unknown: Safari prefixes webkitSpeechRecognition.
130
+ const Ctor =
131
+ typeof window !== 'undefined'
132
+ ? ((window as unknown as Record<string, SpeechRecognitionCtor | undefined>).SpeechRecognition ??
133
+ (window as unknown as Record<string, SpeechRecognitionCtor | undefined>).webkitSpeechRecognition)
134
+ : undefined;
135
+
136
+ const recognitionRef = React.useRef<SpeechRecognitionLike | null>(null);
137
+
138
+ const toggle = React.useCallback(() => {
139
+ if (!Ctor) return;
140
+ if (isListening) {
141
+ recognitionRef.current?.stop();
142
+ setIsListening(false);
143
+ return;
144
+ }
145
+ const recognition = new Ctor();
146
+ recognition.lang = navigator.language || 'en-US';
147
+ recognition.continuous = false;
148
+ recognition.interimResults = false;
149
+ recognition.onresult = (event) => {
150
+ const transcript = Array.from({ length: event.results.length }, (_, i) => event.results[i][0].transcript).join(' ');
151
+ onTranscript(transcript.trim());
152
+ };
153
+ recognition.onend = () => setIsListening(false);
154
+ recognitionRef.current = recognition;
155
+ recognition.start();
156
+ setIsListening(true);
157
+ }, [Ctor, isListening, onTranscript]);
158
+
159
+ return { supported: Boolean(Ctor), isListening, toggle };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Context shared by the compound primitives.
164
+ // ---------------------------------------------------------------------------
165
+
166
+ type PromptInputContextValue = {
167
+ value: string;
168
+ setValue: (value: string) => void;
169
+ disabled: boolean;
170
+ isLoading: boolean;
171
+ canSubmit: boolean;
172
+ submit: () => void;
173
+ stop: () => void;
174
+ hasStop: boolean;
175
+ attachments: AttachmentItem[];
176
+ removeAttachment: (id: string) => void;
177
+ addFiles: (files: FileList | File[]) => void;
178
+ openFilePicker: () => void;
179
+ hiddenInput: React.ReactElement;
180
+ dictate: { supported: boolean; isListening: boolean; toggle: () => void };
181
+ };
182
+
183
+ const PromptInputContext = React.createContext<PromptInputContextValue | null>(null);
184
+
185
+ function usePromptInputContext(): PromptInputContextValue {
186
+ const context = React.useContext(PromptInputContext);
187
+ if (!context) throw new Error('PromptInput primitives must be rendered inside <PromptInput>.');
188
+ return context;
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Root
193
+ // ---------------------------------------------------------------------------
194
+
195
+ export type PromptInputProps = {
196
+ onSubmit: (value: string) => void;
197
+ /** Called when the user requests to stop an in-flight generation. */
198
+ onStop?: () => void;
199
+ /**
200
+ * Externally owned attachment queue. When omitted, PromptInput manages its
201
+ * own queue with drag-and-drop + clipboard-paste support.
202
+ */
203
+ attachments?: AttachmentItem[];
204
+ onRemoveAttachment?: (attachment: AttachmentItem) => void;
205
+ /** Called whenever the managed attachment queue changes. */
206
+ onAttachmentsChange?: (attachments: AttachmentItem[]) => void;
207
+ /** Marks submission as failed; the send control reflects the error state. */
208
+ hasError?: boolean;
209
+ accept?: string;
210
+ multiple?: boolean;
211
+ maxFiles?: number;
212
+ maxFileSize?: number;
213
+ onAttachmentError?: UseAttachmentsOptions['onError'];
214
+ /** Accept file drops anywhere on the document (default false). */
215
+ globalDrop?: boolean;
216
+ placeholder?: string;
217
+ disabled?: boolean;
218
+ isLoading?: boolean;
219
+ className?: string;
220
+ autoFocus?: boolean;
221
+ maxLength?: number;
222
+ showSendButton?: boolean;
223
+ sendButtonText?: string;
224
+ stopButtonText?: string;
225
+ children?: React.ReactNode;
226
+ };
227
+
228
+ const PromptInputRoot = React.forwardRef<HTMLTextAreaElement, PromptInputProps>(
229
+ ({
230
+ onSubmit,
231
+ onStop,
232
+ attachments: controlledAttachments,
233
+ onRemoveAttachment,
234
+ onAttachmentsChange,
235
+ hasError = false,
236
+ accept,
237
+ multiple = true,
238
+ maxFiles,
239
+ maxFileSize,
240
+ onAttachmentError,
241
+ globalDrop = false,
242
+ placeholder = 'Type your message...',
243
+ disabled = false,
244
+ isLoading = false,
245
+ className,
246
+ autoFocus = false,
247
+ maxLength,
248
+ showSendButton = true,
249
+ sendButtonText = 'Send',
250
+ stopButtonText = 'Stop generating',
251
+ children,
252
+ ...props
253
+ }, ref) => {
254
+ const [value, setValue] = React.useState('');
255
+ const textareaRef = React.useRef<HTMLTextAreaElement>(null);
256
+
257
+ const managed = usePromptInputAttachments({ accept, maxFiles, maxFileSize, multiple, onError: onAttachmentError });
258
+
259
+ // Notify consumers of managed-queue changes.
260
+ const prevManagedCount = React.useRef(0);
261
+ React.useEffect(() => {
262
+ if (!controlledAttachments && managed.files.length !== prevManagedCount.current) {
263
+ prevManagedCount.current = managed.files.length;
264
+ onAttachmentsChange?.(managed.files);
265
+ }
266
+ }, [managed.files, controlledAttachments, onAttachmentsChange]);
267
+
268
+ const activeAttachments = controlledAttachments ?? managed.files;
269
+
270
+ // Auto-focus on mount if requested
271
+ React.useEffect(() => {
272
+ if (autoFocus && textareaRef.current) textareaRef.current.focus();
273
+ }, [autoFocus]);
274
+
275
+ // Merge forwarded ref with internal handle (auto-resize needs it).
276
+ React.useImperativeHandle(ref, () => textareaRef.current!);
277
+
278
+ // Auto-resize textarea
279
+ React.useEffect(() => {
280
+ if (textareaRef.current) {
281
+ textareaRef.current.style.height = 'auto';
282
+ textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
283
+ }
284
+ }, [value]);
285
+
286
+ // Global drop: opt-in document-level listener.
287
+ React.useEffect(() => {
288
+ if (!globalDrop || controlledAttachments) return;
289
+ const prevent = (event: DragEvent) => event.preventDefault();
290
+ const drop = (event: DragEvent) => {
291
+ event.preventDefault();
292
+ if (event.dataTransfer?.files.length) managed.add(event.dataTransfer.files);
293
+ };
294
+ document.addEventListener('dragover', prevent);
295
+ document.addEventListener('drop', drop);
296
+ return () => {
297
+ document.removeEventListener('dragover', prevent);
298
+ document.removeEventListener('drop', drop);
299
+ };
300
+ }, [globalDrop, controlledAttachments, managed]);
301
+
302
+ const canSubmit = Boolean(value.trim()) && !disabled && !isLoading;
303
+
304
+ const handleSubmit = () => {
305
+ const trimmedValue = value.trim();
306
+ if (!trimmedValue || disabled || isLoading) return;
307
+ onSubmit(trimmedValue);
308
+ setValue('');
309
+ if (textareaRef.current) textareaRef.current.style.height = 'auto';
310
+ if (!controlledAttachments) managed.clear();
311
+ };
312
+
313
+ const handleStop = () => onStop?.();
314
+
315
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
316
+ if (event.key === 'Enter' && !event.shiftKey) {
317
+ event.preventDefault();
318
+ handleSubmit();
319
+ }
320
+ };
321
+
322
+ const dictate = useSpeechRecognition((transcript) =>
323
+ setValue((current) => (current ? `${current} ${transcript}` : transcript))
324
+ );
325
+
326
+ const contextValue: PromptInputContextValue = {
327
+ value,
328
+ setValue,
329
+ disabled,
330
+ isLoading,
331
+ canSubmit,
332
+ submit: handleSubmit,
333
+ stop: handleStop,
334
+ hasStop: Boolean(onStop),
335
+ attachments: activeAttachments,
336
+ removeAttachment: (id) => (controlledAttachments ? onRemoveAttachment?.({ id } as AttachmentItem) : managed.remove(id)),
337
+ addFiles: (files) => !controlledAttachments && managed.add(files),
338
+ openFilePicker: managed.openFileDialog,
339
+ hiddenInput: managed.input,
340
+ dictate,
341
+ };
342
+
343
+ const showStop = isLoading && Boolean(onStop);
344
+ const submitIcon = showStop ? '■' : hasError ? '⚠' : '→';
345
+ const submitLabel = showStop ? stopButtonText : hasError ? 'Failed — check message' : sendButtonText;
346
+
347
+ const textarea = (
348
+ <textarea
349
+ ref={textareaRef}
350
+ value={value}
351
+ onChange={(event) => setValue(event.target.value)}
352
+ onKeyDown={handleKeyDown}
353
+ placeholder={placeholder}
354
+ disabled={disabled || isLoading}
355
+ maxLength={maxLength}
356
+ rows={1}
357
+ className={cn(
358
+ 'flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm',
359
+ 'placeholder:text-muted-foreground',
360
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
361
+ 'disabled:cursor-not-allowed disabled:opacity-50',
362
+ 'min-h-[40px] max-h-[200px] overflow-y-auto'
363
+ )}
364
+ />
365
+ );
366
+
367
+ const sendButton = showSendButton ? (
368
+ <Button
369
+ onClick={showStop ? handleStop : handleSubmit}
370
+ disabled={showStop ? false : !canSubmit || disabled}
371
+ variant={showStop ? 'outline' : 'default'}
372
+ size="icon"
373
+ type="button"
374
+ aria-label={submitLabel}
375
+ >
376
+ <span aria-hidden="true">{submitIcon}</span>
377
+ </Button>
378
+ ) : null;
379
+
380
+ // Drop/paste surface wraps everything in both modes.
381
+ const dropHandlers = controlledAttachments
382
+ ? {}
383
+ : {
384
+ onDrop: (event: React.DragEvent) => {
385
+ event.preventDefault();
386
+ if (event.dataTransfer.files.length) managed.add(event.dataTransfer.files);
387
+ },
388
+ onPaste: (event: React.ClipboardEvent) => {
389
+ const files = event.clipboardData?.files;
390
+ if (files && files.length > 0) {
391
+ event.preventDefault();
392
+ managed.add(files);
393
+ }
394
+ },
395
+ };
396
+
397
+ return (
398
+ <PromptInputContext.Provider value={contextValue}>
399
+ <div className={cn('flex flex-col gap-2', className)} {...dropHandlers} {...props}>
400
+ {activeAttachments.length > 0 && (
401
+ <AttachmentList
402
+ attachments={activeAttachments}
403
+ onRemove={controlledAttachments ? onRemoveAttachment : (a) => managed.remove(a.id)}
404
+ />
405
+ )}
406
+
407
+ {children ? (
408
+ <>
409
+ {children}
410
+ {managed.input}
411
+ </>
412
+ ) : (
413
+ <>
414
+ <div className="flex gap-2 items-end">
415
+ {textarea}
416
+ {sendButton}
417
+ </div>
418
+ {managed.input}
419
+ </>
420
+ )}
421
+ </div>
422
+ </PromptInputContext.Provider>
423
+ );
424
+ }
425
+ );
426
+ PromptInputRoot.displayName = 'PromptInput';
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Compound primitives
430
+ // ---------------------------------------------------------------------------
431
+
432
+ /** Auto-resizing textarea bound to the prompt state. Accepts standard textarea props. */
433
+ const PromptInputTextarea = React.forwardRef<
434
+ HTMLTextAreaElement,
435
+ Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'>
436
+ >(({ className, onKeyDown, ...props }, ref) => {
437
+ const { value, setValue, disabled, isLoading, submit } = usePromptInputContext();
438
+ return (
439
+ <textarea
440
+ ref={ref}
441
+ value={value}
442
+ onChange={(event) => setValue(event.target.value)}
443
+ onKeyDown={(event) => {
444
+ if (event.key === 'Enter' && !event.shiftKey) {
445
+ event.preventDefault();
446
+ submit();
447
+ }
448
+ onKeyDown?.(event);
449
+ }}
450
+ disabled={disabled || isLoading}
451
+ rows={1}
452
+ className={cn(
453
+ 'flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm',
454
+ 'placeholder:text-muted-foreground',
455
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
456
+ 'min-h-[40px]',
457
+ className
458
+ )}
459
+ {...props}
460
+ />
461
+ );
462
+ });
463
+ PromptInputTextarea.displayName = 'PromptInputTextarea';
464
+
465
+ /** Status-aware submit control: send / stop / error icons chosen automatically. */
466
+ const PromptInputSubmit = React.forwardRef<HTMLButtonElement, { className?: string }>(({ className }, ref) => {
467
+ const { canSubmit, isLoading, hasStop, submit, stop, disabled } = usePromptInputContext();
468
+ return (
469
+ <Button
470
+ ref={ref}
471
+ onClick={isLoading && hasStop ? stop : submit}
472
+ disabled={isLoading && hasStop ? false : !canSubmit || disabled}
473
+ variant={isLoading && hasStop ? 'outline' : 'default'}
474
+ size="icon"
475
+ type="button"
476
+ aria-label={isLoading && hasStop ? 'Stop generating' : 'Send'}
477
+ className={className}
478
+ >
479
+ <span aria-hidden="true">{isLoading && hasStop ? '■' : '→'}</span>
480
+ </Button>
481
+ );
482
+ });
483
+ PromptInputSubmit.displayName = 'PromptInputSubmit';
484
+
485
+ /** Toolbar row under the textarea. */
486
+ function PromptInputFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
487
+ return <div className={cn('flex items-center justify-between gap-2 pt-1', className)} {...props} />;
488
+ }
489
+
490
+ /** Left-aligned group of tools inside a footer. */
491
+ function PromptInputTools({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
492
+ return <div className={cn('flex items-center gap-1', className)} {...props} />;
493
+ }
494
+
495
+ /** Row above the textarea (attachments, hints). */
496
+ function PromptInputHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
497
+ return <div className={cn('mb-1', className)} {...props} />;
498
+ }
499
+
500
+ /** Wrapper around the textarea row. */
501
+ function PromptInputBody({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
502
+ return <div className={cn('flex gap-2 items-end', className)} {...props} />;
503
+ }
504
+
505
+ export type PromptInputButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
506
+ /** Tooltip content; optionally includes a keyboard shortcut hint. */
507
+ tooltip?: string | { content: React.ReactNode; shortcut?: string };
508
+ active?: boolean;
509
+ };
510
+
511
+ /** Small toolbar button with optional tooltip and shortcut hint. */
512
+ const PromptInputButton = React.forwardRef<HTMLButtonElement, PromptInputButtonProps>(
513
+ ({ tooltip, active = false, className, ...props }, ref) => {
514
+ const tip = typeof tooltip === 'string' ? { content: tooltip } : tooltip;
515
+ return (
516
+ <span className="relative inline-flex group/tip">
517
+ <button
518
+ ref={ref}
519
+ type="button"
520
+ aria-pressed={active || undefined}
521
+ className={cn(
522
+ 'inline-flex h-7 items-center gap-1 rounded-md border border-transparent px-2 text-xs text-muted-foreground transition-colors',
523
+ 'hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
524
+ active && 'border-border bg-muted text-foreground',
525
+ className
526
+ )}
527
+ {...props}
528
+ />
529
+ {tip && (
530
+ <span
531
+ role="tooltip"
532
+ className="pointer-events-none absolute bottom-full left-0 mb-1 z-10 flex items-center gap-1 whitespace-nowrap rounded border border-border bg-popover px-2 py-0.5 text-[11px] text-popover-foreground opacity-0 shadow-sm transition-opacity group-hover/tip:opacity-100"
533
+ >
534
+ {tip.content}
535
+ {tip.shortcut && <kbd className="rounded bg-muted px-1 font-mono">{tip.shortcut}</kbd>}
536
+ </span>
537
+ )}
538
+ </span>
539
+ );
540
+ }
541
+ );
542
+ PromptInputButton.displayName = 'PromptInputButton';
543
+
544
+ /** Dictation toggle using the Web Speech API; hidden when unsupported. */
545
+ function PromptInputActionDictate({ labels }: { labels?: { start?: string; stop?: string } }) {
546
+ const { dictate } = usePromptInputContext();
547
+ if (!dictate.supported) return null;
548
+ return (
549
+ <PromptInputButton
550
+ onClick={dictate.toggle}
551
+ active={dictate.isListening}
552
+ tooltip={{ content: dictate.isListening ? labels?.stop ?? 'Stop dictating' : labels?.start ?? 'Dictate' }}
553
+ aria-label={dictate.isListening ? labels?.stop ?? 'Stop dictation' : labels?.start ?? 'Start dictation'}
554
+ >
555
+ <span aria-hidden="true">🎙</span>
556
+ </PromptInputButton>
557
+ );
558
+ }
559
+
560
+ /** Opens the managed file picker with configured constraints. */
561
+
562
+ /** Opens the managed file picker with configured constraints. */
563
+ function PromptInputActionAddAttachments({ label = 'Add photos or files' }: { label?: string }) {
564
+ const { openFilePicker } = usePromptInputContext();
565
+ return (
566
+ <PromptInputButton onClick={openFilePicker}>
567
+ <span aria-hidden="true">+</span>
568
+ <span>{label}</span>
569
+ </PromptInputButton>
570
+ );
571
+ }
572
+
573
+ export {
574
+ PromptInputRoot as PromptInput,
575
+ PromptInputTextarea,
576
+ PromptInputSubmit,
577
+ PromptInputFooter,
578
+ PromptInputTools,
579
+ PromptInputHeader,
580
+ PromptInputBody,
581
+ PromptInputButton,
582
+ PromptInputActionDictate,
583
+ PromptInputActionAddAttachments,
584
+ };
@@ -0,0 +1,21 @@
1
+ export {
2
+ PromptInput,
3
+ PromptInputTextarea,
4
+ PromptInputSubmit,
5
+ PromptInputFooter,
6
+ PromptInputTools,
7
+ PromptInputHeader,
8
+ PromptInputBody,
9
+ PromptInputButton,
10
+ PromptInputActionDictate,
11
+ PromptInputActionAddAttachments,
12
+ } from './PromptInput';
13
+ export type {
14
+ PromptInputProps,
15
+ PromptInputButtonProps,
16
+ } from './PromptInput';
17
+ export { usePromptInputAttachments, useSpeechRecognition } from './PromptInput';
18
+ export type {
19
+ UseAttachmentsOptions,
20
+ UseAttachmentsResult,
21
+ } from './PromptInput';
@@ -0,0 +1,35 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { Reasoning } from './Reasoning';
5
+
6
+ const steps = [
7
+ { id: 's1', content: 'Parse the question' },
8
+ { id: 's2', content: 'Recall facts' },
9
+ ];
10
+
11
+ describe('Reasoning duration and live state', () => {
12
+ it('renders a numeric duration in the header', () => {
13
+ render(<Reasoning steps={steps} duration={4} />);
14
+ expect(screen.getByRole('button')).toHaveTextContent('· 4s');
15
+ });
16
+
17
+ it('renders a string duration verbatim', () => {
18
+ render(<Reasoning steps={steps} duration="4 seconds" />);
19
+ expect(screen.getByRole('button')).toHaveTextContent('· 4 seconds');
20
+ });
21
+
22
+ it('marks streaming reasoning with an animated indicator', () => {
23
+ render(<Reasoning steps={steps} isStreaming />);
24
+ expect(screen.getByText(/thinking…/)).toBeInTheDocument();
25
+ });
26
+
27
+ it('keeps the toggle accessible with new header content', async () => {
28
+ const user = userEvent.setup();
29
+ render(<Reasoning steps={steps} defaultExpanded duration={2} />);
30
+ const toggle = screen.getByRole('button');
31
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
32
+ await user.click(toggle);
33
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
34
+ });
35
+ });
@@ -0,0 +1,58 @@
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 { Reasoning } from './Reasoning';
6
+
7
+ const steps = [
8
+ { id: 's1', content: 'Parse the question' },
9
+ { id: 's2', content: 'Recall relevant facts' },
10
+ { id: 's3', content: 'Compose the answer' },
11
+ ];
12
+
13
+ describe('Reasoning', () => {
14
+ it('renders collapsed by default with an accessible toggle', () => {
15
+ render(<Reasoning steps={steps} />);
16
+ const toggle = screen.getByRole('button');
17
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
18
+ expect(screen.queryByText('Parse the question')).not.toBeInTheDocument();
19
+ });
20
+
21
+ it('expands to reveal steps and updates aria-expanded', async () => {
22
+ const user = userEvent.setup();
23
+ render(<Reasoning steps={steps} />);
24
+ await user.click(screen.getByRole('button'));
25
+ expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
26
+ for (const step of steps) {
27
+ expect(screen.getByText(step.content)).toBeInTheDocument();
28
+ }
29
+ });
30
+
31
+ it('renders expanded when defaultExpanded is true', () => {
32
+ render(<Reasoning steps={steps} defaultExpanded />);
33
+ expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
34
+ expect(screen.getByText(steps[0].content)).toBeInTheDocument();
35
+ });
36
+
37
+ it('renders steps directly without a toggle when collapsible is false', () => {
38
+ // Non-collapsible mode renders steps directly without a toggle.
39
+ render(<Reasoning steps={steps} collapsible={false} />);
40
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
41
+ expect(screen.getByText(steps[0].content)).toBeInTheDocument();
42
+ });
43
+
44
+ it('renders nothing for an empty step list', () => {
45
+ const { container } = render(<Reasoning steps={[]} />);
46
+ expect(container).toBeEmptyDOMElement();
47
+ });
48
+
49
+ it('has no accessibility violations when collapsed', async () => {
50
+ const { container } = render(<Reasoning steps={steps} />);
51
+ expect(await axe(container)).toHaveNoViolations();
52
+ });
53
+
54
+ it('has no accessibility violations when expanded', async () => {
55
+ const { container } = render(<Reasoning steps={steps} defaultExpanded />);
56
+ expect(await axe(container)).toHaveNoViolations();
57
+ });
58
+ });