acecoderz-chat-ui 1.0.3 → 1.0.5

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
@@ -1,4 +1,4 @@
1
- # @chatbot/chat-ui
1
+ # acecoderz-chat-ui
2
2
 
3
3
  A framework-agnostic, fully customizable chat UI package that works with React, Solid, Vanilla JS, and any other framework.
4
4
 
@@ -10,19 +10,30 @@ A framework-agnostic, fully customizable chat UI package that works with React,
10
10
  - 💬 **Real-time Support** - WebSocket and HTTP API support
11
11
  - 🎯 **TypeScript** - Full TypeScript support
12
12
  - 📦 **Modular** - Import only what you need
13
+ - ✅ **Package Manager Compatible** - Works with both npm and pnpm
13
14
 
14
15
  ## Installation
15
16
 
16
- ### npm/pnpm/yarn
17
+ ### npm
17
18
 
18
19
  ```bash
19
- pnpm add @chatbot/chat-ui
20
- # or
21
- npm install @chatbot/chat-ui
22
- # or
23
- yarn add @chatbot/chat-ui
20
+ npm install acecoderz-chat-ui
21
+ ```
22
+
23
+ ### pnpm
24
+
25
+ ```bash
26
+ pnpm add acecoderz-chat-ui
24
27
  ```
25
28
 
29
+ ### yarn
30
+
31
+ ```bash
32
+ yarn add acecoderz-chat-ui
33
+ ```
34
+
35
+ **Note**: This package is compatible with npm, pnpm, and yarn. Use the package manager your project uses.
36
+
26
37
  ### Browser / CDN (Script Tag)
27
38
 
28
39
  For browser usage without a build step, see [CDN.md](./CDN.md) for detailed documentation.
@@ -56,6 +67,8 @@ For browser usage without a build step, see [CDN.md](./CDN.md) for detailed docu
56
67
  **Building for CDN:**
57
68
  ```bash
58
69
  # Build browser bundle
70
+ npm run build:browser
71
+ # or
59
72
  pnpm build:browser
60
73
 
61
74
  # Output files will be in dist/browser/:
@@ -83,13 +96,13 @@ After building, you can embed the chatbot in an iframe:
83
96
  Import the CSS file in your project:
84
97
 
85
98
  ```css
86
- @import '@chatbot/chat-ui/styles';
99
+ @import 'acecoderz-chat-ui/styles';
87
100
  ```
88
101
 
89
102
  Or in your JavaScript/TypeScript:
90
103
 
91
104
  ```javascript
92
- import '@chatbot/chat-ui/styles';
105
+ import 'acecoderz-chat-ui/styles';
93
106
  ```
94
107
 
95
108
  ## Usage
@@ -97,8 +110,8 @@ import '@chatbot/chat-ui/styles';
97
110
  ### React
98
111
 
99
112
  ```tsx
100
- import { ChatUI } from '@chatbot/chat-ui/react';
101
- import '@chatbot/chat-ui/styles';
113
+ import { ChatUI } from 'acecoderz-chat-ui/react';
114
+ import 'acecoderz-chat-ui/styles';
102
115
 
103
116
  function App() {
104
117
  return (
@@ -118,7 +131,7 @@ function App() {
118
131
  Or use the hook directly:
119
132
 
120
133
  ```tsx
121
- import { useChat } from '@chatbot/chat-ui/react';
134
+ import { useChat } from 'acecoderz-chat-ui/react';
122
135
 
123
136
  function CustomChat() {
124
137
  const { messages, input, sendMessage, setInput } = useChat({
@@ -140,8 +153,8 @@ function CustomChat() {
140
153
  ### Solid
141
154
 
142
155
  ```tsx
143
- import { createChat } from '@chatbot/chat-ui/solid';
144
- import '@chatbot/chat-ui/styles';
156
+ import { createChat } from 'acecoderz-chat-ui/solid';
157
+ import 'acecoderz-chat-ui/styles';
145
158
 
146
159
  function App() {
147
160
  const chat = createChat({
@@ -168,8 +181,8 @@ function App() {
168
181
  ### Vanilla JS
169
182
 
170
183
  ```javascript
171
- import { createChatUI } from '@chatbot/chat-ui/vanilla';
172
- import '@chatbot/chat-ui/styles';
184
+ import { createChatUI } from 'acecoderz-chat-ui/vanilla';
185
+ import 'acecoderz-chat-ui/styles';
173
186
 
174
187
  const container = document.getElementById('chat-container');
175
188
 
@@ -190,7 +203,7 @@ const chatUI = createChatUI({
190
203
  ### Core Only (Advanced)
191
204
 
192
205
  ```typescript
193
- import { ChatEngine } from '@chatbot/chat-ui/core';
206
+ import { ChatEngine } from 'acecoderz-chat-ui/core';
194
207
 
195
208
  const engine = new ChatEngine({
196
209
  apiUrl: 'http://localhost:3000/api',
@@ -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,CAsPxC,CAAC"}
@@ -39,9 +39,24 @@ 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
+ // Track if we've already added the initial greeting for the current initialGreeting value
43
+ const greetingAddedRef = React.useRef(null);
42
44
  // Add initial greeting message when component mounts
43
45
  React.useEffect(() => {
44
- if (initialGreeting && messages.length === 0) {
46
+ if (!initialGreeting) {
47
+ greetingAddedRef.current = null;
48
+ return;
49
+ }
50
+ // Check if there's already a greeting message with the same content
51
+ const hasGreeting = messages.some(msg => msg.role === 'assistant' && msg.content === initialGreeting);
52
+ // Only add greeting if:
53
+ // 1. We haven't already added this specific greeting (tracked by ref)
54
+ // 2. There's no existing greeting message with the same content
55
+ // 3. Messages array is empty
56
+ if (greetingAddedRef.current !== initialGreeting &&
57
+ !hasGreeting &&
58
+ messages.length === 0) {
59
+ greetingAddedRef.current = initialGreeting;
45
60
  const greetingMessage = {
46
61
  id: `greeting-${Date.now()}`,
47
62
  content: initialGreeting,
@@ -50,7 +65,7 @@ export const ChatUI = ({ config, theme = {}, className = '', placeholder = 'Type
50
65
  };
51
66
  addMessage(greetingMessage);
52
67
  }
53
- }, [initialGreeting, messages.length, addMessage]);
68
+ }, [initialGreeting, messages, addMessage]);
54
69
  const handleSubmit = (e) => {
55
70
  e.preventDefault();
56
71
  if (input.trim() && !isLoading && !disabled) {
@@ -18,6 +18,13 @@ export interface ChatUIOptions {
18
18
  loadingMessage?: string;
19
19
  customMessageRenderer?: (message: Message) => string;
20
20
  onInit?: (instance: ChatUIInstance) => void;
21
+ enableMessageActions?: boolean;
22
+ showCopyButton?: boolean;
23
+ showDeleteButton?: boolean;
24
+ showEditButton?: boolean;
25
+ onMessageCopy?: (message: Message) => void;
26
+ onMessageDelete?: (message: Message) => void;
27
+ onMessageEdit?: (message: Message, newContent: string) => void;
21
28
  }
22
29
  export interface ChatUIInstance {
23
30
  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;IAE5C,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,CAwkBnE"}
@@ -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, 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,8 @@ 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
+ // Store event handlers for proper cleanup
62
+ const eventHandlers = [];
61
63
  // Apply CSS variables for theming
62
64
  const applyTheme = () => {
63
65
  const root = containerElement;
@@ -107,12 +109,59 @@ export function createChatUI(options) {
107
109
  const messageContent = enableMarkdown
108
110
  ? `<div class="chat-markdown">${formatText(message.content, enableMarkdown)}</div>`
109
111
  : `<div class="chat-message-text">${formatText(message.content, false)}</div>`;
112
+ const messageId = escapeHtml(message.id);
113
+ const messageActions = enableMessageActions ? `
114
+ <div class="chat-message-actions" role="group" aria-label="Message actions">
115
+ ${showCopyButton ? `
116
+ <button
117
+ class="chat-message-action chat-message-copy"
118
+ type="button"
119
+ aria-label="Copy message"
120
+ data-message-id="${messageId}"
121
+ data-action="copy"
122
+ title="Copy message"
123
+ >
124
+ <span aria-hidden="true">📋</span>
125
+ </button>
126
+ ` : ''}
127
+ ${showDeleteButton ? `
128
+ <button
129
+ class="chat-message-action chat-message-delete"
130
+ type="button"
131
+ aria-label="Delete message"
132
+ data-message-id="${messageId}"
133
+ data-action="delete"
134
+ title="Delete message"
135
+ >
136
+ <span aria-hidden="true">🗑️</span>
137
+ </button>
138
+ ` : ''}
139
+ ${showEditButton && isUser && !isSystem ? `
140
+ <button
141
+ class="chat-message-action chat-message-edit"
142
+ type="button"
143
+ aria-label="Edit message"
144
+ data-message-id="${messageId}"
145
+ data-action="edit"
146
+ title="Edit message"
147
+ >
148
+ <span aria-hidden="true">✏️</span>
149
+ </button>
150
+ ` : ''}
151
+ </div>
152
+ ` : '';
110
153
  return `
111
- <div class="chat-message ${isUser ? 'chat-message-user' : isSystem ? 'chat-message-system' : 'chat-message-assistant'}" data-message-id="${escapeHtml(message.id)}">
154
+ <div
155
+ class="chat-message ${isUser ? 'chat-message-user' : isSystem ? 'chat-message-system' : 'chat-message-assistant'}"
156
+ data-message-id="${messageId}"
157
+ role="article"
158
+ aria-label="${isUser ? 'Your message' : 'Assistant message'}"
159
+ >
112
160
  ${!isUser && !isSystem ? avatarHtml : ''}
113
161
  <div class="chat-message-content ${isUser ? 'chat-message-content-user' : isSystem ? 'chat-message-content-system' : 'chat-message-content-assistant'}">
114
162
  ${messageContent}
115
- ${showTimestamp ? `<div class="chat-message-timestamp">${formatTimestamp(message.timestamp)}</div>` : ''}
163
+ ${showTimestamp ? `<div class="chat-message-timestamp" aria-label="Sent at ${formatTimestamp(message.timestamp)}">${formatTimestamp(message.timestamp)}</div>` : ''}
164
+ ${messageActions}
116
165
  </div>
117
166
  ${isUser ? avatarHtml : ''}
118
167
  </div>
@@ -129,15 +178,24 @@ export function createChatUI(options) {
129
178
  applyTheme();
130
179
  containerElement.className = `chat-container ${containerElement.className || ''}`.trim();
131
180
  containerElement.setAttribute('data-chat-ui', 'true');
181
+ containerElement.setAttribute('role', 'log');
182
+ containerElement.setAttribute('aria-label', 'Chat conversation');
183
+ containerElement.setAttribute('aria-live', 'polite');
184
+ containerElement.setAttribute('aria-atomic', 'false');
132
185
  containerElement.innerHTML = `
133
- <div class="chat-messages-container">
186
+ <div class="chat-messages-container" role="log" aria-live="polite" aria-label="Chat messages">
134
187
  ${messages.length === 0 ? `<div class="chat-empty-state">${escapeHtml(emptyStateMessage)}</div>` : ''}
135
188
  ${messages.map(renderMessage).join('')}
136
189
  ${isLoading ? `
137
- <div class="chat-message chat-message-assistant chat-message-loading">
190
+ <div class="chat-message chat-message-assistant chat-message-loading" role="status" aria-live="polite" aria-label="Loading response">
138
191
  ${renderAvatar('assistant')}
139
192
  <div class="chat-message-content chat-message-content-assistant">
140
- <div class="chat-message-text">${escapeHtml(loadingMessage)}</div>
193
+ <div class="chat-message-text">
194
+ <span class="chat-loading-dots">
195
+ <span></span><span></span><span></span>
196
+ </span>
197
+ <span class="chat-loading-text">${escapeHtml(loadingMessage)}</span>
198
+ </div>
141
199
  </div>
142
200
  </div>
143
201
  ` : ''}
@@ -152,7 +210,15 @@ export function createChatUI(options) {
152
210
  </div>
153
211
  ` : ''}
154
212
  <div class="chat-input-container">
155
- <form class="chat-input-form">
213
+ <form
214
+ class="chat-input-form"
215
+ action="javascript:void(0)"
216
+ method="post"
217
+ novalidate
218
+ data-chat-form="true"
219
+ role="form"
220
+ aria-label="Chat input form"
221
+ >
156
222
  <input
157
223
  type="text"
158
224
  class="chat-input"
@@ -189,50 +255,202 @@ export function createChatUI(options) {
189
255
  }
190
256
  });
191
257
  }
192
- // Attach event listeners
258
+ // Attach event listeners with proper cleanup tracking
193
259
  if (formElement) {
194
- formElement.addEventListener('submit', (e) => {
195
- e.preventDefault();
260
+ // Use capture phase to ensure preventDefault runs before any other handlers
261
+ // This prevents page reloads even if parent elements have submit handlers
262
+ const handleSubmit = (e) => {
263
+ const submitEvent = e;
264
+ submitEvent.preventDefault();
265
+ submitEvent.stopPropagation();
196
266
  if (!disabled && !isLoading && input.trim()) {
197
267
  engine.sendMessage();
198
268
  }
199
- });
269
+ };
270
+ // Add handler in capture phase (runs first) to prevent any page reloads
271
+ formElement.addEventListener('submit', handleSubmit, true);
272
+ eventHandlers.push({ element: formElement, event: 'submit', handler: handleSubmit, options: true });
273
+ // Also add in bubble phase as fallback
274
+ formElement.addEventListener('submit', handleSubmit, false);
275
+ eventHandlers.push({ element: formElement, event: 'submit', handler: handleSubmit, options: false });
200
276
  }
201
277
  if (inputElement) {
202
278
  // Handle input changes
203
- inputElement.addEventListener('input', (e) => {
279
+ const handleInput = (e) => {
204
280
  // Skip if we're programmatically updating the input
205
281
  if (isUpdatingInputProgrammatically) {
206
282
  return;
207
283
  }
208
284
  const target = e.target;
209
285
  engine.setInput(target.value);
210
- });
286
+ };
287
+ inputElement.addEventListener('input', handleInput);
288
+ eventHandlers.push({ element: inputElement, event: 'input', handler: handleInput });
211
289
  // Keyboard shortcuts: Enter to send, Shift+Enter for newline
212
- inputElement.addEventListener('keydown', (e) => {
213
- if (e.key === 'Enter' && !e.shiftKey) {
214
- e.preventDefault();
290
+ const handleKeydown = (e) => {
291
+ const keyboardEvent = e;
292
+ if (keyboardEvent.key === 'Enter' && !keyboardEvent.shiftKey) {
293
+ keyboardEvent.preventDefault();
215
294
  if (!disabled && !isLoading && input.trim()) {
216
295
  engine.sendMessage();
217
296
  }
218
297
  }
219
298
  // Shift+Enter allows default behavior (newline if textarea, ignored if input)
220
- });
299
+ };
300
+ inputElement.addEventListener('keydown', handleKeydown);
301
+ eventHandlers.push({ element: inputElement, event: 'keydown', handler: handleKeydown });
221
302
  }
222
303
  // Clear button handler
223
304
  if (clearButtonElement) {
224
- clearButtonElement.addEventListener('click', () => {
305
+ const handleClearClick = () => {
225
306
  if (confirm('Are you sure you want to clear the conversation?')) {
226
307
  engine.clearMessages();
227
308
  }
228
- });
309
+ };
310
+ clearButtonElement.addEventListener('click', handleClearClick);
311
+ eventHandlers.push({ element: clearButtonElement, event: 'click', handler: handleClearClick });
229
312
  }
230
313
  // Retry button handler
231
314
  if (retryButton) {
232
- retryButton.addEventListener('click', () => {
315
+ const handleRetryClick = () => {
233
316
  engine.retryLastMessage();
234
- });
317
+ };
318
+ retryButton.addEventListener('click', handleRetryClick);
319
+ eventHandlers.push({ element: retryButton, event: 'click', handler: handleRetryClick });
320
+ }
321
+ // Message actions handlers (using event delegation)
322
+ if (enableMessageActions) {
323
+ const handleMessageAction = async (e) => {
324
+ const target = e.target;
325
+ const actionButton = target.closest('[data-action]');
326
+ if (!actionButton)
327
+ return;
328
+ e.preventDefault();
329
+ e.stopPropagation();
330
+ const action = actionButton.getAttribute('data-action');
331
+ const messageId = actionButton.getAttribute('data-message-id');
332
+ if (!messageId || !action)
333
+ return;
334
+ const message = engine.getMessages().find(m => m.id === messageId);
335
+ if (!message)
336
+ return;
337
+ switch (action) {
338
+ case 'copy':
339
+ await copyMessageToClipboard(message);
340
+ break;
341
+ case 'delete':
342
+ deleteMessage(message);
343
+ break;
344
+ case 'edit':
345
+ editMessage(message);
346
+ break;
347
+ }
348
+ };
349
+ containerElement.addEventListener('click', handleMessageAction);
350
+ eventHandlers.push({ element: containerElement, event: 'click', handler: handleMessageAction });
235
351
  }
352
+ // Toast notification helper
353
+ const showToast = (message, type = 'info') => {
354
+ const toast = document.createElement('div');
355
+ toast.className = `chat-toast chat-toast-${type}`;
356
+ toast.setAttribute('role', 'alert');
357
+ toast.setAttribute('aria-live', 'polite');
358
+ toast.textContent = message;
359
+ containerElement.appendChild(toast);
360
+ // Animate in
361
+ requestAnimationFrame(() => {
362
+ toast.classList.add('chat-toast-show');
363
+ });
364
+ // Remove after 3 seconds
365
+ setTimeout(() => {
366
+ toast.classList.remove('chat-toast-show');
367
+ setTimeout(() => toast.remove(), 300);
368
+ }, 3000);
369
+ };
370
+ const copyMessageToClipboard = async (message) => {
371
+ try {
372
+ await navigator.clipboard.writeText(message.content);
373
+ showToast('Message copied to clipboard', 'success');
374
+ onMessageCopy?.(message);
375
+ }
376
+ catch (err) {
377
+ console.error('Failed to copy:', err);
378
+ showToast('Failed to copy message', 'error');
379
+ }
380
+ };
381
+ const deleteMessage = (message) => {
382
+ if (confirm('Are you sure you want to delete this message?')) {
383
+ engine.deleteMessage(message.id);
384
+ showToast('Message deleted', 'info');
385
+ onMessageDelete?.(message);
386
+ }
387
+ };
388
+ const editMessage = (message) => {
389
+ const messageElement = containerElement.querySelector(`[data-message-id="${message.id}"]`);
390
+ if (!messageElement)
391
+ return;
392
+ const contentElement = messageElement.querySelector('.chat-message-text, .chat-markdown');
393
+ if (!contentElement)
394
+ return;
395
+ const originalContent = message.content;
396
+ const input = document.createElement('input');
397
+ input.type = 'text';
398
+ input.value = originalContent;
399
+ input.className = 'chat-message-edit-input';
400
+ input.setAttribute('aria-label', 'Edit message');
401
+ const buttonContainer = document.createElement('div');
402
+ buttonContainer.className = 'chat-message-edit-buttons';
403
+ const saveButton = document.createElement('button');
404
+ saveButton.textContent = 'Save';
405
+ saveButton.className = 'chat-message-edit-save';
406
+ saveButton.type = 'button';
407
+ saveButton.setAttribute('aria-label', 'Save changes');
408
+ const cancelButton = document.createElement('button');
409
+ cancelButton.textContent = 'Cancel';
410
+ cancelButton.className = 'chat-message-edit-cancel';
411
+ cancelButton.type = 'button';
412
+ cancelButton.setAttribute('aria-label', 'Cancel editing');
413
+ buttonContainer.appendChild(saveButton);
414
+ buttonContainer.appendChild(cancelButton);
415
+ const originalHTML = contentElement.innerHTML;
416
+ contentElement.innerHTML = '';
417
+ contentElement.appendChild(input);
418
+ contentElement.appendChild(buttonContainer);
419
+ input.focus();
420
+ input.select();
421
+ const handleSave = () => {
422
+ const newContent = input.value.trim();
423
+ if (newContent && newContent !== originalContent) {
424
+ try {
425
+ engine.editMessage(message.id, newContent);
426
+ showToast('Message updated', 'success');
427
+ onMessageEdit?.(message, newContent);
428
+ }
429
+ catch (error) {
430
+ showToast(error instanceof Error ? error.message : 'Failed to edit message', 'error');
431
+ contentElement.innerHTML = originalHTML;
432
+ }
433
+ }
434
+ else {
435
+ contentElement.innerHTML = originalHTML;
436
+ }
437
+ };
438
+ const handleCancel = () => {
439
+ contentElement.innerHTML = originalHTML;
440
+ };
441
+ saveButton.addEventListener('click', handleSave);
442
+ cancelButton.addEventListener('click', handleCancel);
443
+ input.addEventListener('keydown', (e) => {
444
+ if (e.key === 'Enter' && !e.shiftKey) {
445
+ e.preventDefault();
446
+ handleSave();
447
+ }
448
+ if (e.key === 'Escape') {
449
+ e.preventDefault();
450
+ handleCancel();
451
+ }
452
+ });
453
+ };
236
454
  // Auto-scroll to bottom after render
237
455
  // Use setTimeout to ensure DOM is updated
238
456
  setTimeout(() => {
@@ -301,6 +519,11 @@ export function createChatUI(options) {
301
519
  engine,
302
520
  render,
303
521
  destroy: () => {
522
+ // Remove all event listeners
523
+ eventHandlers.forEach(({ element, event, handler, options }) => {
524
+ element.removeEventListener(event, handler, options);
525
+ });
526
+ eventHandlers.length = 0;
304
527
  engine.destroy();
305
528
  containerElement.innerHTML = '';
306
529
  // Clear references