ai-design-system 0.1.94 → 0.1.96
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/components/ai-elements/prompt-input.tsx +35 -8
- package/components/ai-elements/speech-input.tsx +228 -0
- package/components/composites/PromptInput/PromptInput.stories.tsx +26 -0
- package/components/composites/PromptInput/PromptInput.tsx +36 -7
- package/components/composites/PromptInput/index.ts +3 -0
- package/components/composites/StoriesBar/StoriesBar.stories.tsx +69 -0
- package/components/composites/StoriesBar/StoriesBar.tsx +136 -0
- package/components/composites/StoriesBar/index.ts +2 -0
- package/components/composites/SystemMessage/SystemMessage.tsx +47 -30
- package/components/composites/SystemMessage/index.ts +1 -1
- package/components/composites/TaskQueue/TaskQueue.tsx +8 -5
- package/components/composites/TaskQueue/interfaces.ts +4 -0
- package/components/composites/UserMessage/UserMessage.stories.tsx +26 -0
- package/components/composites/UserMessage/UserMessage.tsx +15 -1
- package/components/composites/index.ts +7 -0
- package/components/features/ChatPanel/ChatPanel.mocks.ts +49 -0
- package/components/features/ChatPanel/ChatPanel.stories.tsx +140 -1
- package/components/features/ChatPanel/ChatPanel.tsx +23 -8
- package/components/index.ts +5 -0
- package/dist/index.cjs +609 -111
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +94 -0
- package/dist/index.d.ts +198 -157
- package/dist/index.js +609 -113
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/utils/markdown-formatter.ts +143 -3
|
@@ -39,6 +39,7 @@ import type { ChatStatus, FileUIPart } from "ai";
|
|
|
39
39
|
import {
|
|
40
40
|
FileIcon,
|
|
41
41
|
Loader2Icon,
|
|
42
|
+
MusicIcon,
|
|
42
43
|
SendIcon,
|
|
43
44
|
SquareIcon,
|
|
44
45
|
XIcon,
|
|
@@ -248,6 +249,13 @@ export const usePromptInputAttachments = () => {
|
|
|
248
249
|
return context;
|
|
249
250
|
};
|
|
250
251
|
|
|
252
|
+
/** Optional variant of usePromptInputAttachments (does NOT throw). */
|
|
253
|
+
export const useOptionalPromptInputAttachments = (): AttachmentsContext | null => {
|
|
254
|
+
const provider = useOptionalProviderAttachments();
|
|
255
|
+
const local = useContext(LocalAttachmentsContext);
|
|
256
|
+
return provider ?? local;
|
|
257
|
+
};
|
|
258
|
+
|
|
251
259
|
export type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
|
252
260
|
data: FileUIPart & { id: string };
|
|
253
261
|
className?: string;
|
|
@@ -265,6 +273,7 @@ export function PromptInputAttachment({
|
|
|
265
273
|
const mediaType =
|
|
266
274
|
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
|
267
275
|
const isImage = mediaType === "image";
|
|
276
|
+
const isAudio = data.mediaType?.startsWith("audio/") ?? false;
|
|
268
277
|
|
|
269
278
|
// For non-image files, truncate filename to max 20 characters followed by '..'
|
|
270
279
|
const attachmentLabel = filename
|
|
@@ -273,6 +282,8 @@ export function PromptInputAttachment({
|
|
|
273
282
|
: filename
|
|
274
283
|
: isImage
|
|
275
284
|
? "Image"
|
|
285
|
+
: isAudio
|
|
286
|
+
? "Voice note"
|
|
276
287
|
: "Attachment";
|
|
277
288
|
|
|
278
289
|
if (isImage) {
|
|
@@ -321,7 +332,7 @@ export function PromptInputAttachment({
|
|
|
321
332
|
<div className="relative size-5 shrink-0">
|
|
322
333
|
<div className="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0">
|
|
323
334
|
<div className="flex size-5 items-center justify-center text-muted-foreground">
|
|
324
|
-
<FileIcon className="size-3.5" />
|
|
335
|
+
{isAudio ? <MusicIcon className="size-3.5" /> : <FileIcon className="size-3.5" />}
|
|
325
336
|
</div>
|
|
326
337
|
</div>
|
|
327
338
|
<Button
|
|
@@ -473,11 +484,16 @@ export const PromptInput = ({
|
|
|
473
484
|
if (!accept || accept.trim() === "") {
|
|
474
485
|
return true;
|
|
475
486
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
487
|
+
// Comma-separated tokens: "image/*", "audio/webm", ".txt", "*".
|
|
488
|
+
return accept.split(",").some((raw) => {
|
|
489
|
+
const token = raw.trim();
|
|
490
|
+
if (!token) return false;
|
|
491
|
+
if (token === "*" || token === "*/*") return true;
|
|
492
|
+
if (token.endsWith("/*")) return f.type.startsWith(token.slice(0, -1));
|
|
493
|
+
if (token.startsWith("."))
|
|
494
|
+
return f.name.toLowerCase().endsWith(token.toLowerCase());
|
|
495
|
+
return f.type === token;
|
|
496
|
+
});
|
|
481
497
|
},
|
|
482
498
|
[accept]
|
|
483
499
|
);
|
|
@@ -628,15 +644,26 @@ export const PromptInput = ({
|
|
|
628
644
|
};
|
|
629
645
|
}, [add, globalDrop]);
|
|
630
646
|
|
|
647
|
+
// Latest-ref for the local file list. The unmount cleanup below must NOT
|
|
648
|
+
// list `files` in its deps: that runs the cleanup after EVERY files change,
|
|
649
|
+
// revoking the previous list's blob URLs while they are still live — and
|
|
650
|
+
// submit does `fetch(blobUrl)`, so adding a second attachment (e.g. image
|
|
651
|
+
// + voice note) made the first file's conversion throw "TypeError: Failed
|
|
652
|
+
// to fetch" and the message was never submitted. Revoke only on unmount.
|
|
653
|
+
const filesRef = useRef(files);
|
|
654
|
+
useEffect(() => {
|
|
655
|
+
filesRef.current = files;
|
|
656
|
+
}, [files]);
|
|
657
|
+
|
|
631
658
|
useEffect(
|
|
632
659
|
() => () => {
|
|
633
660
|
if (!usingProvider) {
|
|
634
|
-
for (const f of
|
|
661
|
+
for (const f of filesRef.current) {
|
|
635
662
|
if (f.url) URL.revokeObjectURL(f.url);
|
|
636
663
|
}
|
|
637
664
|
}
|
|
638
665
|
},
|
|
639
|
-
[usingProvider
|
|
666
|
+
[usingProvider]
|
|
640
667
|
);
|
|
641
668
|
|
|
642
669
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { MicIcon } from "lucide-react";
|
|
6
|
+
import { useOptionalPromptInputAttachments } from "@/components/ai-elements/prompt-input";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
|
|
9
|
+
/** Hard cap on a single recording, so a voice note stays far under the 5MB chat-attachment limit. */
|
|
10
|
+
const MAX_RECORDING_MS = 5_000;
|
|
11
|
+
/** Mirrors the SDK's per-attachment cap (chat-attachments.ts). UX-only; the SDK re-checks. */
|
|
12
|
+
const MAX_AUDIO_BYTES = 5 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
export interface SpeechInputProps
|
|
15
|
+
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
|
16
|
+
/**
|
|
17
|
+
* Receives the recording only when no attachments context is available.
|
|
18
|
+
* Inside a PromptInput the recording attaches to the composer as a chip
|
|
19
|
+
* instead — that is the default path.
|
|
20
|
+
*/
|
|
21
|
+
onAudioRecorded?: (blob: Blob) => void;
|
|
22
|
+
className?: string;
|
|
23
|
+
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | "link";
|
|
24
|
+
size?: "default" | "sm" | "lg" | "icon";
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* SpeechInput — voice-note recorder.
|
|
30
|
+
*
|
|
31
|
+
* Records up to MAX_RECORDING_MS from the microphone with MediaRecorder and
|
|
32
|
+
* attaches the result (audio/webm in Chrome/Firefox, audio/mp4 in Safari) to
|
|
33
|
+
* the composer's attachments, where it uploads to S3 with the rest of the
|
|
34
|
+
* message's chat attachments. No live transcription: the message text is typed.
|
|
35
|
+
*/
|
|
36
|
+
export const SpeechInput = React.memo<SpeechInputProps>(
|
|
37
|
+
({
|
|
38
|
+
onAudioRecorded,
|
|
39
|
+
variant = "ghost",
|
|
40
|
+
size = "icon",
|
|
41
|
+
disabled = false,
|
|
42
|
+
className,
|
|
43
|
+
...props
|
|
44
|
+
}) => {
|
|
45
|
+
const [isRecording, setIsRecording] = React.useState(false);
|
|
46
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
47
|
+
const mediaRecorderRef = React.useRef<MediaRecorder | null>(null);
|
|
48
|
+
const streamRef = React.useRef<MediaStream | null>(null);
|
|
49
|
+
const chunksRef = React.useRef<Blob[]>([]);
|
|
50
|
+
const timerRef = React.useRef<number | null>(null);
|
|
51
|
+
const disposedRef = React.useRef(false);
|
|
52
|
+
const attachments = useOptionalPromptInputAttachments();
|
|
53
|
+
|
|
54
|
+
const clearTimer = React.useCallback(() => {
|
|
55
|
+
if (timerRef.current !== null) {
|
|
56
|
+
window.clearTimeout(timerRef.current);
|
|
57
|
+
timerRef.current = null;
|
|
58
|
+
}
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const releaseStream = React.useCallback(() => {
|
|
62
|
+
streamRef.current?.getTracks().forEach((track) => track.stop());
|
|
63
|
+
streamRef.current = null;
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const finalize = React.useCallback(
|
|
67
|
+
(blob: Blob) => {
|
|
68
|
+
if (blob.size === 0) return;
|
|
69
|
+
if (blob.size > MAX_AUDIO_BYTES) {
|
|
70
|
+
setError("Recording is too large — record again.");
|
|
71
|
+
if (process.env.NODE_ENV === "development") {
|
|
72
|
+
console.warn("[SpeechInput] recording exceeds 5MB after encoding");
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Strip codec parameters: MediaRecorder reports
|
|
77
|
+
// `audio/webm;codecs=opus`, and every downstream allowlist
|
|
78
|
+
// (frontend + SDK) matches on the base type.
|
|
79
|
+
const baseType = (blob.type || "audio/webm").split(";")[0];
|
|
80
|
+
const subtype = baseType.split("/")[1] ?? "webm";
|
|
81
|
+
const file = new File([blob], `voice-${Date.now()}.${subtype}`, {
|
|
82
|
+
type: baseType,
|
|
83
|
+
});
|
|
84
|
+
if (attachments) {
|
|
85
|
+
attachments.add([file]);
|
|
86
|
+
} else {
|
|
87
|
+
onAudioRecorded?.(blob);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
[attachments, onAudioRecorded]
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const stopRecording = React.useCallback(() => {
|
|
94
|
+
clearTimer();
|
|
95
|
+
const recorder = mediaRecorderRef.current;
|
|
96
|
+
if (recorder && recorder.state !== "inactive") {
|
|
97
|
+
recorder.stop();
|
|
98
|
+
}
|
|
99
|
+
}, [clearTimer]);
|
|
100
|
+
|
|
101
|
+
const startRecording = React.useCallback(async () => {
|
|
102
|
+
if (
|
|
103
|
+
typeof MediaRecorder === "undefined" ||
|
|
104
|
+
!navigator.mediaDevices?.getUserMedia
|
|
105
|
+
) {
|
|
106
|
+
setError("Voice notes are not supported in this browser.");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
111
|
+
audio: true,
|
|
112
|
+
});
|
|
113
|
+
if (disposedRef.current) {
|
|
114
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
streamRef.current = stream;
|
|
118
|
+
chunksRef.current = [];
|
|
119
|
+
|
|
120
|
+
const recorder = new MediaRecorder(stream);
|
|
121
|
+
recorder.ondataavailable = (event) => {
|
|
122
|
+
if (event.data && event.data.size > 0) {
|
|
123
|
+
chunksRef.current.push(event.data);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
recorder.onstop = () => {
|
|
127
|
+
const blob = new Blob(chunksRef.current, {
|
|
128
|
+
// Base type only — recorder.mimeType carries `;codecs=…`.
|
|
129
|
+
type: (recorder.mimeType || "audio/webm").split(";")[0],
|
|
130
|
+
});
|
|
131
|
+
chunksRef.current = [];
|
|
132
|
+
releaseStream();
|
|
133
|
+
if (disposedRef.current) return;
|
|
134
|
+
mediaRecorderRef.current = null;
|
|
135
|
+
setIsRecording(false);
|
|
136
|
+
finalize(blob);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
mediaRecorderRef.current = recorder;
|
|
140
|
+
recorder.start();
|
|
141
|
+
setError(null);
|
|
142
|
+
setIsRecording(true);
|
|
143
|
+
timerRef.current = window.setTimeout(
|
|
144
|
+
() => stopRecording(),
|
|
145
|
+
MAX_RECORDING_MS
|
|
146
|
+
);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
releaseStream();
|
|
149
|
+
mediaRecorderRef.current = null;
|
|
150
|
+
setIsRecording(false);
|
|
151
|
+
setError("Microphone access denied.");
|
|
152
|
+
if (process.env.NODE_ENV === "development") {
|
|
153
|
+
console.warn("[SpeechInput] failed to start recording:", err);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}, [finalize, releaseStream, stopRecording]);
|
|
157
|
+
|
|
158
|
+
const toggleListening = React.useCallback(
|
|
159
|
+
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
e.stopPropagation();
|
|
162
|
+
if (isRecording) {
|
|
163
|
+
stopRecording();
|
|
164
|
+
} else {
|
|
165
|
+
void startRecording();
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
[isRecording, startRecording, stopRecording]
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
React.useEffect(() => {
|
|
172
|
+
return () => {
|
|
173
|
+
disposedRef.current = true;
|
|
174
|
+
if (timerRef.current !== null) {
|
|
175
|
+
window.clearTimeout(timerRef.current);
|
|
176
|
+
timerRef.current = null;
|
|
177
|
+
}
|
|
178
|
+
const recorder = mediaRecorderRef.current;
|
|
179
|
+
if (recorder && recorder.state !== "inactive") {
|
|
180
|
+
try {
|
|
181
|
+
recorder.stop();
|
|
182
|
+
} catch {
|
|
183
|
+
// already stopped
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
releaseStream();
|
|
187
|
+
};
|
|
188
|
+
}, [releaseStream]);
|
|
189
|
+
|
|
190
|
+
const status = error
|
|
191
|
+
? error
|
|
192
|
+
: isRecording
|
|
193
|
+
? `Recording\u2026 (5s max)`
|
|
194
|
+
: "Record a 5-second voice note";
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<Button
|
|
198
|
+
type="button"
|
|
199
|
+
variant={variant}
|
|
200
|
+
size={size}
|
|
201
|
+
disabled={disabled}
|
|
202
|
+
onClick={toggleListening}
|
|
203
|
+
className={cn(
|
|
204
|
+
"relative h-8 w-8 rounded-full text-muted-foreground hover:text-foreground transition-all duration-200",
|
|
205
|
+
isRecording &&
|
|
206
|
+
"bg-red-500/15 text-red-500 hover:bg-red-500/20 animate-pulse",
|
|
207
|
+
error && "text-red-500",
|
|
208
|
+
className
|
|
209
|
+
)}
|
|
210
|
+
aria-label={isRecording ? "Stop recording" : status}
|
|
211
|
+
title={status}
|
|
212
|
+
{...props}
|
|
213
|
+
>
|
|
214
|
+
<MicIcon
|
|
215
|
+
className={cn("h-4 w-4", isRecording && "text-red-500")}
|
|
216
|
+
/>
|
|
217
|
+
{isRecording && (
|
|
218
|
+
<span className="absolute -top-0.5 -right-0.5 flex h-2 w-2">
|
|
219
|
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
|
220
|
+
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
|
|
221
|
+
</span>
|
|
222
|
+
)}
|
|
223
|
+
</Button>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
SpeechInput.displayName = "SpeechInput";
|
|
@@ -336,3 +336,29 @@ export const WithNearLimitContext: Story = {
|
|
|
336
336
|
},
|
|
337
337
|
};
|
|
338
338
|
|
|
339
|
+
/**
|
|
340
|
+
* With Speech Input
|
|
341
|
+
* Matches the design with paperclip attachment, speech-input mic, and blue enter button.
|
|
342
|
+
*/
|
|
343
|
+
export const WithSpeechInput: Story = {
|
|
344
|
+
args: {
|
|
345
|
+
placeholder: "What would you like to know?",
|
|
346
|
+
enableSpeech: true,
|
|
347
|
+
attachIcon: "paperclip",
|
|
348
|
+
onSubmit: (message) => {
|
|
349
|
+
console.log("Submitted:", message);
|
|
350
|
+
alert(`Submitted: ${message.text}`);
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
parameters: {
|
|
354
|
+
docs: {
|
|
355
|
+
description: {
|
|
356
|
+
story:
|
|
357
|
+
"PromptInput featuring file attachment (paperclip), voice-note SpeechInput (microphone, 5s max), and blue enter return button.",
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
|
|
@@ -31,9 +31,11 @@ import {
|
|
|
31
31
|
ContextReasoningUsage,
|
|
32
32
|
ContextCacheUsage,
|
|
33
33
|
} from "@/components/ai-elements/context";
|
|
34
|
+
import { SpeechInput, type SpeechInputProps } from "@/components/ai-elements/speech-input";
|
|
34
35
|
import type { FormEvent } from "react";
|
|
35
36
|
import { Button } from "@/components/primitives/Button";
|
|
36
37
|
import { Icon } from "@/components/primitives/Icon";
|
|
38
|
+
import { cn } from "@/lib/utils";
|
|
37
39
|
|
|
38
40
|
export interface PromptInputContextProps {
|
|
39
41
|
usedTokens: number;
|
|
@@ -60,6 +62,9 @@ export interface PromptInputBlockProps
|
|
|
60
62
|
onStop?: () => void;
|
|
61
63
|
context?: PromptInputContextProps | ReactNode;
|
|
62
64
|
tools?: ReactNode;
|
|
65
|
+
enableSpeech?: boolean;
|
|
66
|
+
speechProps?: SpeechInputProps;
|
|
67
|
+
attachIcon?: "paperclip" | "plus";
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
export const PromptInput = React.memo<PromptInputBlockProps>(
|
|
@@ -74,7 +79,10 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
|
|
|
74
79
|
onStop,
|
|
75
80
|
context,
|
|
76
81
|
tools,
|
|
77
|
-
|
|
82
|
+
enableSpeech = true,
|
|
83
|
+
speechProps,
|
|
84
|
+
attachIcon = "paperclip",
|
|
85
|
+
accept = "image/*,audio/*",
|
|
78
86
|
multiple = true,
|
|
79
87
|
maxFiles,
|
|
80
88
|
maxFileSize,
|
|
@@ -134,7 +142,13 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
|
|
|
134
142
|
</PromptInputBody>
|
|
135
143
|
<PromptInputFooter>
|
|
136
144
|
<PromptInputTools>
|
|
137
|
-
<AttachButton disabled={disabled || loading} />
|
|
145
|
+
<AttachButton disabled={disabled || loading} icon={attachIcon} />
|
|
146
|
+
{enableSpeech && (
|
|
147
|
+
<SpeechInput
|
|
148
|
+
disabled={disabled || loading}
|
|
149
|
+
{...speechProps}
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
138
152
|
{tools}
|
|
139
153
|
{context ? (
|
|
140
154
|
<PromptInputContextIndicator
|
|
@@ -147,7 +161,13 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
|
|
|
147
161
|
disabled={disabled || (loading && !onStop)}
|
|
148
162
|
status={loading ? (onStop ? "streaming" : "submitted") : undefined}
|
|
149
163
|
onClick={isStopping ? (e: React.MouseEvent) => { e.preventDefault(); onStop?.(); } : undefined}
|
|
150
|
-
|
|
164
|
+
className={cn(
|
|
165
|
+
"rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors",
|
|
166
|
+
isStopping && "bg-transparent hover:bg-accent"
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
{loading ? undefined : <Icon name="corner-down-left" size="sm" />}
|
|
170
|
+
</PromptInputSubmit>
|
|
151
171
|
</PromptInputFooter>
|
|
152
172
|
</AIPromptInput>
|
|
153
173
|
);
|
|
@@ -174,26 +194,35 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
|
|
|
174
194
|
);
|
|
175
195
|
|
|
176
196
|
/**
|
|
177
|
-
* The
|
|
197
|
+
* The attach toolbar button — opens the native file picker via the attachments
|
|
178
198
|
* context. Must render inside <AIPromptInput> (or a PromptInputProvider),
|
|
179
199
|
* since usePromptInputAttachments() reads that context.
|
|
180
200
|
*/
|
|
181
|
-
function AttachButton({
|
|
201
|
+
function AttachButton({
|
|
202
|
+
disabled,
|
|
203
|
+
icon = "paperclip",
|
|
204
|
+
}: {
|
|
205
|
+
disabled?: boolean;
|
|
206
|
+
icon?: "paperclip" | "plus";
|
|
207
|
+
}) {
|
|
182
208
|
const attachments = usePromptInputAttachments();
|
|
183
209
|
return (
|
|
184
210
|
<Button
|
|
185
211
|
variant="ghost"
|
|
186
212
|
size="icon"
|
|
187
|
-
className="h-8 w-8 rounded-full"
|
|
213
|
+
className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground transition-colors"
|
|
188
214
|
type="button"
|
|
189
215
|
disabled={disabled}
|
|
190
216
|
onClick={() => attachments.openFileDialog()}
|
|
217
|
+
aria-label="Attach files"
|
|
218
|
+
title="Attach files"
|
|
191
219
|
>
|
|
192
|
-
<Icon name=
|
|
220
|
+
<Icon name={icon} size="sm" />
|
|
193
221
|
</Button>
|
|
194
222
|
);
|
|
195
223
|
}
|
|
196
224
|
|
|
225
|
+
|
|
197
226
|
/** Renders a preview chip (thumbnail + remove) per attached file. */
|
|
198
227
|
function AttachmentPreviews() {
|
|
199
228
|
return (
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { PromptInput } from "./PromptInput";
|
|
2
2
|
export type { PromptInputBlockProps, PromptInputContextProps } from "./PromptInput";
|
|
3
3
|
|
|
4
|
+
export { SpeechInput } from "@/components/ai-elements/speech-input";
|
|
5
|
+
export type { SpeechInputProps } from "@/components/ai-elements/speech-input";
|
|
6
|
+
|
|
4
7
|
export {
|
|
5
8
|
PromptInputProvider,
|
|
6
9
|
usePromptInputController,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { StoriesBar, type StoryItem } from "./StoriesBar";
|
|
3
|
+
import { fn } from "@storybook/test";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* StoriesBar Composite Stories
|
|
7
|
+
*
|
|
8
|
+
* Displays circular story avatar bubbles with status indicators and titles.
|
|
9
|
+
*/
|
|
10
|
+
const meta = {
|
|
11
|
+
title: "Composites/StoriesBar",
|
|
12
|
+
component: StoriesBar,
|
|
13
|
+
tags: ["autodocs"],
|
|
14
|
+
parameters: { layout: "padded" },
|
|
15
|
+
args: {
|
|
16
|
+
onStoryClick: fn(),
|
|
17
|
+
},
|
|
18
|
+
} satisfies Meta<typeof StoriesBar>;
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<typeof meta>;
|
|
22
|
+
|
|
23
|
+
const mockStories: StoryItem[] = [
|
|
24
|
+
{
|
|
25
|
+
id: "story-1",
|
|
26
|
+
title: "Devin iOS",
|
|
27
|
+
status: "ready",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "story-2",
|
|
31
|
+
title: "Waypoint",
|
|
32
|
+
status: "ready",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "story-3",
|
|
36
|
+
title: "Design Sys",
|
|
37
|
+
status: "generating",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "story-4",
|
|
41
|
+
title: "Workspace",
|
|
42
|
+
status: "ready",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "story-5",
|
|
46
|
+
title: "ZeroOps",
|
|
47
|
+
status: "draft",
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Default usage with active story
|
|
53
|
+
*/
|
|
54
|
+
export const Default: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
activeStoryId: "story-1",
|
|
57
|
+
stories: mockStories,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Empty stories bar
|
|
63
|
+
*/
|
|
64
|
+
export const EmptyState: Story = {
|
|
65
|
+
args: {
|
|
66
|
+
activeStoryId: null,
|
|
67
|
+
stories: [],
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Button } from "@/components/primitives/Button";
|
|
5
|
+
import { Icon } from "@/components/primitives/Icon";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
export interface StoryItem {
|
|
9
|
+
id: string;
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
title: string;
|
|
12
|
+
videoTitle?: string;
|
|
13
|
+
subtitle?: string;
|
|
14
|
+
icon?: string;
|
|
15
|
+
time?: string;
|
|
16
|
+
status?: "ready" | "generating" | "draft";
|
|
17
|
+
thumbnailUrl?: string;
|
|
18
|
+
avatarColor?: string;
|
|
19
|
+
avatarInitials?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface StoriesBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
23
|
+
stories: StoryItem[];
|
|
24
|
+
activeStoryId?: string | null;
|
|
25
|
+
onStoryClick: (story: StoryItem) => void;
|
|
26
|
+
defaultIcon?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getStoryStatusStyle(status?: StoryItem["status"]) {
|
|
30
|
+
switch (status) {
|
|
31
|
+
case "generating":
|
|
32
|
+
return {
|
|
33
|
+
iconColor: "text-amber-500",
|
|
34
|
+
ringClass: "ring-2 ring-amber-500 animate-pulse",
|
|
35
|
+
badgeBg: "bg-amber-500",
|
|
36
|
+
};
|
|
37
|
+
case "ready":
|
|
38
|
+
return {
|
|
39
|
+
iconColor: "text-emerald-500",
|
|
40
|
+
ringClass: "ring-1 ring-emerald-500/70 group-hover:ring-emerald-500",
|
|
41
|
+
badgeBg: "bg-emerald-500",
|
|
42
|
+
};
|
|
43
|
+
case "draft":
|
|
44
|
+
default:
|
|
45
|
+
return {
|
|
46
|
+
iconColor: "text-muted-foreground",
|
|
47
|
+
ringClass: "ring-1 ring-border group-hover:ring-primary/60",
|
|
48
|
+
badgeBg: "bg-muted-foreground",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const StoriesBar = React.forwardRef<HTMLDivElement, StoriesBarProps>(
|
|
54
|
+
({ stories, activeStoryId, onStoryClick, defaultIcon = "clapperboard", className, ...props }, ref) => {
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
ref={ref}
|
|
58
|
+
className={cn("w-full border-b shrink-0", className)}
|
|
59
|
+
data-testid="stories-bar"
|
|
60
|
+
{...props}
|
|
61
|
+
>
|
|
62
|
+
<div className="flex items-center gap-3 overflow-x-auto no-scrollbar scroll-smooth">
|
|
63
|
+
{stories.map((story) => {
|
|
64
|
+
const isActive = story.id === activeStoryId;
|
|
65
|
+
const isGenerating = story.status === "generating";
|
|
66
|
+
const statusStyle = getStoryStatusStyle(story.status);
|
|
67
|
+
const iconName = story.icon || defaultIcon;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Button
|
|
71
|
+
key={story.id}
|
|
72
|
+
variant="ghost"
|
|
73
|
+
onClick={() => onStoryClick(story)}
|
|
74
|
+
className="flex flex-col items-center gap-1 h-auto p-0 hover:bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg cursor-pointer group shrink-0"
|
|
75
|
+
data-testid={`btn-story-${story.id}`}
|
|
76
|
+
aria-label={`Open story: ${story.title}`}
|
|
77
|
+
>
|
|
78
|
+
<div
|
|
79
|
+
className={cn(
|
|
80
|
+
"relative w-12 h-12 shrink-0 aspect-square rounded-full transition-transform group-hover:scale-105",
|
|
81
|
+
isActive
|
|
82
|
+
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
|
83
|
+
: statusStyle.ringClass
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
<div className="w-full h-full rounded-full flex items-center justify-center border shadow-sm overflow-hidden select-none">
|
|
87
|
+
{story.thumbnailUrl ? (
|
|
88
|
+
<img
|
|
89
|
+
src={story.thumbnailUrl}
|
|
90
|
+
alt={story.title}
|
|
91
|
+
className="w-full h-full object-cover"
|
|
92
|
+
/>
|
|
93
|
+
) : (
|
|
94
|
+
<Icon
|
|
95
|
+
name={iconName}
|
|
96
|
+
className={cn("w-5 h-5 transition-colors", statusStyle.iconColor)}
|
|
97
|
+
/>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{isGenerating ? (
|
|
102
|
+
<span className="absolute bottom-0 right-0 flex h-2.5 w-2.5">
|
|
103
|
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-500 opacity-75" />
|
|
104
|
+
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500" />
|
|
105
|
+
</span>
|
|
106
|
+
) : (
|
|
107
|
+
<span
|
|
108
|
+
className={cn(
|
|
109
|
+
"absolute bottom-0 right-0 inline-flex rounded-full h-2.5 w-2.5 ring-2 ring-background",
|
|
110
|
+
statusStyle.badgeBg
|
|
111
|
+
)}
|
|
112
|
+
/>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<span
|
|
117
|
+
className={cn(
|
|
118
|
+
"text-xs truncate max-w-[58px] text-center",
|
|
119
|
+
isActive
|
|
120
|
+
? "font-semibold text-foreground"
|
|
121
|
+
: "text-muted-foreground group-hover:text-foreground"
|
|
122
|
+
)}
|
|
123
|
+
title={story.title}
|
|
124
|
+
>
|
|
125
|
+
{story.title}
|
|
126
|
+
</span>
|
|
127
|
+
</Button>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
StoriesBar.displayName = "StoriesBar";
|