@yemi33/minions 0.1.2088 → 0.1.2090

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -127,6 +127,8 @@ async function refreshLiveOutput() {
127
127
  const el = document.getElementById('live-messages');
128
128
  if (el) {
129
129
  const wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150;
130
+ const savedScrollTop = el.scrollTop;
131
+ const savedScrollLeft = el.scrollLeft;
130
132
  const incrementalSafe = _currentAgentRuntime() !== 'copilot';
131
133
  // Incremental render: only parse new content if text is an extension of previous
132
134
  if (incrementalSafe && _lastRenderedText && text.length > _lastRenderedText.length && text.startsWith(_lastRenderedText.slice(0, 200))) {
@@ -137,6 +139,10 @@ async function refreshLiveOutput() {
137
139
  }
138
140
  _lastRenderedText = text;
139
141
  if (wasAtBottom) el.scrollTop = el.scrollHeight;
142
+ else {
143
+ el.scrollTop = savedScrollTop;
144
+ el.scrollLeft = savedScrollLeft;
145
+ }
140
146
  }
141
147
  } catch (e) { console.error('live-stream reload:', e.message); }
142
148
  }
@@ -182,7 +182,6 @@ let _lastChangedFlags = null;
182
182
  function _safeRender(name, fn) {
183
183
  try { fn(); }
184
184
  catch (e) {
185
- // eslint-disable-next-line no-console
186
185
  console.error('[render] ' + name + ' threw:', e);
187
186
  }
188
187
  }
@@ -396,7 +395,8 @@ function _refreshSidebarCounterSummaries() {
396
395
  // entry read it — drop the dead endpoint + fetch. (Review finding #9.)
397
396
  }
398
397
 
399
- function _processStatusUpdate(data) {
398
+ function _processStatusUpdate(data, opts) {
399
+ opts = opts || {};
400
400
  // Kick off the dedicated-endpoint summary refreshes (issue #2949). They
401
401
  // run in the background and update window._last* globals that the sidebar
402
402
  // _pageCounters read. No await — the current tick continues immediately.
@@ -687,7 +687,11 @@ function _processStatusUpdate(data) {
687
687
  // endpoint that re-runs getDispatchQueue() server-side on every
688
688
  // request (issue #2949). Completion-report sidecars are now loaded
689
689
  // server-side too, so the client no longer needs the cached overlay.
690
+ // Scroll-preservation opts (preserveCompletedScroll) are threaded
691
+ // through to renderDispatch so the completed-dispatch pane keeps its
692
+ // scroll position across refreshes (W-mpp75wkg000b3018).
690
693
  _safeRender('dispatch', function() {
694
+ const dispatchOpts = { preserveCompletedScroll: opts.preserveCompletedScroll };
691
695
  const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
692
696
  window._lastRequestedSeq = window._lastRequestedSeq || {};
693
697
  window._lastRequestedSeq.dispatch = seq;
@@ -697,7 +701,7 @@ function _processStatusUpdate(data) {
697
701
  if (seq < (window._lastRequestedSeq.dispatch || 0)) return;
698
702
  const d = fresh || window._lastDispatch;
699
703
  if (d) window._lastDispatch = d;
700
- _safeRender('dispatch', function() { renderDispatch(d); });
704
+ _safeRender('dispatch', function() { renderDispatch(d, dispatchOpts); });
701
705
  // Dispatch is the third cross-slice edge: derivePlanStatus reads
702
706
  // window._lastDispatch.active/.pending for executing/converting
703
707
  // plan-status badges. Without this trigger, plan-status pills lag
@@ -706,7 +710,7 @@ function _processStatusUpdate(data) {
706
710
  _fireCrossSliceRender('dispatch', d);
707
711
  })
708
712
  .catch(function () {
709
- if (window._lastDispatch) _safeRender('dispatch', function() { renderDispatch(window._lastDispatch); });
713
+ if (window._lastDispatch) _safeRender('dispatch', function() { renderDispatch(window._lastDispatch, dispatchOpts); });
710
714
  });
711
715
  });
712
716
  // prunePrdRequeueState moved into the /api/work-items .then handler so it
@@ -714,15 +718,19 @@ function _processStatusUpdate(data) {
714
718
  // window._lastWorkItems. (Review finding #5.)
715
719
  // Engine log (last 50 entries) comes from /state/engine/log.json with a
716
720
  // client-side .slice(-50) to mirror queries.js getEngineLog (issue #2949).
721
+ // Scroll-preservation opts (preserveEngineLogScroll) are threaded through
722
+ // to renderEngineLog so the log pane keeps its scroll position across
723
+ // refreshes (W-mpp75wkg000b3018).
717
724
  _safeRender('engineLog', function() {
725
+ const logOpts = { preserveEngineLogScroll: opts.preserveEngineLogScroll };
718
726
  Promise.resolve(fetchEngineLogFromDisk())
719
727
  .then(function (fresh) {
720
728
  const list = fresh && fresh.length ? fresh : (window._lastEngineLog || []);
721
729
  window._lastEngineLog = list;
722
- _safeRender('engineLog', function() { renderEngineLog(list); });
730
+ _safeRender('engineLog', function() { renderEngineLog(list, logOpts); });
723
731
  })
724
732
  .catch(function () {
725
- if (Array.isArray(window._lastEngineLog)) _safeRender('engineLog', function() { renderEngineLog(window._lastEngineLog); });
733
+ if (Array.isArray(window._lastEngineLog)) _safeRender('engineLog', function() { renderEngineLog(window._lastEngineLog, logOpts); });
726
734
  });
727
735
  });
728
736
  // Metrics now come from /api/metrics (issue #2949) — the enrichment
@@ -1144,7 +1152,8 @@ document.addEventListener('visibilitychange', function() {
1144
1152
  _lastVisibilityChangeAt = now;
1145
1153
  });
1146
1154
 
1147
- async function refresh() {
1155
+ async function refresh(opts) {
1156
+ opts = opts || {};
1148
1157
  if (_refreshInFlight) return;
1149
1158
  // Backoff gate — only active while the unreachable banner is up. Skips
1150
1159
  // setInterval ticks until _nextPollAllowedAt is reached so a downed
@@ -1241,7 +1250,7 @@ async function refresh() {
1241
1250
  _lastChangedFlags = _diagChanges;
1242
1251
  }
1243
1252
  try {
1244
- _processStatusUpdate(data);
1253
+ _processStatusUpdate(data, opts);
1245
1254
  } finally {
1246
1255
  if (_diagOn) {
1247
1256
  _lastChangedFlags = null;
@@ -47,6 +47,7 @@ function _modelChipHtml(model) {
47
47
  function renderAgents(agents) {
48
48
  agentData = agents;
49
49
  const grid = document.getElementById('agents-grid');
50
+ const scrollState = captureDashboardScrollState(grid, ['.agent-result']);
50
51
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml()/renderMd() (fields: agent id, emoji, name, status, role, lastAction, warning, resultSummary, blocking tool)
51
52
  grid.innerHTML = agents.map(a => `
52
53
  <div class="agent-card ${statusColor(a.status)}" data-agent-id="${escapeHtml(a.id)}" onclick="if(shouldIgnoreSelectionClick(event))return;openAgentDetail(this.dataset.agentId)">
@@ -67,6 +68,7 @@ function renderAgents(agents) {
67
68
  ${a.resultSummary ? `<div class="agent-result" title="${escapeHtml(a.resultSummary)}">${renderMd(a.resultSummary.slice(0, 200))}${a.resultSummary.length > 200 ? '...' : ''}</div>` : ''}
68
69
  </div>
69
70
  `).join('');
71
+ restoreDashboardScrollState(grid, scrollState);
70
72
  }
71
73
 
72
74
  async function openAgentDetail(id) {
@@ -64,10 +64,10 @@ async function fetchDispatchFromDisk(cachedDispatch) {
64
64
  }
65
65
  }
66
66
 
67
- function _completedPrev() { if (_completedPage > 0) { _completedPage--; refresh(); } }
68
- function _completedNext() { _completedPage++; refresh(); } // clamped in renderDispatch
69
- function _logPrev() { if (_logPage > 0) { _logPage--; refresh(); } }
70
- function _logNext() { _logPage++; refresh(); } // clamped in renderEngineLog
67
+ function _completedPrev() { if (_completedPage > 0) { _completedPage--; refresh({ preserveCompletedScroll: false }); } }
68
+ function _completedNext() { _completedPage++; refresh({ preserveCompletedScroll: false }); } // clamped in renderDispatch
69
+ function _logPrev() { if (_logPage > 0) { _logPage--; refresh({ preserveEngineLogScroll: false }); } }
70
+ function _logNext() { _logPage++; refresh({ preserveEngineLogScroll: false }); } // clamped in renderEngineLog
71
71
 
72
72
  // Engine restart grace state (W-mpfw3hgm001gc594). After the operator clicks
73
73
  // "Restart engine" we suppress the STALE indicators for ENGINE_RESTART_GRACE_MS
@@ -245,7 +245,8 @@ function renderGhThrottleAlert(ghThrottle) {
245
245
  el.style.display = 'flex';
246
246
  }
247
247
 
248
- function renderDispatch(dispatch) {
248
+ function renderDispatch(dispatch, opts) {
249
+ opts = opts || {};
249
250
  if (!dispatch) return;
250
251
 
251
252
  // Stats
@@ -294,6 +295,9 @@ function renderDispatch(dispatch) {
294
295
  // Completed
295
296
  const completedEl = document.getElementById('completed-content');
296
297
  const completedCount = document.getElementById('completed-count');
298
+ const completedScroll = opts.preserveCompletedScroll === false
299
+ ? null
300
+ : captureDashboardScrollState(completedEl, [':self', '.pr-table-wrap']);
297
301
  const completed = (dispatch.completed || []).slice().reverse();
298
302
  completedCount.textContent = completed.length;
299
303
 
@@ -331,6 +335,7 @@ function renderDispatch(dispatch) {
331
335
  } else {
332
336
  completedEl.innerHTML = '<p class="empty">No completed dispatches yet.</p>';
333
337
  }
338
+ restoreDashboardScrollState(completedEl, completedScroll);
334
339
  }
335
340
 
336
341
  // Pull the engine log straight off disk through /state/engine/log.json.
@@ -351,11 +356,16 @@ async function fetchEngineLogFromDisk() {
351
356
  }
352
357
  }
353
358
 
354
- function renderEngineLog(log) {
359
+ function renderEngineLog(log, opts) {
360
+ opts = opts || {};
355
361
  const el = document.getElementById('engine-log');
356
362
  if (!el) return;
363
+ const scrollState = opts.preserveEngineLogScroll === false
364
+ ? null
365
+ : captureDashboardScrollState(el, [':self']);
357
366
  if (!log || log.length === 0) {
358
367
  el.innerHTML = '<div class="empty">No log entries yet.</div>';
368
+ restoreDashboardScrollState(el, scrollState);
359
369
  return;
360
370
  }
361
371
  const reversed = log.slice().reverse();
@@ -380,6 +390,7 @@ function renderEngineLog(log) {
380
390
  });
381
391
  // eslint-disable-next-line no-unsanitized/method -- reason: composed from internal numeric page bounds and fixed renderPager() callback names (no user data flows in)
382
392
  if (logPager) el.insertAdjacentHTML('beforeend', logPager);
393
+ restoreDashboardScrollState(el, scrollState);
383
394
  }
384
395
 
385
396
  function shortTime(t) {
@@ -75,10 +75,11 @@ async function fetchNotesFromDisk() {
75
75
  }
76
76
  }
77
77
 
78
- function _inboxPrev() { if (_inboxPage > 0) { _inboxPage--; renderInbox(inboxData); } }
79
- function _inboxNext() { _inboxPage++; renderInbox(inboxData); }
78
+ function _inboxPrev() { if (_inboxPage > 0) { _inboxPage--; renderInbox(inboxData, { preserveScroll: false }); } }
79
+ function _inboxNext() { _inboxPage++; renderInbox(inboxData, { preserveScroll: false }); }
80
80
 
81
- function renderInbox(inbox) {
81
+ function renderInbox(inbox, opts) {
82
+ opts = opts || {};
82
83
  invalidatePinsCache();
83
84
  inbox = inbox.filter(function(item) { return !isDeleted('inbox:' + item.name); });
84
85
  // Stable sort — pinned items float to top
@@ -89,7 +90,8 @@ function renderInbox(inbox) {
89
90
  const list = document.getElementById('inbox-list');
90
91
  const count = document.getElementById('inbox-count');
91
92
  count.textContent = inbox.length;
92
- if (!inbox.length) { list.innerHTML = '<p class="empty">No messages yet.</p>'; return; }
93
+ const scrollState = opts.preserveScroll === false ? null : captureDashboardScrollState(list, [':self']);
94
+ if (!inbox.length) { list.innerHTML = '<p class="empty">No messages yet.</p>'; restoreDashboardScrollState(list, scrollState); return; }
93
95
 
94
96
  const totalInboxPages = Math.ceil(inbox.length / INBOX_PER_PAGE);
95
97
  if (_inboxPage >= totalInboxPages) _inboxPage = totalInboxPages - 1;
@@ -122,6 +124,7 @@ function renderInbox(inbox) {
122
124
  });
123
125
  // eslint-disable-next-line no-unsanitized/method -- reason: composed from internal numeric page bounds and fixed renderPager() callback names (no user data flows in)
124
126
  if (inboxPager) list.insertAdjacentHTML('beforeend', inboxPager);
127
+ restoreDashboardScrollState(list, scrollState);
125
128
  restoreNotifBadges();
126
129
  }
127
130
 
@@ -150,6 +153,7 @@ function renderNotes(notes) {
150
153
  const el = document.getElementById('notes-list');
151
154
  const content = typeof notes === 'object' ? notes.content : notes;
152
155
  const updatedAt = typeof notes === 'object' ? notes.updatedAt : null;
156
+ const scrollState = captureDashboardScrollState(el, ['.notes-preview']);
153
157
 
154
158
  // Show last updated timestamp
155
159
  const updatedEl = document.getElementById('notes-updated');
@@ -157,9 +161,10 @@ function renderNotes(notes) {
157
161
  updatedEl.textContent = 'updated ' + formatLocalDateTime(updatedAt);
158
162
  }
159
163
 
160
- if (!content || !content.trim()) { el.innerHTML = '<p class="empty">No team notes yet.</p>'; return; }
164
+ if (!content || !content.trim()) { el.innerHTML = '<p class="empty">No team notes yet.</p>'; restoreDashboardScrollState(el, scrollState); return; }
161
165
  // eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes all user-controlled fields before assembling HTML (see dashboard/js/utils.js)
162
166
  el.innerHTML = '<div class="notes-preview" data-file="notes.md" onclick="openNotesModal()" title="Click to expand">' + renderMd(content) + '</div>';
167
+ restoreDashboardScrollState(el, scrollState);
163
168
  el.querySelector('.notes-preview')._rawContent = content;
164
169
  restoreNotifBadges();
165
170
  }
@@ -17,8 +17,8 @@ let _kbActiveTab = 'all';
17
17
  const KB_PER_PAGE = 30;
18
18
  let _kbPage = 0;
19
19
 
20
- function _kbPrev() { if (_kbPage > 0) { _kbPage--; renderKnowledgeBase(); } }
21
- function _kbNext() { _kbPage++; renderKnowledgeBase(); }
20
+ function _kbPrev() { if (_kbPage > 0) { _kbPage--; renderKnowledgeBase({ preserveScroll: false }); } }
21
+ function _kbNext() { _kbPage++; renderKnowledgeBase({ preserveScroll: false }); }
22
22
 
23
23
  function _formatSweepElapsed(startedAt) {
24
24
  if (!startedAt) return '';
@@ -58,11 +58,13 @@ function kbPinnedNewestFirst(a, b) {
58
58
  return kbNewestFirst(a, b);
59
59
  }
60
60
 
61
- function renderKnowledgeBase() {
61
+ function renderKnowledgeBase(opts) {
62
+ opts = opts || {};
62
63
  _syncPinsFromServer();
63
64
  const tabsEl = document.getElementById('kb-tabs');
64
65
  const listEl = document.getElementById('kb-list');
65
66
  const countEl = document.getElementById('kb-count');
67
+ const scrollState = opts.preserveScroll === false ? null : captureDashboardScrollState(listEl, [':self']);
66
68
 
67
69
  // Single pass: flatten all KB items and count pinned
68
70
  const allItems = [];
@@ -92,6 +94,7 @@ function renderKnowledgeBase() {
92
94
  if (allItems.length === 0) {
93
95
  tabsEl.innerHTML = '';
94
96
  listEl.innerHTML = '<p class="empty">No knowledge entries yet. Notes are classified here after consolidation.</p>';
97
+ restoreDashboardScrollState(listEl, scrollState);
95
98
  return;
96
99
  }
97
100
 
@@ -126,6 +129,7 @@ function renderKnowledgeBase() {
126
129
 
127
130
  if (items.length === 0) {
128
131
  listEl.innerHTML = '<p class="empty">No entries in this category.</p>';
132
+ restoreDashboardScrollState(listEl, scrollState);
129
133
  return;
130
134
  }
131
135
 
@@ -163,13 +167,14 @@ function renderKnowledgeBase() {
163
167
  });
164
168
  // eslint-disable-next-line no-unsanitized/method -- reason: composed from internal numeric page bounds and fixed renderPager() callback names (no user data flows in)
165
169
  if (kbPager) listEl.insertAdjacentHTML('beforeend', kbPager);
170
+ restoreDashboardScrollState(listEl, scrollState);
166
171
  restoreNotifBadges();
167
172
  }
168
173
 
169
174
  function kbSetTab(tab) {
170
175
  _kbActiveTab = tab;
171
176
  _kbPage = 0;
172
- renderKnowledgeBase();
177
+ renderKnowledgeBase({ preserveScroll: false });
173
178
  }
174
179
 
175
180
  async function kbSweep() {
@@ -62,16 +62,19 @@ async function fetchMeetingsFromDisk() {
62
62
  }
63
63
  }
64
64
 
65
- function renderMeetings(meetings) {
65
+ function renderMeetings(meetings, opts) {
66
+ opts = opts || {};
66
67
  meetings = (meetings || []).filter(function(m) { return !isDeleted('mtg:' + m.id); });
67
68
  meetings.sort((a, b) => (b.createdAt || b.completedAt || '').localeCompare(a.createdAt || a.completedAt || ''));
68
69
  _lastMeetingsForPaging = meetings;
69
70
  const el = document.getElementById('meetings-content');
70
71
  const countEl = document.getElementById('meetings-count');
72
+ const scrollState = opts.preserveScroll === false ? null : captureDashboardScrollState(el, [':self']);
71
73
  if (!meetings || meetings.length === 0) {
72
74
  countEl.textContent = '0';
73
75
  el.innerHTML = '<p class="empty">No meetings yet. Start one to have agents investigate, debate, and conclude on a topic.</p>';
74
76
  _mtgTotalPages = 1;
77
+ restoreDashboardScrollState(el, scrollState);
75
78
  return;
76
79
  }
77
80
 
@@ -88,6 +91,7 @@ function renderMeetings(meetings) {
88
91
  // eslint-disable-next-line no-unsanitized/method -- reason: composed from internal archived count and fixed toggle markup (no user data flows in)
89
92
  if (archived.length) el.insertAdjacentHTML('beforeend', '<div style="text-align:center;margin-top:8px"><button class="pr-pager-btn" style="font-size:10px" onclick="_toggleArchivedMeetings()">Show ' + archived.length + ' archived</button></div>');
90
93
  _mtgTotalPages = 1;
94
+ restoreDashboardScrollState(el, scrollState);
91
95
  return;
92
96
  }
93
97
 
@@ -143,29 +147,30 @@ function renderMeetings(meetings) {
143
147
  el.insertAdjacentHTML('beforeend', '<div style="text-align:center;margin-top:8px"><button class="pr-pager-btn" style="font-size:10px" onclick="_toggleArchivedMeetings()">' +
144
148
  (_showArchived ? 'Hide' : 'Show') + ' ' + archived.length + ' archived</button></div>');
145
149
  }
150
+ restoreDashboardScrollState(el, scrollState);
146
151
  restoreNotifBadges();
147
152
  }
148
153
 
149
- function _rerenderMeetingPageFromCache() {
150
- renderMeetings(_lastMeetingsForPaging || []);
154
+ function _rerenderMeetingPageFromCache(opts) {
155
+ renderMeetings(_lastMeetingsForPaging || [], opts);
151
156
  }
152
157
  function _mtgPrev() {
153
158
  if (_mtgPage > 0) {
154
159
  _mtgPage--;
155
- _rerenderMeetingPageFromCache();
160
+ _rerenderMeetingPageFromCache({ preserveScroll: false });
156
161
  }
157
162
  }
158
163
  function _mtgNext() {
159
164
  if (_mtgPage < _mtgTotalPages - 1) {
160
165
  _mtgPage++;
161
- _rerenderMeetingPageFromCache();
166
+ _rerenderMeetingPageFromCache({ preserveScroll: false });
162
167
  }
163
168
  }
164
169
 
165
170
  function _toggleArchivedMeetings() {
166
171
  _showArchived = !_showArchived;
167
172
  _mtgPage = 0;
168
- _rerenderMeetingPageFromCache();
173
+ _rerenderMeetingPageFromCache({ preserveScroll: false });
169
174
  }
170
175
 
171
176
  let _meetingPollInterval = null;
@@ -204,14 +209,14 @@ function _renderMeetingDetail(m) {
204
209
  if (m.findings?.[agent]) {
205
210
  html += '<div style="padding:8px 12px;font-size:11px;border-bottom:1px solid var(--border)">' +
206
211
  '<div style="color:var(--muted);font-size:10px;margin-bottom:4px">Round 1 — Findings</div>' +
207
- '<div style="word-break:break-word;max-height:300px;overflow-y:auto">' + renderMd(m.findings[agent].content || '') + '</div></div>';
212
+ '<div class="meeting-scroll-panel" style="word-break:break-word;max-height:300px;overflow-y:auto">' + renderMd(m.findings[agent].content || '') + '</div></div>';
208
213
  }
209
214
 
210
215
  // Debate
211
216
  if (m.debate?.[agent]) {
212
217
  html += '<div style="padding:8px 12px;font-size:11px;border-bottom:1px solid var(--border)">' +
213
218
  '<div style="color:var(--muted);font-size:10px;margin-bottom:4px">Round 2 — Debate</div>' +
214
- '<div style="word-break:break-word;max-height:300px;overflow-y:auto">' + renderMd(m.debate[agent].content || '') + '</div></div>';
219
+ '<div class="meeting-scroll-panel" style="word-break:break-word;max-height:300px;overflow-y:auto">' + renderMd(m.debate[agent].content || '') + '</div></div>';
215
220
  }
216
221
 
217
222
  // Status
@@ -226,7 +231,7 @@ function _renderMeetingDetail(m) {
226
231
  if (m.conclusion) {
227
232
  html += '<div style="background:rgba(63,185,80,0.08);border:1px solid var(--green);border-radius:6px;padding:10px 14px">' +
228
233
  '<div style="color:var(--green);font-weight:600;font-size:12px;margin-bottom:6px">Conclusion (by ' + escHtml(m.conclusion.agent || '?') + ')</div>' +
229
- '<div style="font-size:12px;word-break:break-word;max-height:400px;overflow-y:auto">' + renderMd(m.conclusion.content || '') + '</div></div>';
234
+ '<div class="meeting-scroll-panel" style="font-size:12px;word-break:break-word;max-height:400px;overflow-y:auto">' + renderMd(m.conclusion.content || '') + '</div></div>';
230
235
  }
231
236
 
232
237
  // Human notes
@@ -271,12 +276,12 @@ function _renderMeetingDetail(m) {
271
276
 
272
277
  document.getElementById('modal-title').textContent = 'Meeting: ' + m.title;
273
278
  var body = document.getElementById('modal-body');
274
- var scrollTop = body.scrollTop;
279
+ var scrollState = captureDashboardScrollState(body, [':self', '.meeting-scroll-panel']);
275
280
  // eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes all user-controlled markdown fields before assembling HTML (see dashboard/js/utils.js); ids and labels use escHtml()
276
281
  body.innerHTML = html;
277
282
  body.style.fontFamily = "'Segoe UI', system-ui, sans-serif";
278
283
  body.style.whiteSpace = 'normal';
279
- body.scrollTop = scrollTop;
284
+ restoreDashboardScrollState(body, scrollState);
280
285
 
281
286
  // Wire up doc-chat Q&A panel for the meeting transcript
282
287
  const transcript = (m.transcript || []).map(t =>
@@ -290,16 +290,19 @@ function _renderPipelineTriggerLabel(cron) {
290
290
  return escHtml(human) + ' <span style="opacity:0.6">(' + escHtml(tz) + ')</span>';
291
291
  }
292
292
 
293
- function renderPipelines(pipelines) {
293
+ function renderPipelines(pipelines, opts) {
294
+ opts = opts || {};
294
295
  pipelines = (pipelines || []).filter(function(p) { return !isDeleted('pipeline:' + p.id); });
295
296
  _pipelinesData = pipelines;
296
297
  const el = document.getElementById('pipelines-content');
297
298
  const countEl = document.getElementById('pipelines-count');
298
299
  if (!el) return;
300
+ const scrollState = opts.preserveScroll === false ? null : captureDashboardScrollState(el, ['.pl-node-chain']);
299
301
  if (!pipelines || pipelines.length === 0) {
300
302
  countEl.textContent = '0';
301
303
  el.innerHTML = '<p class="empty">No pipelines yet. Create one to chain stages like audit \u2192 meeting \u2192 plan \u2192 merge.</p>';
302
304
  _pipelineTotalPages = 1;
305
+ restoreDashboardScrollState(el, scrollState);
303
306
  return;
304
307
  }
305
308
  countEl.textContent = pipelines.length;
@@ -360,23 +363,29 @@ function renderPipelines(pipelines) {
360
363
  '<button class="pr-pager-btn ' + (_pipelinePage >= totalPages - 1 ? 'disabled' : '') + '" onclick="_pipelineNext()">Next</button>' +
361
364
  '</div></div>');
362
365
  }
366
+ restoreDashboardScrollState(el, scrollState);
363
367
  }
364
368
 
365
369
  function _pipelinePrev() {
366
370
  if (_pipelinePage > 0) {
367
371
  _pipelinePage--;
368
- renderPipelines(_pipelinesData);
372
+ renderPipelines(_pipelinesData, { preserveScroll: false });
369
373
  }
370
374
  }
371
375
 
372
376
  function _pipelineNext() {
373
377
  if (_pipelinePage < _pipelineTotalPages - 1) {
374
378
  _pipelinePage++;
375
- renderPipelines(_pipelinesData);
379
+ renderPipelines(_pipelinesData, { preserveScroll: false });
376
380
  }
377
381
  }
378
382
 
379
383
  function openPipelineDetail(id) {
384
+ var modalBody = document.getElementById('modal-body');
385
+ var preserveModalScroll = document.getElementById('modal')?.classList?.contains('open') && _pipelinePollId === id;
386
+ var modalScrollState = preserveModalScroll
387
+ ? captureDashboardScrollState(modalBody, [':self', '.pl-node-chain', '.pipeline-stage-output'])
388
+ : null;
380
389
  _stopPipelinePoll();
381
390
  var p = _pipelinesData.find(function(x) { return x.id === id; });
382
391
  if (!p) { alert('Pipeline not found'); return; }
@@ -444,7 +453,7 @@ function openPipelineDetail(id) {
444
453
  '</div>' +
445
454
  _renderMonitoredResources(s.monitoredResources || []) +
446
455
  _renderArtifactLinks(stageRun.artifacts, id) +
447
- (stageRun.output ? '<div style="margin-top:6px;font-size:11px;max-height:150px;overflow-y:auto">' + renderMd(stageRun.output.slice(0, 500)) + '</div>' : '') +
456
+ (stageRun.output ? '<div class="pipeline-stage-output" style="margin-top:6px;font-size:11px;max-height:150px;overflow-y:auto">' + renderMd(stageRun.output.slice(0, 500)) + '</div>' : '') +
448
457
  (stageStatus === 'waiting-human' ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green);border-color:var(--green);margin-top:6px" onclick="_continuePipeline(\'' + escHtml(id) + '\',\'' + escHtml(s.id) + '\',this)">Continue</button>' : '') +
449
458
  '</div>';
450
459
  });
@@ -477,7 +486,8 @@ function openPipelineDetail(id) {
477
486
 
478
487
  document.getElementById('modal-title').textContent = 'Pipeline: ' + displayTitle;
479
488
  // eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes user-controlled stage output before assembling HTML; all other user data wrapped in escHtml() (see dashboard/js/utils.js)
480
- document.getElementById('modal-body').innerHTML = html;
489
+ modalBody.innerHTML = html;
490
+ restoreDashboardScrollState(modalBody, modalScrollState);
481
491
  document.getElementById('modal').classList.add('open');
482
492
 
483
493
  // Live-poll while modal is open — always poll (not just active runs)
@@ -160,6 +160,7 @@ function _renderPrdWorkItemIdBadge(workItemId, prdItemId, size, padding) {
160
160
  function renderPrdProgress(prog) {
161
161
  const el = document.getElementById('prd-progress-content');
162
162
  const countEl = document.getElementById('prd-progress-count');
163
+ const scrollState = captureDashboardScrollState(el, ['.prd-items-list', '.pl-node-chain']);
163
164
  if (!prog) { el.innerHTML = ''; countEl.textContent = '—'; return; }
164
165
  const visibleItems = (prog.items || []).filter(i => !isDeleted('plan:' + (i.source || '')) && !isDeleted(_prdItemDeleteKey(i.source, i.id)));
165
166
 
@@ -654,6 +655,7 @@ function renderPrdProgress(prog) {
654
655
 
655
656
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() or renderMd() by PRD renderers (fields: item ids/names/descriptions, source, projects, PR links, branches, agent labels)
656
657
  el.innerHTML = html;
658
+ restoreDashboardScrollState(el, scrollState);
657
659
  restoreNotifBadges();
658
660
  }
659
661
 
@@ -207,7 +207,21 @@ function prTableHtml(rows) {
207
207
  '</tr></thead><tbody>' + rows + '</tbody></table></div>';
208
208
  }
209
209
 
210
- function renderPrs(prs) {
210
+ function _capturePrScrollState(el) {
211
+ const tableWrap = el ? el.querySelector('.pr-table-wrap') : null;
212
+ return tableWrap ? { left: tableWrap.scrollLeft || 0, top: tableWrap.scrollTop || 0 } : null;
213
+ }
214
+
215
+ function _restorePrScrollState(el, state) {
216
+ if (!state) return;
217
+ const tableWrap = el ? el.querySelector('.pr-table-wrap') : null;
218
+ if (!tableWrap) return;
219
+ tableWrap.scrollLeft = state.left || 0;
220
+ tableWrap.scrollTop = state.top || 0;
221
+ }
222
+
223
+ function renderPrs(prs, opts) {
224
+ opts = opts || {};
211
225
  prs = prs.filter(p => !isDeleted('pr:' + p.id));
212
226
  allPrs = prs;
213
227
  const el = document.getElementById('pr-content');
@@ -218,6 +232,7 @@ function renderPrs(prs) {
218
232
  return;
219
233
  }
220
234
  const totalPages = Math.ceil(prs.length / PR_PER_PAGE);
235
+ const previousPage = prPage;
221
236
  if (prPage >= totalPages) prPage = totalPages - 1;
222
237
  const start = prPage * PR_PER_PAGE;
223
238
  const pagePrs = prs.slice(start, start + PR_PER_PAGE);
@@ -235,18 +250,16 @@ function renderPrs(prs) {
235
250
  '</div>';
236
251
  }
237
252
 
238
- const tableWrap = el.querySelector('.pr-table-wrap');
239
- const savedScroll = tableWrap ? tableWrap.scrollLeft : 0;
253
+ const savedScroll = opts.preserveScroll === false || previousPage !== prPage
254
+ ? null
255
+ : _capturePrScrollState(el);
240
256
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all PR user data wrapped in escapeHtml() by prRow() (fields: PR id, title, description, agent, branch, review/build/status labels)
241
257
  el.innerHTML = prTableHtml(rows) + pager;
242
- if (savedScroll) {
243
- const newWrap = el.querySelector('.pr-table-wrap');
244
- if (newWrap) newWrap.scrollLeft = savedScroll;
245
- }
258
+ _restorePrScrollState(el, savedScroll);
246
259
  }
247
260
 
248
- function prPrev() { if (prPage > 0) { prPage--; renderPrs(allPrs); } }
249
- function prNext() { const totalPages = Math.ceil(allPrs.length / PR_PER_PAGE); if (prPage < totalPages-1) { prPage++; renderPrs(allPrs); } }
261
+ function prPrev() { if (prPage > 0) { prPage--; renderPrs(allPrs, { preserveScroll: false }); } }
262
+ function prNext() { const totalPages = Math.ceil(allPrs.length / PR_PER_PAGE); if (prPage < totalPages-1) { prPage++; renderPrs(allPrs, { preserveScroll: false }); } }
250
263
 
251
264
  function openAllPrs() {
252
265
  const modalEl = document.querySelector('#modal .modal');
@@ -247,7 +247,7 @@ function _renderViewToggle() {
247
247
 
248
248
  function _schedSetView(mode) {
249
249
  _schedViewMode = mode;
250
- renderSchedules(window._lastSchedules || []);
250
+ renderSchedules(window._lastSchedules || [], { preserveScroll: false });
251
251
  }
252
252
 
253
253
  function _renderScheduleCalendar(schedules) {
@@ -310,14 +310,17 @@ function _renderScheduleCalendar(schedules) {
310
310
  return html;
311
311
  }
312
312
 
313
- function renderSchedules(schedules) {
313
+ function renderSchedules(schedules, opts) {
314
+ opts = opts || {};
314
315
  schedules = schedules.filter(function(s) { return !isDeleted('sched:' + s.id); });
315
316
  const el = document.getElementById('scheduled-content');
316
317
  const countEl = document.getElementById('scheduled-count');
317
318
  countEl.textContent = schedules.length;
318
319
  window._lastSchedules = schedules;
320
+ const scrollState = opts.preserveScroll === false ? null : captureDashboardScrollState(el, ['.pr-table-wrap']);
319
321
  if (!schedules.length) {
320
322
  el.innerHTML = '<p class="empty">No scheduled tasks. Add one to automate recurring work.</p>';
323
+ restoreDashboardScrollState(el, scrollState);
321
324
  return;
322
325
  }
323
326
 
@@ -371,10 +374,11 @@ function renderSchedules(schedules) {
371
374
 
372
375
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() by schedule renderers (fields: schedule id/title/cron/type/project/agent/description)
373
376
  el.innerHTML = html;
377
+ restoreDashboardScrollState(el, scrollState);
374
378
  }
375
379
 
376
- function _schedPrev() { if (_schedPage > 0) { _schedPage--; renderSchedules(window._lastSchedules || []); } }
377
- function _schedNext() { var tp = Math.ceil((window._lastSchedules || []).length / SCHED_PER_PAGE); if (_schedPage < tp-1) { _schedPage++; renderSchedules(window._lastSchedules || []); } }
380
+ function _schedPrev() { if (_schedPage > 0) { _schedPage--; renderSchedules(window._lastSchedules || [], { preserveScroll: false }); } }
381
+ function _schedNext() { var tp = Math.ceil((window._lastSchedules || []).length / SCHED_PER_PAGE); if (_schedPage < tp-1) { _schedPage++; renderSchedules(window._lastSchedules || [], { preserveScroll: false }); } }
378
382
 
379
383
  function openScheduleDetail(id) {
380
384
  const s = (window._lastSchedules || []).find(x => x.id === id);
@@ -405,4 +405,36 @@ function pinButton(pinKey, pinned, source, opts) {
405
405
  (pinned ? 'Unpin' : 'Pin') + '</button>';
406
406
  }
407
407
 
408
- window.MinionsRenderUtils = { formatToolSummary, renderAgentOutput, renderTerminalBanner, renderPager, pinButton };
408
+ function _scrollNodesForSelector(root, selector) {
409
+ if (!root || !selector) return [];
410
+ if (selector === ':self') return [root];
411
+ var out = [];
412
+ if (root.matches && root.matches(selector)) out.push(root);
413
+ if (root.querySelectorAll) {
414
+ root.querySelectorAll(selector).forEach(function(el) { out.push(el); });
415
+ }
416
+ return out;
417
+ }
418
+
419
+ function captureDashboardScrollState(root, selectors) {
420
+ var state = [];
421
+ (selectors || []).forEach(function(selector) {
422
+ var nodes = _scrollNodesForSelector(root, selector);
423
+ nodes.forEach(function(el, index) {
424
+ state.push({ selector: selector, index: index, top: el.scrollTop || 0, left: el.scrollLeft || 0 });
425
+ });
426
+ });
427
+ return state;
428
+ }
429
+
430
+ function restoreDashboardScrollState(root, state) {
431
+ (state || []).forEach(function(item) {
432
+ var nodes = _scrollNodesForSelector(root, item.selector);
433
+ var el = nodes[item.index];
434
+ if (!el) return;
435
+ el.scrollTop = item.top || 0;
436
+ el.scrollLeft = item.left || 0;
437
+ });
438
+ }
439
+
440
+ window.MinionsRenderUtils = { formatToolSummary, renderAgentOutput, renderTerminalBanner, renderPager, pinButton, captureDashboardScrollState, restoreDashboardScrollState };
@@ -133,16 +133,19 @@ function _parseIntervalStr(s) {
133
133
  let _watchPage = 0;
134
134
  const WATCH_PER_PAGE = 15;
135
135
 
136
- function renderWatches(watchesData) {
136
+ function renderWatches(watchesData, opts) {
137
+ opts = opts || {};
137
138
  var watches = (watchesData || []).filter(function(w) { return !isDeleted('watch:' + w.id); });
138
139
  var el = document.getElementById('watches-content');
139
140
  var countEl = document.getElementById('watches-count');
140
141
  if (!el) return;
141
142
  if (countEl) countEl.textContent = watches.length;
142
143
  window._lastWatches = watches;
144
+ var scrollState = opts.preserveScroll === false ? null : captureDashboardScrollState(el, ['.pr-table-wrap']);
143
145
 
144
146
  if (!watches.length) {
145
147
  el.innerHTML = '<p class="empty">No active watches. Create one to monitor PRs or work items.</p>';
148
+ restoreDashboardScrollState(el, scrollState);
146
149
  return;
147
150
  }
148
151
 
@@ -205,10 +208,11 @@ function renderWatches(watchesData) {
205
208
 
206
209
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: watch id/target/description/action type/owner/timestamps)
207
210
  el.innerHTML = html;
211
+ restoreDashboardScrollState(el, scrollState);
208
212
  }
209
213
 
210
- function _watchPrev() { if (_watchPage > 0) { _watchPage--; renderWatches(window._lastWatches || []); } }
211
- function _watchNext() { var tp = Math.ceil((window._lastWatches || []).length / WATCH_PER_PAGE); if (_watchPage < tp - 1) { _watchPage++; renderWatches(window._lastWatches || []); } }
214
+ function _watchPrev() { if (_watchPage > 0) { _watchPage--; renderWatches(window._lastWatches || [], { preserveScroll: false }); } }
215
+ function _watchNext() { var tp = Math.ceil((window._lastWatches || []).length / WATCH_PER_PAGE); if (_watchPage < tp - 1) { _watchPage++; renderWatches(window._lastWatches || [], { preserveScroll: false }); } }
212
216
 
213
217
  // ─── Detail Modal ───────────────────────────────────────────────────────────
214
218
 
@@ -177,7 +177,8 @@ function wiRow(item) {
177
177
  '</tr>';
178
178
  }
179
179
 
180
- function renderWorkItems(items) {
180
+ function renderWorkItems(items, opts) {
181
+ opts = opts || {};
181
182
  items = items.filter(function(w) { return !isDeleted('wi:' + w.id); });
182
183
  // Sort: active/dispatched first, then by most recent activity
183
184
  const statusOrder = { dispatched: 0, pending: 1, queued: 1, failed: 2, done: 3 };
@@ -198,6 +199,7 @@ function renderWorkItems(items) {
198
199
  }
199
200
 
200
201
  const totalPages = Math.ceil(items.length / WI_PER_PAGE);
202
+ const previousPage = wiPage;
201
203
  if (wiPage >= totalPages) wiPage = totalPages - 1;
202
204
  const start = wiPage * WI_PER_PAGE;
203
205
  const pageItems = items.slice(start, start + WI_PER_PAGE);
@@ -217,14 +219,12 @@ function renderWorkItems(items) {
217
219
  '</div>';
218
220
  }
219
221
 
220
- const tableWrap = el.querySelector('.pr-table-wrap');
221
- const savedScroll = tableWrap ? tableWrap.scrollLeft : 0;
222
+ const scrollState = opts.preserveScroll === false || previousPage !== wiPage
223
+ ? null
224
+ : captureDashboardScrollState(el, ['.pr-table-wrap']);
222
225
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() by wiRow() (fields: work item id/title/source/status/agent/PR links/dates/follow-up metadata)
223
226
  el.innerHTML = html;
224
- if (savedScroll) {
225
- const newWrap = el.querySelector('.pr-table-wrap');
226
- if (newWrap) newWrap.scrollLeft = savedScroll;
227
- }
227
+ restoreDashboardScrollState(el, scrollState);
228
228
  }
229
229
 
230
230
  async function editWorkItem(id, source) {
@@ -437,8 +437,8 @@ async function retryWorkItem(id, source) {
437
437
  }
438
438
  }
439
439
 
440
- function wiPrev() { if (wiPage > 0) { wiPage--; renderWorkItems(allWorkItems); } }
441
- function wiNext() { const tp = Math.ceil(allWorkItems.length / WI_PER_PAGE); if (wiPage < tp-1) { wiPage++; renderWorkItems(allWorkItems); } }
440
+ function wiPrev() { if (wiPage > 0) { wiPage--; renderWorkItems(allWorkItems, { preserveScroll: false }); } }
441
+ function wiNext() { const tp = Math.ceil(allWorkItems.length / WI_PER_PAGE); if (wiPage < tp-1) { wiPage++; renderWorkItems(allWorkItems, { preserveScroll: false }); } }
442
442
 
443
443
  let _feedbackRating = null;
444
444
  function feedbackWorkItem(id, source) {
@@ -53,8 +53,8 @@ function kbPinKey(cat, file) { return 'knowledge/' + cat + '/' + file; }
53
53
  function _togglePinAndRefresh(key, source) {
54
54
  var pinned = togglePin(key);
55
55
  showToast('cmd-toast', pinned ? 'Pinned to top' : 'Unpinned', true);
56
- if (source === 'inbox') renderInbox(inboxData);
57
- else if (source === 'kb') renderKnowledgeBase();
56
+ if (source === 'inbox') renderInbox(inboxData, { preserveScroll: false });
57
+ else if (source === 'kb') renderKnowledgeBase({ preserveScroll: false });
58
58
  }
59
59
 
60
60
  // Modal navigation stack — enables back button when opening a modal from another modal
@@ -93,8 +93,8 @@ function toggleModalPin() {
93
93
  var pinned = togglePin(_modalFilePath);
94
94
  showToast('cmd-toast', pinned ? 'Pinned to top' : 'Unpinned', true);
95
95
  updateModalPinBtn();
96
- if (_modalFilePath.startsWith('notes/inbox/')) renderInbox(inboxData);
97
- else if (_modalFilePath.startsWith('knowledge/')) renderKnowledgeBase();
96
+ if (_modalFilePath.startsWith('notes/inbox/')) renderInbox(inboxData, { preserveScroll: false });
97
+ else if (_modalFilePath.startsWith('knowledge/')) renderKnowledgeBase({ preserveScroll: false });
98
98
  }
99
99
 
100
100
  // Canonical HTML-escape helper (SEC-03). Use this in all new code and for any user-controlled
@@ -29,7 +29,7 @@ function buildDashboardHtml() {
29
29
  }
30
30
 
31
31
  const jsFiles = [
32
- 'utils', 'state', 'features-client', 'detail-panel', 'live-stream',
32
+ 'utils', 'state', 'features-client', 'render-utils', 'detail-panel', 'live-stream',
33
33
  'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
34
34
  'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
35
35
  'render-other', 'render-managed', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
@@ -1,17 +1,4 @@
1
1
  [
2
- {
3
- "id": "status-retention-stale-default-migration",
4
- "description": "applyStatusWorkItemsRetentionMigration + applyStatusMeetingsRetentionMigration: one-time migrations that drop engine.statusWorkItemsRetentionDays=7 and engine.statusMeetingsRetentionDays=7 from loaded config so the new defaults (0, no trim) reach installs whose config.json was persisted while 7 was the baked-in default. The 7-day cutoffs were added alongside the slim projections (W-mphejzmj000718bf for workItems, W-mphlrxx6000a8760 for meetings) but surfaced as data loss to users (completed rows disappearing from /api/status after a week). The slim projections (which deliver the bulk of the payload savings) still run unconditionally; only the date filters were demoted to opt-in. shared.js mutates the in-memory config; engine/cli.js follows up with guarded shared.mutateJsonFileLocked calls on config.json so the fields are also removed on disk — affected installs are permanently corrected the first time the engine boots after this ships.",
5
- "code": [
6
- { "file": "engine/shared.js", "note": "applyStatusWorkItemsRetentionMigration / applyStatusMeetingsRetentionMigration definitions + _resetStaleRetentionMigrationFlag / _resetStaleMeetingsRetentionMigrationFlag test helpers — pure, in-memory" },
7
- { "file": "engine/cli.js", "note": "Two boot blocks inside start() — each applies the in-memory pass THEN mutateJsonFileLocked on config.json to delete the field with skipWriteIfUnchanged so non-7 installs don't churn the file" },
8
- { "file": "dashboard.js", "note": "Initial CONFIG load + reloadConfig() both apply the in-memory migrations so the dashboard picks up the new defaults even if it boots before the engine writes config.json" }
9
- ],
10
- "deprecated": "2026-05-29",
11
- "targetRemovalDate": "2026-06-01",
12
- "cleanup": "On or after 2026-06-01 (3 days), delete both migration functions + their reset helpers from engine/shared.js (including the shared.js export lines), the boot calls + mutateJsonFileLocked blocks in engine/cli.js, the call sites in dashboard.js, the tests in test/unit/status-workitems-retention.test.js and test/unit/status-meetings-retention.test.js gated on the function names, and this entry. Three days is enough because the on-disk rewrite happens on first engine boot — any install whose engine has rebooted at least once since this shipped has already had its config.json corrected and is no longer dependent on the in-memory shim.",
13
- "notes": "Persistent custom values (any non-7 integer) are preserved untouched. The only at-risk users are those who explicitly want a 7-day window — they can re-set via the Settings page after removal. If a user never restarts the engine in the 3-day window, the shim still strips the persisted 7 on dashboard-only boot via the in-memory pass; the on-disk write is a hardening pass for the common case, not a strict requirement."
14
- },
15
2
  {
16
3
  "id": "config-poll-key-migration",
17
4
  "location": "engine/queries.js:126-163",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2088",
3
+ "version": "0.1.2090",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"