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