fix(webui): sp11 item 6, an organization's decision page, decision list, replies and messages name a decision class in the Inbox's words from the same map; message classes the map does not know show as they are (#1873, lane sp11-fixes)

This commit is contained in:
jason.woltje committed 2026-11-15 10:27:28 -06:00
1 parent 9cff3255e6
commit 8aefc0f7a9
3 files changed
+28 -7

No files matched your search

+6 -4
View File
@@ -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') => `<span class="badge badge-${kind}">${txt(text)}</span>`;
// 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) => `<span class="tag${CLASSES.includes(cls) ? ` tag-${cls}` : ''}">${txt(cls)} · <code>${txt(action)}</code></span>`;
// 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) => `<span class="tag${CLASSES.includes(cls) ? ` tag-${cls}` : ''}">${txt(classWord(cls))} · <code>${txt(action)}</code></span>`;
const blockChip = text => `<span class="tag tag-block">${txt(text)}</span>`;
// An empty state says what would appear and where it comes from.
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
@@ -354,7 +356,7 @@
function repliesHtml() {
if (replies === null) return '<p class="small warn">The replies to it could not be read. Read again to retry; <code>mosaic inbox</code> in a terminal lists them.</p>';
if (!replies.length) return '<p class="small muted">No reply yet. One shows here when the role answers, as the page refreshes.</p>';
return `<h3 class="small">Replies <span class="count">${replies.length}</span></h3><ol class="s1-plain s1-replies">${replies.map(m => `<li><p class="small muted">${txt(m.from_role)} ${when(m.at)} · ${txt(m.class)} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-q">${txt(m.body)}</p></li>`).join('')}</ol>${replies.length >= REPLIES ? `<p class="small muted">The first ${REPLIES} replies show here.</p>` : ''}`;
return `<h3 class="small">Replies <span class="count">${replies.length}</span></h3><ol class="s1-plain s1-replies">${replies.map(m => `<li><p class="small muted">${txt(m.from_role)} ${when(m.at)} · ${txt(classWord(m.class))} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-q">${txt(m.body)}</p></li>`).join('')}</ol>${replies.length >= REPLIES ? `<p class="small muted">The first ${REPLIES} replies show here.</p>` : ''}`;
}
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 ? `<ul id="message-list" class="waiting s1-replies">${page.rows.map(m =>
`<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(m.class)} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${messageText(m)}</p></li>`).join('')}</ul>`
`<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(classWord(m.class))} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${messageText(m)}</p></li>`).join('')}</ul>`
: empty('No messages to you.', 'Messages appear here as the bus is read.');
return `${head('Messages', `${readNote(get)} Every message to you, newest first. Reading here marks nothing read.`)}
<p class="small muted">${page.total} messages · ${page.unread} unread</p>${body}
+4 -2
View File
@@ -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 ? `<span class="ws-chip ${CLASS_CHIP[c] || ''}">${esc(TYPE_WORD[c] || c)}</span>` : '');
// 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 ? `<span class="ws-chip ${CLASS_CHIP[c] || ''}">${esc(classWord(c))}</span>` : '');
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();
})();
@@ -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');