@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.
- package/dashboard/js/modal-qa.js +21 -3
- package/dashboard/js/modal.js +35 -1
- package/dashboard/js/refresh.js +1 -1
- package/dashboard/js/render-agents.js +18 -0
- package/dashboard/js/render-kb.js +27 -2
- package/dashboard/js/render-meetings.js +36 -3
- package/dashboard/js/render-pipelines.js +15 -10
- package/dashboard/js/render-plans.js +73 -3
- package/dashboard/js/render-prd.js +29 -5
- package/dashboard/js/render-prs.js +174 -3
- package/dashboard/js/render-schedules.js +12 -0
- package/dashboard/js/render-utils.js +151 -1
- package/dashboard/js/render-watches.js +61 -7
- package/dashboard/js/render-work-items.js +161 -20
- package/dashboard/js/state.js +110 -1
- package/dashboard/js/utils.js +246 -14
- package/dashboard/layout.html +2 -0
- package/dashboard/slim/body.html +23 -13
- package/dashboard/slim/js/knowledge.js +576 -0
- package/dashboard/slim/js/members.js +43 -0
- package/dashboard/slim/js/modals-tiles.js +6 -4
- package/dashboard/slim/js/pinned.js +7 -19
- package/dashboard/slim/js/status.js +17 -21
- package/dashboard/slim/styles.css +94 -36
- package/dashboard/styles.css +34 -0
- package/dashboard-build.js +1 -1
- package/dashboard.js +50 -2
- package/engine/dispatch.js +39 -4
- package/engine/lifecycle.js +27 -7
- package/engine/pipeline.js +10 -0
- package/engine/queries.js +81 -0
- package/engine/scheduler.js +19 -1
- package/engine/shared.js +35 -13
- package/engine.js +4 -6
- package/package.json +1 -1
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
if (arts.
|
|
786
|
-
if (arts.
|
|
787
|
-
|
|
788
|
-
|
|
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
|
|
794
|
-
var
|
|
795
|
-
|
|
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 +=
|
|
881
|
+
artPills += renderArtifactLink({ type: 'kb', id: kbBody, label: kbLabel, title: 'KB: ' + kbFile }) + ' ';
|
|
798
882
|
} else if (noteFile.startsWith('archive:')) {
|
|
799
|
-
var
|
|
800
|
-
|
|
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 +=
|
|
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
|
-
//
|
|
860
|
-
|
|
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) {
|
|
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
|
}
|
package/dashboard/js/state.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dashboard/js/utils.js
CHANGED
|
@@ -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 —
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
91
|
-
var
|
|
122
|
+
function _updateModalChrome() {
|
|
123
|
+
var depth = _modalStackDepth();
|
|
92
124
|
var btn = document.getElementById('modal-back-btn');
|
|
93
|
-
if (btn
|
|
94
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
var
|
|
99
|
-
|
|
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');
|