diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index 1f271b643..3a2f844ee 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -628,14 +628,17 @@
async function inboxAnswer(d, current) {
if (!d || isTester || !d.console || typeof d.org !== 'string') return inboxKeep(d);
const base = `/api/orgs/${enc(d.org)}`;
- let eligible = null, failed = false;
+ let eligible = null, failed = false, seen = null;
try {
const [c, i] = await Promise.all([`${base}/config`, `${base}/bus/inbox`].map(u => fetch(u, { cache: 'no-store', redirect: 'manual' })));
if (c.type === 'opaqueredirect' || i.type === 'opaqueredirect') { if (current()) signOut(); return ''; }
if (!c.ok || !i.ok) throw new Error('not read');
const [cfg, box] = await Promise.all([c.json(), i.json()]);
const row = (Array.isArray(box?.rows) ? box.rows : []).find(r => r?.id === d.id);
- eligible = cfg?.decide === true && cfg?.tester !== true && row?.routed_to === 'human' && row.console && typeof row.console === 'object' ? row.console : null;
+ // The broker's inbox row is the decisions row (`route_to`) plus `authorization`; the confirm
+ // step shows who raised it and what it affects from it, as the decision page does from the trail.
+ eligible = cfg?.decide === true && cfg?.tester !== true && row?.route_to === 'human' && row.console && typeof row.console === 'object' ? row.console : null;
+ if (eligible) seen = { at: row.at, raisedBy: { role: row.raised_by_role, run: row.raised_by_run }, routeTo: row.route_to, authorization: row.authorization ?? null };
} catch { failed = true; }
if (!current()) return '';
const same = answer?.org === d.org && answer.id === d.id;
@@ -645,7 +648,7 @@
answer.console = eligible;
}
else if (!eligible && !failed && !answer.pending && !answer.result) answer = null;
- if (answer?.org === d.org && answer.id === d.id) return decideHtml();
+ if (answer?.org === d.org && answer.id === d.id) { if (seen) answer.decision = seen; return decideHtml(); }
return failed ? '
Whether this Console can answer it here was not read: the organization did not answer. Open it in its organization, or use a terminal.
' : '';
}
// A row this Console cannot answer drops any answer held for another row, unless one is in flight.
@@ -1662,7 +1665,7 @@
if (a === 'inbox' && n === 1 && window.MosaicOrg) {
const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); }
- return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { scope: org, hire: org ? hireStartHtml : null, answer: d => inboxAnswer(d, current), answered: inboxAnswered }));
+ return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { scope: org, hire: org ? hireStartHtml : null, hireIn: !org && canDecide, answer: d => inboxAnswer(d, current), answered: inboxAnswered }));
}
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
if (a === 'inbox' && n === 1) return inboxView(get);
@@ -1819,7 +1822,10 @@
const refused = failed.find(refusal);
if (refused) forget();
if (g !== gen) return;
- if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows);
+ // SP3 (#1873): with organizations the sidebar count is every organization's, from the same
+ // workspace read as #/inbox, never this one organization's inbox.
+ if (window.MosaicOrg) window.MosaicWorkspace?.inboxCount?.();
+ else if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows);
if (messages.status === 'fulfilled' && !refused) messageCount = messages.value.unread;
const good = result => !refused && result.status === 'fulfilled' ? result.value : null;
const taskRows = good(tasks)?.rows, queueRows = good(queue)?.rows, projectRows = good(projects)?.rows;
diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js
index 00d1460dd..a94292493 100644
--- a/packages/webui/src/public/workspace.js
+++ b/packages/webui/src/public/workspace.js
@@ -561,6 +561,10 @@
return `
${title}
${why}
`;
}
// The Inbox count in the navigation follows the cross-organization read wherever it was made.
+ // SP3: the sidebar's Inbox count on a page that reads one organization (the board) comes from the
+ // workspace read too, so it says what #/inbox lists. A failed read leaves the count as it was.
+ const inboxCount = () => data.workspace({ sections: ['inbox'] }).then(b => { if (!noneRead(orgsOf(b), ['inbox'])) tellInbox(arr(b.inbox).filter(d => d && typeof d.org === 'string')); }).catch(() => {});
+ const newest = (a, b) => (b.at ?? '').localeCompare(a.at ?? '');
const tellInbox = rows => { if (Array.isArray(rows)) document.dispatchEvent(new CustomEvent('mosaic-workspace-inbox', { detail: { open: rows.length, blocking: rows.filter(d => d?.blocking).length } })); };
// A stacked bar and its per-organization figures, one segment per organization.
const bar = (o, parts, style = '') => `
${noneRead(o, ['tasks']) ? 'Tasks not read' : 'No open tasks'}
`;
- const needs = inbox.slice(0, 5);
+ // SP3: the same order as #/inbox, newest first, so these five are the Inbox's top five.
+ const needs = inbox.slice().sort(newest).slice(0, 5);
const needsList = needs.length ? `
${noneRead(o, ['inbox']) ? 'Decisions not read' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}
`;
+ const table = rows.length ? `
Items that need you
Needs
Class
Waiting
${rows.map(row).join('')}
` : `
${noneRead(o, ['inbox']) ? 'Decisions not read' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}
`;
let insp = '';
if (picked) {
const d = picked, id = esc(d.id);
@@ -846,7 +851,11 @@
const typeCounts = new Map();
for (const d of inOrg) if (typeof d.class === 'string' && TYPE_RE.test(d.class)) typeCounts.set(d.class, (typeCounts.get(d.class) || 0) + 1);
const types = [...new Set([...typeCounts.keys(), ...(type ? [type] : [])])].sort();
- const typeChips = `
Type${types.map(c => ``).join('')}
`;
+ const typeChips = `
Class${types.map(c => ``).join('')}
`;
+ // The hire, fire and change form posts to one organization's bus, so the system list links to
+ // each answering organization's own Inbox, where the form is.
+ const hiring = opts.hireIn && !scope ? o.rows.filter(r => r.state === 'ok') : [];
+ const hireIn = hiring.length ? `
Hire, fire or change an agent in ${andList(hiring.map(r => `${esc(o.name(r.org))}`))}.