@yemi33/minions 0.1.2454 → 0.1.2455
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/bin/install-internal-minions.js +136 -6
- package/bin/minions.js +27 -13
- package/dashboard/js/agent-identity.js +121 -0
- package/dashboard/js/modal.js +4 -0
- package/dashboard/js/refresh.js +38 -3
- package/dashboard/js/render-agents.js +14 -2
- package/dashboard/js/render-dispatch.js +2 -2
- package/dashboard/js/render-other.js +147 -13
- package/dashboard/js/render-prd.js +6 -5
- package/dashboard/js/render-prs.js +1 -1
- package/dashboard/js/render-work-items.js +436 -21
- package/dashboard/js/utils.js +40 -0
- package/dashboard/pages/tools.html +1 -0
- package/dashboard/pages/work.html +13 -0
- package/dashboard/styles.css +73 -0
- package/dashboard-build.js +2 -2
- package/dashboard.js +93 -7
- package/docs/README.md +1 -0
- package/docs/copilot-cli-schema.md +1 -0
- package/docs/engine-restart.md +4 -2
- package/docs/internal-install.md +44 -5
- package/docs/named-agents.md +48 -0
- package/docs/runtime-adapters.md +39 -0
- package/docs/temporary-agents.md +172 -0
- package/engine/ado/comment.js +261 -4
- package/engine/agents/llm.js +26 -0
- package/engine/agents/playbook.js +2 -1
- package/engine/core/queries.js +27 -2
- package/engine/core/shared.js +148 -1
- package/engine/operations/cli.js +102 -1
- package/engine/orchestration/lifecycle.js +7 -0
- package/engine/orchestration/routing.js +4 -1
- package/engine/providers/gh-comment.js +159 -0
- package/engine/recovery/stop-stack.js +16 -2
- package/engine/runtimes/claude.js +3 -0
- package/engine/runtimes/codex.js +4 -0
- package/engine/runtimes/copilot.js +23 -2
- package/engine.js +2 -2
- package/package.json +1 -1
- package/playbooks/fix.md +23 -2
- package/playbooks/shared-rules.md +24 -2
|
@@ -6,6 +6,66 @@ let wiPage = 0;
|
|
|
6
6
|
let wiFilterOptionsSignature = '';
|
|
7
7
|
const WI_PER_PAGE = 20;
|
|
8
8
|
|
|
9
|
+
// ── Bulk-operations mode (W-mscjvm33007r79ef) ─────────────────────────────
|
|
10
|
+
// OFF by default: the normal Work Items UI shows no selection affordances and
|
|
11
|
+
// cannot multi-select. An explicit "Bulk select" control flips wiBulkMode on,
|
|
12
|
+
// which renders per-row checkboxes + a bulk action bar. wiSelectedIds tracks
|
|
13
|
+
// the selection by work-item id so it stays coherent across the 4s refresh
|
|
14
|
+
// poll, filtering, paging, and item state changes — bulk actions resolve ids
|
|
15
|
+
// to LIVE items at apply time and skip any that vanished (never act on stale
|
|
16
|
+
// or nonexistent items).
|
|
17
|
+
let wiBulkMode = false;
|
|
18
|
+
const wiSelectedIds = new Set();
|
|
19
|
+
|
|
20
|
+
// Bulk actions reuse the EXISTING single-item POST endpoints and their exact
|
|
21
|
+
// availability rules, so bulk never broadens or invents an action. `statuses:
|
|
22
|
+
// null` means "any status" (Delete). Each spec drives eligibility, the
|
|
23
|
+
// endpoint, the confirm gate, and whether the row is optimistically removed.
|
|
24
|
+
//
|
|
25
|
+
// `destructive` IS the bulk confirmation policy (it is the only gate on the
|
|
26
|
+
// single confirm() in wiBulkAction), so it must mirror whether the single-item
|
|
27
|
+
// counterpart confirms: cancelWorkItem/deleteWorkItem confirm, so bulk Cancel
|
|
28
|
+
// and Delete confirm too. A bulk action multiplies the blast radius, so it can
|
|
29
|
+
// never be LESS guarded than acting on one item. Cancel is destructive because
|
|
30
|
+
// the server kills the running agent (handleWorkItemsCancel) and there is no
|
|
31
|
+
// undo — retry only accepts `failed`, so a cancelled `dispatched` item cannot
|
|
32
|
+
// be restored. Parity is enforced by test/unit/dashboard-work-items-bulk-actions.test.js.
|
|
33
|
+
const WI_BULK_ACTIONS = {
|
|
34
|
+
cancel: { label: 'Cancel', endpoint: '/api/work-items/cancel', statuses: ['pending', 'dispatched', 'queued', 'failed'], destructive: true, optimisticRemove: true, wake: false },
|
|
35
|
+
archive: { label: 'Archive', endpoint: '/api/work-items/archive', statuses: ['done', 'failed'], destructive: false, optimisticRemove: true, wake: false },
|
|
36
|
+
retry: { label: 'Retry', endpoint: '/api/work-items/retry', statuses: ['failed'], destructive: false, optimisticRemove: false, wake: true },
|
|
37
|
+
delete: { label: 'Delete', endpoint: '/api/work-items/delete', statuses: null, destructive: true, optimisticRemove: true, wake: false },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Pure eligibility predicate — mirrors the per-item button visibility in
|
|
41
|
+
// wiRow() so a bulk action is only offered for the same statuses. Exported for
|
|
42
|
+
// unit testing (no DOM deps).
|
|
43
|
+
function wiActionEligible(item, action) {
|
|
44
|
+
const spec = WI_BULK_ACTIONS[action];
|
|
45
|
+
if (!spec || !item) return false;
|
|
46
|
+
if (spec.statuses === null) return true;
|
|
47
|
+
return spec.statuses.indexOf(item.status || 'pending') !== -1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Pure planner — resolves a selection (ids) against the LIVE item list and
|
|
51
|
+
// splits it into { eligible, ineligible, missing } for a given action. This is
|
|
52
|
+
// the anti-stale core: an id no longer present in `items` lands in `missing`
|
|
53
|
+
// and is never acted on. Exported for unit testing.
|
|
54
|
+
function wiBulkPlan(items, selectedIds, action) {
|
|
55
|
+
const idSet = selectedIds instanceof Set ? selectedIds : new Set(selectedIds || []);
|
|
56
|
+
const byId = new Map((items || []).map(function(i) { return [i.id, i]; }));
|
|
57
|
+
const eligible = [];
|
|
58
|
+
const ineligible = [];
|
|
59
|
+
const missing = [];
|
|
60
|
+
idSet.forEach(function(id) {
|
|
61
|
+
const it = byId.get(id);
|
|
62
|
+
if (!it) { missing.push(id); return; }
|
|
63
|
+
if (wiActionEligible(it, action)) eligible.push(it);
|
|
64
|
+
else ineligible.push(it);
|
|
65
|
+
});
|
|
66
|
+
return { eligible: eligible, ineligible: ineligible, missing: missing };
|
|
67
|
+
}
|
|
68
|
+
|
|
9
69
|
// Filter + sort controls modeled on the PR table (dashboard/js/render-prs.js).
|
|
10
70
|
// Each dimension maps to a <select> in dashboard/pages/work.html; options are
|
|
11
71
|
// populated dynamically from the current data set with a signature guard so the
|
|
@@ -184,7 +244,7 @@ function wiRetryBtn(item) {
|
|
|
184
244
|
return '<button class="pr-pager-btn" style="font-size:var(--text-sm);padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-left:4px" onclick="event.stopPropagation();retryWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')">Retry</button>';
|
|
185
245
|
}
|
|
186
246
|
|
|
187
|
-
function wiRow(item) {
|
|
247
|
+
function wiRow(item, bulk) {
|
|
188
248
|
const statusBadge = (s) => {
|
|
189
249
|
const cls = s === 'failed' ? 'rejected' : s === 'dispatched' ? 'building' : s === 'pending' || s === 'queued' ? 'active' : s === 'done' ? 'approved' : s === 'decomposed' ? 'approved' : 'draft';
|
|
190
250
|
return '<span class="pr-badge ' + cls + '">' + escapeHtml(s) + '</span>';
|
|
@@ -217,7 +277,15 @@ function wiRow(item) {
|
|
|
217
277
|
: '';
|
|
218
278
|
followupChip = ' <a class="pr-badge draft" style="font-size:var(--text-sm);text-decoration:none" target="_blank" rel="noopener" href="' + escapeHtml(prFollowup.parent_pr_url) + '" title="Follow-up dispatched from ' + escapeHtml(prRef) + (followupBy ? ' by ' + escapeHtml(followupBy) : '') + '" onclick="event.stopPropagation()">↩ from ' + escapeHtml(prLabel) + '</a>';
|
|
219
279
|
}
|
|
220
|
-
|
|
280
|
+
var _wiSelected = bulk && wiSelectedIds.has(item.id);
|
|
281
|
+
var bulkCell = bulk
|
|
282
|
+
? '<td class="wi-bulk-cell"><input type="checkbox" class="wi-bulk-cb" data-wi-id="' + escapeHtml(item.id) + '" data-wi-source="' + escapeHtml(item._source || '') + '" aria-label="Select ' + escapeHtml((item.title || item.id || '').slice(0, 80)) + '"' + (_wiSelected ? ' checked' : '') + ' onclick="event.stopPropagation();wiToggleSelect(this)"></td>'
|
|
283
|
+
: '';
|
|
284
|
+
// The click handler receives the SAME `bulk` flag the row was rendered with
|
|
285
|
+
// (see wiRowClick) — a row drawn without a checkbox must never toggle a
|
|
286
|
+
// selection the operator cannot see.
|
|
287
|
+
return '<tr data-wi-id="' + escapeHtml(item.id) + '"' + (_wiSelected ? ' class="wi-row-selected"' : '') + ' style="cursor:pointer" onclick="wiRowClick(event,\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\',' + (bulk ? 'true' : 'false') + ')">' +
|
|
288
|
+
bulkCell +
|
|
221
289
|
'<td style="min-width:280px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escapeHtml((item.title || '').slice(0, 200)) + '">' + escapeHtml(item.title || '') + followupChip + '</td>' +
|
|
222
290
|
'<td><span style="font-size:var(--text-sm);color:var(--muted)">' + escapeHtml(item._source || '') + '</span>' +
|
|
223
291
|
(item.scope === 'fan-out' ? ' <span class="pr-badge ' + (item.status === 'done' || item.status === 'failed' ? 'draft' : 'building') + '" style="font-size:var(--text-sm)">fan-out</span>' : '') + '</td>' +
|
|
@@ -250,8 +318,8 @@ function wiRow(item) {
|
|
|
250
318
|
'</td>' +
|
|
251
319
|
'<td>' +
|
|
252
320
|
(item.completedAgents && item.completedAgents.length > 0
|
|
253
|
-
? '<span class="pr-agent">' +
|
|
254
|
-
: '<span class="pr-agent">' +
|
|
321
|
+
? '<span class="pr-agent">' + item.completedAgents.map(function(a) { return MinionsAgentIdentity.nameWithBadgeHtml({ id: a }); }).join(', ') + '</span>'
|
|
322
|
+
: '<span class="pr-agent">' + ((item.dispatched_to || item.agent) ? MinionsAgentIdentity.nameWithBadgeHtml({ id: item.dispatched_to || item.agent }) : '—') + '</span>') +
|
|
255
323
|
'</td>' +
|
|
256
324
|
'<td>' + prLink + (failSnippet ? failSnippet : '') + '</td>' +
|
|
257
325
|
'<td><span class="pr-date">' + escapeHtml((item.created || '').slice(0, 16).replace('T', ' ')) + '</span></td>' +
|
|
@@ -272,15 +340,18 @@ function wiRow(item) {
|
|
|
272
340
|
(acCount ? '<span title="' + acCount + ' acceptance criteria">☑' + acCount + '</span>' : '');
|
|
273
341
|
})() +
|
|
274
342
|
'</td>' +
|
|
275
|
-
'<td style="white-space:nowrap;text-align:right">' +
|
|
276
|
-
// W-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
(
|
|
343
|
+
'<td class="wi-actions-cell" style="white-space:nowrap;text-align:right">' +
|
|
344
|
+
// W-mscjvm33007r79ef — every per-item action EXCEPT Delete now lives in an
|
|
345
|
+
// overflow menu (wiOpenOverflowMenu), built on demand from the live item so
|
|
346
|
+
// it always reflects current availability/disabled states. The ⋯ button is
|
|
347
|
+
// omitted when the item has no eligible menu action (see
|
|
348
|
+
// _wiOverflowActionsFor), so an actionless item shows only Delete.
|
|
349
|
+
// The "feedback given" indicator is informational (not an action) and is
|
|
350
|
+
// preserved inline so operators still see prior feedback at a glance.
|
|
351
|
+
(item._humanFeedback ? '<span style="font-size:var(--text-sm);margin-right:4px" title="Feedback given">' + (item._humanFeedback.rating === 'up' ? '👍' : '👎') + '</span>' : '') +
|
|
352
|
+
(_wiOverflowActionsFor(item).length
|
|
353
|
+
? '<button class="wi-overflow-btn pr-pager-btn" style="font-size:var(--text-sm);padding:1px 8px;margin-right:4px" data-wi-id="' + escapeHtml(item.id) + '" data-wi-source="' + escapeHtml(item._source || '') + '" aria-haspopup="menu" aria-expanded="false" aria-label="More actions for ' + escapeHtml((item.title || item.id || '').slice(0, 80)) + '" title="More actions" onclick="event.stopPropagation();wiOpenOverflowMenu(this)">⋯</button>'
|
|
354
|
+
: '') +
|
|
284
355
|
'<button class="btn-destructive" onclick="event.stopPropagation();deleteWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Delete work item and kill agent">✕</button>' +
|
|
285
356
|
'</td>' +
|
|
286
357
|
'</tr>';
|
|
@@ -432,6 +503,9 @@ function renderWorkItems(items, opts) {
|
|
|
432
503
|
const view = computed.view;
|
|
433
504
|
filteredWorkItems = view;
|
|
434
505
|
const el = document.getElementById('work-items-content');
|
|
506
|
+
// Every return path below replaces el.innerHTML, so drop any overflow menu
|
|
507
|
+
// anchored inside it first (see _wiCloseOverflowMenuIn).
|
|
508
|
+
_wiCloseOverflowMenuIn(el);
|
|
435
509
|
const countEl = document.getElementById('wi-count');
|
|
436
510
|
const summaryEl = document.getElementById('wi-filter-summary');
|
|
437
511
|
if (countEl) countEl.textContent = view.length;
|
|
@@ -442,12 +516,15 @@ function renderWorkItems(items, opts) {
|
|
|
442
516
|
}
|
|
443
517
|
if (!allWorkItems.length) {
|
|
444
518
|
wiPage = 0;
|
|
519
|
+
if (wiSelectedIds.size) wiSelectedIds.clear();
|
|
445
520
|
el.innerHTML = '<p class="empty">No work items. Add tasks via Command Center above.</p>';
|
|
521
|
+
_wiSyncBulkUI();
|
|
446
522
|
return;
|
|
447
523
|
}
|
|
448
524
|
if (!view.length) {
|
|
449
525
|
wiPage = 0;
|
|
450
526
|
el.innerHTML = '<p class="empty">No work items match the selected filters.</p>';
|
|
527
|
+
_wiSyncBulkUI();
|
|
451
528
|
return;
|
|
452
529
|
}
|
|
453
530
|
|
|
@@ -457,8 +534,21 @@ function renderWorkItems(items, opts) {
|
|
|
457
534
|
const start = wiPage * WI_PER_PAGE;
|
|
458
535
|
const pageItems = view.slice(start, start + WI_PER_PAGE);
|
|
459
536
|
|
|
460
|
-
|
|
461
|
-
|
|
537
|
+
// Keep the bulk selection coherent as items appear/disappear across refreshes,
|
|
538
|
+
// filtering, and paging: drop any selected id that no longer exists in the
|
|
539
|
+
// full list. Selection is by id (not row index), so it survives re-sorts and
|
|
540
|
+
// page changes; bulk actions still re-resolve to live items at apply time.
|
|
541
|
+
if (wiSelectedIds.size) {
|
|
542
|
+
const liveIds = new Set(allWorkItems.map(function(w) { return w.id; }));
|
|
543
|
+
wiSelectedIds.forEach(function(id) { if (!liveIds.has(id)) wiSelectedIds.delete(id); });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const bulkHeadCell = wiBulkMode ? '<th class="wi-bulk-cell"></th>' : '';
|
|
547
|
+
let html = '<div class="pr-table-wrap"><table class="pr-table"><thead><tr>' + bulkHeadCell + '<th>Title</th><th>Project</th><th>Type</th><th>Priority</th><th>Status</th><th>Agent</th><th>PR</th><th>Created</th><th>Updated</th><th></th><th></th></tr></thead><tbody>';
|
|
548
|
+
// Wrap so wiRow gets (item, wiBulkMode) — NOT (item, index, array) from a bare
|
|
549
|
+
// .map(wiRow), which would pass a truthy index as `bulk` for every row after
|
|
550
|
+
// the first and render stray checkboxes.
|
|
551
|
+
html += pageItems.map(function(it) { return wiRow(it, wiBulkMode); }).join('');
|
|
462
552
|
html += '</tbody></table></div>';
|
|
463
553
|
|
|
464
554
|
if (view.length > WI_PER_PAGE) {
|
|
@@ -478,6 +568,7 @@ function renderWorkItems(items, opts) {
|
|
|
478
568
|
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() by wiRow() (fields: work item id/title/source/status/agent/PR links/dates/follow-up metadata)
|
|
479
569
|
el.innerHTML = html;
|
|
480
570
|
restoreDashboardScrollState(el, scrollState);
|
|
571
|
+
_wiSyncBulkUI();
|
|
481
572
|
}
|
|
482
573
|
|
|
483
574
|
async function editWorkItem(id, source) {
|
|
@@ -686,7 +777,7 @@ async function toggleWorkItemArchive() {
|
|
|
686
777
|
'<td style="min-width:240px;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(i.title || '') + '</td>' +
|
|
687
778
|
'<td><span class="dispatch-type ' + (i.type || '') + '">' + escapeHtml(i.type || '') + '</span></td>' +
|
|
688
779
|
'<td style="color:' + (i.status === 'done' ? 'var(--green)' : 'var(--red)') + '">' + escapeHtml(i.status || '') + '</td>' +
|
|
689
|
-
'<td>' +
|
|
780
|
+
'<td>' + (i.dispatched_to ? MinionsAgentIdentity.nameWithBadgeHtml({ id: i.dispatched_to }) : '—') + '</td>' +
|
|
690
781
|
'<td class="pr-date">' + shortTime(i.archivedAt) + '</td>' +
|
|
691
782
|
'</tr>';
|
|
692
783
|
}).join('') + '</tbody></table></div>';
|
|
@@ -977,7 +1068,7 @@ function _wiRenderDetail(item) {
|
|
|
977
1068
|
_descHtml = escapeHtml(item.title || '—');
|
|
978
1069
|
}
|
|
979
1070
|
html += field('Description', '<div id="wi-detail-desc" style="font-size:var(--text-md);max-height:320px;overflow-y:auto;padding:8px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm)">' + _descHtml + '</div>');
|
|
980
|
-
html += field('Agent',
|
|
1071
|
+
html += field('Agent', (item.dispatched_to || item.agent) ? MinionsAgentIdentity.nameWithBadgeHtml({ id: item.dispatched_to || item.agent }) : escapeHtml('Auto'));
|
|
981
1072
|
// W-ms0ytfdd000d5a26 — the model this work item actually ran on. Resolved
|
|
982
1073
|
// server-side (engine/core/queries.js#resolveWorkItemModelInfo) so persisted
|
|
983
1074
|
// dispatch evidence — what the runtime reported, else what Minions requested
|
|
@@ -1003,7 +1094,7 @@ function _wiRenderDetail(item) {
|
|
|
1003
1094
|
html += field('Working directory', '<code style="font-size:var(--text-sm);background:var(--surface2);padding:2px 6px;border-radius:var(--radius-sm)">' + escapeHtml(item.meta.workdir) + '</code>');
|
|
1004
1095
|
}
|
|
1005
1096
|
if (item.created) html += field('Created', escapeHtml(formatLocalDateTime(item.created)));
|
|
1006
|
-
if (item.dispatched_at) html += field('Dispatched', escapeHtml(formatLocalDateTime(item.dispatched_at)) + ' to ' +
|
|
1097
|
+
if (item.dispatched_at) html += field('Dispatched', escapeHtml(formatLocalDateTime(item.dispatched_at)) + ' to ' + (item.dispatched_to ? MinionsAgentIdentity.nameWithBadgeHtml({ id: item.dispatched_to }) : escapeHtml('?')));
|
|
1007
1098
|
if (item.completedAt) html += field('Completed', escapeHtml(formatLocalDateTime(item.completedAt)));
|
|
1008
1099
|
if (item.failReason) html += field('Failure Reason', '<span style="color:var(--red)">' + escapeHtml(item.failReason) + '</span>');
|
|
1009
1100
|
// W-mrtdet41000h935a — surface the cancellation reason for CANCELLED items.
|
|
@@ -1294,9 +1385,13 @@ function openWorkItemDetail(id, source) {
|
|
|
1294
1385
|
}
|
|
1295
1386
|
|
|
1296
1387
|
function openAllWorkItems() {
|
|
1388
|
+
// Re-opening the modal replaces its rows; defence-in-depth so the menu can
|
|
1389
|
+
// never outlive its anchor even when the outside-click dismissal is bypassed
|
|
1390
|
+
// (e.g. a programmatic open).
|
|
1391
|
+
_wiCloseOverflowMenuIn(document.getElementById('modal-body'));
|
|
1297
1392
|
document.getElementById('modal-title').textContent = 'All Work Items (' + filteredWorkItems.length + ')';
|
|
1298
1393
|
const html = '<div class="pr-table-wrap"><table class="pr-table"><thead><tr><th>Title</th><th>Project</th><th>Type</th><th>Priority</th><th>Status</th><th>Agent</th><th>PR</th><th>Created</th><th>Updated</th><th></th><th></th></tr></thead><tbody>' +
|
|
1299
|
-
filteredWorkItems.map(wiRow).join('') + '</tbody></table></div>';
|
|
1394
|
+
filteredWorkItems.map(function(it) { return wiRow(it, false); }).join('') + '</tbody></table></div>';
|
|
1300
1395
|
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() by wiRow() (fields: work item id/title/source/status/agent/PR links/dates/follow-up metadata)
|
|
1301
1396
|
document.getElementById('modal-body').innerHTML = html;
|
|
1302
1397
|
document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
|
|
@@ -1422,10 +1517,330 @@ function _wiInjectSourceChipFromFrontmatter(content, containerId) {
|
|
|
1422
1517
|
bodyEl.insertAdjacentHTML('afterbegin', sourceBlock);
|
|
1423
1518
|
}
|
|
1424
1519
|
|
|
1520
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1521
|
+
// Overflow (⋯) menu + bulk-operations mode — W-mscjvm33007r79ef
|
|
1522
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1523
|
+
|
|
1524
|
+
// Actions offered in a row's overflow menu, derived from the LIVE item so the
|
|
1525
|
+
// menu always matches current status. Each entry invokes the existing per-item
|
|
1526
|
+
// action fn, preserving its confirmation/optimistic/toast/error behavior. Used
|
|
1527
|
+
// both to build the menu and (via .length) to decide whether to render the ⋯
|
|
1528
|
+
// button at all. Also drives the source-inspection guarantee that Delete is NOT
|
|
1529
|
+
// in here (Delete stays a directly-visible button).
|
|
1530
|
+
function _wiOverflowActionsFor(item) {
|
|
1531
|
+
if (!item) return [];
|
|
1532
|
+
var src = item._source || '';
|
|
1533
|
+
var st = item.status;
|
|
1534
|
+
var agentId = item.dispatched_to || item.agent;
|
|
1535
|
+
var out = [];
|
|
1536
|
+
if (st === 'dispatched' && agentId) {
|
|
1537
|
+
out.push({ label: 'View live output', fn: function() { openArtifact('agent', agentId); } });
|
|
1538
|
+
}
|
|
1539
|
+
if (st === 'pending' || st === 'failed') {
|
|
1540
|
+
out.push({ label: 'Edit', fn: function() { editWorkItem(item.id, src); } });
|
|
1541
|
+
}
|
|
1542
|
+
if (st === 'done' || st === 'failed') {
|
|
1543
|
+
out.push({ label: 'Archive', fn: function() { archiveWorkItem(item.id, src); } });
|
|
1544
|
+
}
|
|
1545
|
+
if ((st === 'done' || st === 'failed') && !item._humanFeedback) {
|
|
1546
|
+
out.push({ label: 'Give feedback', fn: function() { feedbackWorkItem(item.id, src); } });
|
|
1547
|
+
}
|
|
1548
|
+
if (st === 'pending' || st === 'dispatched' || st === 'queued' || st === 'failed') {
|
|
1549
|
+
out.push({ label: 'Cancel', fn: function() { cancelWorkItem(item.id, src); } });
|
|
1550
|
+
}
|
|
1551
|
+
return out;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
let _wiOverflowEl = null;
|
|
1555
|
+
let _wiOverflowAnchor = null;
|
|
1556
|
+
|
|
1557
|
+
// Accessible popover built with pure DOM construction (createElement /
|
|
1558
|
+
// textContent — never innerHTML), mirroring _openCheckoutModeMenu in
|
|
1559
|
+
// render-other.js so it sidesteps the XSS lint gate. role=menu + menuitem,
|
|
1560
|
+
// arrow-key navigation, Escape/outside-click dismissal, focus management.
|
|
1561
|
+
function wiOpenOverflowMenu(btn) {
|
|
1562
|
+
if (_wiOverflowAnchor === btn) { wiCloseOverflowMenu(true); return; }
|
|
1563
|
+
wiCloseOverflowMenu(false);
|
|
1564
|
+
var id = btn.getAttribute('data-wi-id');
|
|
1565
|
+
var src = btn.getAttribute('data-wi-source') || '';
|
|
1566
|
+
var item = allWorkItems.find(function(w) { return w.id === id && (!src || (w._source || '') === src); });
|
|
1567
|
+
var actions = _wiOverflowActionsFor(item);
|
|
1568
|
+
if (!actions.length) return;
|
|
1569
|
+
var menu = document.createElement('div');
|
|
1570
|
+
menu.className = 'wi-overflow-menu';
|
|
1571
|
+
menu.setAttribute('role', 'menu');
|
|
1572
|
+
menu.setAttribute('aria-label', 'Work item actions');
|
|
1573
|
+
actions.forEach(function(a) {
|
|
1574
|
+
var mi = document.createElement('button');
|
|
1575
|
+
mi.type = 'button';
|
|
1576
|
+
mi.className = 'wi-overflow-item';
|
|
1577
|
+
mi.setAttribute('role', 'menuitem');
|
|
1578
|
+
mi.tabIndex = -1;
|
|
1579
|
+
mi.textContent = a.label;
|
|
1580
|
+
mi.addEventListener('click', function(ev) {
|
|
1581
|
+
ev.stopPropagation();
|
|
1582
|
+
wiCloseOverflowMenu(true);
|
|
1583
|
+
a.fn();
|
|
1584
|
+
});
|
|
1585
|
+
menu.appendChild(mi);
|
|
1586
|
+
});
|
|
1587
|
+
document.body.appendChild(menu);
|
|
1588
|
+
// Position after append so offsetWidth is known; right-align under the button.
|
|
1589
|
+
var r = btn.getBoundingClientRect();
|
|
1590
|
+
var top = r.bottom + window.scrollY + 2;
|
|
1591
|
+
var left = r.right + window.scrollX - menu.offsetWidth;
|
|
1592
|
+
if (left < 4) left = 4;
|
|
1593
|
+
menu.style.top = top + 'px';
|
|
1594
|
+
menu.style.left = left + 'px';
|
|
1595
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
1596
|
+
_wiOverflowEl = menu;
|
|
1597
|
+
_wiOverflowAnchor = btn;
|
|
1598
|
+
// Defer listener attach so the opening click doesn't immediately close it.
|
|
1599
|
+
setTimeout(function() {
|
|
1600
|
+
document.addEventListener('mousedown', _wiOverflowOutside, true);
|
|
1601
|
+
document.addEventListener('keydown', _wiOverflowKeydown, true);
|
|
1602
|
+
}, 0);
|
|
1603
|
+
var first = menu.querySelector('.wi-overflow-item');
|
|
1604
|
+
if (first) first.focus();
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function wiCloseOverflowMenu(returnFocus) {
|
|
1608
|
+
var anchor = _wiOverflowAnchor;
|
|
1609
|
+
if (_wiOverflowEl && _wiOverflowEl.parentNode) _wiOverflowEl.parentNode.removeChild(_wiOverflowEl);
|
|
1610
|
+
if (anchor) anchor.setAttribute('aria-expanded', 'false');
|
|
1611
|
+
_wiOverflowEl = null;
|
|
1612
|
+
_wiOverflowAnchor = null;
|
|
1613
|
+
document.removeEventListener('mousedown', _wiOverflowOutside, true);
|
|
1614
|
+
document.removeEventListener('keydown', _wiOverflowKeydown, true);
|
|
1615
|
+
if (returnFocus && anchor) { try { anchor.focus(); } catch (e) { /* anchor gone after re-render */ } }
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
function _wiOverflowOutside(e) {
|
|
1619
|
+
if (_wiOverflowEl && !_wiOverflowEl.contains(e.target) && e.target !== _wiOverflowAnchor) {
|
|
1620
|
+
wiCloseOverflowMenu(false);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// The menu is appended to document.body but is ANCHORED to a ⋯ button living
|
|
1625
|
+
// inside a container that gets re-rendered wholesale — the work-items table on
|
|
1626
|
+
// every non-304 /api/work-items poll, the all-items modal on every open.
|
|
1627
|
+
// Replacing that container's innerHTML destroys the anchor and strands the
|
|
1628
|
+
// menu, which keeps the action closures built from the item as it looked when
|
|
1629
|
+
// the menu opened: a row that has since flipped dispatched → done would still
|
|
1630
|
+
// offer Cancel, breaking the "menu always reflects current availability"
|
|
1631
|
+
// invariant. Close it BEFORE the swap, scoped to the container actually being
|
|
1632
|
+
// replaced so a re-render of one surface cannot dismiss a menu opened from the
|
|
1633
|
+
// other. Pass returnFocus=false — a background poll must not yank focus to an
|
|
1634
|
+
// anchor that is about to vanish.
|
|
1635
|
+
function _wiCloseOverflowMenuIn(container) {
|
|
1636
|
+
if (container && _wiOverflowAnchor && container.contains(_wiOverflowAnchor)) {
|
|
1637
|
+
wiCloseOverflowMenu(false);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// The modal is torn down by paths this module never sees — browser Back /
|
|
1642
|
+
// Alt+Left (popstate), sidebar navigation, a frame pop — and none of them fire
|
|
1643
|
+
// the mousedown/keydown dismissal above. Opt the menu into the shared modal
|
|
1644
|
+
// teardown so a menu opened from a row inside the "See all" modal can't outlive
|
|
1645
|
+
// it. Registering the SCOPED closer keeps a menu anchored outside the modal
|
|
1646
|
+
// unaffected. typeof-guarded: this module is also require()d by unit tests.
|
|
1647
|
+
if (typeof registerTransientPopupCloser === 'function') registerTransientPopupCloser(_wiCloseOverflowMenuIn);
|
|
1648
|
+
|
|
1649
|
+
function _wiOverflowKeydown(e) {
|
|
1650
|
+
if (!_wiOverflowEl) return;
|
|
1651
|
+
var items = Array.prototype.slice.call(_wiOverflowEl.querySelectorAll('.wi-overflow-item'));
|
|
1652
|
+
if (!items.length) return;
|
|
1653
|
+
var idx = items.indexOf(document.activeElement);
|
|
1654
|
+
if (e.key === 'Escape') {
|
|
1655
|
+
e.preventDefault();
|
|
1656
|
+
wiCloseOverflowMenu(true);
|
|
1657
|
+
} else if (e.key === 'ArrowDown') {
|
|
1658
|
+
e.preventDefault();
|
|
1659
|
+
items[(idx + 1) % items.length].focus();
|
|
1660
|
+
} else if (e.key === 'ArrowUp') {
|
|
1661
|
+
e.preventDefault();
|
|
1662
|
+
items[(idx - 1 + items.length) % items.length].focus();
|
|
1663
|
+
} else if (e.key === 'Home') {
|
|
1664
|
+
e.preventDefault();
|
|
1665
|
+
items[0].focus();
|
|
1666
|
+
} else if (e.key === 'End') {
|
|
1667
|
+
e.preventDefault();
|
|
1668
|
+
items[items.length - 1].focus();
|
|
1669
|
+
} else if (e.key === 'Tab') {
|
|
1670
|
+
wiCloseOverflowMenu(false);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// ── Row click + selection ─────────────────────────────────────────────────
|
|
1675
|
+
// A row behaves the way it was RENDERED: `bulk` is the per-render flag wiRow()
|
|
1676
|
+
// baked into the handler, NOT the module-global wiBulkMode. They diverge —
|
|
1677
|
+
// openAllWorkItems() renders wiRow(it, false) while bulk mode can be on — and
|
|
1678
|
+
// reading the global there let a click on a checkbox-less modal row silently
|
|
1679
|
+
// toggle wiSelectedIds: invisible state feeding the destructive bulk Delete,
|
|
1680
|
+
// with the detail view never opening. Fail closed: only an explicit bulk row
|
|
1681
|
+
// toggles selection; anything else opens the detail (preserving the
|
|
1682
|
+
// pre-existing text-selection guard).
|
|
1683
|
+
function wiRowClick(event, id, source, bulk) {
|
|
1684
|
+
if (shouldIgnoreSelectionClick(event)) return;
|
|
1685
|
+
if (bulk === true) { _wiToggleId(id); return; }
|
|
1686
|
+
openWorkItemDetail(id, source);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function _wiToggleId(id) {
|
|
1690
|
+
if (wiSelectedIds.has(id)) wiSelectedIds.delete(id);
|
|
1691
|
+
else wiSelectedIds.add(id);
|
|
1692
|
+
_wiReflectRowSelection(id);
|
|
1693
|
+
_wiSyncBulkUI();
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function wiToggleSelect(cb) {
|
|
1697
|
+
var id = cb.getAttribute('data-wi-id');
|
|
1698
|
+
if (!id) return;
|
|
1699
|
+
if (cb.checked) wiSelectedIds.add(id);
|
|
1700
|
+
else wiSelectedIds.delete(id);
|
|
1701
|
+
_wiReflectRowSelection(id);
|
|
1702
|
+
_wiSyncBulkUI();
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
function _wiReflectRowSelection(id) {
|
|
1706
|
+
var sel = wiSelectedIds.has(id);
|
|
1707
|
+
var content = document.getElementById('work-items-content');
|
|
1708
|
+
if (!content) return;
|
|
1709
|
+
var esc = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : String(id).replace(/"/g, '\\"');
|
|
1710
|
+
content.querySelectorAll('tr[data-wi-id="' + esc + '"]').forEach(function(row) {
|
|
1711
|
+
row.classList.toggle('wi-row-selected', sel);
|
|
1712
|
+
var cb = row.querySelector('.wi-bulk-cb');
|
|
1713
|
+
if (cb) cb.checked = sel;
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// ── Bulk mode enter/exit ──────────────────────────────────────────────────
|
|
1718
|
+
function wiToggleBulkMode() {
|
|
1719
|
+
wiBulkMode = !wiBulkMode;
|
|
1720
|
+
if (!wiBulkMode) wiSelectedIds.clear();
|
|
1721
|
+
wiCloseOverflowMenu(false);
|
|
1722
|
+
_wiSyncBulkToggleBtn();
|
|
1723
|
+
renderWorkItems(allWorkItems, { preserveScroll: true });
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function wiExitBulkMode() {
|
|
1727
|
+
if (!wiBulkMode) return;
|
|
1728
|
+
wiBulkMode = false;
|
|
1729
|
+
wiSelectedIds.clear();
|
|
1730
|
+
wiCloseOverflowMenu(false);
|
|
1731
|
+
_wiSyncBulkToggleBtn();
|
|
1732
|
+
renderWorkItems(allWorkItems, { preserveScroll: true });
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function wiClearSelection() {
|
|
1736
|
+
if (!wiSelectedIds.size) { _wiSyncBulkUI(); return; }
|
|
1737
|
+
var ids = Array.from(wiSelectedIds);
|
|
1738
|
+
wiSelectedIds.clear();
|
|
1739
|
+
ids.forEach(_wiReflectRowSelection);
|
|
1740
|
+
_wiSyncBulkUI();
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// Select-all targets the CURRENT filtered set (every page of the active
|
|
1744
|
+
// filter), keyed by id so it stays coherent across paging and re-sorts.
|
|
1745
|
+
function wiToggleSelectAll(cb) {
|
|
1746
|
+
if (cb.checked) filteredWorkItems.forEach(function(w) { wiSelectedIds.add(w.id); });
|
|
1747
|
+
else wiSelectedIds.clear();
|
|
1748
|
+
filteredWorkItems.forEach(function(w) { _wiReflectRowSelection(w.id); });
|
|
1749
|
+
_wiSyncBulkUI();
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function _wiSyncBulkToggleBtn() {
|
|
1753
|
+
var btn = document.getElementById('wi-bulk-toggle');
|
|
1754
|
+
if (!btn) return;
|
|
1755
|
+
btn.setAttribute('aria-pressed', wiBulkMode ? 'true' : 'false');
|
|
1756
|
+
btn.classList.toggle('active', wiBulkMode);
|
|
1757
|
+
btn.textContent = wiBulkMode ? 'Done' : 'Bulk select';
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// Re-sync the bulk bar to current state after every render: bar visibility,
|
|
1761
|
+
// selected count, select-all checkbox/indeterminate against the filtered set,
|
|
1762
|
+
// and per-action button enablement + eligible counts.
|
|
1763
|
+
function _wiSyncBulkUI() {
|
|
1764
|
+
_wiSyncBulkToggleBtn();
|
|
1765
|
+
var bar = document.getElementById('wi-bulk-bar');
|
|
1766
|
+
if (bar) bar.hidden = !wiBulkMode;
|
|
1767
|
+
if (!wiBulkMode) return;
|
|
1768
|
+
var count = wiSelectedIds.size;
|
|
1769
|
+
var countEl = document.getElementById('wi-bulk-count');
|
|
1770
|
+
if (countEl) countEl.textContent = count + ' selected';
|
|
1771
|
+
var selAll = document.getElementById('wi-bulk-selectall-cb');
|
|
1772
|
+
if (selAll) {
|
|
1773
|
+
var total = filteredWorkItems.length;
|
|
1774
|
+
var selectedInView = filteredWorkItems.reduce(function(n, w) { return n + (wiSelectedIds.has(w.id) ? 1 : 0); }, 0);
|
|
1775
|
+
selAll.checked = total > 0 && selectedInView === total;
|
|
1776
|
+
selAll.indeterminate = selectedInView > 0 && selectedInView < total;
|
|
1777
|
+
}
|
|
1778
|
+
Object.keys(WI_BULK_ACTIONS).forEach(function(action) {
|
|
1779
|
+
var b = document.querySelector('#wi-bulk-bar [data-bulk-action="' + action + '"]');
|
|
1780
|
+
if (!b) return;
|
|
1781
|
+
var plan = wiBulkPlan(allWorkItems, wiSelectedIds, action);
|
|
1782
|
+
var n = plan.eligible.length;
|
|
1783
|
+
b.disabled = count === 0 || n === 0;
|
|
1784
|
+
var base = WI_BULK_ACTIONS[action].label;
|
|
1785
|
+
b.textContent = n > 0 ? base + ' (' + n + ')' : base;
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// Apply a bulk action. Resolves the selection to LIVE items, applies only to
|
|
1790
|
+
// eligible ones, confirms destructive actions, reports partial failures
|
|
1791
|
+
// accurately (never claims success if any failed), and never acts on
|
|
1792
|
+
// stale/missing ids.
|
|
1793
|
+
async function wiBulkAction(action) {
|
|
1794
|
+
var spec = WI_BULK_ACTIONS[action];
|
|
1795
|
+
if (!spec) return;
|
|
1796
|
+
var plan = wiBulkPlan(allWorkItems, wiSelectedIds, action);
|
|
1797
|
+
var targets = plan.eligible;
|
|
1798
|
+
if (!targets.length) {
|
|
1799
|
+
showToast('cmd-toast', 'No selected items are eligible for ' + spec.label + '.', false);
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
if (spec.destructive) {
|
|
1803
|
+
if (!confirm(spec.label + ' ' + targets.length + ' work item' + (targets.length === 1 ? '' : 's') + '? This will kill any running agents and cannot be undone.')) return;
|
|
1804
|
+
}
|
|
1805
|
+
var results = await Promise.allSettled(targets.map(function(it) {
|
|
1806
|
+
return fetch(spec.endpoint, {
|
|
1807
|
+
method: 'POST',
|
|
1808
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1809
|
+
body: JSON.stringify({ id: it.id, source: it._source || undefined })
|
|
1810
|
+
}).then(function(r) {
|
|
1811
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
1812
|
+
return r.json().catch(function() { return {}; });
|
|
1813
|
+
});
|
|
1814
|
+
}));
|
|
1815
|
+
var succeeded = [];
|
|
1816
|
+
var failed = [];
|
|
1817
|
+
results.forEach(function(res, i) {
|
|
1818
|
+
if (res.status === 'fulfilled') succeeded.push(targets[i]);
|
|
1819
|
+
else failed.push(targets[i]);
|
|
1820
|
+
});
|
|
1821
|
+
if (spec.optimisticRemove) {
|
|
1822
|
+
succeeded.forEach(function(it) { markDeleted('wi:' + it.id); _removeWiRow(it.id); });
|
|
1823
|
+
}
|
|
1824
|
+
succeeded.forEach(function(it) { wiSelectedIds.delete(it.id); });
|
|
1825
|
+
if (spec.wake && succeeded.length) wakeEngine();
|
|
1826
|
+
var skipped = plan.ineligible.length + plan.missing.length;
|
|
1827
|
+
if (failed.length) {
|
|
1828
|
+
// Roll back optimistic hides for the failed ids and refresh to the truth.
|
|
1829
|
+
failed.forEach(function(it) { clearDeleted('wi:' + it.id); });
|
|
1830
|
+
showToast('cmd-toast', spec.label + ': ' + succeeded.length + ' succeeded, ' + failed.length + ' failed' + (skipped ? ', ' + skipped + ' skipped' : '') + '.', false);
|
|
1831
|
+
refresh();
|
|
1832
|
+
} else {
|
|
1833
|
+
showToast('cmd-toast', spec.label + ': ' + succeeded.length + ' item' + (succeeded.length === 1 ? '' : 's') + (skipped ? ' (' + skipped + ' skipped)' : '') + '.', true);
|
|
1834
|
+
if (!spec.optimisticRemove) refresh();
|
|
1835
|
+
}
|
|
1836
|
+
_wiSyncBulkUI();
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
|
|
1425
1840
|
if (typeof window !== 'undefined') {
|
|
1426
|
-
window.MinionsWork = { wiRow, renderWorkItems, editWorkItem, submitWorkItemEdit, deleteWorkItem, archiveWorkItem, toggleWorkItemArchive, retryWorkItem, wiPrev, wiNext, feedbackWorkItem, openCreateWorkItemModal, openWorkItemDetail, openAllWorkItems, viewAgentOutput, openInboxNote, needsAttentionInfo, applyWiFilters, resetWiFilters, toggleWiSortDir };
|
|
1841
|
+
window.MinionsWork = { wiRow, renderWorkItems, editWorkItem, submitWorkItemEdit, deleteWorkItem, archiveWorkItem, toggleWorkItemArchive, retryWorkItem, wiPrev, wiNext, feedbackWorkItem, openCreateWorkItemModal, openWorkItemDetail, openAllWorkItems, viewAgentOutput, openInboxNote, needsAttentionInfo, applyWiFilters, resetWiFilters, toggleWiSortDir, wiOpenOverflowMenu, wiCloseOverflowMenu, wiRowClick, wiToggleSelect, wiToggleBulkMode, wiExitBulkMode, wiClearSelection, wiToggleSelectAll, wiBulkAction };
|
|
1427
1842
|
}
|
|
1428
1843
|
// exported for testing — pure helpers safe to require under Node (no DOM deps).
|
|
1429
1844
|
if (typeof module !== 'undefined' && module.exports) {
|
|
1430
|
-
module.exports = { needsAttentionInfo, formatPendingReason };
|
|
1845
|
+
module.exports = { needsAttentionInfo, formatPendingReason, wiActionEligible, wiBulkPlan, WI_BULK_ACTIONS };
|
|
1431
1846
|
}
|
package/dashboard/js/utils.js
CHANGED
|
@@ -269,6 +269,10 @@ function withTopFrame(type, id, fn) {
|
|
|
269
269
|
function _restoreFrameToVisible(frame) {
|
|
270
270
|
// Synchronously restore a snapshotted frame as the visible modal layer.
|
|
271
271
|
// scrollY is restored after a double-rAF so layout settles first.
|
|
272
|
+
// The modal stays open but its whole body is replaced, so any popup anchored
|
|
273
|
+
// inside it is about to lose its anchor — dismiss BEFORE the swap, while
|
|
274
|
+
// contains() can still identify it.
|
|
275
|
+
_dismissModalTransientPopups();
|
|
272
276
|
var titleEl = document.getElementById('modal-title');
|
|
273
277
|
var bodyEl = document.getElementById('modal-body');
|
|
274
278
|
if (titleEl) {
|
|
@@ -308,10 +312,46 @@ function _popOneFrameInternal() {
|
|
|
308
312
|
_restoreFrameToVisible(frame);
|
|
309
313
|
}
|
|
310
314
|
|
|
315
|
+
// ─── Transient popup teardown ────────────────────────────────────────────────
|
|
316
|
+
// An anchored popover (the work-item ⋯ overflow menu, the checkout-mode picker,
|
|
317
|
+
// any future dropdown) is appended to document.body but ANCHORED to a trigger
|
|
318
|
+
// that lives inside some container. Hide or replace that container and the
|
|
319
|
+
// popover is stranded: it floats over the page with its anchor gone, still
|
|
320
|
+
// holding the action closures built when it opened.
|
|
321
|
+
//
|
|
322
|
+
// The modal is the worst offender because it is torn down by paths that never
|
|
323
|
+
// touch the DOM a popover listens to — browser Back / Alt+Left (popstate),
|
|
324
|
+
// sidebar navigation (switchPage), and a frame pop all bypass the
|
|
325
|
+
// mousedown/keydown dismissal the popover installed on itself.
|
|
326
|
+
//
|
|
327
|
+
// Owners register a SCOPED closer: one that closes only when its own anchor is
|
|
328
|
+
// inside the container being torn down, so tearing down the modal can never
|
|
329
|
+
// dismiss a popover opened elsewhere on the page. Every modal teardown and
|
|
330
|
+
// every wholesale replacement of the modal body funnels through
|
|
331
|
+
// dismissTransientPopupsIn(), so a new popup gets this for free by registering.
|
|
332
|
+
var _transientPopupClosers = [];
|
|
333
|
+
function registerTransientPopupCloser(fn) {
|
|
334
|
+
if (typeof fn !== 'function') return;
|
|
335
|
+
if (_transientPopupClosers.indexOf(fn) === -1) _transientPopupClosers.push(fn);
|
|
336
|
+
}
|
|
337
|
+
function dismissTransientPopupsIn(container) {
|
|
338
|
+
// No container means nothing to scope against — a closer could not tell
|
|
339
|
+
// whether its anchor is affected, so it must not fire.
|
|
340
|
+
if (!container) return;
|
|
341
|
+
for (var i = 0; i < _transientPopupClosers.length; i++) {
|
|
342
|
+
// Per-closer isolation: one popup owner must never block modal teardown.
|
|
343
|
+
try { _transientPopupClosers[i](container); } catch (e) { try { console.error(e); } catch {} }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function _dismissModalTransientPopups() {
|
|
347
|
+
dismissTransientPopupsIn(document.getElementById('modal'));
|
|
348
|
+
}
|
|
349
|
+
|
|
311
350
|
function _physicallyCloseModal() {
|
|
312
351
|
// Close the modal DOM without going through closeModal() (which would itself
|
|
313
352
|
// call popModalFrame and recurse). Mirrors the visible side-effects of
|
|
314
353
|
// closeModal but skips stack management.
|
|
354
|
+
_dismissModalTransientPopups();
|
|
315
355
|
var modalEl = document.getElementById('modal');
|
|
316
356
|
if (modalEl) modalEl.classList.remove('open');
|
|
317
357
|
var inner = document.querySelector('#modal .modal');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<section>
|
|
2
|
+
<p id="tools-refresh-status" class="empty" role="status" aria-live="polite" style="display:none"></p>
|
|
2
3
|
<h2>Minions Skills <span class="count" id="skills-count">0</span> <span class="qa-section-subtitle">discovered from runtime native dirs, plugin installs, and configured project repos</span></h2>
|
|
3
4
|
<div id="skills-list"><p class="empty">No skills yet. Agents create these when they discover repeatable workflows.</p></div>
|
|
4
5
|
</section>
|