steps/groups tracking and bugfix; timeout for msgs

3clyp50 committed Jan 21, 2026 at 17:33 UTC 36925876968ee569cd61faf553a314474fda639d
2 files changed +166 -68
webui/components/messages/process-group/process-group-store.js
+45 -48
@@ -40,23 +40,15 @@ const DISPLAY_CODES = {
40 };
41
42 const model = {
43 - // Track which process groups are expanded (by group ID)
44 - expandedGroups: {},
45 -
46 - // Track which individual steps are expanded within a group
47 - expandedSteps: {},
48 -
43 // Default collapsed state for new process groups
44 defaultCollapsed: true,
45
46 init() {
47 try {
54 - // Load persisted state
48 + // Load persisted default collapsed state only
49 const stored = localStorage.getItem("processGroupState");
50 if (stored) {
51 const parsed = JSON.parse(stored);
58 - this.expandedGroups = parsed.expandedGroups || {};
59 - this.expandedSteps = parsed.expandedSteps || {};
52 this.defaultCollapsed = parsed.defaultCollapsed ?? true;
53 }
54 } catch (e) {
@@ -66,9 +58,9 @@ const model = {
58
59 _persist() {
60 try {
61 + // Only persist the default collapsed preference
62 + // DOM is the source of truth for actual state
63 localStorage.setItem("processGroupState", JSON.stringify({
70 - expandedGroups: this.expandedGroups,
71 - expandedSteps: this.expandedSteps,
64 defaultCollapsed: this.defaultCollapsed
65 }));
66 } catch (e) {
@@ -78,43 +70,54 @@ const model = {
70
71 // Check if a process group is expanded
72 isGroupExpanded(groupId) {
81 - if (groupId in this.expandedGroups) {
82 - return this.expandedGroups[groupId];
73 + const groupElement = document.getElementById(groupId);
74 + if (groupElement) {
75 + return groupElement.classList.contains("expanded");
76 }
77 return !this.defaultCollapsed;
78 },
79
80 // Toggle process group expansion
81 toggleGroup(groupId) {
89 - const current = this.isGroupExpanded(groupId);
90 - this.expandedGroups[groupId] = !current;
91 - this._persist();
82 + const groupElement = document.getElementById(groupId);
83 + if (!groupElement) return;
84 +
85 + const currentState = groupElement.classList.contains("expanded");
86 + groupElement.classList.toggle("expanded", !currentState);
87 },
88
89 // Expand a specific group
90 expandGroup(groupId) {
96 - this.expandedGroups[groupId] = true;
97 - this._persist();
91 + const groupElement = document.getElementById(groupId);
92 + if (groupElement) {
93 + groupElement.classList.add("expanded");
94 + }
95 },
96
97 // Collapse a specific group
98 collapseGroup(groupId) {
102 - this.expandedGroups[groupId] = false;
103 - this._persist();
99 + const groupElement = document.getElementById(groupId);
100 + if (groupElement) {
101 + groupElement.classList.remove("expanded");
102 + }
103 },
104
105 // Check if a step within a group is expanded
106 isStepExpanded(groupId, stepId) {
108 - const key = `${groupId}:${stepId}`;
109 - return this.expandedSteps[key] || false;
107 + const stepElement = document.getElementById(`process-step-${stepId}`);
108 + if (stepElement) {
109 + return stepElement.classList.contains("step-expanded");
110 + }
111 + return false;
112 },
113
114 // Toggle step expansion
115 toggleStep(groupId, stepId) {
114 - const key = `${groupId}:${stepId}`;
115 - const currentState = this.expandedSteps[key] || false;
116 - this.expandedSteps[key] = !currentState;
117 - this._persist();
116 + const stepElement = document.getElementById(`process-step-${stepId}`);
117 + if (!stepElement) return;
118 +
119 + const currentState = stepElement.classList.contains("step-expanded");
120 + stepElement.classList.toggle("step-expanded", !currentState);
121 },
122
123 // Status code (3-4 letter) for backend log types
@@ -163,19 +166,6 @@ const model = {
166
167 // Clear state for a specific context (when chat is reset)
168 clearContext(contextPrefix) {
166 - // Clear groups matching the context
167 - for (const key of Object.keys(this.expandedGroups)) {
168 - if (key.startsWith(contextPrefix)) {
169 - delete this.expandedGroups[key];
170 - }
171 - }
172 - // Clear steps matching the context
173 - for (const key of Object.keys(this.expandedSteps)) {
174 - if (key.startsWith(contextPrefix)) {
175 - delete this.expandedSteps[key];
176 - }
177 - }
178 - this._persist();
169 },
170
171 // Get current detail mode from preferences
@@ -183,11 +173,10 @@ const model = {
173 return preferencesStore.detailMode || "current";
174 },
175
186 - expandGroup(groupId, isActiveAndGenerating = false) {
176 + shouldExpandGroup(groupId, isActiveAndGenerating = false) {
177 const mode = this._getDetailMode();
178 if (mode === "collapsed") {
189 - // Only expand if generating, not for completed groups
190 - return isActiveAndGenerating;
179 + return false;
180 }
181 if (mode === "current" || mode === "expanded") return true;
182 return !this.defaultCollapsed;
@@ -207,17 +196,20 @@ const model = {
196 const showUtils = preferencesStore.showUtils || false;
197 const allGroups = document.querySelectorAll(".process-group");
198
210 - // Find the last VISIBLE step using targeted selector
211 - const stepSelector = showUtils ? ".process-step" : ".process-step:not(.message-util)";
199 + // Find the last visible step in an active (not completed) group only
200 + const stepSelector = showUtils
201 + ? ".process-group:not(.process-group-completed) .process-step"
202 + : ".process-group:not(.process-group-completed) .process-step:not(.message-util)";
203 const visibleSteps = document.querySelectorAll(stepSelector);
213 - const lastStep = visibleSteps.length > 0 ? visibleSteps[visibleSteps.length - 1] : null;
204 + const lastActiveStep = visibleSteps.length > 0 ? visibleSteps[visibleSteps.length - 1] : null;
205
206 // Get all steps for applying expansion
207 const allSteps = document.querySelectorAll(".process-step");
208
209 // Apply to groups
210 allGroups.forEach(group => {
220 - group.classList.toggle("expanded", mode !== "collapsed");
211 + const shouldExpandGroup = mode !== "collapsed";
212 + group.classList.toggle("expanded", shouldExpandGroup);
213 });
214
215 // Apply to steps
@@ -226,10 +218,15 @@ const model = {
218 if (mode === "expanded") {
219 shouldExpand = true;
220 } else if (mode === "current") {
229 - // Expand the last step and any parent steps containing it (for nested subordinate steps)
230 - shouldExpand = step === lastStep || step.contains(lastStep);
221 + // Only expand the last step in an active group
222 + shouldExpand = step === lastActiveStep || step.contains(lastActiveStep);
223 }
224 step.classList.toggle("step-expanded", shouldExpand);
225 +
226 + // Clear user-pinned flag when mode changes
227 + if (!shouldExpand) {
228 + step.removeAttribute("data-user-pinned");
229 + }
230 });
231
232 // Apply to error groups
webui/js/messages.js
+121 -20
@@ -9,6 +9,17 @@ import { store as stepDetailStore } from "/components/modals/process-step-detail
9 import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
10 import { formatDuration } from "./time-utils.js";
11
12 +// ============================================
13 +// Timing Constants
14 +// ============================================
15 +// Delay before collapsing previous steps when a new step is added
16 +const STEP_COLLAPSE_DELAY_MS = 2000;
17 +// Delay before collapsing the last step when processing completes
18 +const FINAL_STEP_COLLAPSE_DELAY_MS = 2000;
19 +
20 +// Track active collapse timeouts for steps (key: step DOM element, value: timeout ID)
21 +const stepCollapseTimeouts = new Map();
22 +
23 const chatHistory = document.getElementById("chat-history");
24
25 let messageGroup = null;
@@ -1295,8 +1306,8 @@ function createProcessGroup(id) {
1306 group.classList.add("process-group");
1307 group.setAttribute("data-group-id", groupId);
1308
1298 - // Check initial expansion state from store
1299 - const initiallyExpanded = processGroupStore.expandGroup(groupId, true); // true = is active
1309 + // Determine initial expansion state from current detail mode
1310 + const initiallyExpanded = processGroupStore.shouldExpandGroup(groupId, true); // true = is active
1311 if (initiallyExpanded) {
1312 group.classList.add('expanded');
1313 }
@@ -1318,9 +1329,8 @@ function createProcessGroup(id) {
1329
1330 // Add click handler for expansion
1331 header.addEventListener("click", (e) => {
1332 + // Toggle group (store directly modifies DOM - single source of truth)
1333 processGroupStore.toggleGroup(groupId);
1322 - const newState = processGroupStore.isGroupExpanded(groupId);
1323 - group.classList.toggle("expanded", newState);
1334 });
1335
1336 group.appendChild(header);
@@ -1364,6 +1374,70 @@ function getNestedContainer(parentStep) {
1374 return innerWrapper || nestedContainer; // Fallback to container if wrapper missing
1375 }
1376
1377 +/**
1378 + * Schedule a step to collapse after a delay
1379 + * Automatically handles cancellation on click and reset on hover
1380 + */
1381 +function scheduleStepCollapse(stepElement, delayMs) {
1382 + // Cancel any existing timeout for this step
1383 + cancelStepCollapse(stepElement);
1384 +
1385 + // Schedule the collapse
1386 + const timeoutId = setTimeout(() => {
1387 + stepElement.classList.remove("step-expanded");
1388 + stepCollapseTimeouts.delete(stepElement);
1389 + // Clear user-pinned flag when auto-collapsing
1390 + stepElement.removeAttribute("data-user-pinned");
1391 + }, delayMs);
1392 +
1393 + // Store the timeout ID
1394 + stepCollapseTimeouts.set(stepElement, timeoutId);
1395 +}
1396 +
1397 +/**
1398 + * Cancel a scheduled collapse for a step
1399 + */
1400 +function cancelStepCollapse(stepElement) {
1401 + const timeoutId = stepCollapseTimeouts.get(stepElement);
1402 + if (timeoutId) {
1403 + clearTimeout(timeoutId);
1404 + stepCollapseTimeouts.delete(stepElement);
1405 + }
1406 +}
1407 +
1408 +/**
1409 + * Add interaction handlers to prevent fighting with user
1410 + * - Hover: cancels the collapse timeout (keeps step open while reading)
1411 + * - Leave: starts a new timeout ONLY if user hasn't clicked (no explicit interaction)
1412 + * - Click anywhere on step: permanently cancels auto-collapse (user wants it open)
1413 + */
1414 +function addStepCollapseInteractionHandlers(stepElement) {
1415 + // On hover, cancel the timeout to keep it open while user is reading
1416 + stepElement.addEventListener("mouseenter", () => {
1417 + if (stepElement.classList.contains("step-expanded")) {
1418 + cancelStepCollapse(stepElement);
1419 + }
1420 + });
1421 +
1422 + // On leave, start a new timeout ONLY if user hasn't explicitly clicked
1423 + stepElement.addEventListener("mouseleave", () => {
1424 + // Don't restart timeout if user has explicitly interacted (clicked)
1425 + if (stepElement.classList.contains("step-expanded") &&
1426 + !stepElement.hasAttribute("data-user-pinned")) {
1427 + scheduleStepCollapse(stepElement, STEP_COLLAPSE_DELAY_MS);
1428 + }
1429 + });
1430 +
1431 + // On click anywhere on step, permanently cancel auto-collapse
1432 + stepElement.addEventListener("click", () => {
1433 + if (stepElement.classList.contains("step-expanded")) {
1434 + cancelStepCollapse(stepElement);
1435 + // Mark as user-pinned so mouseleave won't restart timeout
1436 + stepElement.setAttribute("data-user-pinned", "true");
1437 + }
1438 + });
1439 +}
1440 +
1441 /**
1442 * Add a step to a process group
1443 */
@@ -1431,19 +1505,28 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
1505 // Get step info from heading (single source of truth: backend)
1506 const title = getStepTitle(heading, kvps, type);
1507
1434 - // Check if step should be expanded
1435 - const isActiveStep = !isGroupCompleted && group.id === activeProcessGroupId;
1436 - const isStepExpanded = processGroupStore.expandStep(groupId, id, isActiveStep);
1437 -
1438 - // In "current" mode, collapse all other steps
1508 + // Determine if this new step should be expanded
1509 const detailMode = preferencesStore.detailMode;
1440 - if (detailMode === "current" && isStepExpanded) {
1441 - document.querySelectorAll(".process-step.step-expanded").forEach(s => {
1442 - s.classList.remove("step-expanded");
1510 + let shouldExpand = false;
1511 +
1512 + if (detailMode === "expanded") {
1513 + shouldExpand = true;
1514 + } else if (detailMode === "current" && !isGroupCompleted) {
1515 + // In "current" mode: expand new step, delay-collapse all previous steps
1516 + shouldExpand = true;
1517 +
1518 + // Schedule collapse for ALL previously expanded steps
1519 + const allExpandedSteps = stepsContainer.querySelectorAll(".process-step.step-expanded");
1520 + allExpandedSteps.forEach(expandedStep => {
1521 + // Don't schedule collapse for the newly added step (the current one)
1522 + if (expandedStep.id !== `process-step-${id}`) {
1523 + scheduleStepCollapse(expandedStep, STEP_COLLAPSE_DELAY_MS);
1524 + }
1525 });
1526 }
1527 + // In "collapsed" mode: shouldExpand stays false
1528
1446 - if (isStepExpanded) {
1529 + if (shouldExpand) {
1530 step.classList.add("step-expanded");
1531 }
1532
@@ -1467,14 +1550,16 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
1550 // Add click handler for step expansion
1551 stepHeader.addEventListener("click", (e) => {
1552 e.stopPropagation();
1553 +
1554 + // Cancel any scheduled auto-collapse (user is manually toggling)
1555 + cancelStepCollapse(step);
1556 +
1557 + // Toggle step (store directly modifies DOM - single source of truth)
1558 processGroupStore.toggleStep(groupId, id);
1471 - const newState = processGroupStore.isStepExpanded(groupId, id);
1472 - // Explicitly add or remove the class based on state
1473 - if (newState) {
1474 - step.classList.add("step-expanded");
1475 - } else {
1476 - step.classList.remove("step-expanded");
1477 - }
1559 +
1560 + // Clear user-pinned flag when manually toggling
1561 + // (allows auto-collapse to work again on next expansion)
1562 + step.removeAttribute("data-user-pinned");
1563 });
1564
1565 step.appendChild(stepHeader);
@@ -1532,6 +1617,9 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
1617
1618 appendTarget.appendChild(step);
1619
1620 + // Add interaction handlers to prevent fighting with user during auto-collapse
1621 + addStepCollapseInteractionHandlers(step);
1622 +
1623 // Scroll terminal to bottom on initial render (including page refresh)
1624 const initialTerminal = step.querySelector(".terminal-output");
1625 if (initialTerminal) {
@@ -2159,6 +2247,15 @@ function markProcessGroupComplete(group, responseTitle) {
2247 // Add completed class to group
2248 group.classList.add("process-group-completed");
2249
2250 + // Collapse all expanded steps when processing is done (in "current" mode) with delay
2251 + const detailMode = preferencesStore.detailMode;
2252 + if (detailMode === "current") {
2253 + // Schedule collapse for all expanded steps (deterministic)
2254 + const allExpandedSteps = group.querySelectorAll(".process-step.step-expanded");
2255 + allExpandedSteps.forEach(expandedStep => {
2256 + scheduleStepCollapse(expandedStep, FINAL_STEP_COLLAPSE_DELAY_MS);
2257 + });
2258 + }
2259
2260 // Calculate final duration from backend data (sum of all step durations)
2261 const steps = group.querySelectorAll(".process-step");
@@ -2190,6 +2287,10 @@ export function resetProcessGroups() {
2287 activeStepTitleEl.classList.remove("shiny-text");
2288 }
2289 activeStepTitleEl = null;
2290 +
2291 + // Clear all pending collapse timeouts
2292 + stepCollapseTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
2293 + stepCollapseTimeouts.clear();
2294 }
2295
2296 /**