@nickle/chatbot-react 0.1.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.
@@ -0,0 +1,739 @@
1
+ import clsx from "clsx";
2
+ import {
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ChangeEvent,
8
+ type CSSProperties,
9
+ type FormEvent,
10
+ type KeyboardEvent as ReactKeyboardEvent,
11
+ } from "react";
12
+ import type { ChatMessage } from "../types";
13
+ import { useChatbotContext } from "../context/chatbot-context";
14
+ import { AwaitingDots } from "./awaiting-dots";
15
+ import { MessageMarkdown } from "./message-markdown";
16
+
17
+ const PANEL_INSET_REM = 1.5;
18
+ const EXPANDED_MAX_WIDTH_PX = 630;
19
+ const MOBILE_BREAKPOINT_PX = 768;
20
+ const ROOT_BOTTOM_OFFSET_REM = 1.5; // cb-bottom-6
21
+ const PANEL_TO_BUBBLE_GAP_REM = 0.75; // cb-gap-3
22
+ const BUBBLE_SIZE_REM = 3.5; // cb-h-14
23
+ const EXPANDED_BOTTOM_OFFSET_REM = ROOT_BOTTOM_OFFSET_REM + PANEL_TO_BUBBLE_GAP_REM + BUBBLE_SIZE_REM;
24
+ const EXPANDED_TOTAL_VERTICAL_INSET_REM = PANEL_INSET_REM + EXPANDED_BOTTOM_OFFSET_REM;
25
+
26
+ function formatFileSize(size: number) {
27
+ if (size < 1024) {
28
+ return `${size} B`;
29
+ }
30
+ if (size < 1024 * 1024) {
31
+ return `${(size / 1024).toFixed(1)} KB`;
32
+ }
33
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
34
+ }
35
+
36
+ function formatGmtOffset(date: Date) {
37
+ const total = -date.getTimezoneOffset();
38
+ const sign = total >= 0 ? "+" : "-";
39
+ const abs = Math.abs(total);
40
+ const hh = String(Math.floor(abs / 60)).padStart(2, "0");
41
+ const mm = String(abs % 60).padStart(2, "0");
42
+ return `GMT${sign}${hh}${mm}`;
43
+ }
44
+
45
+ function readTimeZoneName(date: Date, mode: "long" | "short") {
46
+ const parts = new Intl.DateTimeFormat(undefined, { timeZoneName: mode }).formatToParts(date);
47
+ return parts.find((part) => part.type === "timeZoneName")?.value ?? "";
48
+ }
49
+
50
+ function formatTranscriptDateTime(date: Date) {
51
+ const datePart = new Intl.DateTimeFormat(undefined, {
52
+ month: "long",
53
+ day: "numeric",
54
+ year: "numeric",
55
+ }).format(date);
56
+ const timePart = new Intl.DateTimeFormat(undefined, {
57
+ hour: "2-digit",
58
+ minute: "2-digit",
59
+ hour12: true,
60
+ }).format(date);
61
+ const zoneLong = readTimeZoneName(date, "long");
62
+ const zoneShort = readTimeZoneName(date, "short");
63
+ const gmtOffset = formatGmtOffset(date);
64
+ return `${datePart} at ${timePart} ${zoneLong} time ${zoneShort} (${gmtOffset})`.replace(/\s+/g, " ").trim();
65
+ }
66
+
67
+ function formatTranscriptMessageTime(value: string) {
68
+ const date = new Date(value);
69
+ if (Number.isNaN(date.getTime())) {
70
+ return value;
71
+ }
72
+
73
+ return new Intl.DateTimeFormat(undefined, {
74
+ hour: "2-digit",
75
+ minute: "2-digit",
76
+ hour12: true,
77
+ }).format(date);
78
+ }
79
+
80
+ function toTranscript(messages: ChatMessage[], agentName: string) {
81
+ const exportedAt = new Date();
82
+ const firstDate =
83
+ messages.length > 0 ? new Date(messages[0].createdAt) : exportedAt;
84
+ const startedAt = Number.isNaN(firstDate.getTime()) ? exportedAt : firstDate;
85
+
86
+ const lines: string[] = [
87
+ `Conversation with ${agentName}`,
88
+ `Started on ${formatTranscriptDateTime(startedAt)}`,
89
+ "",
90
+ "---",
91
+ "",
92
+ ];
93
+
94
+ messages.forEach((message) => {
95
+ const role = message.role === "user" ? "Visitor" : message.role === "assistant" ? agentName : "System";
96
+ const content = message.content.trim();
97
+ if (!content) {
98
+ return;
99
+ }
100
+
101
+ lines.push(`${formatTranscriptMessageTime(message.createdAt)} | ${role}: ${content}`);
102
+ lines.push("");
103
+ });
104
+
105
+ lines.push("---");
106
+ lines.push(`Exported from ${agentName} on ${formatTranscriptDateTime(exportedAt)}`);
107
+
108
+ return lines.join("\n");
109
+ }
110
+
111
+ function toTranscriptFilename(date: Date) {
112
+ const yyyy = date.getFullYear();
113
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
114
+ const dd = String(date.getDate()).padStart(2, "0");
115
+ const hh = String(date.getHours()).padStart(2, "0");
116
+ const min = String(date.getMinutes()).padStart(2, "0");
117
+ const ss = String(date.getSeconds()).padStart(2, "0");
118
+ return `chat-transcript-${yyyy}${mm}${dd}-${hh}${min}${ss}.txt`;
119
+ }
120
+
121
+ export function ChatbotWidget() {
122
+ const {
123
+ isOpen,
124
+ isAwaiting,
125
+ messages,
126
+ uploadEnabled,
127
+ position,
128
+ agentName,
129
+ assistantNote,
130
+ disclaimerText,
131
+ themeVars,
132
+ icons,
133
+ classNames,
134
+ close,
135
+ open,
136
+ sendMessage,
137
+ widgetEnabled,
138
+ } = useChatbotContext();
139
+
140
+ const [inputValue, setInputValue] = useState("");
141
+ const [queuedFiles, setQueuedFiles] = useState<File[]>([]);
142
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
143
+ const [isExpanded, setIsExpanded] = useState(false);
144
+ const [isMobileViewport, setIsMobileViewport] = useState(false);
145
+
146
+ const panelRef = useRef<HTMLDivElement>(null);
147
+ const messagesScrollRef = useRef<HTMLElement>(null);
148
+ const inputRef = useRef<HTMLTextAreaElement>(null);
149
+ const fileInputRef = useRef<HTMLInputElement>(null);
150
+ const menuRef = useRef<HTMLDivElement>(null);
151
+
152
+ const LauncherClosedIcon = icons.launcherClosed;
153
+ const LauncherOpenIcon = icons.launcherOpen;
154
+ const BotIcon = icons.bot;
155
+ const MenuIcon = icons.menu;
156
+ const CloseIcon = icons.close;
157
+ const SendIcon = icons.send;
158
+ const AttachIcon = icons.attach;
159
+ const ExpandIcon = icons.expand;
160
+ const DownloadIcon = icons.download;
161
+
162
+ const alignment = position === "bottom-left" ? "cb-left-6" : "cb-right-6";
163
+ const stackAlignment = position === "bottom-left" ? "cb-items-start" : "cb-items-end";
164
+ const panelOrigin = position === "bottom-left" ? "cb-origin-bottom-left" : "cb-origin-bottom-right";
165
+ const panelLayoutMode = isMobileViewport ? "mobile" : isExpanded ? "desktop-expanded" : "desktop-collapsed";
166
+ const showExpandAction = !isMobileViewport;
167
+ const showDownloadAction = messages.length > 0;
168
+ const hasMenuActions = showExpandAction || showDownloadAction;
169
+
170
+ const canSend = useMemo(() => {
171
+ return !isAwaiting && (inputValue.trim().length > 0 || queuedFiles.length > 0);
172
+ }, [inputValue, isAwaiting, queuedFiles.length]);
173
+
174
+ const resizeComposer = () => {
175
+ const textarea = inputRef.current;
176
+ if (!textarea) {
177
+ return;
178
+ }
179
+
180
+ if (textarea.value.length === 0) {
181
+ textarea.style.height = "38px";
182
+ textarea.style.overflowY = "hidden";
183
+ return;
184
+ }
185
+
186
+ const maxHeightPx = 168;
187
+ textarea.style.height = "auto";
188
+ const nextHeight = Math.min(textarea.scrollHeight, maxHeightPx);
189
+ textarea.style.height = `${nextHeight}px`;
190
+ textarea.style.overflowY = textarea.scrollHeight > maxHeightPx ? "auto" : "hidden";
191
+ };
192
+
193
+ const scrollMessagesToBottom = () => {
194
+ const container = messagesScrollRef.current;
195
+ if (!container) {
196
+ return;
197
+ }
198
+
199
+ container.scrollTop = container.scrollHeight;
200
+ };
201
+
202
+ const panelSize = useMemo<{ className: string; style?: CSSProperties }>(() => {
203
+ const inset = `${PANEL_INSET_REM}rem`;
204
+
205
+ if (panelLayoutMode === "mobile") {
206
+ return {
207
+ className: "cb-fixed cb-max-h-none cb-max-w-none",
208
+ style: {
209
+ top: inset,
210
+ right: inset,
211
+ bottom: `${EXPANDED_BOTTOM_OFFSET_REM}rem`,
212
+ left: inset,
213
+ },
214
+ };
215
+ }
216
+
217
+ if (panelLayoutMode === "desktop-expanded") {
218
+ return {
219
+ className: "cb-relative cb-max-h-none cb-max-w-none",
220
+ style: {
221
+ width: `min(${EXPANDED_MAX_WIDTH_PX}px, calc(100vw - ${PANEL_INSET_REM * 2}rem))`,
222
+ height: `calc(100vh - ${EXPANDED_TOTAL_VERTICAL_INSET_REM}rem)`,
223
+ },
224
+ };
225
+ }
226
+
227
+ return {
228
+ className: "cb-relative cb-h-[min(680px,calc(100vh-7.5rem))] cb-w-[min(92vw,420px)]",
229
+ style: undefined,
230
+ };
231
+ }, [panelLayoutMode]);
232
+
233
+ useEffect(() => {
234
+ if (typeof window === "undefined") {
235
+ return;
236
+ }
237
+
238
+ const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`);
239
+ const syncViewportMode = () => {
240
+ setIsMobileViewport(mediaQuery.matches);
241
+ };
242
+
243
+ syncViewportMode();
244
+
245
+ if (typeof mediaQuery.addEventListener === "function") {
246
+ mediaQuery.addEventListener("change", syncViewportMode);
247
+ return () => mediaQuery.removeEventListener("change", syncViewportMode);
248
+ }
249
+
250
+ mediaQuery.addListener(syncViewportMode);
251
+ return () => mediaQuery.removeListener(syncViewportMode);
252
+ }, []);
253
+
254
+ useEffect(() => {
255
+ if (!isOpen) {
256
+ setIsMenuOpen(false);
257
+ return;
258
+ }
259
+
260
+ inputRef.current?.focus();
261
+ resizeComposer();
262
+ }, [isOpen]);
263
+
264
+ useEffect(() => {
265
+ resizeComposer();
266
+ }, [inputValue]);
267
+
268
+ useEffect(() => {
269
+ if (!isOpen) {
270
+ return;
271
+ }
272
+
273
+ const rafId = window.requestAnimationFrame(() => {
274
+ scrollMessagesToBottom();
275
+ });
276
+
277
+ return () => window.cancelAnimationFrame(rafId);
278
+ }, [isOpen, messages, isAwaiting]);
279
+
280
+ useEffect(() => {
281
+ if (!isOpen) {
282
+ return;
283
+ }
284
+
285
+ const onPointerDown = (event: MouseEvent) => {
286
+ const target = event.target as Node;
287
+ if (menuRef.current && !menuRef.current.contains(target)) {
288
+ setIsMenuOpen(false);
289
+ }
290
+ };
291
+
292
+ window.addEventListener("mousedown", onPointerDown);
293
+ return () => window.removeEventListener("mousedown", onPointerDown);
294
+ }, [isOpen]);
295
+
296
+ useEffect(() => {
297
+ if (!isOpen) {
298
+ return;
299
+ }
300
+
301
+ const onKeyDown = (event: globalThis.KeyboardEvent) => {
302
+ if (event.key === "Escape") {
303
+ if (isMenuOpen) {
304
+ setIsMenuOpen(false);
305
+ return;
306
+ }
307
+ close();
308
+ }
309
+ };
310
+
311
+ window.addEventListener("keydown", onKeyDown);
312
+ return () => window.removeEventListener("keydown", onKeyDown);
313
+ }, [close, isMenuOpen, isOpen]);
314
+
315
+ useEffect(() => {
316
+ setIsMenuOpen(false);
317
+ }, [isMobileViewport]);
318
+
319
+ useEffect(() => {
320
+ if (!hasMenuActions) {
321
+ setIsMenuOpen(false);
322
+ }
323
+ }, [hasMenuActions]);
324
+
325
+ const trapFocus = (event: ReactKeyboardEvent<HTMLDivElement>) => {
326
+ if (event.key !== "Tab") {
327
+ return;
328
+ }
329
+
330
+ const root = panelRef.current;
331
+ if (!root) {
332
+ return;
333
+ }
334
+
335
+ const focusables = root.querySelectorAll<HTMLElement>(
336
+ "button, [href], textarea, input, select, [tabindex]:not([tabindex='-1'])",
337
+ );
338
+
339
+ if (focusables.length === 0) {
340
+ return;
341
+ }
342
+
343
+ const first = focusables[0];
344
+ const last = focusables[focusables.length - 1];
345
+
346
+ if (event.shiftKey && document.activeElement === first) {
347
+ last.focus();
348
+ event.preventDefault();
349
+ } else if (!event.shiftKey && document.activeElement === last) {
350
+ first.focus();
351
+ event.preventDefault();
352
+ }
353
+ };
354
+
355
+ const onSubmit = async (event: FormEvent) => {
356
+ event.preventDefault();
357
+ if (!canSend) {
358
+ return;
359
+ }
360
+
361
+ const submittedText = inputValue;
362
+ setInputValue("");
363
+ scrollMessagesToBottom();
364
+
365
+ await sendMessage({
366
+ text: submittedText,
367
+ files: uploadEnabled ? queuedFiles : [],
368
+ });
369
+
370
+ setQueuedFiles([]);
371
+ if (fileInputRef.current) {
372
+ fileInputRef.current.value = "";
373
+ }
374
+ };
375
+
376
+ const onPickFiles = (event: ChangeEvent<HTMLInputElement>) => {
377
+ const list = event.target.files;
378
+ if (!list || !uploadEnabled) {
379
+ return;
380
+ }
381
+
382
+ const files = Array.from(list);
383
+ setQueuedFiles((current) => {
384
+ const seen = new Set(current.map((file) => `${file.name}:${file.size}:${file.lastModified}`));
385
+ const next = [...current];
386
+
387
+ files.forEach((file) => {
388
+ const key = `${file.name}:${file.size}:${file.lastModified}`;
389
+ if (!seen.has(key)) {
390
+ seen.add(key);
391
+ next.push(file);
392
+ }
393
+ });
394
+
395
+ return next;
396
+ });
397
+ };
398
+
399
+ const createIdFromFile = (file: File) => `${file.name}:${file.size}:${file.lastModified}`;
400
+
401
+ const removeQueuedFile = (fileId: string) => {
402
+ setQueuedFiles((current) => current.filter((file) => createIdFromFile(file) !== fileId));
403
+ };
404
+
405
+ const downloadTranscript = () => {
406
+ if (messages.length === 0) {
407
+ return;
408
+ }
409
+
410
+ const now = new Date();
411
+ const fileContent = toTranscript(messages, agentName);
412
+ const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" });
413
+ const objectUrl = URL.createObjectURL(blob);
414
+
415
+ const anchor = document.createElement("a");
416
+ anchor.href = objectUrl;
417
+ anchor.download = toTranscriptFilename(now);
418
+ document.body.appendChild(anchor);
419
+ anchor.click();
420
+ document.body.removeChild(anchor);
421
+ URL.revokeObjectURL(objectUrl);
422
+
423
+ setIsMenuOpen(false);
424
+ };
425
+
426
+ const toggleExpanded = () => {
427
+ if (isMobileViewport) {
428
+ return;
429
+ }
430
+
431
+ setIsExpanded((current) => !current);
432
+ setIsMenuOpen(false);
433
+ };
434
+
435
+ if (!widgetEnabled) {
436
+ return null;
437
+ }
438
+
439
+ return (
440
+ <div
441
+ className={clsx(
442
+ "cb-fixed cb-bottom-6 cb-z-[2147483640] cb-text-[var(--cb-surface-foreground)]",
443
+ alignment,
444
+ classNames.root,
445
+ )}
446
+ style={themeVars}
447
+ data-chatbot-root
448
+ >
449
+ <div className={clsx("cb-pointer-events-none cb-relative cb-flex cb-flex-col cb-gap-3", stackAlignment)}>
450
+ <div
451
+ ref={panelRef}
452
+ className={clsx(
453
+ "cb-pointer-events-auto cb-flex cb-flex-col cb-overflow-hidden cb-rounded-[var(--cb-radius)] cb-border cb-border-[var(--cb-border)] cb-bg-[var(--cb-surface)] cb-shadow-[var(--cb-shadow-panel)] cb-transition-[opacity,transform,width,height,top,left,right,bottom] cb-duration-[380ms] cb-ease-[cubic-bezier(0.22,1,0.36,1)]",
454
+ panelOrigin,
455
+ panelSize.className,
456
+ isOpen
457
+ ? "cb-translate-y-0 cb-scale-100 cb-opacity-100"
458
+ : "cb-translate-y-3 cb-scale-90 cb-opacity-0 cb-pointer-events-none",
459
+ classNames.panel,
460
+ )}
461
+ style={panelSize.style}
462
+ aria-hidden={!isOpen}
463
+ onKeyDown={trapFocus}
464
+ >
465
+ <header
466
+ className={clsx(
467
+ "cb-flex cb-items-center cb-justify-between cb-gap-2 cb-border-b cb-border-[var(--cb-border)] cb-bg-[var(--cb-surface)] cb-px-4 cb-py-3.5",
468
+ classNames.header,
469
+ )}
470
+ style={{
471
+ borderBottomWidth: "1px",
472
+ borderBottomStyle: "solid",
473
+ borderBottomColor: "var(--cb-border)",
474
+ }}
475
+ >
476
+ <div className={clsx("cb-flex cb-min-w-0 cb-items-center cb-gap-2.5", classNames.headerMeta)}>
477
+ <span className="cb-inline-flex cb-h-9 cb-w-9 cb-shrink-0 cb-items-center cb-justify-center cb-self-center cb-text-[var(--cb-surface-foreground)]">
478
+ <BotIcon size={34} />
479
+ </span>
480
+ <div className="cb-min-w-0 cb-space-y-0">
481
+ <p className="cb-m-0 cb-truncate cb-text-lg cb-font-semibold cb-leading-6 cb-text-[var(--cb-surface-foreground)]">
482
+ {agentName}
483
+ </p>
484
+ <p className="cb-m-0 cb-text-base cb-leading-5 cb-text-[var(--cb-muted-foreground)]">{assistantNote}</p>
485
+ </div>
486
+ </div>
487
+
488
+ <div ref={menuRef} className="cb-relative cb-flex cb-items-center cb-gap-1">
489
+ {hasMenuActions && (
490
+ <button
491
+ type="button"
492
+ onClick={() => setIsMenuOpen((current) => !current)}
493
+ className={clsx(
494
+ "cb-flex cb-h-9 cb-w-9 cb-shrink-0 cb-items-center cb-justify-center cb-rounded-lg cb-bg-transparent cb-p-0 cb-text-[var(--cb-muted-foreground)] cb-transition hover:cb-bg-[var(--cb-muted)] hover:cb-text-white focus-visible:cb-bg-[var(--cb-muted)] focus-visible:cb-text-white",
495
+ isMenuOpen && "cb-bg-[var(--cb-muted)] cb-text-white",
496
+ )}
497
+ aria-label="More options"
498
+ aria-expanded={isMenuOpen}
499
+ >
500
+ <MenuIcon size={18} />
501
+ </button>
502
+ )}
503
+
504
+ {isMenuOpen && hasMenuActions && (
505
+ <div
506
+ className={clsx(
507
+ "cb-absolute cb-right-0 cb-top-full cb-z-20 cb-mt-2 cb-flex cb-min-w-[220px] cb-flex-col cb-gap-1 cb-rounded-xl cb-border cb-border-[var(--cb-border)] cb-bg-[var(--cb-surface)] cb-p-1.5 cb-shadow-[var(--cb-shadow-panel)]",
508
+ classNames.menu,
509
+ )}
510
+ >
511
+ {showExpandAction && (
512
+ <button
513
+ type="button"
514
+ onClick={toggleExpanded}
515
+ className={clsx(
516
+ "cb-flex cb-w-full cb-items-center cb-gap-2 cb-rounded-lg cb-bg-transparent cb-px-3.5 cb-py-2.5 cb-text-left cb-text-sm cb-text-[var(--cb-muted-foreground)] cb-transition hover:cb-bg-[var(--cb-muted)] hover:cb-text-white focus-visible:cb-bg-[var(--cb-muted)] focus-visible:cb-text-white",
517
+ classNames.menuItem,
518
+ )}
519
+ >
520
+ <ExpandIcon size={16} />
521
+ <span>{isExpanded ? "Restore window" : "Expand window"}</span>
522
+ </button>
523
+ )}
524
+
525
+ {showDownloadAction && (
526
+ <button
527
+ type="button"
528
+ onClick={downloadTranscript}
529
+ className={clsx(
530
+ "cb-flex cb-w-full cb-items-center cb-gap-2 cb-rounded-lg cb-bg-transparent cb-px-3.5 cb-py-2.5 cb-text-left cb-text-sm cb-text-[var(--cb-muted-foreground)] cb-transition hover:cb-bg-[var(--cb-muted)] hover:cb-text-white focus-visible:cb-bg-[var(--cb-muted)] focus-visible:cb-text-white",
531
+ classNames.menuItem,
532
+ )}
533
+ >
534
+ <DownloadIcon size={16} />
535
+ <span>Download transcript</span>
536
+ </button>
537
+ )}
538
+ </div>
539
+ )}
540
+
541
+ <button
542
+ type="button"
543
+ onClick={close}
544
+ className="cb-flex cb-h-9 cb-w-9 cb-shrink-0 cb-items-center cb-justify-center cb-rounded-lg cb-bg-transparent cb-p-0 cb-text-[var(--cb-muted-foreground)] cb-transition hover:cb-bg-[var(--cb-muted)] hover:cb-text-white focus-visible:cb-bg-[var(--cb-muted)] focus-visible:cb-text-white"
545
+ aria-label="Close chatbot"
546
+ >
547
+ <CloseIcon size={18} />
548
+ </button>
549
+ </div>
550
+ </header>
551
+
552
+ <section
553
+ ref={messagesScrollRef}
554
+ className={clsx(
555
+ "cb-chat-scroll cb-flex-1 cb-overflow-y-auto cb-scroll-smooth cb-bg-[var(--cb-background)] cb-px-4 cb-pt-4 cb-pb-36",
556
+ classNames.body,
557
+ )}
558
+ >
559
+ <div className="cb-space-y-3.5">
560
+ {messages.length === 0 && (
561
+ <div className="cb-rounded-xl cb-bg-[var(--cb-muted)] cb-p-3 cb-text-sm cb-text-[var(--cb-muted-foreground)]">
562
+ Ask anything about this company and the assistant will answer using your RAG knowledge base.
563
+ </div>
564
+ )}
565
+
566
+ {messages.map((message) => {
567
+ const isUser = message.role === "user";
568
+
569
+ return (
570
+ <article
571
+ key={message.id}
572
+ className={clsx(
573
+ "cb-flex",
574
+ isUser ? "cb-justify-end cb-animate-cb-slide-fade-in-right" : "cb-justify-start cb-animate-cb-slide-fade-in-left",
575
+ )}
576
+ >
577
+ <div
578
+ className={clsx(
579
+ "cb-max-w-[85%] cb-rounded-2xl cb-px-4 cb-py-2",
580
+ isUser
581
+ ? "cb-rounded-br-md cb-bg-[var(--cb-user-bubble)] cb-text-[var(--cb-user-text)]"
582
+ : "cb-rounded-bl-md cb-bg-[var(--cb-assistant-bubble)] cb-text-[var(--cb-assistant-text)]",
583
+ )}
584
+ >
585
+ {message.role === "assistant" && message.status === "streaming" && !message.content ? (
586
+ <AwaitingDots />
587
+ ) : message.role === "assistant" ? (
588
+ <MessageMarkdown content={message.content} />
589
+ ) : (
590
+ <p className="cb-message-text cb-m-0 cb-whitespace-pre-wrap cb-text-base cb-leading-6">
591
+ {message.content}
592
+ </p>
593
+ )}
594
+
595
+ {message.attachments && message.attachments.length > 0 && (
596
+ <div className="cb-mt-2 cb-flex cb-flex-wrap cb-gap-2">
597
+ {message.attachments.map((attachment) => (
598
+ <span
599
+ key={attachment.id}
600
+ className="cb-inline-flex cb-items-center cb-gap-2 cb-rounded-full cb-bg-black/10 cb-px-2 cb-py-1 cb-text-xs"
601
+ >
602
+ <span>{attachment.name}</span>
603
+ <span className="cb-opacity-70">{formatFileSize(attachment.size)}</span>
604
+ </span>
605
+ ))}
606
+ </div>
607
+ )}
608
+ </div>
609
+ </article>
610
+ );
611
+ })}
612
+
613
+ <div />
614
+ </div>
615
+ </section>
616
+
617
+ <footer
618
+ className={clsx(
619
+ "cb-absolute cb-bottom-4 cb-left-4 cb-right-4 cb-z-10 cb-bg-transparent cb-p-0",
620
+ classNames.footer,
621
+ )}
622
+ >
623
+ {uploadEnabled && queuedFiles.length > 0 && (
624
+ <div className="cb-mb-2 cb-flex cb-flex-wrap cb-gap-2">
625
+ {queuedFiles.map((file) => {
626
+ const fileId = createIdFromFile(file);
627
+ return (
628
+ <button
629
+ type="button"
630
+ key={fileId}
631
+ className="cb-inline-flex cb-items-center cb-gap-2 cb-rounded-full cb-bg-[var(--cb-muted)] cb-px-3 cb-py-1 cb-text-xs cb-text-[var(--cb-muted-foreground)]"
632
+ onClick={() => removeQueuedFile(fileId)}
633
+ aria-label={`Remove ${file.name}`}
634
+ >
635
+ <span className="cb-max-w-[140px] cb-truncate">{file.name}</span>
636
+ <span>x</span>
637
+ </button>
638
+ );
639
+ })}
640
+ </div>
641
+ )}
642
+
643
+ <form onSubmit={onSubmit}>
644
+ <div
645
+ className={clsx(
646
+ "cb-flex cb-items-end cb-gap-2 cb-rounded-2xl cb-bg-[var(--cb-surface)] cb-p-2 cb-shadow-[0_18px_35px_-24px_rgba(0,0,0,0.85)] cb-transition",
647
+ classNames.composer,
648
+ )}
649
+ >
650
+ {uploadEnabled && (
651
+ <>
652
+ <input
653
+ type="file"
654
+ multiple
655
+ ref={fileInputRef}
656
+ className="cb-hidden"
657
+ onChange={onPickFiles}
658
+ aria-label="Add attachments"
659
+ />
660
+ <button
661
+ type="button"
662
+ className="cb-flex cb-h-10 cb-w-10 cb-shrink-0 cb-items-center cb-justify-center cb-rounded-lg cb-bg-transparent cb-p-0 cb-text-[var(--cb-muted-foreground)] cb-transition hover:cb-bg-[var(--cb-muted)] hover:cb-text-white focus-visible:cb-bg-[var(--cb-muted)] focus-visible:cb-text-white"
663
+ onClick={() => fileInputRef.current?.click()}
664
+ aria-label="Attach files"
665
+ >
666
+ <AttachIcon size={18} />
667
+ </button>
668
+ </>
669
+ )}
670
+
671
+ <div className="cb-flex-1">
672
+ <textarea
673
+ ref={inputRef}
674
+ value={inputValue}
675
+ onChange={(event) => setInputValue(event.target.value)}
676
+ onKeyDown={(event) => {
677
+ if (event.key === "Enter" && !event.shiftKey) {
678
+ event.preventDefault();
679
+ void onSubmit(event);
680
+ }
681
+ }}
682
+ rows={1}
683
+ placeholder="Ask a question..."
684
+ className={clsx(
685
+ "cb-composer-input cb-min-h-[38px] cb-w-full cb-resize-none cb-bg-transparent cb-px-2 cb-py-1.5 cb-text-sm cb-leading-6 cb-text-[var(--cb-surface-foreground)] cb-outline-none placeholder:cb-text-[var(--cb-muted-foreground)]",
686
+ classNames.input,
687
+ )}
688
+ />
689
+ </div>
690
+
691
+ <button
692
+ type="submit"
693
+ disabled={!canSend}
694
+ className={clsx(
695
+ "cb-flex cb-h-10 cb-w-10 cb-shrink-0 cb-items-center cb-justify-center cb-rounded-full cb-bg-white cb-p-0 cb-text-[#111111] cb-transition cb-duration-200 hover:cb-scale-105 hover:cb-brightness-95 hover:cb-text-[#111111] active:cb-scale-95 focus-visible:cb-text-[#111111] disabled:cb-cursor-not-allowed disabled:cb-bg-white/70 disabled:cb-text-[#111111] disabled:cb-opacity-100",
696
+ classNames.sendButton,
697
+ )}
698
+ aria-label="Send message"
699
+ >
700
+ <SendIcon size={18} />
701
+ </button>
702
+ </div>
703
+ <p className="cb-m-0 cb-mt-1 cb-px-1 cb-text-center cb-text-xs cb-leading-4 cb-text-[var(--cb-muted-foreground)]">
704
+ {disclaimerText}
705
+ </p>
706
+ </form>
707
+ </footer>
708
+ </div>
709
+
710
+ <button
711
+ type="button"
712
+ onClick={isOpen ? close : open}
713
+ className={clsx(
714
+ "cb-pointer-events-auto cb-group cb-flex cb-h-14 cb-w-14 cb-items-center cb-justify-center cb-rounded-full cb-border cb-border-[var(--cb-border)] cb-bg-[#111111] cb-text-[#f5f5f5] cb-shadow-[var(--cb-shadow-bubble)] cb-transition cb-duration-300 hover:cb-scale-110 hover:cb-bg-[#262626] hover:cb-shadow-[0_24px_50px_-18px_rgba(0,0,0,0.9)] active:cb-scale-95",
715
+ classNames.bubble,
716
+ )}
717
+ aria-label={isOpen ? "Close chatbot" : "Open chatbot"}
718
+ >
719
+ <span className="cb-relative cb-flex cb-h-6 cb-w-6 cb-items-center cb-justify-center">
720
+ <LauncherClosedIcon
721
+ size={22}
722
+ className={clsx(
723
+ "cb-absolute cb-fill-current cb-transition-all cb-duration-300 cb-ease-out",
724
+ isOpen ? "cb-rotate-90 cb-opacity-0" : "cb-rotate-0 cb-opacity-100",
725
+ )}
726
+ />
727
+ <LauncherOpenIcon
728
+ size={22}
729
+ className={clsx(
730
+ "cb-absolute cb-transition-all cb-duration-300 cb-ease-out",
731
+ isOpen ? "cb-rotate-0 cb-opacity-100" : "-cb-rotate-90 cb-opacity-0",
732
+ )}
733
+ />
734
+ </span>
735
+ </button>
736
+ </div>
737
+ </div>
738
+ );
739
+ }