diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index c9cc4ad79..49ee77e42 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -208,7 +208,7 @@
out.groupAs = m?.from_role === 'human' ? 'request' : m?.decision ? 'decision' : 'other';
} else if (r.table === 'decisions') {
out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run };
- out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
+ out.text = `Raised a ${String(classWord(r.class)).toLowerCase()} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
} else if (r.table === 'decision_events') {
// The human answers from the CLI (via cli) or, since row 252 (#1753), a grant request from the
// signed-in Console (via console); every other row is the broker's.
@@ -243,7 +243,9 @@
const badge = (text, kind = 'muted') => `${txt(text)}`;
// Class chip: the decision class, the action in mono. BLOCKING is its own chip (CSS capitalises it; the text stays lowercase).
const CLASSES = ['routine', 'within-role', 'cross-role', 'gated'];
- const chip = (cls, action) => `${txt(cls)} · ${txt(action)}`;
+ // SP11: the class in the Inbox's words, from workspace.js's map.
+ const classWord = cls => window.MosaicWorkspace?.classWord?.(cls) ?? cls;
+ const chip = (cls, action) => `${txt(classWord(cls))} · ${txt(action)}`;
const blockChip = text => `${txt(text)}`;
// An empty state says what would appear and where it comes from.
const empty = (what, from) => `
${from}
` : ''}The replies to it could not be read. Read again to retry; mosaic inbox in a terminal lists them.
No reply yet. One shows here when the role answers, as the page refreshes.
'; - return `${txt(m.from_role)} ${when(m.at)} · ${txt(m.class)} · trail
${txt(m.body)}
The first ${REPLIES} replies show here.
` : ''}`; + return `${txt(m.from_role)} ${when(m.at)} · ${txt(classWord(m.class))} · trail
${txt(m.body)}
The first ${REPLIES} replies show here.
` : ''}`; } const draft = { to: '', body: '' }; function composerHtml() { @@ -707,7 +709,7 @@ if (after !== null && !SAFE.test(after)) return notFound('The message cursor is not a message id.'); const page = await get('messages', after === null ? undefined : after); const body = page.rows.length ? `${txt(m.from_role)} · ${txt(m.class)} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · trail
${txt(m.from_role)} · ${txt(classWord(m.class))} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · trail
${page.total} messages · ${page.unread} unread
${body} diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 9ce8dd145..35cef6cfe 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -634,7 +634,9 @@ const CLASS_CHIP = { gated: 'gated', 'within-role': 'within', routine: 'routine', 'cross-role': 'cross' }; // The one map of decision classes to words: the class chips and the Inbox Class filter both read it. const TYPE_WORD = { gated: 'Gated', 'within-role': 'Within role', 'cross-role': 'Cross-role', routine: 'Routine' }; - const classChip = c => (c ? `${esc(TYPE_WORD[c] || c)}` : ''); + // SP11: the Inbox's word for a decision class; a class the map does not know shows as it is. + const classWord = c => (Object.hasOwn(TYPE_WORD, c) ? TYPE_WORD[c] : c); + const classChip = c => (c ? `${esc(classWord(c))}` : ''); const itemKey = d => `${d.org}:${d.id}`; const roleOf = d => d.session?.role || d.raisedBy || ''; const fromWords = (o, d, withOrg = true) => [withOrg ? o.name(d.org) : '', d.project?.name || 'No project', roleOf(d)].filter(Boolean).map(esc).join(' · '); @@ -1134,6 +1136,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, agentsPage, inboxCount, setGrouping, orgsOf, orgChip, projChip, unreadNote, setQuery, 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, agentsPage, inboxCount, setGrouping, orgsOf, orgChip, projChip, unreadNote, setQuery, classWord, get pollMs() { return pollMs; } }); refresh(); })(); diff --git a/packages/webui/tests/workspace-pages-browser.test.mjs b/packages/webui/tests/workspace-pages-browser.test.mjs index 8d2a4fde1..bc66a7d76 100644 --- a/packages/webui/tests/workspace-pages-browser.test.mjs +++ b/packages/webui/tests/workspace-pages-browser.test.mjs @@ -61,7 +61,9 @@ function fixture({ pollMs, fail = {}, down = [] } = {}) { 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: {} }) }, + 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: {} }), + // SP11: a decision's own page reads its trail. + trail: async subject => DATA[id].inbox.filter(d => d.id === subject).map((d, i) => ({ table: 'decisions', seq: i + 1, ...d, business: id, raised_by_run: 'run', route_to: 'human', action: 'price.set', recommendation: null })) }, chat: { projects: async () => DATA[id].projects, threads: async () => [] }, }; }, @@ -224,6 +226,21 @@ test('Inbox lists both organizations, copies a command with --org and the full i await wait(`document.querySelectorAll("[data-ws-item]").length === 1 && document.querySelector("[data-ws-item]").dataset.wsItem === ${JSON.stringify(`beta:${BETA_DECISION}`)}`); }); +// SP11 (#1873): an organization's own decision page names the class in the words the Inbox uses, +// from the same map, not the raw class. +test('SP11: the per-organization decision page names the class as the Inbox does', async t => { + const { b, base, wait } = await setup(t, { hash: '#/inbox' }); + const item = `[data-ws-item="beta:${BETA_DECISION}"]`; + await wait(`!!document.querySelector(${JSON.stringify(`${item} .ws-chip.within`)})`); + const word = await b.evaluate(`document.querySelector(${JSON.stringify(`${item} .ws-chip.within`)}).textContent`); + assert.equal(word, 'Within role'); + await b.navigate(`${base}/#/orgs/beta/inbox/${BETA_DECISION}`); + await wait('!!document.querySelector("#bus-view .tag.tag-within-role")'); + const chip = await b.evaluate('document.querySelector("#bus-view .tag.tag-within-role").textContent'); + assert.match(chip, new RegExp(`^${word} · `), chip); + assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /within-role/); +}); + test('A poll repaints in place: focus, caret and scroll stay where they were', async t => { const { b, wait } = await setup(t, { hash: '/projects', viewport: [1000, 420] }); await wait('document.querySelector("[data-ws-table]") && document.querySelectorAll("[data-ws-project]").length === 3');