action buttons redesign (detail-copy-speak)

3clyp50 committed Jan 21, 2026 at 14:56 UTC bcee4768c99ec6494561999043e95dce9bfaec7e
4 files changed +190 -228
webui/components/messages/action-buttons/simple-action-buttons.css
+53 -88
@@ -1,117 +1,82 @@
1 -/* Simplified Action Buttons - Keeping the Great Look & Feel */
1 +/* ===========================================
2 + Step Action Buttons - Always visible, icon-only
3 + Used in process steps for view details, copy, speak
4 + =========================================== */
5
3 -/* Main action buttons container - precise positioning */
4 -.action-buttons {
5 - position: sticky;
6 - height:0;
7 - width:fit-content;
8 - overflow: visible;
9 - top: 0.3em;
10 - margin-right:0.1em;
11 - margin-left: auto;
12 - display: none;
6 +.step-action-buttons {
7 + display: flex;
8 flex-direction: row;
14 - gap: 0;
15 - border-radius: 6px;
16 - transition: opacity var(--transition-speed) ease-in-out;
17 - z-index: 10;
9 + align-items: center;
10 + gap: var(--spacing-sm);
11 + padding: var(--spacing-sm) 0;
12 + opacity: 1;
13 + animation: none;
14 }
15
20 -/* Individual action button - precise hit area */
21 -.action-buttons .action-button {
16 +.step-action-buttons .action-button {
17 display: flex;
18 align-items: center;
19 justify-content: center;
25 - width: 26px;
26 - height: 26px;
20 + background: transparent;
21 + border: none;
22 + padding: 2px;
23 cursor: pointer;
28 - background-color: var(--color-chat-background);
29 - border: 1px solid var(--color-border);
30 - /* transition: background-color var(--transition-speed) ease-in-out; */
31 - color: var(--color-text);
32 - padding: 0;
33 - font-size: 14px;
34 - /* opacity: 0.7; */
35 - margin: 0;
36 -}
37 -
38 -.action-buttons .action-button:first-child {
39 - border-radius: 5px 0 0 5px;
24 + transition: color 0.15s ease;
25 }
26
42 -.action-buttons .action-button:last-child {
43 - border-radius: 0 5px 5px 0;
44 -}
45 -
46 -.action-buttons .action-button:hover {
47 - opacity: 1;
48 - background: var(--color-panel);
49 - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
50 -}
51 -
52 -.action-buttons .action-button:active {
53 - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
54 -}
55 -
56 -/* Material icons - same as original */
57 -.action-buttons .action-button .material-symbols-outlined {
58 - font-size: 16px;
27 +.step-action-buttons .action-button .material-symbols-outlined {
28 + font-size: 0.85rem;
29 line-height: 1;
30 + color: var(--color-message-text);
31 font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
32 + transition: color 0.15s ease;
33 }
34
63 -/* Success state - same as original */
64 -.action-buttons .action-button.success {
65 - background: #4CAF50;
66 - border-color: #4CAF50;
67 - color: white;
35 +.step-action-buttons .action-button:hover .material-symbols-outlined {
36 + color: var(--color-text);
37 }
38
70 -.action-buttons .action-button.success .material-symbols-outlined {
39 +/* Success state for step buttons */
40 +.step-action-buttons .action-button.success .material-symbols-outlined {
41 + color: #4CAF50;
42 font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 20;
43 }
44
74 -/* Error state - same as original */
75 -.action-buttons .action-button.error {
76 - background: var(--color-accent);
77 - border-color: var(--color-accent);
78 - color: white;
79 -}
80 -
81 -.action-buttons .action-button.error .material-symbols-outlined {
45 +/* Error state for step buttons */
46 +.step-action-buttons .action-button.error .material-symbols-outlined {
47 + color: var(--color-accent);
48 font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 20;
49 }
50
85 -/* Speaking state - same as original */
86 -.action-buttons .action-button.speaking {
87 - background: var(--color-primary);
88 - border-color: var(--color-primary);
89 - color: white;
90 - animation: pulse 2s infinite;
91 -}
51 +/* ===========================================
52 + User Message Action Buttons - Hover behavior
53 + Right-aligned, hidden by default on pointer devices, show on hover
54 + Always visible on touch devices
55 + =========================================== */
56
93 -@keyframes pulse {
94 - 0% { opacity: 1; }
95 - 50% { opacity: 0.7; }
96 - 100% { opacity: 1; }
57 +/* Base styling for user message action buttons */
58 +.message-user .step-action-buttons {
59 + justify-content: flex-end;
60 + width: 100%;
61 + opacity: 0;
62 + transition: opacity 0.2s ease-in-out;
63 + margin-top: var(--spacing-sm);
64 }
65
99 -/* Show action buttons on hover - simplified, no device detection needed */
100 -.msg-content:hover .action-buttons,
101 -/* .kvps-row:hover .action-buttons, */
102 -.message-text:hover .action-buttons,
103 -.kvps-val:hover .action-buttons,
104 -.message-body:hover > .action-buttons,
105 -.error-content-inner:hover .action-buttons {
106 - display: flex;
107 - animation: fadeInAfterDelay 0.3s ease-in-out;
108 - animation-delay: 0.3s;
109 - animation-fill-mode: forwards;
66 +/* Hide by default for user messages on pointer devices */
67 +.device-pointer .message-user .step-action-buttons {
68 opacity: 0;
69 + pointer-events: none;
70 }
71
113 -/* Animation to fade in action buttons after delay */
114 -@keyframes fadeInAfterDelay {
115 - 0% { opacity: 0; }
116 - 100% { opacity: 1; }
72 +/* Show on hover for user messages on pointer devices */
73 +.device-pointer .message-user:hover .step-action-buttons {
74 + opacity: 1;
75 + pointer-events: auto;
76 +}
77 +
78 +/* Always show for user messages on touch devices */
79 +.device-touch .message-user .step-action-buttons {
80 + opacity: 1;
81 + pointer-events: auto;
82 }
webui/components/messages/action-buttons/simple-action-buttons.js
+101 -111
@@ -1,134 +1,124 @@
1 -// Simplified Message Action Buttons - Keeping the Great Look & Feel
1 +// Message Action Buttons - Copy and Speak functionality
2 import { store as speechStore } from "/components/chat/speech/speech-store.js";
3
4 -// Extract text content from different message types
5 -function getTextContent(element,html=false) {
6 - // Get all children except action buttons
7 - const textParts = [];
8 - // Loop through all child elements
9 - for (const child of element.children) {
10 - // Skip action buttons
11 - if (child.classList.contains("action-buttons")) continue;
12 - // If the child is an image, copy its src URL
13 - if (child.tagName && child.tagName.toLowerCase() === "img") {
14 - if (child.src) textParts.push(child.src);
15 - continue;
16 - }
17 - // Get text content from the child
18 - const text = (html ? child.innerHTML : child.innerText) || "";
19 - if (text.trim()) {
20 - textParts.push(text.trim());
21 - }
4 +/**
5 + * Copy text to clipboard with fallback for non-secure contexts
6 + */
7 +async function copyToClipboard(text) {
8 + if (navigator.clipboard && window.isSecureContext) {
9 + await navigator.clipboard.writeText(text);
10 + } else {
11 + // Fallback for local dev / non-secure contexts
12 + const textarea = document.createElement("textarea");
13 + textarea.value = text;
14 + textarea.style.cssText = "position:fixed;left:-9999px";
15 + document.body.appendChild(textarea);
16 + textarea.select();
17 + document.execCommand("copy");
18 + document.body.removeChild(textarea);
19 }
23 - // Join all text parts with double newlines
24 - return textParts.join("\n\n");
20 }
21
22 +/**
23 + * Show visual feedback on a button (success/error state)
24 + */
25 +function showButtonFeedback(button, success, originalIcon) {
26 + const icon = button.querySelector(".material-symbols-outlined");
27 + if (!icon) return;
28 +
29 + icon.textContent = success ? "check" : "error";
30 + button.classList.add(success ? "success" : "error");
31 +
32 + setTimeout(() => {
33 + icon.textContent = originalIcon;
34 + button.classList.remove("success", "error");
35 + }, 2000);
36 +}
37
28 -// Create and add action buttons to element
29 -export function addActionButtonsToElement(element) {
30 - // Skip if buttons already exist
31 - if (element.querySelector(".action-buttons")) return;
32 -
33 - // Create container with same styling as original
34 - const container = document.createElement("div");
35 - container.className = "action-buttons";
36 -
37 - // Copy button - matches original design
38 - const copyBtn = document.createElement("button");
39 - copyBtn.className = "action-button copy-action";
40 - copyBtn.setAttribute("aria-label", "Copy text");
41 - copyBtn.innerHTML =
42 - '<span class="material-symbols-outlined">content_copy</span>';
38 +/**
39 + * Create action button element
40 + */
41 +function createButton(iconName, label, className) {
42 + const btn = document.createElement("button");
43 + btn.className = `action-button ${className}`;
44 + btn.setAttribute("aria-label", label);
45 + btn.setAttribute("title", label);
46 + btn.innerHTML = `<span class="material-symbols-outlined">${iconName}</span>`;
47 + return btn;
48 +}
49
50 +/**
51 + * Add action buttons (copy, speak, optionally view details) to an element
52 + *
53 + * @param {HTMLElement} container - Element to append buttons to
54 + * @param {Object} options - Configuration
55 + * @param {string|Function|HTMLElement} options.contentRef - Text content source:
56 + * - string: Use directly
57 + * - Function: Call to get text
58 + * - HTMLElement: Get innerText from element
59 + * @param {Function} [options.onViewDetails] - If provided, adds view details button
60 + */
61 +export function addActionButtonsToElement(container, options = {}) {
62 + const { contentRef, onViewDetails } = options;
63 +
64 + // Skip if buttons already exist
65 + if (container.querySelector(".step-action-buttons")) return;
66 +
67 + // Create buttons container
68 + const buttonsDiv = document.createElement("div");
69 + buttonsDiv.className = "step-action-buttons";
70 +
71 + // Helper to resolve content from contentRef
72 + const getContent = () => {
73 + if (typeof contentRef === "string") return contentRef;
74 + if (typeof contentRef === "function") return contentRef();
75 + if (contentRef instanceof HTMLElement) return contentRef.innerText || "";
76 + return "";
77 + };
78 +
79 + // View Details button (optional)
80 + if (onViewDetails) {
81 + const viewBtn = createButton("open_in_full", "View details", "view-details-action");
82 + viewBtn.onclick = (e) => {
83 + e.stopPropagation();
84 + onViewDetails();
85 + };
86 + buttonsDiv.appendChild(viewBtn);
87 + }
88 +
89 + // Copy button
90 + const copyBtn = createButton("content_copy", "Copy text", "copy-action");
91 copyBtn.onclick = async (e) => {
92 e.stopPropagation();
46 -
47 - // Check if the button container is still fading in (opacity < 0.5)
48 - if (parseFloat(window.getComputedStyle(container).opacity) < 0.5) return; // Don't proceed if still fading in
49 -
50 - const text = getTextContent(element);
51 - const icon = copyBtn.querySelector(".material-symbols-outlined");
52 -
93 + const text = getContent();
94 + if (!text) return;
95 +
96 try {
54 - // Try modern clipboard API
55 - if (navigator.clipboard && window.isSecureContext) {
56 - await navigator.clipboard.writeText(text);
57 - } else {
58 - // Fallback for local dev
59 - const textarea = document.createElement("textarea");
60 - textarea.value = text;
61 - textarea.style.position = "fixed";
62 - textarea.style.left = "-999999px";
63 - document.body.appendChild(textarea);
64 - textarea.select();
65 - document.execCommand("copy");
66 - document.body.removeChild(textarea);
67 - }
68 -
69 - // Visual feedback
70 - icon.textContent = "check";
71 - copyBtn.classList.add("success");
72 - setTimeout(() => {
73 - icon.textContent = "content_copy";
74 - copyBtn.classList.remove("success");
75 - }, 2000);
97 + await copyToClipboard(text);
98 + showButtonFeedback(copyBtn, true, "content_copy");
99 } catch (err) {
100 console.error("Copy failed:", err);
78 - icon.textContent = "error";
79 - copyBtn.classList.add("error");
80 - setTimeout(() => {
81 - icon.textContent = "content_copy";
82 - copyBtn.classList.remove("error");
83 - }, 2000);
101 + showButtonFeedback(copyBtn, false, "content_copy");
102 }
103 };
86 -
87 - // Speak button - matches original design
88 - const speakBtn = document.createElement("button");
89 - speakBtn.className = "action-button speak-action";
90 - speakBtn.setAttribute("aria-label", "Speak text");
91 - speakBtn.innerHTML =
92 - '<span class="material-symbols-outlined">volume_up</span>';
93 -
104 + buttonsDiv.appendChild(copyBtn);
105 +
106 + // Speak button
107 + const speakBtn = createButton("volume_up", "Speak text", "speak-action");
108 speakBtn.onclick = async (e) => {
109 e.stopPropagation();
96 -
97 - // Check if the button container is still fading in (opacity < 0.5)
98 - if (parseFloat(window.getComputedStyle(container).opacity) < 0.5) return; // Don't proceed if still fading in
99 -
100 - const text = getTextContent(element);
101 - const icon = speakBtn.querySelector(".material-symbols-outlined");
102 -
103 - if (!text || text.trim().length === 0) return;
104 -
110 + const text = getContent();
111 + if (!text?.trim()) return;
112 +
113 try {
106 - // Visual feedback
107 - icon.textContent = "check";
108 - speakBtn.classList.add("success");
109 - setTimeout(() => {
110 - icon.textContent = "volume_up";
111 - speakBtn.classList.remove("success");
112 - }, 2000);
113 -
114 - // Use speech store
114 + showButtonFeedback(speakBtn, true, "volume_up");
115 await speechStore.speak(text);
116 } catch (err) {
117 console.error("Speech failed:", err);
118 - icon.textContent = "error";
119 - speakBtn.classList.add("error");
120 - setTimeout(() => {
121 - icon.textContent = "volume_up";
122 - speakBtn.classList.remove("error");
123 - }, 2000);
118 + showButtonFeedback(speakBtn, false, "volume_up");
119 }
120 };
126 -
127 - container.append(copyBtn, speakBtn);
128 - // Add container as the first child instead of appending it
129 - if (element.firstChild) {
130 - element.insertBefore(container, element.firstChild);
131 - } else {
132 - element.appendChild(container);
133 - }
121 + buttonsDiv.appendChild(speakBtn);
122 +
123 + container.appendChild(buttonsDiv);
124 }
webui/css/messages.css
+2 -8
@@ -486,12 +486,6 @@
486 opacity: 1;
487 }
488
489 -/* Legacy copy button styles - DEPRECATED
490 - * These styles have been replaced by the new action buttons component
491 - * located at /components/messages/action-buttons/message-action-buttons.css
492 - * Remove this section after confirming all functionality works with action buttons
493 - */
494 -
489 /* Make message containers relative for absolute positioning of copy buttons */
490 .msg-content,
491 .kvps-row,
@@ -766,8 +760,8 @@
760 .message-group-right {
761 width: 100%;
762 justify-content: end;
769 - margin-top: 5em;
770 - margin-bottom:5em;
763 + margin-top: 4em;
764 + margin-bottom:4em;
765 }
766
767 .message-container {
webui/js/messages.js
+34 -21
@@ -335,8 +335,8 @@ export function _drawMessage(
335 });
336 }
337
338 - // Ensure action buttons exist
339 - // addActionButtonsToElement(bodyDiv);
338 + // Ensure action buttons exist - pass content directly
339 + addActionButtonsToElement(bodyDiv, { contentRef: content });
340 adjustMarkdownRender(contentDiv);
341
342 } else {
@@ -360,8 +360,8 @@ export function _drawMessage(
360
361 spanElement.innerHTML = convertHTML(content);
362
363 - // Ensure action buttons exist
364 - // addActionButtonsToElement(bodyDiv);
363 + // Ensure action buttons exist - pass content directly
364 + addActionButtonsToElement(bodyDiv, { contentRef: content });
365
366 }
367 } else {
@@ -550,7 +550,6 @@ export function drawMessageUser(
550 textDiv.appendChild(spanElement);
551 }
552 spanElement.innerHTML = escapeHTML(content);
553 - // addActionButtonsToElement(textDiv);
553 } else {
554 if (textDiv) textDiv.remove();
555 }
@@ -613,6 +612,9 @@ export function drawMessageUser(
612 } else {
613 if (attachmentsContainer) attachmentsContainer.remove();
614 }
615 +
616 + // Add action buttons below text and attachments (hover for pointer, always for touch - via CSS)
617 + addActionButtonsToElement(messageDiv, { contentRef: content });
618 // The messageDiv is already appended or updated, no need to append again
619 }
620
@@ -1496,9 +1498,9 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
1498 toolName: toolNameToUse
1499 };
1500
1499 - // Add "View Details" button for full modal view (reads fresh data from step._stepData)
1500 - const viewDetailsBtn = createViewDetailsButton(step);
1501 - detail.appendChild(viewDetailsBtn);
1501 + // Add step action buttons (view details, copy, speak) for full modal view
1502 + const stepActionBtns = createStepActionButtons(step);
1503 + detail.appendChild(stepActionBtns);
1504
1505 step.appendChild(detail);
1506
@@ -1950,26 +1952,37 @@ function renderThoughts(container, value) {
1952 }
1953
1954 /**
1953 - * Create "View Details" button for opening step detail modal
1955 + * Create step action buttons (view details, copy, speak) using unified action buttons
1956 * @param {HTMLElement} stepElement - The step DOM element containing _stepData property
1957 */
1956 -function createViewDetailsButton(stepElement) {
1958 +function createStepActionButtons(stepElement) {
1959 const btnContainer = document.createElement("div");
1960 btnContainer.classList.add("step-detail-actions");
1961
1960 - const btn = document.createElement("button");
1961 - btn.classList.add("btn", "text-button");
1962 - btn.innerHTML = '<span class="material-symbols-outlined">open_in_full</span> View Details';
1963 - btn.title = "Open full step details in modal";
1964 -
1965 - btn.addEventListener("click", (e) => {
1966 - e.stopPropagation();
1967 - // Read fresh data from the step element at click time
1968 - const freshData = stepElement._stepData || {};
1969 - stepDetailStore.showStepDetail(freshData);
1962 + // Use unified action buttons with step-specific options
1963 + addActionButtonsToElement(btnContainer, {
1964 + contentRef: () => {
1965 + // Get text content from step data at action time
1966 + const data = stepElement._stepData || {};
1967 + const parts = [];
1968 + if (data.heading) parts.push(data.heading);
1969 + if (data.content) parts.push(data.content);
1970 + if (data.kvps) {
1971 + for (const [key, value] of Object.entries(data.kvps)) {
1972 + if (key === "reasoning" || key === "finished" || key === "attachments") continue;
1973 + const valStr = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
1974 + parts.push(`${key}: ${valStr}`);
1975 + }
1976 + }
1977 + return parts.join("\n\n");
1978 + },
1979 + onViewDetails: () => {
1980 + // Read fresh data from the step element at click time
1981 + const freshData = stepElement._stepData || {};
1982 + stepDetailStore.showStepDetail(freshData);
1983 + }
1984 });
1985
1972 - btnContainer.appendChild(btn);
1986 return btnContainer;
1987 }
1988