diff --git a/webui/components/messages/action-buttons/simple-action-buttons.css b/webui/components/messages/action-buttons/simple-action-buttons.css
index ab148370e..7d1310ea5 100644
--- a/webui/components/messages/action-buttons/simple-action-buttons.css
+++ b/webui/components/messages/action-buttons/simple-action-buttons.css
@@ -1,117 +1,82 @@
-/* Simplified Action Buttons - Keeping the Great Look & Feel */
+/* ===========================================
+ Step Action Buttons - Always visible, icon-only
+ Used in process steps for view details, copy, speak
+ =========================================== */
-/* Main action buttons container - precise positioning */
-.action-buttons {
- position: sticky;
- height:0;
- width:fit-content;
- overflow: visible;
- top: 0.3em;
- margin-right:0.1em;
- margin-left: auto;
- display: none;
+.step-action-buttons {
+ display: flex;
flex-direction: row;
- gap: 0;
- border-radius: 6px;
- transition: opacity var(--transition-speed) ease-in-out;
- z-index: 10;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm) 0;
+ opacity: 1;
+ animation: none;
}
-/* Individual action button - precise hit area */
-.action-buttons .action-button {
+.step-action-buttons .action-button {
display: flex;
align-items: center;
justify-content: center;
- width: 26px;
- height: 26px;
+ background: transparent;
+ border: none;
+ padding: 2px;
cursor: pointer;
- background-color: var(--color-chat-background);
- border: 1px solid var(--color-border);
- /* transition: background-color var(--transition-speed) ease-in-out; */
- color: var(--color-text);
- padding: 0;
- font-size: 14px;
- /* opacity: 0.7; */
- margin: 0;
+ transition: color 0.15s ease;
}
-.action-buttons .action-button:first-child {
- border-radius: 5px 0 0 5px;
-}
-
-.action-buttons .action-button:last-child {
- border-radius: 0 5px 5px 0;
-}
-
-.action-buttons .action-button:hover {
- opacity: 1;
- background: var(--color-panel);
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
-}
-
-.action-buttons .action-button:active {
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
-}
-
-/* Material icons - same as original */
-.action-buttons .action-button .material-symbols-outlined {
- font-size: 16px;
+.step-action-buttons .action-button .material-symbols-outlined {
+ font-size: 0.85rem;
line-height: 1;
+ color: var(--color-message-text);
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
+ transition: color 0.15s ease;
}
-/* Success state - same as original */
-.action-buttons .action-button.success {
- background: #4CAF50;
- border-color: #4CAF50;
- color: white;
+.step-action-buttons .action-button:hover .material-symbols-outlined {
+ color: var(--color-text);
}
-.action-buttons .action-button.success .material-symbols-outlined {
+/* Success state for step buttons */
+.step-action-buttons .action-button.success .material-symbols-outlined {
+ color: #4CAF50;
font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 20;
}
-/* Error state - same as original */
-.action-buttons .action-button.error {
- background: var(--color-accent);
- border-color: var(--color-accent);
- color: white;
-}
-
-.action-buttons .action-button.error .material-symbols-outlined {
+/* Error state for step buttons */
+.step-action-buttons .action-button.error .material-symbols-outlined {
+ color: var(--color-accent);
font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 20;
}
-/* Speaking state - same as original */
-.action-buttons .action-button.speaking {
- background: var(--color-primary);
- border-color: var(--color-primary);
- color: white;
- animation: pulse 2s infinite;
-}
+/* ===========================================
+ User Message Action Buttons - Hover behavior
+ Right-aligned, hidden by default on pointer devices, show on hover
+ Always visible on touch devices
+ =========================================== */
-@keyframes pulse {
- 0% { opacity: 1; }
- 50% { opacity: 0.7; }
- 100% { opacity: 1; }
-}
-
-/* Show action buttons on hover - simplified, no device detection needed */
-.msg-content:hover .action-buttons,
-/* .kvps-row:hover .action-buttons, */
-.message-text:hover .action-buttons,
-.kvps-val:hover .action-buttons,
-.message-body:hover > .action-buttons,
-.error-content-inner:hover .action-buttons {
- display: flex;
- animation: fadeInAfterDelay 0.3s ease-in-out;
- animation-delay: 0.3s;
- animation-fill-mode: forwards;
+/* Base styling for user message action buttons */
+.message-user .step-action-buttons {
+ justify-content: flex-end;
+ width: 100%;
opacity: 0;
+ transition: opacity 0.2s ease-in-out;
+ margin-top: var(--spacing-sm);
}
-/* Animation to fade in action buttons after delay */
-@keyframes fadeInAfterDelay {
- 0% { opacity: 0; }
- 100% { opacity: 1; }
+/* Hide by default for user messages on pointer devices */
+.device-pointer .message-user .step-action-buttons {
+ opacity: 0;
+ pointer-events: none;
+}
+
+/* Show on hover for user messages on pointer devices */
+.device-pointer .message-user:hover .step-action-buttons {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+/* Always show for user messages on touch devices */
+.device-touch .message-user .step-action-buttons {
+ opacity: 1;
+ pointer-events: auto;
}
diff --git a/webui/components/messages/action-buttons/simple-action-buttons.js b/webui/components/messages/action-buttons/simple-action-buttons.js
index c19682d65..0e5aa12ff 100644
--- a/webui/components/messages/action-buttons/simple-action-buttons.js
+++ b/webui/components/messages/action-buttons/simple-action-buttons.js
@@ -1,134 +1,124 @@
-// Simplified Message Action Buttons - Keeping the Great Look & Feel
+// Message Action Buttons - Copy and Speak functionality
import { store as speechStore } from "/components/chat/speech/speech-store.js";
-// Extract text content from different message types
-function getTextContent(element,html=false) {
- // Get all children except action buttons
- const textParts = [];
- // Loop through all child elements
- for (const child of element.children) {
- // Skip action buttons
- if (child.classList.contains("action-buttons")) continue;
- // If the child is an image, copy its src URL
- if (child.tagName && child.tagName.toLowerCase() === "img") {
- if (child.src) textParts.push(child.src);
- continue;
- }
- // Get text content from the child
- const text = (html ? child.innerHTML : child.innerText) || "";
- if (text.trim()) {
- textParts.push(text.trim());
- }
+/**
+ * Copy text to clipboard with fallback for non-secure contexts
+ */
+async function copyToClipboard(text) {
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ } else {
+ // Fallback for local dev / non-secure contexts
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.style.cssText = "position:fixed;left:-9999px";
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand("copy");
+ document.body.removeChild(textarea);
}
- // Join all text parts with double newlines
- return textParts.join("\n\n");
}
+/**
+ * Show visual feedback on a button (success/error state)
+ */
+function showButtonFeedback(button, success, originalIcon) {
+ const icon = button.querySelector(".material-symbols-outlined");
+ if (!icon) return;
+
+ icon.textContent = success ? "check" : "error";
+ button.classList.add(success ? "success" : "error");
+
+ setTimeout(() => {
+ icon.textContent = originalIcon;
+ button.classList.remove("success", "error");
+ }, 2000);
+}
-// Create and add action buttons to element
-export function addActionButtonsToElement(element) {
+/**
+ * Create action button element
+ */
+function createButton(iconName, label, className) {
+ const btn = document.createElement("button");
+ btn.className = `action-button ${className}`;
+ btn.setAttribute("aria-label", label);
+ btn.setAttribute("title", label);
+ btn.innerHTML = `${iconName}`;
+ return btn;
+}
+
+/**
+ * Add action buttons (copy, speak, optionally view details) to an element
+ *
+ * @param {HTMLElement} container - Element to append buttons to
+ * @param {Object} options - Configuration
+ * @param {string|Function|HTMLElement} options.contentRef - Text content source:
+ * - string: Use directly
+ * - Function: Call to get text
+ * - HTMLElement: Get innerText from element
+ * @param {Function} [options.onViewDetails] - If provided, adds view details button
+ */
+export function addActionButtonsToElement(container, options = {}) {
+ const { contentRef, onViewDetails } = options;
+
// Skip if buttons already exist
- if (element.querySelector(".action-buttons")) return;
-
- // Create container with same styling as original
- const container = document.createElement("div");
- container.className = "action-buttons";
-
- // Copy button - matches original design
- const copyBtn = document.createElement("button");
- copyBtn.className = "action-button copy-action";
- copyBtn.setAttribute("aria-label", "Copy text");
- copyBtn.innerHTML =
- 'content_copy';
-
+ if (container.querySelector(".step-action-buttons")) return;
+
+ // Create buttons container
+ const buttonsDiv = document.createElement("div");
+ buttonsDiv.className = "step-action-buttons";
+
+ // Helper to resolve content from contentRef
+ const getContent = () => {
+ if (typeof contentRef === "string") return contentRef;
+ if (typeof contentRef === "function") return contentRef();
+ if (contentRef instanceof HTMLElement) return contentRef.innerText || "";
+ return "";
+ };
+
+ // View Details button (optional)
+ if (onViewDetails) {
+ const viewBtn = createButton("open_in_full", "View details", "view-details-action");
+ viewBtn.onclick = (e) => {
+ e.stopPropagation();
+ onViewDetails();
+ };
+ buttonsDiv.appendChild(viewBtn);
+ }
+
+ // Copy button
+ const copyBtn = createButton("content_copy", "Copy text", "copy-action");
copyBtn.onclick = async (e) => {
e.stopPropagation();
-
- // Check if the button container is still fading in (opacity < 0.5)
- if (parseFloat(window.getComputedStyle(container).opacity) < 0.5) return; // Don't proceed if still fading in
-
- const text = getTextContent(element);
- const icon = copyBtn.querySelector(".material-symbols-outlined");
-
+ const text = getContent();
+ if (!text) return;
+
try {
- // Try modern clipboard API
- if (navigator.clipboard && window.isSecureContext) {
- await navigator.clipboard.writeText(text);
- } else {
- // Fallback for local dev
- const textarea = document.createElement("textarea");
- textarea.value = text;
- textarea.style.position = "fixed";
- textarea.style.left = "-999999px";
- document.body.appendChild(textarea);
- textarea.select();
- document.execCommand("copy");
- document.body.removeChild(textarea);
- }
-
- // Visual feedback
- icon.textContent = "check";
- copyBtn.classList.add("success");
- setTimeout(() => {
- icon.textContent = "content_copy";
- copyBtn.classList.remove("success");
- }, 2000);
+ await copyToClipboard(text);
+ showButtonFeedback(copyBtn, true, "content_copy");
} catch (err) {
console.error("Copy failed:", err);
- icon.textContent = "error";
- copyBtn.classList.add("error");
- setTimeout(() => {
- icon.textContent = "content_copy";
- copyBtn.classList.remove("error");
- }, 2000);
+ showButtonFeedback(copyBtn, false, "content_copy");
}
};
-
- // Speak button - matches original design
- const speakBtn = document.createElement("button");
- speakBtn.className = "action-button speak-action";
- speakBtn.setAttribute("aria-label", "Speak text");
- speakBtn.innerHTML =
- 'volume_up';
-
+ buttonsDiv.appendChild(copyBtn);
+
+ // Speak button
+ const speakBtn = createButton("volume_up", "Speak text", "speak-action");
speakBtn.onclick = async (e) => {
e.stopPropagation();
-
- // Check if the button container is still fading in (opacity < 0.5)
- if (parseFloat(window.getComputedStyle(container).opacity) < 0.5) return; // Don't proceed if still fading in
-
- const text = getTextContent(element);
- const icon = speakBtn.querySelector(".material-symbols-outlined");
-
- if (!text || text.trim().length === 0) return;
-
+ const text = getContent();
+ if (!text?.trim()) return;
+
try {
- // Visual feedback
- icon.textContent = "check";
- speakBtn.classList.add("success");
- setTimeout(() => {
- icon.textContent = "volume_up";
- speakBtn.classList.remove("success");
- }, 2000);
-
- // Use speech store
+ showButtonFeedback(speakBtn, true, "volume_up");
await speechStore.speak(text);
} catch (err) {
console.error("Speech failed:", err);
- icon.textContent = "error";
- speakBtn.classList.add("error");
- setTimeout(() => {
- icon.textContent = "volume_up";
- speakBtn.classList.remove("error");
- }, 2000);
+ showButtonFeedback(speakBtn, false, "volume_up");
}
};
-
- container.append(copyBtn, speakBtn);
- // Add container as the first child instead of appending it
- if (element.firstChild) {
- element.insertBefore(container, element.firstChild);
- } else {
- element.appendChild(container);
- }
+ buttonsDiv.appendChild(speakBtn);
+
+ container.appendChild(buttonsDiv);
}
diff --git a/webui/css/messages.css b/webui/css/messages.css
index a79e488a6..12962637f 100644
--- a/webui/css/messages.css
+++ b/webui/css/messages.css
@@ -486,12 +486,6 @@
opacity: 1;
}
-/* 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,
.kvps-row,
@@ -766,8 +760,8 @@
.message-group-right {
width: 100%;
justify-content: end;
- margin-top: 5em;
- margin-bottom:5em;
+ margin-top: 4em;
+ margin-bottom:4em;
}
.message-container {
diff --git a/webui/js/messages.js b/webui/js/messages.js
index 1b1f426cc..f1254921f 100644
--- a/webui/js/messages.js
+++ b/webui/js/messages.js
@@ -335,8 +335,8 @@ export function _drawMessage(
});
}
- // Ensure action buttons exist
- // addActionButtonsToElement(bodyDiv);
+ // Ensure action buttons exist - pass content directly
+ addActionButtonsToElement(bodyDiv, { contentRef: content });
adjustMarkdownRender(contentDiv);
} else {
@@ -360,8 +360,8 @@ export function _drawMessage(
spanElement.innerHTML = convertHTML(content);
- // Ensure action buttons exist
- // addActionButtonsToElement(bodyDiv);
+ // Ensure action buttons exist - pass content directly
+ addActionButtonsToElement(bodyDiv, { contentRef: content });
}
} else {
@@ -550,7 +550,6 @@ export function drawMessageUser(
textDiv.appendChild(spanElement);
}
spanElement.innerHTML = escapeHTML(content);
- // addActionButtonsToElement(textDiv);
} else {
if (textDiv) textDiv.remove();
}
@@ -613,6 +612,9 @@ export function drawMessageUser(
} else {
if (attachmentsContainer) attachmentsContainer.remove();
}
+
+ // Add action buttons below text and attachments (hover for pointer, always for touch - via CSS)
+ addActionButtonsToElement(messageDiv, { contentRef: content });
// The messageDiv is already appended or updated, no need to append again
}
@@ -1496,9 +1498,9 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
toolName: toolNameToUse
};
- // Add "View Details" button for full modal view (reads fresh data from step._stepData)
- const viewDetailsBtn = createViewDetailsButton(step);
- detail.appendChild(viewDetailsBtn);
+ // Add step action buttons (view details, copy, speak) for full modal view
+ const stepActionBtns = createStepActionButtons(step);
+ detail.appendChild(stepActionBtns);
step.appendChild(detail);
@@ -1950,26 +1952,37 @@ function renderThoughts(container, value) {
}
/**
- * Create "View Details" button for opening step detail modal
+ * Create step action buttons (view details, copy, speak) using unified action buttons
* @param {HTMLElement} stepElement - The step DOM element containing _stepData property
*/
-function createViewDetailsButton(stepElement) {
+function createStepActionButtons(stepElement) {
const btnContainer = document.createElement("div");
btnContainer.classList.add("step-detail-actions");
- const btn = document.createElement("button");
- btn.classList.add("btn", "text-button");
- btn.innerHTML = 'open_in_full View Details';
- btn.title = "Open full step details in modal";
-
- btn.addEventListener("click", (e) => {
- e.stopPropagation();
- // Read fresh data from the step element at click time
- const freshData = stepElement._stepData || {};
- stepDetailStore.showStepDetail(freshData);
+ // Use unified action buttons with step-specific options
+ addActionButtonsToElement(btnContainer, {
+ contentRef: () => {
+ // Get text content from step data at action time
+ const data = stepElement._stepData || {};
+ const parts = [];
+ if (data.heading) parts.push(data.heading);
+ if (data.content) parts.push(data.content);
+ if (data.kvps) {
+ for (const [key, value] of Object.entries(data.kvps)) {
+ if (key === "reasoning" || key === "finished" || key === "attachments") continue;
+ const valStr = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
+ parts.push(`${key}: ${valStr}`);
+ }
+ }
+ return parts.join("\n\n");
+ },
+ onViewDetails: () => {
+ // Read fresh data from the step element at click time
+ const freshData = stepElement._stepData || {};
+ stepDetailStore.showStepDetail(freshData);
+ }
});
- btnContainer.appendChild(btn);
return btnContainer;
}