fix(webui): sp3 review round, answers from the system Inbox read route_to and show who raised them, the hire form is linked, the sidebar count stays system-wide (#1873, lane sp3)

This commit is contained in:
jason.woltje committed 2026-11-15 01:10:13 -06:00
1 parent 0785af8129
commit ba9933309b
3 files changed
+54 -13

No files matched your search

+11 -5
View File
@@ -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 ? '<p class="note" data-ws-answer-why>Whether this Console can answer it here was not read: the organization did not answer. Open it in its organization, or use a terminal.</p>' : '';
}
// 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;
+15 -6
View File
@@ -561,6 +561,10 @@
return `<section class="ws ${cls}" ${attr} data-ws-failed><div class="ws-vh"><h1 tabindex="-1">${title}</h1><p class="sub">${why}</p></div></section>`;
}
// 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 = '') => `<div class="ws-bar"${style ? ` style="${style}"` : ''} aria-hidden="true">${parts.filter(p => p.n > 0).map(p => `<i class="ws-o" style="${o.style(p.id)};flex:${p.n}"></i>`).join('')}</div>`;
@@ -639,7 +643,8 @@
const max = Math.max(1, ...srows.map(s => s.n));
const states = srows.length ? `<div class="ws-panel tight ws-states" data-ws-states>${srows.map(s => { const ps = [...s.g.entries()].filter(([, n]) => n > 0).map(([id, n]) => ({ id, n })); return `<div class="ws-srow"><span class="sl">${esc(s.name)}</span><span class="sn">${s.n}</span>${bar(o, ps, `width:${Math.max(4, Math.round((s.n / max) * 90))}%`)}${by(o, ps)}</div>`; }).join('')}</div>` : `<p class="ws-panel tight note" data-ws-states>${noneRead(o, ['tasks']) ? 'Tasks not read' : 'No open tasks'}</p>`;
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 ? `<ul class="ws-needs" data-ws-needs>${needs.map(d => `<li class="ws-edge ws-o" style="${o.style(d.org)}">${av(o.meta(d.org), 'md')}<a class="q" href="${esc(`#/inbox?d=${enc(itemKey(d))}`)}" data-focus-key="${esc(`need:${itemKey(d)}`)}">${esc(d.question || `Decision ${d.id}`)}</a><span class="src">${fromWords(o, d)}</span>${classChip(d.class)}</li>`).join('')}</ul>${inbox.length > needs.length ? `<p class="note ws-more-line">${inbox.length - needs.length} more in the inbox</p>` : ''}` : `<p class="ws-panel tight note" data-ws-needs>${noneRead(o, ['inbox']) ? 'Decisions not read' : 'Nothing needs you'}</p>`;
const now = sessions.filter(s => s.kind === 'thread' || s.state === 'working' || s.state === 'waiting');
@@ -793,7 +798,7 @@
for (const d of everyRows) counts.set(d.org, (counts.get(d.org) || 0) + 1);
// 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((a, b) => (b.at ?? '').localeCompare(a.at ?? ''));
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 here = path => (scope ? `${orgHref(scope)}${path}` : `#${path}`);
@@ -812,7 +817,7 @@
const why = r => `${stateWords(r).replace(/, read .*$/, '')}${r.reason ? `: ${esc(r.reason)}` : ''}${r.at ? ` (read ${esc(hm(r.at))})` : ''}`;
const unread = (o.unread.length ? `<p class="note ws-unread-note" data-ws-unread>Not read: ${andList(o.unread.map(why))}.</p>` : '') + missLine(o, ['inbox'], 'inbox');
const filtered = (org && !scope) || type;
const table = rows.length ? `<div class="ws-tbl"><table class="ws-ibx" data-ws-items><caption class="ws-sr">Items that need you</caption><thead><tr><th scope="col">Needs</th><th scope="col" class="hide-m">Type</th><th scope="col" class="num">Waiting</th></tr></thead><tbody>${rows.map(row).join('')}</tbody></table></div>` : `<p class="ws-panel tight note" data-ws-items>${noneRead(o, ['inbox']) ? 'Decisions not read' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}</p>`;
const table = rows.length ? `<div class="ws-tbl"><table class="ws-ibx" data-ws-items><caption class="ws-sr">Items that need you</caption><thead><tr><th scope="col">Needs</th><th scope="col" class="hide-m">Class</th><th scope="col" class="num">Waiting</th></tr></thead><tbody>${rows.map(row).join('')}</tbody></table></div>` : `<p class="ws-panel tight note" data-ws-items>${noneRead(o, ['inbox']) ? 'Decisions not read' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}</p>`;
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 = `<div class="ws-fchips" role="group" aria-label="Type"><span class="ws-label">Type</span><button type="button" class="f plain" data-ws-type="" data-focus-key="type:all" aria-pressed="${!type}">All <span class="n">${inOrg.length}</span></button>${types.map(c => `<button type="button" class="f plain" data-ws-type="${esc(c)}" data-focus-key="${esc(`type:${c}`)}" aria-pressed="${type === c}">${esc(TYPE_WORD[c] || c)} <span class="n">${typeCounts.get(c) || 0}</span></button>`).join('')}</div>`;
const typeChips = `<div class="ws-fchips" role="group" aria-label="Class"><span class="ws-label">Class</span><button type="button" class="f plain" data-ws-type="" data-focus-key="type:all" aria-pressed="${!type}">All <span class="n">${inOrg.length}</span></button>${types.map(c => `<button type="button" class="f plain" data-ws-type="${esc(c)}" data-focus-key="${esc(`type:${c}`)}" aria-pressed="${type === c}">${esc(TYPE_WORD[c] || c)} <span class="n">${typeCounts.get(c) || 0}</span></button>`).join('')}</div>`;
// 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 ? `<p class="note" data-ws-hire-in>Hire, fire or change an agent in ${andList(hiring.map(r => `<a href="${esc(`${orgHref(r.org)}/inbox`)}" data-focus-key="${esc(`hire-in:${r.org}`)}">${esc(o.name(r.org))}</a>`))}.</p>` : '';
const orgsWith = new Set(rows.map(d => d.org)).size;
const tally = rows.length ? ` ${plural(rows.length, 'item')}${scope ? '' : ` from ${plural(orgsWith, 'organization')}`}.` : '';
const sub = scope
@@ -860,7 +869,7 @@
${typeChips}
</div>
<div class="ws-ibx-grid${insp ? ' with-insp' : ''}"><div class="ws-ibx-list">${table}
${typeof opts.hire === 'function' ? opts.hire() : ''}</div>
${typeof opts.hire === 'function' ? opts.hire() : hireIn}</div>
${insp}
</div>
</section>`;
@@ -918,6 +927,6 @@
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, inboxCount, setGrouping, get pollMs() { return pollMs; } });
refresh();
})();
@@ -58,7 +58,8 @@ function fixture({ empty = false } = {}) {
return {
busHealthy: true,
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) },
// The broker's inbox row: the decisions row, so `route_to`, plus `authorization`.
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', route_to: 'human', authorization: null })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) },
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
};
},
@@ -147,7 +148,7 @@ test('the organization and type filters live in the hash and survive a reload',
});
test('answering beta\'s row from the list while acme was opened last reaches beta', async t => {
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization', stub: true });
const { b, wait, text } = await setup(t, { hash: '#/orgs/acme/organization', stub: true });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/inbox"');
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
@@ -159,6 +160,11 @@ test('answering beta\'s row from the list while acme was opened last reaches bet
await b.evaluate(`window.__answers.push({ body: { id: '${BETA_DECISION}', action: 'answer', choice: 'high', text: 'High', confirmation: 'c-1', kind: 'decision' } }, { body: { id: '${BETA_DECISION}', op: 'resolved', choice: 'high' } })`);
await b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].find(x => x.textContent === "High").click()');
await wait('document.querySelector("#decide-section [data-decide=confirm]")');
// The confirm step names who raised the item, from beta's bus row, as the decision page does.
const confirm = await text('#decide-section');
assert.match(confirm, /Raised by pm · run run/);
assert.match(confirm, /Decision routed to: human/);
assert.doesNotMatch(confirm, /date unknown/);
await b.evaluate('document.querySelector("#decide-section [data-decide=confirm]").click()');
await wait('/Recorded/.test(document.getElementById("decide-status")?.textContent ?? "")');
const posts = await b.evaluate('window.__posts');
@@ -185,3 +191,23 @@ test('with nothing waiting the list says so and still names the stopped organiza
assert.equal(await text('[data-ws-items]'), 'Nothing needs you');
assert.match(await text('[data-ws-unread]'), /Gamma Home.*stopped by the owner/s);
});
test('the hire, fire and change form is reached by clicks from #/inbox, one link per answering organization', async t => {
const { b, wait } = await setup(t, { stub: true });
await wait('document.querySelector("[data-ws-hire-in]")');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].map(a => [a.textContent, a.getAttribute("href")])'), [['Acme Studio', '#/orgs/acme/inbox'], ['Beta Works', '#/orgs/beta/inbox']]);
// The system list has no form of its own: a hire goes to one organization's bus.
assert.equal(await b.evaluate('document.getElementById("hire-start")'), null);
await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].find(a => a.textContent === "Beta Works").click()');
await wait('location.hash === "#/orgs/beta/inbox" && document.getElementById("hire-start")');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
assert.equal(await b.evaluate('document.querySelector("#hire-start input[readonly]")?.value'), 'beta');
});
test('the sidebar Inbox count on an organization\'s board is every organization\'s, as #/inbox lists', async t => {
const { b, wait } = await setup(t, { hash: '#/orgs/acme/board' });
await wait('document.querySelector("#sections a[href$=\'/inbox\'] .ws-n")?.textContent === "2"');
// Held across the board's next refresh, which reads only acme's inbox.
await new Promise(r => setTimeout(r, 300));
assert.equal(await b.evaluate('document.querySelector("#sections a[href$=\'/inbox\'] .ws-n")?.textContent'), '2');
});