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
/**