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) {