@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.
@@ -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
 
@@ -564,8 +565,10 @@ function renderPrdProgress(prog) {
564
565
  '</div>';
565
566
  };
566
567
 
567
- // E2E / verification PRs — group by sourcePlan
568
- const allPrs = window._lastStatus?.pullRequests || [];
568
+ // E2E / verification PRs — group by sourcePlan. Issue #2949 — pullRequests
569
+ // moved off /api/status to /api/pull-requests; refresh.js stashes the fresh
570
+ // fetch into window._lastPullRequests.
571
+ const allPrs = window._lastPullRequests || [];
569
572
  const e2eByPlan = {};
570
573
  for (const pr of allPrs) {
571
574
  // Match by explicit sourcePlan (robust), or fall back to title/branch heuristic (legacy)
@@ -576,8 +579,11 @@ function renderPrdProgress(prog) {
576
579
  e2eByPlan[planKey].push(pr);
577
580
  }
578
581
 
579
- // Find testing guides in prd/ (verify-*.md files)
580
- const verifyGuides = (window._lastStatus?.verifyGuides || []);
582
+ // Find testing guides in prd/ (verify-*.md files). Issue #2949 —
583
+ // verifyGuides moved off /api/status to /api/verify-guides; refresh.js
584
+ // fetches it alongside /api/prd in a Promise.all and stashes onto
585
+ // window._lastVerifyGuides.
586
+ const verifyGuides = window._lastVerifyGuides || [];
581
587
  const guideByPlan = {};
582
588
  for (const g of verifyGuides) {
583
589
  guideByPlan[g.planFile] = g;
@@ -649,6 +655,7 @@ function renderPrdProgress(prog) {
649
655
 
650
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)
651
657
  el.innerHTML = html;
658
+ restoreDashboardScrollState(el, scrollState);
652
659
  restoreNotifBadges();
653
660
  }
654
661
 
@@ -750,9 +757,11 @@ async function prdItemEdit(source, itemId) {
750
757
 
751
758
  _prdDescRawCache = item.description || '';
752
759
 
753
- // Look up work item and dispatch completion info
760
+ // Look up work item and dispatch completion info. Issue #2949 —
761
+ // dispatch moved off /api/status to /api/dispatch; refresh.js stashes
762
+ // the fresh queue into window._lastDispatch.
754
763
  const wi = (window._lastWorkItems || []).find(w => w.id === itemId && w.sourcePlan === source);
755
- const dispatch = window._lastStatus?.dispatch || {};
764
+ const dispatch = window._lastDispatch || {};
756
765
  const completedEntry = (dispatch.completed || []).find(d =>
757
766
  d.meta?.item?.id === itemId && d.meta?.item?.sourcePlan === source
758
767
  );
@@ -4,6 +4,61 @@ let allPrs = [];
4
4
  let prPage = 0;
5
5
  const PR_PER_PAGE = 25;
6
6
 
7
+ // Fetch each project's pull-requests.json straight off disk through the
8
+ // /state/projects/<name>/pull-requests.json static-file passthrough. This
9
+ // is the issue #2949 escape hatch — the engine writes PR status / review
10
+ // state / build state directly into pull-requests.json, and the mtime-ETag
11
+ // on /state/ guarantees a fresh read on the next 4 s poll regardless of
12
+ // whether the /api/status outer cache has busted.
13
+ //
14
+ // Engine-side enrichment is minimal: stamping `_project` from the project
15
+ // name (the filename context) and back-filling `url` from `prUrlBase` when
16
+ // missing. Both are applied here to keep parity with the legacy slice.
17
+ // Other engine-derived fields (build status from CI poll, reviewStatus,
18
+ // _buildStatusStale) are PERSISTED to disk by the engine's PR pollers, so
19
+ // they survive the round-trip naturally.
20
+ async function fetchPullRequestsFromDisk(projects) {
21
+ if (!Array.isArray(projects) || !projects.length) return null;
22
+ const results = await Promise.all(projects.map(async (p) => {
23
+ const name = p && (p.name || p.path);
24
+ if (!name) return [];
25
+ const urlBase = (p && p.prUrlBase) || '';
26
+ try {
27
+ const r = await fetch('/state/projects/' + encodeURIComponent(name) + '/pull-requests.json');
28
+ if (!r.ok) return [];
29
+ const arr = await r.json();
30
+ if (!Array.isArray(arr)) return [];
31
+ return arr.map((pr) => {
32
+ if (!pr || typeof pr !== 'object' || !pr.id) return null;
33
+ const stamped = Object.assign({ _project: name }, pr);
34
+ if (!stamped.url && urlBase && typeof stamped.number === 'number') {
35
+ stamped.url = urlBase + stamped.number;
36
+ }
37
+ return stamped;
38
+ }).filter(Boolean);
39
+ } catch {
40
+ return [];
41
+ }
42
+ }));
43
+ const out = [];
44
+ const seen = new Set();
45
+ for (const arr of results) {
46
+ for (const pr of arr) {
47
+ if (seen.has(pr.id)) continue;
48
+ seen.add(pr.id);
49
+ out.push(pr);
50
+ }
51
+ }
52
+ // Same sort the engine applies: most recent created first, fall back to id.
53
+ out.sort((a, b) => {
54
+ const ac = a.created || '';
55
+ const bc = b.created || '';
56
+ if (ac && bc) return bc.localeCompare(ac);
57
+ return (b.id || '').localeCompare(a.id || '');
58
+ });
59
+ return out;
60
+ }
61
+
7
62
  function _countPrFollowups(pr) {
8
63
  // PR follow-up chip (W-mpej3cox00099466) — counts WIs whose
9
64
  // meta.pr_followup.parent_pr_url or parent_pr_id matches this PR.
@@ -152,7 +207,21 @@ function prTableHtml(rows) {
152
207
  '</tr></thead><tbody>' + rows + '</tbody></table></div>';
153
208
  }
154
209
 
155
- 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 || {};
156
225
  prs = prs.filter(p => !isDeleted('pr:' + p.id));
157
226
  allPrs = prs;
158
227
  const el = document.getElementById('pr-content');
@@ -163,6 +232,7 @@ function renderPrs(prs) {
163
232
  return;
164
233
  }
165
234
  const totalPages = Math.ceil(prs.length / PR_PER_PAGE);
235
+ const previousPage = prPage;
166
236
  if (prPage >= totalPages) prPage = totalPages - 1;
167
237
  const start = prPage * PR_PER_PAGE;
168
238
  const pagePrs = prs.slice(start, start + PR_PER_PAGE);
@@ -180,18 +250,16 @@ function renderPrs(prs) {
180
250
  '</div>';
181
251
  }
182
252
 
183
- const tableWrap = el.querySelector('.pr-table-wrap');
184
- const savedScroll = tableWrap ? tableWrap.scrollLeft : 0;
253
+ const savedScroll = opts.preserveScroll === false || previousPage !== prPage
254
+ ? null
255
+ : _capturePrScrollState(el);
185
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)
186
257
  el.innerHTML = prTableHtml(rows) + pager;
187
- if (savedScroll) {
188
- const newWrap = el.querySelector('.pr-table-wrap');
189
- if (newWrap) newWrap.scrollLeft = savedScroll;
190
- }
258
+ _restorePrScrollState(el, savedScroll);
191
259
  }
192
260
 
193
- function prPrev() { if (prPage > 0) { prPage--; renderPrs(allPrs); } }
194
- 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 }); } }
195
263
 
196
264
  function openAllPrs() {
197
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 };
@@ -1,5 +1,21 @@
1
1
  // render-watches.js — Watch rendering and CRUD functions for the dashboard
2
2
 
3
+ // Pull watches.json straight off disk through /state/engine/watches.json
4
+ // instead of waiting on the /api/status outer cache (issue #2949). The
5
+ // engine writes watch status / _history transitions directly into
6
+ // watches.json, so mtime+size ETag surfaces them on the next 4 s poll.
7
+ // Falls back to data.watches when /state/ returns null.
8
+ async function fetchWatchesFromDisk() {
9
+ try {
10
+ const r = await fetch('/state/engine/watches.json');
11
+ if (!r.ok) return null;
12
+ const arr = await r.json();
13
+ return Array.isArray(arr) ? arr : null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
3
19
  // ─── Helpers ────────────────────────────────────────────────────────────────
4
20
 
5
21
  const _WATCH_STATUS_BADGES = {
@@ -117,16 +133,19 @@ function _parseIntervalStr(s) {
117
133
  let _watchPage = 0;
118
134
  const WATCH_PER_PAGE = 15;
119
135
 
120
- function renderWatches(watchesData) {
136
+ function renderWatches(watchesData, opts) {
137
+ opts = opts || {};
121
138
  var watches = (watchesData || []).filter(function(w) { return !isDeleted('watch:' + w.id); });
122
139
  var el = document.getElementById('watches-content');
123
140
  var countEl = document.getElementById('watches-count');
124
141
  if (!el) return;
125
142
  if (countEl) countEl.textContent = watches.length;
126
143
  window._lastWatches = watches;
144
+ var scrollState = opts.preserveScroll === false ? null : captureDashboardScrollState(el, ['.pr-table-wrap']);
127
145
 
128
146
  if (!watches.length) {
129
147
  el.innerHTML = '<p class="empty">No active watches. Create one to monitor PRs or work items.</p>';
148
+ restoreDashboardScrollState(el, scrollState);
130
149
  return;
131
150
  }
132
151
 
@@ -189,10 +208,11 @@ function renderWatches(watchesData) {
189
208
 
190
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)
191
210
  el.innerHTML = html;
211
+ restoreDashboardScrollState(el, scrollState);
192
212
  }
193
213
 
194
- function _watchPrev() { if (_watchPage > 0) { _watchPage--; renderWatches(window._lastWatches || []); } }
195
- 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 }); } }
196
216
 
197
217
  // ─── Detail Modal ───────────────────────────────────────────────────────────
198
218
 
@@ -4,6 +4,76 @@ let allWorkItems = [];
4
4
  let wiPage = 0;
5
5
  const WI_PER_PAGE = 20;
6
6
 
7
+ // Engine-enrichment field names that live on the /api/status workItems slice
8
+ // but are NOT on disk in projects/<name>/work-items.json. The /state/ fetch
9
+ // recovers raw disk state — these get overlaid from the most recent
10
+ // /api/status snapshot (window._lastWorkItems) so the table still renders the
11
+ // PR chip, pending-reason badge, blockedBy hint, etc. The disk fields win
12
+ // for everything else, so a status flip from `pending` → `done` lands within
13
+ // one /state poll regardless of how stale /api/status got.
14
+ const _WI_ENRICHMENT_FIELDS = [
15
+ '_pr', '_prUrl', '_pendingReason', '_skipReason', '_blockedBy',
16
+ '_humanFeedback', '_reopened', '_managedSpawnPartial', '_securityFlag',
17
+ '_artifacts', 'referencesCount', 'acceptanceCriteriaCount',
18
+ ];
19
+
20
+ // Pull each project's work-items.json straight off disk through
21
+ // /state/projects/<name>/work-items.json. The endpoint is a static-file
22
+ // passthrough with mtime+size ETag — there is no server-side cache in the
23
+ // way, so an engine write to `status: 'done'` is visible to the next poll
24
+ // (issue #2949 root fix). Enrichment fields are overlaid from a cached
25
+ // id→item map sourced from window._lastWorkItems (the last /api/status
26
+ // snapshot). All fetches run in parallel; a single project's failure does
27
+ // not block the others.
28
+ async function fetchWorkItemsFromDisk(projects, cachedById) {
29
+ if (!Array.isArray(projects) || !projects.length) return null;
30
+ const results = await Promise.all(projects.map(async (p) => {
31
+ const name = p && (p.name || p.path);
32
+ if (!name) return [];
33
+ try {
34
+ const r = await fetch('/state/projects/' + encodeURIComponent(name) + '/work-items.json');
35
+ if (!r.ok) return [];
36
+ const arr = await r.json();
37
+ if (!Array.isArray(arr)) return [];
38
+ return arr.map((item) => {
39
+ if (!item || typeof item !== 'object') return null;
40
+ const stamped = Object.assign({}, item, { _source: name });
41
+ const cached = cachedById && cachedById.get(item.id);
42
+ if (cached) {
43
+ for (const k of _WI_ENRICHMENT_FIELDS) {
44
+ if (stamped[k] === undefined && cached[k] !== undefined) {
45
+ stamped[k] = cached[k];
46
+ }
47
+ }
48
+ }
49
+ // referencesCount / acceptanceCriteriaCount don't live on disk;
50
+ // derive from the raw arrays if present so the table icons render
51
+ // even before the cached snapshot catches up.
52
+ if (stamped.referencesCount == null && Array.isArray(stamped.references)) {
53
+ stamped.referencesCount = stamped.references.length;
54
+ }
55
+ if (stamped.acceptanceCriteriaCount == null && Array.isArray(stamped.acceptanceCriteria)) {
56
+ stamped.acceptanceCriteriaCount = stamped.acceptanceCriteria.length;
57
+ }
58
+ return stamped;
59
+ }).filter(Boolean);
60
+ } catch {
61
+ return [];
62
+ }
63
+ }));
64
+ // Flatten + dedupe by id (a project rename mid-flight could double-list).
65
+ const out = [];
66
+ const seen = new Set();
67
+ for (const arr of results) {
68
+ for (const item of arr) {
69
+ if (!item.id || seen.has(item.id)) continue;
70
+ seen.add(item.id);
71
+ out.push(item);
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
7
77
  // Track retry state per work item so loading/success/error survives re-renders
8
78
  const _wiRetryState = {}; // { [id]: { status: 'pending'|'done'|'error', message?, until? } }
9
79
  function setWiRetryState(id, state) { _wiRetryState[id] = state; }
@@ -107,7 +177,8 @@ function wiRow(item) {
107
177
  '</tr>';
108
178
  }
109
179
 
110
- function renderWorkItems(items) {
180
+ function renderWorkItems(items, opts) {
181
+ opts = opts || {};
111
182
  items = items.filter(function(w) { return !isDeleted('wi:' + w.id); });
112
183
  // Sort: active/dispatched first, then by most recent activity
113
184
  const statusOrder = { dispatched: 0, pending: 1, queued: 1, failed: 2, done: 3 };
@@ -128,6 +199,7 @@ function renderWorkItems(items) {
128
199
  }
129
200
 
130
201
  const totalPages = Math.ceil(items.length / WI_PER_PAGE);
202
+ const previousPage = wiPage;
131
203
  if (wiPage >= totalPages) wiPage = totalPages - 1;
132
204
  const start = wiPage * WI_PER_PAGE;
133
205
  const pageItems = items.slice(start, start + WI_PER_PAGE);
@@ -147,14 +219,12 @@ function renderWorkItems(items) {
147
219
  '</div>';
148
220
  }
149
221
 
150
- const tableWrap = el.querySelector('.pr-table-wrap');
151
- const savedScroll = tableWrap ? tableWrap.scrollLeft : 0;
222
+ const scrollState = opts.preserveScroll === false || previousPage !== wiPage
223
+ ? null
224
+ : captureDashboardScrollState(el, ['.pr-table-wrap']);
152
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)
153
226
  el.innerHTML = html;
154
- if (savedScroll) {
155
- const newWrap = el.querySelector('.pr-table-wrap');
156
- if (newWrap) newWrap.scrollLeft = savedScroll;
157
- }
227
+ restoreDashboardScrollState(el, scrollState);
158
228
  }
159
229
 
160
230
  async function editWorkItem(id, source) {
@@ -367,8 +437,8 @@ async function retryWorkItem(id, source) {
367
437
  }
368
438
  }
369
439
 
370
- function wiPrev() { if (wiPage > 0) { wiPage--; renderWorkItems(allWorkItems); } }
371
- 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 }); } }
372
442
 
373
443
  let _feedbackRating = null;
374
444
  function feedbackWorkItem(id, source) {
@@ -390,11 +390,9 @@ async function openSettings() {
390
390
  '<div data-search="routing table playbook agent assignment" style="margin-bottom:16px">' +
391
391
  '<textarea id="set-routing" rows="10" style="width:100%;padding:8px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:monospace;font-size:var(--text-base);resize:vertical">' + escHtml(data.routing || '') + '</textarea>' +
392
392
  '</div>' +
393
- '<h4>Inbox &amp; Status Retention</h4>' +
393
+ '<h4>Inbox</h4>' +
394
394
  '<div class="settings-grid-2">' +
395
395
  settingsField('Consolidation Threshold', 'set-inboxConsolidateThreshold', e.inboxConsolidateThreshold || 5, 'notes', 'Inbox notes before auto-consolidation') +
396
- settingsField('Status WorkItems Retention', 'set-statusWorkItemsRetentionDays', e.statusWorkItemsRetentionDays ?? 0, 'days', 'Optional age-based trim for done/failed/cancelled work items in the /api/status workItems slice (active items are always shipped). Default 0 = no trim — the slim projection that strips description/AC/references already cuts the payload by ~80%. Set to a positive integer to also drop terminal items older than N days.') +
397
- settingsField('Status Meetings Retention', 'set-statusMeetingsRetentionDays', e.statusMeetingsRetentionDays ?? 0, 'days', 'Optional age-based trim for completed/archived meetings in the /api/status meetings slice (active meetings are always shipped). Default 0 = no trim — the slim projection that collapses findings/debate/transcript bodies to {agentId: true} sentinels already cuts the payload by ~80%. Set to a positive integer to also drop terminal meetings older than N days. Detail modal still fetches full bodies via /api/meetings/:id.') +
398
396
  settingsField('Version Check Interval', 'set-versionCheckInterval', e.versionCheckInterval || 3600000, 'ms', 'How often to check npm for updates (default: 1 hour)') +
399
397
  '</div>' +
400
398
  '<h4>Operator &amp; Comments</h4>' +
@@ -841,8 +839,6 @@ async function saveSettings() {
841
839
  restartGracePeriod: document.getElementById('set-restartGracePeriod').value,
842
840
  meetingRoundTimeout: document.getElementById('set-meetingRoundTimeout').value,
843
841
  operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
844
- statusWorkItemsRetentionDays: document.getElementById('set-statusWorkItemsRetentionDays').value,
845
- statusMeetingsRetentionDays: document.getElementById('set-statusMeetingsRetentionDays').value,
846
842
  autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
847
843
  evalLoop: document.getElementById('set-evalLoop').checked,
848
844
  autoDecompose: document.getElementById('set-autoDecompose').checked,
@@ -208,9 +208,15 @@ document.addEventListener('visibilitychange', function() {
208
208
  });
209
209
 
210
210
  function rerenderPrdFromCache() {
211
- if (!window._lastStatus || !window._lastStatus.prdProgress) return;
212
- renderPrdProgress(window._lastStatus.prdProgress);
213
- renderPrd(window._lastStatus.prd, window._lastStatus.prdProgress);
211
+ // Issue #2949 — prdProgress + prd moved off /api/status to /api/prd;
212
+ // refresh.js stashes the fresh fetch into window._lastPrdProgress +
213
+ // window._lastPrdStatus. `!== undefined` (not a truthy check) so a
214
+ // cache-was-null state (last PRD archived → /api/prd returned
215
+ // {progress: null}) still triggers a re-render — renderPrd handles
216
+ // null gracefully and paints the empty 'No PRD found' affordance.
217
+ if (window._lastPrdProgress === undefined) return;
218
+ renderPrdProgress(window._lastPrdProgress);
219
+ renderPrd(window._lastPrdStatus, window._lastPrdProgress);
214
220
  }
215
221
 
216
222
  // Global fetch wrapper with timeout — prevents connection exhaustion from hung requests.
@@ -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',