feat: implement enhanced message action buttons with copy and TTS functionality

Replace plain text copy buttons with modern icon-based action buttons featuring: - Material Design icons for copy and text-to-speech actions - Responsive behavior: hover on desktop, tap on mobile - Visual feedback for success/error states - Speech synthesis integration with existing speech store - Viewport-aware positioning for long messages - Modern Clipboard API with execCommand fallback - Full accessibility support with ARIA labels and keyboard navigation Files added: - webui/components/messages/action-buttons/message-action-buttons.js - webui/components/messages/action-buttons/message-action-buttons.css - webui/components/messages/action-buttons/README.md - webui/js/message-interactions.js Files modified: - webui/js/messages.js (integrate new component, remove legacy functions) - webui/css/messages.css (deprecate old copy button styles) - webui/index.html (add component CSS import) The implementation follows Agent Zero's modular component architecture and maintains backward compatibility during the transition period.

TerminallyLazy committed Aug 4, 2025 at 22:20 UTC b7391403297de8d414352b5128c4d150021db982
7 files changed +730 -120
.gitignore
+3 -1
@@ -39,4 +39,6 @@ instruments/**
39
40 # Global rule to include .gitkeep files anywhere
41 !**/.gitkeep
42 -agent_history.gif
\ No newline at end of file
42 +agent_history.gif
43 +
44 +tests/
\ No newline at end of file
webui/components/messages/action-buttons/message-action-buttons.css new
+245
@@ -0,0 +1,245 @@
1 +/* Message Action Buttons Styles */
2 +
3 +/* Main action buttons container */
4 +.action-buttons {
5 + position: sticky;
6 + top: var(--spacing-sm);
7 + right: 0;
8 + display: flex;
9 + align-items: center;
10 + gap: var(--spacing-xs);
11 + opacity: 0;
12 + transition: opacity var(--transition-speed) ease-in-out;
13 + z-index: 10;
14 + align-self: flex-start;
15 + margin-left: auto;
16 + margin-top: var(--spacing-sm);
17 + margin-right: var(--spacing-xs);
18 +}
19 +
20 +/* Individual action button */
21 +.action-button {
22 + position: relative;
23 + display: flex;
24 + align-items: center;
25 + justify-content: center;
26 + width: 32px;
27 + height: 32px;
28 + background: var(--color-panel);
29 + border: 1px solid var(--color-border);
30 + border-radius: 6px;
31 + cursor: pointer;
32 + transition: all var(--transition-speed) ease-in-out;
33 + color: var(--color-text);
34 + padding: 0;
35 + font-size: 16px;
36 + opacity: 0.7;
37 +}
38 +
39 +.action-button:hover {
40 + opacity: 1;
41 + background: var(--color-background);
42 + border-color: var(--color-primary);
43 + transform: translateY(-1px);
44 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
45 +}
46 +
47 +.action-button:active {
48 + transform: translateY(0);
49 + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
50 +}
51 +
52 +/* Material icons in buttons */
53 +.action-button .material-symbols-outlined {
54 + font-size: 16px;
55 + line-height: 1;
56 + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
57 +}
58 +
59 +/* Success state */
60 +.action-button.success {
61 + background: #4CAF50;
62 + border-color: #4CAF50;
63 + color: white;
64 +}
65 +
66 +.action-button.success .material-symbols-outlined {
67 + font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 20;
68 +}
69 +
70 +/* Error state */
71 +.action-button.error {
72 + background: var(--color-accent);
73 + border-color: var(--color-accent);
74 + color: white;
75 +}
76 +
77 +.action-button.error .material-symbols-outlined {
78 + font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 20;
79 +}
80 +
81 +/* Speaking state */
82 +.action-button.speaking {
83 + background: var(--color-primary);
84 + border-color: var(--color-primary);
85 + color: white;
86 + animation: pulse 2s infinite;
87 +}
88 +
89 +@keyframes pulse {
90 + 0% { opacity: 1; }
91 + 50% { opacity: 0.7; }
92 + 100% { opacity: 1; }
93 +}
94 +
95 +/* Tooltips for desktop */
96 +.action-tooltip {
97 + position: absolute;
98 + bottom: calc(100% + 8px);
99 + left: 50%;
100 + transform: translateX(-50%);
101 + background: rgba(0, 0, 0, 0.9);
102 + color: white;
103 + padding: var(--spacing-xs) var(--spacing-sm);
104 + border-radius: 4px;
105 + font-size: var(--font-size-small);
106 + white-space: nowrap;
107 + opacity: 0;
108 + visibility: hidden;
109 + transition: all var(--transition-speed) ease-in-out;
110 + pointer-events: none;
111 + z-index: 20;
112 + font-family: "Rubik", Arial, Helvetica, sans-serif;
113 +}
114 +
115 +.action-tooltip::after {
116 + content: '';
117 + position: absolute;
118 + top: 100%;
119 + left: 50%;
120 + transform: translateX(-50%);
121 + border: 4px solid transparent;
122 + border-top-color: rgba(0, 0, 0, 0.9);
123 +}
124 +
125 +/* Show action buttons on hover for desktop */
126 +.device-pointer .msg-content:hover .action-buttons,
127 +.device-pointer .kvps-row:hover .action-buttons,
128 +.device-pointer .message-text:hover .action-buttons {
129 + opacity: 1;
130 +}
131 +
132 +/* Show tooltips on button hover for desktop only */
133 +.device-pointer .action-button:hover .action-tooltip {
134 + opacity: 1;
135 + visibility: visible;
136 +}
137 +
138 +/* Touch device behavior - buttons shown when parent has active class */
139 +.device-touch .msg-content.show-actions .action-buttons,
140 +.device-touch .kvps-row.show-actions .action-buttons,
141 +.device-touch .message-text.show-actions .action-buttons {
142 + opacity: 1;
143 +}
144 +
145 +/* Hide tooltips on touch devices */
146 +.device-touch .action-tooltip {
147 + display: none;
148 +}
149 +
150 +/* Viewport sticky positioning for long messages */
151 +.action-buttons.viewport-sticky {
152 + position: fixed;
153 + top: var(--spacing-md);
154 + right: var(--spacing-md);
155 + background: var(--color-panel);
156 + border: 1px solid var(--color-border);
157 + border-radius: 8px;
158 + padding: var(--spacing-xs);
159 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
160 + opacity: 1;
161 + z-index: 100;
162 +}
163 +
164 +.action-buttons.viewport-sticky .action-button {
165 + opacity: 1;
166 +}
167 +
168 +/* Extra bottom padding for mobile browser bars */
169 +.device-touch .action-buttons {
170 + margin-bottom: 70px;
171 +}
172 +
173 +/* Specific positioning adjustments for different message types */
174 +.message-user .action-buttons {
175 + margin-right: 0;
176 + align-self: flex-end;
177 +}
178 +
179 +.message-agent .action-buttons,
180 +.message-agent-response .action-buttons {
181 + margin-right: 0;
182 +}
183 +
184 +.message-tool .action-buttons,
185 +.message-info .action-buttons {
186 + margin-top: 0;
187 +}
188 +
189 +.msg-thoughts .action-buttons {
190 + margin-top: 0;
191 +}
192 +
193 +/* Ensure message containers are positioned for sticky children */
194 +.msg-content,
195 +.kvps-row,
196 +.message-text {
197 + position: relative;
198 + display: flex;
199 + flex-direction: column;
200 +}
201 +
202 +/* Responsive adjustments for smaller screens */
203 +@media (max-width: 768px) {
204 + .action-button {
205 + width: 36px;
206 + height: 36px;
207 + }
208 +
209 + .action-button .material-symbols-outlined {
210 + font-size: 18px;
211 + }
212 +
213 + .action-buttons.viewport-sticky {
214 + right: var(--spacing-sm);
215 + top: var(--spacing-sm);
216 + }
217 +}
218 +
219 +/* High contrast mode support */
220 +@media (prefers-contrast: high) {
221 + .action-button {
222 + border-width: 2px;
223 + }
224 +
225 + .action-button:hover {
226 + border-width: 2px;
227 + }
228 +}
229 +
230 +/* Reduced motion support */
231 +@media (prefers-reduced-motion: reduce) {
232 + .action-button,
233 + .action-buttons,
234 + .action-tooltip {
235 + transition: none;
236 + }
237 +
238 + .action-button.speaking {
239 + animation: none;
240 + }
241 +
242 + .action-button:hover {
243 + transform: none;
244 + }
245 +}
\ No newline at end of file
webui/components/messages/action-buttons/message-action-buttons.js new
+200
@@ -0,0 +1,200 @@
1 +// Message Action Buttons Component
2 +import { store as speechStore } from "/components/chat/speech/speech-store.js";
3 +
4 +// Extract text content based on container type
5 +function extractTextContent(container) {
6 + if (container.classList.contains("kvps-row")) {
7 + return container.querySelector(".kvps-val")?.innerText || "";
8 + } else if (container.classList.contains("message-text")) {
9 + return container.querySelector("span")?.innerText || "";
10 + } else {
11 + return container.querySelector("span")?.innerText || "";
12 + }
13 +}
14 +
15 +// Copy text with fallback for local development
16 +async function copyToClipboard(text, button) {
17 + try {
18 + // Try modern clipboard API first
19 + if (navigator.clipboard && window.isSecureContext) {
20 + await navigator.clipboard.writeText(text);
21 + showCopyFeedback(button, true);
22 + } else {
23 + // Fallback for local development or non-HTTPS
24 + const textarea = document.createElement("textarea");
25 + textarea.value = text;
26 + textarea.style.position = "fixed";
27 + textarea.style.left = "-999999px";
28 + document.body.appendChild(textarea);
29 + textarea.select();
30 +
31 + try {
32 + document.execCommand("copy");
33 + showCopyFeedback(button, true);
34 + } catch (err) {
35 + console.error("Fallback copy failed:", err);
36 + showCopyFeedback(button, false);
37 + } finally {
38 + document.body.removeChild(textarea);
39 + }
40 + }
41 + } catch (err) {
42 + console.error("Copy failed:", err);
43 + showCopyFeedback(button, false);
44 + }
45 +}
46 +
47 +// Show visual feedback for copy action
48 +function showCopyFeedback(button, success) {
49 +
50 +
51 + if (success) {
52 +
53 + button.classList.add("success");
54 + } else {
55 + button.classList.add("error");
56 + }
57 +
58 + setTimeout(() => {
59 + button.classList.remove("success", "error");
60 + }, 2000);
61 +}
62 +
63 +// Speak text using speech store
64 +async function speakContent(text, button) {
65 + try {
66 + // Update button state
67 + button.classList.add("speaking");
68 + const icon = button.querySelector(".material-symbols-outlined");
69 + const originalIcon = icon.textContent;
70 +
71 + // Check if already speaking
72 + if (speechStore.isSpeaking && speechStore.speakingText === text) {
73 + // Stop speaking
74 + speechStore.stopSpeaking();
75 + icon.textContent = originalIcon;
76 + button.classList.remove("speaking");
77 + return;
78 + }
79 +
80 + // Clean and speak text
81 + const cleanedText = speechStore.cleanText ? speechStore.cleanText(text) : text;
82 +
83 + // Update icon to show speaking state
84 + icon.textContent = "stop_circle";
85 +
86 + // Start speaking
87 + await speechStore.speak(cleanedText);
88 +
89 + // Reset button state when done
90 + icon.textContent = "volume_up";
91 + button.classList.remove("speaking");
92 +
93 + } catch (err) {
94 + console.error("Speech failed:", err);
95 + button.classList.remove("speaking");
96 +
97 + // Show error feedback
98 + button.classList.add("error");
99 +
100 + setTimeout(() => {
101 + button.classList.remove("error");
102 + }, 2000);
103 + }
104 +}
105 +
106 +// Create action buttons for a message or KVP
107 +export function createActionButtons() {
108 + const container = document.createElement("div");
109 + container.className = "action-buttons";
110 +
111 + // Copy button
112 + const copyButton = document.createElement("button");
113 + copyButton.className = "action-button copy-action";
114 + copyButton.setAttribute("aria-label", "Copy text");
115 + copyButton.innerHTML = `
116 + <span class="material-symbols-outlined">content_copy</span>
117 + `;
118 +
119 + // Speak button
120 + const speakButton = document.createElement("button");
121 + speakButton.className = "action-button speak-action";
122 + speakButton.setAttribute("aria-label", "Speak text");
123 + speakButton.innerHTML = `
124 + <span class="material-symbols-outlined">volume_up</span>
125 + `;
126 +
127 + // Add event listeners
128 + copyButton.addEventListener("click", async function(e) {
129 + e.stopPropagation();
130 + const messageContainer = this.closest(".msg-content, .kvps-row, .message-text");
131 + const textToCopy = extractTextContent(messageContainer);
132 + await copyToClipboard(textToCopy, this);
133 + });
134 +
135 + speakButton.addEventListener("click", async function(e) {
136 + e.stopPropagation();
137 + const messageContainer = this.closest(".msg-content, .kvps-row, .message-text");
138 + const textToSpeak = extractTextContent(messageContainer);
139 + await speakContent(textToSpeak, this);
140 + });
141 +
142 + // Add buttons to container
143 + container.appendChild(copyButton);
144 + container.appendChild(speakButton);
145 +
146 + return container;
147 +}
148 +
149 +// Add action buttons to an element
150 +export function addActionButtonsToElement(element) {
151 + // Remove any existing action buttons first
152 + const existingButtons = element.querySelector(".action-buttons");
153 + if (existingButtons) {
154 + existingButtons.remove();
155 + }
156 +
157 +
158 + // Create and add new action buttons
159 + const actionButtons = createActionButtons();
160 + element.appendChild(actionButtons);
161 +
162 + // Set up intersection observer for viewport detection
163 + setupViewportObserver(element);
164 +}
165 +
166 +// Set up intersection observer for keeping buttons in viewport
167 +function setupViewportObserver(element) {
168 + const actionButtons = element.querySelector(".action-buttons");
169 + if (!actionButtons) return;
170 +
171 + // Create observer for viewport detection
172 + const observer = new IntersectionObserver((entries) => {
173 + entries.forEach(entry => {
174 + const buttons = entry.target.querySelector(".action-buttons");
175 + if (buttons && !entry.isIntersecting) {
176 +
177 + } else if (buttons) {
178 +
179 + }
180 + });
181 + }, {
182 + root: null,
183 + rootMargin: "-50px 0px",
184 + threshold: 0
185 + });
186 +
187 + // Start observing
188 + observer.observe(element);
189 +
190 + // Store observer reference for cleanup
191 + element._actionButtonsObserver = observer;
192 +}
193 +
194 +// Cleanup function to disconnect observer
195 +export function cleanupActionButtons(element) {
196 + if (element._actionButtonsObserver) {
197 + element._actionButtonsObserver.disconnect();
198 + delete element._actionButtonsObserver;
199 + }
200 +}
\ No newline at end of file
webui/css/messages.css
+5 -66
@@ -279,72 +279,11 @@
279 opacity: 1;
280 }
281
282 -/* Copy button styles */
283 -.copy-button {
284 - position: absolute;
285 - right: 0;
286 - top: var(--spacing-sm);
287 - background: none;
288 - border: none;
289 - padding: var(--spacing-xs) var(--spacing-sm);
290 - padding-right: 0;
291 - cursor: pointer;
292 - text-decoration: underline;
293 - text-wrap: nowrap;
294 - opacity: 0;
295 - -webkit-transition: opacity var(--transition-speed) ease-in-out;
296 - transition: opacity var(--transition-speed) ease-in-out;
297 - color: inherit;
298 - font-size: 12px;
299 - font-family: "Rubik", Arial, Helvetica, sans-serif;
300 -}
301 -
302 -.copy-button:hover {
303 - opacity: 0.8 !important;
304 -}
305 -
306 -.msg-content:hover .copy-button,
307 -.kvps-row:hover .copy-button,
308 -.message-text:hover .copy-button {
309 - opacity: 0.6;
310 -}
311 -
312 -.copy-button.copied {
313 - font-family: "Rubik", Arial, Helvetica, sans-serif !important;
314 - opacity: 1 !important;
315 -}
316 -
317 -.msg-thoughts .copy-button {
318 - top: -12px !important;
319 -}
320 -
321 -.message-user .copy-button {
322 - top: -15px !important;
323 - left: -13px !important;
324 - right: 99% !important;
325 -}
326 -
327 -.message-agent-response .copy-button {
328 - top: -22px !important;
329 - right: 0 !important;
330 - padding-right: 0px !important;
331 -}
332 -
333 -.message-info .copy-button {
334 - top: -22px !important;
335 - right: 0 !important;
336 - padding-right: 0px !important;
337 -}
338 -
339 -.message-tool .copy-button {
340 - top: -12px !important;
341 - right: 0 !important;
342 -}
343 -
344 -.msg-output .copy-button {
345 - top: -6px !important;
346 - right: 0 !important;
347 -}
282 +/* Legacy copy button styles - DEPRECATED
283 + * These styles have been replaced by the new action buttons component
284 + * located at /components/messages/action-buttons/message-action-buttons.css
285 + * Remove this section after confirming all functionality works with action buttons
286 + */
287
288 /* Make message containers relative for absolute positioning of copy buttons */
289 .msg-content,
webui/index.html
+1
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="public/favicon.svg">
9 <link rel="stylesheet" href="index.css">
10 <link rel="stylesheet" href="css/messages.css">
11 + <link rel="stylesheet" href="components/messages/action-buttons/message-action-buttons.css">
12 <link rel="stylesheet" href="css/toast.css">
13 <link rel="stylesheet" href="css/settings.css">
14 <link rel="stylesheet" href="css/file_browser.css">
webui/js/message-interactions.js new
+243
@@ -0,0 +1,243 @@
1 +// Message Interactions Handler for Touch/Pointer Devices
2 +import { getInputType } from "./device.js";
3 +
4 +// Track which messages currently have visible action buttons
5 +const visibleActionStates = new WeakSet();
6 +
7 +// Initialize interaction handlers for touch devices
8 +export function initializeMessageInteractions() {
9 + const inputType = getInputType();
10 +
11 + if (inputType === "touch") {
12 + setupTouchInteractions();
13 + }
14 + // Pointer devices use hover states from CSS, no JS needed
15 +}
16 +
17 +// Set up touch device interactions
18 +function setupTouchInteractions() {
19 + // Use event delegation on the chat history container
20 + const chatHistory = document.getElementById("chat-history");
21 + if (!chatHistory) return;
22 +
23 + // Handle touch interactions for showing/hiding action buttons
24 + chatHistory.addEventListener("click", handleTouchInteraction, { passive: false });
25 +
26 + // Handle clicks outside messages to hide action buttons
27 + document.addEventListener("click", handleOutsideClick, { passive: true });
28 +
29 + // Prevent text selection when tapping action buttons area
30 + chatHistory.addEventListener("selectstart", handleSelectStart, { passive: false });
31 +}
32 +
33 +// Handle touch interactions on messages
34 +function handleTouchInteraction(e) {
35 + // Find the closest message container
36 + const messageContainer = e.target.closest(".msg-content, .kvps-row, .message-text");
37 + if (!messageContainer) return;
38 +
39 + // If clicking on action buttons, let them handle their own events
40 + if (e.target.closest(".action-buttons")) {
41 + return;
42 + }
43 +
44 + // Toggle action buttons visibility
45 + toggleActionButtons(messageContainer, e);
46 +}
47 +
48 +// Handle clicks outside of messages to hide action buttons
49 +function handleOutsideClick(e) {
50 + // If clicking on a message or action button, don't hide
51 + if (e.target.closest(".msg-content, .kvps-row, .message-text, .action-buttons")) {
52 + return;
53 + }
54 +
55 + // Hide all visible action buttons
56 + hideAllActionButtons();
57 +}
58 +
59 +// Prevent text selection when interacting with action area
60 +function handleSelectStart(e) {
61 + // Allow text selection unless we're in the action buttons area
62 + const actionButtons = e.target.closest(".action-buttons");
63 + const messageContainer = e.target.closest(".msg-content, .kvps-row, .message-text");
64 +
65 + if (actionButtons) {
66 + e.preventDefault();
67 + return false;
68 + }
69 +
70 + // If a message has visible action buttons, prevent selection to avoid conflicts
71 + if (messageContainer && visibleActionStates.has(messageContainer)) {
72 + // Allow selection after a short delay to distinguish from tap-to-show-buttons
73 + setTimeout(() => {
74 + if (visibleActionStates.has(messageContainer)) {
75 + // Still visible, user might want to select text
76 + return true;
77 + }
78 + }, 200);
79 + }
80 +}
81 +
82 +// Toggle action buttons visibility for a message
83 +export function toggleActionButtons(messageContainer, event = null) {
84 + if (!messageContainer) return;
85 +
86 + const isCurrentlyVisible = visibleActionStates.has(messageContainer);
87 +
88 + if (isCurrentlyVisible) {
89 + hideActionButtons(messageContainer);
90 + } else {
91 + // Hide all other visible action buttons first
92 + hideAllActionButtons();
93 +
94 + // Show action buttons for this message
95 + showActionButtons(messageContainer);
96 + }
97 +
98 + // Prevent event bubbling to avoid triggering document click handler
99 + if (event) {
100 + event.stopPropagation();
101 + }
102 +}
103 +
104 +// Show action buttons for a message
105 +export function showActionButtons(messageContainer) {
106 + if (!messageContainer) return;
107 +
108 + messageContainer.classList.add("show-actions");
109 + visibleActionStates.add(messageContainer);
110 +
111 + // Add a slight delay to prevent immediate hiding from document click
112 + setTimeout(() => {
113 + messageContainer.setAttribute("data-actions-visible", "true");
114 + }, 50);
115 +}
116 +
117 +// Hide action buttons for a message
118 +export function hideActionButtons(messageContainer) {
119 + if (!messageContainer) return;
120 +
121 + messageContainer.classList.remove("show-actions");
122 + messageContainer.removeAttribute("data-actions-visible");
123 + visibleActionStates.delete(messageContainer);
124 +}
125 +
126 +// Hide all visible action buttons
127 +export function hideAllActionButtons() {
128 + const chatHistory = document.getElementById("chat-history");
129 + if (!chatHistory) return;
130 +
131 + // Find all messages with visible action buttons
132 + const visibleMessages = chatHistory.querySelectorAll(".show-actions");
133 + visibleMessages.forEach(message => {
134 + hideActionButtons(message);
135 + });
136 +}
137 +
138 +// Setup interaction for a specific message (called when message is created)
139 +export function setupMessageInteraction(messageContainer) {
140 + if (!messageContainer) return;
141 +
142 + const inputType = getInputType();
143 +
144 + if (inputType === "touch") {
145 + // For touch devices, add click handler
146 + messageContainer.addEventListener("click", (e) => {
147 + // Only handle if not clicking on action buttons
148 + if (!e.target.closest(".action-buttons")) {
149 + toggleActionButtons(messageContainer, e);
150 + }
151 + }, { passive: false });
152 +
153 + // Prevent context menu on long press for action area
154 + messageContainer.addEventListener("contextmenu", (e) => {
155 + if (e.target.closest(".action-buttons")) {
156 + e.preventDefault();
157 + }
158 + });
159 + }
160 +
161 + // For both touch and pointer devices, handle keyboard navigation
162 + setupKeyboardNavigation(messageContainer);
163 +}
164 +
165 +// Set up keyboard navigation for accessibility
166 +function setupKeyboardNavigation(messageContainer) {
167 + const actionButtons = messageContainer.querySelector(".action-buttons");
168 + if (!actionButtons) return;
169 +
170 + const buttons = actionButtons.querySelectorAll(".action-button");
171 +
172 + buttons.forEach((button, index) => {
173 + button.setAttribute("tabindex", "0");
174 +
175 + button.addEventListener("keydown", (e) => {
176 + switch (e.key) {
177 + case "Enter":
178 + case " ":
179 + e.preventDefault();
180 + button.click();
181 + break;
182 + case "ArrowLeft":
183 + e.preventDefault();
184 + const prevButton = buttons[index - 1] || buttons[buttons.length - 1];
185 + prevButton.focus();
186 + break;
187 + case "ArrowRight":
188 + e.preventDefault();
189 + const nextButton = buttons[index + 1] || buttons[0];
190 + nextButton.focus();
191 + break;
192 + case "Escape":
193 + hideActionButtons(messageContainer);
194 + messageContainer.focus();
195 + break;
196 + }
197 + });
198 + });
199 +}
200 +
201 +// Debounced scroll handler to manage viewport sticky buttons
202 +let scrollTimeout;
203 +export function handleScroll() {
204 + clearTimeout(scrollTimeout);
205 + scrollTimeout = setTimeout(() => {
206 + // Check if any viewport-sticky buttons should be hidden
207 + const stickyButtons = document.querySelectorAll(".action-buttons.viewport-sticky");
208 + stickyButtons.forEach(buttons => {
209 + const parent = buttons.closest(".msg-content, .kvps-row, .message-text");
210 + if (parent) {
211 + const rect = parent.getBoundingClientRect();
212 + const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
213 +
214 + if (isVisible) {
215 + buttons.classList.remove("viewport-sticky");
216 + }
217 + }
218 + });
219 + }, 100);
220 +}
221 +
222 +// Initialize scroll handler
223 +export function initializeScrollHandler() {
224 + window.addEventListener("scroll", handleScroll, { passive: true });
225 + window.addEventListener("resize", handleScroll, { passive: true });
226 +}
227 +
228 +// Cleanup function
229 +export function cleanupMessageInteractions() {
230 + window.removeEventListener("scroll", handleScroll);
231 + window.removeEventListener("resize", handleScroll);
232 +
233 + const chatHistory = document.getElementById("chat-history");
234 + if (chatHistory) {
235 + chatHistory.removeEventListener("click", handleTouchInteraction);
236 + chatHistory.removeEventListener("selectstart", handleSelectStart);
237 + }
238 +
239 + document.removeEventListener("click", handleOutsideClick);
240 +
241 + // Clear visible states
242 + visibleActionStates.clear?.();
243 +}
\ No newline at end of file
webui/js/messages.js
+33 -53
@@ -1,22 +1,39 @@
1 -// copy button
1 +// message actions and components
2 import { openImageModal } from "./image_modal.js";
3 import { marked } from "../vendor/marked/marked.esm.js";
4 import { store as _messageResizeStore } from "/components/messages/resize/message-resize-store.js"; // keep here, required in html
5 import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
6 +import { addActionButtonsToElement, cleanupActionButtons } from "/components/messages/action-buttons/message-action-buttons.js";
7 +import { setupMessageInteraction, initializeMessageInteractions, initializeScrollHandler } from "./message-interactions.js";
8
9 const chatHistory = document.getElementById("chat-history");
10
11 let messageGroup = null;
12
13 +// Initialize message interactions when DOM is ready
14 +let interactionsInitialized = false;
15 +function ensureInteractionsInitialized() {
16 + if (!interactionsInitialized && chatHistory) {
17 + initializeMessageInteractions();
18 + initializeScrollHandler();
19 + interactionsInitialized = true;
20 + }
21 +}
22 +
23 export function setMessage(id, type, heading, content, temp, kvps = null) {
24 + // Ensure interactions are initialized
25 + ensureInteractionsInitialized();
26 +
27 // Search for the existing message container by id
28 let messageContainer = document.getElementById(`message-${id}`);
29 + let isNewMessage = false;
30
31 if (messageContainer) {
32 // Don't clear innerHTML - we'll do incremental updates
33 // messageContainer.innerHTML = "";
34 } else {
35 // Create a new container if not found
36 + isNewMessage = true;
37 const sender = type === "user" ? "user" : "ai";
38 messageContainer = document.createElement("div");
39 messageContainer.id = `message-${id}`;
@@ -64,49 +81,16 @@ export function setMessage(id, type, heading, content, temp, kvps = null) {
81 messageGroup.appendChild(messageContainer);
82 chatHistory.appendChild(messageGroup);
83 }
84 +
85 + // Set up interactions for new messages
86 + if (isNewMessage) {
87 + setupMessageInteraction(messageContainer);
88 + }
89 +
90 return messageContainer;
91 }
92
70 -function createCopyButton() {
71 - const button = document.createElement("button");
72 - button.className = "copy-button";
73 - button.textContent = "Copy";
74 -
75 - button.addEventListener("click", async function (e) {
76 - e.stopPropagation();
77 - const container = this.closest(".msg-content, .kvps-row, .message-text");
78 - let textToCopy;
79 -
80 - if (container.classList.contains("kvps-row")) {
81 - textToCopy = container.querySelector(".kvps-val").innerText;
82 - } else if (container.classList.contains("message-text")) {
83 - textToCopy = container.querySelector("span").innerText;
84 - } else {
85 - textToCopy = container.querySelector("span").innerText;
86 - }
87 -
88 - try {
89 - await navigator.clipboard.writeText(textToCopy);
90 - const originalText = button.textContent;
91 - button.classList.add("copied");
92 - button.textContent = "Copied!";
93 - setTimeout(() => {
94 - button.classList.remove("copied");
95 - button.textContent = originalText;
96 - }, 2000);
97 - } catch (err) {
98 - console.error("Failed to copy text:", err);
99 - }
100 - });
101 -
102 - return button;
103 -}
104 -
105 -function addCopyButtonToElement(element) {
106 - if (!element.querySelector(".copy-button")) {
107 - element.appendChild(createCopyButton());
108 - }
109 -}
93 +// Legacy copy button functions removed - now using action buttons component
94
95 export function getHandler(type) {
96 switch (type) {
@@ -249,10 +233,8 @@ export function _drawMessage(
233 });
234 }
235
252 - // Ensure copy button exists
253 - if (!contentDiv.querySelector(".copy-button")) {
254 - addCopyButtonToElement(contentDiv);
255 - }
236 + // Ensure action buttons exist
237 + addActionButtonsToElement(contentDiv);
238 adjustMarkdownRender(contentDiv);
239
240 // reapply scroll position or autoscroll
@@ -286,10 +268,8 @@ export function _drawMessage(
268
269 spanElement.innerHTML = convertHTML(content);
270
289 - // Ensure copy button exists
290 - if (!preElement.querySelector(".copy-button")) {
291 - addCopyButtonToElement(preElement);
292 - }
271 + // Ensure action buttons exist
272 + addActionButtonsToElement(preElement);
273
274 // reapply scroll position or autoscroll
275 scroller.reApplyScroll();
@@ -468,7 +448,7 @@ export function drawMessageUser(
448 copyText(content, textDiv);
449 });
450
471 - addCopyButtonToElement(textDiv);
451 + addActionButtonsToElement(textDiv);
452 messageDiv.appendChild(textDiv);
453 }
454
@@ -889,10 +869,10 @@ function drawKvpsIncremental(container, kvps, latex) {
869 pre.appendChild(span);
870 tdiv.appendChild(pre);
871
892 - // Only add copy button if it doesn't exist
872 + // Add action buttons to the row
873 const row = tdiv.closest(".kvps-row");
894 - if (row && !row.querySelector(".copy-button")) {
895 - addCopyButtonToElement(row);
874 + if (row) {
875 + addActionButtonsToElement(row);
876 }
877
878 // Add click handler