@yemi33/minions 0.1.2194 → 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.
@@ -476,6 +476,8 @@ function ccAbort() {
476
476
 
477
477
  function toggleCommandCenter() {
478
478
  _ccOpen = !_ccOpen;
479
+ // Opening CC means the operator found it — retire the first-use coachmark.
480
+ if (_ccOpen && typeof dismissCcHint === 'function') dismissCcHint();
479
481
  var drawer = document.getElementById('cc-drawer');
480
482
  var overlay = document.getElementById('cc-overlay');
481
483
  if (_ccOpen) ccApplySavedWidth();
@@ -188,4 +188,128 @@ function renderFre(statusOrProjects) {
188
188
  '</div>';
189
189
  }
190
190
 
191
- window.MinionsFre = { renderFre, dismissFre, openSettingsToDefaultCli, _freReset, FRE_DISMISS_KEY };
191
+ // ── Command Center first-use coachmark ──────────────────────────────────────
192
+ // A small popup anchored under the Command Center header button, shown to
193
+ // brand-new operators who have never used CC. Teaches the primary entry point:
194
+ // "prompt the Command Center to ask it to dispatch work." Self-contained and
195
+ // idempotent — safe to call renderCcHint() every refresh tick.
196
+ //
197
+ // Trigger: NOT dismissed AND the operator has never sent a CC message (no
198
+ // 'cc-tabs' entry carries any messages). Auto-suppressed while the CC drawer
199
+ // is open. Dismissed permanently when the user opens CC (see
200
+ // toggleCommandCenter in command-center.js) or clicks "Got it".
201
+
202
+ const CC_HINT_DISMISS_KEY = 'minions_cc_hint_dismissed';
203
+ const CC_HINT_MOUNT_ID = 'cc-hint-coachmark';
204
+
205
+ function _ccHintIsDismissed() {
206
+ try { return localStorage.getItem(CC_HINT_DISMISS_KEY) === '1'; } catch { return false; }
207
+ }
208
+
209
+ // "Brand new" = the operator has never sent a Command Center message. Reads the
210
+ // same 'cc-tabs' localStorage key command-center.js persists; any tab carrying
211
+ // at least one message means CC has been used and the hint has served its
212
+ // purpose. Absent/empty/parse-error all read as new (fail toward showing).
213
+ function _ccHintUserIsNew() {
214
+ try {
215
+ const raw = localStorage.getItem('cc-tabs');
216
+ if (!raw) return true;
217
+ const tabs = JSON.parse(raw);
218
+ if (!Array.isArray(tabs) || tabs.length === 0) return true;
219
+ return !tabs.some(function(t) { return t && Array.isArray(t.messages) && t.messages.length > 0; });
220
+ } catch { return true; }
221
+ }
222
+
223
+ function _ccHintRemove() {
224
+ const mount = document.getElementById(CC_HINT_MOUNT_ID);
225
+ if (mount && mount.parentNode) mount.parentNode.removeChild(mount);
226
+ }
227
+
228
+ function dismissCcHint() {
229
+ try { localStorage.setItem(CC_HINT_DISMISS_KEY, '1'); } catch { /* expected */ }
230
+ _ccHintRemove();
231
+ }
232
+
233
+ // Reset helper (exposed for tests / debugging only). Not wired to UI.
234
+ function _ccHintReset() {
235
+ try { localStorage.removeItem(CC_HINT_DISMISS_KEY); } catch { /* expected */ }
236
+ }
237
+
238
+ // Open the Command Center from the coachmark CTA, then dismiss the hint.
239
+ function openCcFromHint() {
240
+ dismissCcHint();
241
+ if (typeof toggleCommandCenter === 'function' && !window._ccOpen) {
242
+ try { toggleCommandCenter(); } catch { /* drawer not mounted yet */ }
243
+ }
244
+ }
245
+
246
+ // Re-anchor the coachmark under the CC button. Called on render and on resize.
247
+ function _ccHintPosition() {
248
+ const mount = document.getElementById(CC_HINT_MOUNT_ID);
249
+ const btn = document.getElementById('cc-toggle-btn');
250
+ if (!mount || !btn) return;
251
+ const rect = btn.getBoundingClientRect();
252
+ mount.style.top = (rect.bottom + 10) + 'px';
253
+ // Right-align the card to the button's right edge.
254
+ mount.style.right = Math.max(8, window.innerWidth - rect.right) + 'px';
255
+ // Point the caret at the button's horizontal centre.
256
+ const arrow = document.getElementById('cc-hint-arrow');
257
+ if (arrow) arrow.style.right = Math.max(12, (rect.width / 2) - 6) + 'px';
258
+ }
259
+
260
+ let _ccHintResizeBound = false;
261
+
262
+ // Render the coachmark into document.body. Idempotent — bails out (and removes
263
+ // any stale mount) when dismissed, when CC has been used, when the header
264
+ // button isn't mounted yet, or while the drawer is open.
265
+ function renderCcHint() {
266
+ if (_ccHintIsDismissed() || !_ccHintUserIsNew()) {
267
+ _ccHintRemove();
268
+ return;
269
+ }
270
+ const btn = document.getElementById('cc-toggle-btn');
271
+ if (!btn) return; // header not assembled yet
272
+ const drawer = document.getElementById('cc-drawer');
273
+ if (drawer && drawer.style.display === 'flex') {
274
+ // User already found CC — don't hover the hint over the open drawer.
275
+ _ccHintRemove();
276
+ return;
277
+ }
278
+
279
+ let mount = document.getElementById(CC_HINT_MOUNT_ID);
280
+ if (!mount) {
281
+ mount = document.createElement('div');
282
+ mount.id = CC_HINT_MOUNT_ID;
283
+ mount.style.cssText = [
284
+ 'position:fixed',
285
+ 'z-index:330',
286
+ 'width:260px',
287
+ 'padding:14px 16px',
288
+ 'background:var(--surface)',
289
+ 'border:1px solid var(--blue)',
290
+ 'border-radius:var(--radius-lg)',
291
+ 'box-shadow:var(--shadow-md)',
292
+ 'color:var(--text)',
293
+ 'font-size:var(--text-md)',
294
+ 'line-height:1.5',
295
+ ].join(';');
296
+ document.body.appendChild(mount);
297
+ // eslint-disable-next-line no-unsanitized/property -- reason: static string literal — no user-controlled data is interpolated
298
+ mount.innerHTML =
299
+ '<div id="cc-hint-arrow" style="position:absolute;top:-7px;width:12px;height:12px;background:var(--surface);border-left:1px solid var(--blue);border-top:1px solid var(--blue);transform:rotate(45deg)"></div>' +
300
+ '<div style="font-weight:700;color:var(--blue);margin-bottom:4px">&#x1F44B; Start here</div>' +
301
+ '<div style="color:var(--text)">Prompt your <strong>Command Center</strong> here to ask it to dispatch work.</div>' +
302
+ '<div style="display:flex;gap:8px;margin-top:12px">' +
303
+ '<button onclick="openCcFromHint()" class="btn-primary-solid" style="padding:5px 12px;font-size:var(--text-sm)">Open Command Center</button>' +
304
+ '<button onclick="dismissCcHint()" style="padding:5px 12px;font-size:var(--text-sm);background:transparent;color:var(--muted);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer">Got it</button>' +
305
+ '</div>';
306
+ }
307
+ _ccHintPosition();
308
+ if (!_ccHintResizeBound) {
309
+ _ccHintResizeBound = true;
310
+ window.addEventListener('resize', _ccHintPosition);
311
+ }
312
+ }
313
+
314
+ window.MinionsFre = { renderFre, dismissFre, openSettingsToDefaultCli, _freReset, FRE_DISMISS_KEY,
315
+ renderCcHint, dismissCcHint, openCcFromHint, _ccHintReset, _ccHintUserIsNew, CC_HINT_DISMISS_KEY };
@@ -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,
@@ -612,6 +612,12 @@ function _processStatusUpdate(data, opts) {
612
612
  if (typeof renderFre === 'function') {
613
613
  _safeRender('fre', function() { renderFre(data); });
614
614
  }
615
+ // Command Center first-use coachmark — anchored under the CC header button
616
+ // for brand-new operators who have never sent a CC message. Idempotent +
617
+ // cheap; self-suppresses once CC is used or dismissed.
618
+ if (typeof renderCcHint === 'function') {
619
+ _safeRender('ccHint', function() { renderCcHint(); });
620
+ }
615
621
  // Notes file (notes.md) is now sourced from /state/notes.md. Same shape
616
622
  // as the legacy /api/status notes slice ({ content, updatedAt }); the
617
623
  // ETag mtime is parsed out of the response header for updatedAt.
@@ -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)';