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 3a2f844ee..ba1796e11 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -620,6 +620,10 @@ 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 @@ -1665,7 +1669,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, hireIn: !org && canDecide, answer: d => inboxAnswer(d, current), answered: inboxAnswered })); + 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); diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 03da72349..87d6125fb 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -162,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/workspace.js b/packages/webui/src/public/workspace.js index a94292493..aa2cc5c3d 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -818,6 +818,13 @@ 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 ? `| Needs | Waiting |
|---|
${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); @@ -852,9 +859,6 @@ 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 = `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')}`}.` : ''; diff --git a/packages/webui/tests/system-inbox-browser.test.mjs b/packages/webui/tests/system-inbox-browser.test.mjs index 6e512b239..f2125a5b9 100644 --- a/packages/webui/tests/system-inbox-browser.test.mjs +++ b/packages/webui/tests/system-inbox-browser.test.mjs @@ -35,7 +35,7 @@ function fixture({ empty = false } = {}) { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }], // Eligible for an answer in the Console: the bus marks it with its `console` field. - inbox: [{ id: BETA_DECISION, question: 'Pick a launch price', class: 'within-role', blocking: false, raised_by_role: 'pm', at: at(5), options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], console: { kind: 'decision', options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], digest: DIGEST } }], + inbox: [{ id: BETA_DECISION, authorization: { action: 'x.y', target: 'shop', approvalChoice: 'high' }, question: 'Pick a launch price', class: 'within-role', blocking: false, raised_by_role: 'pm', at: at(5), options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], console: { kind: 'decision', options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], digest: DIGEST } }], }, }; if (empty) { DATA.acme.inbox = []; DATA.beta.inbox = []; } @@ -57,9 +57,9 @@ function fixture({ empty = false } = {}) { if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } }; 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 () => ({}) }, + reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, // 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: {} }) }, + 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: d.authorization ?? null })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) }, chat: { projects: async () => DATA[id].projects, threads: async () => [] }, }; }, @@ -67,8 +67,9 @@ function fixture({ empty = false } = {}) { } // Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL. Every -// organization's config says this Console answers decisions; decision POSTs are recorded and -// answered from a queue, so no real write happens. +// organization's config says this Console answers decisions, except those named in `noDecide`; +// decision POSTs are recorded and answered from a queue, so no real write happens. +const stubFor = (noDecide = []) => `window.__noDecide = ${JSON.stringify(noDecide)};\n${STUB}`; const STUB = `(() => { const real = window.fetch.bind(window); window.__posts = []; window.__answers = []; @@ -77,7 +78,8 @@ const STUB = `(() => { const u = String(url); if (/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) { const c = await (await real(url, init)).json(); - return answer(200, { ...c, send: true, decide: true }); + const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1]; + return answer(200, { ...c, send: true, decide: !window.__noDecide.includes(org) }); } if (!u.includes('/decisions/')) return real(url, init); window.__posts.push({ url: u, method: init.method, body: init.body }); @@ -87,12 +89,12 @@ const STUB = `(() => { }; })();`; -async function setup(t, { viewport = [1440, 900], hash = '#/inbox', empty = false, stub = false } = {}) { +async function setup(t, { viewport = [1440, 900], hash = '#/inbox', empty = false, stub = false, noDecide = [] } = {}) { const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty }), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(...viewport); - if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); + if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stubFor(noDecide) }); const base = `http://127.0.0.1:${server.address().port}`; await b.navigate(`${base}/${hash}`); return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) }; @@ -165,6 +167,7 @@ test('answering beta\'s row from the list while acme was opened last reaches bet assert.match(confirm, /Raised by pm ยท run run/); assert.match(confirm, /Decision routed to: human/); assert.doesNotMatch(confirm, /date unknown/); + assert.match(confirm, /Affected object: shop/); 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'); @@ -204,10 +207,51 @@ test('the hire, fire and change form is reached by clicks from #/inbox, one link assert.equal(await b.evaluate('document.querySelector("#hire-start input[readonly]")?.value'), 'beta'); }); +test('the hire links follow each organization\'s own config, not the organization opened last', async t => { + // Acme is opened last and this Console does not decide there; beta's config still says it does. + const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization', stub: true, noDecide: ['acme'] }); + await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"'); + await b.evaluate('location.hash = "#/inbox"'); + await wait('document.querySelector("[data-ws-hire-in]")'); + assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme'); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].map(a => a.getAttribute("href"))'), ['#/orgs/beta/inbox']); +}); + +test('with no organization deciding here, #/inbox shows no hire line', async t => { + const { b, wait } = await setup(t, { stub: true, noDecide: ['acme', 'beta'] }); + await wait('document.querySelectorAll("[data-ws-item]").length === 2'); + assert.equal(await b.evaluate('document.querySelector("[data-ws-hire-in]")'), null); +}); + 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)); + // Held across a second board render, which reads only acme's inbox. + const reads = 'performance.getEntriesByType("resource").filter(e => new URL(e.name).pathname === "/api/orgs/acme/bus/inbox").length'; + const before = await b.evaluate(reads); + assert.ok(before >= 1, String(before)); + await b.evaluate('window.dispatchEvent(new HashChangeEvent("hashchange"))'); + await wait(`${reads} > ${before}`); + await new Promise(r => setTimeout(r, 200)); assert.equal(await b.evaluate('document.querySelector("#sections a[href$=\'/inbox\'] .ws-n")?.textContent'), '2'); }); + +// SP2 gave the system dashboard the system theme. #/inbox is a system page too: it shows the system +// theme, and a choice made there is the system's own, never written to the organization opened last. +test('#/inbox keeps the system theme, and a choice made there stays off the organization opened last', async t => { + const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization' }); + await wait('document.documentElement.dataset.palette === "cobalt"'); + await b.evaluate('location.hash = "#/inbox"'); + await wait(`${items} !== "[]" && document.documentElement.dataset.palette === "harbor"`); + assert.equal(await b.evaluate('window.MosaicAppearance.state().organization'), null); + assert.equal(await b.evaluate('document.getElementById("org-cue").hidden'), true); + await b.evaluate('window.MosaicAppearance.set({ mode: "light" })'); + assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'light'); + assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-appearance:acme")'), null); + // The organization's own Inbox is its page: its theme, and not the system's choice. + await b.evaluate('location.hash = "#/orgs/acme/inbox"'); + await wait('document.documentElement.dataset.palette === "cobalt"'); + assert.notEqual(await b.evaluate('document.documentElement.dataset.mode'), 'light'); + await b.evaluate('location.hash = "#/inbox"'); + await wait('document.documentElement.dataset.palette === "harbor" && document.documentElement.dataset.mode === "light"'); +});