diff --git a/webui/components/messages/action-buttons/simple-action-buttons.js b/webui/components/messages/action-buttons/simple-action-buttons.js
index 44763733c..ea46a2ee3 100644
--- a/webui/components/messages/action-buttons/simple-action-buttons.js
+++ b/webui/components/messages/action-buttons/simple-action-buttons.js
@@ -1,11 +1,32 @@
-// Message Action Buttons - Copy and Speak functionality
-import { store as speechStore } from "/components/chat/speech/speech-store.js";
-import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
+// Message Action Buttons - DOM helpers for message action buttons
+
+const ACTION_ICON_MAP = {
+ detail: "open_in_full",
+ speak: "volume_up",
+ copy: "content_copy",
+};
+
+const ACTION_LABELS = {
+ detail: "View details",
+ speak: "Speak",
+ copy: "Copy",
+};
+
+function resolveActionIcon(icon) {
+ if (!icon) return "";
+ return ACTION_ICON_MAP[icon] || icon;
+}
+
+function buildActionLabel(icon, text) {
+ const baseLabel = ACTION_LABELS[icon] || text || icon;
+ if (text && ACTION_LABELS[icon]) return `${ACTION_LABELS[icon]} ${text}`;
+ return baseLabel;
+}
/**
* Copy text to clipboard with fallback for non-secure contexts
*/
-async function copyToClipboard(text) {
+export async function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
@@ -23,7 +44,7 @@ async function copyToClipboard(text) {
/**
* Show visual feedback on a button (success/error state)
*/
-function showButtonFeedback(button, success, originalIcon) {
+export function showButtonFeedback(button, success, originalIcon) {
const icon = button.querySelector(".material-symbols-outlined");
if (!icon) return;
@@ -39,135 +60,37 @@ function showButtonFeedback(button, success, originalIcon) {
/**
* 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;
-}
+export function createActionButton(icon, text = "", handler = null) {
+ const iconName = resolveActionIcon(icon);
+ if (!iconName) return null;
-/**
- * Add action buttons (copy, speak, optionally view details) to an element.
- * Data is attached to buttons as data attributes for DOM-first behavior.
- *
- * @param {HTMLElement} container - Element to append buttons to
- * @param {Object} options - Configuration
- * @param {string|Function|HTMLElement} [options.contentRef] - Text content source
- * @param {Object} [options.detailPayload] - Detail payload for modal
- * @param {Function} [options.onViewDetails] - Optional detail handler
- * @param {string} [options.copyContent] - Text for copy action
- * @param {string} [options.speakContent] - Text for speak action
- */
-export function addActionButtonsToElement(container, options = {}) {
- const {
- contentRef,
- detailPayload,
- onViewDetails,
- copyContent,
- speakContent
- } = options;
-
- const resolveContent = (explicit) => {
- if (typeof explicit === "string") return explicit;
- if (typeof explicit === "function") return explicit();
- if (explicit instanceof HTMLElement) return explicit.innerText || "";
- return "";
- };
-
- const resolvedCopyContent =
- resolveContent(copyContent ?? contentRef) || container.innerText || "";
- const resolvedSpeakContent =
- resolveContent(speakContent ?? contentRef) || container.innerText || "";
-
- let buttonsDiv = container.querySelector(".step-action-buttons");
- if (!buttonsDiv) {
- buttonsDiv = document.createElement("div");
- buttonsDiv.className = "step-action-buttons";
- container.appendChild(buttonsDiv);
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = `action-button action-${icon}`;
+ const label = buildActionLabel(icon, text);
+ if (label) {
+ button.setAttribute("aria-label", label);
+ button.setAttribute("title", label);
}
+ button.innerHTML = `${iconName}`;
- const setDetailPayload = (btn) => {
- if (detailPayload) {
- btn.dataset.detailPayload = JSON.stringify(detailPayload);
- } else {
- delete btn.dataset.detailPayload;
- }
- if (onViewDetails) {
- btn._detailHandler = onViewDetails;
- } else {
- delete btn._detailHandler;
- }
- };
-
- // View Details button (optional)
- let viewBtn = buttonsDiv.querySelector(".view-details-action");
- if (detailPayload || onViewDetails) {
- if (!viewBtn) {
- viewBtn = createButton("open_in_full", "View details", "view-details-action");
- viewBtn.onclick = (e) => {
- e.stopPropagation();
- const handler = viewBtn._detailHandler;
- if (typeof handler === "function") {
- handler();
- return;
- }
- const payload = viewBtn.dataset.detailPayload;
- if (payload) {
- try {
- stepDetailStore.showStepDetail(JSON.parse(payload));
- } catch (err) {
- console.error("Failed to parse detail payload:", err);
- }
- }
- };
- buttonsDiv.appendChild(viewBtn);
- }
- setDetailPayload(viewBtn);
- } else if (viewBtn) {
- viewBtn.remove();
- }
-
- // Copy button
- let copyBtn = buttonsDiv.querySelector(".copy-action");
- if (!copyBtn) {
- copyBtn = createButton("content_copy", "Copy text", "copy-action");
- copyBtn.onclick = async (e) => {
- e.stopPropagation();
- const text = copyBtn.dataset.copyContent || "";
- if (!text) return;
-
+ if (typeof handler === "function") {
+ button.addEventListener("click", async (event) => {
+ event.stopPropagation();
+ const shouldShowFeedback = icon === "copy" || icon === "speak";
try {
- await copyToClipboard(text);
- showButtonFeedback(copyBtn, true, "content_copy");
+ await handler();
+ if (shouldShowFeedback) {
+ showButtonFeedback(button, true, iconName);
+ }
} catch (err) {
- console.error("Copy failed:", err);
- showButtonFeedback(copyBtn, false, "content_copy");
+ console.error("Action button failed:", err);
+ if (shouldShowFeedback) {
+ showButtonFeedback(button, false, iconName);
+ }
}
- };
- buttonsDiv.appendChild(copyBtn);
+ });
}
- copyBtn.dataset.copyContent = resolvedCopyContent;
- // Speak button
- let speakBtn = buttonsDiv.querySelector(".speak-action");
- if (!speakBtn) {
- speakBtn = createButton("volume_up", "Speak text", "speak-action");
- speakBtn.onclick = async (e) => {
- e.stopPropagation();
- const text = speakBtn.dataset.speakContent || "";
- if (!text.trim()) return;
-
- try {
- showButtonFeedback(speakBtn, true, "volume_up");
- await speechStore.speak(text);
- } catch (err) {
- console.error("Speech failed:", err);
- showButtonFeedback(speakBtn, false, "volume_up");
- }
- };
- buttonsDiv.appendChild(speakBtn);
- }
- speakBtn.dataset.speakContent = resolvedSpeakContent;
+ return button;
}
diff --git a/webui/js/messages.js b/webui/js/messages.js
index da5f2cc0c..542d81bcd 100644
--- a/webui/js/messages.js
+++ b/webui/js/messages.js
@@ -3,7 +3,9 @@ import { store as imageViewerStore } from "../components/modals/image-viewer/ima
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 } from "/components/messages/action-buttons/simple-action-buttons.js";
+import { store as speechStore } from "/components/chat/speech/speech-store.js";
+import { createActionButton, copyToClipboard } from "/components/messages/action-buttons/simple-action-buttons.js";
+import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
import { formatDuration } from "./time-utils.js";
@@ -379,8 +381,7 @@ function drawProcessStep({
kvps,
content,
contentClasses,
- copyText,
- speakText,
+ actionButtons = [],
log,
allowCompletedGroup = false,
...additional
@@ -394,10 +395,6 @@ function drawProcessStep({
const isNewStep = !step;
const isGroupCompleted = group.classList.contains("process-group-completed");
- // const detailData = buildDetailPayload(stepData);
- // const speakText = speakContent ?? copyText;
- // const speakText = speakContent ?? copyText;
-
if (isNewStep) {
// create the base DOM element for the step
step = document.createElement("div");
@@ -431,17 +428,6 @@ function drawProcessStep({
// }
// }
- // // create step detail container
- // detail = document.createElement("div");
- // detail.classList.add("process-step-detail");
- // detailContent = document.createElement("div")
- // detailContent.classList.add("process-step-detail-content")
-
- // const stepActionBtns = document.createElement("div");
- // stepActionBtns.classList.add("step-detail-actions");
- // detail.appendChild(stepActionBtns);
- // step.appendChild(detail);
-
let appendTarget = stepsContainer;
const parentStep = findParentDelegationStep(group, log.agentno);
if (parentStep) {
@@ -510,14 +496,6 @@ function drawProcessStep({
"process-step-detail-scroll",
);
- // create action buttons
- const stepActionBtns = ensureChild(
- stepDetail,
- ".step-detail-actions",
- "div",
- "step-detail-actions",
- );
-
// else {
// if (timestamp && !step.hasAttribute("data-timestamp")) {
// step.setAttribute("data-timestamp", timestamp);
@@ -623,13 +601,18 @@ function drawProcessStep({
// statusClass: resolvedStatusClass,
// });
- // const stepActions = ensureChild(detail, ".step-detail-actions", "div", "step-detail-actions");
- addActionButtonsToElement(stepActionBtns, {
- detailPayload: {}, // detailDataToUse,
- onViewDetails: null,
- copyContent: copyText,
- speakContent: speakText,
- });
+ // Render action buttons: get/create container, clear, append
+ const stepActionBtns = ensureChild(
+ stepDetail,
+ ".step-detail-actions",
+ "div",
+ "step-detail-actions",
+ "step-action-buttons",
+ );
+ stepActionBtns.textContent = "";
+ (actionButtons || [])
+ .filter(Boolean)
+ .forEach((button) => stepActionBtns.appendChild(button));
if (isExpanded && !isMassRender()) detailScroller.reApplyScroll(); // reapply scroll position (autoscroll if bottom) - only when expanded already and not
@@ -664,8 +647,7 @@ function drawStandaloneMessage({
markdown = false,
latex = false,
kvps = null,
- copyContent = null,
- speakContent = null,
+ actionButtons = [],
}) {
const container = getOrCreateMessageContainer(
@@ -686,12 +668,17 @@ function drawStandaloneMessage({
mainClass,
});
- const copyText = copyContent ?? content ?? "";
- const speakText = speakContent ?? copyText;
- addActionButtonsToElement(messageDiv, {
- copyContent: copyText,
- speakContent: speakText,
- });
+ // Render action buttons: get/create container, clear, append
+ const actionButtonsContainer = ensureChild(
+ messageDiv,
+ ".step-action-buttons",
+ "div",
+ "step-action-buttons",
+ );
+ actionButtonsContainer.textContent = "";
+ (actionButtons || [])
+ .filter(Boolean)
+ .forEach((button) => actionButtonsContainer.appendChild(button));
return container;
}
@@ -859,6 +846,14 @@ export function drawMessageDefault({
kvps = null,
...additional
}) {
+ const contentText = String(content ?? "");
+ const actionButtons = contentText.trim()
+ ? [
+ createActionButton("speak", "", () => speechStore.speak(contentText)),
+ createActionButton("copy", "", () => copyToClipboard(contentText)),
+ ].filter(Boolean)
+ : [];
+
return drawStandaloneMessage({
id,
heading,
@@ -869,6 +864,7 @@ export function drawMessageDefault({
messageClasses: ["message-ai"],
contentClasses: ["msg-json"],
kvps,
+ actionButtons,
});
}
@@ -886,6 +882,16 @@ export function drawMessageAgent({
let displayKvps = {};
if (kvps?.thoughts) displayKvps["icon://lightbulb"] = kvps.thoughts;
if (kvps?.step) displayKvps["icon://step"] = kvps.step;
+ const thoughtsText = String(kvps?.thoughts ?? "");
+ const actionButtons = thoughtsText.trim()
+ ? [
+ createActionButton("detail", "", () =>
+ stepDetailStore.showStepDetail(buildDetailPayload(arguments[0])),
+ ),
+ createActionButton("speak", "", () => speechStore.speak(thoughtsText)),
+ createActionButton("copy", "", () => copyToClipboard(thoughtsText)),
+ ].filter(Boolean)
+ : [];
return drawProcessStep({
id,
@@ -893,8 +899,7 @@ export function drawMessageAgent({
code: "GEN",
classes: null,
kvps: displayKvps,
- copyText: kvps?.thoughts,
- speakText: kvps?.thoughts,
+ actionButtons,
log: arguments[0],
});
}
@@ -914,6 +919,13 @@ export function drawMessageResponse({
const title = getStepTitle(heading, kvps, type);
const statusCode = getStatusCode(type);
const statusClass = getStatusClass(type);
+ const contentText = String(content ?? "");
+ const actionButtons = contentText.trim()
+ ? [
+ createActionButton("speak", "", () => speechStore.speak(contentText)),
+ createActionButton("copy", "", () => copyToClipboard(contentText)),
+ ].filter(Boolean)
+ : [];
return drawProcessStep({
id,
title,
@@ -925,6 +937,7 @@ export function drawMessageResponse({
content,
timestamp,
agentno,
+ actionButtons,
});
}
@@ -954,12 +967,24 @@ export function drawMessageResponse({
mainClass: "message-agent-response",
});
- // const copyText = copyContent ?? content ?? "";
- // const speakText = speakContent ?? copyText;
- // addActionButtonsToElement(messageDiv, {
- // copyContent: copyText,
- // speakContent: speakText,
- // });
+ // Render action buttons: get/create container, clear, append
+ const responseText = String(content ?? "");
+ const responseActionButtons = responseText.trim()
+ ? [
+ createActionButton("speak", "", () => speechStore.speak(responseText)),
+ createActionButton("copy", "", () => copyToClipboard(responseText)),
+ ].filter(Boolean)
+ : [];
+ const actionButtonsContainer = ensureChild(
+ messageDiv,
+ ".step-action-buttons",
+ "div",
+ "step-action-buttons",
+ );
+ actionButtonsContainer.textContent = "";
+ responseActionButtons.forEach((button) =>
+ actionButtonsContainer.appendChild(button),
+ );
if (group) updateProcessGroupHeader(group);
@@ -1081,11 +1106,24 @@ export function drawMessageUser({
headingElement.remove();
}
- // Add action buttons below text and attachments (hover for pointer, always for touch - via CSS)
- addActionButtonsToElement(messageDiv, {
- copyContent: content || "",
- speakContent: content || "",
- });
+ // Render action buttons: get/create container, clear, append
+ const userText = String(content ?? "");
+ const userActionButtons = userText.trim()
+ ? [
+ createActionButton("speak", "", () => speechStore.speak(userText)),
+ createActionButton("copy", "", () => copyToClipboard(userText)),
+ ].filter(Boolean)
+ : [];
+ const actionButtonsContainer = ensureChild(
+ messageDiv,
+ ".step-action-buttons",
+ "div",
+ "step-action-buttons",
+ );
+ actionButtonsContainer.textContent = "";
+ userActionButtons.forEach((button) =>
+ actionButtonsContainer.appendChild(button),
+ );
}
export function drawMessageTool({
@@ -1100,6 +1138,16 @@ export function drawMessageTool({
}) {
const title = cleanStepTitle(heading);
let displayKvps = { ...kvps };
+ const contentText = String(content ?? "");
+ const actionButtons = contentText.trim()
+ ? [
+ createActionButton("detail", "", () =>
+ stepDetailStore.showStepDetail(buildDetailPayload(arguments[0])),
+ ),
+ createActionButton("speak", "", () => speechStore.speak(contentText)),
+ createActionButton("copy", "", () => copyToClipboard(contentText)),
+ ].filter(Boolean)
+ : [];
return drawProcessStep({
id,
@@ -1109,8 +1157,7 @@ export function drawMessageTool({
kvps: displayKvps,
content,
// contentClasses: [],
- copyText: content,
- speakText: content,
+ actionButtons,
log: arguments[0],
});
}
@@ -1144,6 +1191,21 @@ export function drawMessageCodeExe({
if (kvps?.session) displayKvps.session = kvps.session;
// render the standard step
+ const commandText = String(kvps?.code ?? "");
+ const outputText = String(content ?? "");
+ const actionButtons = [
+ createActionButton("detail", "", () =>
+ stepDetailStore.showStepDetail(buildDetailPayload(arguments[0])),
+ ),
+ commandText.trim()
+ ? createActionButton("copy", "Command", () =>
+ copyToClipboard(commandText),
+ )
+ : null,
+ outputText.trim()
+ ? createActionButton("copy", "Output", () => copyToClipboard(outputText))
+ : null,
+ ].filter(Boolean);
const stepData = drawProcessStep({
id,
title,
@@ -1152,8 +1214,7 @@ export function drawMessageCodeExe({
kvps: displayKvps,
content,
contentClasses: ["terminal-output"],
- copyText: content,
- speakText: null,
+ actionButtons,
log: arguments[0],
});
}
@@ -1170,6 +1231,16 @@ export function drawMessageBrowser({
}) {
const title = cleanStepTitle(heading);
let displayKvps = { ...kvps };
+ const answerText = String(kvps?.answer ?? "");
+ const actionButtons = answerText.trim()
+ ? [
+ createActionButton("detail", "", () =>
+ stepDetailStore.showStepDetail(buildDetailPayload(arguments[0])),
+ ),
+ createActionButton("speak", "", () => speechStore.speak(answerText)),
+ createActionButton("copy", "", () => copyToClipboard(answerText)),
+ ].filter(Boolean)
+ : [];
return drawProcessStep({
id,
@@ -1179,8 +1250,7 @@ export function drawMessageBrowser({
kvps: displayKvps,
content,
// contentClasses: [],
- copyText: content,
- speakText: content,
+ actionButtons,
log: arguments[0],
});
}
@@ -1197,6 +1267,16 @@ export function drawMessageMcp({
}) {
const title = cleanStepTitle(heading);
let displayKvps = { ...kvps };
+ const contentText = String(content ?? "");
+ const actionButtons = contentText.trim()
+ ? [
+ createActionButton("detail", "", () =>
+ stepDetailStore.showStepDetail(buildDetailPayload(arguments[0])),
+ ),
+ createActionButton("speak", "", () => speechStore.speak(contentText)),
+ createActionButton("copy", "", () => copyToClipboard(contentText)),
+ ].filter(Boolean)
+ : [];
return drawProcessStep({
id,
@@ -1206,19 +1286,11 @@ export function drawMessageMcp({
kvps: displayKvps,
content,
// contentClasses: [],
- actionButtons:[
- createActionButton("detail","", ()=>{ openDetail(arguments[0]) }),
- createActionButton("speak","", ()=>{ speakText(content) }),
- createActionButton("copy","Command", ()=>{ copyToClipboard(kvps?.code) }),
- createActionButton("copy","Output", ()=>{ copyText(content) })
- ],
+ actionButtons,
log: arguments[0],
});
}
-// todo - move to store
-function createActionButton(icon, text, handler){}
-
export function drawMessageSubagent({
id,
type,
@@ -1231,6 +1303,16 @@ export function drawMessageSubagent({
}) {
const title = cleanStepTitle(heading);
let displayKvps = { ...kvps };
+ const contentText = String(content ?? "");
+ const actionButtons = contentText.trim()
+ ? [
+ createActionButton("detail", "", () =>
+ stepDetailStore.showStepDetail(buildDetailPayload(arguments[0])),
+ ),
+ createActionButton("speak", "", () => speechStore.speak(contentText)),
+ createActionButton("copy", "", () => copyToClipboard(contentText)),
+ ].filter(Boolean)
+ : [];
return drawProcessStep({
id,
@@ -1240,8 +1322,7 @@ export function drawMessageSubagent({
kvps: displayKvps,
content,
// contentClasses: [],
- copyText: content,
- speakText: content,
+ actionButtons,
log: arguments[0],
});
}
@@ -1255,6 +1336,13 @@ export function drawMessageInfo({
}) {
const title = cleanStepTitle(heading);
let displayKvps = { ...kvps };
+ const contentText = String(content ?? "");
+ const actionButtons = contentText.trim()
+ ? [
+ createActionButton("speak", "", () => speechStore.speak(contentText)),
+ createActionButton("copy", "", () => copyToClipboard(contentText)),
+ ].filter(Boolean)
+ : [];
return drawProcessStep({
id,
@@ -1264,8 +1352,7 @@ export function drawMessageInfo({
kvps: displayKvps,
content,
// contentClasses: [],
- copyText: content,
- speakText: content,
+ actionButtons,
log: arguments[0],
});
}
@@ -1281,6 +1368,13 @@ export function drawMessageUtil({
...additional
}) {
const title = cleanStepTitle(heading);
+ const contentText = String(content ?? "");
+ const actionButtons = contentText.trim()
+ ? [
+ createActionButton("speak", "", () => speechStore.speak(contentText)),
+ createActionButton("copy", "", () => copyToClipboard(contentText)),
+ ].filter(Boolean)
+ : [];
return drawProcessStep({
id,
@@ -1288,8 +1382,8 @@ export function drawMessageUtil({
code: "UTL",
classes: ["message-util"],
kvps,
- copyText: null,
- speakText: null,
+ content,
+ actionButtons,
log: arguments[0],
allowCompletedGroup: true,
});
@@ -1308,6 +1402,13 @@ export function drawMessageHint({
const title = getStepTitle(heading, kvps, type);
const statusCode = getStatusCode(type);
const statusClass = getStatusClass(type);
+ const contentText = String(content ?? "");
+ const actionButtons = contentText.trim()
+ ? [
+ createActionButton("speak", "", () => speechStore.speak(contentText)),
+ createActionButton("copy", "", () => copyToClipboard(contentText)),
+ ].filter(Boolean)
+ : [];
return drawStandaloneMessage({
id,
@@ -1320,6 +1421,7 @@ export function drawMessageHint({
content,
timestamp,
agentno,
+ actionButtons,
});
}
@@ -1344,8 +1446,7 @@ export function drawMessageProgress({
kvps: displayKvps,
content,
// contentClasses: [],
- // copyText: kvps?.thoughts,
- // speakText: kvps?.thoughts,
+ actionButtons: [],
log: arguments[0],
});
}
@@ -1359,6 +1460,13 @@ export function drawMessageWarning({
}) {
const title = cleanStepTitle(heading);
let displayKvps = { ...kvps };
+ const contentText = String(content ?? "");
+ const actionButtons = contentText.trim()
+ ? [
+ createActionButton("speak", "", () => speechStore.speak(contentText)),
+ createActionButton("copy", "", () => copyToClipboard(contentText)),
+ ].filter(Boolean)
+ : [];
//TODO: if process group is running, append there instead
// return drawProcessStep({
@@ -1369,8 +1477,6 @@ export function drawMessageWarning({
// kvps: displayKvps,
// content,
// // contentClasses: [],
- // copyText: content,
- // speakText: content,
// log: arguments[0],
// });
return drawStandaloneMessage({
@@ -1381,6 +1487,7 @@ export function drawMessageWarning({
containerClasses: ["ai-container", "center-container"],
mainClass: "message-warning",
kvps,
+ actionButtons,
});
}
@@ -1391,6 +1498,16 @@ export function drawMessageError({
kvps = null,
...additional
}) {
+ const contentText = String(content ?? "");
+ const actionButtons = [
+ createActionButton("detail", "", () =>
+ stepDetailStore.showStepDetail(buildDetailPayload(arguments[0])),
+ ),
+ contentText.trim()
+ ? createActionButton("copy", "", () => copyToClipboard(contentText))
+ : null,
+ ].filter(Boolean);
+
return drawStandaloneMessage({
id,
heading,
@@ -1399,6 +1516,7 @@ export function drawMessageError({
containerClasses: ["ai-container", "center-container"],
mainClass: "message-error",
kvps,
+ actionButtons,
});
}
@@ -1529,11 +1647,6 @@ export function drawMessageError({
// pre.textContent = content;
// contentInner.appendChild(pre);
-// // Add action buttons for copy functionality
-// addActionButtonsToElement(contentInner, {
-// copyContent: content,
-// speakContent: content,
-// });
// }
// messageContainer.classList.add("center-container");