fix(webui): sp3 second review round, #/inbox keeps the system theme, hire links follow each organization's own decide (#1873, lane sp3)

This commit was merged in pull request #1967.
This commit is contained in:
jason.woltje committed 2026-11-15 02:09:04 -06:00
1 parent ba9933309b
commit 3885f4628b
5 files changed
+70 -15

No files matched your search

+2 -1
View File
@@ -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; } },
+5 -1
View File
@@ -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);
+2
View File
@@ -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) });
+7 -3
View File
@@ -818,6 +818,13 @@
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">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>`;
// 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 = `<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')}`}.` : '';
@@ -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"');
});