acecoderz-chat-ui 1.0.4 → 1.0.7

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/README.md CHANGED
@@ -11,6 +11,10 @@ A framework-agnostic, fully customizable chat UI package that works with React,
11
11
  - 🎯 **TypeScript** - Full TypeScript support
12
12
  - 📦 **Modular** - Import only what you need
13
13
  - ✅ **Package Manager Compatible** - Works with both npm and pnpm
14
+ - 💾 **Message Persistence** - Automatic localStorage save/restore
15
+ - ✂️ **Message Actions** - Copy, delete, and edit messages
16
+ - ♿ **Accessible** - Full ARIA support and keyboard navigation
17
+ - 🎭 **Improved Loading** - Animated indicators with accessibility
14
18
 
15
19
  ## Installation
16
20
 
@@ -189,17 +193,26 @@ const container = document.getElementById('chat-container');
189
193
  const chatUI = createChatUI({
190
194
  config: {
191
195
  apiUrl: 'http://localhost:3000/api',
196
+ enablePersistence: true, // Optional: Save messages to localStorage
197
+ storageKey: 'my-chat-app', // Optional: Custom storage key
192
198
  },
193
199
  theme: {
194
200
  primaryColor: '#3b82f6',
195
201
  },
196
202
  container,
203
+ // Optional: Enable message actions (copy, delete, edit)
204
+ enableMessageActions: true,
205
+ showCopyButton: true,
206
+ showDeleteButton: true,
207
+ showEditButton: true,
197
208
  });
198
209
 
199
210
  // Later, to destroy:
200
211
  // chatUI.destroy();
201
212
  ```
202
213
 
214
+ **Note**: The vanilla adapter now handles form submission automatically - no workarounds needed! See [INTEGRATION_GUIDE.md](../../INTEGRATION_GUIDE.md) for complete integration examples.
215
+
203
216
  ### Core Only (Advanced)
204
217
 
205
218
  ```typescript
@@ -259,9 +272,92 @@ interface ChatConfig {
259
272
  onError?: (error: Error) => void; // Error callback
260
273
  enableWebSocket?: boolean; // Enable WebSocket
261
274
  websocketUrl?: string; // WebSocket URL
275
+ enablePersistence?: boolean; // Enable localStorage persistence (default: false)
276
+ storageKey?: string; // Storage key prefix (default: 'chat-ui')
262
277
  }
263
278
  ```
264
279
 
280
+ ## New Features
281
+
282
+ ### Message Persistence
283
+
284
+ Automatically save and restore conversations across page reloads:
285
+
286
+ ```javascript
287
+ const chatUI = createChatUI({
288
+ config: {
289
+ apiUrl: '/api/chat',
290
+ enablePersistence: true, // Enable localStorage
291
+ storageKey: 'my-app', // Optional: custom key
292
+ },
293
+ container: document.getElementById('chat'),
294
+ });
295
+ ```
296
+
297
+ ### Message Actions
298
+
299
+ Enable copy, delete, and edit buttons on messages:
300
+
301
+ ```javascript
302
+ const chatUI = createChatUI({
303
+ config: { apiUrl: '/api/chat' },
304
+ container: document.getElementById('chat'),
305
+ enableMessageActions: true,
306
+ showCopyButton: true,
307
+ showDeleteButton: true,
308
+ showEditButton: true,
309
+ onMessageCopy: (message) => console.log('Copied:', message.content),
310
+ onMessageDelete: (message) => console.log('Deleted:', message.id),
311
+ onMessageEdit: (message, newContent) => console.log('Edited:', newContent),
312
+ });
313
+ ```
314
+
315
+ ### Initial Greeting
316
+
317
+ Display a welcome message when the chat first opens:
318
+
319
+ **Vanilla JS:**
320
+ ```javascript
321
+ const chatUI = createChatUI({
322
+ config: { apiUrl: '/api/chat' },
323
+ container: document.getElementById('chat'),
324
+ initialGreeting: "Hello! 👋 I'm your assistant. How can I help you today?",
325
+ });
326
+ ```
327
+
328
+ **React:**
329
+ ```tsx
330
+ <ChatUI
331
+ config={config}
332
+ initialGreeting="Hello! 👋 I'm your assistant. How can I help you today?"
333
+ />
334
+ ```
335
+
336
+ The greeting will:
337
+ - Appear automatically when the chat opens
338
+ - Only show once per conversation
339
+ - Reappear after clearing the chat
340
+ - Not trigger an API call (it's a local message)
341
+
342
+ ### Improved Loading States
343
+
344
+ - Animated loading indicators
345
+ - Better accessibility with ARIA live regions
346
+ - Smooth transitions
347
+
348
+ See [NEW_FEATURES.md](./NEW_FEATURES.md) for complete feature documentation.
349
+
350
+ ## Integration Guide
351
+
352
+ For complete integration instructions, examples, and troubleshooting, see the [Integration Guide](../../INTEGRATION_GUIDE.md).
353
+
354
+ The integration guide includes:
355
+ - Complete vanilla JS examples with modal
356
+ - React integration examples
357
+ - Backend configuration
358
+ - Feature documentation
359
+ - Troubleshooting guide
360
+
265
361
  ## Customization
266
362
 
267
363
  ### Custom Message Renderer (React)
@@ -1 +1 @@
1
- {"version":3,"file":"ChatUI.d.ts","sourceRoot":"","sources":["../../../adapters/react/ChatUI.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAuD3E,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IACtC,eAAe,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAE7B,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,KAAK,CAAC,SAAS,CAAC;IAC9D,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE;QAC5B,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QAClC,MAAM,EAAE,MAAM,IAAI,CAAC;QACnB,QAAQ,EAAE,OAAO,CAAC;KACnB,KAAK,KAAK,CAAC,SAAS,CAAC;IAEtB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC;AAED,eAAO,MAAM,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC,WAAW,CAgOxC,CAAC"}
1
+ {"version":3,"file":"ChatUI.d.ts","sourceRoot":"","sources":["../../../adapters/react/ChatUI.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAuD3E,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IACtC,eAAe,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAE7B,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,KAAK,CAAC,SAAS,CAAC;IAC9D,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE;QAC5B,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QAClC,MAAM,EAAE,MAAM,IAAI,CAAC;QACnB,QAAQ,EAAE,OAAO,CAAC;KACnB,KAAK,KAAK,CAAC,SAAS,CAAC;IAEtB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC;AAED,eAAO,MAAM,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC,WAAW,CAsQxC,CAAC"}
@@ -39,9 +39,36 @@ const MarkdownContent = ({ content }) => {
39
39
  };
40
40
  export const ChatUI = ({ config, theme = {}, className = '', placeholder = 'Type your message...', showTimestamp = true, showAvatar = true, userAvatar, assistantAvatar, maxHeight = '600px', disabled = false, initialGreeting, messagesContainerClassName = '', messageClassName = '', inputContainerClassName = '', inputClassName = '', buttonClassName = '', errorClassName = '', emptyStateClassName = '', customMessageRenderer, customInputRenderer, dataAttributes = {}, }) => {
41
41
  const { messages, input, isLoading, error, sendMessage, setInput, addMessage, } = useChat(config);
42
- // Add initial greeting message when component mounts
42
+ // Track if we've already added the initial greeting
43
+ const greetingAddedRef = React.useRef(false);
44
+ const previousInitialGreetingRef = React.useRef(initialGreeting);
45
+ const messagesInitializedRef = React.useRef(false);
46
+ // Reset greeting flag if initialGreeting prop changes
47
+ if (previousInitialGreetingRef.current !== initialGreeting) {
48
+ greetingAddedRef.current = false;
49
+ previousInitialGreetingRef.current = initialGreeting;
50
+ }
51
+ // Mark when messages are initialized (loaded from storage or empty)
52
+ React.useEffect(() => {
53
+ if (messages.length >= 0) {
54
+ messagesInitializedRef.current = true;
55
+ }
56
+ }, [messages.length]);
57
+ // Add initial greeting message once when component mounts and messages are ready
43
58
  React.useEffect(() => {
44
- if (initialGreeting && messages.length === 0) {
59
+ // Skip if no greeting or already added
60
+ if (!initialGreeting || greetingAddedRef.current) {
61
+ return;
62
+ }
63
+ // Wait for messages to be initialized
64
+ if (!messagesInitializedRef.current) {
65
+ return;
66
+ }
67
+ // Check if there's already a greeting message with the same content
68
+ const hasGreeting = messages.some(msg => msg.role === 'assistant' && msg.content === initialGreeting);
69
+ // Add greeting if it doesn't already exist
70
+ if (!hasGreeting) {
71
+ greetingAddedRef.current = true;
45
72
  const greetingMessage = {
46
73
  id: `greeting-${Date.now()}`,
47
74
  content: initialGreeting,
@@ -50,7 +77,11 @@ export const ChatUI = ({ config, theme = {}, className = '', placeholder = 'Type
50
77
  };
51
78
  addMessage(greetingMessage);
52
79
  }
53
- }, [initialGreeting, messages.length, addMessage]);
80
+ else {
81
+ // If greeting already exists, mark as added
82
+ greetingAddedRef.current = true;
83
+ }
84
+ }, [initialGreeting, messages, addMessage]);
54
85
  const handleSubmit = (e) => {
55
86
  e.preventDefault();
56
87
  if (input.trim() && !isLoading && !disabled) {
@@ -18,6 +18,14 @@ export interface ChatUIOptions {
18
18
  loadingMessage?: string;
19
19
  customMessageRenderer?: (message: Message) => string;
20
20
  onInit?: (instance: ChatUIInstance) => void;
21
+ initialGreeting?: string;
22
+ enableMessageActions?: boolean;
23
+ showCopyButton?: boolean;
24
+ showDeleteButton?: boolean;
25
+ showEditButton?: boolean;
26
+ onMessageCopy?: (message: Message) => void;
27
+ onMessageDelete?: (message: Message) => void;
28
+ onMessageEdit?: (message: Message, newContent: string) => void;
21
29
  }
22
30
  export interface ChatUIInstance {
23
31
  engine: ChatEngine;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../adapters/vanilla/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AACvD,OAAO,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAE3E,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,SAAS,EAAE,WAAW,GAAG,MAAM,CAAC;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC;IACrD,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,cAAc,KAAK,IAAI,CAAC;CAC7C;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,YAAY,EAAE,MAAM,WAAW,CAAC;CACjC;AA2DD,wBAAgB,YAAY,CAAC,OAAO,EAAE,aAAa,GAAG,cAAc,CAyUnE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../adapters/vanilla/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AACvD,OAAO,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAE3E,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,SAAS,EAAE,WAAW,GAAG,MAAM,CAAC;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC;IACrD,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,cAAc,KAAK,IAAI,CAAC;IAC5C,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAC3C,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAC7C,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;CAChE;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,YAAY,EAAE,MAAM,WAAW,CAAC;CACjC;AA2DD,wBAAgB,YAAY,CAAC,OAAO,EAAE,aAAa,GAAG,cAAc,CAsoBnE"}
@@ -47,7 +47,7 @@ function getContainerElement(container) {
47
47
  return container;
48
48
  }
49
49
  export function createChatUI(options) {
50
- const { config, theme = {}, container, placeholder = 'Type your message...', showTimestamp = true, showAvatar = true, userAvatar, assistantAvatar, maxHeight = '600px', disabled = false, autoScroll = true, enableMarkdown = true, showClearButton = true, emptyStateMessage = 'Start a conversation...', loadingMessage = 'Thinking...', customMessageRenderer, onInit, } = options;
50
+ const { config, theme = {}, container, placeholder = 'Type your message...', showTimestamp = true, showAvatar = true, userAvatar, assistantAvatar, maxHeight = '600px', disabled = false, autoScroll = true, enableMarkdown = true, showClearButton = true, emptyStateMessage = 'Start a conversation...', loadingMessage = 'Thinking...', customMessageRenderer, onInit, initialGreeting, enableMessageActions = false, showCopyButton = true, showDeleteButton = true, showEditButton = true, onMessageCopy, onMessageDelete, onMessageEdit, } = options;
51
51
  // Get container element
52
52
  const containerElement = getContainerElement(container);
53
53
  const engine = new ChatEngine(config);
@@ -58,6 +58,12 @@ export function createChatUI(options) {
58
58
  let sendButtonElement = null;
59
59
  let clearButtonElement = null;
60
60
  let isUpdatingInputProgrammatically = false; // Flag to prevent input event loop
61
+ // Track if we've already added the initial greeting
62
+ let greetingAdded = false;
63
+ let previousInitialGreeting = initialGreeting;
64
+ let messagesInitialized = false;
65
+ // Store event handlers for proper cleanup
66
+ const eventHandlers = [];
61
67
  // Apply CSS variables for theming
62
68
  const applyTheme = () => {
63
69
  const root = containerElement;
@@ -107,12 +113,59 @@ export function createChatUI(options) {
107
113
  const messageContent = enableMarkdown
108
114
  ? `<div class="chat-markdown">${formatText(message.content, enableMarkdown)}</div>`
109
115
  : `<div class="chat-message-text">${formatText(message.content, false)}</div>`;
116
+ const messageId = escapeHtml(message.id);
117
+ const messageActions = enableMessageActions ? `
118
+ <div class="chat-message-actions" role="group" aria-label="Message actions">
119
+ ${showCopyButton ? `
120
+ <button
121
+ class="chat-message-action chat-message-copy"
122
+ type="button"
123
+ aria-label="Copy message"
124
+ data-message-id="${messageId}"
125
+ data-action="copy"
126
+ title="Copy message"
127
+ >
128
+ <span aria-hidden="true">📋</span>
129
+ </button>
130
+ ` : ''}
131
+ ${showDeleteButton ? `
132
+ <button
133
+ class="chat-message-action chat-message-delete"
134
+ type="button"
135
+ aria-label="Delete message"
136
+ data-message-id="${messageId}"
137
+ data-action="delete"
138
+ title="Delete message"
139
+ >
140
+ <span aria-hidden="true">🗑️</span>
141
+ </button>
142
+ ` : ''}
143
+ ${showEditButton && isUser && !isSystem ? `
144
+ <button
145
+ class="chat-message-action chat-message-edit"
146
+ type="button"
147
+ aria-label="Edit message"
148
+ data-message-id="${messageId}"
149
+ data-action="edit"
150
+ title="Edit message"
151
+ >
152
+ <span aria-hidden="true">✏️</span>
153
+ </button>
154
+ ` : ''}
155
+ </div>
156
+ ` : '';
110
157
  return `
111
- <div class="chat-message ${isUser ? 'chat-message-user' : isSystem ? 'chat-message-system' : 'chat-message-assistant'}" data-message-id="${escapeHtml(message.id)}">
158
+ <div
159
+ class="chat-message ${isUser ? 'chat-message-user' : isSystem ? 'chat-message-system' : 'chat-message-assistant'}"
160
+ data-message-id="${messageId}"
161
+ role="article"
162
+ aria-label="${isUser ? 'Your message' : 'Assistant message'}"
163
+ >
112
164
  ${!isUser && !isSystem ? avatarHtml : ''}
113
165
  <div class="chat-message-content ${isUser ? 'chat-message-content-user' : isSystem ? 'chat-message-content-system' : 'chat-message-content-assistant'}">
114
166
  ${messageContent}
115
- ${showTimestamp ? `<div class="chat-message-timestamp">${formatTimestamp(message.timestamp)}</div>` : ''}
167
+ ${showTimestamp ? `<div class="chat-message-timestamp" aria-label="Sent at ${formatTimestamp(message.timestamp)}">${formatTimestamp(message.timestamp)}</div>` : ''}
168
+ ${messageActions}
116
169
  </div>
117
170
  ${isUser ? avatarHtml : ''}
118
171
  </div>
@@ -129,15 +182,24 @@ export function createChatUI(options) {
129
182
  applyTheme();
130
183
  containerElement.className = `chat-container ${containerElement.className || ''}`.trim();
131
184
  containerElement.setAttribute('data-chat-ui', 'true');
185
+ containerElement.setAttribute('role', 'log');
186
+ containerElement.setAttribute('aria-label', 'Chat conversation');
187
+ containerElement.setAttribute('aria-live', 'polite');
188
+ containerElement.setAttribute('aria-atomic', 'false');
132
189
  containerElement.innerHTML = `
133
- <div class="chat-messages-container">
190
+ <div class="chat-messages-container" role="log" aria-live="polite" aria-label="Chat messages">
134
191
  ${messages.length === 0 ? `<div class="chat-empty-state">${escapeHtml(emptyStateMessage)}</div>` : ''}
135
192
  ${messages.map(renderMessage).join('')}
136
193
  ${isLoading ? `
137
- <div class="chat-message chat-message-assistant chat-message-loading">
194
+ <div class="chat-message chat-message-assistant chat-message-loading" role="status" aria-live="polite" aria-label="Loading response">
138
195
  ${renderAvatar('assistant')}
139
196
  <div class="chat-message-content chat-message-content-assistant">
140
- <div class="chat-message-text">${escapeHtml(loadingMessage)}</div>
197
+ <div class="chat-message-text">
198
+ <span class="chat-loading-dots">
199
+ <span></span><span></span><span></span>
200
+ </span>
201
+ <span class="chat-loading-text">${escapeHtml(loadingMessage)}</span>
202
+ </div>
141
203
  </div>
142
204
  </div>
143
205
  ` : ''}
@@ -152,7 +214,15 @@ export function createChatUI(options) {
152
214
  </div>
153
215
  ` : ''}
154
216
  <div class="chat-input-container">
155
- <form class="chat-input-form">
217
+ <form
218
+ class="chat-input-form"
219
+ action="javascript:void(0)"
220
+ method="post"
221
+ novalidate
222
+ data-chat-form="true"
223
+ role="form"
224
+ aria-label="Chat input form"
225
+ >
156
226
  <input
157
227
  type="text"
158
228
  class="chat-input"
@@ -189,58 +259,249 @@ export function createChatUI(options) {
189
259
  }
190
260
  });
191
261
  }
192
- // Attach event listeners
262
+ // Attach event listeners with proper cleanup tracking
193
263
  if (formElement) {
194
- formElement.addEventListener('submit', (e) => {
195
- e.preventDefault();
264
+ // Use capture phase to ensure preventDefault runs before any other handlers
265
+ // This prevents page reloads even if parent elements have submit handlers
266
+ const handleSubmit = (e) => {
267
+ const submitEvent = e;
268
+ submitEvent.preventDefault();
269
+ submitEvent.stopPropagation();
196
270
  if (!disabled && !isLoading && input.trim()) {
197
271
  engine.sendMessage();
198
272
  }
199
- });
273
+ };
274
+ // Add handler in capture phase (runs first) to prevent any page reloads
275
+ formElement.addEventListener('submit', handleSubmit, true);
276
+ eventHandlers.push({ element: formElement, event: 'submit', handler: handleSubmit, options: true });
277
+ // Also add in bubble phase as fallback
278
+ formElement.addEventListener('submit', handleSubmit, false);
279
+ eventHandlers.push({ element: formElement, event: 'submit', handler: handleSubmit, options: false });
200
280
  }
201
281
  if (inputElement) {
202
282
  // Handle input changes
203
- inputElement.addEventListener('input', (e) => {
283
+ const handleInput = (e) => {
204
284
  // Skip if we're programmatically updating the input
205
285
  if (isUpdatingInputProgrammatically) {
206
286
  return;
207
287
  }
208
288
  const target = e.target;
209
289
  engine.setInput(target.value);
210
- });
290
+ };
291
+ inputElement.addEventListener('input', handleInput);
292
+ eventHandlers.push({ element: inputElement, event: 'input', handler: handleInput });
211
293
  // Keyboard shortcuts: Enter to send, Shift+Enter for newline
212
- inputElement.addEventListener('keydown', (e) => {
213
- if (e.key === 'Enter' && !e.shiftKey) {
214
- e.preventDefault();
294
+ const handleKeydown = (e) => {
295
+ const keyboardEvent = e;
296
+ if (keyboardEvent.key === 'Enter' && !keyboardEvent.shiftKey) {
297
+ keyboardEvent.preventDefault();
215
298
  if (!disabled && !isLoading && input.trim()) {
216
299
  engine.sendMessage();
217
300
  }
218
301
  }
219
302
  // Shift+Enter allows default behavior (newline if textarea, ignored if input)
220
- });
303
+ };
304
+ inputElement.addEventListener('keydown', handleKeydown);
305
+ eventHandlers.push({ element: inputElement, event: 'keydown', handler: handleKeydown });
221
306
  }
222
307
  // Clear button handler
223
308
  if (clearButtonElement) {
224
- clearButtonElement.addEventListener('click', () => {
309
+ const handleClearClick = () => {
225
310
  if (confirm('Are you sure you want to clear the conversation?')) {
226
311
  engine.clearMessages();
227
312
  }
228
- });
313
+ };
314
+ clearButtonElement.addEventListener('click', handleClearClick);
315
+ eventHandlers.push({ element: clearButtonElement, event: 'click', handler: handleClearClick });
229
316
  }
230
317
  // Retry button handler
231
318
  if (retryButton) {
232
- retryButton.addEventListener('click', () => {
319
+ const handleRetryClick = () => {
233
320
  engine.retryLastMessage();
234
- });
321
+ };
322
+ retryButton.addEventListener('click', handleRetryClick);
323
+ eventHandlers.push({ element: retryButton, event: 'click', handler: handleRetryClick });
324
+ }
325
+ // Message actions handlers (using event delegation)
326
+ if (enableMessageActions) {
327
+ const handleMessageAction = async (e) => {
328
+ const target = e.target;
329
+ const actionButton = target.closest('[data-action]');
330
+ if (!actionButton)
331
+ return;
332
+ e.preventDefault();
333
+ e.stopPropagation();
334
+ const action = actionButton.getAttribute('data-action');
335
+ const messageId = actionButton.getAttribute('data-message-id');
336
+ if (!messageId || !action)
337
+ return;
338
+ const message = engine.getMessages().find(m => m.id === messageId);
339
+ if (!message)
340
+ return;
341
+ switch (action) {
342
+ case 'copy':
343
+ await copyMessageToClipboard(message);
344
+ break;
345
+ case 'delete':
346
+ deleteMessage(message);
347
+ break;
348
+ case 'edit':
349
+ editMessage(message);
350
+ break;
351
+ }
352
+ };
353
+ containerElement.addEventListener('click', handleMessageAction);
354
+ eventHandlers.push({ element: containerElement, event: 'click', handler: handleMessageAction });
235
355
  }
356
+ // Toast notification helper
357
+ const showToast = (message, type = 'info') => {
358
+ const toast = document.createElement('div');
359
+ toast.className = `chat-toast chat-toast-${type}`;
360
+ toast.setAttribute('role', 'alert');
361
+ toast.setAttribute('aria-live', 'polite');
362
+ toast.textContent = message;
363
+ containerElement.appendChild(toast);
364
+ // Animate in
365
+ requestAnimationFrame(() => {
366
+ toast.classList.add('chat-toast-show');
367
+ });
368
+ // Remove after 3 seconds
369
+ setTimeout(() => {
370
+ toast.classList.remove('chat-toast-show');
371
+ setTimeout(() => toast.remove(), 300);
372
+ }, 3000);
373
+ };
374
+ const copyMessageToClipboard = async (message) => {
375
+ try {
376
+ await navigator.clipboard.writeText(message.content);
377
+ showToast('Message copied to clipboard', 'success');
378
+ onMessageCopy?.(message);
379
+ }
380
+ catch (err) {
381
+ console.error('Failed to copy:', err);
382
+ showToast('Failed to copy message', 'error');
383
+ }
384
+ };
385
+ const deleteMessage = (message) => {
386
+ if (confirm('Are you sure you want to delete this message?')) {
387
+ engine.deleteMessage(message.id);
388
+ showToast('Message deleted', 'info');
389
+ onMessageDelete?.(message);
390
+ }
391
+ };
392
+ const editMessage = (message) => {
393
+ const messageElement = containerElement.querySelector(`[data-message-id="${message.id}"]`);
394
+ if (!messageElement)
395
+ return;
396
+ const contentElement = messageElement.querySelector('.chat-message-text, .chat-markdown');
397
+ if (!contentElement)
398
+ return;
399
+ const originalContent = message.content;
400
+ const input = document.createElement('input');
401
+ input.type = 'text';
402
+ input.value = originalContent;
403
+ input.className = 'chat-message-edit-input';
404
+ input.setAttribute('aria-label', 'Edit message');
405
+ const buttonContainer = document.createElement('div');
406
+ buttonContainer.className = 'chat-message-edit-buttons';
407
+ const saveButton = document.createElement('button');
408
+ saveButton.textContent = 'Save';
409
+ saveButton.className = 'chat-message-edit-save';
410
+ saveButton.type = 'button';
411
+ saveButton.setAttribute('aria-label', 'Save changes');
412
+ const cancelButton = document.createElement('button');
413
+ cancelButton.textContent = 'Cancel';
414
+ cancelButton.className = 'chat-message-edit-cancel';
415
+ cancelButton.type = 'button';
416
+ cancelButton.setAttribute('aria-label', 'Cancel editing');
417
+ buttonContainer.appendChild(saveButton);
418
+ buttonContainer.appendChild(cancelButton);
419
+ const originalHTML = contentElement.innerHTML;
420
+ contentElement.innerHTML = '';
421
+ contentElement.appendChild(input);
422
+ contentElement.appendChild(buttonContainer);
423
+ input.focus();
424
+ input.select();
425
+ const handleSave = () => {
426
+ const newContent = input.value.trim();
427
+ if (newContent && newContent !== originalContent) {
428
+ try {
429
+ engine.editMessage(message.id, newContent);
430
+ showToast('Message updated', 'success');
431
+ onMessageEdit?.(message, newContent);
432
+ }
433
+ catch (error) {
434
+ showToast(error instanceof Error ? error.message : 'Failed to edit message', 'error');
435
+ contentElement.innerHTML = originalHTML;
436
+ }
437
+ }
438
+ else {
439
+ contentElement.innerHTML = originalHTML;
440
+ }
441
+ };
442
+ const handleCancel = () => {
443
+ contentElement.innerHTML = originalHTML;
444
+ };
445
+ saveButton.addEventListener('click', handleSave);
446
+ cancelButton.addEventListener('click', handleCancel);
447
+ input.addEventListener('keydown', (e) => {
448
+ if (e.key === 'Enter' && !e.shiftKey) {
449
+ e.preventDefault();
450
+ handleSave();
451
+ }
452
+ if (e.key === 'Escape') {
453
+ e.preventDefault();
454
+ handleCancel();
455
+ }
456
+ });
457
+ };
236
458
  // Auto-scroll to bottom after render
237
459
  // Use setTimeout to ensure DOM is updated
238
460
  setTimeout(() => {
239
461
  scrollToBottom();
240
462
  }, 0);
241
463
  };
464
+ // Helper function to add initial greeting if needed
465
+ const addInitialGreetingIfNeeded = () => {
466
+ if (!initialGreeting || greetingAdded) {
467
+ return;
468
+ }
469
+ // Reset greeting flag if initialGreeting prop changed
470
+ if (previousInitialGreeting !== initialGreeting) {
471
+ greetingAdded = false;
472
+ previousInitialGreeting = initialGreeting;
473
+ }
474
+ // Check if there's already a greeting message with the same content
475
+ const messages = engine.getMessages();
476
+ const hasGreeting = messages.some(msg => msg.role === 'assistant' && msg.content === initialGreeting);
477
+ // Add greeting if it doesn't already exist
478
+ if (!hasGreeting) {
479
+ greetingAdded = true;
480
+ const greetingMessage = {
481
+ id: `greeting-${Date.now()}`,
482
+ content: initialGreeting,
483
+ role: 'assistant',
484
+ timestamp: new Date(),
485
+ };
486
+ engine.addMessage(greetingMessage);
487
+ }
488
+ else {
489
+ // If greeting already exists, mark as added
490
+ greetingAdded = true;
491
+ }
492
+ };
242
493
  // Subscribe to engine events
243
494
  engine.on('messagesChange', () => {
495
+ // Mark messages as initialized once we get the first messagesChange event
496
+ if (!messagesInitialized) {
497
+ messagesInitialized = true;
498
+ // Try to add greeting after messages are initialized
499
+ addInitialGreetingIfNeeded();
500
+ // If greeting was added, it will trigger another messagesChange, so return early
501
+ if (greetingAdded) {
502
+ return;
503
+ }
504
+ }
244
505
  render();
245
506
  // Scroll after messages change
246
507
  setTimeout(() => {
@@ -297,10 +558,23 @@ export function createChatUI(options) {
297
558
  engine.on('error', render);
298
559
  // Initial render
299
560
  render();
561
+ // After initial render, check if we need to add greeting
562
+ // Use setTimeout to ensure messages are loaded from storage first
563
+ setTimeout(() => {
564
+ if (!messagesInitialized) {
565
+ messagesInitialized = true;
566
+ addInitialGreetingIfNeeded();
567
+ }
568
+ }, 100);
300
569
  const instance = {
301
570
  engine,
302
571
  render,
303
572
  destroy: () => {
573
+ // Remove all event listeners
574
+ eventHandlers.forEach(({ element, event, handler, options }) => {
575
+ element.removeEventListener(event, handler, options);
576
+ });
577
+ eventHandlers.length = 0;
304
578
  engine.destroy();
305
579
  containerElement.innerHTML = '';
306
580
  // Clear references
@@ -311,6 +585,8 @@ export function createChatUI(options) {
311
585
  clearButtonElement = null;
312
586
  },
313
587
  clear: () => {
588
+ // Reset greeting flag so it can be added again after clearing
589
+ greetingAdded = false;
314
590
  engine.clearMessages();
315
591
  },
316
592
  scrollToBottom,