@yemi33/minions 0.1.2087 → 0.1.2089

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.
@@ -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) {
@@ -5,10 +5,69 @@ const LOG_PER_PAGE = 50;
5
5
  let _completedPage = 0;
6
6
  let _logPage = 0;
7
7
 
8
- function _completedPrev() { if (_completedPage > 0) { _completedPage--; refresh(); } }
9
- function _completedNext() { _completedPage++; refresh(); } // clamped in renderDispatch
10
- function _logPrev() { if (_logPage > 0) { _logPage--; refresh(); } }
11
- function _logNext() { _logPage++; refresh(); } // clamped in renderEngineLog
8
+ // Fetch engine/dispatch.json straight off disk through the /state/<path>
9
+ // static-file passthrough so dispatch state flips (queued→active, active→
10
+ // completed, skipReason changes on pending entries) surface within one 4 s
11
+ // poll regardless of /api/status outer-cache state (issue #2949).
12
+ //
13
+ // completedTotal isn't on disk — it's metrics-derived (sum across
14
+ // agents.tasksCompleted + agents.tasksErrored). The renderer already falls
15
+ // back to completed.length when completedTotal is missing, so we just
16
+ // overlay the cached value from the previous /api/status snapshot for
17
+ // continuity. Engine-side _withCompletionReportSummary enrichment (which
18
+ // reads per-dispatch sidecar files) is preserved on the cached snapshot
19
+ // too, so the modal "view report" links keep working — we only overlay the
20
+ // fresh status/timestamp fields onto the cached row when ids match.
21
+ async function fetchDispatchFromDisk(cachedDispatch) {
22
+ try {
23
+ const r = await fetch('/state/engine/dispatch.json');
24
+ if (!r.ok) return null;
25
+ const fresh = await r.json();
26
+ if (!fresh || typeof fresh !== 'object') return null;
27
+ // Cap completed to last 20 to match engine/queries.js getDispatchQueue
28
+ // — render-dispatch only paginates within this window, and dispatch.json
29
+ // on disk is already capped to 100 by the engine, so this is the same
30
+ // shape the renderer has always seen.
31
+ if (Array.isArray(fresh.completed) && fresh.completed.length > 20) {
32
+ fresh.completed = fresh.completed.slice(-20);
33
+ }
34
+ // Overlay completion-report summaries from the cached snapshot keyed by
35
+ // dispatch id. They live in sidecar files the client can't read, but
36
+ // they're slow to change so the cached overlay is fine.
37
+ if (cachedDispatch) {
38
+ const cachedById = new Map();
39
+ for (const list of ['pending', 'active', 'completed']) {
40
+ for (const d of (cachedDispatch[list] || [])) {
41
+ if (d && d.id) cachedById.set(d.id, d);
42
+ }
43
+ }
44
+ for (const list of ['pending', 'active', 'completed']) {
45
+ if (!Array.isArray(fresh[list])) continue;
46
+ fresh[list] = fresh[list].map((d) => {
47
+ if (!d || !d.id) return d;
48
+ const cached = cachedById.get(d.id);
49
+ if (!cached) return d;
50
+ const merged = Object.assign({}, d);
51
+ if (cached._completionReport !== undefined && merged._completionReport === undefined) {
52
+ merged._completionReport = cached._completionReport;
53
+ }
54
+ return merged;
55
+ });
56
+ }
57
+ if (fresh.completedTotal == null && cachedDispatch.completedTotal != null) {
58
+ fresh.completedTotal = cachedDispatch.completedTotal;
59
+ }
60
+ }
61
+ return fresh;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
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
12
71
 
13
72
  // Engine restart grace state (W-mpfw3hgm001gc594). After the operator clicks
14
73
  // "Restart engine" we suppress the STALE indicators for ENGINE_RESTART_GRACE_MS
@@ -186,7 +245,8 @@ function renderGhThrottleAlert(ghThrottle) {
186
245
  el.style.display = 'flex';
187
246
  }
188
247
 
189
- function renderDispatch(dispatch) {
248
+ function renderDispatch(dispatch, opts) {
249
+ opts = opts || {};
190
250
  if (!dispatch) return;
191
251
 
192
252
  // Stats
@@ -235,6 +295,9 @@ function renderDispatch(dispatch) {
235
295
  // Completed
236
296
  const completedEl = document.getElementById('completed-content');
237
297
  const completedCount = document.getElementById('completed-count');
298
+ const completedScroll = opts.preserveCompletedScroll === false
299
+ ? null
300
+ : captureDashboardScrollState(completedEl, [':self', '.pr-table-wrap']);
238
301
  const completed = (dispatch.completed || []).slice().reverse();
239
302
  completedCount.textContent = completed.length;
240
303
 
@@ -272,13 +335,37 @@ function renderDispatch(dispatch) {
272
335
  } else {
273
336
  completedEl.innerHTML = '<p class="empty">No completed dispatches yet.</p>';
274
337
  }
338
+ restoreDashboardScrollState(completedEl, completedScroll);
339
+ }
340
+
341
+ // Pull the engine log straight off disk through /state/engine/log.json.
342
+ // On-disk file is the full append-only array; engine/queries.js getEngineLog
343
+ // slices to the last 50 for /api/status. We replicate that slice client-side
344
+ // after the disk fetch. ETag/304 keeps the steady state cheap; a new log
345
+ // line forces a fresh download (file is ~500 KB at high engine traffic, but
346
+ // it changes only on engine writes — not on every 4 s poll).
347
+ async function fetchEngineLogFromDisk() {
348
+ try {
349
+ const r = await fetch('/state/engine/log.json');
350
+ if (!r.ok) return null;
351
+ const raw = await r.json();
352
+ const arr = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.entries) ? raw.entries : []);
353
+ return arr.slice(-50);
354
+ } catch {
355
+ return null;
356
+ }
275
357
  }
276
358
 
277
- function renderEngineLog(log) {
359
+ function renderEngineLog(log, opts) {
360
+ opts = opts || {};
278
361
  const el = document.getElementById('engine-log');
279
362
  if (!el) return;
363
+ const scrollState = opts.preserveEngineLogScroll === false
364
+ ? null
365
+ : captureDashboardScrollState(el, [':self']);
280
366
  if (!log || log.length === 0) {
281
367
  el.innerHTML = '<div class="empty">No log entries yet.</div>';
368
+ restoreDashboardScrollState(el, scrollState);
282
369
  return;
283
370
  }
284
371
  const reversed = log.slice().reverse();
@@ -303,6 +390,7 @@ function renderEngineLog(log) {
303
390
  });
304
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)
305
392
  if (logPager) el.insertAdjacentHTML('beforeend', logPager);
393
+ restoreDashboardScrollState(el, scrollState);
306
394
  }
307
395
 
308
396
  function shortTime(t) {
@@ -3,10 +3,83 @@
3
3
  const INBOX_PER_PAGE = 15;
4
4
  let _inboxPage = 0;
5
5
 
6
- function _inboxPrev() { if (_inboxPage > 0) { _inboxPage--; renderInbox(inboxData); } }
7
- function _inboxNext() { _inboxPage++; renderInbox(inboxData); }
6
+ // Quick relative-time formatter — mirrors engine/queries.js timeSince so the
7
+ // inbox-item "age" chip reads the same as it did via /api/status.
8
+ function _timeSinceMs(mtimeMs) {
9
+ if (!mtimeMs) return '';
10
+ const ageMs = Date.now() - Number(mtimeMs);
11
+ if (ageMs < 0) return 'just now';
12
+ const sec = Math.floor(ageMs / 1000);
13
+ if (sec < 60) return sec + 's ago';
14
+ const min = Math.floor(sec / 60);
15
+ if (min < 60) return min + 'm ago';
16
+ const hr = Math.floor(min / 60);
17
+ if (hr < 24) return hr + 'h ago';
18
+ const days = Math.floor(hr / 24);
19
+ return days + 'd ago';
20
+ }
21
+
22
+ // Fetch the inbox via the /state/notes/inbox directory listing + per-file
23
+ // content fetch. The listing call returns {entries:[{name, mtimeMs, size,
24
+ // isDir}]} and is cheap (one statSync per entry). The per-file fetches run
25
+ // in parallel with their own mtime+size ETag so unchanged files 304 with
26
+ // no body. Issue #2949 — the staleness fix applies here too: the inbox
27
+ // directory mtime advances on every new note, and per-file ETags surface
28
+ // content edits within one 4 s poll.
29
+ async function fetchInboxFromDisk() {
30
+ try {
31
+ const listResp = await fetch('/state/notes/inbox');
32
+ if (!listResp.ok) return null;
33
+ const listing = await listResp.json();
34
+ const entries = Array.isArray(listing && listing.entries) ? listing.entries : [];
35
+ const mdFiles = entries
36
+ .filter((e) => e && !e.isDir && typeof e.name === 'string' && e.name.endsWith('.md'))
37
+ .sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
38
+ const items = await Promise.all(mdFiles.map(async (e) => {
39
+ try {
40
+ const r = await fetch('/state/notes/inbox/' + encodeURIComponent(e.name));
41
+ if (!r.ok) return null;
42
+ const content = await r.text();
43
+ return {
44
+ name: e.name,
45
+ mtime: e.mtimeMs,
46
+ age: _timeSinceMs(e.mtimeMs),
47
+ content,
48
+ };
49
+ } catch {
50
+ return null;
51
+ }
52
+ }));
53
+ return items.filter(Boolean);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ // Fetch notes.md via /state/notes.md and return the renderer-shaped
60
+ // { content, updatedAt } pair. updatedAt comes from a HEAD-style poll of
61
+ // the ETag (mtime+size); we don't have direct mtime exposure, so a parallel
62
+ // listing of the parent dir would be needed. Cheaper path: just include the
63
+ // ETag value (which is "<mtimeMs>-<size>") and parse it out.
64
+ async function fetchNotesFromDisk() {
65
+ try {
66
+ const r = await fetch('/state/notes.md');
67
+ if (!r.ok) return null;
68
+ const content = await r.text();
69
+ const etag = r.headers.get('ETag') || '';
70
+ const m = etag.match(/^"(\d+)-/);
71
+ const updatedAt = m ? Number(m[1]) : null;
72
+ return { content, updatedAt };
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function _inboxPrev() { if (_inboxPage > 0) { _inboxPage--; renderInbox(inboxData, { preserveScroll: false }); } }
79
+ function _inboxNext() { _inboxPage++; renderInbox(inboxData, { preserveScroll: false }); }
8
80
 
9
- function renderInbox(inbox) {
81
+ function renderInbox(inbox, opts) {
82
+ opts = opts || {};
10
83
  invalidatePinsCache();
11
84
  inbox = inbox.filter(function(item) { return !isDeleted('inbox:' + item.name); });
12
85
  // Stable sort — pinned items float to top
@@ -17,7 +90,8 @@ function renderInbox(inbox) {
17
90
  const list = document.getElementById('inbox-list');
18
91
  const count = document.getElementById('inbox-count');
19
92
  count.textContent = inbox.length;
20
- 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; }
21
95
 
22
96
  const totalInboxPages = Math.ceil(inbox.length / INBOX_PER_PAGE);
23
97
  if (_inboxPage >= totalInboxPages) _inboxPage = totalInboxPages - 1;
@@ -50,6 +124,7 @@ function renderInbox(inbox) {
50
124
  });
51
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)
52
126
  if (inboxPager) list.insertAdjacentHTML('beforeend', inboxPager);
127
+ restoreDashboardScrollState(list, scrollState);
53
128
  restoreNotifBadges();
54
129
  }
55
130
 
@@ -78,6 +153,7 @@ function renderNotes(notes) {
78
153
  const el = document.getElementById('notes-list');
79
154
  const content = typeof notes === 'object' ? notes.content : notes;
80
155
  const updatedAt = typeof notes === 'object' ? notes.updatedAt : null;
156
+ const scrollState = captureDashboardScrollState(el, ['.notes-preview']);
81
157
 
82
158
  // Show last updated timestamp
83
159
  const updatedEl = document.getElementById('notes-updated');
@@ -85,9 +161,10 @@ function renderNotes(notes) {
85
161
  updatedEl.textContent = 'updated ' + formatLocalDateTime(updatedAt);
86
162
  }
87
163
 
88
- 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; }
89
165
  // eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes all user-controlled fields before assembling HTML (see dashboard/js/utils.js)
90
166
  el.innerHTML = '<div class="notes-preview" data-file="notes.md" onclick="openNotesModal()" title="Click to expand">' + renderMd(content) + '</div>';
167
+ restoreDashboardScrollState(el, scrollState);
91
168
  el.querySelector('.notes-preview')._rawContent = content;
92
169
  restoreNotifBadges();
93
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() {
@@ -7,16 +7,74 @@ let _lastMeetingHash = '';
7
7
  let _lastMeetingsForPaging = [];
8
8
  let _mtgTotalPages = 1;
9
9
 
10
- function renderMeetings(meetings) {
10
+ // Pull meetings straight off disk via /state/meetings (dir listing) +
11
+ // per-file fetch of /state/meetings/<id>.json. Each file is slimmed
12
+ // client-side to match dashboard.js _slimMeetingForStatus so the
13
+ // renderer sees the same shape it always has (findings/debate reduced
14
+ // to {agentId: true} sentinels, transcript/conclusion bodies dropped).
15
+ // Detail view still calls /api/meetings/<id> for the full bodies.
16
+ // Issue #2949 — meetings on /api/status used to inherit the same outer
17
+ // cache staleness as work-items; this disk-direct path sidesteps it.
18
+ function _slimMeetingForRender(m) {
19
+ if (!m || typeof m !== 'object') return null;
20
+ const findingsKeys = m.findings && typeof m.findings === 'object' ? Object.keys(m.findings) : [];
21
+ const debateKeys = m.debate && typeof m.debate === 'object' ? Object.keys(m.debate) : [];
22
+ const findings = {};
23
+ for (const k of findingsKeys) findings[k] = true;
24
+ const debate = {};
25
+ for (const k of debateKeys) debate[k] = true;
26
+ const slim = {
27
+ id: m.id,
28
+ title: m.title,
29
+ status: m.status,
30
+ round: m.round,
31
+ participants: Array.isArray(m.participants) ? m.participants : [],
32
+ agenda: m.agenda,
33
+ createdAt: m.createdAt,
34
+ findings,
35
+ debate,
36
+ };
37
+ if (m.completedAt !== undefined) slim.completedAt = m.completedAt;
38
+ if (m.roundStartedAt !== undefined) slim.roundStartedAt = m.roundStartedAt;
39
+ if (m.createdBy !== undefined) slim.createdBy = m.createdBy;
40
+ return slim;
41
+ }
42
+ async function fetchMeetingsFromDisk() {
43
+ try {
44
+ const listResp = await fetch('/state/meetings');
45
+ if (!listResp.ok) return null;
46
+ const listing = await listResp.json();
47
+ const entries = Array.isArray(listing && listing.entries) ? listing.entries : [];
48
+ const jsonFiles = entries.filter(e => e && !e.isDir && typeof e.name === 'string' && e.name.endsWith('.json'));
49
+ const meetings = await Promise.all(jsonFiles.map(async (e) => {
50
+ try {
51
+ const r = await fetch('/state/meetings/' + encodeURIComponent(e.name));
52
+ if (!r.ok) return null;
53
+ const raw = await r.json();
54
+ return _slimMeetingForRender(raw);
55
+ } catch {
56
+ return null;
57
+ }
58
+ }));
59
+ return meetings.filter(Boolean);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function renderMeetings(meetings, opts) {
66
+ opts = opts || {};
11
67
  meetings = (meetings || []).filter(function(m) { return !isDeleted('mtg:' + m.id); });
12
68
  meetings.sort((a, b) => (b.createdAt || b.completedAt || '').localeCompare(a.createdAt || a.completedAt || ''));
13
69
  _lastMeetingsForPaging = meetings;
14
70
  const el = document.getElementById('meetings-content');
15
71
  const countEl = document.getElementById('meetings-count');
72
+ const scrollState = opts.preserveScroll === false ? null : captureDashboardScrollState(el, [':self']);
16
73
  if (!meetings || meetings.length === 0) {
17
74
  countEl.textContent = '0';
18
75
  el.innerHTML = '<p class="empty">No meetings yet. Start one to have agents investigate, debate, and conclude on a topic.</p>';
19
76
  _mtgTotalPages = 1;
77
+ restoreDashboardScrollState(el, scrollState);
20
78
  return;
21
79
  }
22
80
 
@@ -33,6 +91,7 @@ function renderMeetings(meetings) {
33
91
  // eslint-disable-next-line no-unsanitized/method -- reason: composed from internal archived count and fixed toggle markup (no user data flows in)
34
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>');
35
93
  _mtgTotalPages = 1;
94
+ restoreDashboardScrollState(el, scrollState);
36
95
  return;
37
96
  }
38
97
 
@@ -88,29 +147,30 @@ function renderMeetings(meetings) {
88
147
  el.insertAdjacentHTML('beforeend', '<div style="text-align:center;margin-top:8px"><button class="pr-pager-btn" style="font-size:10px" onclick="_toggleArchivedMeetings()">' +
89
148
  (_showArchived ? 'Hide' : 'Show') + ' ' + archived.length + ' archived</button></div>');
90
149
  }
150
+ restoreDashboardScrollState(el, scrollState);
91
151
  restoreNotifBadges();
92
152
  }
93
153
 
94
- function _rerenderMeetingPageFromCache() {
95
- renderMeetings(_lastMeetingsForPaging || []);
154
+ function _rerenderMeetingPageFromCache(opts) {
155
+ renderMeetings(_lastMeetingsForPaging || [], opts);
96
156
  }
97
157
  function _mtgPrev() {
98
158
  if (_mtgPage > 0) {
99
159
  _mtgPage--;
100
- _rerenderMeetingPageFromCache();
160
+ _rerenderMeetingPageFromCache({ preserveScroll: false });
101
161
  }
102
162
  }
103
163
  function _mtgNext() {
104
164
  if (_mtgPage < _mtgTotalPages - 1) {
105
165
  _mtgPage++;
106
- _rerenderMeetingPageFromCache();
166
+ _rerenderMeetingPageFromCache({ preserveScroll: false });
107
167
  }
108
168
  }
109
169
 
110
170
  function _toggleArchivedMeetings() {
111
171
  _showArchived = !_showArchived;
112
172
  _mtgPage = 0;
113
- _rerenderMeetingPageFromCache();
173
+ _rerenderMeetingPageFromCache({ preserveScroll: false });
114
174
  }
115
175
 
116
176
  let _meetingPollInterval = null;
@@ -149,14 +209,14 @@ function _renderMeetingDetail(m) {
149
209
  if (m.findings?.[agent]) {
150
210
  html += '<div style="padding:8px 12px;font-size:11px;border-bottom:1px solid var(--border)">' +
151
211
  '<div style="color:var(--muted);font-size:10px;margin-bottom:4px">Round 1 — Findings</div>' +
152
- '<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>';
153
213
  }
154
214
 
155
215
  // Debate
156
216
  if (m.debate?.[agent]) {
157
217
  html += '<div style="padding:8px 12px;font-size:11px;border-bottom:1px solid var(--border)">' +
158
218
  '<div style="color:var(--muted);font-size:10px;margin-bottom:4px">Round 2 — Debate</div>' +
159
- '<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>';
160
220
  }
161
221
 
162
222
  // Status
@@ -171,7 +231,7 @@ function _renderMeetingDetail(m) {
171
231
  if (m.conclusion) {
172
232
  html += '<div style="background:rgba(63,185,80,0.08);border:1px solid var(--green);border-radius:6px;padding:10px 14px">' +
173
233
  '<div style="color:var(--green);font-weight:600;font-size:12px;margin-bottom:6px">Conclusion (by ' + escHtml(m.conclusion.agent || '?') + ')</div>' +
174
- '<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>';
175
235
  }
176
236
 
177
237
  // Human notes
@@ -216,12 +276,12 @@ function _renderMeetingDetail(m) {
216
276
 
217
277
  document.getElementById('modal-title').textContent = 'Meeting: ' + m.title;
218
278
  var body = document.getElementById('modal-body');
219
- var scrollTop = body.scrollTop;
279
+ var scrollState = captureDashboardScrollState(body, [':self', '.meeting-scroll-panel']);
220
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()
221
281
  body.innerHTML = html;
222
282
  body.style.fontFamily = "'Segoe UI', system-ui, sans-serif";
223
283
  body.style.whiteSpace = 'normal';
224
- body.scrollTop = scrollTop;
284
+ restoreDashboardScrollState(body, scrollState);
225
285
 
226
286
  // Wire up doc-chat Q&A panel for the meeting transcript
227
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)
@@ -128,12 +128,12 @@ function isFollowUpOutcomeSatisfied(wi, allWorkItems, allPrs) {
128
128
  * but completion/progress is always derived from actual work item state.
129
129
  *
130
130
  * opts.allPrs — optional PR records used to reconcile stale failed follow-up
131
- * tasks (issue #2366). Falls back to window._lastStatus.pullRequests in the
132
- * browser. Pass `[]` to disable reconciliation.
131
+ * tasks (issue #2366). Falls back to window._lastPullRequests in the browser.
132
+ * Pass `[]` to disable reconciliation.
133
133
  */
134
134
  function derivePlanStatus(prdFile, mdFile, prdJsonStatus, workItems, opts) {
135
135
  const allPrs = (opts && opts.allPrs)
136
- || (typeof window !== 'undefined' && window._lastStatus && window._lastStatus.pullRequests)
136
+ || (typeof window !== 'undefined' && window._lastPullRequests)
137
137
  || [];
138
138
  const wi = workItems.filter(w =>
139
139
  w.sourcePlan === prdFile || w.sourcePlan === mdFile ||
@@ -708,8 +708,11 @@ async function planApprove(file, btn) {
708
708
  }
709
709
  try { renderPlans(window._lastPlans); } catch { /* render is best-effort */ }
710
710
  }
711
- if (isPrd && window._lastStatus?.prdProgress?.items) {
712
- for (const item of window._lastStatus.prdProgress.items) {
711
+ // Issue #2949 — prdProgress moved off /api/status to /api/prd; refresh.js
712
+ // stashes the fresh fetch into window._lastPrdProgress.
713
+ const _prdProgressForApprove = window._lastPrdProgress;
714
+ if (isPrd && _prdProgressForApprove?.items) {
715
+ for (const item of _prdProgressForApprove.items) {
713
716
  if (item.source === file) {
714
717
  item.planStatus = 'approved';
715
718
  item.planStale = false;
@@ -919,14 +922,16 @@ function _renderVerifyBadge(verifyWi) {
919
922
  const statusColors = { pending: 'var(--muted)', dispatched: 'var(--blue)', done: 'var(--green)', failed: 'var(--red)' };
920
923
  const color = statusColors[verifyWi.status] || 'var(--muted)';
921
924
  const label = verifyWi.status === 'dispatched' ? 'Verifying...' : verifyWi.status === 'done' ? '\u2714 Verified' : verifyWi.status === 'failed' ? 'Verify failed' : 'Verify pending';
922
- // E2E PR — check by prdItems, branch, or title
923
- const allPrs = (window._lastStatus?.pullRequests) || [];
925
+ // E2E PR — check by prdItems, branch, or title. Issue #2949 — pullRequests
926
+ // moved off /api/status to /api/pull-requests (window._lastPullRequests).
927
+ const allPrs = window._lastPullRequests || [];
924
928
  const planFile = verifyWi.sourcePlan || '';
925
929
  const planSlug = planFile.replace('.json', '');
926
930
  const verifyPr = allPrs.find(pr => (pr.prdItems || []).includes(verifyWi.id) || (pr.branch && pr.branch.includes(planSlug) && (pr.title || '').includes('[E2E]')));
927
931
  const prLink = verifyPr?.url ? ' <a href="' + escapeHtml(verifyPr.url) + '" target="_blank" onclick="event.stopPropagation()" style="color:var(--blue);text-decoration:underline;font-size:9px">' + escapeHtml(verifyPr.id || 'E2E PR') + '</a>' : '';
928
- // Testing guide
929
- const guides = window._lastStatus?.verifyGuides || [];
932
+ // Testing guide. verifyGuides moved to /api/verify-guides
933
+ // (window._lastVerifyGuides set by refresh.js).
934
+ const guides = window._lastVerifyGuides || [];
930
935
  const guide = guides.find(g => g.planFile === planFile);
931
936
  const guideLink = guide ? ' <span onclick="event.stopPropagation();openVerifyGuide(\'' + escapeHtml(guide.file) + '\')" style="color:var(--green);cursor:pointer;text-decoration:underline;font-size:9px">Testing Guide</span>' : '';
932
937
  return '<span style="font-size:9px;font-weight:600;color:' + color + ';padding:0 4px">' + label + '</span>' + prLink + guideLink;