From bcee4768c99ec6494561999043e95dce9bfaec7e Mon Sep 17 00:00:00 2001 From: 3clyp50 Date: Wed, 21 Jan 2026 14:56:03 +0100 Subject: [PATCH] action buttons redesign (detail-copy-speak) --- .../action-buttons/simple-action-buttons.css | 145 +++++------- .../action-buttons/simple-action-buttons.js | 212 +++++++++--------- webui/css/messages.css | 10 +- webui/js/messages.js | 55 +++-- 4 files changed, 192 insertions(+), 230 deletions(-) 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; }