diff --git a/.gitignore b/.gitignore
index 17d47e1c8..be8e424b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,4 +39,6 @@ instruments/**
# Global rule to include .gitkeep files anywhere
!**/.gitkeep
-agent_history.gif
\ No newline at end of file
+agent_history.gif
+
+tests/
\ No newline at end of file
diff --git a/webui/components/messages/action-buttons/message-action-buttons.css b/webui/components/messages/action-buttons/message-action-buttons.css
new file mode 100644
index 000000000..e196e1238
--- /dev/null
+++ b/webui/components/messages/action-buttons/message-action-buttons.css
@@ -0,0 +1,245 @@
+/* Message Action Buttons Styles */
+
+/* Main action buttons container */
+.action-buttons {
+ position: sticky;
+ top: var(--spacing-sm);
+ right: 0;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ opacity: 0;
+ transition: opacity var(--transition-speed) ease-in-out;
+ z-index: 10;
+ align-self: flex-start;
+ margin-left: auto;
+ margin-top: var(--spacing-sm);
+ margin-right: var(--spacing-xs);
+}
+
+/* Individual action button */
+.action-button {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ background: var(--color-panel);
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all var(--transition-speed) ease-in-out;
+ color: var(--color-text);
+ padding: 0;
+ font-size: 16px;
+ opacity: 0.7;
+}
+
+.action-button:hover {
+ opacity: 1;
+ background: var(--color-background);
+ border-color: var(--color-primary);
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+.action-button:active {
+ transform: translateY(0);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
+}
+
+/* Material icons in buttons */
+.action-button .material-symbols-outlined {
+ font-size: 16px;
+ line-height: 1;
+ font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
+}
+
+/* Success state */
+.action-button.success {
+ background: #4CAF50;
+ border-color: #4CAF50;
+ color: white;
+}
+
+.action-button.success .material-symbols-outlined {
+ font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 20;
+}
+
+/* Error state */
+.action-button.error {
+ background: var(--color-accent);
+ border-color: var(--color-accent);
+ color: white;
+}
+
+.action-button.error .material-symbols-outlined {
+ font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 20;
+}
+
+/* Speaking state */
+.action-button.speaking {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+ color: white;
+ animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+ 0% { opacity: 1; }
+ 50% { opacity: 0.7; }
+ 100% { opacity: 1; }
+}
+
+/* Tooltips for desktop */
+.action-tooltip {
+ position: absolute;
+ bottom: calc(100% + 8px);
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(0, 0, 0, 0.9);
+ color: white;
+ padding: var(--spacing-xs) var(--spacing-sm);
+ border-radius: 4px;
+ font-size: var(--font-size-small);
+ white-space: nowrap;
+ opacity: 0;
+ visibility: hidden;
+ transition: all var(--transition-speed) ease-in-out;
+ pointer-events: none;
+ z-index: 20;
+ font-family: "Rubik", Arial, Helvetica, sans-serif;
+}
+
+.action-tooltip::after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border: 4px solid transparent;
+ border-top-color: rgba(0, 0, 0, 0.9);
+}
+
+/* Show action buttons on hover for desktop */
+.device-pointer .msg-content:hover .action-buttons,
+.device-pointer .kvps-row:hover .action-buttons,
+.device-pointer .message-text:hover .action-buttons {
+ opacity: 1;
+}
+
+/* Show tooltips on button hover for desktop only */
+.device-pointer .action-button:hover .action-tooltip {
+ opacity: 1;
+ visibility: visible;
+}
+
+/* Touch device behavior - buttons shown when parent has active class */
+.device-touch .msg-content.show-actions .action-buttons,
+.device-touch .kvps-row.show-actions .action-buttons,
+.device-touch .message-text.show-actions .action-buttons {
+ opacity: 1;
+}
+
+/* Hide tooltips on touch devices */
+.device-touch .action-tooltip {
+ display: none;
+}
+
+/* Viewport sticky positioning for long messages */
+.action-buttons.viewport-sticky {
+ position: fixed;
+ top: var(--spacing-md);
+ right: var(--spacing-md);
+ background: var(--color-panel);
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ padding: var(--spacing-xs);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
+ opacity: 1;
+ z-index: 100;
+}
+
+.action-buttons.viewport-sticky .action-button {
+ opacity: 1;
+}
+
+/* Extra bottom padding for mobile browser bars */
+.device-touch .action-buttons {
+ margin-bottom: 70px;
+}
+
+/* Specific positioning adjustments for different message types */
+.message-user .action-buttons {
+ margin-right: 0;
+ align-self: flex-end;
+}
+
+.message-agent .action-buttons,
+.message-agent-response .action-buttons {
+ margin-right: 0;
+}
+
+.message-tool .action-buttons,
+.message-info .action-buttons {
+ margin-top: 0;
+}
+
+.msg-thoughts .action-buttons {
+ margin-top: 0;
+}
+
+/* Ensure message containers are positioned for sticky children */
+.msg-content,
+.kvps-row,
+.message-text {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Responsive adjustments for smaller screens */
+@media (max-width: 768px) {
+ .action-button {
+ width: 36px;
+ height: 36px;
+ }
+
+ .action-button .material-symbols-outlined {
+ font-size: 18px;
+ }
+
+ .action-buttons.viewport-sticky {
+ right: var(--spacing-sm);
+ top: var(--spacing-sm);
+ }
+}
+
+/* High contrast mode support */
+@media (prefers-contrast: high) {
+ .action-button {
+ border-width: 2px;
+ }
+
+ .action-button:hover {
+ border-width: 2px;
+ }
+}
+
+/* Reduced motion support */
+@media (prefers-reduced-motion: reduce) {
+ .action-button,
+ .action-buttons,
+ .action-tooltip {
+ transition: none;
+ }
+
+ .action-button.speaking {
+ animation: none;
+ }
+
+ .action-button:hover {
+ transform: none;
+ }
+}
\ No newline at end of file
diff --git a/webui/components/messages/action-buttons/message-action-buttons.js b/webui/components/messages/action-buttons/message-action-buttons.js
new file mode 100644
index 000000000..37808b3e3
--- /dev/null
+++ b/webui/components/messages/action-buttons/message-action-buttons.js
@@ -0,0 +1,200 @@
+// Message Action Buttons Component
+import { store as speechStore } from "/components/chat/speech/speech-store.js";
+
+// Extract text content based on container type
+function extractTextContent(container) {
+ if (container.classList.contains("kvps-row")) {
+ return container.querySelector(".kvps-val")?.innerText || "";
+ } else if (container.classList.contains("message-text")) {
+ return container.querySelector("span")?.innerText || "";
+ } else {
+ return container.querySelector("span")?.innerText || "";
+ }
+}
+
+// Copy text with fallback for local development
+async function copyToClipboard(text, button) {
+ try {
+ // Try modern clipboard API first
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ showCopyFeedback(button, true);
+ } else {
+ // Fallback for local development or non-HTTPS
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.style.position = "fixed";
+ textarea.style.left = "-999999px";
+ document.body.appendChild(textarea);
+ textarea.select();
+
+ try {
+ document.execCommand("copy");
+ showCopyFeedback(button, true);
+ } catch (err) {
+ console.error("Fallback copy failed:", err);
+ showCopyFeedback(button, false);
+ } finally {
+ document.body.removeChild(textarea);
+ }
+ }
+ } catch (err) {
+ console.error("Copy failed:", err);
+ showCopyFeedback(button, false);
+ }
+}
+
+// Show visual feedback for copy action
+function showCopyFeedback(button, success) {
+
+
+ if (success) {
+
+ button.classList.add("success");
+ } else {
+ button.classList.add("error");
+ }
+
+ setTimeout(() => {
+ button.classList.remove("success", "error");
+ }, 2000);
+}
+
+// Speak text using speech store
+async function speakContent(text, button) {
+ try {
+ // Update button state
+ button.classList.add("speaking");
+ const icon = button.querySelector(".material-symbols-outlined");
+ const originalIcon = icon.textContent;
+
+ // Check if already speaking
+ if (speechStore.isSpeaking && speechStore.speakingText === text) {
+ // Stop speaking
+ speechStore.stopSpeaking();
+ icon.textContent = originalIcon;
+ button.classList.remove("speaking");
+ return;
+ }
+
+ // Clean and speak text
+ const cleanedText = speechStore.cleanText ? speechStore.cleanText(text) : text;
+
+ // Update icon to show speaking state
+ icon.textContent = "stop_circle";
+
+ // Start speaking
+ await speechStore.speak(cleanedText);
+
+ // Reset button state when done
+ icon.textContent = "volume_up";
+ button.classList.remove("speaking");
+
+ } catch (err) {
+ console.error("Speech failed:", err);
+ button.classList.remove("speaking");
+
+ // Show error feedback
+ button.classList.add("error");
+
+ setTimeout(() => {
+ button.classList.remove("error");
+ }, 2000);
+ }
+}
+
+// Create action buttons for a message or KVP
+export function createActionButtons() {
+ const container = document.createElement("div");
+ container.className = "action-buttons";
+
+ // Copy button
+ const copyButton = document.createElement("button");
+ copyButton.className = "action-button copy-action";
+ copyButton.setAttribute("aria-label", "Copy text");
+ copyButton.innerHTML = `
+ content_copy
+ `;
+
+ // Speak button
+ const speakButton = document.createElement("button");
+ speakButton.className = "action-button speak-action";
+ speakButton.setAttribute("aria-label", "Speak text");
+ speakButton.innerHTML = `
+ volume_up
+ `;
+
+ // Add event listeners
+ copyButton.addEventListener("click", async function(e) {
+ e.stopPropagation();
+ const messageContainer = this.closest(".msg-content, .kvps-row, .message-text");
+ const textToCopy = extractTextContent(messageContainer);
+ await copyToClipboard(textToCopy, this);
+ });
+
+ speakButton.addEventListener("click", async function(e) {
+ e.stopPropagation();
+ const messageContainer = this.closest(".msg-content, .kvps-row, .message-text");
+ const textToSpeak = extractTextContent(messageContainer);
+ await speakContent(textToSpeak, this);
+ });
+
+ // Add buttons to container
+ container.appendChild(copyButton);
+ container.appendChild(speakButton);
+
+ return container;
+}
+
+// Add action buttons to an element
+export function addActionButtonsToElement(element) {
+ // Remove any existing action buttons first
+ const existingButtons = element.querySelector(".action-buttons");
+ if (existingButtons) {
+ existingButtons.remove();
+ }
+
+
+ // Create and add new action buttons
+ const actionButtons = createActionButtons();
+ element.appendChild(actionButtons);
+
+ // Set up intersection observer for viewport detection
+ setupViewportObserver(element);
+}
+
+// Set up intersection observer for keeping buttons in viewport
+function setupViewportObserver(element) {
+ const actionButtons = element.querySelector(".action-buttons");
+ if (!actionButtons) return;
+
+ // Create observer for viewport detection
+ const observer = new IntersectionObserver((entries) => {
+ entries.forEach(entry => {
+ const buttons = entry.target.querySelector(".action-buttons");
+ if (buttons && !entry.isIntersecting) {
+
+ } else if (buttons) {
+
+ }
+ });
+ }, {
+ root: null,
+ rootMargin: "-50px 0px",
+ threshold: 0
+ });
+
+ // Start observing
+ observer.observe(element);
+
+ // Store observer reference for cleanup
+ element._actionButtonsObserver = observer;
+}
+
+// Cleanup function to disconnect observer
+export function cleanupActionButtons(element) {
+ if (element._actionButtonsObserver) {
+ element._actionButtonsObserver.disconnect();
+ delete element._actionButtonsObserver;
+ }
+}
\ No newline at end of file
diff --git a/webui/css/messages.css b/webui/css/messages.css
index 291fb7ddd..c731112dd 100644
--- a/webui/css/messages.css
+++ b/webui/css/messages.css
@@ -279,72 +279,11 @@
opacity: 1;
}
-/* Copy button styles */
-.copy-button {
- position: absolute;
- right: 0;
- top: var(--spacing-sm);
- background: none;
- border: none;
- padding: var(--spacing-xs) var(--spacing-sm);
- padding-right: 0;
- cursor: pointer;
- text-decoration: underline;
- text-wrap: nowrap;
- opacity: 0;
- -webkit-transition: opacity var(--transition-speed) ease-in-out;
- transition: opacity var(--transition-speed) ease-in-out;
- color: inherit;
- font-size: 12px;
- font-family: "Rubik", Arial, Helvetica, sans-serif;
-}
-
-.copy-button:hover {
- opacity: 0.8 !important;
-}
-
-.msg-content:hover .copy-button,
-.kvps-row:hover .copy-button,
-.message-text:hover .copy-button {
- opacity: 0.6;
-}
-
-.copy-button.copied {
- font-family: "Rubik", Arial, Helvetica, sans-serif !important;
- opacity: 1 !important;
-}
-
-.msg-thoughts .copy-button {
- top: -12px !important;
-}
-
-.message-user .copy-button {
- top: -15px !important;
- left: -13px !important;
- right: 99% !important;
-}
-
-.message-agent-response .copy-button {
- top: -22px !important;
- right: 0 !important;
- padding-right: 0px !important;
-}
-
-.message-info .copy-button {
- top: -22px !important;
- right: 0 !important;
- padding-right: 0px !important;
-}
-
-.message-tool .copy-button {
- top: -12px !important;
- right: 0 !important;
-}
-
-.msg-output .copy-button {
- top: -6px !important;
- right: 0 !important;
-}
+/* Legacy copy button styles - DEPRECATED
+ * These styles have been replaced by the new action buttons component
+ * located at /components/messages/action-buttons/message-action-buttons.css
+ * Remove this section after confirming all functionality works with action buttons
+ */
/* Make message containers relative for absolute positioning of copy buttons */
.msg-content,
diff --git a/webui/index.html b/webui/index.html
index ea16e736d..73a905a96 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -8,6 +8,7 @@
+
diff --git a/webui/js/message-interactions.js b/webui/js/message-interactions.js
new file mode 100644
index 000000000..208b6dcd3
--- /dev/null
+++ b/webui/js/message-interactions.js
@@ -0,0 +1,243 @@
+// Message Interactions Handler for Touch/Pointer Devices
+import { getInputType } from "./device.js";
+
+// Track which messages currently have visible action buttons
+const visibleActionStates = new WeakSet();
+
+// Initialize interaction handlers for touch devices
+export function initializeMessageInteractions() {
+ const inputType = getInputType();
+
+ if (inputType === "touch") {
+ setupTouchInteractions();
+ }
+ // Pointer devices use hover states from CSS, no JS needed
+}
+
+// Set up touch device interactions
+function setupTouchInteractions() {
+ // Use event delegation on the chat history container
+ const chatHistory = document.getElementById("chat-history");
+ if (!chatHistory) return;
+
+ // Handle touch interactions for showing/hiding action buttons
+ chatHistory.addEventListener("click", handleTouchInteraction, { passive: false });
+
+ // Handle clicks outside messages to hide action buttons
+ document.addEventListener("click", handleOutsideClick, { passive: true });
+
+ // Prevent text selection when tapping action buttons area
+ chatHistory.addEventListener("selectstart", handleSelectStart, { passive: false });
+}
+
+// Handle touch interactions on messages
+function handleTouchInteraction(e) {
+ // Find the closest message container
+ const messageContainer = e.target.closest(".msg-content, .kvps-row, .message-text");
+ if (!messageContainer) return;
+
+ // If clicking on action buttons, let them handle their own events
+ if (e.target.closest(".action-buttons")) {
+ return;
+ }
+
+ // Toggle action buttons visibility
+ toggleActionButtons(messageContainer, e);
+}
+
+// Handle clicks outside of messages to hide action buttons
+function handleOutsideClick(e) {
+ // If clicking on a message or action button, don't hide
+ if (e.target.closest(".msg-content, .kvps-row, .message-text, .action-buttons")) {
+ return;
+ }
+
+ // Hide all visible action buttons
+ hideAllActionButtons();
+}
+
+// Prevent text selection when interacting with action area
+function handleSelectStart(e) {
+ // Allow text selection unless we're in the action buttons area
+ const actionButtons = e.target.closest(".action-buttons");
+ const messageContainer = e.target.closest(".msg-content, .kvps-row, .message-text");
+
+ if (actionButtons) {
+ e.preventDefault();
+ return false;
+ }
+
+ // If a message has visible action buttons, prevent selection to avoid conflicts
+ if (messageContainer && visibleActionStates.has(messageContainer)) {
+ // Allow selection after a short delay to distinguish from tap-to-show-buttons
+ setTimeout(() => {
+ if (visibleActionStates.has(messageContainer)) {
+ // Still visible, user might want to select text
+ return true;
+ }
+ }, 200);
+ }
+}
+
+// Toggle action buttons visibility for a message
+export function toggleActionButtons(messageContainer, event = null) {
+ if (!messageContainer) return;
+
+ const isCurrentlyVisible = visibleActionStates.has(messageContainer);
+
+ if (isCurrentlyVisible) {
+ hideActionButtons(messageContainer);
+ } else {
+ // Hide all other visible action buttons first
+ hideAllActionButtons();
+
+ // Show action buttons for this message
+ showActionButtons(messageContainer);
+ }
+
+ // Prevent event bubbling to avoid triggering document click handler
+ if (event) {
+ event.stopPropagation();
+ }
+}
+
+// Show action buttons for a message
+export function showActionButtons(messageContainer) {
+ if (!messageContainer) return;
+
+ messageContainer.classList.add("show-actions");
+ visibleActionStates.add(messageContainer);
+
+ // Add a slight delay to prevent immediate hiding from document click
+ setTimeout(() => {
+ messageContainer.setAttribute("data-actions-visible", "true");
+ }, 50);
+}
+
+// Hide action buttons for a message
+export function hideActionButtons(messageContainer) {
+ if (!messageContainer) return;
+
+ messageContainer.classList.remove("show-actions");
+ messageContainer.removeAttribute("data-actions-visible");
+ visibleActionStates.delete(messageContainer);
+}
+
+// Hide all visible action buttons
+export function hideAllActionButtons() {
+ const chatHistory = document.getElementById("chat-history");
+ if (!chatHistory) return;
+
+ // Find all messages with visible action buttons
+ const visibleMessages = chatHistory.querySelectorAll(".show-actions");
+ visibleMessages.forEach(message => {
+ hideActionButtons(message);
+ });
+}
+
+// Setup interaction for a specific message (called when message is created)
+export function setupMessageInteraction(messageContainer) {
+ if (!messageContainer) return;
+
+ const inputType = getInputType();
+
+ if (inputType === "touch") {
+ // For touch devices, add click handler
+ messageContainer.addEventListener("click", (e) => {
+ // Only handle if not clicking on action buttons
+ if (!e.target.closest(".action-buttons")) {
+ toggleActionButtons(messageContainer, e);
+ }
+ }, { passive: false });
+
+ // Prevent context menu on long press for action area
+ messageContainer.addEventListener("contextmenu", (e) => {
+ if (e.target.closest(".action-buttons")) {
+ e.preventDefault();
+ }
+ });
+ }
+
+ // For both touch and pointer devices, handle keyboard navigation
+ setupKeyboardNavigation(messageContainer);
+}
+
+// Set up keyboard navigation for accessibility
+function setupKeyboardNavigation(messageContainer) {
+ const actionButtons = messageContainer.querySelector(".action-buttons");
+ if (!actionButtons) return;
+
+ const buttons = actionButtons.querySelectorAll(".action-button");
+
+ buttons.forEach((button, index) => {
+ button.setAttribute("tabindex", "0");
+
+ button.addEventListener("keydown", (e) => {
+ switch (e.key) {
+ case "Enter":
+ case " ":
+ e.preventDefault();
+ button.click();
+ break;
+ case "ArrowLeft":
+ e.preventDefault();
+ const prevButton = buttons[index - 1] || buttons[buttons.length - 1];
+ prevButton.focus();
+ break;
+ case "ArrowRight":
+ e.preventDefault();
+ const nextButton = buttons[index + 1] || buttons[0];
+ nextButton.focus();
+ break;
+ case "Escape":
+ hideActionButtons(messageContainer);
+ messageContainer.focus();
+ break;
+ }
+ });
+ });
+}
+
+// Debounced scroll handler to manage viewport sticky buttons
+let scrollTimeout;
+export function handleScroll() {
+ clearTimeout(scrollTimeout);
+ scrollTimeout = setTimeout(() => {
+ // Check if any viewport-sticky buttons should be hidden
+ const stickyButtons = document.querySelectorAll(".action-buttons.viewport-sticky");
+ stickyButtons.forEach(buttons => {
+ const parent = buttons.closest(".msg-content, .kvps-row, .message-text");
+ if (parent) {
+ const rect = parent.getBoundingClientRect();
+ const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
+
+ if (isVisible) {
+ buttons.classList.remove("viewport-sticky");
+ }
+ }
+ });
+ }, 100);
+}
+
+// Initialize scroll handler
+export function initializeScrollHandler() {
+ window.addEventListener("scroll", handleScroll, { passive: true });
+ window.addEventListener("resize", handleScroll, { passive: true });
+}
+
+// Cleanup function
+export function cleanupMessageInteractions() {
+ window.removeEventListener("scroll", handleScroll);
+ window.removeEventListener("resize", handleScroll);
+
+ const chatHistory = document.getElementById("chat-history");
+ if (chatHistory) {
+ chatHistory.removeEventListener("click", handleTouchInteraction);
+ chatHistory.removeEventListener("selectstart", handleSelectStart);
+ }
+
+ document.removeEventListener("click", handleOutsideClick);
+
+ // Clear visible states
+ visibleActionStates.clear?.();
+}
\ No newline at end of file
diff --git a/webui/js/messages.js b/webui/js/messages.js
index f6e49ef8a..e39193b9c 100644
--- a/webui/js/messages.js
+++ b/webui/js/messages.js
@@ -1,22 +1,39 @@
-// copy button
+// message actions and components
import { openImageModal } from "./image_modal.js";
import { marked } from "../vendor/marked/marked.esm.js";
import { store as _messageResizeStore } from "/components/messages/resize/message-resize-store.js"; // keep here, required in html
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
+import { addActionButtonsToElement, cleanupActionButtons } from "/components/messages/action-buttons/message-action-buttons.js";
+import { setupMessageInteraction, initializeMessageInteractions, initializeScrollHandler } from "./message-interactions.js";
const chatHistory = document.getElementById("chat-history");
let messageGroup = null;
+// Initialize message interactions when DOM is ready
+let interactionsInitialized = false;
+function ensureInteractionsInitialized() {
+ if (!interactionsInitialized && chatHistory) {
+ initializeMessageInteractions();
+ initializeScrollHandler();
+ interactionsInitialized = true;
+ }
+}
+
export function setMessage(id, type, heading, content, temp, kvps = null) {
+ // Ensure interactions are initialized
+ ensureInteractionsInitialized();
+
// Search for the existing message container by id
let messageContainer = document.getElementById(`message-${id}`);
+ let isNewMessage = false;
if (messageContainer) {
// Don't clear innerHTML - we'll do incremental updates
// messageContainer.innerHTML = "";
} else {
// Create a new container if not found
+ isNewMessage = true;
const sender = type === "user" ? "user" : "ai";
messageContainer = document.createElement("div");
messageContainer.id = `message-${id}`;
@@ -64,49 +81,16 @@ export function setMessage(id, type, heading, content, temp, kvps = null) {
messageGroup.appendChild(messageContainer);
chatHistory.appendChild(messageGroup);
}
+
+ // Set up interactions for new messages
+ if (isNewMessage) {
+ setupMessageInteraction(messageContainer);
+ }
+
return messageContainer;
}
-function createCopyButton() {
- const button = document.createElement("button");
- button.className = "copy-button";
- button.textContent = "Copy";
-
- button.addEventListener("click", async function (e) {
- e.stopPropagation();
- const container = this.closest(".msg-content, .kvps-row, .message-text");
- let textToCopy;
-
- if (container.classList.contains("kvps-row")) {
- textToCopy = container.querySelector(".kvps-val").innerText;
- } else if (container.classList.contains("message-text")) {
- textToCopy = container.querySelector("span").innerText;
- } else {
- textToCopy = container.querySelector("span").innerText;
- }
-
- try {
- await navigator.clipboard.writeText(textToCopy);
- const originalText = button.textContent;
- button.classList.add("copied");
- button.textContent = "Copied!";
- setTimeout(() => {
- button.classList.remove("copied");
- button.textContent = originalText;
- }, 2000);
- } catch (err) {
- console.error("Failed to copy text:", err);
- }
- });
-
- return button;
-}
-
-function addCopyButtonToElement(element) {
- if (!element.querySelector(".copy-button")) {
- element.appendChild(createCopyButton());
- }
-}
+// Legacy copy button functions removed - now using action buttons component
export function getHandler(type) {
switch (type) {
@@ -249,10 +233,8 @@ export function _drawMessage(
});
}
- // Ensure copy button exists
- if (!contentDiv.querySelector(".copy-button")) {
- addCopyButtonToElement(contentDiv);
- }
+ // Ensure action buttons exist
+ addActionButtonsToElement(contentDiv);
adjustMarkdownRender(contentDiv);
// reapply scroll position or autoscroll
@@ -286,10 +268,8 @@ export function _drawMessage(
spanElement.innerHTML = convertHTML(content);
- // Ensure copy button exists
- if (!preElement.querySelector(".copy-button")) {
- addCopyButtonToElement(preElement);
- }
+ // Ensure action buttons exist
+ addActionButtonsToElement(preElement);
// reapply scroll position or autoscroll
scroller.reApplyScroll();
@@ -468,7 +448,7 @@ export function drawMessageUser(
copyText(content, textDiv);
});
- addCopyButtonToElement(textDiv);
+ addActionButtonsToElement(textDiv);
messageDiv.appendChild(textDiv);
}
@@ -889,10 +869,10 @@ function drawKvpsIncremental(container, kvps, latex) {
pre.appendChild(span);
tdiv.appendChild(pre);
- // Only add copy button if it doesn't exist
+ // Add action buttons to the row
const row = tdiv.closest(".kvps-row");
- if (row && !row.querySelector(".copy-button")) {
- addCopyButtonToElement(row);
+ if (row) {
+ addActionButtonsToElement(row);
}
// Add click handler