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
+}