smooth response rendering

frdel committed Jan 29, 2026 at 21:27 UTC 25ed7714b08a2a652a5b86546d4cabe13cb2795c
3 files changed +269 -203
webui/css/messages.css
+53 -19
@@ -11,7 +11,7 @@
11 overflow-x: hidden;
12 scroll-behavior: auto !important; /* avoid infinite scrolling! */
13 padding-left: var(--spacing-sm) !important;
14 - padding-right: var(--spacing-md) !important;
14 + padding-right: var(--spacing-md) !important;
15 padding-top: 4rem !important;
16 padding-bottom: 4rem !important;
17 -webkit-transition: all 0.3s ease;
@@ -109,7 +109,6 @@
109 color: var(--color-text);
110 }
111
112 -
112 /* .light-mode .message-user .message-text pre {
113 color: #2e2e2e;
114 } */
@@ -430,12 +429,12 @@
429 width: 100%;
430 }
431
433 -.kvps-val{
432 +.kvps-val {
433 font-size: 0.75rem;
434 }
435
437 -.kvps-val p{
438 - margin:0;
436 +.kvps-val p {
437 + margin: 0;
438 }
439
440 .kvps-val pre {
@@ -519,7 +518,7 @@
518 text-align: right;
519 }
520
522 -.kvps-key .material-symbols-outlined{
521 +.kvps-key .material-symbols-outlined {
522 font-size: var(--font-size-smaller);
523 }
524
@@ -542,14 +541,12 @@
541 width: 100%;
542 }
543
545 -
544 .msg-content {
545 margin-bottom: 0;
546 padding: 0;
547 overflow: hidden;
548 }
549
552 -
550 /* Math (KaTeX) */
551 .katex {
552 line-height: 1.2 !important;
@@ -573,11 +570,11 @@
570
571 @media (max-width: 1024px) {
572 #chat-history {
576 - padding-left: var(--spacing-sm) !important;
577 - padding-right: var(--spacing-md) !important;
578 - padding-top: 4rem !important;
579 - padding-bottom: 2rem !important;
580 -}
573 + padding-left: var(--spacing-sm) !important;
574 + padding-right: var(--spacing-md) !important;
575 + padding-top: 4rem !important;
576 + padding-bottom: 3rem !important;
577 + }
578 }
579
580 @media (max-width: 640px) {
@@ -700,25 +697,25 @@
697 margin: 1em 0;
698 font-size: 0.98em;
699 background: transparent;
703 - border-radius: 8px;
700 + border-radius: var(--border-radius-sm);
701 overflow: hidden;
702 }
703
704 .msg-content th,
705 .msg-content td {
706 padding: 0.55em 1em;
710 - border: 1px solid rgba(142, 142, 142, 0.1);
707 + border: 1px solid var(--color-border);
708 text-align: left;
709 background: transparent;
710 }
711
712 .msg-content th {
713 font-weight: 600;
717 - background: rgba(142, 142, 142, 0.1);
714 + background: var(--color-table-row);
715 }
716
717 .msg-content tr:nth-child(even) {
721 - background: rgba(142, 142, 142, 0.1);
718 + background: var(--color-table-row);
719 }
720
721 .msg-content tr:nth-child(odd) {
@@ -731,8 +728,8 @@
728
729 .msg-content pre:has(code) {
730 padding: 0.5em;
734 - border: 1px solid rgba(142, 142, 142, 0.1);
735 - border-radius: 0.3em;
731 + border: 1px solid var(--color-border);
732 + border-radius: var(--border-radius-sm);
733 }
734
735 .msg-content hr {
@@ -846,3 +843,40 @@
843 opacity: 0;
844 }
845
846 +/* Smooth Render */
847 +.smooth-render-visible,
848 +.smooth-render-invisible {
849 + position: absolute;
850 + width:100%;
851 + inset: 0;
852 + opacity: 0;
853 +}
854 +
855 +.smooth-render-visible {
856 + z-index: 2;
857 + animation: smoothFadeIn 200ms ease-out forwards;
858 +}
859 +
860 +.smooth-render-invisible {
861 + z-index: 1;
862 + opacity: 1;
863 + animation: smoothFadeOut 70ms ease-out forwards;
864 + animation-delay: 100ms;
865 +}
866 +
867 +@keyframes smoothFadeIn {
868 + from {
869 + opacity: 0;
870 + }
871 + to {
872 + opacity: 1;
873 + }
874 +}
875 +@keyframes smoothFadeOut {
876 + from {
877 + opacity: 1;
878 + }
879 + to {
880 + opacity: 0;
881 + }
882 +}
webui/index.css
+6 -2
@@ -24,6 +24,7 @@
24 --color-input-focus-dark: #101010;
25 --color-chat-background-dark: #212121;
26 --color-error-text-dark:#e72323;
27 + --color-table-row-dark: #272727;
28
29 /* Light mode */
30 --color-background-light: #fafafa;
@@ -39,8 +40,9 @@
40 --color-border-light: #bdbdbdcf;
41 --color-input-light: #e4e4e4;
42 --color-input-focus-light: #dadada;
42 - --color-chat-background-light: #fafafaf3;
43 + --color-chat-background-light: #fafafa;
44 --color-error-text-light:#920000;
45 + --color-table-row-light: #edededf3;
46
47
48 /* Default to dark mode */
@@ -60,6 +62,7 @@
62 --color-background-hover: color-mix(in srgb, var(--color-border) 50%, transparent);
63 --color-chat-background: var(--color-chat-background-dark);
64 --color-error-text:var(--color-error-text-dark);
65 + --color-table-row: var(--color-table-row-dark);
66
67 /* Spacing variables */
68 --spacing-xxs: 0.15rem;
@@ -105,7 +108,8 @@
108 --color-background-hover: color-mix(in srgb, var(--color-border) 50%, transparent);
109 --color-chat-background: var(--color-chat-background-light);
110 --color-error-text:var(--color-error-text-light);
108 - }
111 + --color-table-row: var(--color-table-row-light);
112 +}
113
114
115 /* Reset and Base Styles */
webui/js/messages.js
+210 -182
@@ -14,6 +14,7 @@ import { formatDuration } from "./time-utils.js";
14
15 // Delay before collapsing previous steps when a new step is added
16 const STEP_COLLAPSE_DELAY_MS = 3000;
17 +const STEP_COLLAPSE_HOVER_DELAY_MS = 5000;
18 // Delay before collapsing the last step when processing completes
19 const FINAL_STEP_COLLAPSE_DELAY_MS = 3000;
20
@@ -131,7 +132,6 @@ function getOrCreateMessageContainer(
132 return container;
133 }
134
134 -
135 function getChatHistoryEl() {
136 if (!_chatHistory) _chatHistory = document.getElementById("chat-history");
137 return _chatHistory;
@@ -169,8 +169,7 @@ function getLastProcessGroup(allowCompleted = true) {
169 const groups = lastContainer.querySelectorAll(".process-group");
170 if (groups.length === 0) return null;
171 const group = groups[groups.length - 1];
172 - if (!allowCompleted && group.classList.contains("process-group-completed"))
173 - return null;
172 + if (!allowCompleted && isProcessGroupComplete(group)) return null;
173
174 return group;
175 }
@@ -223,11 +222,14 @@ function drawProcessStep({
222 ...additional
223 }) {
224 // group and steps DOM elements
226 - const group = getOrCreateProcessGroup(id, allowCompletedGroup);
227 - const stepsContainer = group.querySelector(".process-steps");
225 const stepId = `process-step-${id}`;
226 let step = document.getElementById(stepId);
227
228 + const group =
229 + getStepProcessGroup(step) ||
230 + getOrCreateProcessGroup(id, allowCompletedGroup);
231 + const stepsContainer = group.querySelector(".process-steps");
232 +
233 const isNewStep = !step;
234 const isGroupComplete = isProcessGroupComplete(group);
235
@@ -262,9 +264,6 @@ function drawProcessStep({
264 // insert step
265 appendTarget.appendChild(step);
266
265 - // add interaction handlers - don't collapse when user interacts
266 - addStepCollapseInteractionHandlers(step); // TODO? cleanup listeners?
267 -
267 // expand all or current step based on settings
268 const detailMode = preferencesStore.detailMode;
269 // const isActiveGroup = group.classList.contains("active");
@@ -287,6 +286,14 @@ function drawProcessStep({
286 });
287 step.classList.add("expanded");
288 }
289 +
290 + // create step header
291 + const stepHeader = ensureChild(
292 + step,
293 + ".process-step-header",
294 + "div",
295 + "process-step-header",
296 + );
297 }
298
299 // is step expanded?
@@ -314,20 +321,8 @@ function drawProcessStep({
321 "process-step-detail-scroll",
322 );
323
317 - // expand/collapse handler for step header
318 - if (!stepHeader.hasAttribute("data-expand-handler")) {
319 - stepHeader.setAttribute("data-expand-handler", "true");
320 - stepHeader.addEventListener("click", (e) => {
321 - e.stopPropagation();
322 - cancelStepCollapse(step);
323 - step.classList.toggle("expanded");
324 - if (step.classList.contains("expanded")) {
325 - step.setAttribute("data-user-pinned", "true");
326 - } else {
327 - step.removeAttribute("data-user-pinned");
328 - }
329 - });
330 - }
324 + // set click handlers
325 + setupProcessStepHandlers(step, stepHeader);
326
327 // header row - expand icon
328 ensureChild(stepHeader, ".step-expand-icon", "span", "step-expand-icon");
@@ -421,6 +416,9 @@ function drawStandaloneMessage({
416 kvps = null,
417 actionButtons = [],
418 }) {
419 + // end last process group on any standalone messge
420 + completeLastProcessGroup();
421 +
422 const container = getOrCreateMessageContainer(
423 id,
424 position,
@@ -456,6 +454,7 @@ export function _drawMessage({
454 markdown = false,
455 latex = false,
456 mainClass = "",
457 + smoothStream = false,
458 }) {
459 // Find existing message div or create new one
460 let messageDiv = messageContainer.querySelector(".message");
@@ -467,7 +466,8 @@ export function _drawMessage({
466
467 // Update message classes (preserve collapsible state)
468 const preserve = ["message-collapsible", "expanded", "has-overflow"]
470 - .filter((c) => messageDiv.classList.contains(c)).join(" ");
469 + .filter((c) => messageDiv.classList.contains(c))
470 + .join(" ");
471 messageDiv.className = `message ${mainClass} ${messageClasses.join(" ")} ${preserve}`;
472
473 // Handle heading (important for error/rate_limit messages that show context)
@@ -517,11 +517,11 @@ export function _drawMessage({
517 }
518 contentDiv.className = `msg-content ${contentClasses.join(" ")}`;
519
520 - let spanElement = contentDiv.querySelector("span");
521 - if (!spanElement) {
522 - spanElement = document.createElement("span");
523 - contentDiv.appendChild(spanElement);
524 - }
520 + // let spanElement = contentDiv.querySelector("span");
521 + // if (!spanElement) {
522 + // spanElement = document.createElement("span");
523 + // contentDiv.appendChild(spanElement);
524 + // }
525
526 let processedContent = content;
527 processedContent = convertImageTags(processedContent);
@@ -531,11 +531,13 @@ export function _drawMessage({
531 processedContent = convertPathsToLinks(processedContent);
532 processedContent = addBlankTargetsToLinks(processedContent);
533
534 - spanElement.innerHTML = processedContent;
534 + // do a smooth stream if requested
535 + if (smoothStream) smoothRender(contentDiv, processedContent);
536 + else contentDiv.innerHTML = processedContent;
537
538 // KaTeX rendering for markdown
539 if (latex) {
538 - spanElement.querySelectorAll("latex").forEach((element) => {
540 + contentDiv.querySelectorAll("latex").forEach((element) => {
541 katex.render(element.innerHTML, element, {
542 throwOnError: false,
543 });
@@ -556,13 +558,14 @@ export function _drawMessage({
558 preElement.className = `msg-content ${contentClasses.join(" ")}`;
559 }
560
559 - let spanElement = preElement.querySelector("span");
560 - if (!spanElement) {
561 - spanElement = document.createElement("span");
562 - preElement.appendChild(spanElement);
563 - }
561 + // let spanElement = preElement.querySelector("span");
562 + // if (!spanElement) {
563 + // spanElement = document.createElement("span");
564 + // preElement.appendChild(spanElement);
565 + // }
566
565 - spanElement.innerHTML = convertHTML(content);
567 + if (smoothStream) smoothRender(preElement, convertHTML(content));
568 + else preElement.innerHTML = convertHTML(content);
569 }
570 } else {
571 // Remove content if it exists but content is empty
@@ -650,17 +653,22 @@ export function drawMessageAgent({
653 const headerLabels = [
654 kvps?.tool_name && { label: kvps.tool_name, class: "tool-name-badge" },
655 ].filter(Boolean);
653 - const actionButtons = thoughtsText.trim()
654 - ? [
655 - createActionButton("detail", "", () =>
656 - stepDetailStore.showStepDetail(
657 - buildDetailPayload(arguments[0], { headerLabels }),
658 - ),
659 - ),
660 - createActionButton("speak", "", () => speechStore.speak(thoughtsText)),
661 - createActionButton("copy", "", () => copyToClipboard(thoughtsText)),
662 - ].filter(Boolean)
663 - : [];
656 + const actionButtons = [
657 + createActionButton("detail", "", () =>
658 + stepDetailStore.showStepDetail(
659 + buildDetailPayload(arguments[0], { headerLabels }),
660 + ),
661 + ),
662 + ];
663 +
664 + if (thoughtsText.trim()) {
665 + actionButtons.push(
666 + createActionButton("speak", "", () => speechStore.speak(thoughtsText)),
667 + );
668 + actionButtons.push(
669 + createActionButton("copy", "", () => copyToClipboard(thoughtsText)),
670 + );
671 + }
672
673 return drawProcessStep({
674 id,
@@ -714,17 +722,20 @@ export function drawMessageResponse({
722 let container = null;
723
724 if (group) {
725 + // new response, collapse all previous steps once
726 + if (!group.querySelector(".process-group-response")) {
727 + if (preferencesStore.detailMode == "current")
728 + group.querySelectorAll(".process-step").forEach((step) => {
729 + scheduleStepCollapse(step);
730 + });
731 + }
732 +
733 container = ensureChild(
734 group,
735 ".process-group-response",
736 "div",
737 "process-group-response",
738 );
723 - //collapse all steps when response is ready
724 - if(preferencesStore.detailMode!=="expanded")
725 - group.querySelectorAll(".process-step").forEach((step) => {
726 - scheduleStepCollapse(step);
727 - });
739 } else container = getOrCreateMessageContainer(id, "left");
740
741 const messageDiv = _drawMessage({
@@ -737,6 +748,7 @@ export function drawMessageResponse({
748 markdown: true,
749 latex: true,
750 mainClass: "message-agent-response",
751 + smoothStream: !isMassRender(), // stream smoothly if not in mass render mode
752 });
753
754 // Collapsible with action buttons
@@ -747,7 +759,12 @@ export function drawMessageResponse({
759 createActionButton("copy", "", () => copyToClipboard(responseText)),
760 ].filter(Boolean)
761 : [];
750 - setupCollapsible(messageDiv, ":scope > .step-action-buttons", !isMassRender(), responseActionButtons);
762 + setupCollapsible(
763 + messageDiv,
764 + ":scope > .step-action-buttons",
765 + !isMassRender(),
766 + responseActionButtons,
767 + );
768
769 if (group) updateProcessGroupHeader(group);
770
@@ -768,6 +785,9 @@ export function drawMessageUser({
785 true,
786 );
787
788 + // end last process group on any user message
789 + completeLastProcessGroup();
790 +
791 // Find existing message div or create new one
792 let messageDiv = messageContainer.querySelector(".message");
793 if (!messageDiv) {
@@ -1121,7 +1141,7 @@ export function drawMessageInfo({
1141 kvps = null,
1142 ...additional
1143 }) {
1124 - const title = cleanStepTitle(heading);
1144 + const title = cleanStepTitle(heading || content);
1145 let displayKvps = { ...kvps };
1146 const contentText = String(content ?? "");
1147 const actionButtons = contentText.trim()
@@ -1154,7 +1174,7 @@ export function drawMessageUtil({
1174 agentno = 0,
1175 ...additional
1176 }) {
1157 - const title = cleanStepTitle(heading);
1177 + const title = cleanStepTitle(heading || content);
1178 const contentText = String(content ?? "");
1179 const actionButtons = contentText.trim()
1180 ? [
@@ -1220,7 +1240,7 @@ export function drawMessageProgress({
1240 agentno = 0,
1241 ...additional
1242 }) {
1223 - const title = cleanStepTitle(heading);
1243 + const title = cleanStepTitle(heading || content);
1244 let displayKvps = { ...kvps };
1245
1246 return drawProcessStep({
@@ -1243,7 +1263,7 @@ export function drawMessageWarning({
1263 kvps = null,
1264 ...additional
1265 }) {
1246 - const title = cleanStepTitle(heading);
1266 + const title = cleanStepTitle(heading || content);
1267 let displayKvps = { ...kvps };
1268 const contentText = String(content ?? "");
1269 const actionButtons = contentText.trim()
@@ -1544,15 +1564,15 @@ function adjustMarkdownRender(element) {
1564 // find all tables in the element
1565 const tables = element.querySelectorAll("table");
1566 tables.forEach((el) => {
1547 - const wrapper = wrapElement(el, "message-markdown-table-wrap");
1548 - const actionsDiv = document.createElement("div");
1549 - actionsDiv.className = "step-action-buttons";
1550 - actionsDiv.appendChild(
1551 - createActionButton("copy", "", () =>
1552 - copyToClipboard(extractTableTSV(el)),
1553 - ),
1554 - );
1555 - wrapper.appendChild(actionsDiv);
1567 + // const wrapper = wrapElement(el, "message-markdown-table-wrap");
1568 + // const actionsDiv = document.createElement("div");
1569 + // actionsDiv.className = "step-action-buttons";
1570 + // actionsDiv.appendChild(
1571 + // createActionButton("copy", "", () =>
1572 + // copyToClipboard(extractTableTSV(el)),
1573 + // ),
1574 + // );
1575 + // wrapper.appendChild(actionsDiv);
1576 });
1577
1578 // find all code blocks
@@ -1560,12 +1580,12 @@ function adjustMarkdownRender(element) {
1580 codeElements.forEach((code) => {
1581 const pre = code.parentNode;
1582 const wrapper = wrapElement(pre, "code-block-wrapper");
1563 - const actionsDiv = document.createElement("div");
1564 - actionsDiv.className = "step-action-buttons";
1565 - actionsDiv.appendChild(
1566 - createActionButton("copy", "", () => copyToClipboard(code.textContent)),
1567 - );
1568 - wrapper.appendChild(actionsDiv);
1583 + // const actionsDiv = document.createElement("div");
1584 + // actionsDiv.className = "step-action-buttons";
1585 + // actionsDiv.appendChild(
1586 + // createActionButton("copy", "", () => copyToClipboard(code.textContent)),
1587 + // );
1588 + // wrapper.appendChild(actionsDiv);
1589 });
1590
1591 // find all images
@@ -1689,22 +1709,58 @@ function getNestedContainer(parentStep) {
1709 */
1710 function scheduleStepCollapse(stepElement, delayMs) {
1711 // skip if any existing timeout for this step
1692 - if (stepElement.hasAttribute("data-collapse-timeout-id")) {
1693 - return;
1694 - }
1712 + if (stepElement.hasAttribute("data-collapse-timeout-id")) return;
1713 + // skip already collapsed steps
1714 + if (!stepElement.classList.contains("expanded")) return;
1715
1716 // Schedule the collapse
1717 const timeoutId = setTimeout(() => {
1698 - stepElement.classList.remove("expanded");
1718 stepElement.removeAttribute("data-collapse-timeout-id");
1700 - // Clear user-pinned flag when auto-collapsing
1701 - stepElement.removeAttribute("data-user-pinned");
1719 +
1720 + if (stepElement.dataset.clicked === "true") {
1721 + console.log(`Skip clicked collapse: ${stepElement.id}`);
1722 + return;
1723 + }
1724 +
1725 + if (stepElement.matches(":hover")) {
1726 + console.log(`Delay hover collapse: ${stepElement.id}`);
1727 + scheduleStepCollapse(stepElement, STEP_COLLAPSE_HOVER_DELAY_MS);
1728 + return;
1729 + }
1730 +
1731 + console.log(`Collapse step: ${stepElement.id}`);
1732 + stepElement.classList.remove("expanded");
1733 }, delayMs);
1734
1735 // Store the timeout ID
1736 stepElement.setAttribute("data-collapse-timeout-id", String(timeoutId));
1737 }
1738
1739 +function setupProcessStepHandlers(stepElement, stepHeader) {
1740 + if (!stepElement.hasAttribute("data-step-handlers")) {
1741 + stepElement.setAttribute("data-step-handlers", "true");
1742 +
1743 + stepElement.addEventListener(
1744 + "click",
1745 + function handler() {
1746 + stepElement.dataset.clicked = "true";
1747 + console.log(`Step clicked: ${stepElement.id}`);
1748 + },
1749 + { once: true },
1750 + );
1751 + }
1752 +
1753 + if (stepHeader && !stepHeader.hasAttribute("data-expand-handler")) {
1754 + stepHeader.setAttribute("data-expand-handler", "true");
1755 + stepHeader.addEventListener("click", (e) => {
1756 + e.stopPropagation();
1757 + cancelStepCollapse(stepElement);
1758 + stepElement.dataset.clicked = "true";
1759 + stepElement.classList.toggle("expanded");
1760 + });
1761 + }
1762 +}
1763 +
1764 /**
1765 * Cancel a scheduled collapse for a step
1766 */
@@ -1716,46 +1772,6 @@ function cancelStepCollapse(stepElement) {
1772 stepElement.removeAttribute("data-collapse-timeout-id");
1773 }
1774
1719 -/**
1720 - * Add interaction handlers to prevent fighting with user
1721 - * - Hover: cancels the collapse timeout (keeps step open while reading)
1722 - * - Leave: starts a new timeout ONLY if user hasn't clicked (no explicit interaction)
1723 - * - Click anywhere on step: permanently cancels auto-collapse (user wants it open)
1724 - */
1725 -function addStepCollapseInteractionHandlers(stepElement) {
1726 - // On hover, cancel the timeout to keep it open while user is reading
1727 - stepElement.addEventListener("mouseenter", () => {
1728 - if (stepElement.classList.contains("expanded")) {
1729 - cancelStepCollapse(stepElement);
1730 - }
1731 - });
1732 -
1733 - // On leave, start a new timeout ONLY if user hasn't explicitly clicked
1734 - // and only in "current" mode (in "expanded" mode, everything stays open)
1735 - stepElement.addEventListener("mouseleave", () => {
1736 - const detailMode = preferencesStore.detailMode;
1737 - // Don't schedule collapse in "expanded" mode - user wants everything open
1738 - if (detailMode === "expanded") return;
1739 -
1740 - // Don't restart timeout if user has explicitly interacted (clicked)
1741 - if (
1742 - stepElement.classList.contains("expanded") &&
1743 - !stepElement.hasAttribute("data-user-pinned")
1744 - ) {
1745 - scheduleStepCollapse(stepElement, STEP_COLLAPSE_DELAY_MS);
1746 - }
1747 - });
1748 -
1749 - // On click anywhere on step, permanently cancel auto-collapse
1750 - stepElement.addEventListener("click", () => {
1751 - if (stepElement.classList.contains("expanded")) {
1752 - cancelStepCollapse(stepElement);
1753 - // Mark as user-pinned so mouseleave won't restart timeout
1754 - stepElement.setAttribute("data-user-pinned", "true");
1755 - }
1756 - });
1757 -}
1758 -
1775 /**
1776 * Find parent delegation step for nested agents (DOM-first, reverse scan).
1777 */
@@ -1958,10 +1974,25 @@ function updateProcessGroupHeader(group) {
1974 }
1975
1976 function isProcessGroupComplete(group) {
1977 + // manually closed group
1978 + if (group?.hasAttribute?.("data-group-complete")) return true;
1979 + // naturally completed group
1980 const response = group.querySelector(".process-group-response");
1981 return !!response;
1982 }
1983
1984 +// manually complete last process group
1985 +export function completeLastProcessGroup() {
1986 + const group = getLastProcessGroup();
1987 + if (!group || isProcessGroupComplete(group)) return;
1988 + group.setAttribute("data-group-complete", "true");
1989 + updateProcessGroupHeader(group);
1990 +}
1991 +
1992 +function getStepProcessGroup(step) {
1993 + return step?.closest(".process-group");
1994 +}
1995 +
1996 /**
1997 * Truncate text to a maximum length
1998 */
@@ -1972,66 +2003,6 @@ function truncateText(text, maxLength) {
2003 return text.substring(0, maxLength - 3) + "...";
2004 }
2005
1975 -/**
1976 - * Mark a process group as complete (END state)
1977 - */
1978 -// function markProcessGroupComplete(group, responseTitle) {
1979 -// if (!group) return;
1980 -
1981 -// // Update status badge to END
1982 -// const statusEl = group.querySelector(".group-status");
1983 -// if (statusEl) {
1984 -// // statusEl.innerHTML = '<span class="badge-icon material-symbols-outlined">check</span>END';
1985 -// statusEl.innerHTML = "END";
1986 -// statusEl.className = "step-badge status-end group-status";
1987 -// }
1988 -
1989 -// // Update title if response title is available
1990 -// const titleEl = group.querySelector(".group-title");
1991 -// if (titleEl && responseTitle) {
1992 -// const cleanTitle = cleanStepTitle(responseTitle, 50);
1993 -// if (cleanTitle) {
1994 -// titleEl.textContent = cleanTitle;
1995 -// }
1996 -// }
1997 -
1998 -// // Add completed class to group
1999 -// group.classList.add("process-group-completed");
2000 -
2001 -// // Collapse all expanded steps when processing is done (in "current" mode) with delay
2002 -// const detailMode = preferencesStore.detailMode;
2003 -// if (detailMode === "current") {
2004 -// // Schedule collapse for all expanded steps (deterministic)
2005 -// const allExpandedSteps = group.querySelectorAll(
2006 -// ".process-step.expanded",
2007 -// );
2008 -// allExpandedSteps.forEach((expandedStep) => {
2009 -// scheduleStepCollapse(expandedStep, FINAL_STEP_COLLAPSE_DELAY_MS);
2010 -// });
2011 -// }
2012 -
2013 -// // Calculate final duration from backend data (difference between first and last timestamps)
2014 -// const steps = group.querySelectorAll(".process-step");
2015 -// const firstTimestampMs = parseInt(
2016 -// steps[0]?.getAttribute("data-timestamp") || "0",
2017 -// 10,
2018 -// );
2019 -// const lastTimestampMs = parseInt(
2020 -// steps[steps.length - 1]?.getAttribute("data-timestamp") || "0",
2021 -// 10,
2022 -// );
2023 -// const totalDurationMs = Math.max(0, lastTimestampMs - firstTimestampMs);
2024 -
2025 -// // Update duration metric with final value from backend
2026 -// const metricsEl = group.querySelector(".group-metrics");
2027 -// const durationMetricEl = metricsEl?.querySelector(
2028 -// ".metric-duration .metric-value",
2029 -// );
2030 -// if (durationMetricEl && totalDurationMs > 0) {
2031 -// durationMetricEl.textContent = formatDuration(totalDurationMs);
2032 -// }
2033 -// }
2034 -
2006 // gets or creates a child DOM element
2007 function ensureChild(parent, selector, tagName, ...classNames) {
2008 let el = parent.querySelector(selector);
@@ -2044,11 +2015,21 @@ function ensureChild(parent, selector, tagName, ...classNames) {
2015 }
2016
2017 // Setup collapsible message with expand button and action buttons
2047 -function setupCollapsible(messageDiv, containerSelector, initialExpanded, actionButtons = []) {
2018 +function setupCollapsible(
2019 + messageDiv,
2020 + containerSelector,
2021 + initialExpanded,
2022 + actionButtons = [],
2023 +) {
2024 messageDiv.classList.add("message-collapsible");
2025 messageDiv.classList.toggle("expanded", initialExpanded);
2026
2051 - const container = ensureChild(messageDiv, containerSelector, "div", "step-action-buttons");
2027 + const container = ensureChild(
2028 + messageDiv,
2029 + containerSelector,
2030 + "div",
2031 + "step-action-buttons",
2032 + );
2033 container.textContent = "";
2034
2035 const btn = ensureChild(container, ".expand-btn", "button", "expand-btn");
@@ -2059,7 +2040,10 @@ function setupCollapsible(messageDiv, containerSelector, initialExpanded, action
2040 btn.classList.toggle("show-more-btn", !exp);
2041 };
2042 syncBtn();
2062 - btn.onclick = () => { messageDiv.classList.toggle("expanded"); syncBtn(); };
2043 + btn.onclick = () => {
2044 + messageDiv.classList.toggle("expanded");
2045 + syncBtn();
2046 + };
2047
2048 actionButtons.filter(Boolean).forEach((b) => container.appendChild(b));
2049
@@ -2069,7 +2053,10 @@ function setupCollapsible(messageDiv, containerSelector, initialExpanded, action
2053 const body = messageDiv.querySelector(".message-body");
2054 const wasExp = messageDiv.classList.contains("expanded");
2055 messageDiv.classList.remove("expanded");
2072 - messageDiv.classList.toggle("has-overflow", body?.scrollHeight > body?.clientHeight);
2056 + messageDiv.classList.toggle(
2057 + "has-overflow",
2058 + body?.scrollHeight > body?.clientHeight,
2059 + );
2060 messageDiv.classList.toggle("expanded", wasExp);
2061 });
2062 }
@@ -2080,3 +2067,44 @@ function setupCollapsible(messageDiv, containerSelector, initialExpanded, action
2067 function isMassRender() {
2068 return _massRender;
2069 }
2070 +
2071 +// smooth fade in animation for new chunks when streaming
2072 +function smoothRender(element, newContent, delay = 350) {
2073 + // skip on mass render
2074 + if (isMassRender()) {
2075 + element.innerHTML = newContent;
2076 + return;
2077 + }
2078 +
2079 + element.dataset.smoothPendingHtml = newContent;
2080 +
2081 + if (element.dataset.smoothTimeoutId) return;
2082 +
2083 + const timeoutId = window.setTimeout(() => {
2084 + const pending = element.dataset.smoothPendingHtml || "";
2085 + delete element.dataset.smoothPendingHtml;
2086 + delete element.dataset.smoothTimeoutId;
2087 +
2088 + const existing = element.querySelector(
2089 + ":scope > div.smooth-render-visible",
2090 + );
2091 + if (existing) {
2092 + existing.classList.remove("smooth-render-visible");
2093 + existing.classList.add("smooth-render-invisible");
2094 +
2095 + existing.addEventListener("animationend", () => existing.remove(), {
2096 + once: true,
2097 + });
2098 + }
2099 +
2100 + const nextLayer = document.createElement("div");
2101 + nextLayer.className = "smooth-render-visible";
2102 + nextLayer.innerHTML = pending;
2103 + element.appendChild(nextLayer);
2104 +
2105 + // Keep container height stable while layers are absolute
2106 + element.style.height = `${nextLayer.scrollHeight}px`;
2107 + }, delay);
2108 +
2109 + element.dataset.smoothTimeoutId = String(timeoutId);
2110 +}