collapsible responses rework

3clyp50 committed Jan 29, 2026 at 09:35 UTC d912caae61fa0b4fa026ee98fe7fef7b964a6d0b
3 files changed +108 -102
webui/components/messages/action-buttons/simple-action-buttons.css
+68
@@ -14,6 +14,24 @@
14 pointer-events: none;
15 }
16
17 +.step-action-buttons .expand-btn {
18 + display: inline-flex;
19 + align-items: center;
20 + background: transparent;
21 + border: none;
22 + color: var(--color-text-muted);
23 + font-family: "Rubik", Arial, Helvetica, sans-serif;
24 + font-size: var(--font-size-xs);
25 + cursor: pointer;
26 + padding: var(--spacing-xs) 0;
27 + opacity: 0.7;
28 + transition: opacity 0.15s ease;
29 +}
30 +
31 +.step-action-buttons .expand-btn:hover {
32 + opacity: 1;
33 +}
34 +
35 .step-action-buttons .action-button,
36 .action-button {
37 display: flex;
@@ -53,6 +71,55 @@
71 font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 20;
72 }
73
74 +.show-more-btn,
75 +.show-less-btn {
76 + display: none;
77 +}
78 +
79 +.message.message-collapsible.has-overflow:not(.expanded) > .step-action-buttons {
80 + opacity: 1;
81 + pointer-events: auto;
82 +}
83 +
84 +.message.message-collapsible.has-overflow:not(.expanded)
85 + > .step-action-buttons
86 + .show-more-btn {
87 + display: inline-flex;
88 + pointer-events: auto;
89 +}
90 +
91 +.device-pointer
92 + .message.message-collapsible.has-overflow:not(.expanded)
93 + > .step-action-buttons
94 + .action-button {
95 + opacity: 0;
96 + pointer-events: none;
97 +}
98 +
99 +.device-pointer
100 + .message.message-collapsible.has-overflow:not(.expanded):hover
101 + > .step-action-buttons
102 + .action-button {
103 + opacity: 1;
104 + pointer-events: auto;
105 +}
106 +
107 +.device-pointer
108 + .message.message-collapsible.has-overflow.expanded:hover
109 + > .step-action-buttons
110 + .show-less-btn {
111 + display: inline-flex;
112 + pointer-events: auto;
113 +}
114 +
115 +.device-touch
116 + .message.message-collapsible.has-overflow.expanded
117 + > .step-action-buttons
118 + .show-less-btn {
119 + display: inline-flex;
120 + pointer-events: auto;
121 +}
122 +
123 /* User messages: right-aligned */
124 .message-user .step-action-buttons {
125 justify-content: flex-end;
@@ -70,6 +137,7 @@
137
138 .device-pointer .message-user:hover > .step-action-buttons,
139 .device-pointer .message-agent-response:hover > .step-action-buttons,
140 +.device-pointer .message.message-collapsible.expanded:hover > .step-action-buttons,
141 .device-pointer .process-step:hover > .process-step-detail .step-action-buttons {
142 opacity: 1;
143 pointer-events: auto;
webui/css/messages.css
-21
@@ -846,24 +846,3 @@
846 opacity: 0;
847 }
848
849 -/* Expand button - hidden by default, shown when overflow */
850 -.message.message-collapsible .expand-btn {
851 - display: none;
852 - background: transparent;
853 - border: none;
854 - color: var(--color-text-muted);
855 - font-family: "Rubik", Arial, Helvetica, sans-serif;
856 - font-size: var(--font-size-xs);
857 - cursor: pointer;
858 - padding: var(--spacing-xs) 0;
859 - opacity: 0.7;
860 - transition: opacity 0.15s ease;
861 -}
862 -
863 -.message.message-collapsible .expand-btn:hover {
864 - opacity: 1;
865 -}
866 -
867 -.message.message-collapsible.has-overflow .expand-btn {
868 - display: block;
869 -}
webui/js/messages.js
+40 -81
@@ -518,45 +518,8 @@ function drawStandaloneMessage({
518 mainClass,
519 });
520
521 - // Collapsible: show ~10 lines with fade, expand button reveals full content
522 - messageDiv.classList.add("message-collapsible");
523 -
524 - const expandBtn = ensureChild(
525 - messageDiv,
526 - ".expand-btn",
527 - "button",
528 - "expand-btn",
529 - );
530 - expandBtn.textContent = messageDiv.classList.contains("expanded")
531 - ? "Show less"
532 - : "Show more";
533 - expandBtn.onclick = () => {
534 - messageDiv.classList.toggle("expanded");
535 - expandBtn.textContent = messageDiv.classList.contains("expanded")
536 - ? "Show less"
537 - : "Show more";
538 - };
539 -
540 - // Detect overflow after render - CSS handles visibility based on .has-overflow class
541 - requestAnimationFrame(() => {
542 - const body = messageDiv.querySelector(".message-body");
543 - messageDiv.classList.toggle(
544 - "has-overflow",
545 - body.scrollHeight > body.clientHeight,
546 - );
547 - });
548 -
549 - // Render action buttons: get/create container, clear, append
550 - const actionButtonsContainer = ensureChild(
551 - messageDiv,
552 - ".step-action-buttons",
553 - "div",
554 - "step-action-buttons",
555 - );
556 - actionButtonsContainer.textContent = "";
557 - (actionButtons || [])
558 - .filter(Boolean)
559 - .forEach((button) => actionButtonsContainer.appendChild(button));
521 + // Collapsible with action buttons
522 + setupCollapsible(messageDiv, ".step-action-buttons", false, actionButtons);
523
524 return container;
525 }
@@ -581,8 +544,10 @@ export function _drawMessage({
544 messageContainer.appendChild(messageDiv);
545 }
546
584 - // Update message classes
585 - messageDiv.className = `message ${mainClass} ${messageClasses.join(" ")}`;
547 + // Update message classes (preserve collapsible state)
548 + const preserve = ["message-collapsible", "expanded", "has-overflow"]
549 + .filter((c) => messageDiv.classList.contains(c)).join(" ");
550 + messageDiv.className = `message ${mainClass} ${messageClasses.join(" ")} ${preserve}`;
551
552 // Handle heading (important for error/rate_limit messages that show context)
553 if (heading) {
@@ -855,35 +820,7 @@ export function drawMessageResponse({
820 mainClass: "message-agent-response",
821 });
822
858 - // Collapsible: show ~10 lines with fade, expand button reveals full content
859 - messageDiv.classList.add("message-collapsible");
860 -
861 - const expandBtn = ensureChild(
862 - messageDiv,
863 - ".expand-btn",
864 - "button",
865 - "expand-btn",
866 - );
867 - expandBtn.textContent = messageDiv.classList.contains("expanded")
868 - ? "Show less"
869 - : "Show more";
870 - expandBtn.onclick = () => {
871 - messageDiv.classList.toggle("expanded");
872 - expandBtn.textContent = messageDiv.classList.contains("expanded")
873 - ? "Show less"
874 - : "Show more";
875 - };
876 -
877 - // Detect overflow after render - CSS handles visibility based on .has-overflow class
878 - requestAnimationFrame(() => {
879 - const body = messageDiv.querySelector(".message-body");
880 - messageDiv.classList.toggle(
881 - "has-overflow",
882 - body.scrollHeight > body.clientHeight,
883 - );
884 - });
885 -
886 - // Render action buttons: get/create container, clear, append
823 + // Collapsible with action buttons
824 const responseText = String(content ?? "");
825 const responseActionButtons = responseText.trim()
826 ? [
@@ -891,17 +828,7 @@ export function drawMessageResponse({
828 createActionButton("copy", "", () => copyToClipboard(responseText)),
829 ].filter(Boolean)
830 : [];
894 - // Look for direct child only to avoid finding nested code block buttons
895 - const actionButtonsContainer = ensureChild(
896 - messageDiv,
897 - ":scope > .step-action-buttons",
898 - "div",
899 - "step-action-buttons",
900 - );
901 - actionButtonsContainer.textContent = "";
902 - responseActionButtons.forEach((button) =>
903 - actionButtonsContainer.appendChild(button),
904 - );
831 + setupCollapsible(messageDiv, ":scope > .step-action-buttons", !isMassRender(), responseActionButtons);
832
833 if (group) updateProcessGroupHeader(group);
834
@@ -2199,6 +2126,38 @@ function ensureChild(parent, selector, tagName, ...classNames) {
2126 return el;
2127 }
2128
2129 +// Setup collapsible message with expand button and action buttons
2130 +function setupCollapsible(messageDiv, containerSelector, initialExpanded, actionButtons = []) {
2131 + messageDiv.classList.add("message-collapsible");
2132 + messageDiv.classList.toggle("expanded", initialExpanded);
2133 +
2134 + const container = ensureChild(messageDiv, containerSelector, "div", "step-action-buttons");
2135 + container.textContent = "";
2136 +
2137 + const btn = ensureChild(container, ".expand-btn", "button", "expand-btn");
2138 + const syncBtn = () => {
2139 + const exp = messageDiv.classList.contains("expanded");
2140 + btn.textContent = exp ? "Show less" : "Show more";
2141 + btn.classList.toggle("show-less-btn", exp);
2142 + btn.classList.toggle("show-more-btn", !exp);
2143 + };
2144 + syncBtn();
2145 + btn.onclick = () => { messageDiv.classList.toggle("expanded"); syncBtn(); };
2146 +
2147 + actionButtons.filter(Boolean).forEach((b) => container.appendChild(b));
2148 +
2149 + // Detect overflow after render (skip if already detected to avoid scroll disruption)
2150 + if (!messageDiv.classList.contains("has-overflow")) {
2151 + requestAnimationFrame(() => {
2152 + const body = messageDiv.querySelector(".message-body");
2153 + const wasExp = messageDiv.classList.contains("expanded");
2154 + messageDiv.classList.remove("expanded");
2155 + messageDiv.classList.toggle("has-overflow", body?.scrollHeight > body?.clientHeight);
2156 + messageDiv.classList.toggle("expanded", wasExp);
2157 + });
2158 + }
2159 +}
2160 +
2161 // returns true if this is the initial render of a chat eg. when reloading window, switching chat or catching up after a break
2162 // returns false when already in a rendered chat and adding messages regurarly
2163 function isMassRender() {