@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.
@@ -575,4 +575,154 @@ function restoreDashboardScrollState(root, state) {
575
575
  });
576
576
  }
577
577
 
578
- window.MinionsRenderUtils = { formatToolSummary, renderAgentOutput, renderTerminalBanner, renderPager, pinButton, captureDashboardScrollState, restoreDashboardScrollState };
578
+ // P-ce1e5e47 universal artifact-modal dispatcher. Chip clicks (and the
579
+ // URL-hash replay path) call openArtifact(type, id); this pushes a stack
580
+ // frame and delegates to the existing per-type opener.
581
+ //
582
+ // Type → handler table:
583
+ // wi → openWorkItemDetail(id)
584
+ // pr → openPrDetail(id) (P-79b47b0c; falls back to openPrUrl)
585
+ // plan/prd → planView(id)
586
+ // note → openInboxNote(id)
587
+ // kb → kbOpenItem(category, file) (id = "category/file")
588
+ // schedule → openScheduleDetail(id)
589
+ // watch → openWatchDetail(id)
590
+ // pipeline → openPipelineDetail(id)
591
+ // meeting → openMeetingDetail(id)
592
+ // agent → openAgentDetail(id) ← detail-panel exception (see below)
593
+ //
594
+ // P-30b6cf8a — Detail-panel exception (intentional, documented). The agent
595
+ // type does NOT push a modal-stack frame and does NOT use the modal shell.
596
+ // openAgentDetail() opens the right-side slide-in detail panel (tabs +
597
+ // live-stream + charter editor) which is a peer of the modal stack, not a
598
+ // member of it. Clicking an agent chip from inside a modal therefore:
599
+ // 1. Resets the modal stack (resetModalStack) and physically closes the
600
+ // modal shell — modal-stack identity goes back to empty.
601
+ // 2. Opens the agent detail panel.
602
+ // The reverse direction is symmetric: clicking a WI/PR/plan chip from
603
+ // inside the panel calls openArtifact('<type>', id), which (per the
604
+ // non-agent branch below) closes the detail panel and starts a fresh
605
+ // modal stack. This one-way exception is the deliberate contract — do
606
+ // NOT "fix" it by adding pushModalFrame for the agent type. See the
607
+ // matching JSDoc on dashboard/js/render-agents.js openAgentDetail().
608
+ function openArtifact(type, id) {
609
+ if (!type || id === undefined || id === null) return;
610
+ if (type === 'agent') {
611
+ if (typeof resetModalStack === 'function') resetModalStack();
612
+ if (typeof _physicallyCloseModal === 'function') _physicallyCloseModal();
613
+ if (typeof openAgentDetail === 'function') openAgentDetail(id);
614
+ return;
615
+ }
616
+ // Non-agent (modal) type: dismiss the detail panel if it's open so the
617
+ // modal-stack peer cleanly takes over the foreground.
618
+ try {
619
+ var panelEl = document.getElementById('detail-panel');
620
+ if (panelEl && panelEl.classList.contains('open') && typeof closeDetail === 'function') {
621
+ closeDetail();
622
+ }
623
+ } catch { /* DOM may not be ready in unit-test eval */ }
624
+ if (typeof pushModalFrame === 'function') {
625
+ try {
626
+ pushModalFrame({ type: type, id: String(id), openFn: openArtifact, openArgs: [type, String(id)] });
627
+ } catch (e) { try { console.error('pushModalFrame failed', e); } catch {} }
628
+ }
629
+ switch (type) {
630
+ case 'wi':
631
+ if (typeof openWorkItemDetail === 'function') openWorkItemDetail(id);
632
+ break;
633
+ case 'pr':
634
+ if (typeof openPrDetail === 'function') openPrDetail(id);
635
+ else if (typeof window !== 'undefined' && window.MinionsPrs && typeof window.MinionsPrs.openPrDetail === 'function') window.MinionsPrs.openPrDetail(id);
636
+ break;
637
+ case 'plan':
638
+ case 'prd':
639
+ if (typeof planView === 'function') planView(id);
640
+ break;
641
+ case 'note':
642
+ if (typeof openInboxNote === 'function') openInboxNote(id);
643
+ break;
644
+ case 'kb': {
645
+ var idx = String(id).indexOf('/');
646
+ if (idx > 0 && typeof kbOpenItem === 'function') {
647
+ kbOpenItem(String(id).slice(0, idx), String(id).slice(idx + 1));
648
+ }
649
+ break;
650
+ }
651
+ case 'schedule':
652
+ if (typeof openScheduleDetail === 'function') openScheduleDetail(id);
653
+ break;
654
+ case 'watch':
655
+ if (typeof openWatchDetail === 'function') openWatchDetail(id);
656
+ break;
657
+ case 'pipeline':
658
+ if (typeof openPipelineDetail === 'function') openPipelineDetail(id);
659
+ break;
660
+ case 'meeting':
661
+ if (typeof openMeetingDetail === 'function') openMeetingDetail(id);
662
+ break;
663
+ default:
664
+ try { console.warn('openArtifact: unknown type', type); } catch {}
665
+ }
666
+ }
667
+
668
+ // P-e265cd31 — shared artifact-chip renderer. Every chip click routes through
669
+ // openArtifact() so the modal stack push, URL-hash update, and browser-Back
670
+ // integration come for free at every callsite (no more inline
671
+ // pushModalBack(...);openXxxDetail(...) string-built pills).
672
+ //
673
+ // Icon table — keep consistent across the dashboard:
674
+ // wi=📋 pr=🔀 plan=📋 prd=📄 note=📝 kb=📚
675
+ // schedule=⏰ watch=👁 pipeline=⛓ meeting=🗣 agent=🤖
676
+ //
677
+ // Options:
678
+ // type — one of the icon-table keys (case-sensitive)
679
+ // id — artifact identifier; passed verbatim to openArtifact(type, id)
680
+ // label — visible chip text (defaults to id)
681
+ // title — optional hover tooltip (defaults to empty)
682
+ // icon — optional icon override (defaults to ICON_TABLE[type])
683
+ // deleted — true → render struck-out, non-clickable chip so dead refs
684
+ // still show context to the operator
685
+ //
686
+ // Unknown type with no explicit icon falls back to a plain escaped span (no
687
+ // chip styling, no throw) so callers can pass through arbitrary refs safely.
688
+ var _ARTIFACT_ICON_TABLE = {
689
+ wi: '📋', pr: '🔀', plan: '📋', prd: '📄', note: '📝', kb: '📚',
690
+ schedule: '⏰', watch: '👁', pipeline: '⛓', meeting: '🗣', agent: '🤖',
691
+ };
692
+
693
+ function renderArtifactLink(opts) {
694
+ var o = opts || {};
695
+ var type = String(o.type == null ? '' : o.type);
696
+ var id = o.id == null ? '' : String(o.id);
697
+ var label = o.label == null ? id : String(o.label);
698
+ var title = o.title == null ? '' : String(o.title);
699
+ var hasIconOverride = typeof o.icon === 'string' && o.icon.length > 0;
700
+ var icon = hasIconOverride ? o.icon : (_ARTIFACT_ICON_TABLE[type] || '');
701
+ var deleted = o.deleted === true;
702
+
703
+ if (!Object.prototype.hasOwnProperty.call(_ARTIFACT_ICON_TABLE, type) && !hasIconOverride) {
704
+ return '<span class="artifact-chip-unknown">' + escHtml(label) + '</span>';
705
+ }
706
+
707
+ var classes = 'artifact-chip' + (deleted ? ' deleted' : '');
708
+ // JSON.stringify produces a safe JS string literal (handles backslashes,
709
+ // quotes, control chars). escHtml then makes it safe for the attribute
710
+ // value; the HTML parser reverses the entity escapes before the JS
711
+ // evaluator sees them, so the call site always receives the original
712
+ // string values.
713
+ var clickAttr = deleted
714
+ ? ''
715
+ : ' onclick="event.stopPropagation();openArtifact('
716
+ + escHtml(JSON.stringify(type)) + ','
717
+ + escHtml(JSON.stringify(id)) + ')"';
718
+ var titleAttr = title ? ' title="' + escHtml(title) + '"' : '';
719
+ return '<span class="' + classes + '"'
720
+ + ' data-art-type="' + escHtml(type) + '"'
721
+ + ' data-art-id="' + escHtml(id) + '"'
722
+ + clickAttr + titleAttr + '>'
723
+ + (icon ? '<span class="artifact-chip-icon">' + escHtml(icon) + '</span>' : '')
724
+ + '<span class="artifact-chip-label">' + escHtml(label) + '</span>'
725
+ + '</span>';
726
+ }
727
+
728
+ window.MinionsRenderUtils = { formatToolSummary, renderAgentOutput, renderTerminalBanner, renderPager, pinButton, captureDashboardScrollState, restoreDashboardScrollState, openArtifact, renderArtifactLink };
@@ -101,6 +101,48 @@ function _targetTypeLabel(type) {
101
101
  return _WATCH_TARGET_LABELS[type] || (type || '');
102
102
  }
103
103
 
104
+ // P-1acd4cde — map a watch.targetType to the artifact-chip type understood by
105
+ // renderArtifactLink / openArtifact (defined in render-utils.js). Returns null
106
+ // for targetTypes that don't correspond to a navigable artifact (teams-channel,
107
+ // future plugin types) so callers fall back to plain text rendering. The
108
+ // mapping covers the 8 first-class TARGET_TYPES registered in engine/watches.js
109
+ // (engine/shared.js#WATCH_TARGET_TYPE) — dispatch ids are WI ids in practice
110
+ // so they share the wi chip target.
111
+ var _WATCH_TARGET_TO_ART_TYPE = {
112
+ 'pr': 'pr',
113
+ 'work-item': 'wi',
114
+ 'meeting': 'meeting',
115
+ 'plan': 'plan',
116
+ 'schedule': 'schedule',
117
+ 'pipeline': 'pipeline',
118
+ 'dispatch': 'wi',
119
+ 'agent': 'agent',
120
+ };
121
+ function _watchTargetToArtType(targetType) {
122
+ if (!targetType) return null;
123
+ return _WATCH_TARGET_TO_ART_TYPE[targetType] || null;
124
+ }
125
+
126
+ // P-1acd4cde — render the watch target as a clickable artifact chip when the
127
+ // targetType maps to a known artifact AND the target is a non-empty string id.
128
+ // Object targets (e.g. teams-channel `{teamId, channelId}`) and plugin
129
+ // targetTypes without an artifact equivalent fall back to the legacy
130
+ // _formatWatchTarget plain text so the W-mq1j5f9z00030b8f title-fallback
131
+ // contract still holds for non-artifact watches.
132
+ function _renderWatchTargetChip(w) {
133
+ var artType = _watchTargetToArtType(w && w.targetType);
134
+ if (artType && typeof w.target === 'string' && w.target.length > 0
135
+ && typeof renderArtifactLink === 'function') {
136
+ return renderArtifactLink({
137
+ type: artType,
138
+ id: w.target,
139
+ label: w.target,
140
+ title: _targetTypeLabel(w.targetType) + ' ' + w.target,
141
+ });
142
+ }
143
+ return escHtml(_formatWatchTarget(w && w.target, w && w.targetType));
144
+ }
145
+
104
146
  // W-mq1j5f9z00030b8f — `teams-channel` target is `{teamId, channelId}` (object);
105
147
  // other targetTypes use strings. Avoid `String(target)` → "[object Object]";
106
148
  // route all target rendering here so future object-target plugins inherit safety.
@@ -339,7 +381,7 @@ function openWatchDetail(id) {
339
381
 
340
382
  var body = '<div style="display:flex;flex-direction:column;gap:10px;font-size:var(--text-md);line-height:1.6">' +
341
383
  '<div><strong style="color:var(--muted)">ID:</strong> ' + escHtml(w.id) + '</div>' +
342
- '<div><strong style="color:var(--muted)">Target:</strong> ' + escHtml(_formatWatchTarget(w.target, w.targetType)) + '</div>' +
384
+ '<div><strong style="color:var(--muted)">Target:</strong> ' + _renderWatchTargetChip(w) + '</div>' +
343
385
  '<div><strong style="color:var(--muted)">Target Type:</strong> <span class="dispatch-type explore">' + escHtml(targetLabel) + '</span></div>' +
344
386
  '<div><strong style="color:var(--muted)">Condition:</strong> <span style="color:var(--blue)">' + escHtml(condLabel) + '</span></div>' +
345
387
  '<div><strong style="color:var(--muted)">Check Interval:</strong> ' + escHtml(_intervalToHuman(w.interval)) + '</div>' +
@@ -356,7 +398,7 @@ function openWatchDetail(id) {
356
398
  // P-w14e7a8c — Phase 7.1: render cross-target requirements list when
357
399
  // the watch carries a non-empty requires[] from the editor.
358
400
  _renderWatchRequiresDetail(w.requires) +
359
- (w._lastActionResult ? '<div><strong style="color:var(--muted)">Last Action Result:</strong> <span style="color:' + (w._lastActionResult.ok ? 'var(--green)' : 'var(--red)') + '">' + (w._lastActionResult.ok ? 'OK' : 'FAILED') + '</span> — ' + escHtml(w._lastActionResult.summary || '') + (w._lastActionResult.dispatchedItemId ? ' (dispatched: ' + escHtml(w._lastActionResult.dispatchedItemId) + ')' : '') + '</div>' : '') +
401
+ (w._lastActionResult ? '<div><strong style="color:var(--muted)">Last Action Result:</strong> <span style="color:' + (w._lastActionResult.ok ? 'var(--green)' : 'var(--red)') + '">' + (w._lastActionResult.ok ? 'OK' : 'FAILED') + '</span> — ' + escHtml(w._lastActionResult.summary || '') + (w._lastActionResult.dispatchedItemId && typeof renderArtifactLink === 'function' ? ' ' + renderArtifactLink({ type: 'wi', id: String(w._lastActionResult.dispatchedItemId), label: String(w._lastActionResult.dispatchedItemId), title: 'Dispatched work item ' + w._lastActionResult.dispatchedItemId }) : '') + '</div>' : '') +
360
402
  '<div><strong style="color:var(--muted)">Created:</strong> ' + escHtml(createdAt) + '</div>' +
361
403
  '<div><strong style="color:var(--muted)">Last Checked:</strong> ' + escHtml(lastChecked) + '</div>' +
362
404
  '<div><strong style="color:var(--muted)">Last Triggered:</strong> ' + escHtml(lastTriggered) + '</div>' +
@@ -389,13 +431,22 @@ function openWatchDetail(id) {
389
431
  return res.json();
390
432
  }).then(function(data) {
391
433
  if (!container.isConnected) return; // user closed the modal
392
- // eslint-disable-next-line no-unsanitized/property -- reason: _renderWatchHistoryDetail() escapes all user-controlled fields before assembling HTML
393
- container.innerHTML = _renderWatchHistoryDetail(data && data.history);
434
+ // P-ce1e5e47 skip the history render when the user has stacked
435
+ // another modal on top of this watch view.
436
+ var apply = function() {
437
+ // eslint-disable-next-line no-unsanitized/property -- reason: _renderWatchHistoryDetail() escapes all user-controlled fields before assembling HTML
438
+ container.innerHTML = _renderWatchHistoryDetail(data && data.history);
439
+ };
440
+ if (typeof withTopFrame === 'function') withTopFrame('watch', w.id, apply);
441
+ else apply();
394
442
  }).catch(function() {
395
443
  if (!container.isConnected) return;
396
- // Fall back to the inline _history from /api/watches (best-effort).
397
- // eslint-disable-next-line no-unsanitized/property -- reason: _renderWatchHistoryDetail() escapes all user-controlled fields before assembling HTML
398
- container.innerHTML = _renderWatchHistoryDetail(w._history || []);
444
+ var applyFallback = function() {
445
+ // eslint-disable-next-line no-unsanitized/property -- reason: _renderWatchHistoryDetail() escapes all user-controlled fields before assembling HTML
446
+ container.innerHTML = _renderWatchHistoryDetail(w._history || []);
447
+ };
448
+ if (typeof withTopFrame === 'function') withTopFrame('watch', w.id, applyFallback);
449
+ else applyFallback();
399
450
  });
400
451
  })();
401
452
  }
@@ -824,4 +875,7 @@ window.MinionsWatches = {
824
875
  // W-mq1j5f9z00030b8f — target / title fallback helpers exposed for unit coverage.
825
876
  _formatWatchTarget: _formatWatchTarget,
826
877
  _watchTitleFallback: _watchTitleFallback,
878
+ // P-1acd4cde — watch ↔ target artifact-chip linkage helpers exposed for unit coverage.
879
+ _watchTargetToArtType: _watchTargetToArtType,
880
+ _renderWatchTargetChip: _renderWatchTargetChip,
827
881
  };
@@ -668,6 +668,19 @@ async function _submitCreateWorkItem(e) {
668
668
  } catch (e) { alert('Error: ' + e.message); openCreateWorkItemModal(); }
669
669
  }
670
670
 
671
+ // P-79b47b0c — Derive a canonical PR id (`<host>:<slug>#<number>`) from a
672
+ // GitHub or ADO PR URL. Matches the format produced by engine/queries.js
673
+ // when stamping pr.id. Returns null when the URL doesn't match a known
674
+ // host pattern.
675
+ function _wiDeriveCanonicalPrIdFromUrl(url) {
676
+ if (!url || typeof url !== 'string') return null;
677
+ var ghMatch = url.match(/https?:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/i);
678
+ if (ghMatch) return 'github:' + ghMatch[1] + '#' + ghMatch[2];
679
+ var adoMatch = url.match(/https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/?]+)\/pullrequest\/(\d+)/i);
680
+ if (adoMatch) return 'ado:' + adoMatch[1] + '/' + adoMatch[2] + '/' + adoMatch[3] + '#' + adoMatch[4];
681
+ return null;
682
+ }
683
+
671
684
  function _wiRenderDetail(item) {
672
685
  const field = (label, value) => value ? '<div style="margin-bottom:8px"><span style="color:var(--muted);font-size:var(--text-sm);text-transform:uppercase;letter-spacing:0.5px">' + label + '</span><div style="margin-top:2px">' + value + '</div></div>' : '';
673
686
  const badge = (cls, text) => '<span class="pr-badge ' + cls + '">' + escapeHtml(text) + '</span>';
@@ -753,8 +766,18 @@ function _wiRenderDetail(item) {
753
766
  // Defensive: CC dispatches can land here with these fields as strings
754
767
  // (e.g. acceptanceCriteria: "fix the login bug"). Coerce to arrays so
755
768
  // .map() doesn't throw and crash the modal.
769
+ // P-79b47b0c — depends_on[] now renders as WI chips routed through
770
+ // renderArtifactLink (was bare <code> spans). Each chip clicks into the
771
+ // dependency's WI detail modal in-stack.
756
772
  var deps = Array.isArray(item.depends_on) ? item.depends_on : [];
757
- if (deps.length) html += field('Depends On', deps.map(d => '<code>' + escapeHtml(d) + '</code>').join(', '));
773
+ if (deps.length) {
774
+ html += field('Depends On', deps.map(d => renderArtifactLink({ type: 'wi', id: String(d), label: String(d), title: 'Depends on ' + d })).join(' '));
775
+ }
776
+ // P-79b47b0c — parent_id chip (when present). Surfaces the parent WI that
777
+ // decomposed/spawned this child item.
778
+ if (item.parent_id) {
779
+ html += field('Parent Work Item', renderArtifactLink({ type: 'wi', id: String(item.parent_id), label: String(item.parent_id), title: 'Parent: ' + item.parent_id }));
780
+ }
758
781
  var ac = Array.isArray(item.acceptanceCriteria)
759
782
  ? item.acceptanceCriteria
760
783
  : (typeof item.acceptanceCriteria === 'string' && item.acceptanceCriteria.trim()
@@ -774,38 +797,114 @@ function _wiRenderDetail(item) {
774
797
  html += field('References', '<span style="color:var(--muted)">' + item.referencesCount + ' reference(s) — loading…</span>');
775
798
  }
776
799
  if (item._humanFeedback) html += field('Human Feedback', (item._humanFeedback.rating === 'up' ? '👍' : '👎') + (item._humanFeedback.comment ? ' — ' + escapeHtml(item._humanFeedback.comment) : ''));
777
- if (item._pr) html += field('Pull Request', '<a href="' + escapeHtml(item._prUrl || '#') + '" target="_blank" style="color:var(--blue)">' + escapeHtml(item._pr) + '</a>');
800
+ // P-79b47b0c PR chip routed through renderArtifactLink so click opens the
801
+ // PR detail modal in-stack (was a raw <a target="_blank">).
802
+ if (item._pr) html += field('Pull Request', renderArtifactLink({ type: 'pr', id: item._pr, label: item._pr, title: item._prUrl || item._pr }));
803
+ // P-79b47b0c — follow-up parent PR chip. WIs spawned by PR review/build-fix
804
+ // dispatchers stamp meta.pr_followup.{parent_pr_url, parent_pr_id} on the
805
+ // child item; surface that lineage as a clickable in-stack PR chip. Prefer
806
+ // parent_pr_id when stamped; otherwise parse the URL into a canonical id.
807
+ var followup = item.meta && item.meta.pr_followup;
808
+ if (followup && (followup.parent_pr_id || followup.parent_pr_url)) {
809
+ var parentPrId = followup.parent_pr_id || _wiDeriveCanonicalPrIdFromUrl(followup.parent_pr_url);
810
+ if (parentPrId) {
811
+ html += field('Parent PR (follow-up)', renderArtifactLink({ type: 'pr', id: parentPrId, label: parentPrId, title: 'Follow-up from ' + (followup.parent_pr_url || parentPrId) }));
812
+ } else if (followup.parent_pr_url) {
813
+ // Couldn't derive a canonical id (unknown host?). Fall back to plain link.
814
+ html += field('Parent PR (follow-up)', '<a href="' + escapeHtml(followup.parent_pr_url) + '" target="_blank" rel="noopener" style="color:var(--blue)">' + escapeHtml(followup.parent_pr_url) + '</a>');
815
+ }
816
+ }
778
817
 
779
- // Artifactsoutput log, branch, skills, etc.
818
+ // P-c549d07e"Spawned by" back-link chip. The schedule/pipeline that
819
+ // dispatched this WI is the natural Back target from the WI modal. The
820
+ // primary source is meta.spawnedBy ('schedule:<id>' | 'pipeline:<id>'),
821
+ // stamped by engine/scheduler.js + engine/pipeline.js on new dispatches.
822
+ // For older WIs already in state we fall back to (a) item._scheduleId
823
+ // and (b) item.createdBy when it starts with 'pipeline:' so historical
824
+ // items still light up the chip — purely additive, no behavior change.
825
+ (function() {
826
+ var meta = item.meta || {};
827
+ var spawnedBy = typeof meta.spawnedBy === 'string' ? meta.spawnedBy : '';
828
+ if (!spawnedBy && item._scheduleId) spawnedBy = 'schedule:' + item._scheduleId;
829
+ if (!spawnedBy && typeof item.createdBy === 'string' && item.createdBy.indexOf('pipeline:') === 0) {
830
+ spawnedBy = item.createdBy;
831
+ }
832
+ if (!spawnedBy) return;
833
+ var colonIdx = spawnedBy.indexOf(':');
834
+ if (colonIdx <= 0) return;
835
+ var kind = spawnedBy.slice(0, colonIdx);
836
+ var dispatcherId = spawnedBy.slice(colonIdx + 1);
837
+ if (!dispatcherId) return;
838
+ var chip;
839
+ if (kind === 'schedule') {
840
+ chip = renderArtifactLink({ type: 'schedule', id: dispatcherId, label: dispatcherId, title: 'Open schedule ' + dispatcherId });
841
+ } else if (kind === 'pipeline') {
842
+ chip = renderArtifactLink({ type: 'pipeline', id: dispatcherId, label: dispatcherId, title: 'Open pipeline ' + dispatcherId });
843
+ } else {
844
+ return;
845
+ }
846
+ html += field('Spawned by', chip);
847
+ })();
848
+
849
+ // Artifacts — branch (non-navigation) + navigation chips routed through
850
+ // renderArtifactLink so chip click → openArtifact() → modal-stack push +
851
+ // URL hash + browser-Back integration come for free (P-e265cd31).
852
+ // P-79b47b0c — also surface item._source (the WI's raw source file) as a
853
+ // dedicated chip when it isn't already covered by arts.plan / arts.prd /
854
+ // arts.sourcePlan. Chip type is inferred from the file extension (.md →
855
+ // plan, .json → prd).
780
856
  var arts = item._artifacts || {};
781
857
  var artPills = '';
782
- var pillStyle = 'display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:10px;font-size:var(--text-sm);cursor:pointer;background:var(--surface2);border:1px solid var(--border);color:var(--text)';
783
- // Output log pill removed raw stream-json output is not human-readable
784
- var artBackFn = "pushModalBack(function(){openWorkItemDetail('" + escapeHtml(item.id) + "')});";
785
- if (arts.branch) artPills += '<span style="' + pillStyle + ';cursor:default">🌿 ' + escapeHtml(arts.branch) + '</span> ';
786
- if (arts.plan) artPills += '<span onclick="' + artBackFn + 'planView(\'' + escapeHtml(arts.plan) + '\')" style="' + pillStyle + '">📋 Plan</span> ';
787
- if (arts.prd) artPills += '<span onclick="' + artBackFn + 'planView(\'' + escapeHtml(arts.prd) + '\')" style="' + pillStyle + '">📄 PRD</span> ';
788
- if (arts.sourcePlan) artPills += '<span onclick="' + artBackFn + 'planView(\'' + escapeHtml(arts.sourcePlan) + '\')" style="' + pillStyle + '">📋 Source Plan</span> ';
858
+ var branchStyle = 'display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:10px;font-size:var(--text-sm);background:var(--surface2);border:1px solid var(--border);color:var(--text);cursor:default';
859
+ if (arts.branch) artPills += '<span style="' + branchStyle + '">🌿 ' + escapeHtml(arts.branch) + '</span> ';
860
+ if (arts.plan) artPills += renderArtifactLink({ type: 'plan', id: arts.plan, label: 'Plan' }) + ' ';
861
+ if (arts.prd) artPills += renderArtifactLink({ type: 'prd', id: arts.prd, label: 'PRD' }) + ' ';
862
+ if (arts.sourcePlan) artPills += renderArtifactLink({ type: 'plan', id: arts.sourcePlan, label: 'Source Plan' }) + ' ';
863
+ // Source chip (item._source) render when it's a file path that isn't
864
+ // already represented in arts.plan / arts.prd / arts.sourcePlan.
865
+ if (item._source && typeof item._source === 'string' && item._source !== 'central') {
866
+ var sourceAlreadyShown = arts.plan === item._source || arts.prd === item._source || arts.sourcePlan === item._source;
867
+ if (!sourceAlreadyShown) {
868
+ var sourceType = item._source.endsWith('.json') ? 'prd' : 'plan';
869
+ artPills += renderArtifactLink({ type: sourceType, id: item._source, label: 'Source', title: item._source }) + ' ';
870
+ }
871
+ }
789
872
  if (arts.notes && arts.notes.length > 0) {
790
873
  arts.notes.forEach(function(n) {
791
874
  var noteFile = (n && typeof n === 'object') ? (n.file || n) : String(n || '');
792
875
  if (noteFile.startsWith('kb:')) {
793
- var kbParts = noteFile.slice(3).split('/');
794
- var kbCat = kbParts[0];
795
- var kbFile = kbParts.slice(1).join('/');
876
+ var kbBody = noteFile.slice(3);
877
+ var slashIdx = kbBody.indexOf('/');
878
+ if (slashIdx <= 0) return;
879
+ var kbFile = kbBody.slice(slashIdx + 1);
796
880
  var kbLabel = kbFile.replace(/\.md$/, '').slice(0, 30);
797
- artPills += '<span onclick="' + artBackFn + 'kbOpenItem(\'' + escapeHtml(kbCat) + '\',\'' + escapeHtml(kbFile) + '\')" style="' + pillStyle + '">📚 ' + escapeHtml(kbLabel) + '</span> ';
881
+ artPills += renderArtifactLink({ type: 'kb', id: kbBody, label: kbLabel, title: 'KB: ' + kbFile }) + ' ';
798
882
  } else if (noteFile.startsWith('archive:')) {
799
- var archLabel = noteFile.slice(8).replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '').slice(0, 30);
800
- artPills += '<span onclick="' + artBackFn + 'openInboxNote(\'' + escapeHtml(noteFile.slice(8)) + '\')" style="' + pillStyle + ';opacity:0.7">📄 ' + escapeHtml(archLabel) + ' <span style="font-size:var(--text-xs)">(archived)</span></span> ';
883
+ var archFile = noteFile.slice(8);
884
+ var archLabel = archFile.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '').slice(0, 30) + ' (archived)';
885
+ artPills += renderArtifactLink({ type: 'note', id: archFile, label: archLabel, icon: '📄', title: 'Archived note: ' + archFile }) + ' ';
801
886
  } else {
802
887
  var noteLabel = noteFile.replace(/\.md$/, '').slice(0, 30);
803
- artPills += '<span onclick="' + artBackFn + 'openInboxNote(\'' + escapeHtml(noteFile) + '\')" style="' + pillStyle + '">📝 ' + escapeHtml(noteLabel) + '</span> ';
888
+ artPills += renderArtifactLink({ type: 'note', id: noteFile, label: noteLabel, title: 'Note: ' + noteFile }) + ' ';
804
889
  }
805
890
  });
806
891
  }
807
892
  if (artPills) html += field('Artifacts', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + artPills + '</div>');
808
893
 
894
+ // P-34fa5d79 — Mentions: notes whose YAML frontmatter cites this WI
895
+ // (work_item: / wi: / sourceItem:). Slim string[] field set by
896
+ // engine/queries.js#getWorkItems from _buildNotesByWiMap. Each entry is
897
+ // either a bare inbox filename or `archive:<filename>` for archive notes.
898
+ if (Array.isArray(item._notes) && item._notes.length > 0) {
899
+ var mentionPills = item._notes.map(function(token) {
900
+ var isArchive = token.indexOf('archive:') === 0;
901
+ var fname = isArchive ? token.slice(8) : token;
902
+ var label = fname.replace(/\.md$/, '').slice(0, 30) + (isArchive ? ' (archived)' : '');
903
+ return renderArtifactLink({ type: 'note', id: fname, label: label, title: 'Note: ' + fname });
904
+ }).join(' ');
905
+ html += field('Mentions', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + mentionPills + '</div>');
906
+ }
907
+
809
908
  if (item._totalCostUsd != null) html += field('Cumulative Cost', '$' + Number(item._totalCostUsd).toFixed(4));
810
909
  if (item._totalInputTokens) html += field('Total Input Tokens', Number(item._totalInputTokens).toLocaleString());
811
910
  if (item._totalOutputTokens) html += field('Total Output Tokens', Number(item._totalOutputTokens).toLocaleString());
@@ -856,8 +955,14 @@ function openWorkItemDetail(id) {
856
955
  merged.description = full.description || cached.description || '';
857
956
  if (Array.isArray(full.acceptanceCriteria)) merged.acceptanceCriteria = full.acceptanceCriteria;
858
957
  if (Array.isArray(full.references)) merged.references = full.references;
859
- // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() or renderMd() by _wiRenderDetail() (fields: title, description, agent, source, reasons, references, artifacts, PR links)
860
- document.getElementById('modal-body').innerHTML = _wiRenderDetail(merged);
958
+ // P-ce1e5e47 withTopFrame skips the re-render when the user has
959
+ // stacked another modal on top of this work-item view.
960
+ var applyHydration = function() {
961
+ // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() or renderMd() by _wiRenderDetail() (fields: title, description, agent, source, reasons, references, artifacts, PR links)
962
+ document.getElementById('modal-body').innerHTML = _wiRenderDetail(merged);
963
+ };
964
+ if (typeof withTopFrame === 'function') withTopFrame('wi', id, applyHydration);
965
+ else applyHydration();
861
966
  })
862
967
  .catch(function() {
863
968
  var desc = document.getElementById('wi-detail-desc');
@@ -895,7 +1000,17 @@ function viewAgentOutput(logPath) {
895
1000
 
896
1001
  function openInboxNote(filename) {
897
1002
  var idx = (inboxData || []).findIndex(function(item) { return item.name === filename; });
898
- if (idx >= 0) { openModal(idx); return; }
1003
+ if (idx >= 0) {
1004
+ openModal(idx);
1005
+ // P-34fa5d79 — Source-WI chip for live inbox notes. openModal() above
1006
+ // paints the modal body from inboxData[idx].content; we parse the
1007
+ // frontmatter here and inject a back-link chip when wi: / work_item: /
1008
+ // sourceItem: is present. Append to body so the existing "Add to KB"
1009
+ // button + rendered content stay intact.
1010
+ var live = inboxData[idx];
1011
+ if (live) _wiInjectSourceChipFromFrontmatter(live.content, 'modal-body');
1012
+ return;
1013
+ }
899
1014
  // Not in the live inbox — it's an archived note (the common case for a
900
1015
  // completed work item's note pill, which the rewriter re-encodes as an
901
1016
  // 'archive:<name>' token). Fetch it from notes/archive/ and show it in the
@@ -911,6 +1026,9 @@ function openInboxNote(filename) {
911
1026
  // eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes note content before assembling HTML; filename is set via textContent
912
1027
  bodyEl.innerHTML = '<div style="font-size:var(--text-md);line-height:1.7;color:var(--muted)">' + renderMd(content) + '</div>';
913
1028
  document.getElementById('modal').classList.add('open');
1029
+ // P-34fa5d79 — Source-WI chip for archive notes. Same convention as the
1030
+ // live-inbox branch above; parsed off the freshly-fetched content.
1031
+ _wiInjectSourceChipFromFrontmatter(content, 'modal-body');
914
1032
  })
915
1033
  .catch(function() {
916
1034
  closeModal();
@@ -918,6 +1036,29 @@ function openInboxNote(filename) {
918
1036
  });
919
1037
  }
920
1038
 
1039
+ // P-34fa5d79 — shared frontmatter → source-WI chip injector. Parses the YAML
1040
+ // frontmatter block at the start of `content`, looks for the WI fields the
1041
+ // repo uses (work_item / wi / sourceItem), and prepends a renderArtifactLink
1042
+ // chip to the named modal body container so the operator can click back to
1043
+ // the WI that the note cites. No-op when no frontmatter / no WI field.
1044
+ function _wiInjectSourceChipFromFrontmatter(content, containerId) {
1045
+ var bodyEl = document.getElementById(containerId);
1046
+ if (!bodyEl) return;
1047
+ var m = String(content || '').match(/^---\n([\s\S]*?)\n---/);
1048
+ if (!m) return;
1049
+ var fm = {};
1050
+ m[1].split('\n').forEach(function(line) {
1051
+ var lm = line.match(/^([\w-]+):\s*(.*)$/);
1052
+ if (lm) fm[lm[1]] = lm[2].trim();
1053
+ });
1054
+ var wiId = fm.work_item || fm.wi || fm.sourceItem;
1055
+ if (!wiId) return;
1056
+ var chip = renderArtifactLink({ type: 'wi', id: wiId, label: wiId, title: 'Source work item: ' + wiId });
1057
+ var sourceBlock = '<div style="margin-bottom:10px;padding:6px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm)"><span style="color:var(--muted);font-size:var(--text-sm);text-transform:uppercase;letter-spacing:0.5px;margin-right:8px">Source</span>' + chip + '</div>';
1058
+ // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; chip HTML produced by renderArtifactLink() which escapes label + id via escapeHtml
1059
+ bodyEl.insertAdjacentHTML('afterbegin', sourceBlock);
1060
+ }
1061
+
921
1062
  if (typeof window !== 'undefined') {
922
1063
  window.MinionsWork = { wiRow, renderWorkItems, editWorkItem, submitWorkItemEdit, deleteWorkItem, archiveWorkItem, toggleWorkItemArchive, retryWorkItem, wiPrev, wiNext, feedbackWorkItem, submitFeedback, openCreateWorkItemModal, openWorkItemDetail, openAllWorkItems, viewAgentOutput, openInboxNote, needsAttentionInfo };
923
1064
  }
@@ -70,6 +70,10 @@ function _invokePageHooks(names) {
70
70
 
71
71
  function switchPage(page, pushState) {
72
72
  _invokePageHooks(PAGE_LEAVE_HOOKS);
73
+ // P-ce1e5e47 — page change clears the modal stack and closes any open modal.
74
+ // Per-modal polls observe withTopFrame and self-stop when the frame goes away.
75
+ if (typeof resetModalStack === 'function') resetModalStack();
76
+ if (typeof _physicallyCloseModal === 'function') _physicallyCloseModal();
73
77
 
74
78
  currentPage = page;
75
79
  document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
@@ -109,8 +113,113 @@ function switchPage(page, pushState) {
109
113
 
110
114
  // Browser back/forward navigation
111
115
  window.addEventListener('popstate', (e) => {
112
- switchPage(e.state?.page || getPageFromUrl(), false);
116
+ // P-ce1e5e47 — modal-stack-aware popstate. State shape (per history.pushState
117
+ // in pushModalFrame / pushModalBack):
118
+ // { page: 'home', modal: { breadcrumbs: [{type,id}, ...], current: {type,id} | null } }
119
+ // The "page" key takes precedence: a real page navigation (sidebar / browser
120
+ // back across tabs) clears the modal stack entirely.
121
+ const newPage = e.state?.page || getPageFromUrl();
122
+ if (newPage !== currentPage) {
123
+ // Page change wipes any open modal stack.
124
+ if (typeof resetModalStack === 'function') resetModalStack();
125
+ if (typeof _physicallyCloseModal === 'function') _physicallyCloseModal();
126
+ switchPage(newPage, false);
127
+ return;
128
+ }
129
+ // Same page — diff the modal stack and pop frames to match. We compute the
130
+ // desired total depth from breadcrumbs.length + (current ? 1 : 0).
131
+ if (typeof _popOneFrameInternal !== 'function' || typeof _modalStackDepth !== 'function') return;
132
+ const desired = e.state?.modal;
133
+ const desiredDepth = desired
134
+ ? (Array.isArray(desired.breadcrumbs) ? desired.breadcrumbs.length : 0) + (desired.current ? 1 : 0)
135
+ : 0;
136
+ const currentDepth = _modalStackDepth();
137
+ if (currentDepth <= desiredDepth) {
138
+ // No frames to pop. Refresh chrome and bail.
139
+ if (typeof _updateModalChrome === 'function') _updateModalChrome();
140
+ return;
141
+ }
142
+ _suppressHistoryFromPopstate = true;
143
+ try {
144
+ let safety = 0;
145
+ while (_modalStackDepth() > desiredDepth && safety++ < 32) {
146
+ _popOneFrameInternal();
147
+ }
148
+ if (typeof _updateModalChrome === 'function') _updateModalChrome();
149
+ } finally {
150
+ _suppressHistoryFromPopstate = false;
151
+ }
113
152
  });
153
+
154
+ // P-ce1e5e47 — parse #modal=type:id,type:id,… and return an array of
155
+ // { type, id } frames in bottom→top order. Used both for replay on initial
156
+ // page load and for unit tests of the URL contract.
157
+ // P-3ed68b1e — separator is ',' (was '>'); '>' is in the WHATWG URL
158
+ // fragment percent-encode set and Chromium serialises it as '%3E' on
159
+ // history.pushState — that broke split('>') for every multi-frame hash.
160
+ // We also still accept the legacy '>' / '%3E' separators so any bookmark
161
+ // or in-flight URL from before the fix continues to replay correctly.
162
+ function getModalStackFromUrl() {
163
+ const hash = window.location.hash || '';
164
+ const idx = hash.indexOf('modal=');
165
+ if (idx < 0) return [];
166
+ const raw = hash.slice(idx + 'modal='.length);
167
+ if (!raw) return [];
168
+ const out = [];
169
+ const parts = raw.split(/,|%3E|>/i);
170
+ for (let i = 0; i < parts.length; i++) {
171
+ const seg = parts[i];
172
+ if (!seg) continue;
173
+ // type:id — id may contain encoded ':' so split on the first ':' only.
174
+ const colon = seg.indexOf(':');
175
+ if (colon < 0) continue;
176
+ let type, id;
177
+ try {
178
+ type = decodeURIComponent(seg.slice(0, colon));
179
+ id = decodeURIComponent(seg.slice(colon + 1));
180
+ } catch { continue; }
181
+ if (!type || !id) continue;
182
+ out.push({ type, id });
183
+ }
184
+ // Cap at MAX (defined in utils.js); take the last N.
185
+ const cap = (typeof MODAL_STACK_MAX_DEPTH === 'number') ? MODAL_STACK_MAX_DEPTH : 5;
186
+ return out.length > cap ? out.slice(-cap) : out;
187
+ }
188
+
189
+ // P-ce1e5e47 — replay the modal stack encoded in the URL hash on initial load.
190
+ // Uses history.replaceState (via pushModalFrame's history call only if we
191
+ // skip it) — we want a single history entry after replay, not N. We achieve
192
+ // this by suppressing pushState for the first N-1 pushes and letting the
193
+ // final push collapse into a replaceState.
194
+ function replayModalStackFromUrl() {
195
+ const frames = (typeof getModalStackFromUrl === 'function') ? getModalStackFromUrl() : [];
196
+ if (!frames.length) return;
197
+ if (typeof openArtifact !== 'function') return;
198
+ // Suppress per-push pushState during replay; afterward write a single
199
+ // replaceState that captures the assembled stack.
200
+ if (typeof _suppressHistoryFromPopstate !== 'undefined') _suppressHistoryFromPopstate = true;
201
+ try {
202
+ for (let i = 0; i < frames.length; i++) {
203
+ try { openArtifact(frames[i].type, frames[i].id); } catch {}
204
+ }
205
+ } finally {
206
+ if (typeof _suppressHistoryFromPopstate !== 'undefined') _suppressHistoryFromPopstate = false;
207
+ }
208
+ try {
209
+ const state = { page: currentPage, modal: _serializeStackForHistory() };
210
+ history.replaceState(state, '', _buildModalHash());
211
+ } catch {}
212
+ }
213
+
214
+ // Kick off replay once the DOM (and all renderer modules) is in place.
215
+ if (typeof document !== 'undefined') {
216
+ if (document.readyState === 'loading') {
217
+ document.addEventListener('DOMContentLoaded', function() { try { replayModalStackFromUrl(); } catch {} });
218
+ } else if (typeof setTimeout !== 'undefined') {
219
+ // Defer to next tick so all openXxxDetail functions are registered first.
220
+ setTimeout(function() { try { replayModalStackFromUrl(); } catch {} }, 0);
221
+ }
222
+ }
114
223
  window._prdRequeueUi = window._prdRequeueUi || {};
115
224
 
116
225
  function getPrdRequeueState(workItemId) {