fix autoexpand, generation step info

frdel committed Jan 28, 2026 at 17:46 UTC 2edb3e511ac903f527d076ad70a648a9e50c8b1c
5 files changed +162 -131
python/extensions/before_main_llm_call/_10_log_for_stream.py
+1 -1
@@ -25,4 +25,4 @@ def build_heading(agent, text: str, icon: str = "network_intelligence"):
25 return f"{agent_prefix}{text}"
26
27 def build_default_heading(agent):
28 - return build_heading(agent, "Generating...")
\ No newline at end of file
28 + return build_heading(agent, "Calling LLM...")
\ No newline at end of file
python/extensions/reasoning_stream/_10_log_from_stream.py
+3 -2
@@ -13,7 +13,7 @@ class LogFromStream(Extension):
13
14 # thought length indicator
15 pipes = "|" * math.ceil(math.sqrt(len(text)))
16 - heading = build_heading(self.agent, f"Reasoning.. {pipes}")
16 + heading = build_heading(self.agent, f"Reasoning... {pipes}")
17
18 # create log message and store it in loop data temporary params
19 if "log_item_generating" not in loop_data.params_temporary:
@@ -21,9 +21,10 @@ class LogFromStream(Extension):
21 self.agent.context.log.log(
22 type="agent",
23 heading=heading,
24 + step="Reasoning..."
25 )
26 )
27
28 # update log message
29 log_item = loop_data.params_temporary["log_item_generating"]
29 - log_item.update(heading=heading, reasoning=text)
30 + log_item.update(heading=heading, reasoning=text, step="Reasoning...")
python/extensions/response_stream/_10_log_from_stream.py
+12 -4
@@ -24,7 +24,11 @@ class LogFromStream(Extension):
24 elif "tool_name" in parsed:
25 heading = build_heading(self.agent, f"Using {parsed['tool_name']}") # if the llm skipped headline
26 elif "thoughts" in parsed:
27 - heading = build_default_heading(self.agent)
27 + # thought length indicator
28 + pipes = "|" * math.ceil(math.sqrt(len(text)))
29 + heading = build_heading(self.agent, f"Thinking... {pipes}")
30 + else:
31 + heading = build_heading(self.agent, "Receiving...")
32
33 # create log message and store it in loop data temporary params
34 if "log_item_generating" not in loop_data.params_temporary:
@@ -48,12 +52,16 @@ class LogFromStream(Extension):
52 kvps["step"] = f"Using {parsed['tool_name']}..." # using tool XY
53 if parsed["tool_name"]=="code_execution_tool":
54 if "tool_args" in parsed and "runtime" in parsed["tool_args"]:
55 + pipes = ""
56 + if "code" in parsed["tool_args"]:
57 + pipes = "|" * math.ceil(math.sqrt(len(parsed["tool_args"]["code"])))
58 + kvps["step"] = f"Writing code... {pipes}"
59 if parsed["tool_args"]["runtime"] == "python":
52 - kvps["step"] = "Writing Python code..."
60 + kvps["step"] = f"Writing Python code... {pipes}"
61 elif parsed["tool_args"]["runtime"] == "nodejs":
54 - kvps["step"] = "Writing Node.js code..."
62 + kvps["step"] = f"Writing Node.js code... {pipes}"
63 elif parsed["tool_args"]["runtime"] == "terminal":
56 - kvps["step"] = "Writing terminal command..."
64 + kvps["step"] = f"Writing terminal command... {pipes}"
65 kvps.update(parsed)
66
67
webui/index.js
-2
@@ -11,7 +11,6 @@ import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
11 import { store as tasksStore } from "/components/sidebar/tasks/tasks-store.js";
12 import { store as chatTopStore } from "/components/chat/top-section/chat-top-store.js";
13 import { store as _tooltipsStore } from "/components/tooltips/tooltip-store.js";
14 -import { applyModeSteps } from "/components/messages/process-group/process-group-dom.js";
14
15 globalThis.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
16
@@ -320,7 +319,6 @@ export async function poll() {
319 updated = true;
320 setMessages(response.logs);
321 afterMessagesUpdate(response.logs);
323 - applyModeSteps(preferencesStore.detailMode, preferencesStore.showUtils);
322 }
323
324 lastLogVersion = response.log_version;
webui/js/messages.js
+146 -122
@@ -4,7 +4,10 @@ import { marked } from "../vendor/marked/marked.esm.js";
4 import { store as _messageResizeStore } from "/components/messages/resize/message-resize-store.js"; // keep here, required in html
5 import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
6 import { store as speechStore } from "/components/chat/speech/speech-store.js";
7 -import { createActionButton, copyToClipboard } from "/components/messages/action-buttons/simple-action-buttons.js";
7 +import {
8 + createActionButton,
9 + copyToClipboard,
10 +} from "/components/messages/action-buttons/simple-action-buttons.js";
11 import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
12 import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
13 import { formatDuration } from "./time-utils.js";
@@ -63,10 +66,8 @@ export function getMessageHandler(type) {
66 */
67 function setActiveProcessGroup(group) {
68 // if (!group) return;
66 -
69 // // Already active? Nothing to do
70 // if (group.classList.contains("active")) return;
69 -
71 // // Clear active + shiny from all other groups
72 // getChatHistoryEl()
73 // .querySelectorAll(".process-group.active")
@@ -78,7 +79,6 @@ function setActiveProcessGroup(group) {
79 // );
80 // }
81 // });
81 -
82 // // Mark this group as active
83 // group.classList.add("active");
84 }
@@ -288,25 +288,6 @@ function buildDetailPayload(stepData, extras = {}) {
288 };
289 }
290
291 -function buildStepCopyContent(stepData) {
292 - if (!stepData) return "";
293 - const parts = [];
294 - if (stepData.heading) parts.push(stepData.heading);
295 - if (stepData.content) parts.push(stepData.content);
296 - if (stepData.kvps) {
297 - for (const [key, value] of Object.entries(stepData.kvps)) {
298 - if (key === "reasoning" || key === "finished" || key === "attachments")
299 - continue;
300 - const valStr =
301 - typeof value === "object"
302 - ? JSON.stringify(value, null, 2)
303 - : String(value);
304 - parts.push(`${key}: ${valStr}`);
305 - }
306 - }
307 - return parts.join("\n\n");
308 -}
309 -
291 function drawProcessStep({
292 id,
293 title,
@@ -371,16 +352,19 @@ function drawProcessStep({
352 if (detailMode === "expanded") {
353 step.classList.add("expanded");
354 // expand current step and schedule collapse of previous
374 - } else if (detailMode === "current" && !isMassRender() && !isGroupComplete) {
375 - step.classList.add("expanded");
376 - const allExpandedSteps = stepsContainer.querySelectorAll(
377 - ".process-step.expanded",
378 - );
379 - allExpandedSteps.forEach((expandedStep) => {
380 - if (expandedStep.id !== stepId) {
355 + } else if (
356 + detailMode === "current" &&
357 + !isMassRender() &&
358 + !isGroupComplete
359 + ) {
360 + stepsContainer
361 + .querySelectorAll(".process-step.expanded")
362 + .forEach((expandedStep) => {
363 + if (expandedStep.id !== stepId) {
364 scheduleStepCollapse(expandedStep, STEP_COLLAPSE_DELAY_MS);
365 }
366 });
367 + step.classList.add("expanded");
368 }
369 }
370
@@ -463,7 +447,6 @@ function drawProcessStep({
447 // reapply scroll position (autoscroll if bottom) - only when expanded already and not mass rendering
448 if (isExpanded && !isMassRender()) detailScroller.reApplyScroll();
449
466 -
450 // Render action buttons: get/create container, clear, append
451 const stepActionBtns = ensureChild(
452 stepDetail,
@@ -477,15 +460,16 @@ function drawProcessStep({
460 .filter(Boolean)
461 .forEach((button) => stepActionBtns.appendChild(button));
462
480 -
463 // update the process grop header by this step
464 updateProcessGroupHeader(group);
465
466 // remove shine from previous steps and add to this one if new and not completed
467 if (isNewStep && !isGroupComplete) {
486 - stepDetailScroll.querySelectorAll(".step-title.shiny-text").forEach((el) => {
487 - el.classList.remove("shiny-text");
488 - });
468 + stepDetailScroll
469 + .querySelectorAll(".step-title.shiny-text")
470 + .forEach((el) => {
471 + el.classList.remove("shiny-text");
472 + });
473 titleEl.classList.add("shiny-text");
474 }
475
@@ -516,7 +500,6 @@ function drawStandaloneMessage({
500 kvps = null,
501 actionButtons = [],
502 }) {
519 -
503 const container = getOrCreateMessageContainer(
504 id,
505 position,
@@ -538,17 +521,29 @@ function drawStandaloneMessage({
521 // Collapsible: show ~10 lines with fade, expand button reveals full content
522 messageDiv.classList.add("message-collapsible");
523
541 - const expandBtn = ensureChild(messageDiv, ".expand-btn", "button", "expand-btn");
542 - expandBtn.textContent = messageDiv.classList.contains("expanded") ? "Show less" : "Show more";
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");
545 - expandBtn.textContent = messageDiv.classList.contains("expanded") ? "Show less" : "Show more";
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");
551 - messageDiv.classList.toggle("has-overflow", body.scrollHeight > body.clientHeight);
543 + messageDiv.classList.toggle(
544 + "has-overflow",
545 + body.scrollHeight > body.clientHeight,
546 + );
547 });
548
549 // Render action buttons: get/create container, clear, append
@@ -772,7 +767,9 @@ export function drawMessageAgent({
767 const actionButtons = thoughtsText.trim()
768 ? [
769 createActionButton("detail", "", () =>
775 - stepDetailStore.showStepDetail(buildDetailPayload(arguments[0], { headerLabels })),
770 + stepDetailStore.showStepDetail(
771 + buildDetailPayload(arguments[0], { headerLabels }),
772 + ),
773 ),
774 createActionButton("speak", "", () => speechStore.speak(thoughtsText)),
775 createActionButton("copy", "", () => copyToClipboard(thoughtsText)),
@@ -832,14 +829,18 @@ export function drawMessageResponse({
829 const group = getLastProcessGroup();
830 let container = null;
831
835 - if (group)
832 + if (group) {
833 container = ensureChild(
834 group,
835 ".process-group-response",
836 "div",
837 "process-group-response",
838 );
842 - else container = getOrCreateMessageContainer(id, "left");
839 + //collapse all steps when response is ready
840 + group.querySelectorAll(".process-step").forEach((step) => {
841 + scheduleStepCollapse(step);
842 + });
843 + } else container = getOrCreateMessageContainer(id, "left");
844
845 const messageDiv = _drawMessage({
846 messageContainer: container,
@@ -856,17 +857,29 @@ export function drawMessageResponse({
857 // Collapsible: show ~10 lines with fade, expand button reveals full content
858 messageDiv.classList.add("message-collapsible");
859
859 - const expandBtn = ensureChild(messageDiv, ".expand-btn", "button", "expand-btn");
860 - expandBtn.textContent = messageDiv.classList.contains("expanded") ? "Show less" : "Show more";
860 + const expandBtn = ensureChild(
861 + messageDiv,
862 + ".expand-btn",
863 + "button",
864 + "expand-btn",
865 + );
866 + expandBtn.textContent = messageDiv.classList.contains("expanded")
867 + ? "Show less"
868 + : "Show more";
869 expandBtn.onclick = () => {
870 messageDiv.classList.toggle("expanded");
863 - expandBtn.textContent = messageDiv.classList.contains("expanded") ? "Show less" : "Show more";
871 + expandBtn.textContent = messageDiv.classList.contains("expanded")
872 + ? "Show less"
873 + : "Show more";
874 };
875
876 // Detect overflow after render - CSS handles visibility based on .has-overflow class
877 requestAnimationFrame(() => {
878 const body = messageDiv.querySelector(".message-body");
869 - messageDiv.classList.toggle("has-overflow", body.scrollHeight > body.clientHeight);
879 + messageDiv.classList.toggle(
880 + "has-overflow",
881 + body.scrollHeight > body.clientHeight,
882 + );
883 });
884
885 // Render action buttons: get/create container, clear, append
@@ -1048,7 +1061,9 @@ export function drawMessageTool({
1061 const actionButtons = contentText.trim()
1062 ? [
1063 createActionButton("detail", "", () =>
1051 - stepDetailStore.showStepDetail(buildDetailPayload(arguments[0], { headerLabels })),
1064 + stepDetailStore.showStepDetail(
1065 + buildDetailPayload(arguments[0], { headerLabels }),
1066 + ),
1067 ),
1068 createActionButton("speak", "", () => speechStore.speak(contentText)),
1069 createActionButton("copy", "", () => copyToClipboard(contentText)),
@@ -1080,10 +1095,7 @@ export function drawMessageCodeExe({
1095 }) {
1096 let title = "Code Execution";
1097 // show command at the start and end
1083 - if (
1084 - kvps?.code &&
1085 - /done_all|code_execution_tool/.test(heading || "")
1086 - ) {
1098 + if (kvps?.code && /done_all|code_execution_tool/.test(heading || "")) {
1099 const s = kvps.session ?? kvps.Session;
1100 title = `${s != null ? `[${s}] ` : ""}${kvps.runtime || "bash"}> ${kvps.code.trim()}`;
1101 } else {
@@ -1098,7 +1110,10 @@ export function drawMessageCodeExe({
1110
1111 const headerLabels = [
1112 kvps?.runtime && { label: kvps.runtime, class: "tool-name-badge" },
1101 - kvps?.session != null && { label: `Session ${kvps.session}`, class: "header-label" },
1113 + kvps?.session != null && {
1114 + label: `Session ${kvps.session}`,
1115 + class: "header-label",
1116 + },
1117 ].filter(Boolean);
1118
1119 // render the standard step
@@ -1106,7 +1121,9 @@ export function drawMessageCodeExe({
1121 const outputText = String(content ?? "");
1122 const actionButtons = [
1123 createActionButton("detail", "", () =>
1109 - stepDetailStore.showStepDetail(buildDetailPayload(arguments[0], { headerLabels })),
1124 + stepDetailStore.showStepDetail(
1125 + buildDetailPayload(arguments[0], { headerLabels }),
1126 + ),
1127 ),
1128 commandText.trim()
1129 ? createActionButton("copy", "Command", () =>
@@ -1146,7 +1163,9 @@ export function drawMessageBrowser({
1163 const actionButtons = answerText.trim()
1164 ? [
1165 createActionButton("detail", "", () =>
1149 - stepDetailStore.showStepDetail(buildDetailPayload(arguments[0], { headerLabels: [] })),
1166 + stepDetailStore.showStepDetail(
1167 + buildDetailPayload(arguments[0], { headerLabels: [] }),
1168 + ),
1169 ),
1170 createActionButton("speak", "", () => speechStore.speak(answerText)),
1171 createActionButton("copy", "", () => copyToClipboard(answerText)),
@@ -1185,7 +1204,9 @@ export function drawMessageMcp({
1204 const actionButtons = contentText.trim()
1205 ? [
1206 createActionButton("detail", "", () =>
1188 - stepDetailStore.showStepDetail(buildDetailPayload(arguments[0], { headerLabels })),
1207 + stepDetailStore.showStepDetail(
1208 + buildDetailPayload(arguments[0], { headerLabels }),
1209 + ),
1210 ),
1211 createActionButton("speak", "", () => speechStore.speak(contentText)),
1212 createActionButton("copy", "", () => copyToClipboard(contentText)),
@@ -1224,7 +1245,9 @@ export function drawMessageSubagent({
1245 const actionButtons = contentText.trim()
1246 ? [
1247 createActionButton("detail", "", () =>
1227 - stepDetailStore.showStepDetail(buildDetailPayload(arguments[0], { headerLabels })),
1248 + stepDetailStore.showStepDetail(
1249 + buildDetailPayload(arguments[0], { headerLabels }),
1250 + ),
1251 ),
1252 createActionButton("speak", "", () => speechStore.speak(contentText)),
1253 createActionButton("copy", "", () => copyToClipboard(contentText)),
@@ -1418,7 +1441,9 @@ export function drawMessageError({
1441 const contentText = String(content ?? "");
1442 const actionButtons = [
1443 createActionButton("detail", "", () =>
1421 - stepDetailStore.showStepDetail(buildDetailPayload(arguments[0], { headerLabels: [] })),
1444 + stepDetailStore.showStepDetail(
1445 + buildDetailPayload(arguments[0], { headerLabels: [] }),
1446 + ),
1447 ),
1448 contentText.trim()
1449 ? createActionButton("copy", "", () => copyToClipboard(contentText))
@@ -1437,7 +1462,6 @@ export function drawMessageError({
1462 });
1463 }
1464
1440 -
1465 function drawKvpsIncremental(container, kvps, latex) {
1466 // existing KVPS table
1467 let table = container.querySelector(".msg-kvps");
@@ -1664,7 +1688,9 @@ const extractTableTSV = (table) =>
1688 [...table.rows]
1689 .map((row) =>
1690 [...row.cells]
1667 - .map((cell) => cell.textContent.replace(/\t/g, " ").replace(/\n/g, " "))
1691 + .map((cell) =>
1692 + cell.textContent.replace(/\t/g, " ").replace(/\n/g, " "),
1693 + )
1694 .join("\t"),
1695 )
1696 .join("\n");
@@ -1677,7 +1703,9 @@ function adjustMarkdownRender(element) {
1703 const actionsDiv = document.createElement("div");
1704 actionsDiv.className = "step-action-buttons";
1705 actionsDiv.appendChild(
1680 - createActionButton("copy", "", () => copyToClipboard(extractTableTSV(el)))
1706 + createActionButton("copy", "", () =>
1707 + copyToClipboard(extractTableTSV(el)),
1708 + ),
1709 );
1710 wrapper.appendChild(actionsDiv);
1711 });
@@ -1690,7 +1718,7 @@ function adjustMarkdownRender(element) {
1718 const actionsDiv = document.createElement("div");
1719 actionsDiv.className = "step-action-buttons";
1720 actionsDiv.appendChild(
1693 - createActionButton("copy", "", () => copyToClipboard(code.textContent))
1721 + createActionButton("copy", "", () => copyToClipboard(code.textContent)),
1722 );
1723 wrapper.appendChild(actionsDiv);
1724 });
@@ -1728,7 +1756,8 @@ export class Scroller {
1756 }
1757
1758 reApplyScroll() {
1731 - if (this.wasAtBottom && !this.isAtBottom()) this.element.scrollTop = this.element.scrollHeight;
1759 + if (this.wasAtBottom && !this.isAtBottom())
1760 + this.element.scrollTop = this.element.scrollHeight;
1761 }
1762 }
1763
@@ -1905,7 +1934,6 @@ function findParentDelegationStep(group, agentno) {
1934 * Get a concise title for a process step
1935 */
1936 function getStepTitle(heading, kvps, type) {
1908 -
1937 // Try to get a meaningful title from heading or kvps
1938 if (heading && heading.trim()) {
1939 return cleanStepTitle(heading, 100);
@@ -1966,7 +1994,6 @@ function cleanStepTitle(text, maxLength = 100) {
1994 return truncateText(cleaned, maxLength);
1995 }
1996
1969 -
1997 /**
1998 * Update process group header with step count, status, and metrics
1999 */
@@ -2013,7 +2040,6 @@ function updateProcessGroupHeader(group) {
2040 const code = lastStep.getAttribute("data-step-code");
2041 badgeEl.outerHTML = `<span class="step-badge ${code}">${code}</span>`;
2042 }
2016 -
2043 }
2044
2045 // Update step count in metrics - All GEN steps from all agents per process group
@@ -2084,8 +2110,6 @@ function updateProcessGroupHeader(group) {
2110 notificationsEl.hidden = true;
2111 }
2112 }
2087 -
2088 -
2113 }
2114
2115 function isProcessGroupComplete(group) {
@@ -2106,62 +2130,62 @@ function truncateText(text, maxLength) {
2130 /**
2131 * Mark a process group as complete (END state)
2132 */
2109 -function markProcessGroupComplete(group, responseTitle) {
2110 - if (!group) return;
2111 -
2112 - // // Update status badge to END
2113 - // const statusEl = group.querySelector(".group-status");
2114 - // if (statusEl) {
2115 - // // statusEl.innerHTML = '<span class="badge-icon material-symbols-outlined">check</span>END';
2116 - // statusEl.innerHTML = "END";
2117 - // statusEl.className = "step-badge status-end group-status";
2118 - // }
2119 -
2120 - // // Update title if response title is available
2121 - // const titleEl = group.querySelector(".group-title");
2122 - // if (titleEl && responseTitle) {
2123 - // const cleanTitle = cleanStepTitle(responseTitle, 50);
2124 - // if (cleanTitle) {
2125 - // titleEl.textContent = cleanTitle;
2126 - // }
2127 - // }
2128 -
2129 - // Add completed class to group
2130 - group.classList.add("process-group-completed");
2131 -
2132 - // Collapse all expanded steps when processing is done (in "current" mode) with delay
2133 - const detailMode = preferencesStore.detailMode;
2134 - if (detailMode === "current") {
2135 - // Schedule collapse for all expanded steps (deterministic)
2136 - const allExpandedSteps = group.querySelectorAll(
2137 - ".process-step.expanded",
2138 - );
2139 - allExpandedSteps.forEach((expandedStep) => {
2140 - scheduleStepCollapse(expandedStep, FINAL_STEP_COLLAPSE_DELAY_MS);
2141 - });
2142 - }
2133 +// function markProcessGroupComplete(group, responseTitle) {
2134 +// if (!group) return;
2135 +
2136 +// // Update status badge to END
2137 +// const statusEl = group.querySelector(".group-status");
2138 +// if (statusEl) {
2139 +// // statusEl.innerHTML = '<span class="badge-icon material-symbols-outlined">check</span>END';
2140 +// statusEl.innerHTML = "END";
2141 +// statusEl.className = "step-badge status-end group-status";
2142 +// }
2143
2144 - // Calculate final duration from backend data (difference between first and last timestamps)
2145 - const steps = group.querySelectorAll(".process-step");
2146 - const firstTimestampMs = parseInt(
2147 - steps[0]?.getAttribute("data-timestamp") || "0",
2148 - 10,
2149 - );
2150 - const lastTimestampMs = parseInt(
2151 - steps[steps.length - 1]?.getAttribute("data-timestamp") || "0",
2152 - 10,
2153 - );
2154 - const totalDurationMs = Math.max(0, lastTimestampMs - firstTimestampMs);
2144 +// // Update title if response title is available
2145 +// const titleEl = group.querySelector(".group-title");
2146 +// if (titleEl && responseTitle) {
2147 +// const cleanTitle = cleanStepTitle(responseTitle, 50);
2148 +// if (cleanTitle) {
2149 +// titleEl.textContent = cleanTitle;
2150 +// }
2151 +// }
2152
2156 - // Update duration metric with final value from backend
2157 - const metricsEl = group.querySelector(".group-metrics");
2158 - const durationMetricEl = metricsEl?.querySelector(
2159 - ".metric-duration .metric-value",
2160 - );
2161 - if (durationMetricEl && totalDurationMs > 0) {
2162 - durationMetricEl.textContent = formatDuration(totalDurationMs);
2163 - }
2164 -}
2153 +// // Add completed class to group
2154 +// group.classList.add("process-group-completed");
2155 +
2156 +// // Collapse all expanded steps when processing is done (in "current" mode) with delay
2157 +// const detailMode = preferencesStore.detailMode;
2158 +// if (detailMode === "current") {
2159 +// // Schedule collapse for all expanded steps (deterministic)
2160 +// const allExpandedSteps = group.querySelectorAll(
2161 +// ".process-step.expanded",
2162 +// );
2163 +// allExpandedSteps.forEach((expandedStep) => {
2164 +// scheduleStepCollapse(expandedStep, FINAL_STEP_COLLAPSE_DELAY_MS);
2165 +// });
2166 +// }
2167 +
2168 +// // Calculate final duration from backend data (difference between first and last timestamps)
2169 +// const steps = group.querySelectorAll(".process-step");
2170 +// const firstTimestampMs = parseInt(
2171 +// steps[0]?.getAttribute("data-timestamp") || "0",
2172 +// 10,
2173 +// );
2174 +// const lastTimestampMs = parseInt(
2175 +// steps[steps.length - 1]?.getAttribute("data-timestamp") || "0",
2176 +// 10,
2177 +// );
2178 +// const totalDurationMs = Math.max(0, lastTimestampMs - firstTimestampMs);
2179 +
2180 +// // Update duration metric with final value from backend
2181 +// const metricsEl = group.querySelector(".group-metrics");
2182 +// const durationMetricEl = metricsEl?.querySelector(
2183 +// ".metric-duration .metric-value",
2184 +// );
2185 +// if (durationMetricEl && totalDurationMs > 0) {
2186 +// durationMetricEl.textContent = formatDuration(totalDurationMs);
2187 +// }
2188 +// }
2189
2190 // gets or creates a child DOM element
2191 function ensureChild(parent, selector, tagName, ...classNames) {