fix: take durationms from backend

duration and timestamp persist log and process groups cleanup

3clyp50 committed Jan 2, 2026 at 17:40 UTC 5844e58b83bbc37cc3d4734067b2a630b5299686
7 files changed +90 -113
python/helpers/log.py
+1 -6
@@ -138,12 +138,7 @@ class LogItem:
138
139 def __post_init__(self):
140 self.guid = self.log.guid
141 - self.timestamp = time.time() # Record creation time
142 - # Capture agent number from context if available
143 - if self.log.context and self.log.context.streaming_agent:
144 - self.agent_number = self.log.context.streaming_agent.number
145 - else:
146 - self.agent_number = 0 # Default to main agent
141 + self.timestamp = time.time()
142
143 def update(
144 self,
python/helpers/persist_chat.py
+13 -11
@@ -262,17 +262,19 @@ def _deserialize_log(data: dict[str, Any]) -> "Log":
262 # Deserialize the list of LogItem objects
263 i = 0
264 for item_data in data.get("logs", []):
265 - log.logs.append(
266 - LogItem(
267 - log=log, # restore the log reference
268 - no=i, # item_data["no"],
269 - type=item_data["type"],
270 - heading=item_data.get("heading", ""),
271 - content=item_data.get("content", ""),
272 - kvps=OrderedDict(item_data["kvps"]) if item_data["kvps"] else None,
273 - temp=item_data.get("temp", False),
274 - )
275 - )
265 + log.logs.append(LogItem(
266 + log=log, # restore the log reference
267 + no=i, # item_data["no"],
268 + type=item_data["type"],
269 + heading=item_data.get("heading", ""),
270 + content=item_data.get("content", ""),
271 + kvps=OrderedDict(item_data["kvps"]) if item_data["kvps"] else None,
272 + temp=item_data.get("temp", False),
273 + # Pass metrics directly to constructor
274 + timestamp=item_data.get("timestamp", 0.0),
275 + duration_ms=item_data.get("duration_ms"),
276 + agent_number=item_data.get("agent_number", 0),
277 + ))
278 log.updates.append(i)
279 i += 1
280
webui/components/messages/process-group/process-group.css
-7
@@ -669,13 +669,6 @@
669 height: 0;
670 }
671
672 -
673 - }
674 - #chat-input::-webkit-scrollbar { width: 6px; height: 6px; }
675 - #chat-input::-webkit-scrollbar-track { background: transparent; margin: 4px 0; border-radius: 6px; }
676 - #chat-input::-webkit-scrollbar-thumb { background-color: rgba(155,155,155,0.5); border-radius: 6px; -webkit-transition: background-color 0.2s ease; transition: background-color 0.2s ease; }
677 - #chat-input::-webkit-scrollbar-thumb:hover { background-color: rgba(155,155,155,0.7); }
678 -
672 /* Light mode terminal */
673 .light-mode .process-step-detail-content .terminal-output {
674 background: rgba(0, 0, 0, 0.05);
webui/css/messages.css
+2
@@ -96,6 +96,8 @@
96
97 .message-user .message-text pre {
98 font-family: var(--font-family-main);
99 + text-align: right;
100 + margin-left: 25%;
101 }
102
103 .message-ai {
webui/index.js
-1
@@ -10,7 +10,6 @@ import { store as inputStore } from "/components/chat/input/input-store.js";
10 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 processGroupStore } from "/components/messages/process-group/process-group-store.js";
13
14 globalThis.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
15
webui/js/messages.js
+57 -88
@@ -6,6 +6,7 @@ import { store as attachmentsStore } from "/components/chat/attachments/attachme
6 import { addActionButtonsToElement } from "/components/messages/action-buttons/simple-action-buttons.js";
7 import { store as processGroupStore } from "/components/messages/process-group/process-group-store.js";
8 import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
9 +import { formatDuration } from "./time-utils.js";
10
11 const chatHistory = document.getElementById("chat-history");
12
@@ -13,11 +14,11 @@ let messageGroup = null;
14 let currentProcessGroup = null; // Track current process group for collapsible UI
15
16 // Process types that should be grouped into collapsible sections
16 -const PROCESS_TYPES = ['agent', 'tool', 'code_exe', 'browser', 'info', 'hint', 'util', 'warning'];
17 +const PROCESS_TYPES = ['agent', 'tool', 'code_exe', 'browser', 'progress', 'info', 'hint', 'util', 'warning'];
18 // Main types that should always be visible (not collapsed)
19 const MAIN_TYPES = ['user', 'response', 'error', 'rate_limit'];
20
20 -export function setMessage(id, type, heading, content, temp, kvps = null, timestamp = null, durationMs = null, /* tokensIn = 0, tokensOut = 0, */ agentNumber = 0) {
21 +export function setMessage(id, type, heading, content, temp, kvps = null, timestamp = null, durationMs = null, agentNumber = 0) {
22 // Check if this is a process type message
23 const isProcessType = PROCESS_TYPES.includes(type);
24 const isMainType = MAIN_TYPES.includes(type);
@@ -36,7 +37,7 @@ export function setMessage(id, type, heading, content, temp, kvps = null, timest
37 if (isProcessType) {
38 if (processStepElement) {
39 // Update existing process step
39 - updateProcessStep(processStepElement, id, type, heading, content, kvps, durationMs /*, tokensIn, tokensOut */);
40 + updateProcessStep(processStepElement, id, type, heading, content, kvps, durationMs);
41 return processStepElement;
42 }
43
@@ -47,7 +48,7 @@ export function setMessage(id, type, heading, content, temp, kvps = null, timest
48 }
49
50 // Add step to current process group
50 - processStepElement = addProcessStep(currentProcessGroup, id, type, heading, content, kvps, timestamp, durationMs /*, tokensIn, tokensOut */);
51 + processStepElement = addProcessStep(currentProcessGroup, id, type, heading, content, kvps, timestamp, durationMs);
52 return processStepElement;
53 }
54
@@ -55,7 +56,7 @@ export function setMessage(id, type, heading, content, temp, kvps = null, timest
56 // agentNumber: 0 = main agent, 1+ = subordinate agents
57 if (type === "response" && agentNumber !== 0) {
58 if (processStepElement) {
58 - updateProcessStep(processStepElement, id, "agent", heading, content, kvps, durationMs /*, tokensIn, tokensOut */);
59 + updateProcessStep(processStepElement, id, "agent", heading, content, kvps, durationMs);
60 return processStepElement;
61 }
62
@@ -66,7 +67,7 @@ export function setMessage(id, type, heading, content, temp, kvps = null, timest
67 }
68
69 // Add subordinate response as a step (type "agent" for appropriate styling)
69 - processStepElement = addProcessStep(currentProcessGroup, id, "agent", heading, content, kvps, timestamp, durationMs /*, tokensIn, tokensOut */);
70 + processStepElement = addProcessStep(currentProcessGroup, id, "agent", heading, content, kvps, timestamp, durationMs);
71 return processStepElement;
72 }
73
@@ -1140,7 +1141,6 @@ function createProcessGroup(id) {
1141 <span class="metric-time" title="Start time"><span class="material-symbols-outlined">schedule</span><span class="metric-value">--:--</span></span>
1142 <span class="metric-steps" title="Steps"><span class="material-symbols-outlined">list_alt</span><span class="metric-value">0</span></span>
1143 <span class="metric-duration" title="Duration"><span class="material-symbols-outlined">timer</span><span class="metric-value">0s</span></span>
1143 - <!-- <span class="metric-tokens" title="Tokens"><span class="material-symbols-outlined">data_object</span><span class="metric-value">--</span></span> -->
1144 </span>
1145 `;
1146
@@ -1170,7 +1170,7 @@ function createProcessGroup(id) {
1170 /**
1171 * Add a step to a process group
1172 */
1173 -function addProcessStep(group, id, type, heading, content, kvps, timestamp = null, durationMs = null /*, tokensIn = 0, tokensOut = 0 */) {
1173 +function addProcessStep(group, id, type, heading, content, kvps, timestamp = null, durationMs = null) {
1174 const groupId = group.getAttribute("data-group-id");
1175 const stepsContainer = group.querySelector(".process-steps");
1176 const isGroupCompleted = group.classList.contains("process-group-completed");
@@ -1181,8 +1181,6 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
1181 step.classList.add("process-step");
1182 step.setAttribute("data-type", type);
1183 step.setAttribute("data-step-id", id);
1184 - // step.setAttribute("data-tokens-in", tokensIn || 0);
1185 - // step.setAttribute("data-tokens-out", tokensOut || 0);
1184
1185 // Store timestamp for duration calculation
1186 if (timestamp) {
@@ -1199,6 +1197,11 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
1197 }
1198 }
1199
1200 + // Store duration from backend (used for final duration calculation)
1201 + if (durationMs != null) {
1202 + step.setAttribute("data-duration-ms", durationMs);
1203 + }
1204 +
1205 // Add message-util class for utility/info types (controlled by showUtils preference)
1206 if (type === "util" || type === "info" || type === "hint") {
1207 step.classList.add("message-util");
@@ -1272,7 +1275,7 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
1275 /**
1276 * Update an existing process step
1277 */
1275 -function updateProcessStep(stepElement, id, type, heading, content, kvps, durationMs = null /*, tokensIn = 0, tokensOut = 0 */) {
1278 +function updateProcessStep(stepElement, id, type, heading, content, kvps, durationMs = null) {
1279 // Update title
1280 const titleEl = stepElement.querySelector(".step-title");
1281 if (titleEl) {
@@ -1280,13 +1283,10 @@ function updateProcessStep(stepElement, id, type, heading, content, kvps, durati
1283 titleEl.textContent = title;
1284 }
1285
1283 - // Update token data (use the latest values)
1284 - // if (tokensIn > 0) {
1285 - // stepElement.setAttribute("data-tokens-in", tokensIn);
1286 - // }
1287 - // if (tokensOut > 0) {
1288 - // stepElement.setAttribute("data-tokens-out", tokensOut);
1289 - // }
1286 + // Update duration from backend
1287 + if (durationMs != null) {
1288 + stepElement.setAttribute("data-duration-ms", durationMs);
1289 + }
1290
1291 // Update detail content
1292 const detailContent = stepElement.querySelector(".process-step-detail-content");
@@ -1590,8 +1590,15 @@ function updateProcessGroupHeader(group) {
1590 const metricsEl = group.querySelector(".group-metrics");
1591 const isCompleted = group.classList.contains("process-group-completed");
1592
1593 + // If completed, only remove active badges and exit early (don't update metrics)
1594 + if (isCompleted) {
1595 + const activeBadges = group.querySelectorAll(".status-badge.status-active");
1596 + activeBadges.forEach(badge => badge.classList.remove("status-active"));
1597 + return;
1598 + }
1599 +
1600 // Update group title with the latest agent step heading
1594 - if (titleEl && !isCompleted) {
1601 + if (titleEl) {
1602 // Find the last "agent" type step
1603 const agentSteps = Array.from(steps).filter(step => step.getAttribute("data-type") === "agent");
1604 if (agentSteps.length > 0) {
@@ -1622,44 +1629,28 @@ function updateProcessGroupHeader(group) {
1629 timeMetricEl.textContent = `${hours}:${minutes}`;
1630 }
1631
1625 - // Update duration metric (elapsed time since start) - but only if not completed
1632 + // Update duration metric
1633 const durationMetricEl = metricsEl?.querySelector(".metric-duration .metric-value");
1627 - if (durationMetricEl && startTimestamp && !isCompleted) {
1628 - const startMs = parseFloat(startTimestamp) * 1000;
1629 - const elapsedMs = Date.now() - startMs;
1630 - if (elapsedMs < 60000) {
1631 - durationMetricEl.textContent = `${Math.round(elapsedMs / 1000)}s`;
1632 - } else {
1633 - const mins = Math.floor(elapsedMs / 60000);
1634 - const secs = Math.round((elapsedMs % 60000) / 1000);
1635 - durationMetricEl.textContent = `${mins}m${secs}s`;
1634 + if (durationMetricEl && steps.length > 0) {
1635 + // Calculate accumulated duration from backend data
1636 + let accumulatedMs = 0;
1637 + steps.forEach(step => {
1638 + accumulatedMs += parseInt(step.getAttribute("data-duration-ms") || "0", 10);
1639 + });
1640 +
1641 + // Check if last step is still in progress (no duration_ms set yet)
1642 + const lastStep = steps[steps.length - 1];
1643 + const lastStepDuration = lastStep.getAttribute("data-duration-ms");
1644 + const lastStepTimestamp = lastStep.getAttribute("data-timestamp");
1645 +
1646 + if (lastStepDuration == null && lastStepTimestamp) {
1647 + // Last step is in progress - add live elapsed time for this step only
1648 + const lastStepStartMs = parseFloat(lastStepTimestamp) * 1000;
1649 + const liveElapsedMs = Math.max(0, Date.now() - lastStepStartMs);
1650 + accumulatedMs += liveElapsedMs;
1651 }
1637 - }
1638 -
1639 - // Update tokens metric (aggregate from all steps)
1640 - // const tokensMetricEl = metricsEl?.querySelector(".metric-tokens .metric-value");
1641 - // if (tokensMetricEl) {
1642 - // let totalTokensIn = 0;
1643 - // let totalTokensOut = 0;
1644 - // steps.forEach(step => {
1645 - // totalTokensIn += parseInt(step.getAttribute("data-tokens-in") || 0, 10);
1646 - // totalTokensOut += parseInt(step.getAttribute("data-tokens-out") || 0, 10);
1647 - // });
1648 - // const totalTokens = totalTokensIn + totalTokensOut;
1649 - // if (totalTokens > 0) {
1650 - // // Format as compact notation (e.g., 20k/3k for input/output)
1651 - // tokensMetricEl.textContent = formatTokenCount(totalTokensIn, totalTokensOut);
1652 - // } else {
1653 - // tokensMetricEl.textContent = "--";
1654 - // }
1655 - // }
1656 -
1657 - // Once a group is completed, never re-enable any loading spinners (status-active).
1658 - // This prevents late util/tool messages from making a completed group look "running".
1659 - if (isCompleted) {
1660 - const activeBadges = group.querySelectorAll(".status-badge.status-active");
1661 - activeBadges.forEach(badge => badge.classList.remove("status-active"));
1662 - return;
1652 +
1653 + durationMetricEl.textContent = formatDuration(accumulatedMs);
1654 }
1655
1656 if (steps.length > 0) {
@@ -1741,20 +1732,19 @@ function markProcessGroupComplete(group, responseTitle) {
1732 // Add completed class to group
1733 group.classList.add("process-group-completed");
1734
1744 - // Update duration to final value
1735 + // Calculate final duration from backend data (sum of all step durations)
1736 + const steps = group.querySelectorAll(".process-step");
1737 + let totalDurationMs = 0;
1738 + steps.forEach(step => {
1739 + const durationMs = parseInt(step.getAttribute("data-duration-ms") || "0", 10);
1740 + totalDurationMs += durationMs;
1741 + });
1742 +
1743 + // Update duration metric with final value from backend
1744 const metricsEl = group.querySelector(".group-metrics");
1745 const durationMetricEl = metricsEl?.querySelector(".metric-duration .metric-value");
1747 - const startTimestamp = group.getAttribute("data-start-timestamp");
1748 - if (durationMetricEl && startTimestamp) {
1749 - const startMs = parseFloat(startTimestamp) * 1000;
1750 - const elapsedMs = Date.now() - startMs;
1751 - if (elapsedMs < 60000) {
1752 - durationMetricEl.textContent = `${Math.round(elapsedMs / 1000)}s`;
1753 - } else {
1754 - const mins = Math.floor(elapsedMs / 60000);
1755 - const secs = Math.round((elapsedMs % 60000) / 1000);
1756 - durationMetricEl.textContent = `${mins}m${secs}s`;
1757 - }
1746 + if (durationMetricEl && totalDurationMs > 0) {
1747 + durationMetricEl.textContent = formatDuration(totalDurationMs);
1748 }
1749 }
1750
@@ -1766,27 +1756,6 @@ export function resetProcessGroups() {
1756 messageGroup = null;
1757 }
1758
1769 -/**
1770 - * Format token counts in compact notation (e.g., "12k/3k" for input/output)
1771 - * (Currently disabled - token tracking not implemented)
1772 - */
1773 -// function formatTokenCount(tokensIn, tokensOut) {
1774 -// const formatCompact = (n) => {
1775 -// if (n >= 1000000) return `${(n / 1000000).toFixed(1)}m`;
1776 -// if (n >= 1000) return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`;
1777 -// return n.toString();
1778 -// };
1779 -//
1780 -// if (tokensIn > 0 && tokensOut > 0) {
1781 -// return `${formatCompact(tokensIn)}/${formatCompact(tokensOut)}`;
1782 -// } else if (tokensIn > 0) {
1783 -// return `${formatCompact(tokensIn)}↓`;
1784 -// } else if (tokensOut > 0) {
1785 -// return `${formatCompact(tokensOut)}↑`;
1786 -// }
1787 -// return "--";
1788 -// }
1789 -
1759 /**
1760 * Format Unix timestamp as date-time string (YYYY-MM-DD HH:MM:SS)
1761 */
webui/js/time-utils.js
+17
@@ -69,3 +69,20 @@ export function formatDateTime(utcIsoString, format = 'full') {
69 export function getUserTimezone() {
70 return Intl.DateTimeFormat().resolvedOptions().timeZone;
71 }
72 +
73 +/**
74 + * Format a duration in milliseconds to a human-readable string
75 + * @param {number} durationMs - Duration in milliseconds
76 + * @returns {string} Formatted duration (e.g., '45s', '2m30s')
77 + */
78 +export function formatDuration(durationMs) {
79 + if (durationMs == null || durationMs < 0) return '0s';
80 +
81 + if (durationMs < 60000) {
82 + return `${Math.round(durationMs / 1000)}s`;
83 + }
84 +
85 + const mins = Math.floor(durationMs / 60000);
86 + const secs = Math.round((durationMs % 60000) / 1000);
87 + return `${mins}m${secs}s`;
88 +}