diff --git a/webui/components/messages/action-buttons/simple-action-buttons.css b/webui/components/messages/action-buttons/simple-action-buttons.css index b9d3279f1..f914ef8ba 100644 --- a/webui/components/messages/action-buttons/simple-action-buttons.css +++ b/webui/components/messages/action-buttons/simple-action-buttons.css @@ -14,7 +14,8 @@ pointer-events: none; } -.step-action-buttons .action-button { +.step-action-buttons .action-button, +.action-button { display: flex; align-items: center; justify-content: center; @@ -24,7 +25,8 @@ cursor: pointer; } -.step-action-buttons .action-button .material-symbols-outlined { +.step-action-buttons .action-button .material-symbols-outlined, +.action-button .material-symbols-outlined { font-size: 0.85rem; line-height: 1; color: var(--color-message-text); @@ -32,18 +34,21 @@ transition: color 0.15s ease; } -.step-action-buttons .action-button:hover .material-symbols-outlined { +.step-action-buttons .action-button:hover .material-symbols-outlined, +.action-button:hover .material-symbols-outlined { color: var(--color-text); } /* Success state */ -.step-action-buttons .action-button.success .material-symbols-outlined { +.step-action-buttons .action-button.success .material-symbols-outlined, +.action-button.success .material-symbols-outlined { color: #4CAF50; font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 20; } /* Error state */ -.step-action-buttons .action-button.error .material-symbols-outlined { +.step-action-buttons .action-button.error .material-symbols-outlined, +.action-button.error .material-symbols-outlined { color: var(--color-accent); font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 20; } @@ -54,17 +59,29 @@ width: 100%; } +.code-block-wrapper > .step-action-buttons, +.message-markdown-table-wrap > .step-action-buttons { + padding-top: 0; +} + /* =========================================== Hover behavior - Pointer devices =========================================== */ -.device-pointer .message-user:hover .step-action-buttons, -.device-pointer .message-agent-response:hover .step-action-buttons, +.device-pointer .message-user:hover > .step-action-buttons, +.device-pointer .message-agent-response:hover > .step-action-buttons, .device-pointer .process-step:hover > .process-step-detail .step-action-buttons { opacity: 1; pointer-events: auto; } +/* Code blocks and tables: show copy button on hover */ +.device-pointer .code-block-wrapper:hover > .step-action-buttons, +.device-pointer .message-markdown-table-wrap:hover > .step-action-buttons { + opacity: 1; + pointer-events: auto; +} + /* =========================================== Touch devices - Always visible =========================================== */ diff --git a/webui/css/messages.css b/webui/css/messages.css index 176ae987d..c2a04b5b9 100644 --- a/webui/css/messages.css +++ b/webui/css/messages.css @@ -438,6 +438,10 @@ font-size: 0.75rem; } +.kvps-val p{ + margin:0; +} + .kvps-val pre { white-space: pre-wrap; /* keep \n, collapse no spaces, allow wrapping */ word-break: break-word; /* optional – forces really long “words” to break */ @@ -803,3 +807,64 @@ .dark-mode .message { box-shadow: none; } + +/* =========================================== + Collapsible Standalone Messages + Shows ~10 lines with fade-out, expand button reveals full content + =========================================== */ + +.message.message-collapsible .message-body { + position: relative; + max-height: 15em !important; + overflow: hidden; + transition: max-height 0.3s ease-out; +} + +.message.message-collapsible .message-body::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3em; + background: linear-gradient(transparent, var(--color-chat-background)); + pointer-events: none; + opacity: 1; + transition: opacity 0.3s ease-out; +} + +/* Expanded state */ +.message.message-collapsible.expanded .message-body { + max-height: none !important; +} + +.message.message-collapsible.expanded .message-body::after { + opacity: 0; +} + +/* No fade when content fits */ +.message.message-collapsible:not(.has-overflow) .message-body::after { + opacity: 0; +} + +/* Expand button - hidden by default, shown when overflow */ +.message.message-collapsible .expand-btn { + display: none; + background: transparent; + border: none; + color: var(--color-text-muted); + font-family: "Rubik", Arial, Helvetica, sans-serif; + font-size: var(--font-size-xs); + cursor: pointer; + padding: var(--spacing-xs) 0; + opacity: 0.7; + transition: opacity 0.15s ease; +} + +.message.message-collapsible .expand-btn:hover { + opacity: 1; +} + +.message.message-collapsible.has-overflow .expand-btn { + display: block; +} diff --git a/webui/js/messages.js b/webui/js/messages.js index f86280f63..61f9b7cc9 100644 --- a/webui/js/messages.js +++ b/webui/js/messages.js @@ -669,6 +669,22 @@ function drawStandaloneMessage({ mainClass, }); + // Collapsible: show ~10 lines with fade, expand button reveals full content + messageDiv.classList.add("message-collapsible"); + + const expandBtn = ensureChild(messageDiv, ".expand-btn", "button", "expand-btn"); + expandBtn.textContent = messageDiv.classList.contains("expanded") ? "Show less" : "Show more"; + expandBtn.onclick = () => { + messageDiv.classList.toggle("expanded"); + expandBtn.textContent = messageDiv.classList.contains("expanded") ? "Show less" : "Show more"; + }; + + // Detect overflow after render - CSS handles visibility based on .has-overflow class + requestAnimationFrame(() => { + const body = messageDiv.querySelector(".message-body"); + messageDiv.classList.toggle("has-overflow", body.scrollHeight > body.clientHeight); + }); + // Render action buttons: get/create container, clear, append const actionButtonsContainer = ensureChild( messageDiv, @@ -971,6 +987,22 @@ export function drawMessageResponse({ mainClass: "message-agent-response", }); + // Collapsible: show ~10 lines with fade, expand button reveals full content + messageDiv.classList.add("message-collapsible"); + + const expandBtn = ensureChild(messageDiv, ".expand-btn", "button", "expand-btn"); + expandBtn.textContent = messageDiv.classList.contains("expanded") ? "Show less" : "Show more"; + expandBtn.onclick = () => { + messageDiv.classList.toggle("expanded"); + expandBtn.textContent = messageDiv.classList.contains("expanded") ? "Show less" : "Show more"; + }; + + // Detect overflow after render - CSS handles visibility based on .has-overflow class + requestAnimationFrame(() => { + const body = messageDiv.querySelector(".message-body"); + messageDiv.classList.toggle("has-overflow", body.scrollHeight > body.clientHeight); + }); + // Render action buttons: get/create container, clear, append const responseText = String(content ?? ""); const responseActionButtons = responseText.trim() @@ -979,9 +1011,10 @@ export function drawMessageResponse({ createActionButton("copy", "", () => copyToClipboard(responseText)), ].filter(Boolean) : []; + // Look for direct child only to avoid finding nested code block buttons const actionButtonsContainer = ensureChild( messageDiv, - ".step-action-buttons", + ":scope > .step-action-buttons", "div", "step-action-buttons", ); @@ -1763,7 +1796,7 @@ function drawKvpsIncremental(container, kvps, latex) { imageViewerStore.open(imgElement.src, { refreshInterval: 1000 }); }); } else { - const span = document.createElement("span"); + const span = document.createElement("p"); span.innerHTML = convertHTML(value); tdiv.appendChild(span); @@ -1901,16 +1934,51 @@ function convertPathsToLinks(str) { .join(""); } +// markdown render helpers // + +// wraps an element with a container div +const wrapElement = (el, className) => { + const wrapper = document.createElement("div"); + wrapper.className = className; + el.parentNode.insertBefore(wrapper, el); + wrapper.appendChild(el); + return wrapper; +}; + +// data extractors +const extractTableTSV = (table) => + [...table.rows] + .map((row) => + [...row.cells] + .map((cell) => cell.textContent.replace(/\t/g, " ").replace(/\n/g, " ")) + .join("\t"), + ) + .join("\n"); + function adjustMarkdownRender(element) { // find all tables in the element - const elements = element.querySelectorAll("table"); + const tables = element.querySelectorAll("table"); + tables.forEach((el) => { + const wrapper = wrapElement(el, "message-markdown-table-wrap"); + const actionsDiv = document.createElement("div"); + actionsDiv.className = "step-action-buttons"; + actionsDiv.appendChild( + createActionButton("copy", "", () => copyToClipboard(extractTableTSV(el))) + ); + wrapper.appendChild(actionsDiv); + }); - // wrap each with a div with class message-markdown-table-wrap - elements.forEach((el) => { - const wrapper = document.createElement("div"); - wrapper.className = "message-markdown-table-wrap"; - el.parentNode.insertBefore(wrapper, el); - wrapper.appendChild(el); + // find all code blocks + const codeElements = element.querySelectorAll("pre > code"); + codeElements.forEach((code) => { + const pre = code.parentNode; + const wrapper = wrapElement(pre, "code-block-wrapper"); + const actionsDiv = document.createElement("div"); + actionsDiv.className = "step-action-buttons"; + actionsDiv.appendChild( + createActionButton("copy", "", () => copyToClipboard(code.textContent)) + ); + wrapper.appendChild(actionsDiv); }); // find all images