@object-ui/plugin-chatbot 0.3.1 → 0.5.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;AAgED,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": "0.5.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": "^9.0.1",
29
+ "react-syntax-highlighter": "^15.6.1",
30
+ "remark-gfm": "^4.0.0",
31
+ "@object-ui/components": "0.5.0",
32
+ "@object-ui/react": "0.5.0",
33
+ "@object-ui/core": "0.5.0",
34
+ "@object-ui/types": "0.5.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.10",
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,374 @@
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
+ <ReactMarkdown
54
+ remarkPlugins={[remarkGfm]}
55
+ className="prose prose-sm dark:prose-invert max-w-none"
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
+ )
107
+ }
108
+
109
+ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
110
+ (
111
+ {
112
+ className,
113
+ messages = [],
114
+ placeholder = "Type your message...",
115
+ onSendMessage,
116
+ onClear,
117
+ disabled = false,
118
+ showTimestamp = false,
119
+ userAvatarUrl,
120
+ userAvatarFallback = "You",
121
+ assistantAvatarUrl,
122
+ assistantAvatarFallback = "AI",
123
+ maxHeight = "500px",
124
+ enableMarkdown = true,
125
+ enableFileUpload = false,
126
+ acceptedFileTypes = "image/*,.pdf,.doc,.docx,.txt",
127
+ maxFileSize = 10 * 1024 * 1024, // 10MB
128
+ ...props
129
+ },
130
+ ref
131
+ ) => {
132
+ const [inputValue, setInputValue] = React.useState("")
133
+ const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
134
+ const scrollRef = React.useRef<HTMLDivElement>(null)
135
+ const inputRef = React.useRef<HTMLInputElement>(null)
136
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
137
+
138
+ // Auto-scroll to bottom when new messages arrive
139
+ React.useEffect(() => {
140
+ if (scrollRef.current) {
141
+ const scrollElement = scrollRef.current.querySelector('[data-radix-scroll-area-viewport]')
142
+ if (scrollElement) {
143
+ scrollElement.scrollTop = scrollElement.scrollHeight
144
+ }
145
+ }
146
+ }, [messages])
147
+
148
+ const handleSend = () => {
149
+ if ((inputValue.trim() || selectedFiles.length > 0) && onSendMessage) {
150
+ onSendMessage(inputValue.trim(), selectedFiles)
151
+ setInputValue("")
152
+ setSelectedFiles([])
153
+ inputRef.current?.focus()
154
+ }
155
+ }
156
+
157
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
158
+ if (e.key === "Enter" && !e.shiftKey) {
159
+ e.preventDefault()
160
+ handleSend()
161
+ }
162
+ }
163
+
164
+ const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
165
+ const files = Array.from(e.target.files || [])
166
+
167
+ // Validate file sizes and MIME types
168
+ const validFiles = files.filter(file => {
169
+ // Size validation
170
+ if (file.size > maxFileSize) {
171
+ console.warn(`File ${file.name} exceeds ${maxFileSize / 1024 / 1024}MB limit`)
172
+ return false
173
+ }
174
+
175
+ // Basic MIME type validation
176
+ const acceptedTypes = acceptedFileTypes.split(',').map(t => t.trim())
177
+ const matchesType = acceptedTypes.some(type => {
178
+ if (type.startsWith('.')) {
179
+ return file.name.toLowerCase().endsWith(type.toLowerCase())
180
+ }
181
+ if (type.endsWith('/*')) {
182
+ const category = type.split('/')[0]
183
+ return file.type.startsWith(category + '/')
184
+ }
185
+ return file.type === type
186
+ })
187
+
188
+ if (!matchesType) {
189
+ console.warn(`File ${file.name} type ${file.type} not accepted`)
190
+ return false
191
+ }
192
+
193
+ return true
194
+ })
195
+
196
+ if (validFiles.length < files.length) {
197
+ console.warn(`${files.length - validFiles.length} file(s) were rejected`)
198
+ }
199
+
200
+ setSelectedFiles(prev => [...prev, ...validFiles])
201
+ }
202
+
203
+ const handleRemoveFile = (index: number) => {
204
+ setSelectedFiles(prev => prev.filter((_, i) => i !== index))
205
+ }
206
+
207
+ const handleClear = () => {
208
+ if (onClear) {
209
+ onClear()
210
+ }
211
+ }
212
+
213
+ return (
214
+ <div
215
+ ref={ref}
216
+ className={cn(
217
+ "flex flex-col border rounded-lg bg-background overflow-hidden",
218
+ className
219
+ )}
220
+ style={{ maxHeight }}
221
+ {...props}
222
+ >
223
+ {/* Header with clear button */}
224
+ {onClear && messages.length > 0 && (
225
+ <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30">
226
+ <span className="text-sm text-muted-foreground">
227
+ {messages.length} message{messages.length !== 1 ? 's' : ''}
228
+ </span>
229
+ <Button
230
+ variant="ghost"
231
+ size="sm"
232
+ onClick={handleClear}
233
+ className="h-8 text-xs"
234
+ >
235
+ <Trash2 className="h-3 w-3 mr-1" />
236
+ Clear
237
+ </Button>
238
+ </div>
239
+ )}
240
+
241
+ {/* Messages area */}
242
+ <ScrollArea ref={scrollRef} className="flex-1 p-4">
243
+ <div className="space-y-4">
244
+ {messages.length === 0 ? (
245
+ <div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
246
+ Start a conversation...
247
+ </div>
248
+ ) : (
249
+ messages.map((message) => (
250
+ <div
251
+ key={message.id}
252
+ className={cn(
253
+ "flex gap-3",
254
+ message.role === "user" ? "justify-end" : "justify-start"
255
+ )}
256
+ >
257
+ {message.role !== "user" && (
258
+ <Avatar className="h-8 w-8 flex-shrink-0">
259
+ <AvatarImage src={message.avatar || assistantAvatarUrl} />
260
+ <AvatarFallback>{message.avatarFallback || assistantAvatarFallback}</AvatarFallback>
261
+ </Avatar>
262
+ )}
263
+
264
+ <div
265
+ className={cn(
266
+ "rounded-lg px-4 py-2 max-w-[80%]",
267
+ message.role === "user"
268
+ ? "bg-primary text-primary-foreground"
269
+ : "bg-muted"
270
+ )}
271
+ >
272
+ <MessageContent content={message.content} enableMarkdown={enableMarkdown} />
273
+ {showTimestamp && message.timestamp && (
274
+ <div className="text-xs opacity-70 mt-1">
275
+ {message.timestamp}
276
+ </div>
277
+ )}
278
+ {message.streaming && (
279
+ <div className="flex gap-1 mt-2">
280
+ <div className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
281
+ <div className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
282
+ <div className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
283
+ </div>
284
+ )}
285
+ </div>
286
+
287
+ {message.role === "user" && (
288
+ <Avatar className="h-8 w-8 flex-shrink-0">
289
+ <AvatarImage src={message.avatar || userAvatarUrl} />
290
+ <AvatarFallback>{message.avatarFallback || userAvatarFallback}</AvatarFallback>
291
+ </Avatar>
292
+ )}
293
+ </div>
294
+ ))
295
+ )}
296
+ </div>
297
+ </ScrollArea>
298
+
299
+ {/* Selected files preview */}
300
+ {selectedFiles.length > 0 && (
301
+ <div className="px-4 py-2 border-t bg-muted/30">
302
+ <div className="flex flex-wrap gap-2">
303
+ {selectedFiles.map((file, index) => (
304
+ <div
305
+ key={index}
306
+ className="flex items-center gap-2 bg-background border rounded px-2 py-1 text-xs"
307
+ >
308
+ <Paperclip className="h-3 w-3" />
309
+ <span className="max-w-[150px] truncate">{file.name}</span>
310
+ <Button
311
+ variant="ghost"
312
+ size="sm"
313
+ className="h-4 w-4 p-0"
314
+ onClick={() => handleRemoveFile(index)}
315
+ >
316
+ <X className="h-3 w-3" />
317
+ </Button>
318
+ </div>
319
+ ))}
320
+ </div>
321
+ </div>
322
+ )}
323
+
324
+ {/* Input area */}
325
+ <div className="flex items-center gap-2 p-4 border-t">
326
+ {enableFileUpload && (
327
+ <>
328
+ <input
329
+ ref={fileInputRef}
330
+ type="file"
331
+ multiple
332
+ accept={acceptedFileTypes}
333
+ onChange={handleFileSelect}
334
+ className="hidden"
335
+ />
336
+ <Button
337
+ variant="ghost"
338
+ size="icon"
339
+ onClick={() => fileInputRef.current?.click()}
340
+ disabled={disabled}
341
+ aria-label="Attach file"
342
+ >
343
+ <Paperclip className="h-4 w-4" />
344
+ </Button>
345
+ </>
346
+ )}
347
+
348
+ <Input
349
+ ref={inputRef}
350
+ value={inputValue}
351
+ onChange={(e) => setInputValue(e.target.value)}
352
+ onKeyDown={handleKeyDown}
353
+ placeholder={placeholder}
354
+ disabled={disabled}
355
+ className="flex-1"
356
+ />
357
+
358
+ <Button
359
+ onClick={handleSend}
360
+ disabled={disabled || (!inputValue.trim() && selectedFiles.length === 0)}
361
+ size="icon"
362
+ aria-label="Send message"
363
+ >
364
+ <Send className="h-4 w-4" />
365
+ </Button>
366
+ </div>
367
+ </div>
368
+ )
369
+ }
370
+ )
371
+
372
+ ChatbotEnhanced.displayName = "ChatbotEnhanced"
373
+
374
+ 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
+ });