From ba9933309b5a017a1f4ed7e8688ae458f9c5000b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 01:09:49 -0600 Subject: [PATCH] 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) --- packages/webui/src/public/bus.js | 16 ++++++---- packages/webui/src/public/workspace.js | 21 +++++++++---- .../webui/tests/system-inbox-browser.test.mjs | 30 +++++++++++++++++-- 3 files changed, 54 insertions(+), 13 deletions(-) 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 = '') => ``; @@ -639,7 +643,8 @@ const max = Math.max(1, ...srows.map(s => s.n)); const states = srows.length ? `
${srows.map(s => { const ps = [...s.g.entries()].filter(([, n]) => n > 0).map(([id, n]) => ({ id, n })); return `
${esc(s.name)}${s.n}${bar(o, ps, `width:${Math.max(4, Math.round((s.n / max) * 90))}%`)}${by(o, ps)}
`; }).join('')}
` : `

${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 ? `${inbox.length > needs.length ? `

${inbox.length - needs.length} more in the inbox

` : ''}` : `

${noneRead(o, ['inbox']) ? 'Decisions not read' : 'Nothing needs you'}

`; 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 ? `

Not read: ${andList(o.unread.map(why))}.

` : '') + missLine(o, ['inbox'], 'inbox'); const filtered = (org && !scope) || type; - const table = rows.length ? `
${rows.map(row).join('')}
Items that need you
NeedsTypeWaiting
` : `

${noneRead(o, ['inbox']) ? 'Decisions not read' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}

`; + const table = rows.length ? `
${rows.map(row).join('')}
Items that need you
NeedsClassWaiting
` : `

${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))}`))}.

` : ''; 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}
${table} - ${typeof opts.hire === 'function' ? opts.hire() : ''}
+ ${typeof opts.hire === 'function' ? opts.hire() : hireIn}
${insp} `; @@ -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(); })(); diff --git a/packages/webui/tests/system-inbox-browser.test.mjs b/packages/webui/tests/system-inbox-browser.test.mjs index 3a4b5991b..6e512b239 100644 --- a/packages/webui/tests/system-inbox-browser.test.mjs +++ b/packages/webui/tests/system-inbox-browser.test.mjs @@ -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'); +});