@yemi33/minions 0.1.2195 → 0.1.2197

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.
@@ -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) {
@@ -80,24 +80,256 @@ function _togglePinAndRefresh(key, source) {
80
80
  else if (source === 'kb') renderKnowledgeBase({ preserveScroll: false });
81
81
  }
82
82
 
83
- // Modal navigation stack — enables back button when opening a modal from another modal
84
- var _modalBackStack = [];
85
- function pushModalBack(reopenFn) {
86
- _modalBackStack.push(reopenFn);
87
- var btn = document.getElementById('modal-back-btn');
88
- if (btn) btn.style.display = '';
83
+ // Modal navigation stack — frames for nested modal layers (P-ce1e5e47)
84
+ // _modalStack holds breadcrumb frames the user can navigate BACK to.
85
+ // _currentVisibleArtifact tracks the currently-shown modal's identity (set by
86
+ // openArtifact / pushModalFrame). Direct row-click openers may leave this null;
87
+ // the back stack still works via the legacy pushModalBack snapshot path.
88
+ //
89
+ // Each frame: { type, id, scrollY, titleHtml, bodyHtml, openFn, openArgs }.
90
+ // Frames whose type starts with "_" (e.g. _legacy, _direct) are opaque
91
+ // breadcrumb-only frames that are NOT mirrored to the URL hash and are NOT
92
+ // replayed on refresh — they exist only for in-session back navigation.
93
+ var MODAL_STACK_MAX_DEPTH = 5;
94
+ var _modalStack = [];
95
+ var _currentVisibleArtifact = null;
96
+ var _legacyFrameSeq = 0;
97
+ var _suppressHistoryFromPopstate = false;
98
+
99
+ function _captureModalSnapshot() {
100
+ var titleEl = document.getElementById('modal-title');
101
+ var bodyEl = document.getElementById('modal-body');
102
+ return {
103
+ titleHtml: titleEl ? titleEl.innerHTML : '',
104
+ bodyHtml: bodyEl ? bodyEl.innerHTML : '',
105
+ scrollY: bodyEl ? (bodyEl.scrollTop || 0) : 0,
106
+ };
107
+ }
108
+ function _modalIsOpen() {
109
+ var el = document.getElementById('modal');
110
+ return !!(el && el.classList.contains('open'));
111
+ }
112
+ function _modalStackDepth() {
113
+ return _modalStack.length + (_currentVisibleArtifact ? 1 : 0);
114
+ }
115
+ function _capModalStackDepth() {
116
+ // Cap at MODAL_STACK_MAX_DEPTH: a 6th push evicts the OLDEST breadcrumb
117
+ // (index 0 of _modalStack), never the visible top.
118
+ while (_modalStackDepth() > MODAL_STACK_MAX_DEPTH && _modalStack.length > 0) {
119
+ _modalStack.shift();
120
+ }
89
121
  }
90
- function modalGoBack() {
91
- var fn = _modalBackStack.pop();
122
+ function _updateModalChrome() {
123
+ var depth = _modalStackDepth();
92
124
  var btn = document.getElementById('modal-back-btn');
93
- if (btn && _modalBackStack.length === 0) btn.style.display = 'none';
94
- if (fn) fn();
125
+ if (btn) btn.style.display = depth > 1 ? '' : 'none';
126
+ var chip = document.getElementById('modal-stack-chip');
127
+ if (chip) {
128
+ if (depth > 1) {
129
+ chip.style.display = '';
130
+ chip.textContent = depth + '/' + MODAL_STACK_MAX_DEPTH;
131
+ } else {
132
+ chip.style.display = 'none';
133
+ }
134
+ }
95
135
  }
96
- function clearModalBackStack() {
97
- _modalBackStack = [];
98
- var btn = document.getElementById('modal-back-btn');
99
- if (btn) btn.style.display = 'none';
136
+ function _buildModalHash() {
137
+ var entries = [];
138
+ for (var i = 0; i < _modalStack.length; i++) {
139
+ var f = _modalStack[i];
140
+ if (f && f.type && f.id && String(f.type).charAt(0) !== '_') {
141
+ entries.push(encodeURIComponent(f.type) + ':' + encodeURIComponent(f.id));
142
+ }
143
+ }
144
+ if (_currentVisibleArtifact && _currentVisibleArtifact.type && _currentVisibleArtifact.id) {
145
+ entries.push(
146
+ encodeURIComponent(_currentVisibleArtifact.type) + ':' +
147
+ encodeURIComponent(_currentVisibleArtifact.id)
148
+ );
149
+ }
150
+ var base = window.location.pathname + window.location.search;
151
+ if (entries.length === 0) return base;
152
+ // Cap URL to last MODAL_STACK_MAX_DEPTH entries (truncate oldest)
153
+ if (entries.length > MODAL_STACK_MAX_DEPTH) entries = entries.slice(-MODAL_STACK_MAX_DEPTH);
154
+ // P-3ed68b1e — frame separator is ',' (not '>'); '>' is in the WHATWG URL
155
+ // fragment percent-encode set and Chromium rewrites it to '%3E' on
156
+ // history.pushState, which broke deep-link replay (split('>') saw zero
157
+ // separators). ',' survives history serialisation intact, and since type
158
+ // + id are individually encodeURIComponent'd (',' itself encodes to
159
+ // '%2C'), no frame field can collide with the separator.
160
+ return base + '#modal=' + entries.join(',');
161
+ }
162
+ function _serializeStackForHistory() {
163
+ return {
164
+ breadcrumbs: _modalStack.map(function(f) { return { type: f.type, id: f.id }; }),
165
+ current: _currentVisibleArtifact
166
+ ? { type: _currentVisibleArtifact.type, id: _currentVisibleArtifact.id }
167
+ : null,
168
+ };
169
+ }
170
+ function _pushModalHistoryState() {
171
+ if (_suppressHistoryFromPopstate) return;
172
+ try {
173
+ var state = { page: (typeof currentPage !== 'undefined' ? currentPage : null), modal: _serializeStackForHistory() };
174
+ history.pushState(state, '', _buildModalHash());
175
+ } catch { /* history disabled (e.g. file://) — fall back to in-memory stack */ }
176
+ }
177
+
178
+ function pushModalFrame(frame) {
179
+ // frame: { type, id, openFn?, openArgs? } — describes the NEW visible modal.
180
+ // The current visible (if any) is snapshotted and pushed onto _modalStack as
181
+ // a breadcrumb; scrollY is captured here so popping restores the user's
182
+ // place in the parent view.
183
+ if (_currentVisibleArtifact) {
184
+ var snap = _captureModalSnapshot();
185
+ _modalStack.push({
186
+ type: _currentVisibleArtifact.type,
187
+ id: _currentVisibleArtifact.id,
188
+ titleHtml: snap.titleHtml,
189
+ bodyHtml: snap.bodyHtml,
190
+ scrollY: snap.scrollY,
191
+ openFn: _currentVisibleArtifact.openFn || null,
192
+ openArgs: _currentVisibleArtifact.openArgs || null,
193
+ });
194
+ } else if (_modalIsOpen()) {
195
+ // Modal was direct-opened (row click) without going through openArtifact.
196
+ // Snapshot it as an opaque _direct breadcrumb so Back still restores it.
197
+ var snap2 = _captureModalSnapshot();
198
+ _modalStack.push({
199
+ type: '_direct',
200
+ id: '_direct_' + (++_legacyFrameSeq),
201
+ titleHtml: snap2.titleHtml,
202
+ bodyHtml: snap2.bodyHtml,
203
+ scrollY: snap2.scrollY,
204
+ openFn: null,
205
+ openArgs: null,
206
+ });
207
+ }
208
+ _currentVisibleArtifact = {
209
+ type: frame.type,
210
+ id: frame.id,
211
+ openFn: typeof frame.openFn === 'function' ? frame.openFn : null,
212
+ openArgs: Array.isArray(frame.openArgs) ? frame.openArgs : null,
213
+ };
214
+ _capModalStackDepth();
215
+ _updateModalChrome();
216
+ _pushModalHistoryState();
217
+ }
218
+
219
+ function popModalFrame() {
220
+ // Esc / X / back-btn / browser Back all funnel here. Use history.back() so
221
+ // the popstate listener is the single source of truth for stack diffing.
222
+ if (_modalStackDepth() === 0) {
223
+ if (_modalIsOpen()) _physicallyCloseModal();
224
+ return;
225
+ }
226
+ try { history.back(); }
227
+ catch { _popOneFrameInternal(); _updateModalChrome(); }
228
+ }
229
+
230
+ function peekTopFrame() {
231
+ if (_currentVisibleArtifact) {
232
+ return Object.assign({}, _currentVisibleArtifact);
233
+ }
234
+ if (_modalStack.length > 0) {
235
+ return Object.assign({}, _modalStack[_modalStack.length - 1]);
236
+ }
237
+ return null;
238
+ }
239
+
240
+ function resetModalStack() {
241
+ _modalStack = [];
242
+ _currentVisibleArtifact = null;
243
+ _updateModalChrome();
244
+ }
245
+
246
+ function withTopFrame(type, id, fn) {
247
+ // Guard for per-modal poll bodies: only invoke `fn` when the named frame is
248
+ // still the visible top, so a stale poll on a lower frame can't clobber the
249
+ // user's currently-stacked modal. When no stack is tracked (direct-opened
250
+ // modal — _currentVisibleArtifact is null and _modalStack is empty) the
251
+ // poll proceeds unconditionally; row-click openers haven't routed through
252
+ // openArtifact, so we have no identity to check.
253
+ var top = peekTopFrame();
254
+ if (top && (top.type !== type || String(top.id) !== String(id))) return false;
255
+ try { fn(); } catch (e) { try { console.error(e); } catch {} }
256
+ return true;
257
+ }
258
+
259
+ function _restoreFrameToVisible(frame) {
260
+ // Synchronously restore a snapshotted frame as the visible modal layer.
261
+ // scrollY is restored after a double-rAF so layout settles first.
262
+ var titleEl = document.getElementById('modal-title');
263
+ var bodyEl = document.getElementById('modal-body');
264
+ if (titleEl) {
265
+ // eslint-disable-next-line no-unsanitized/property -- reason: snapshot of previously-rendered (already-escaped) modal title HTML; round-trip restore only
266
+ titleEl.innerHTML = frame.titleHtml || '';
267
+ }
268
+ if (bodyEl) {
269
+ // eslint-disable-next-line no-unsanitized/property -- reason: snapshot of previously-rendered (already-escaped) modal body HTML; round-trip restore only
270
+ bodyEl.innerHTML = frame.bodyHtml || '';
271
+ }
272
+ var modalEl = document.getElementById('modal');
273
+ if (modalEl && !modalEl.classList.contains('open')) modalEl.classList.add('open');
274
+ requestAnimationFrame(function() {
275
+ requestAnimationFrame(function() {
276
+ var b = document.getElementById('modal-body');
277
+ if (b) b.scrollTop = frame.scrollY || 0;
278
+ });
279
+ });
280
+ }
281
+
282
+ function _popOneFrameInternal() {
283
+ // Pop one breadcrumb and restore it as the visible modal. Used by the
284
+ // popstate handler (and as a fallback when history.back is unavailable).
285
+ if (_modalStack.length === 0) {
286
+ // Nothing to fall back to — close the modal entirely.
287
+ _currentVisibleArtifact = null;
288
+ if (_modalIsOpen()) _physicallyCloseModal();
289
+ return;
290
+ }
291
+ var frame = _modalStack.pop();
292
+ _currentVisibleArtifact = {
293
+ type: frame.type,
294
+ id: frame.id,
295
+ openFn: frame.openFn,
296
+ openArgs: frame.openArgs,
297
+ };
298
+ _restoreFrameToVisible(frame);
299
+ }
300
+
301
+ function _physicallyCloseModal() {
302
+ // Close the modal DOM without going through closeModal() (which would itself
303
+ // call popModalFrame and recurse). Mirrors the visible side-effects of
304
+ // closeModal but skips stack management.
305
+ var modalEl = document.getElementById('modal');
306
+ if (modalEl) modalEl.classList.remove('open');
307
+ var inner = document.querySelector('#modal .modal');
308
+ if (inner) inner.classList.remove('modal-wide');
309
+ }
310
+
311
+ // ─── Deprecation shims (legacy back-stack API) ───────────────────────────────
312
+ // Existing callers in render-work-items.js and render-pipelines.js still use
313
+ // pushModalBack(closure); on Back the closure re-renders the prior modal.
314
+ // We translate this into a breadcrumb push that carries the snapshot of the
315
+ // outgoing modal, so the new popstate-driven Back returns the same view.
316
+ function pushModalBack(reopenFn) {
317
+ var snap = _captureModalSnapshot();
318
+ _modalStack.push({
319
+ type: '_legacy',
320
+ id: '_legacy_' + (++_legacyFrameSeq),
321
+ titleHtml: snap.titleHtml,
322
+ bodyHtml: snap.bodyHtml,
323
+ scrollY: snap.scrollY,
324
+ openFn: typeof reopenFn === 'function' ? reopenFn : null,
325
+ openArgs: null,
326
+ });
327
+ _capModalStackDepth();
328
+ _updateModalChrome();
329
+ _pushModalHistoryState();
100
330
  }
331
+ function modalGoBack() { popModalFrame(); }
332
+ function clearModalBackStack() { resetModalStack(); }
101
333
 
102
334
  function updateModalPinBtn() {
103
335
  var btn = document.getElementById('modal-pin-btn');