@object-ui/plugin-chatbot 0.3.1 → 2.0.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,38 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ import * as React from "react";
9
+ export interface ChatMessage {
10
+ id: string;
11
+ role: "user" | "assistant" | "system";
12
+ content: string;
13
+ timestamp?: string;
14
+ avatar?: string;
15
+ avatarFallback?: string;
16
+ streaming?: boolean;
17
+ }
18
+ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElement> {
19
+ messages?: ChatMessage[];
20
+ placeholder?: string;
21
+ onSendMessage?: (message: string, files?: File[]) => void;
22
+ onClear?: () => void;
23
+ disabled?: boolean;
24
+ showTimestamp?: boolean;
25
+ userAvatarUrl?: string;
26
+ userAvatarFallback?: string;
27
+ assistantAvatarUrl?: string;
28
+ assistantAvatarFallback?: string;
29
+ maxHeight?: string;
30
+ enableMarkdown?: boolean;
31
+ enableFileUpload?: boolean;
32
+ acceptedFileTypes?: string;
33
+ maxFileSize?: number;
34
+ onStreamingUpdate?: (messageId: string, content: string) => void;
35
+ }
36
+ declare const ChatbotEnhanced: React.ForwardRefExoticComponent<ChatbotEnhancedProps & React.RefAttributes<HTMLDivElement>>;
37
+ export { ChatbotEnhanced };
38
+ //# sourceMappingURL=ChatbotEnhanced.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ChatbotEnhanced.d.ts","sourceRoot":"","sources":["../../src/ChatbotEnhanced.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAS9B,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,QAAQ,CAAA;IACrC,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED,MAAM,WAAW,oBAAqB,SAAQ,KAAK,CAAC,cAAc,CAAC,cAAc,CAAC;IAChF,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAA;IACxB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,IAAI,EAAE,KAAK,IAAI,CAAA;IACzD,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,uBAAuB,CAAC,EAAE,MAAM,CAAA;IAChC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CACjE;AAiED,QAAA,MAAM,eAAe,6FAqQpB,CAAA;AAID,OAAO,EAAE,eAAe,EAAE,CAAA"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Generates a unique ID for messages or other entities
10
+ * Uses crypto.randomUUID() if available, otherwise falls back to timestamp + random string
11
+ */
12
+ export declare function generateUniqueId(prefix?: string): string;
13
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,SAAQ,GAAG,MAAM,CAKvD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/plugin-chatbot",
3
- "version": "0.3.1",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Chatbot interface plugin for Object UI",
@@ -25,19 +25,23 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "lucide-react": "^0.563.0",
28
- "@object-ui/components": "0.3.1",
29
- "@object-ui/core": "0.3.1",
30
- "@object-ui/react": "0.3.1",
31
- "@object-ui/types": "0.3.1"
28
+ "react-markdown": "^10.1.0",
29
+ "react-syntax-highlighter": "^16.1.0",
30
+ "remark-gfm": "^4.0.0",
31
+ "@object-ui/react": "2.0.0",
32
+ "@object-ui/core": "2.0.0",
33
+ "@object-ui/components": "2.0.0",
34
+ "@object-ui/types": "2.0.0"
32
35
  },
33
36
  "peerDependencies": {
34
37
  "react": "^18.0.0 || ^19.0.0",
35
38
  "react-dom": "^18.0.0 || ^19.0.0"
36
39
  },
37
40
  "devDependencies": {
38
- "@types/react": "^19.2.9",
41
+ "@types/react": "^19.2.13",
39
42
  "@types/react-dom": "^19.2.3",
40
- "@vitejs/plugin-react": "^4.2.1",
43
+ "@types/react-syntax-highlighter": "^15.5.13",
44
+ "@vitejs/plugin-react": "^5.1.3",
41
45
  "typescript": "^5.9.3",
42
46
  "vite": "^7.3.1",
43
47
  "vite-plugin-dts": "^4.5.4"
@@ -0,0 +1,375 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import * as React from "react"
10
+ import { cn } from "@object-ui/components"
11
+ import { Button, Input, ScrollArea, Avatar, AvatarFallback, AvatarImage } from "@object-ui/components"
12
+ import { Send, Trash2, Paperclip, X } from "lucide-react"
13
+ import ReactMarkdown from "react-markdown"
14
+ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
15
+ import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"
16
+ import remarkGfm from "remark-gfm"
17
+
18
+ export interface ChatMessage {
19
+ id: string
20
+ role: "user" | "assistant" | "system"
21
+ content: string
22
+ timestamp?: string
23
+ avatar?: string
24
+ avatarFallback?: string
25
+ streaming?: boolean
26
+ }
27
+
28
+ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElement> {
29
+ messages?: ChatMessage[]
30
+ placeholder?: string
31
+ onSendMessage?: (message: string, files?: File[]) => void
32
+ onClear?: () => void
33
+ disabled?: boolean
34
+ showTimestamp?: boolean
35
+ userAvatarUrl?: string
36
+ userAvatarFallback?: string
37
+ assistantAvatarUrl?: string
38
+ assistantAvatarFallback?: string
39
+ maxHeight?: string
40
+ enableMarkdown?: boolean
41
+ enableFileUpload?: boolean
42
+ acceptedFileTypes?: string
43
+ maxFileSize?: number
44
+ onStreamingUpdate?: (messageId: string, content: string) => void
45
+ }
46
+
47
+ function MessageContent({ content, enableMarkdown }: { content: string; enableMarkdown?: boolean }) {
48
+ if (!enableMarkdown) {
49
+ return <div className="whitespace-pre-wrap">{content}</div>
50
+ }
51
+
52
+ return (
53
+ <div className="prose prose-sm dark:prose-invert max-w-none">
54
+ <ReactMarkdown
55
+ remarkPlugins={[remarkGfm]}
56
+ components={{
57
+ code({ node, inline, className, children, ...props }: any) {
58
+ const match = /language-(\w+)/.exec(className || '')
59
+ return !inline && match ? (
60
+ <SyntaxHighlighter
61
+ style={oneDark}
62
+ language={match[1]}
63
+ PreTag="div"
64
+ className="rounded-md my-2"
65
+ {...props}
66
+ >
67
+ {String(children).replace(/\n$/, '')}
68
+ </SyntaxHighlighter>
69
+ ) : (
70
+ <code className={cn("bg-muted px-1 py-0.5 rounded text-sm", className)} {...props}>
71
+ {children}
72
+ </code>
73
+ )
74
+ },
75
+ p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
76
+ ul: ({ children }) => <ul className="list-disc pl-4 mb-2">{children}</ul>,
77
+ ol: ({ children }) => <ol className="list-decimal pl-4 mb-2">{children}</ol>,
78
+ li: ({ children }) => <li className="mb-1">{children}</li>,
79
+ a: ({ href, children }) => (
80
+ <a href={href} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer">
81
+ {children}
82
+ </a>
83
+ ),
84
+ blockquote: ({ children }) => (
85
+ <blockquote className="border-l-4 border-primary pl-4 italic my-2 text-muted-foreground">
86
+ {children}
87
+ </blockquote>
88
+ ),
89
+ table: ({ children }) => (
90
+ <div className="overflow-x-auto my-2">
91
+ <table className="min-w-full divide-y divide-border">{children}</table>
92
+ </div>
93
+ ),
94
+ th: ({ children }) => (
95
+ <th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider bg-muted">
96
+ {children}
97
+ </th>
98
+ ),
99
+ td: ({ children }) => (
100
+ <td className="px-3 py-2 text-sm border-t border-border">{children}</td>
101
+ ),
102
+ }}
103
+ >
104
+ {content}
105
+ </ReactMarkdown>
106
+ </div>
107
+ )
108
+ }
109
+
110
+ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
111
+ (
112
+ {
113
+ className,
114
+ messages = [],
115
+ placeholder = "Type your message...",
116
+ onSendMessage,
117
+ onClear,
118
+ disabled = false,
119
+ showTimestamp = false,
120
+ userAvatarUrl,
121
+ userAvatarFallback = "You",
122
+ assistantAvatarUrl,
123
+ assistantAvatarFallback = "AI",
124
+ maxHeight = "500px",
125
+ enableMarkdown = true,
126
+ enableFileUpload = false,
127
+ acceptedFileTypes = "image/*,.pdf,.doc,.docx,.txt",
128
+ maxFileSize = 10 * 1024 * 1024, // 10MB
129
+ ...props
130
+ },
131
+ ref
132
+ ) => {
133
+ const [inputValue, setInputValue] = React.useState("")
134
+ const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
135
+ const scrollRef = React.useRef<HTMLDivElement>(null)
136
+ const inputRef = React.useRef<HTMLInputElement>(null)
137
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
138
+
139
+ // Auto-scroll to bottom when new messages arrive
140
+ React.useEffect(() => {
141
+ if (scrollRef.current) {
142
+ const scrollElement = scrollRef.current.querySelector('[data-radix-scroll-area-viewport]')
143
+ if (scrollElement) {
144
+ scrollElement.scrollTop = scrollElement.scrollHeight
145
+ }
146
+ }
147
+ }, [messages])
148
+
149
+ const handleSend = () => {
150
+ if ((inputValue.trim() || selectedFiles.length > 0) && onSendMessage) {
151
+ onSendMessage(inputValue.trim(), selectedFiles)
152
+ setInputValue("")
153
+ setSelectedFiles([])
154
+ inputRef.current?.focus()
155
+ }
156
+ }
157
+
158
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
159
+ if (e.key === "Enter" && !e.shiftKey) {
160
+ e.preventDefault()
161
+ handleSend()
162
+ }
163
+ }
164
+
165
+ const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
166
+ const files = Array.from(e.target.files || [])
167
+
168
+ // Validate file sizes and MIME types
169
+ const validFiles = files.filter(file => {
170
+ // Size validation
171
+ if (file.size > maxFileSize) {
172
+ console.warn(`File ${file.name} exceeds ${maxFileSize / 1024 / 1024}MB limit`)
173
+ return false
174
+ }
175
+
176
+ // Basic MIME type validation
177
+ const acceptedTypes = acceptedFileTypes.split(',').map(t => t.trim())
178
+ const matchesType = acceptedTypes.some(type => {
179
+ if (type.startsWith('.')) {
180
+ return file.name.toLowerCase().endsWith(type.toLowerCase())
181
+ }
182
+ if (type.endsWith('/*')) {
183
+ const category = type.split('/')[0]
184
+ return file.type.startsWith(category + '/')
185
+ }
186
+ return file.type === type
187
+ })
188
+
189
+ if (!matchesType) {
190
+ console.warn(`File ${file.name} type ${file.type} not accepted`)
191
+ return false
192
+ }
193
+
194
+ return true
195
+ })
196
+
197
+ if (validFiles.length < files.length) {
198
+ console.warn(`${files.length - validFiles.length} file(s) were rejected`)
199
+ }
200
+
201
+ setSelectedFiles(prev => [...prev, ...validFiles])
202
+ }
203
+
204
+ const handleRemoveFile = (index: number) => {
205
+ setSelectedFiles(prev => prev.filter((_, i) => i !== index))
206
+ }
207
+
208
+ const handleClear = () => {
209
+ if (onClear) {
210
+ onClear()
211
+ }
212
+ }
213
+
214
+ return (
215
+ <div
216
+ ref={ref}
217
+ className={cn(
218
+ "flex flex-col border rounded-lg bg-background overflow-hidden",
219
+ className
220
+ )}
221
+ style={{ maxHeight }}
222
+ {...props}
223
+ >
224
+ {/* Header with clear button */}
225
+ {onClear && messages.length > 0 && (
226
+ <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30">
227
+ <span className="text-sm text-muted-foreground">
228
+ {messages.length} message{messages.length !== 1 ? 's' : ''}
229
+ </span>
230
+ <Button
231
+ variant="ghost"
232
+ size="sm"
233
+ onClick={handleClear}
234
+ className="h-8 text-xs"
235
+ >
236
+ <Trash2 className="h-3 w-3 mr-1" />
237
+ Clear
238
+ </Button>
239
+ </div>
240
+ )}
241
+
242
+ {/* Messages area */}
243
+ <ScrollArea ref={scrollRef} className="flex-1 p-4">
244
+ <div className="space-y-4">
245
+ {messages.length === 0 ? (
246
+ <div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
247
+ Start a conversation...
248
+ </div>
249
+ ) : (
250
+ messages.map((message) => (
251
+ <div
252
+ key={message.id}
253
+ className={cn(
254
+ "flex gap-3",
255
+ message.role === "user" ? "justify-end" : "justify-start"
256
+ )}
257
+ >
258
+ {message.role !== "user" && (
259
+ <Avatar className="h-8 w-8 flex-shrink-0">
260
+ <AvatarImage src={message.avatar || assistantAvatarUrl} />
261
+ <AvatarFallback>{message.avatarFallback || assistantAvatarFallback}</AvatarFallback>
262
+ </Avatar>
263
+ )}
264
+
265
+ <div
266
+ className={cn(
267
+ "rounded-lg px-4 py-2 max-w-[80%]",
268
+ message.role === "user"
269
+ ? "bg-primary text-primary-foreground"
270
+ : "bg-muted"
271
+ )}
272
+ >
273
+ <MessageContent content={message.content} enableMarkdown={enableMarkdown} />
274
+ {showTimestamp && message.timestamp && (
275
+ <div className="text-xs opacity-70 mt-1">
276
+ {message.timestamp}
277
+ </div>
278
+ )}
279
+ {message.streaming && (
280
+ <div className="flex gap-1 mt-2">
281
+ <div className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
282
+ <div className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
283
+ <div className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
284
+ </div>
285
+ )}
286
+ </div>
287
+
288
+ {message.role === "user" && (
289
+ <Avatar className="h-8 w-8 flex-shrink-0">
290
+ <AvatarImage src={message.avatar || userAvatarUrl} />
291
+ <AvatarFallback>{message.avatarFallback || userAvatarFallback}</AvatarFallback>
292
+ </Avatar>
293
+ )}
294
+ </div>
295
+ ))
296
+ )}
297
+ </div>
298
+ </ScrollArea>
299
+
300
+ {/* Selected files preview */}
301
+ {selectedFiles.length > 0 && (
302
+ <div className="px-4 py-2 border-t bg-muted/30">
303
+ <div className="flex flex-wrap gap-2">
304
+ {selectedFiles.map((file, index) => (
305
+ <div
306
+ key={index}
307
+ className="flex items-center gap-2 bg-background border rounded px-2 py-1 text-xs"
308
+ >
309
+ <Paperclip className="h-3 w-3" />
310
+ <span className="max-w-[150px] truncate">{file.name}</span>
311
+ <Button
312
+ variant="ghost"
313
+ size="sm"
314
+ className="h-4 w-4 p-0"
315
+ onClick={() => handleRemoveFile(index)}
316
+ >
317
+ <X className="h-3 w-3" />
318
+ </Button>
319
+ </div>
320
+ ))}
321
+ </div>
322
+ </div>
323
+ )}
324
+
325
+ {/* Input area */}
326
+ <div className="flex items-center gap-2 p-4 border-t">
327
+ {enableFileUpload && (
328
+ <>
329
+ <input
330
+ ref={fileInputRef}
331
+ type="file"
332
+ multiple
333
+ accept={acceptedFileTypes}
334
+ onChange={handleFileSelect}
335
+ className="hidden"
336
+ />
337
+ <Button
338
+ variant="ghost"
339
+ size="icon"
340
+ onClick={() => fileInputRef.current?.click()}
341
+ disabled={disabled}
342
+ aria-label="Attach file"
343
+ >
344
+ <Paperclip className="h-4 w-4" />
345
+ </Button>
346
+ </>
347
+ )}
348
+
349
+ <Input
350
+ ref={inputRef}
351
+ value={inputValue}
352
+ onChange={(e) => setInputValue(e.target.value)}
353
+ onKeyDown={handleKeyDown}
354
+ placeholder={placeholder}
355
+ disabled={disabled}
356
+ className="flex-1"
357
+ />
358
+
359
+ <Button
360
+ onClick={handleSend}
361
+ disabled={disabled || (!inputValue.trim() && selectedFiles.length === 0)}
362
+ size="icon"
363
+ aria-label="Send message"
364
+ >
365
+ <Send className="h-4 w-4" />
366
+ </Button>
367
+ </div>
368
+ </div>
369
+ )
370
+ }
371
+ )
372
+
373
+ ChatbotEnhanced.displayName = "ChatbotEnhanced"
374
+
375
+ export { ChatbotEnhanced }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import '@testing-library/jest-dom';
10
+ import { describe, it, expect, vi } from 'vitest';
11
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
12
+ import { ChatbotEnhanced, type ChatMessage } from '../ChatbotEnhanced';
13
+
14
+ describe('ChatbotEnhanced', () => {
15
+ const mockMessages: ChatMessage[] = [
16
+ {
17
+ id: '1',
18
+ role: 'user',
19
+ content: 'Hello!',
20
+ timestamp: '10:00 AM',
21
+ },
22
+ {
23
+ id: '2',
24
+ role: 'assistant',
25
+ content: 'Hi there! How can I help you?',
26
+ timestamp: '10:01 AM',
27
+ },
28
+ ];
29
+
30
+ it('should render without crashing', () => {
31
+ const { container } = render(<ChatbotEnhanced />);
32
+ expect(container).toBeTruthy();
33
+ });
34
+
35
+ it('should render messages', () => {
36
+ render(<ChatbotEnhanced messages={mockMessages} />);
37
+
38
+ expect(screen.getByText('Hello!')).toBeInTheDocument();
39
+ expect(screen.getByText('Hi there! How can I help you?')).toBeInTheDocument();
40
+ });
41
+
42
+ it('should render placeholder in input', () => {
43
+ const placeholder = 'Type your message...';
44
+ render(<ChatbotEnhanced placeholder={placeholder} />);
45
+
46
+ expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument();
47
+ });
48
+
49
+ it('should call onSendMessage when send button is clicked', async () => {
50
+ const onSendMessage = vi.fn();
51
+ render(<ChatbotEnhanced onSendMessage={onSendMessage} />);
52
+
53
+ const input = screen.getByPlaceholderText(/message/i);
54
+ const sendButton = screen.getByRole('button', { name: /send/i });
55
+
56
+ fireEvent.change(input, { target: { value: 'Test message' } });
57
+ fireEvent.click(sendButton);
58
+
59
+ await waitFor(() => {
60
+ expect(onSendMessage).toHaveBeenCalledWith('Test message', []);
61
+ });
62
+ });
63
+
64
+ it('should call onSendMessage when Enter is pressed', async () => {
65
+ const onSendMessage = vi.fn();
66
+ render(<ChatbotEnhanced onSendMessage={onSendMessage} />);
67
+
68
+ const input = screen.getByPlaceholderText(/message/i);
69
+
70
+ fireEvent.change(input, { target: { value: 'Test message' } });
71
+ fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
72
+
73
+ await waitFor(() => {
74
+ expect(onSendMessage).toHaveBeenCalledWith('Test message', []);
75
+ });
76
+ });
77
+
78
+ it('should not send empty messages', async () => {
79
+ const onSendMessage = vi.fn();
80
+ render(<ChatbotEnhanced onSendMessage={onSendMessage} />);
81
+
82
+ const sendButton = screen.getByRole('button', { name: /send/i });
83
+
84
+ fireEvent.click(sendButton);
85
+
86
+ await waitFor(() => {
87
+ expect(onSendMessage).not.toHaveBeenCalled();
88
+ });
89
+ });
90
+
91
+ it('should call onClear when clear button is clicked', () => {
92
+ const onClear = vi.fn();
93
+ render(<ChatbotEnhanced messages={mockMessages} onClear={onClear} />);
94
+
95
+ const clearButton = screen.getByRole('button', { name: /clear/i });
96
+ fireEvent.click(clearButton);
97
+
98
+ expect(onClear).toHaveBeenCalled();
99
+ });
100
+
101
+ it('should render markdown content when enabled', () => {
102
+ const markdownMessage: ChatMessage[] = [
103
+ {
104
+ id: '1',
105
+ role: 'assistant',
106
+ content: '**Bold text** and *italic text*',
107
+ },
108
+ ];
109
+
110
+ render(<ChatbotEnhanced messages={markdownMessage} enableMarkdown={true} />);
111
+
112
+ // Check that markdown is rendered (will create strong and em tags)
113
+ const { container } = render(<ChatbotEnhanced messages={markdownMessage} enableMarkdown={true} />);
114
+ expect(container.querySelector('strong')).toBeTruthy();
115
+ });
116
+
117
+ it('should render plain text when markdown is disabled', () => {
118
+ const textMessage: ChatMessage[] = [
119
+ {
120
+ id: '1',
121
+ role: 'assistant',
122
+ content: '**Bold text** and *italic text*',
123
+ },
124
+ ];
125
+
126
+ render(<ChatbotEnhanced messages={textMessage} enableMarkdown={false} />);
127
+
128
+ // Should render as plain text
129
+ expect(screen.getByText('**Bold text** and *italic text*')).toBeInTheDocument();
130
+ });
131
+
132
+ it('should show timestamps when enabled', () => {
133
+ render(<ChatbotEnhanced messages={mockMessages} showTimestamp={true} />);
134
+
135
+ expect(screen.getByText('10:00 AM')).toBeInTheDocument();
136
+ expect(screen.getByText('10:01 AM')).toBeInTheDocument();
137
+ });
138
+
139
+ it('should disable input when disabled prop is true', () => {
140
+ render(<ChatbotEnhanced disabled={true} />);
141
+
142
+ const input = screen.getByPlaceholderText(/message/i);
143
+ expect(input).toBeDisabled();
144
+ });
145
+
146
+ it('should render file upload button when enabled', () => {
147
+ render(<ChatbotEnhanced enableFileUpload={true} />);
148
+
149
+ const fileButton = screen.getByRole('button', { name: /attach/i });
150
+ expect(fileButton).toBeInTheDocument();
151
+ });
152
+
153
+ it('should handle streaming messages', () => {
154
+ const streamingMessage: ChatMessage[] = [
155
+ {
156
+ id: '1',
157
+ role: 'assistant',
158
+ content: 'Streaming content...',
159
+ streaming: true,
160
+ },
161
+ ];
162
+
163
+ const { container } = render(<ChatbotEnhanced messages={streamingMessage} />);
164
+
165
+ expect(screen.getByText('Streaming content...')).toBeInTheDocument();
166
+ // Streaming messages typically have a visual indicator (cursor/animation)
167
+ });
168
+
169
+ it('should render user and assistant avatars', () => {
170
+ render(
171
+ <ChatbotEnhanced
172
+ messages={mockMessages}
173
+ userAvatarFallback="U"
174
+ assistantAvatarFallback="A"
175
+ />
176
+ );
177
+
178
+ // Avatars should be rendered
179
+ expect(screen.getByText('U')).toBeInTheDocument();
180
+ expect(screen.getByText('A')).toBeInTheDocument();
181
+ });
182
+
183
+ it('should clear input after sending message', async () => {
184
+ const onSendMessage = vi.fn();
185
+ render(<ChatbotEnhanced onSendMessage={onSendMessage} />);
186
+
187
+ const input = screen.getByPlaceholderText(/message/i) as HTMLInputElement;
188
+
189
+ fireEvent.change(input, { target: { value: 'Test message' } });
190
+ expect(input.value).toBe('Test message');
191
+
192
+ const sendButton = screen.getByRole('button', { name: /send/i });
193
+ fireEvent.click(sendButton);
194
+
195
+ await waitFor(() => {
196
+ expect(input.value).toBe('');
197
+ });
198
+ });
199
+ });