active groups use DOM; mode bugfix

3clyp50 committed Jan 22, 2026 at 09:54 UTC 2c8eeda6b4ca43480526c807f874217cd12f0515
2 files changed +94 -171
webui/components/messages/process-group/process-group-store.js
+50 -115
@@ -40,84 +40,20 @@ const DISPLAY_CODES = {
40 };
41
42 const model = {
43 - // Default collapsed state for new process groups
44 - defaultCollapsed: true,
43 + init() {},
44
46 - init() {
47 - try {
48 - // Load persisted default collapsed state only
49 - const stored = localStorage.getItem("processGroupState");
50 - if (stored) {
51 - const parsed = JSON.parse(stored);
52 - this.defaultCollapsed = parsed.defaultCollapsed ?? true;
53 - }
54 - } catch (e) {
55 - console.error("Failed to load process group state", e);
56 - }
57 - },
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({
64 - defaultCollapsed: this.defaultCollapsed
65 - }));
66 - } catch (e) {
67 - console.error("Failed to persist process group state", e);
68 - }
69 - },
70 -
71 - // Check if a process group is expanded
72 - isGroupExpanded(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
45 + // Toggle process group expansion (called from click handler in messages.js)
46 toggleGroup(groupId) {
47 const groupElement = document.getElementById(groupId);
48 if (!groupElement) return;
84 -
85 - const currentState = groupElement.classList.contains("expanded");
86 - groupElement.classList.toggle("expanded", !currentState);
49 + groupElement.classList.toggle("expanded");
50 },
51
89 - // Expand a specific group
90 - expandGroup(groupId) {
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) {
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) {
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
52 + // Toggle step expansion (called from click handler in messages.js)
53 toggleStep(groupId, stepId) {
54 const stepElement = document.getElementById(`process-step-${stepId}`);
55 if (!stepElement) return;
118 -
119 - const currentState = stepElement.classList.contains("step-expanded");
120 - stepElement.classList.toggle("step-expanded", !currentState);
56 + stepElement.classList.toggle("step-expanded");
57 },
58
59 // Status code (3-4 letter) for backend log types
@@ -164,30 +100,15 @@ const model = {
100 return colors[type] || 'status-gen';
101 },
102
167 - // Clear state for a specific context (when chat is reset)
168 - clearContext(contextPrefix) {
169 - },
170 -
103 // Get current detail mode from preferences
104 _getDetailMode() {
105 return preferencesStore.detailMode || "current";
106 },
107
176 - shouldExpandGroup(groupId, isActiveAndGenerating = false) {
108 + shouldExpandGroup() {
109 const mode = this._getDetailMode();
178 - if (mode === "collapsed") {
179 - return false;
180 - }
181 - if (mode === "current" || mode === "expanded") return true;
182 - return !this.defaultCollapsed;
183 - },
184 -
185 - expandStep(groupId, stepId, isActive = false) {
186 - const mode = this._getDetailMode();
187 - if (mode === "collapsed") return false;
188 - if (mode === "expanded") return true;
189 - if (mode === "current") return isActive;
190 - return this.isStepExpanded(groupId, stepId);
110 + // Groups expand in "current" and "expanded" modes, collapse only in "collapsed" mode
111 + return mode !== "collapsed";
112 },
113
114 // Apply current mode to all existing DOM elements
@@ -196,15 +117,18 @@ const model = {
117 const showUtils = preferencesStore.showUtils || false;
118 const allGroups = document.querySelectorAll(".process-group");
119
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);
204 - const lastActiveStep = visibleSteps.length > 0 ? visibleSteps[visibleSteps.length - 1] : null;
120 + // Find the active group (currently streaming) - DOM is source of truth
121 + const activeGroup = document.querySelector(".process-group.active");
122
206 - // Get all steps for applying expansion
207 - const allSteps = document.querySelectorAll(".process-step");
123 + // Find the last visible step in the ACTIVE group only (for "current" mode)
124 + let lastActiveStep = null;
125 + if (activeGroup && !activeGroup.classList.contains("process-group-completed")) {
126 + const stepSelector = showUtils
127 + ? ".process-step"
128 + : ".process-step:not(.message-util)";
129 + const stepsInActiveGroup = activeGroup.querySelectorAll(stepSelector);
130 + lastActiveStep = stepsInActiveGroup.length > 0 ? stepsInActiveGroup[stepsInActiveGroup.length - 1] : null;
131 + }
132
133 // Apply to groups
134 allGroups.forEach(group => {
@@ -212,27 +136,38 @@ const model = {
136 group.classList.toggle("expanded", shouldExpandGroup);
137 });
138
215 - // Apply to steps
216 - allSteps.forEach(step => {
217 - let shouldExpand = false;
218 - if (mode === "expanded") {
219 - shouldExpand = true;
220 - } else if (mode === "current") {
221 - // Only expand the last step in an active group
222 - shouldExpand = step === lastActiveStep || step.contains(lastActiveStep);
223 - }
139 + // Apply to steps - different logic for active vs non-active groups
140 + allGroups.forEach(group => {
141 + const isActiveGroup = group.classList.contains("active");
142 + const steps = group.querySelectorAll(".process-step");
143
225 - // IMPORTANT: Only EXPAND steps here, never collapse them during streaming.
226 - // Collapsing is handled by the timeout mechanism in messages.js to avoid
227 - // fighting with the scheduled delays. We only force-collapse when mode is "collapsed".
228 - if (shouldExpand) {
229 - step.classList.add("step-expanded");
230 - } else if (mode === "collapsed") {
231 - // In collapsed mode, force all steps closed
232 - step.classList.remove("step-expanded");
233 - step.removeAttribute("data-user-pinned");
234 - }
235 - // In "current" mode: don't remove step-expanded - let timeout handle it
144 + steps.forEach(step => {
145 + let shouldExpand = false;
146 +
147 + if (mode === "expanded") {
148 + shouldExpand = true;
149 + } else if (mode === "current") {
150 + if (isActiveGroup) {
151 + // Active group: only expand the last step
152 + shouldExpand = step === lastActiveStep;
153 + }
154 + // Non-active groups: shouldExpand stays false → all steps collapsed
155 + }
156 + // In "collapsed" mode: shouldExpand stays false
157 +
158 + if (shouldExpand) {
159 + step.classList.add("step-expanded");
160 + } else {
161 + // For non-active groups or collapsed mode, immediately collapse
162 + // Only defer to timeout for active group in "current" mode when there's an active step
163 + // (lastActiveStep is null if group is completed - no deferral needed)
164 + const shouldDefer = mode === "current" && isActiveGroup && lastActiveStep && step !== lastActiveStep;
165 + if (!shouldDefer) {
166 + step.classList.remove("step-expanded");
167 + step.removeAttribute("data-user-pinned");
168 + }
169 + }
170 + });
171 });
172
173 // Apply to error groups
webui/js/messages.js
+44 -56
@@ -25,38 +25,30 @@ const chatHistory = document.getElementById("chat-history");
25 let messageGroup = null;
26 let currentProcessGroup = null; // Track current process group for collapsible UI
27 let currentDelegationSteps = {}; // Track delegation steps by agent number for nesting
28 -let activeProcessGroupId = null; // Only one process group should show "running" indicators at a time
29 -let activeProcessGroupEl = null;
30 -let activeStepTitleEl = null;
31 -
32 -// Expose activeProcessGroupId for store access
33 -window.activeProcessGroupId = null;
28
29 /**
36 - * Mark current process group as active and clear active badges.
30 + * Mark a process group as the active one (via .active class)
31 */
32 function setActiveProcessGroup(group) {
39 - if (!group || !group.id) return;
40 - if (activeProcessGroupId === group.id) return;
41 -
42 - // Clear shiny effect from the previous active step title if we moved to a new group
43 - if (activeStepTitleEl && activeProcessGroupEl && activeProcessGroupEl !== group && activeProcessGroupEl.contains(activeStepTitleEl)) {
44 - activeStepTitleEl.classList.remove("shiny-text");
45 - activeStepTitleEl = null;
46 - }
47 -
48 - activeProcessGroupId = group.id;
49 - activeProcessGroupEl = group;
50 - window.activeProcessGroupId = group.id; // Keep window copy in sync for store access
51 -
33 + if (!group) return;
34 +
35 + // Already active? Nothing to do
36 + if (group.classList.contains("active")) return;
37 +
38 + // Clear active + shiny from all other groups
39 + document.querySelectorAll(".process-group.active").forEach(g => {
40 + if (g !== group) {
41 + g.classList.remove("active");
42 + g.querySelectorAll(".step-title.shiny-text").forEach(el => el.classList.remove("shiny-text"));
43 + }
44 + });
45 +
46 + // Mark this group as active
47 + group.classList.add("active");
48 }
49
50 export function clearActiveStepShine() {
55 - if (activeStepTitleEl) {
56 - activeStepTitleEl.classList.remove("shiny-text");
57 - activeStepTitleEl = null;
58 - }
59 - // clear any lingering shine in process steps
51 + // Clear all shiny step titles in process steps
52 document.querySelectorAll(".process-step .step-title.shiny-text").forEach((el) => {
53 el.classList.remove("shiny-text");
54 });
@@ -1308,7 +1300,7 @@ function createProcessGroup(id) {
1300 group.setAttribute("data-group-id", groupId);
1301
1302 // Determine initial expansion state from current detail mode
1311 - const initiallyExpanded = processGroupStore.shouldExpandGroup(groupId, true); // true = is active
1303 + const initiallyExpanded = processGroupStore.shouldExpandGroup();
1304 if (initiallyExpanded) {
1305 group.classList.add('expanded');
1306 }
@@ -1448,9 +1440,6 @@ function addStepCollapseInteractionHandlers(stepElement) {
1440 * Add a step to a process group
1441 */
1442 function addProcessStep(group, id, type, heading, content, kvps, timestamp = null, durationMs = null, agentNumber = 0) {
1451 - // group with newest step becomes the active one
1452 - setActiveProcessGroup(group);
1453 -
1443 const groupId = group.getAttribute("data-group-id");
1444 let stepsContainer = group.querySelector(".process-steps");
1445 const isGroupCompleted = group.classList.contains("process-group-completed");
@@ -1513,22 +1502,27 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
1502
1503 // Determine if this new step should be expanded
1504 const detailMode = preferencesStore.detailMode;
1505 + const isActiveGroup = group.classList.contains("active");
1506 let shouldExpand = false;
1507
1508 if (detailMode === "expanded") {
1509 shouldExpand = true;
1520 - } else if (detailMode === "current" && !isGroupCompleted) {
1521 - // In "current" mode: expand new step, delay-collapse all previous steps
1522 - shouldExpand = true;
1523 -
1524 - // Schedule collapse for ALL previously expanded steps
1525 - const allExpandedSteps = stepsContainer.querySelectorAll(".process-step.step-expanded");
1526 - allExpandedSteps.forEach(expandedStep => {
1527 - // Don't schedule collapse for the newly added step (the current one)
1528 - if (expandedStep.id !== `process-step-${id}`) {
1529 - scheduleStepCollapse(expandedStep, STEP_COLLAPSE_DELAY_MS);
1530 - }
1531 - });
1510 + } else if (detailMode === "current") {
1511 + // Only expand and schedule timeouts for the ACTIVE group (currently streaming)
1512 + // For non-active groups (historical data), render steps collapsed immediately
1513 + if (isActiveGroup && !isGroupCompleted) {
1514 + shouldExpand = true;
1515 +
1516 + // Schedule collapse for ALL previously expanded steps
1517 + const allExpandedSteps = stepsContainer.querySelectorAll(".process-step.step-expanded");
1518 + allExpandedSteps.forEach(expandedStep => {
1519 + // Don't schedule collapse for the newly added step (the current one)
1520 + if (expandedStep.id !== `process-step-${id}`) {
1521 + scheduleStepCollapse(expandedStep, STEP_COLLAPSE_DELAY_MS);
1522 + }
1523 + });
1524 + }
1525 + // Non-active groups: shouldExpand stays false → steps render collapsed
1526 }
1527 // In "collapsed" mode: shouldExpand stays false
1528
@@ -1619,11 +1613,10 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
1613 step.classList.add("nested-step");
1614 }
1615
1622 - // Remove shiny effect from the previously active step title (O(1))
1623 - if (activeStepTitleEl) {
1624 - activeStepTitleEl.classList.remove("shiny-text");
1625 - activeStepTitleEl = null;
1626 - }
1616 + // Clear shiny effect from all previous steps in this group
1617 + group.querySelectorAll(".process-step .step-title.shiny-text").forEach(el => {
1618 + el.classList.remove("shiny-text");
1619 + });
1620
1621 appendTarget.appendChild(step);
1622
@@ -1639,12 +1632,11 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
1632 // Update group header
1633 updateProcessGroupHeader(group);
1634
1642 - // Apply shiny effect to the active step title
1643 - if (!isGroupCompleted && group.id === activeProcessGroupId) {
1635 + // Apply shiny effect to the new step's title if group is still active
1636 + if (!isGroupCompleted) {
1637 const titleEl = step.querySelector(".process-step-header .step-title");
1638 if (titleEl) {
1639 titleEl.classList.add("shiny-text");
1647 - activeStepTitleEl = titleEl;
1640 }
1641 }
1642
@@ -2301,13 +2293,9 @@ export function resetProcessGroups() {
2293 currentProcessGroup = null;
2294 currentDelegationSteps = {};
2295 messageGroup = null;
2304 - activeProcessGroupId = null;
2305 - activeProcessGroupEl = null;
2306 - window.activeProcessGroupId = null; // Keep window copy in sync
2307 - if (activeStepTitleEl) {
2308 - activeStepTitleEl.classList.remove("shiny-text");
2309 - }
2310 - activeStepTitleEl = null;
2296 +
2297 + // Clear shiny effect from DOM (source of truth)
2298 + clearActiveStepShine();
2299
2300 // Clear all pending collapse timeouts
2301 stepCollapseTimeouts.forEach(timeoutId => clearTimeout(timeoutId));