diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js
index 80d74a12b..8b492746e 100644
--- a/packages/webui/src/public/app.js
+++ b/packages/webui/src/public/app.js
@@ -63,7 +63,8 @@
// #1873 SP2: the system dashboard is no organization's page. It shows the system theme: this browser's
// own choice made there, else the default, never the theme of the organization opened last.
const SYSTEM_KEY = 'mosaic-console-appearance-system';
- const onSystem = () => !!window.MosaicOrg && !/^#\/orgs\//.test(location.hash) && ['', '/', '/dashboard'].includes(location.hash.replace(/^#/, '').split('?')[0]);
+ // SP3: display.js owns which addresses are system pages (the dashboard and #/inbox); the bare address is one before it normalizes.
+ const onSystem = () => !!window.MosaicOrg && ((!/^#\/orgs\//.test(location.hash) && ['', '/'].includes(location.hash.replace(/^#/, '').split('?')[0])) || window.MosaicOrg.system?.() === true);
const store = {
read(k) { try { const v = JSON.parse(localStorage.getItem(k)); return v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { return null; } },
write(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch { return false; } },
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index 8d9843552..ba1796e11 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -583,6 +583,9 @@
};
const decideRefusal = (res, json, fallback) => res && !res.ok && res.status !== 408 && typeof json?.error === 'string' && typeof json?.message === 'string'
? { code: json.error, message: json.message } : { code: fallback, message: DECIDE_FALLBACK[fallback] };
+ // SP3: an answer started from the system Inbox names its organization and goes there, whichever
+ // organization was opened last. One started on a decision page keeps the page's own scope.
+ const decideBase = a => a.org ? `/api/orgs/${enc(a.org)}` : '/api';
async function decide(step) {
const a = answer;
if (!a || a.pending || a.result) return;
@@ -593,7 +596,7 @@
const choice = selected.key, ordinary = ['decision','hire'].includes(a.console.kind);
const adjust=choice==='yes'?collectHireAdjustment(a.console):undefined;
a.pending = step; a.error = null; a.confirm = null; paintDecide();
- const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/confirmation`, { choice,...(adjust===undefined?{}:{adjust}) });
+ const { res, json } = await decidePost(`${decideBase(a)}/decisions/${enc(a.id)}/confirmation`, { choice,...(adjust===undefined?{}:{adjust}) });
a.pending = null;
// The page moved to another decision meanwhile: its confirmation is dropped, never shown.
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); return; }
@@ -607,7 +610,7 @@
if (step !== 'confirm' || !a.confirm) return;
const c = a.confirm;
a.pending = 'confirm'; a.error = null; paintDecide();
- const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation });
+ const { res, json } = await decidePost(`${decideBase(a)}/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation });
// One use, whatever came back.
a.pending = null; a.confirm = null;
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; }
@@ -617,6 +620,48 @@
paintDecide('#decide-status'); announce(['decision','hire'].includes(c.kind) ? 'Recorded' : c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
} else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
}
+ // Whether this Console decides in one organization, from that organization's own config: the system
+ // Inbox's hire links must not follow the organization opened last, whose bus may be the stopped one.
+ const decidesIn = id => (isTester ? Promise.resolve(false) : fetch(`/api/orgs/${enc(id)}/config`, { cache: 'no-store', redirect: 'manual' })
+ .then(r => (r.ok ? r.json() : null)).then(c => c?.decide === true && c?.tester !== true).catch(() => false));
+ // SP3: the answer panel for the row selected in the Inbox, in that row's organization. That
+ // organization's config says whether this Console answers decisions there, and its bus inbox says
+ // whether this decision is eligible (its `console` field), so neither comes from the organization
+ // opened last. The state follows the same keep rules as the decision page's. `current` is the
+ // render's own check: a read that returns after the page moved on changes nothing.
+ 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, 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);
+ // 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;
+ if (!same) answer = eligible ? { id: d.id, org: d.org, console: eligible, pending: null, confirm: null, error: null, result: null } : null;
+ else if (eligible && !answer.pending) {
+ if (answer.console.digest !== eligible.digest) answer.confirm = null;
+ answer.console = eligible;
+ }
+ else if (!eligible && !failed && !answer.pending && !answer.result) answer = null;
+ 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.
+ function inboxKeep(d) {
+ if (answer?.org && !answer.pending && !(d && answer.org === d.org && answer.id === d.id && answer.result)) answer = null;
+ return answer?.org && d && answer.org === d.org && answer.id === d.id ? decideHtml() : '';
+ }
+ // The receipt stays on screen after the answered row leaves the list.
+ const inboxAnswered = key => answer?.org && answer.result && `${answer.org}:${answer.id}` === key ? decideHtml() : '';
async function inboxView(get) {
await configRead;
if(!hireDraft.business && window.MosaicOrg?.id)hireDraft.business=window.MosaicOrg.id;
@@ -1620,7 +1665,12 @@
if ((n === 0 || (a === 'dashboard' && n === 1)) && window.MosaicOrg) return window.MosaicWorkspace.home();
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
- if (a === 'inbox' && n === 1 && window.MosaicOrg) return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { hire: hireStartHtml }));
+ // SP3: #/inbox is every organization's items; #/orgs//inbox is that list for one, with its hire form.
+ 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, hireIn: org ? null : decidesIn, 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);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
@@ -1707,7 +1757,7 @@
if (path !== '/settings/model-proxy' && proxyCleanup) {proxyCleanup();proxyCleanup=null;}
if ((navigated || path !== '/settings/organizations') && orgCleanup) {orgCleanup();orgCleanup=null;}
// Leaving a decision discards its confirmation immediately, including a preview in flight.
- if (answer && (navigated || path !== `/inbox/${answer.id}`)) answer = null;
+ if (answer && (navigated || path !== (answer.org ? '/inbox' : `/inbox/${answer.id}`))) answer = null;
const g = ++gen, routeVersion = routesEditVersion;
const current = () => g === gen && route === routeOf() && !out();
// In organization mode #/ is the cross-organization dashboard; the processing controls stay on the board.
@@ -1776,7 +1826,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/display.js b/packages/webui/src/public/display.js
index 18cbac1e5..87d6125fb 100644
--- a/packages/webui/src/public/display.js
+++ b/packages/webui/src/public/display.js
@@ -21,12 +21,14 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
// The bare address and the old per-organization dashboard address are the system dashboard;
// an organization page without the prefix gets the current organization's. #1873 SP2: an
// organization's own bare address (#/orgs/, no trailing slash) is that organization's page;
- // #/orgs// stays the old dashboard address.
+ // #/orgs// stays the old dashboard address. SP3: `#/inbox` is a system route too (every
+ // organization's items); `#/orgs//inbox` is that list, filtered.
+ const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && bare === '/inbox'); };
const normalize = (hash, seg = current) => {
const path = tail(hash), bare = path.split('?')[0], named = /^#\/orgs\/([^/?]+)$/.exec(hash.split('?')[0])?.[1];
if (named) return `#/orgs/${named}/organization${hash.slice(hash.split('?')[0].length)}`;
if (bare === '/' || bare === '/dashboard') return `#/dashboard${path.slice(bare.length)}`;
- return orgOf(hash) || !seg ? hash : `#/orgs/${seg}${path}`;
+ return orgOf(hash) || !seg || system(hash) ? hash : `#/orgs/${seg}${path}`;
};
// /api/workspace reads and writes across organizations (U1, U4b), so it keeps its own path.
const unscoped = /^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/(?:levels|root)(?:\?|$))/;
@@ -50,13 +52,13 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
return null;
}).catch(() => { listError = window.MosaicOrganizationError = 'Organization registry could not be read'; return null; });
const readList = () => readRows().then(rows => rows ?? []);
- // The bus banner belongs to an organization page; the dashboard lists every bus on its own rows.
+ // The bus banner belongs to an organization page; the dashboard and the Inbox name every bus themselves.
// #1873 SP2: the organization's own page draws its down banner with the restart action, so this one hides there.
const paintBus = () => {
const banner = document.getElementById('bus-down');
if (!banner) return;
const org = rowsSeen.find(row => current && row.id === decodeURIComponent(current) && !row.invalid), path = route().split('?')[0];
- if (path === '/dashboard' || (path === '/organization' && org?.busHealthy === false) || (banner.dataset.orgBus && org?.busHealthy !== false)) { banner.hidden = true; delete banner.dataset.orgBus; return; }
+ if (system(location.hash) || (path === '/organization' && org?.busHealthy === false) || (banner.dataset.orgBus && org?.busHealthy !== false)) { banner.hidden = true; delete banner.dataset.orgBus; return; }
if (org?.busHealthy === false) { banner.textContent = 'Bus unavailable'; banner.hidden = false; banner.dataset.orgBus = '1'; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); }
};
// Idempotent: each read replaces the banner, and removes it on recovery.
@@ -160,6 +162,8 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
// The open organization's name as the last list read gave it, for the breadcrumb.
get label() { const id = current ? decodeURIComponent(current) : null, row = id && rowsSeen.find(r => r.id === id); return id ? (typeof row?.label === 'string' && row.label ? row.label : id) : null; },
ready, route, get list() { return list; }, readList, refreshList,
+ // SP3: whether this address is a system page (no organization's), for the shell's theme scope.
+ system: () => system(location.hash),
switchTo: next => { const path = route().split('?')[0]; location.hash = `#/orgs/${encodeURIComponent(next)}${path === '/dashboard' || path === '/' ? '/organization' : route()}`; },
});
Object.defineProperty(window, 'MosaicOrg', { configurable: true, enumerable: true, get: () => (current || !settled ? api : undefined) });
diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css
index 76f58610e..2f36f5eaa 100644
--- a/packages/webui/src/public/shell.css
+++ b/packages/webui/src/public/shell.css
@@ -686,6 +686,14 @@ html[data-organizations] .ws-ibx .q:hover{text-decoration:underline}
html[data-organizations] .ws-ibx .via{display:block;font-size:12px;color:var(--muted)}
html[data-organizations] .ws-ibx tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))}
html[data-organizations] .ws-ibx tr.on td:first-child{box-shadow:inset 3px 0 0 var(--action)}
+/* SP3: the system Inbox's filter rows and the organization and project chips on each item. */
+html[data-organizations] .ws-ibx-filters{flex-direction:column;align-items:flex-start;gap:8px}
+html[data-organizations] .ws-fgroup{display:flex;gap:6px 8px;align-items:center;flex-wrap:wrap}
+html[data-organizations] .ws-ibx-filters .ws-fchips>.ws-label,html[data-organizations] .ws-fgroup>.ws-label{min-width:36px}
+html[data-organizations] .ws-ichips{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px;margin-top:3px}
+html[data-organizations] .ws-ichips .via{display:inline}
+html[data-organizations] .ws-ochip{background:color-mix(in srgb,var(--org,var(--muted)) 14%,var(--surface))}
+html[data-organizations] .ws-ochip i{width:8px;height:8px;border-radius:999px;background:var(--org,var(--muted));flex:none}
html[data-organizations] .ws-msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px}
html[data-organizations] .ws-insp{border:1px solid var(--line);border-radius:10px;background:var(--surface);display:flex;flex-direction:column;min-width:0;position:sticky;top:12px;max-height:calc(100vh - 80px);overflow:auto}
html[data-organizations] .ws-insp header{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:1;box-shadow:inset 3px 0 0 var(--org)}
diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js
index 26a2e693a..aa2cc5c3d 100644
--- a/packages/webui/src/public/workspace.js
+++ b/packages/webui/src/public/workspace.js
@@ -56,7 +56,8 @@
// A status mark: a glyph plus its word, shown or read aloud only.
const dot = (cls, glyph, word, shown = false) => `${glyph}${shown ? esc(word) : `${esc(word)}`}`;
const orgHref = id => `#/orgs/${enc(id)}`;
- const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
+ // SP3: a system route such as #/inbox has no organization prefix; both forms give the path.
+ const route = () => location.hash.replace(/^#(?:\/orgs\/[^/]+)?/, '') || '/';
const ensureThemes = async () => { if (!themes) themes = await data.themes().then(b => (Array.isArray(b?.themes) ? b.themes : [])).catch(() => []); return themes; };
const instancesOf = value => { const v = value?.rows?.[0]?.instances ?? value?.instances; return v && typeof v === 'object' && !Array.isArray(v) ? v : null; };
// #1873 A3: the root and project agent copies the bus booted with, from the same instances read.
@@ -560,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, ['inbox']) ? 'Decisions not read' : 'Nothing needs you'}
`;
+ // 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' : 'Nothing needs you'}
`;
const now = sessions.filter(s => s.kind === 'thread' || s.state === 'working' || s.state === 'waiting');
const idle = sessions.length - now.length;
@@ -666,7 +672,7 @@
${states}${missLine(o, ['tasks'], 'states')}
- ${secH('Needs you', '', `Inbox`)}
+ ${secH('Needs you', '', 'Inbox')}
${needsList}${missLine(o, ['inbox'], 'needs')}
${secH('Active agents', 'sessions now', `Agents`)}
${sessTable}${missLine(o, ['agents', 'claims'], 'sessions')}
@@ -766,71 +772,108 @@
`;
}
- // ---- inbox across organizations (sample 05) ----
- // `opts.hire`: a function that returns the hire, fire and change form bus.js builds for the current
- // organization (D4, P0-1). It is called after the workspace read so typing or a submit result that
- // lands during the read is not painted over with an older draft.
+ // ---- inbox across organizations (sample 05; SP3 prototype 1114 /inbox) ----
+ // SP3: #/inbox lists every organization's items from one read (GET /api/workspace), newest first,
+ // each row with its organization chip and a project chip when it has one. The organization and type
+ // filters live in the hash query and run on that read, so the chip counts, the sidebar count and the
+ // dashboard's Needs you agree. `opts.scope` is the organization of #/orgs//inbox: the same list,
+ // filtered to it. `opts.answer(d)` returns the answer panel bus.js builds in the row's own
+ // organization; `opts.answered(key)` keeps its receipt after the row leaves the list. `opts.hire`: a
+ // function that returns the hire, fire and change form bus.js builds for the current organization
+ // (D4, P0-1), shown on the scoped list only. It is called after the read so typing or a submit
+ // result that lands during the read is not painted over with an older draft.
+ const TYPE_WORD = { gated: 'Gated', 'within-role': 'Within role', 'cross-role': 'Cross-role', routine: 'Routine' };
+ const TYPE_RE = /^[a-z][a-z-]{0,31}$/;
async function inboxPage(q, opts = {}) {
- const org = listParam(q, 'org', id => ORG_ID.test(id));
+ const scope = typeof opts.scope === 'string' && ORG_ID.test(opts.scope) ? opts.scope : null;
+ const org = scope ? [scope] : listParam(q, 'org', id => ORG_ID.test(id));
+ const type = TYPE_RE.test(String(q?.get?.('type') || '')) ? q.get('type') : null;
const want = String(q?.get?.('d') || '');
- let body, all;
- try {
- [body, all] = await Promise.all([data.workspace({ sections: ['inbox'], org }), org ? data.workspace({ sections: ['inbox'] }) : null, ensureThemes()]);
- took(body);
- } catch (err) { return failedPage('ws-inbox', 'data-ws-inbox', 'Inbox', err); }
- const every = orgsOf(all || body), o = orgsOf(body);
- const everyRows = arr((all || body).inbox);
+ let body;
+ try { [body] = await Promise.all([data.workspace({ sections: ['inbox'] }), ensureThemes()]); took(body); }
+ catch (err) { return failedPage('ws-inbox', 'data-ws-inbox', 'Inbox', err); }
+ const every = orgsOf(body), everyRows = arr(body.inbox).filter(d => d && typeof d.org === 'string');
if (!noneRead(every, ['inbox'])) tellInbox(everyRows);
const counts = new Map();
for (const d of everyRows) counts.set(d.org, (counts.get(d.org) || 0) + 1);
- const rows = arr(body.inbox);
+ // The organizations in view: every one, or the chosen ones.
+ const o = org ? { ...every, rows: every.rows.filter(r => org.includes(r.org)), unread: every.unread.filter(r => org.includes(r.org)) } : every;
+ const inOrg = everyRows.filter(d => !org || org.includes(d.org)).sort(newest);
+ const rows = inOrg.filter(d => !type || d.class === type);
const picked = want === 'none' ? null : rows.find(d => itemKey(d) === want) || (want ? null : rows[0] || null);
- const qs = d => { const p = new URLSearchParams(); if (org) p.set('org', org.join(',')); p.set('d', d ? itemKey(d) : 'none'); return `/inbox?${p}`; };
+ const here = path => (scope ? `${orgHref(scope)}${path}` : `#${path}`);
+ const qs = d => { const p = new URLSearchParams(); if (org && !scope) p.set('org', org.join(',')); if (type) p.set('type', type); p.set('d', d ? itemKey(d) : 'none'); return here(`/inbox?${p}`); };
+ const orgChip = d => `${esc(o.name(d.org))}`;
+ const projChip = d => (d.project?.name ? `${esc(d.project.name)}` : '');
const row = d => {
- const on = picked && itemKey(d) === itemKey(picked);
+ const on = picked && itemKey(d) === itemKey(picked), by = roleOf(d);
return `
${noneRead(o, ['inbox']) ? 'Decisions not read' : org ? 'Nothing needs you in these organizations' : 'Nothing needs you'}
`;
+ // An organization that did not answer is named first, with the reason its read gave.
+ const why = r => `${stateWords(r).replace(/, read .*$/, '')}${r.reason ? `: ${esc(r.reason)}` : ''}${r.at ? ` (read ${esc(hm(r.at))})` : ''}`;
+ const unread = (o.unread.length ? `
${noneRead(o, ['inbox']) ? 'Decisions not read' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}
`;
+ // 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: only those whose own config says this
+ // Console decides there, never judged by the organization opened last (its bus may be the stopped one).
+ // Read before the answer panel: its HTML must be the last thing this render waits for, so a
+ // confirmation shown while another read was in flight is never painted over with an older panel.
+ const asks = typeof opts.hireIn === 'function' && !scope ? o.rows.filter(r => r.state === 'ok') : [];
+ const hiring = (await Promise.all(asks.map(async r => ((await opts.hireIn(r.org)) === true ? r : null)))).filter(Boolean);
let insp = '';
if (picked) {
const d = picked, id = esc(d.id);
const decideHref = `${orgHref(d.org)}/inbox/${enc(d.id)}`;
- const opts = arr(d.options);
- const optList = opts.length ? `
The inbox mixes organizations, so the copied command always names the organization's bus.
` : '
No terminal command for this item: its id or options cannot be passed to a shell safely.
';
const by = roleOf(d);
insp = ``;
+ } else if (want && want !== 'none') {
+ // The answered row has left the list; its receipt stays until another row is chosen.
+ const kept = opts.answered?.(want) || '';
+ if (kept) insp = ``;
}
+ // Type chips: the decision classes the read carries, in the organizations in view.
+ 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 = `
Class${types.map(c => ``).join('')}
`;
+ const hireIn = hiring.length ? `
Hire, fire or change an agent in ${andList(hiring.map(r => `${esc(o.name(r.org))}`))}.
` : '';
const orgsWith = new Set(rows.map(d => d.org)).size;
- const sub = rows.length ? `${plural(rows.length, 'item')} need${rows.length === 1 ? 's' : ''} you, from ${plural(orgsWith, 'organization')}. Each row names where it came from. An item leaves only when it is answered.` : `${noneRead(o, ['inbox']) ? 'No organization\'s inbox could be read.' : missed(o, ['inbox']).length || o.unread.length ? 'Nothing needs you in the organizations that were read.' : 'Nothing needs you right now.'} An item leaves only when it is answered.`;
- return `
+ const tally = rows.length ? ` ${plural(rows.length, 'item')}${scope ? '' : ` from ${plural(orgsWith, 'organization')}`}.` : '';
+ const sub = scope
+ ? `Items from ${esc(o.name(scope))} only.${tally} Every organization`
+ : `Everything waiting on you, from every organization.${tally} An item leaves only when it is answered.`;
+ return `