@yemi33/minions 0.1.2195 → 0.1.2196

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.
@@ -1,10 +1,22 @@
1
1
  // modal.js — Modal and notification badge functions extracted from dashboard.html
2
2
 
3
3
  function closeModal() {
4
+ // P-ce1e5e47 — Esc / X / back-button all route through popModalFrame(),
5
+ // which itself calls history.back(); the popstate listener then pops one
6
+ // breadcrumb and restores the previous modal. When the stack still has
7
+ // depth > 1, the modal stays open with the parent view restored, so we
8
+ // must NOT tear down Q&A / edit / pin state here — only when the modal is
9
+ // actually about to be hidden (depth <= 1).
10
+ var depth = (typeof _modalStackDepth === 'function') ? _modalStackDepth() : 0;
11
+ if (depth > 1) {
12
+ if (typeof popModalFrame === 'function') popModalFrame();
13
+ return;
14
+ }
4
15
  const modalEl = document.querySelector('#modal .modal');
5
16
  if (modalEl) modalEl.classList.remove('modal-wide');
6
17
  document.getElementById('modal').classList.remove('open');
7
- clearModalBackStack();
18
+ if (typeof popModalFrame === 'function') popModalFrame();
19
+ else if (typeof clearModalBackStack === 'function') clearModalBackStack();
8
20
  // Hide Q&A section (only shown for document modals)
9
21
  document.getElementById('modal-qa').style.display = 'none';
10
22
  // Remove settings-body marker so the next modal opens with the default
@@ -132,4 +144,26 @@ function renderArchiveButtons(archives) {
132
144
  ).join(' ');
133
145
  }
134
146
 
147
+ // P-3ed68b1e — Esc key closes the active modal layer. Routes through
148
+ // closeModal() so the depth-aware logic (pop one frame vs full teardown) is
149
+ // the single source of truth. Bubble phase + early-out when the QA review
150
+ // modal is on top so qa.js's own Escape handler isn't shadowed. Inputs and
151
+ // textareas inside the modal are exempt so typing Esc inside Q&A / steering
152
+ // inputs still reaches qa.js / cancel handlers (those listeners use
153
+ // stopPropagation; this guard is belt-and-braces for any future inputs).
154
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
155
+ document.addEventListener('keydown', function(ev) {
156
+ if (ev.key !== 'Escape' && ev.keyCode !== 27) return;
157
+ if (typeof _qaModalState !== 'undefined' && _qaModalState) return;
158
+ var modalEl = document.getElementById('modal');
159
+ if (!modalEl || !modalEl.classList.contains('open')) return;
160
+ var t = ev.target;
161
+ if (t && t.tagName) {
162
+ var tag = String(t.tagName).toUpperCase();
163
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || t.isContentEditable) return;
164
+ }
165
+ closeModal();
166
+ });
167
+ }
168
+
135
169
  window.MinionsModal = { closeModal, copyModalContent, showNotifBadge, clearNotifBadge, restoreNotifBadges, findCardForFile, renderArchiveButtons };
@@ -153,7 +153,7 @@ const RENDER_VERSIONS = {
153
153
  mcpServers: 1,
154
154
  harnessDiag: 1,
155
155
  schedules: 1,
156
- watches: 2,
156
+ watches: 3,
157
157
  meetings: 1,
158
158
  pipelines: 1,
159
159
  pinned: 1,
@@ -71,6 +71,24 @@ function renderAgents(agents) {
71
71
  restoreDashboardScrollState(grid, scrollState);
72
72
  }
73
73
 
74
+ /**
75
+ * Open the right-side agent detail panel.
76
+ *
77
+ * P-30b6cf8a — Detail-panel exception. This panel is intentionally NOT a
78
+ * modal-stack frame; it is a peer of the stack. Routing comes through
79
+ * openArtifact('agent', id) in dashboard/js/render-utils.js, which:
80
+ * 1. Calls resetModalStack() + _physicallyCloseModal() so the modal-stack
81
+ * identity goes back to empty (any prior modal is dismissed).
82
+ * 2. Calls openAgentDetail(id), which opens this slide-in panel.
83
+ *
84
+ * Reverse direction is symmetric: clicking a WI/PR/plan chip from inside
85
+ * the panel calls openArtifact('<type>', id), which closes this panel and
86
+ * starts a fresh modal stack. This is the one documented exception to
87
+ * in-stack chip behaviour — do NOT refactor it into a normal modal frame.
88
+ * The panel UI (tabs, live-output stream, charter editor) does not fit
89
+ * the single-shell modal contract; trying to force it into pushModalFrame
90
+ * would lose the tabbed layout and the persistent live-stream socket.
91
+ */
74
92
  async function openAgentDetail(id) {
75
93
  const agent = agentData.find(a => a.id === id);
76
94
  if (!agent) return;
@@ -251,8 +251,33 @@ async function kbOpenItem(category, file) {
251
251
  const display = content.replace(/^---[\s\S]*?---\n*/m, '');
252
252
  document.getElementById('modal-title').textContent = file;
253
253
  const modalBody = document.getElementById('modal-body');
254
- // eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes all user-controlled fields before assembling HTML (see dashboard/js/utils.js)
255
- modalBody.innerHTML = renderMd(display);
254
+ // P-34fa5d79 KB modal renders "From note: …" and "From WI: …" chips
255
+ // when the entry was promoted via /api/inbox/promote-kb (which stamps
256
+ // source_note + source_wi). Parse the frontmatter block off the raw
257
+ // fetched content (we already strip it for `display`).
258
+ var _fmChipsHtml = '';
259
+ var _fmMatch = String(content || '').match(/^---\n([\s\S]*?)\n---/);
260
+ if (_fmMatch) {
261
+ var _fm = {};
262
+ _fmMatch[1].split('\n').forEach(function(line) {
263
+ var lm = line.match(/^([\w-]+):\s*(.*)$/);
264
+ if (lm) _fm[lm[1]] = lm[2].trim();
265
+ });
266
+ var _chips = [];
267
+ if (_fm.source_note) {
268
+ _chips.push('<span style="color:var(--muted);font-size:var(--text-sm);text-transform:uppercase;letter-spacing:0.5px;margin-right:4px">From note</span>' +
269
+ renderArtifactLink({ type: 'note', id: _fm.source_note, label: _fm.source_note.replace(/\.md$/, '').slice(0, 30), title: 'Source note: ' + _fm.source_note }));
270
+ }
271
+ if (_fm.source_wi) {
272
+ _chips.push('<span style="color:var(--muted);font-size:var(--text-sm);text-transform:uppercase;letter-spacing:0.5px;margin-right:4px">From WI</span>' +
273
+ renderArtifactLink({ type: 'wi', id: _fm.source_wi, label: _fm.source_wi, title: 'Source work item: ' + _fm.source_wi }));
274
+ }
275
+ if (_chips.length > 0) {
276
+ _fmChipsHtml = '<div style="margin-bottom:10px;padding:6px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);display:flex;flex-wrap:wrap;gap:12px;align-items:center">' + _chips.join('') + '</div>';
277
+ }
278
+ }
279
+ // eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes all user-controlled fields before assembling HTML (see dashboard/js/utils.js); _fmChipsHtml is built from renderArtifactLink() chips which escape id+label via escapeHtml
280
+ modalBody.innerHTML = _fmChipsHtml + renderMd(display);
256
281
  _modalDocContext = { title: file, content: display, selection: '' };
257
282
  _modalFilePath = 'knowledge/' + category + '/' + file; showModalQa();
258
283
  // Clear notification badge when opening this document
@@ -196,6 +196,30 @@ function _renderMeetingDetail(m) {
196
196
  '<span style="font-size:var(--text-sm);color:var(--muted)">' + escHtml(m.createdAt?.slice(0, 16).replace('T', ' ') || '') + '</span>' +
197
197
  '</div>';
198
198
 
199
+ // P-30b6cf8a — Linked-artifact chip row. Renders the meeting.linkedPlan
200
+ // (derived by _findLinkedPlan from conclusion text / title-slug match)
201
+ // and the participants list as in-stack renderArtifactLink chips.
202
+ // Plan chip routes through openArtifact -> planView (in-stack modal
203
+ // push, browser Back returns here). Agent chips route through the
204
+ // detail-panel exception in openArtifact (resetModalStack + open the
205
+ // slide-in panel); see render-utils.js openArtifact comment.
206
+ const _linkedPlanForChip = _findLinkedPlan(m);
207
+ const _hasChipRenderer = typeof renderArtifactLink === 'function';
208
+ if (_hasChipRenderer && (_linkedPlanForChip || (m.participants || []).length > 0)) {
209
+ let chipRow = '<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:var(--text-sm)">';
210
+ if (_linkedPlanForChip) {
211
+ chipRow += '<span style="color:var(--muted)">Linked plan:</span> ' +
212
+ renderArtifactLink({ type: 'plan', id: _linkedPlanForChip.file, label: _linkedPlanForChip.file, title: _linkedPlanForChip.summary || _linkedPlanForChip.file });
213
+ }
214
+ if ((m.participants || []).length > 0) {
215
+ chipRow += (_linkedPlanForChip ? ' <span style="color:var(--border)">·</span> ' : '') +
216
+ '<span style="color:var(--muted)">Participants:</span> ' +
217
+ (m.participants || []).map(a => renderArtifactLink({ type: 'agent', id: a, label: a, title: 'Open ' + a + ' detail panel' })).join(' ');
218
+ }
219
+ chipRow += '</div>';
220
+ html += chipRow;
221
+ }
222
+
199
223
  // Agenda — render markdown-like formatting
200
224
  html += '<div style="background:var(--surface2);padding:8px 12px;border-radius:6px;font-size:var(--text-md);white-space:pre-wrap;line-height:1.6">' +
201
225
  '<strong>Agenda:</strong>\n' + renderMd(m.agenda || '') + '</div>';
@@ -203,7 +227,13 @@ function _renderMeetingDetail(m) {
203
227
  // Per-agent panels
204
228
  for (const agent of (m.participants || [])) {
205
229
  html += '<div style="border:1px solid var(--border);border-radius:6px;overflow:hidden">';
206
- html += '<div style="background:var(--surface2);padding:6px 12px;font-weight:600;font-size:var(--text-md)">' + escHtml(agent) + '</div>';
230
+ // P-30b6cf8a agent header is a chip so it routes through the
231
+ // detail-panel exception in openArtifact (resetModalStack + open
232
+ // slide-in panel).
233
+ const _agentChip = _hasChipRenderer
234
+ ? renderArtifactLink({ type: 'agent', id: agent, label: agent, title: 'Open ' + agent + ' detail panel' })
235
+ : escHtml(agent);
236
+ html += '<div style="background:var(--surface2);padding:6px 12px;font-weight:600;font-size:var(--text-md)">' + _agentChip + '</div>';
207
237
 
208
238
  // Findings
209
239
  if (m.findings?.[agent]) {
@@ -322,8 +352,11 @@ function openMeetingDetail(id) {
322
352
  if (d.meeting && _meetingPollId === id) {
323
353
  const hash = JSON.stringify(d.meeting);
324
354
  if (hash === _lastMeetingHash) return; // no change — skip re-render
325
- _lastMeetingHash = hash;
326
- _renderMeetingDetail(d.meeting);
355
+ // P-ce1e5e47 — withTopFrame skips the re-render when the user
356
+ // has stacked another modal on top of this meeting view.
357
+ const apply = function() { _lastMeetingHash = hash; _renderMeetingDetail(d.meeting); };
358
+ if (typeof withTopFrame === 'function') withTopFrame('meeting', id, apply);
359
+ else apply();
327
360
  }
328
361
  })
329
362
  .catch(function() {});
@@ -98,29 +98,34 @@ function _getPipelineDisplayTitle(pipeline) {
98
98
 
99
99
  /**
100
100
  * Render clickable artifact links for a pipeline stage.
101
- * Each artifact type gets an icon and navigates to the relevant detail view.
101
+ * Navigation chips (workItems / meetings / plans / prds) route through
102
+ * renderArtifactLink → openArtifact() so the modal stack push and URL hash
103
+ * come for free (P-e265cd31). PRs / notes / subStages stay inline: PRs and
104
+ * notes page-switch instead of opening a detail modal, and subStages are
105
+ * non-clickable — none has an openArtifact path, so the "View PR" / "View
106
+ * Note" pills render identically to today.
102
107
  * @param {Object} artifacts - artifact map from stage run
103
- * @param {string} [pipelineId] - pipeline ID for modal back-navigation
108
+ * @param {string} [pipelineId] - retained for back-compat; unused now that
109
+ * openArtifact captures the current visible modal automatically.
104
110
  */
105
- function _renderArtifactLinks(artifacts, pipelineId) {
111
+ function _renderArtifactLinks(artifacts /* , pipelineId — unused since P-e265cd31 */) {
106
112
  if (!artifacts) return '';
107
113
  var links = [];
108
114
  var linkStyle = 'display:inline-flex;align-items:center;gap:2px;padding:1px 6px;border-radius:10px;font-size:var(--text-sm);cursor:pointer;text-decoration:none;color:var(--blue);background:color-mix(in srgb, var(--blue) 10%, transparent);border:1px solid color-mix(in srgb, var(--blue) 20%, transparent)';
109
115
 
110
- // Pushes current pipeline modal onto back stack so detail modals can navigate back
111
- var backFn = pipelineId ? "pushModalBack(function(){openPipelineDetail('" + escHtml(pipelineId) + "')});" : '';
112
-
113
116
  (artifacts.workItems || []).forEach(function(id) {
114
- links.push('<span style="' + linkStyle + '" onclick="event.stopPropagation();' + backFn + 'openWorkItemDetail(\'' + escHtml(id) + '\')" title="Open work item ' + escHtml(id) + '">⚙ ' + escHtml(id) + '</span>');
117
+ links.push(renderArtifactLink({ type: 'wi', id: id, label: id, title: 'Open work item ' + id }));
115
118
  });
116
119
  (artifacts.meetings || []).forEach(function(id) {
117
- links.push('<span style="' + linkStyle + '" onclick="event.stopPropagation();' + backFn + 'openMeetingDetail(\'' + escHtml(id) + '\')" title="Open meeting ' + escHtml(id) + '">💬 ' + escHtml(id) + '</span>');
120
+ links.push(renderArtifactLink({ type: 'meeting', id: id, label: id, title: 'Open meeting ' + id }));
118
121
  });
119
122
  (artifacts.plans || []).forEach(function(name) {
120
- links.push('<span style="' + linkStyle + '" onclick="event.stopPropagation();' + backFn + 'planView(\'' + escHtml(name) + '\')" title="Plan: ' + escHtml(name) + '">📋 ' + escHtml(name.replace(/\.md$/, '').slice(0, 30)) + '</span>');
123
+ var label = String(name).replace(/\.md$/, '').slice(0, 30);
124
+ links.push(renderArtifactLink({ type: 'plan', id: name, label: label, title: 'Plan: ' + name }));
121
125
  });
122
126
  (artifacts.prds || []).forEach(function(name) {
123
- links.push('<span style="' + linkStyle + '" onclick="event.stopPropagation();' + backFn + 'planView(\'' + escHtml(name) + '\')" title="PRD: ' + escHtml(name) + '">📄 ' + escHtml(name.replace(/\.json$/, '').slice(0, 30)) + '</span>');
127
+ var label = String(name).replace(/\.json$/, '').slice(0, 30);
128
+ links.push(renderArtifactLink({ type: 'prd', id: name, label: label, title: 'PRD: ' + name }));
124
129
  });
125
130
  (artifacts.prs || []).forEach(function(id) {
126
131
  links.push('<span style="' + linkStyle + '" onclick="event.stopPropagation();closeModal();switchPage(\'prs\')" title="Pull request ' + escHtml(id) + '">🔀 PR-' + escHtml(id) + '</span>');
@@ -697,11 +697,73 @@ function _renderPlanModal(normalizedFile, raw, lastMod) {
697
697
  modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--red)" onclick="planDelete(\'' + escapeHtml(normalizedFile) + '\')">Delete</button>';
698
698
  }
699
699
 
700
+ // P-79b47b0c — surface linked artifacts (PRD + verify-guide) as in-stack
701
+ // renderArtifactLink chips alongside the plan modal action buttons.
702
+ // P-30b6cf8a — also surface the source-meeting chip when the PRD JSON
703
+ // carries a `meetingId` field or the .md plan body contains the
704
+ // `**Source Meeting:** MTG-…` header line (the dashboard convention
705
+ // written by POST /api/plans/create when a plan is created from a
706
+ // meeting). Chip routes through openArtifact → openMeetingDetail for
707
+ // in-stack push (Back returns here).
708
+ let linkedChips = '';
709
+ if (typeof renderArtifactLink === 'function') {
710
+ if (prdFile && prdFile !== normalizedFile) {
711
+ linkedChips += renderArtifactLink({ type: 'prd', id: prdFile, label: 'PRD', title: prdFile }) + ' ';
712
+ }
713
+ let meetingChipId = '';
714
+ if (normalizedFile.endsWith('.json')) {
715
+ try {
716
+ const _parsedForMtg = JSON.parse(raw);
717
+ if (_parsedForMtg && typeof _parsedForMtg.meetingId === 'string' && /^MTG-[\w-]+$/.test(_parsedForMtg.meetingId)) {
718
+ meetingChipId = _parsedForMtg.meetingId;
719
+ }
720
+ } catch { /* JSON parse already surfaced upstream */ }
721
+ } else {
722
+ const _mtgMatch = (raw || '').match(/\*\*Source Meeting:\*\*\s+(MTG-[\w-]+)/);
723
+ if (_mtgMatch) meetingChipId = _mtgMatch[1];
724
+ }
725
+ if (meetingChipId) {
726
+ linkedChips += renderArtifactLink({ type: 'meeting', id: meetingChipId, label: meetingChipId, title: 'Source meeting: ' + meetingChipId }) + ' ';
727
+ }
728
+ const guidesForChip = window._lastVerifyGuides || [];
729
+ const guideForChip = guidesForChip.find(g => g && g.planFile === (prdFile || normalizedFile));
730
+ if (guideForChip && guideForChip.file) {
731
+ // Verify guides live at prd/guides/verify-<slug>.md and are fetched via
732
+ // /api/plans/<file>, so a plain {type:'plan'} chip resolves correctly
733
+ // through openArtifact -> planView. openVerifyGuide() is the legacy
734
+ // helper that does the same thing — we keep the modal-stack semantics
735
+ // by routing through openArtifact instead.
736
+ linkedChips += renderArtifactLink({ type: 'plan', id: guideForChip.file, label: 'Verify Guide', icon: '&#x1F4D6;', title: 'Manual testing guide (openVerifyGuide)' }) + ' ';
737
+ }
738
+ // P-e6093f70 — cross-repo plan ↔ plan sibling chips. When the plan
739
+ // fans out across ≥2 projects (lifecycle.checkPlanCompletion creates
740
+ // one verify WI per touched project — see CLAUDE.md §"Cross-repo
741
+ // plans" invariant 5), surface a clickable WI chip per sibling so
742
+ // operators can hop between the per-project verify WIs without
743
+ // leaving the modal stack. The chip label is the project slug so each
744
+ // sibling is identifiable at a glance (mirrors _renderVerifyBadge's
745
+ // projectLabel handling at line ~1042). Gate matches the existing
746
+ // per-project verify-badge fanout gate at line ~689 so single-project
747
+ // plans are unaffected.
748
+ if (modalVerifyWis.length >= 2) {
749
+ for (const v of modalVerifyWis) {
750
+ const projLabel = v.project || 'verify';
751
+ linkedChips += renderArtifactLink({
752
+ type: 'wi',
753
+ id: v.id,
754
+ label: projLabel,
755
+ title: 'Sibling verify WI for ' + projLabel + ' (cross-repo plan fanout)',
756
+ }) + ' ';
757
+ }
758
+ }
759
+ }
760
+
700
761
  const lastModLabel = lastMod ? '<div style="font-size:var(--text-sm);color:var(--muted);font-weight:400;margin-top:2px">Last updated: ' + formatLocalDateTime(lastMod) + '</div>' : '';
762
+ const linkedChipsRow = linkedChips ? '<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:4px">' + linkedChips + '</div>' : '';
701
763
  const actionBtns = '<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:4px">' + modalActions + '</div>';
702
764
 
703
- // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: plan title, version label, action target files)
704
- document.getElementById('modal-title').innerHTML = escapeHtml(title) + (versionLabel ? ' <span style="font-size:var(--text-base);font-weight:700;padding:1px 6px;border-radius:3px;background:rgba(56,139,253,0.15);color:var(--blue)">' + escapeHtml(versionLabel) + '</span>' : '') + lastModLabel + actionBtns;
765
+ // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: plan title, version label, action target files); linkedChipsRow output is renderArtifactLink chips (escapes internally) or empty
766
+ document.getElementById('modal-title').innerHTML = escapeHtml(title) + (versionLabel ? ' <span style="font-size:var(--text-base);font-weight:700;padding:1px 6px;border-radius:3px;background:rgba(56,139,253,0.15);color:var(--blue)">' + escapeHtml(versionLabel) + '</span>' : '') + lastModLabel + linkedChipsRow + actionBtns;
705
767
  const modalBody = document.getElementById('modal-body');
706
768
  const scrollTop = modalBody.scrollTop;
707
769
  if (normalizedFile.endsWith('.json')) {
@@ -748,7 +810,15 @@ async function planView(file) {
748
810
  }
749
811
  fetch('/api/plans/' + encodeURIComponent(normalizedFile))
750
812
  .then(function(r) { return r.text().then(function(raw) { return { raw: raw, lastMod: r.headers.get('Last-Modified') }; }); })
751
- .then(function(d) { if (_planPollFile === normalizedFile && d.raw !== _planPollLastRaw) { _planPollLastRaw = d.raw; _renderPlanModal(normalizedFile, d.raw, d.lastMod); } })
813
+ .then(function(d) {
814
+ // P-ce1e5e47 — withTopFrame guards a stacked modal: if the user
815
+ // navigated to a deeper modal on top of this plan view, skip the
816
+ // re-render so we don't clobber the visible top frame.
817
+ if (_planPollFile !== normalizedFile || d.raw === _planPollLastRaw) return;
818
+ var apply = function() { _planPollLastRaw = d.raw; _renderPlanModal(normalizedFile, d.raw, d.lastMod); };
819
+ if (typeof withTopFrame === 'function') withTopFrame('plan', normalizedFile, apply);
820
+ else apply();
821
+ })
752
822
  .catch(function() {});
753
823
  }, 3000);
754
824
  } catch (e) { console.error(e); }
@@ -144,9 +144,16 @@ function renderPrd(prd, prog) {
144
144
 
145
145
  function _renderPrLink(pr, opts) {
146
146
  var size = (opts && opts.size) || '10px';
147
- var statusColor = pr.status === 'merged' ? 'var(--green)' : pr.status === 'abandoned' ? 'var(--red)' : 'var(--blue)';
148
147
  var statusIcon = pr.status === 'merged' ? '✓' : pr.status === 'abandoned' ? '✗' : '○';
149
- return '<a href="' + escHtml(pr.url || '#') + '" target="_blank" rel="noopener" onclick="event.stopPropagation()" style="font-size:' + size + ';color:' + statusColor + ';text-decoration:underline;cursor:pointer;margin-left:4px" title="' + escHtml((pr.title || '') + ' (' + (pr.status || 'active') + ')') + '">' + statusIcon + ' ' + escHtml(pr.id) + '</a>';
148
+ // P-79b47b0c render as an in-stack modal chip via renderArtifactLink
149
+ // (was a raw <a target="_blank">). The status icon stays as a non-link
150
+ // prefix so the chip body remains the canonical id, matching the WI/PRD
151
+ // chip pattern. Click is gated by openArtifact -> openPrDetail.
152
+ var chip = (typeof renderArtifactLink === 'function')
153
+ ? renderArtifactLink({ type: 'pr', id: pr.id, label: pr.id, title: (pr.title || '') + ' (' + (pr.status || 'active') + ')' })
154
+ : '<code>' + escHtml(pr.id) + '</code>';
155
+ return '<span style="font-size:' + size + ';margin-left:4px;display:inline-flex;align-items:center;gap:3px">' +
156
+ '<span title="' + escHtml(pr.status || 'active') + '">' + statusIcon + '</span>' + chip + '</span>';
150
157
  }
151
158
 
152
159
  function _renderPrdWorkItemIdBadge(workItemId, prdItemId, size, padding) {
@@ -288,9 +295,19 @@ function renderPrdProgress(prog) {
288
295
  ? '<span onclick="event.stopPropagation();prdItemReopen(\'' + escHtml(i.source || '') + '\',\'' + escHtml(i.id) + '\')" style="color:var(--blue);cursor:pointer;font-size:var(--text-xs);padding:1px 5px;background:rgba(56,139,253,0.1);border:1px solid rgba(56,139,253,0.3);border-radius:3px" title="Re-open: set to updated so engine re-dispatches on existing branch">re-open</span>'
289
296
  : '';
290
297
 
298
+ // P-79b47b0c — replace the bare prd-item-id span with a renderArtifactLink
299
+ // chip pointing at the backing WI (when one exists). The chip's onclick
300
+ // calls event.stopPropagation, so the parent row's prdItemEdit() still
301
+ // fires only on row-body clicks; clicking the chip opens the WI detail
302
+ // modal in-stack. When there's no backing WI, fall back to the legacy
303
+ // bare-id span (clicking the row still opens prdItemEdit).
304
+ const idChip = (wi && typeof renderArtifactLink === 'function')
305
+ ? renderArtifactLink({ type: 'wi', id: wi.id, label: i.id, title: i.name || wi.title || i.id })
306
+ : '<span class="prd-item-id">' + escHtml(i.id) + '</span>';
307
+
291
308
  return '<div class="prd-item-row st-' + (i.status || 'missing') + '" style="flex-wrap:wrap;cursor:pointer" onclick="if(shouldIgnoreSelectionClick(event))return;prdItemEdit(\'' + src + '\',\'' + iid + '\')">' +
292
309
  statusBadge(i.status, i.id) +
293
- '<span class="prd-item-id">' + escHtml(i.id) + '</span>' +
310
+ idChip +
294
311
  '<span class="prd-item-name" title="' + escHtml(i.name) + '">' + escHtml(i.name) + '</span>' +
295
312
  wiLabel +
296
313
  agentLabel +
@@ -600,9 +617,13 @@ function renderPrdProgress(prog) {
600
617
  html += '<div style="font-size:var(--text-sm);font-weight:600;color:var(--blue);margin-bottom:4px">E2E Aggregate PRs</div>';
601
618
  html += prs.map(pr => {
602
619
  const statusColor = pr.status === 'active' ? 'var(--green)' : pr.status === 'merged' ? 'var(--purple)' : 'var(--muted)';
620
+ // P-79b47b0c — render PR id as in-stack chip (was raw <a target="_blank">).
621
+ const prChip = (typeof renderArtifactLink === 'function')
622
+ ? renderArtifactLink({ type: 'pr', id: pr.id, label: pr.id, title: pr.title || pr.id })
623
+ : '<code>' + escHtml(pr.id) + '</code>';
603
624
  return '<div style="display:flex;align-items:center;gap:6px;padding:2px 0;font-size:var(--text-base)">' +
604
625
  '<span style="color:' + statusColor + ';font-size:var(--text-xs);font-weight:600;padding:1px 4px;border:1px solid;border-radius:3px">' + escHtml(pr.status || 'active') + '</span>' +
605
- '<a href="' + escHtml(pr.url || '#') + '" target="_blank" rel="noopener" style="color:var(--blue);text-decoration:underline;font-weight:500">' + escHtml(pr.id) + '</a>' +
626
+ prChip +
606
627
  '<span style="color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(pr.title || '') + '</span>' +
607
628
  '<span style="color:var(--muted);font-size:var(--text-xs)">' + escHtml(pr._project || '') + '</span>' +
608
629
  '</div>';
@@ -791,7 +812,10 @@ async function prdItemEdit(source, itemId) {
791
812
  const completedAt = wi?.completedAt || completedEntry?.completed_at || '';
792
813
  const summary = completedEntry?.resultSummary || '';
793
814
  const prLinks = (item.prs || []).map(function(pr) {
794
- return '<a href="' + escHtml(pr.url || '#') + '" target="_blank" rel="noopener" style="color:var(--green);text-decoration:underline">' + escHtml(pr.id) + '</a>';
815
+ // P-79b47b0c render as in-stack chip (was raw <a target="_blank">).
816
+ return (typeof renderArtifactLink === 'function')
817
+ ? renderArtifactLink({ type: 'pr', id: pr.id, label: pr.id, title: pr.title || pr.id })
818
+ : '<code>' + escHtml(pr.id) + '</code>';
795
819
  }).join(', ');
796
820
 
797
821
  const statusColor = isDone ? 'var(--green)' : isFailed ? 'var(--red)' : 'var(--blue)';
@@ -204,7 +204,8 @@ function prRow(pr) {
204
204
  '<td><span class="pr-badge ' + statusClass + '" title="' + escapeHtml(statusLabel) + '">' + escapeHtml(statusLabel) + '</span></td>' +
205
205
  '<td>' + (observeBtn || '<span style="color:var(--muted);font-size:var(--text-base)">—</span>') + '</td>' +
206
206
  '<td><span class="pr-date" title="' + escapeHtml(createdLabel) + '">' + escapeHtml(createdLabel) + '</span></td>' +
207
- '<td><button class="btn-destructive" style="padding:1px 5px" data-pr-id="' + escapeHtml(String(prId)) + '" onclick="event.stopPropagation();unlinkPr(this.dataset.prId)" title="Remove from tracking">x</button></td>' +
207
+ '<td><button class="pr-info-pill" data-pr-id="' + escapeHtml(String(prId)) + '" onclick="event.stopPropagation();openPrDetail(this.dataset.prId)" title="Open PR detail (in-stack modal)">&#x2197;</button>' +
208
+ '<button class="btn-destructive" style="padding:1px 5px;margin-left:4px" data-pr-id="' + escapeHtml(String(prId)) + '" onclick="event.stopPropagation();unlinkPr(this.dataset.prId)" title="Remove from tracking">x</button></td>' +
208
209
  '</tr>';
209
210
  }
210
211
 
@@ -224,7 +225,7 @@ const PRS_COLGROUP =
224
225
  '<col style="width:110px">' + // Status
225
226
  '<col style="width:100px">' + // Observe
226
227
  '<col style="width:130px">' + // Created
227
- '<col style="width:50px">' + // Actions
228
+ '<col style="width:80px">' + // Actions (info-pill + remove)
228
229
  '</colgroup>';
229
230
 
230
231
  function prTableHtml(rows) {
@@ -300,6 +301,176 @@ function openAllPrs() {
300
301
  document.getElementById('modal').classList.add('open');
301
302
  }
302
303
 
304
+ // P-79b47b0c — in-stack PR detail modal. Routed from openArtifact('pr', id)
305
+ // in render-utils.js and from the PR-row info-pill in prRow(). Read-only:
306
+ // renders title, status/review/build badges, reviewers, linked-WI chips
307
+ // (joined via item._pr === pr.id from queries enrichment), description, and
308
+ // an "Open on GitHub" secondary button. No POSTs from this modal.
309
+ function openPrDetail(prId) {
310
+ if (window.getSelection && window.getSelection().toString().length > 0) return;
311
+ if (!prId) return;
312
+ // Render an immediate skeleton from the in-memory PR cache (allPrs) so the
313
+ // modal feels instant. Then hit GET /api/prs/<id> for the canonical record
314
+ // and re-render via withTopFrame() to respect modal-stack semantics.
315
+ const cached = (allPrs || []).find(p => p && p.id === prId) ||
316
+ ((window._lastPrs) || []).find(p => p && p.id === prId);
317
+ document.getElementById('modal-title').textContent = (cached && (cached.title || cached.id)) || prId;
318
+ // eslint-disable-next-line no-unsanitized/property -- reason: _renderPrDetail() escapes all user fields (title, description, agent, reviewers, branch) via escapeHtml() / renderMd() / renderArtifactLink() before assembly
319
+ document.getElementById('modal-body').innerHTML = _renderPrDetail(cached || { id: prId, _loading: true });
320
+ document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
321
+ document.getElementById('modal-body').style.whiteSpace = 'normal';
322
+ document.getElementById('modal').classList.add('open');
323
+
324
+ fetch('/api/prs/' + encodeURIComponent(prId))
325
+ .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
326
+ .then(function(data) {
327
+ const pr = data && data.pr;
328
+ if (!pr) return;
329
+ const apply = function() {
330
+ document.getElementById('modal-title').textContent = pr.title || pr.id || prId;
331
+ // eslint-disable-next-line no-unsanitized/property -- reason: _renderPrDetail() escapes all user fields (title, description, agent, reviewers, branch) via escapeHtml() / renderMd() / renderArtifactLink() before assembly
332
+ document.getElementById('modal-body').innerHTML = _renderPrDetail(pr);
333
+ };
334
+ if (typeof withTopFrame === 'function') withTopFrame('pr', prId, apply);
335
+ else apply();
336
+ })
337
+ .catch(function() {
338
+ const body = document.getElementById('modal-body');
339
+ if (body) {
340
+ body.innerHTML = '<p style="color:var(--red)">Failed to load PR ' + escapeHtml(String(prId)) + '.</p>';
341
+ }
342
+ });
343
+ }
344
+
345
+ function _renderPrDetail(pr) {
346
+ if (!pr) return '<p style="color:var(--muted)">No data.</p>';
347
+ if (pr._loading) {
348
+ return '<p style="color:var(--muted)">Loading PR ' + escapeHtml(String(pr.id || '')) + '...</p>';
349
+ }
350
+ const sq = pr.minionsReview || {};
351
+ const reviewLabel = sq.status === 'waiting' ? 'reviewing (minions)' : sq.status ? sq.status + ' (minions)' : (pr.reviewStatus || 'pending');
352
+ const reviewClass = (sq.status || pr.reviewStatus) === 'approved' ? 'approved' :
353
+ ((sq.status || pr.reviewStatus) === 'changes-requested' || (sq.status || pr.reviewStatus) === 'rejected') ? 'rejected' :
354
+ (sq.status || pr.reviewStatus) === 'waiting' ? 'building' : 'draft';
355
+ const buildLabel = pr.buildStatus || 'none';
356
+ const buildClass = pr._buildStatusStale ? 'build-stale' :
357
+ pr.buildStatus === 'passing' ? 'build-pass' :
358
+ pr.buildStatus === 'failing' ? 'build-fail' :
359
+ pr.buildStatus === 'running' ? 'building' : 'no-build';
360
+ const statusLabel = pr.status || 'active';
361
+ const statusClass = statusLabel === 'merged' ? 'pr-merged' :
362
+ statusLabel === 'abandoned' ? 'pr-abandoned' :
363
+ statusLabel === 'draft' ? 'draft' : 'pr-active';
364
+
365
+ const badges =
366
+ '<span class="pr-badge ' + statusClass + '" title="status">' + escapeHtml(String(statusLabel)) + '</span> ' +
367
+ '<span class="pr-badge ' + reviewClass + '" title="review">' + escapeHtml(String(reviewLabel)) + '</span> ' +
368
+ '<span class="pr-badge ' + buildClass + '" title="build">' + escapeHtml(String(buildLabel)) + '</span>';
369
+
370
+ // Linked WIs — join via item._pr === pr.id (stamped by engine/queries.js).
371
+ const wis = (window._lastWorkItems) || [];
372
+ const linkedWis = wis.filter(w => w && w._pr === pr.id);
373
+ let linkedChips = '';
374
+ if (linkedWis.length && typeof renderArtifactLink === 'function') {
375
+ linkedChips = '<div style="margin-top:10px"><strong style="color:var(--muted);font-size:var(--text-base)">Linked Work Items:</strong><br>' +
376
+ linkedWis.map(w => renderArtifactLink({ type: 'wi', id: w.id, label: w.id, title: w.title || w.id })).join(' ') +
377
+ '</div>';
378
+ }
379
+
380
+ // P-e6093f70 — PR ↔ PR linkage. Surface (a) a "Parent PR (follow-up)"
381
+ // chip when this PR record carries meta.pr_followup.parent_pr_url /
382
+ // parent_pr_id (mirrors the WI-side rendering at render-work-items.js
383
+ // _wiRenderDetail, P-79b47b0c) and (b) one chip per pr.depends_on[]
384
+ // entry. Both surfaces use renderArtifactLink({type:'pr'}) so click
385
+ // opens the parent / dep PR modal in-stack with browser-Back support.
386
+ //
387
+ // Graceful degradation: when parent_pr_url cannot be parsed into a
388
+ // canonical id (unknown host), fall back to a plain external <a>.
389
+ // depends_on chips consult the local PR cache (allPrs / window._lastPrs
390
+ // / window._lastPullRequests) and pass deleted:true when the dep is
391
+ // unknown so the chip renders struck-out (non-clickable) instead of
392
+ // silently linking to a 404.
393
+ let parentChip = '';
394
+ const followup = pr.meta && pr.meta.pr_followup;
395
+ if (followup && (followup.parent_pr_id || followup.parent_pr_url) && typeof renderArtifactLink === 'function') {
396
+ let parentPrId = followup.parent_pr_id || '';
397
+ if (!parentPrId && followup.parent_pr_url && typeof _wiDeriveCanonicalPrIdFromUrl === 'function') {
398
+ parentPrId = _wiDeriveCanonicalPrIdFromUrl(followup.parent_pr_url) || '';
399
+ }
400
+ if (parentPrId) {
401
+ parentChip = '<div style="margin-top:10px"><strong style="color:var(--muted);font-size:var(--text-base)">Parent PR (follow-up):</strong> ' +
402
+ renderArtifactLink({ type: 'pr', id: parentPrId, label: parentPrId, title: 'Follow-up from ' + (followup.parent_pr_url || parentPrId) }) +
403
+ '</div>';
404
+ } else if (followup.parent_pr_url) {
405
+ parentChip = '<div style="margin-top:10px"><strong style="color:var(--muted);font-size:var(--text-base)">Parent PR (follow-up):</strong> ' +
406
+ '<a href="' + escapeHtml(followup.parent_pr_url) + '" target="_blank" rel="noopener" style="color:var(--blue)">' + escapeHtml(followup.parent_pr_url) + '</a>' +
407
+ '</div>';
408
+ }
409
+ }
410
+
411
+ let dependsChips = '';
412
+ if (Array.isArray(pr.depends_on) && pr.depends_on.length && typeof renderArtifactLink === 'function') {
413
+ const knownPrIds = new Set();
414
+ const knownSources = [
415
+ (typeof allPrs !== 'undefined' && Array.isArray(allPrs)) ? allPrs : null,
416
+ window._lastPrs,
417
+ window._lastPullRequests,
418
+ ];
419
+ for (const src of knownSources) {
420
+ if (Array.isArray(src)) for (const p of src) { if (p && p.id) knownPrIds.add(p.id); }
421
+ }
422
+ dependsChips = '<div style="margin-top:10px"><strong style="color:var(--muted);font-size:var(--text-base)">Depends On:</strong> ' +
423
+ pr.depends_on.map(function(d) {
424
+ const depId = String(d);
425
+ const isDeleted = knownPrIds.size > 0 && !knownPrIds.has(depId);
426
+ return renderArtifactLink({
427
+ type: 'pr',
428
+ id: depId,
429
+ label: depId,
430
+ title: isDeleted ? 'Dependency PR ' + depId + ' is no longer in local state' : 'Depends on ' + depId,
431
+ deleted: isDeleted,
432
+ });
433
+ }).join(' ') +
434
+ '</div>';
435
+ }
436
+
437
+ // Reviewers list
438
+ let reviewers = '';
439
+ if (Array.isArray(pr.reviewers) && pr.reviewers.length) {
440
+ reviewers = '<div style="margin-top:6px"><strong style="color:var(--muted);font-size:var(--text-base)">Reviewers:</strong> ' +
441
+ pr.reviewers.map(r => '<code>' + escapeHtml(String(typeof r === 'string' ? r : (r.displayName || r.name || ''))) + '</code>').join(' ') +
442
+ '</div>';
443
+ }
444
+
445
+ const agent = pr.agent ? '<div><strong style="color:var(--muted);font-size:var(--text-base)">Agent:</strong> ' + escapeHtml(String(pr.agent)) + '</div>' : '';
446
+ const branch = pr.branch ? '<div><strong style="color:var(--muted);font-size:var(--text-base)">Branch:</strong> <code>' + escapeHtml(String(pr.branch)) + '</code></div>' : '';
447
+ const created = pr.created ? '<div><strong style="color:var(--muted);font-size:var(--text-base)">Created:</strong> ' + escapeHtml(String(pr.created)) + '</div>' : '';
448
+ const project = pr._project ? '<div><strong style="color:var(--muted);font-size:var(--text-base)">Project:</strong> ' + escapeHtml(String(pr._project)) + '</div>' : '';
449
+
450
+ const descSafe = typeof renderMd === 'function' ? renderMd(pr.description || '') : escapeHtml(pr.description || '');
451
+ const descBlock = pr.description ?
452
+ '<div style="margin-top:14px"><strong style="color:var(--muted);font-size:var(--text-base)">Description:</strong><div style="margin-top:6px;font-size:var(--text-md);line-height:1.6">' + descSafe + '</div></div>' :
453
+ '';
454
+
455
+ const openOnHost = pr.url ?
456
+ '<a class="btn-secondary" style="display:inline-block;margin-top:14px;padding:6px 12px;text-decoration:none" href="' + escapeHtml(safeUrl(pr.url)) + '" target="_blank" rel="noopener">Open on GitHub &#x2197;</a>' :
457
+ '';
458
+
459
+ return '<div style="font-family:\'Segoe UI\',system-ui,sans-serif">' +
460
+ '<div style="margin-bottom:10px">' +
461
+ '<code style="color:var(--muted);font-size:var(--text-base)">' + escapeHtml(String(pr.id || '')) + '</code><br>' +
462
+ badges +
463
+ '</div>' +
464
+ agent + branch + project + created +
465
+ reviewers +
466
+ linkedChips +
467
+ parentChip +
468
+ dependsChips +
469
+ descBlock +
470
+ openOnHost +
471
+ '</div>';
472
+ }
473
+
303
474
  function openModal(i) {
304
475
  const item = inboxData[i];
305
476
  if (!item) return;
@@ -490,4 +661,4 @@ async function resumePausedCause(btn) {
490
661
  }
491
662
  }
492
663
 
493
- window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, unlinkPr, togglePrObserve, resumePausedCause };
664
+ window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, openPrDetail, unlinkPr, togglePrObserve, resumePausedCause };
@@ -405,6 +405,18 @@ function openScheduleDetail(id) {
405
405
  '<div><strong style="color:var(--muted)">Agent:</strong> ' + escHtml(s.agent || 'auto') + '</div>' +
406
406
  '<div><strong style="color:var(--muted)">Status:</strong> ' + enabledLabel + '</div>' +
407
407
  '<div><strong style="color:var(--muted)">Last Run:</strong> ' + escHtml(lastRun) + '</div>' +
408
+ // P-c549d07e — "Recent dispatches" chip row. Each chip routes through
409
+ // renderArtifactLink({type:'wi'}) → openArtifact() so clicking pushes
410
+ // the WI detail modal onto the stack. Hard-cap to 5 chips even if the
411
+ // API returned more (defensive — the API already slices).
412
+ (Array.isArray(s._recentWorkItemIds) && s._recentWorkItemIds.length > 0
413
+ ? '<div><strong style="color:var(--muted)">Recent dispatches:</strong>' +
414
+ '<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px">' +
415
+ s._recentWorkItemIds.slice(0, 5).map(function(wi) {
416
+ return renderArtifactLink({ type: 'wi', id: String(wi), label: String(wi), title: 'Open work item ' + wi });
417
+ }).join('') +
418
+ '</div></div>'
419
+ : '') +
408
420
  (s.description ? '<div><strong style="color:var(--muted)">Description:</strong><div style="margin-top:4px;padding:8px;background:var(--surface2);border-radius:4px">' + renderMd(s.description) + '</div></div>' : '') +
409
421
  '</div>';
410
422